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
@@ -1,35 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
- require "securerandom"
5
-
6
3
  module Uniword
7
4
  module Docx
8
- # Reconciles DOCX-level invariants before serialization.
9
- #
10
- # Ensures that the document's model state is internally consistent so that
11
- # the serialized output is always a valid DOCX file. Called from
12
- # Docx::Package#to_zip_content before the serialization phase.
13
- #
14
- # This is not an extension point -- it enforces built-in invariants.
15
- # For customizable validation, use Uniword::Validation::Rules instead.
16
- # For user-defined requirements, use Docx::Requirement (future).
17
5
  class Reconciler
18
- CONFIG_DIR = File.join(__dir__, "../../../config")
19
-
20
- # All w1x/w16x extension namespace prefixes declared by namespace_scope
21
- # on wordprocessingml parts. These MUST be listed in mc:Ignorable so
22
- # Word doesn't reject them as unknown extensions.
23
- IGNORABLE_PREFIXES = %w[
24
- w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl w16du
25
- ].freeze
26
-
27
- # Extension prefixes used in document.xml (adds wp14 for drawing)
28
- IGNORABLE_PREFIXES_DOCUMENT = (IGNORABLE_PREFIXES + %w[wp14]).freeze
29
-
30
- def initialize(package, profile: nil)
6
+ EXTENSION_PREFIXES = Ooxml::Namespaces::EXTENSION_PREFIXES
7
+ FULL_IGNORABLE = Ooxml::Namespaces::FULL_IGNORABLE_PREFIXES
8
+
9
+ VALID_NOTE_TYPES = %w[separator continuationSeparator
10
+ footnoteSeparator continuationNotice].freeze
11
+
12
+ # Sub-modules autoloaded no require_relative for internal code.
13
+ autoload :FixCodes, "#{__dir__}/reconciler/fix_codes"
14
+ autoload :Helpers, "#{__dir__}/reconciler/helpers"
15
+ autoload :Notes, "#{__dir__}/reconciler/notes"
16
+ autoload :ReferentialIntegrity, "#{__dir__}/reconciler/referential_integrity"
17
+ autoload :Tables, "#{__dir__}/reconciler/tables"
18
+ autoload :Theme, "#{__dir__}/reconciler/theme"
19
+ autoload :Parts, "#{__dir__}/reconciler/parts"
20
+ autoload :PackageStructure, "#{__dir__}/reconciler/package_structure"
21
+ autoload :Body, "#{__dir__}/reconciler/body"
22
+
23
+ include Helpers
24
+ include Notes
25
+ include ReferentialIntegrity
26
+ include Tables
27
+ include Theme
28
+ include Parts
29
+ include PackageStructure
30
+ include Body
31
+
32
+ def initialize(package, profile: nil, allocator: nil)
31
33
  @package = package
32
34
  @profile = profile
35
+ @allocator = allocator
33
36
  @applied_fixes = []
34
37
  end
35
38
 
@@ -39,6 +42,8 @@ module Uniword
39
42
  reconcile_footnotes
40
43
  reconcile_endnotes
41
44
  reconcile_note_references
45
+ reconcile_headers_footers
46
+ reconcile_tables
42
47
  repair_theme
43
48
 
44
49
  # Group 2: Support parts (profile-dependent)
@@ -61,16 +66,22 @@ module Uniword
61
66
  reconcile_content_types
62
67
  reconcile_package_rels
63
68
  reconcile_document_rels
69
+
70
+ # Group 4: Integrity checks (after rels are assembled)
71
+ reconcile_referential_integrity
64
72
  end
65
73
 
66
74
  # Audit trail of fixes applied during reconciliation.
67
- # Each entry is { validity_rule:, message:, timestamp: }.
68
75
  attr_reader :applied_fixes
69
76
 
70
77
  private
71
78
 
72
79
  attr_reader :package, :profile
73
80
 
81
+ def allocator
82
+ @allocator || package.allocator
83
+ end
84
+
74
85
  def record_fix(validity_rule, message)
75
86
  @applied_fixes << {
76
87
  validity_rule: validity_rule,
@@ -79,10 +90,6 @@ module Uniword
79
90
  }
80
91
  end
81
92
 
82
- # Clear stored namespace plans from parsed XML so that
83
- # declare: :always namespace_scopes take full effect during
84
- # serialization. Without this, parsed objects limit namespace
85
- # declarations to only those present in the source XML.
86
93
  def clear_stored_namespace_plans
87
94
  parts = [
88
95
  package.document,
@@ -93,1078 +100,18 @@ module Uniword
93
100
  package.numbering,
94
101
  package.core_properties,
95
102
  package.app_properties,
103
+ package.footnotes,
104
+ package.endnotes,
96
105
  ].compact
97
106
 
98
- parts.each do |part|
99
- part.import_declaration_plan = nil
100
- part.pending_plan_root_element = nil
101
- end
102
-
103
- parts.each(&:clear_xml_parse_state!)
104
- end
105
-
106
- def reconcile_section_properties
107
- return unless package.document&.body
108
-
109
- body = package.document.body
110
-
111
- unless body.section_properties
112
- body.section_properties = Wordprocessingml::SectionProperties.new(
113
- page_size: Wordprocessingml::PageSize.new(width: 12_240,
114
- height: 15_840),
115
- page_margins: Wordprocessingml::PageMargins.new(
116
- top: 1440, right: 1440, bottom: 1440, left: 1440,
117
- header: 720, footer: 720, gutter: 0
118
- ),
119
- columns: Wordprocessingml::Columns.new(space: 720),
120
- doc_grid: Wordprocessingml::DocGrid.new(line_pitch: 360),
121
- )
122
- record_fix("R11",
123
- "Added default section properties with US Letter page size")
124
- return
125
- end
126
-
127
- sect_pr = body.section_properties
128
- fixed = false
129
- unless sect_pr.page_size
130
- sect_pr.page_size = Wordprocessingml::PageSize.new(width: 12_240,
131
- height: 15_840)
132
- fixed = true
133
- end
134
- unless sect_pr.page_margins
135
- sect_pr.page_margins = Wordprocessingml::PageMargins.new(
136
- top: 1440, right: 1440, bottom: 1440, left: 1440,
137
- header: 720, footer: 720, gutter: 0
138
- )
139
- fixed = true
140
- end
141
- if fixed
142
- record_fix("R11",
143
- "Filled missing pgSz/pgMar in existing section properties")
144
- end
145
- end
146
-
147
- # -- Footnotes --
148
-
149
- def reconcile_footnotes
150
- has_fn_pr = package.settings&.footnote_pr
151
- has_footnotes = package.footnotes
152
-
153
- if has_fn_pr && !has_footnotes
154
- package.footnotes = minimal_footnotes
155
- record_fix("R9",
156
- "Created footnotes.xml to match footnotePr in settings")
157
- elsif has_footnotes && !has_fn_pr
158
- package.settings ||= Wordprocessingml::Settings.new
159
- package.settings.footnote_pr = Wordprocessingml::FootnotePr.new
160
- record_fix("R9",
161
- "Added footnotePr to settings to match footnotes.xml")
162
- end
163
-
164
- ensure_separators(package.footnotes, :footnote) if package.footnotes
165
- end
166
-
167
- # -- Endnotes --
168
-
169
- def reconcile_endnotes
170
- has_en_pr = package.settings&.endnote_pr
171
- has_endnotes = package.endnotes
172
-
173
- if has_en_pr && !has_endnotes
174
- package.endnotes = minimal_endnotes
175
- record_fix("R9",
176
- "Created endnotes.xml to match endnotePr in settings")
177
- elsif has_endnotes && !has_en_pr
178
- package.settings ||= Wordprocessingml::Settings.new
179
- package.settings.endnote_pr = Wordprocessingml::EndnotePr.new
180
- record_fix("R9", "Added endnotePr to settings to match endnotes.xml")
181
- end
182
-
183
- ensure_separators(package.endnotes, :endnote) if package.endnotes
184
- end
185
-
186
- # -- Note reference consistency --
187
-
188
- def reconcile_note_references
189
- reconcile_footnote_references
190
- reconcile_endnote_references
191
- reconcile_note_definition_integrity
192
- end
193
-
194
- def reconcile_footnote_references
195
- return unless package.document&.body
196
-
197
- referenced = collect_note_ids(:footnote)
198
- return if referenced.empty?
199
-
200
- ensure_footnotes_part
201
- defined = package.footnotes.footnote_entries.each_with_object(Set.new) do |fn, s|
202
- s << fn.id
203
- end
204
-
205
- missing = referenced.to_set - defined
206
- return if missing.empty?
207
-
208
- missing.each do |id|
209
- package.footnotes.footnote_entries << Wordprocessingml::Footnote.new(
210
- id: id, paragraphs: [Wordprocessingml::Paragraph.new],
211
- )
212
- end
213
- record_fix("R10",
214
- "Added #{missing.size} missing footnote definition(s) " \
215
- "for orphaned footnoteReference IDs: #{missing.sort.join(', ')}")
216
- end
217
-
218
- def reconcile_endnote_references
219
- return unless package.document&.body
220
-
221
- referenced = collect_note_ids(:endnote)
222
- return if referenced.empty?
223
-
224
- ensure_endnotes_part
225
- defined = package.endnotes.endnote_entries.each_with_object(Set.new) do |en, s|
226
- s << en.id
227
- end
228
-
229
- missing = referenced.to_set - defined
230
- return if missing.empty?
231
-
232
- missing.each do |id|
233
- package.endnotes.endnote_entries << Wordprocessingml::Endnote.new(
234
- id: id, paragraphs: [Wordprocessingml::Paragraph.new],
235
- )
236
- end
237
- record_fix("R10",
238
- "Added #{missing.size} missing endnote definition(s) " \
239
- "for orphaned endnoteReference IDs: #{missing.sort.join(', ')}")
240
- end
241
-
242
- # Validates structural integrity of footnote/endnote definitions.
243
- def reconcile_note_definition_integrity
244
- strip_invalid_note_types(:footnote)
245
- strip_invalid_note_types(:endnote)
246
- deduplicate_note_ids(:footnote)
247
- deduplicate_note_ids(:endnote)
248
- end
249
-
250
- # R15: Regular footnotes/endnotes must NOT have w:type.
251
- # Only separator (id=-1) and continuationSeparator (id=0) use w:type.
252
- def strip_invalid_note_types(type)
253
- notes = package.public_send(:"#{type}s")
254
- return unless notes
255
-
256
- entries = notes.public_send(:"#{type}_entries")
257
- invalid = entries.select do |e|
258
- e.type && e.id != "-1" && e.id != "0"
259
- end
260
- return if invalid.empty?
261
-
262
- invalid.each { |e| e.type = nil }
263
- record_fix("R15",
264
- "Removed invalid w:type from #{invalid.size} " \
265
- "regular #{type} definition(s): " \
266
- "IDs #{invalid.map(&:id).sort.join(', ')}")
267
- end
268
-
269
- # R16: Duplicate IDs in footnotes.xml/endnotes.xml are invalid.
270
- # Keeps the first occurrence, removes subsequent duplicates.
271
- def deduplicate_note_ids(type)
272
- notes = package.public_send(:"#{type}s")
273
- return unless notes
274
-
275
- entries = notes.public_send(:"#{type}_entries")
276
- seen = Set.new
277
- dupes = []
278
- entries.reject! do |e|
279
- if seen.include?(e.id)
280
- dupes << e
281
- true
282
- else
283
- seen << e.id
284
- false
285
- end
286
- end
287
- return if dupes.empty?
288
-
289
- record_fix("R16",
290
- "Removed #{dupes.size} duplicate #{type} ID(s): " \
291
- "#{dupes.map(&:id).sort.join(', ')}")
292
- end
293
-
294
- # -- Builders --
295
-
296
- def minimal_footnotes
297
- Wordprocessingml::Footnotes.new(
298
- footnote_entries: [separator_entry(:footnote),
299
- continuation_entry(:footnote)],
300
- )
301
- end
302
-
303
- def minimal_endnotes
304
- Wordprocessingml::Endnotes.new(
305
- endnote_entries: [separator_entry(:endnote),
306
- continuation_entry(:endnote)],
307
- )
308
- end
309
-
310
- def separator_entry(type)
311
- entry_class(type).new(
312
- id: "-1", type: "separator",
313
- paragraphs: [Wordprocessingml::Paragraph.new]
314
- )
315
- end
316
-
317
- def continuation_entry(type)
318
- entry_class(type).new(
319
- id: "0", type: "continuationSeparator",
320
- paragraphs: [Wordprocessingml::Paragraph.new]
321
- )
322
- end
323
-
324
- def entry_class(type)
325
- type == :footnote ? Wordprocessingml::Footnote : Wordprocessingml::Endnote
326
- end
327
-
328
- def ensure_separators(notes, type)
329
- entries = notes.public_send(:"#{type}_entries")
330
- ids = entries.to_set(&:id)
331
-
332
- entries.unshift(separator_entry(type)) unless ids.include?("-1")
333
- entries.unshift(continuation_entry(type)) unless ids.include?("0")
334
- end
335
-
336
- def collect_note_ids(type)
337
- ref_attr = type == :footnote ? :footnote_reference : :endnote_reference
338
- ids = []
339
- collect_note_ids_from_element(package.document.body, ref_attr, ids)
340
- ids
341
- end
342
-
343
- def collect_note_ids_from_element(element, ref_attr, ids)
344
- return unless element
345
-
346
- collect_note_ids_from_paragraphs(element.paragraphs, ref_attr, ids)
347
-
348
- tables = element.tables
349
- return unless tables
350
- tables.each do |tbl|
351
- tbl.rows.each do |row|
352
- row.cells.each do |cell|
353
- collect_note_ids_from_paragraphs(cell.paragraphs, ref_attr, ids)
354
- end
355
- end
356
- end
357
- end
358
-
359
- def collect_note_ids_from_paragraphs(paragraphs, ref_attr, ids)
360
- return unless paragraphs
361
- paragraphs.each do |p|
362
- (p.runs || []).each do |r|
363
- ref = r.public_send(ref_attr)
364
- ids << ref.id if ref&.id
365
- end
366
- end
367
- end
368
-
369
- def ensure_footnotes_part
370
- return if package.footnotes
371
-
372
- package.footnotes = minimal_footnotes
373
- record_fix("R9", "Created footnotes.xml for orphaned references")
374
- end
375
-
376
- def ensure_endnotes_part
377
- return if package.endnotes
378
-
379
- package.endnotes = minimal_endnotes
380
- record_fix("R9", "Created endnotes.xml for orphaned references")
381
- end
382
-
383
- # -- Group 2: Support parts (profile-dependent) --
384
-
385
- def reconcile_theme
386
- return unless profile
387
- return if package.theme
388
-
389
- theme_name = profile.system.default_theme_name
390
- return unless theme_name
391
-
392
- begin
393
- friendly = Themes::Theme.load(theme_name)
394
- word_theme = friendly.to_word_theme
395
- word_theme.name = "Office Theme"
396
- package.theme = word_theme
397
- record_fix("R3", "Created default theme with complete fmtScheme")
398
- rescue ArgumentError
399
- nil
400
- end
401
- end
402
-
403
- # Repair broken theme fmtScheme even without a profile.
404
- # Word replaces the entire theme on repair; we fill minimum content.
405
- def repair_theme
406
- theme = package.theme
407
- return unless theme
408
-
409
- fmt = theme.theme_elements&.fmt_scheme
410
- return unless fmt
411
-
412
- repaired = false
413
-
414
- if count_fill_styles(fmt.fill_style_lst) < 2
415
- ensure_minimal_fill_list(fmt)
416
- repaired = true
417
- end
418
-
419
- if count_line_styles(fmt.ln_style_lst) < 3
420
- ensure_minimal_line_list(fmt)
421
- repaired = true
422
- end
423
-
424
- if count_effect_styles(fmt.effect_style_lst) < 3
425
- ensure_minimal_effect_list(fmt)
426
- repaired = true
427
- end
428
-
429
- if count_fill_styles(fmt.bg_fill_style_lst) < 2
430
- ensure_minimal_bg_fill_list(fmt)
431
- repaired = true
432
- end
433
-
434
- if repaired
435
- record_fix("R3",
436
- "Repaired theme fmtScheme with minimum required content")
437
- end
438
- end
439
-
440
- def count_fill_styles(lst)
441
- return 0 unless lst
442
-
443
- (lst.solid_fills || []).size + (lst.gradient_fills || []).size + (lst.blip_fills || []).size
444
- end
445
-
446
- def count_line_styles(lst)
447
- return 0 unless lst
448
-
449
- (lst.lines || []).size
450
- end
451
-
452
- def count_effect_styles(lst)
453
- return 0 unless lst
454
-
455
- (lst.effect_styles || []).size
456
- end
457
-
458
- def ensure_minimal_fill_list(fmt)
459
- lst = fmt.fill_style_lst || Drawingml::FillStyleList.new
460
- fills = Array(lst.solid_fills).dup
461
- while fills.size < 2
462
- fills << Drawingml::SolidFill.new(
463
- scheme_color: Drawingml::SchemeColor.new(val: "accent#{fills.size + 1}"),
464
- )
465
- end
466
- lst.solid_fills = fills
467
- fmt.fill_style_lst = lst
468
- end
469
-
470
- def ensure_minimal_line_list(fmt)
471
- lst = fmt.ln_style_lst || Drawingml::LineStyleList.new
472
- lines = Array(lst.lines).dup
473
- widths = [9525, 25400, 38100]
474
- while lines.size < 3
475
- idx = lines.size
476
- lines << Drawingml::LineProperties.new(
477
- width: widths[idx] || 9525,
478
- solid_fill: Drawingml::SolidFill.new(
479
- scheme_color: Drawingml::SchemeColor.new(val: "accent#{idx + 1}"),
480
- ),
481
- )
482
- end
483
- lst.lines = lines
484
- fmt.ln_style_lst = lst
485
- end
486
-
487
- def ensure_minimal_effect_list(fmt)
488
- lst = fmt.effect_style_lst || Drawingml::EffectStyleList.new
489
- styles = Array(lst.effect_styles).dup
490
- while styles.size < 3
491
- styles << Drawingml::EffectStyle.new
492
- end
493
- lst.effect_styles = styles
494
- fmt.effect_style_lst = lst
495
- end
496
-
497
- def ensure_minimal_bg_fill_list(fmt)
498
- lst = fmt.bg_fill_style_lst || Drawingml::BackgroundFillStyleList.new
499
- fills = Array(lst.solid_fills).dup
500
- while fills.size < 2
501
- fills << Drawingml::SolidFill.new(
502
- scheme_color: Drawingml::SchemeColor.new(val: "accent#{fills.size + 1}"),
503
- )
504
- end
505
- lst.solid_fills = fills
506
- fmt.bg_fill_style_lst = lst
507
- end
508
-
509
- def reconcile_settings
510
- return unless profile
511
-
512
- settings = package.settings
513
- settings ||= begin
514
- package.settings = Wordprocessingml::Settings.new
515
- package.settings
516
- end
517
-
518
- rsid = generate_rsid
519
-
520
- # Only inject settings defaults for newly created documents.
521
- # If settings already has substantial content (zoom + compat + rsids),
522
- # the document was loaded from a real DOCX and defaults would alter
523
- # its round-trip fidelity.
524
- loaded_doc = settings.zoom && settings.compat && settings.rsids
525
-
526
- settings.zoom ||= Wordprocessingml::Zoom.new(percent: 100)
527
- settings.proof_state ||= Wordprocessingml::ProofState.new(
528
- spelling: "clean", grammar: "clean",
529
- ) unless loaded_doc
530
- settings.default_tab_stop ||= Wordprocessingml::DefaultTabStop.new(val: "720")
531
- settings.character_spacing_control ||= Wordprocessingml::CharacterSpacingControl.new(
532
- val: "doNotCompress",
533
- )
534
-
535
- settings.compat ||= build_compat
536
- settings.rsids ||= build_rsids(rsid)
537
- settings.math_pr ||= build_math_pr
538
- settings.theme_font_lang ||= Wordprocessingml::ThemeFontLang.new(
539
- val: profile.lang,
540
- east_asia: profile.east_asia_lang,
541
- )
542
- settings.clr_scheme_mapping ||= build_clr_scheme_mapping
543
- settings.decimal_symbol ||= Wordprocessingml::DecimalSymbol.new(
544
- val: profile.decimal_symbol,
545
- )
546
- settings.list_separator ||= Wordprocessingml::ListSeparator.new(
547
- val: profile.list_separator,
548
- )
549
-
550
- unless settings.w14_doc_id
551
- settings.w14_doc_id = Wordprocessingml::W14DocId.new(
552
- val: SecureRandom.hex(4).upcase,
553
- )
554
- record_fix("R2", "Generated w14:docId")
555
- end
556
- unless settings.w15_doc_id
557
- settings.w15_doc_id = Wordprocessingml::W15DocId.new(
558
- val: "{#{SecureRandom.uuid.upcase}}",
559
- )
560
- record_fix("R2", "Generated w15:docId in GUID format")
561
- end
562
-
563
- settings.mc_ignorable = Ooxml::Types::McIgnorable.new(
564
- IGNORABLE_PREFIXES.join(" "),
565
- )
566
- end
567
-
568
- def reconcile_font_table
569
- return unless profile
570
-
571
- font_table = package.font_table
572
- font_table ||= begin
573
- package.font_table = Wordprocessingml::FontTable.new
574
- record_fix("R13", "Created font table")
575
- package.font_table
576
- end
577
-
578
- return unless font_table.fonts.empty?
579
-
580
- metadata = load_font_metadata
581
- return unless metadata
582
-
583
- font_names = font_names_for_profile
584
- font_names.each do |name|
585
- meta = metadata[name]
586
- next unless meta
587
-
588
- sig_data = meta["sig"] || {}
589
- font = Wordprocessingml::Font.new(
590
- name: name,
591
- panose1: Wordprocessingml::Panose1.new(val: meta["panose1"]),
592
- charset: Wordprocessingml::Charset.new(val: meta["charset"]),
593
- family: Wordprocessingml::Family.new(val: meta["family"]),
594
- pitch: Wordprocessingml::Pitch.new(val: meta["pitch"]),
595
- sig: Wordprocessingml::Sig.new(
596
- usb0: sig_data["usb0"], usb1: sig_data["usb1"],
597
- usb2: sig_data["usb2"], usb3: sig_data["usb3"],
598
- csb0: sig_data["csb0"], csb1: sig_data["csb1"]
599
- ),
600
- )
601
-
602
- font.alt_name = Wordprocessingml::AltName.new(val: meta["alt_name"]) if meta["alt_name"]
603
- font_table.fonts << font
604
- end
605
-
606
- font_table.mc_ignorable = Ooxml::Types::McIgnorable.new(
607
- IGNORABLE_PREFIXES.join(" "),
608
- )
609
- record_fix("R13",
610
- "Populated font table with profile fonts and signatures")
611
- end
612
-
613
- def reconcile_styles
614
- return unless profile
615
-
616
- styles = package.styles
617
- styles ||= begin
618
- package.styles = Wordprocessingml::StylesConfiguration.new(include_defaults: false)
619
- package.styles
620
- end
621
-
622
- styles.doc_defaults ||= build_doc_defaults
623
- styles.latent_styles ||= build_latent_styles
624
-
625
- ensure_default_styles(styles)
626
-
627
- styles.mc_ignorable = Ooxml::Types::McIgnorable.new(
628
- IGNORABLE_PREFIXES.join(" "),
629
- )
630
- record_fix("R10",
631
- "Ensured styles have docDefaults, latentStyles, and default styles")
632
- end
633
-
634
- def reconcile_numbering
635
- return unless profile
636
- return unless package.numbering
637
-
638
- package.numbering.mc_ignorable = Ooxml::Types::McIgnorable.new(
639
- IGNORABLE_PREFIXES.join(" "),
640
- )
641
-
642
- # Validate instance → definition references
643
- package.numbering.instances.each do |inst|
644
- next unless inst.abstract_num_id
645
-
646
- abs_id = inst.abstract_num_id.is_a?(Uniword::Wordprocessingml::AbstractNumId) ? inst.abstract_num_id.val : inst.abstract_num_id
647
- defn = package.numbering.definitions.find do |d|
648
- d.abstract_num_id == abs_id
649
- end
650
- next if defn
651
-
652
- record_fix("R4", "Numbering instance numId=#{inst.num_id} references " \
653
- "missing abstractNumId=#{abs_id}")
654
- end
655
- end
656
-
657
- def reconcile_web_settings
658
- return unless profile
659
-
660
- ws = package.web_settings
661
- ws ||= begin
662
- package.web_settings = Wordprocessingml::WebSettings.new
663
- package.web_settings
664
- end
665
-
666
- ws.mc_ignorable = Ooxml::Types::McIgnorable.new(
667
- IGNORABLE_PREFIXES.join(" "),
668
- )
669
- ws.allow_png ||= Wordprocessingml::AllowPng.new
670
- record_fix("R1", "Set mc:Ignorable and allowPNG on webSettings")
671
- end
672
-
673
- def reconcile_app_properties
674
- return unless profile
675
-
676
- app = package.app_properties
677
- app ||= begin
678
- package.app_properties = Ooxml::AppProperties.new
679
- package.app_properties
680
- end
681
-
682
- # Always assign to override lutaml-model's using_default tracking,
683
- # which prevents serialization of attributes that equal their defaults.
684
- app.template = "Normal.dotm"
685
- app.application = profile.application_name
686
- app.app_version = profile.app_version
687
- app.company = profile.user_company if profile.user_company && !profile.user_company.empty?
688
-
689
- # Calculate document statistics only when missing
690
- unless app.pages && !app.pages.to_s.empty?
691
- stats = calculate_document_statistics
692
- app.pages = stats[:pages].to_s
693
- app.words = stats[:words].to_s
694
- app.characters = stats[:characters].to_s
695
- app.characters_with_spaces = stats[:characters_with_spaces].to_s
696
- app.paragraphs = stats[:paragraphs].to_s
697
- app.lines = stats[:lines].to_s
698
- end
699
-
700
- # Re-assign to clear lutaml-model's @using_default tracking
701
- # which otherwise omits these from XML output.
702
- # Preserve source values when present.
703
- app.total_time = app.total_time || "0"
704
- app.scale_crop = app.scale_crop || "false"
705
- app.doc_security = app.doc_security || "0"
706
- app.links_up_to_date = app.links_up_to_date || "false"
707
- app.shared_doc = app.shared_doc || "false"
708
- app.hyperlinks_changed = app.hyperlinks_changed || "false"
709
-
710
- # HeadingPairs and TitlesOfParts are NOT generated here.
711
- # Word repairs files that have incorrect values, so we only
712
- # preserve what was parsed from the source document.
713
- record_fix("R8", "Ensured app properties with statistics")
714
- end
715
-
716
- def reconcile_core_properties
717
- return unless profile
718
-
719
- # Rebuild from parsed state to ensure namespace_scope
720
- # declarations (e.g. xmlns:dcmitype) are applied on
721
- # serialization (lutaml-model preserves parsed namespaces)
722
- old_cp = package.core_properties
723
- package.core_properties = if old_cp
724
- Ooxml::CoreProperties.new(
725
- title: old_cp.title,
726
- subject: old_cp.subject,
727
- creator: old_cp.creator,
728
- keywords: old_cp.keywords,
729
- description: old_cp.description,
730
- last_modified_by: old_cp.last_modified_by,
731
- revision: old_cp.revision,
732
- created: old_cp.created,
733
- modified: old_cp.modified,
734
- )
735
- else
736
- Ooxml::CoreProperties.new
737
- end
738
- cp = package.core_properties
739
-
740
- if profile.user_name && !profile.user_name.empty?
741
- cp.last_modified_by = profile.user_name
742
- cp.creator ||= profile.user_name
107
+ (package.document&.header_footer_parts || []).each do |part|
108
+ parts << part[:content] if part[:content]
743
109
  end
744
110
 
745
- cp.last_modified_by ||= profile.application_name
746
-
747
- now = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
748
- cp.modified = Ooxml::Types::DctermsModifiedType.new(
749
- value: now, type: "dcterms:W3CDTF",
750
- )
751
- cp.created ||= Ooxml::Types::DctermsCreatedType.new(
752
- value: now, type: "dcterms:W3CDTF",
753
- )
754
-
755
- cp.revision = "1" unless cp.revision
756
- record_fix("R14", "Rebuilt core properties with namespace declarations")
757
- end
758
-
759
- def reconcile_document_body
760
- return unless profile
761
- return unless package.document&.body
762
-
763
- doc = package.document
764
- doc.mc_ignorable = Ooxml::Types::McIgnorable.new(
765
- IGNORABLE_PREFIXES_DOCUMENT.join(" "),
766
- )
767
-
768
- record_fix("R1", "Added mc:Ignorable to document body")
769
- record_fix("R12", "Assigned rsid and paraId to paragraphs")
770
-
771
- rsid = generate_rsid
772
- body = doc.body
773
-
774
- body.paragraphs.each do |para|
775
- para.rsid_r ||= rsid
776
- para.rsid_r_default ||= "00000000"
777
- para.para_id ||= generate_hex_id
778
- para.text_id ||= "77777777"
779
- end
780
-
781
- sect_pr = body.section_properties
782
- return unless sect_pr
783
-
784
- sect_pr.rsid_r ||= rsid
785
- end
786
-
787
- # -- Group 3: Package consistency (always) --
788
-
789
- def reconcile_content_types
790
- ct = package.content_types
791
- return unless ct
792
-
793
- # Defaults: only rels + xml (image types added by inject_image_parts)
794
- ct.defaults = [
795
- Uniword::ContentTypes::Default.new(
796
- extension: "rels",
797
- content_type: "application/vnd.openxmlformats-package.relationships+xml",
798
- ),
799
- Uniword::ContentTypes::Default.new(
800
- extension: "xml",
801
- content_type: "application/xml",
802
- ),
803
- ]
804
-
805
- # Overrides: rebuild for standard parts that exist
806
- standard = content_type_overrides_for_present_parts
807
- standard_parts = standard.to_set(&:part_name)
808
- non_standard = ct.overrides.reject do |o|
809
- standard_parts.include?(o.part_name)
810
- end
811
-
812
- ct.overrides = standard + non_standard
813
- record_fix("R7", "Rebuilt content types for standard parts")
814
- end
815
-
816
- def reconcile_package_rels
817
- rels = package.package_rels
818
- return unless rels
819
-
820
- base = "http://schemas.openxmlformats.org"
821
- standard_defs = [
822
- ["rId1",
823
- "#{base}/officeDocument/2006/relationships/officeDocument",
824
- "word/document.xml"],
825
- ["rId2",
826
- "#{base}/package/2006/relationships/metadata/core-properties",
827
- "docProps/core.xml"],
828
- ["rId3",
829
- "#{base}/officeDocument/2006/relationships/extended-properties",
830
- "docProps/app.xml"],
831
- ]
111
+ (package.document&.headers&.values || []).each { |h| parts << h }
112
+ (package.document&.footers&.values || []).each { |f| parts << f }
832
113
 
833
- standard_targets = standard_defs.to_set { |_, _, t| t }
834
- standard_rids = standard_defs.to_set { |rid, _, _| rid }
835
- non_standard = rels.relationships.reject do |r|
836
- standard_targets.include?(r.target) || standard_rids.include?(r.id)
837
- end
838
-
839
- existing_by_target = rels.relationships.to_h { |r| [r.target, r] }
840
- standard = standard_defs.map do |rid, type, target|
841
- existing = existing_by_target[target]
842
- build_rel(existing ? existing.id : rid, type, target)
843
- end
844
-
845
- rels.relationships = standard + non_standard
846
- record_fix("R6", "Rebuilt package relationships for standard parts")
847
- end
848
-
849
- def reconcile_document_rels
850
- rels = package.document_rels
851
- return unless rels
852
-
853
- base = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
854
- defs = [
855
- ["rId1", "styles", "styles.xml", package.styles],
856
- ["rId2", "settings", "settings.xml", package.settings],
857
- ["rId3", "webSettings", "webSettings.xml", package.web_settings],
858
- ["rId4", "fontTable", "fontTable.xml", package.font_table],
859
- ["rId5", "theme", "theme/theme1.xml", package.theme],
860
- ["rId6", "numbering", "numbering.xml", package.numbering],
861
- ]
862
-
863
- standard_targets = defs.filter_map do |_, _, target, obj|
864
- target if obj
865
- end.to_set
866
- standard_rids = defs.filter_map { |rid, _, _, obj| rid if obj }.to_set
867
- non_standard = rels.relationships.reject do |r|
868
- standard_targets.include?(r.target) || standard_rids.include?(r.id)
869
- end
870
-
871
- # Reuse existing rIds for matching targets to avoid duplicates
872
- existing_by_target = rels.relationships.to_h { |r| [r.target, r] }
873
- standard = defs.filter_map do |_rid, suffix, target, obj|
874
- next unless obj
875
-
876
- existing = existing_by_target[target]
877
- rid = existing ? existing.id : _rid
878
- build_rel(rid, "#{base}/#{suffix}", target)
879
- end
880
-
881
- rels.relationships = standard + non_standard
882
- record_fix("R6", "Rebuilt document relationships for standard parts")
883
- end
884
-
885
- # -- Helpers --
886
-
887
- def calculate_document_statistics
888
- DocumentStatistics.new(package).calculate
889
- end
890
-
891
- def generate_rsid
892
- "00#{SecureRandom.hex(3).upcase}"
893
- end
894
-
895
- def generate_hex_id
896
- SecureRandom.hex(4).upcase
897
- end
898
-
899
- def content_type_overrides_for_present_parts
900
- checks = [
901
- [package.document, "/word/document.xml",
902
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"],
903
- [package.styles, "/word/styles.xml",
904
- "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"],
905
- [package.settings, "/word/settings.xml",
906
- "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"],
907
- [package.font_table, "/word/fontTable.xml",
908
- "application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"],
909
- [package.web_settings, "/word/webSettings.xml",
910
- "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"],
911
- [package.theme, "/word/theme/theme1.xml",
912
- "application/vnd.openxmlformats-officedocument.theme+xml"],
913
- [package.core_properties, "/docProps/core.xml",
914
- "application/vnd.openxmlformats-package.core-properties+xml"],
915
- [package.app_properties, "/docProps/app.xml",
916
- "application/vnd.openxmlformats-officedocument.extended-properties+xml"],
917
- [package.footnotes, "/word/footnotes.xml",
918
- "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml"],
919
- [package.endnotes, "/word/endnotes.xml",
920
- "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml"],
921
- [package.numbering, "/word/numbering.xml",
922
- "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"],
923
- ]
924
-
925
- checks.filter_map do |obj, part_name, content_type|
926
- next unless obj
927
-
928
- Uniword::ContentTypes::Override.new(
929
- part_name: part_name,
930
- content_type: content_type,
931
- )
932
- end
933
- end
934
-
935
- def build_rel(id, type, target)
936
- Ooxml::Relationships::Relationship.new(
937
- id: id, type: type, target: target,
938
- )
939
- end
940
-
941
- def build_compat
942
- Wordprocessingml::Compat.new(
943
- use_fe_layout: Wordprocessingml::UseFELayout.new,
944
- compatSetting: [
945
- Wordprocessingml::CompatSetting.new(
946
- name: "compatibilityMode",
947
- uri: "http://schemas.microsoft.com/office/word",
948
- val: profile.compat_mode,
949
- ),
950
- Wordprocessingml::CompatSetting.new(
951
- name: "overrideTableStyleFontSizeAndJustification",
952
- uri: "http://schemas.microsoft.com/office/word",
953
- val: "1",
954
- ),
955
- Wordprocessingml::CompatSetting.new(
956
- name: "enableOpenTypeFeatures",
957
- uri: "http://schemas.microsoft.com/office/word",
958
- val: "1",
959
- ),
960
- Wordprocessingml::CompatSetting.new(
961
- name: "doNotFlipMirrorIndents",
962
- uri: "http://schemas.microsoft.com/office/word",
963
- val: "1",
964
- ),
965
- Wordprocessingml::CompatSetting.new(
966
- name: "differentiateMultirowTableHeaders",
967
- uri: "http://schemas.microsoft.com/office/word",
968
- val: "1",
969
- ),
970
- Wordprocessingml::CompatSetting.new(
971
- name: "useWord2013TrackBottomHyphenation",
972
- uri: "http://schemas.microsoft.com/office/word",
973
- val: "0",
974
- ),
975
- ],
976
- )
977
- end
978
-
979
- def build_rsids(rsid)
980
- root = "00#{SecureRandom.hex(3).upcase}"
981
- Wordprocessingml::Rsids.new(
982
- rsid_root: Wordprocessingml::RsidRoot.new(val: root),
983
- rsid: [Wordprocessingml::Rsid.new(val: rsid)],
984
- )
985
- end
986
-
987
- def build_math_pr
988
- Wordprocessingml::MathPr.new(
989
- math_font: Wordprocessingml::MathFont.new(val: "Cambria Math"),
990
- brk_bin: Wordprocessingml::BrkBin.new(val: "before"),
991
- brk_bin_sub: Wordprocessingml::BrkBinSub.new(val: "--"),
992
- small_frac: Wordprocessingml::SmallFrac.new(val: "0"),
993
- disp_def: Wordprocessingml::DispDef.new,
994
- l_margin: Wordprocessingml::LMargin.new(val: "0"),
995
- r_margin: Wordprocessingml::RMargin.new(val: "0"),
996
- def_jc: Wordprocessingml::DefJc.new(val: "centerGroup"),
997
- wrap_indent: Wordprocessingml::WrapIndent.new(val: "1440"),
998
- int_lim: Wordprocessingml::IntLim.new(val: "subSup"),
999
- nary_lim: Wordprocessingml::NaryLim.new(val: "undOvr"),
1000
- )
1001
- end
1002
-
1003
- def build_clr_scheme_mapping
1004
- Wordprocessingml::ClrSchemeMapping.new(
1005
- bg1: "light1", t1: "dark1", bg2: "light2", t2: "dark2",
1006
- accent1: "accent1", accent2: "accent2", accent3: "accent3",
1007
- accent4: "accent4", accent5: "accent5", accent6: "accent6",
1008
- hyperlink: "hyperlink", followed_hyperlink: "followedHyperlink"
1009
- )
1010
- end
1011
-
1012
- def build_doc_defaults
1013
- r_pr = Wordprocessingml::RunProperties.new(
1014
- fonts: Properties::RunFonts.new(
1015
- ascii_theme: "minorHAnsi",
1016
- east_asia_theme: "minorEastAsia",
1017
- h_ansi_theme: "minorHAnsi",
1018
- cs_theme: "minorBidi",
1019
- ),
1020
- kerning: Properties::Kerning.new(value: 2),
1021
- size: Properties::FontSize.new(value: 24),
1022
- size_cs: Properties::FontSize.new(value: 24),
1023
- language: Properties::Language.new(
1024
- val: profile.lang,
1025
- east_asia: profile.east_asia_lang,
1026
- bidi: profile.bidi_lang,
1027
- ),
1028
- ligatures: Uniword::Wordprocessingml2010::Ligatures.new(
1029
- val: "standardContextual",
1030
- ),
1031
- )
1032
-
1033
- p_pr = Wordprocessingml::ParagraphProperties.new(
1034
- spacing: Properties::Spacing.new(after: 160, line: 278,
1035
- line_rule: "auto"),
1036
- )
1037
-
1038
- Wordprocessingml::DocDefaults.new(
1039
- rPrDefault: Wordprocessingml::RPrDefault.new(rPr: r_pr),
1040
- pPrDefault: Wordprocessingml::PPrDefault.new(pPr: p_pr),
1041
- )
1042
- end
1043
-
1044
- def build_latent_styles
1045
- config = load_latent_styles_config
1046
- return Wordprocessingml::LatentStyles.new(count: 0) unless config
1047
-
1048
- exceptions = (config["exceptions"] || []).map do |ex|
1049
- attrs = { name: ex["name"] }
1050
- attrs[:ui_priority] = ex["uiPriority"].to_i if ex["uiPriority"]
1051
- attrs[:q_format] = ex["qFormat"] if ex["qFormat"]
1052
- attrs[:semi_hidden] = ex["semiHidden"] if ex["semiHidden"]
1053
- if ex["unhideWhenUsed"]
1054
- attrs[:unhide_when_used] =
1055
- ex["unhideWhenUsed"]
1056
- end
1057
- attrs[:locked] = ex["locked"] if ex["locked"]
1058
- Wordprocessingml::LatentStylesException.new(attrs)
1059
- end
1060
-
1061
- Wordprocessingml::LatentStyles.new(
1062
- def_locked_state: config["defLockedState"],
1063
- def_ui_priority: config["defUIPriority"].to_i,
1064
- def_semi_hidden: config["defSemiHidden"],
1065
- def_unhide_when_used: config["defUnhideWhenUsed"],
1066
- def_q_format: config["defQFormat"],
1067
- count: config["count"].to_i,
1068
- lsd_exception: exceptions,
1069
- )
1070
- end
1071
-
1072
- def ensure_default_styles(styles)
1073
- style_ids = styles.styles.to_set(&:id)
1074
-
1075
- unless style_ids.include?("Normal")
1076
- styles.add_style(Wordprocessingml::Style.new(
1077
- type: "paragraph", default: true, styleId: "Normal",
1078
- name: Wordprocessingml::StyleName.new(val: "Normal"),
1079
- qFormat: Properties::QuickFormat.new
1080
- ))
1081
- end
1082
-
1083
- unless style_ids.include?("DefaultParagraphFont")
1084
- styles.add_style(Wordprocessingml::Style.new(
1085
- type: "character", default: true, styleId: "DefaultParagraphFont",
1086
- name: Wordprocessingml::StyleName.new(val: "Default Paragraph Font"),
1087
- uiPriority: Wordprocessingml::UiPriority.new(val: 1),
1088
- semiHidden: Wordprocessingml::SemiHidden.new,
1089
- unhideWhenUsed: Wordprocessingml::UnhideWhenUsed.new
1090
- ))
1091
- end
1092
-
1093
- unless style_ids.include?("TableNormal")
1094
- tbl_pr = Wordprocessingml::TableProperties.new(
1095
- table_indent: Properties::TableIndent.new(value: 0, type: "dxa"),
1096
- table_cell_margin: Properties::TableCellMargin.new(
1097
- top: Properties::Margin.new(w: 0, type: "dxa"),
1098
- left: Properties::Margin.new(w: 108, type: "dxa"),
1099
- bottom: Properties::Margin.new(w: 0, type: "dxa"),
1100
- right: Properties::Margin.new(w: 108, type: "dxa"),
1101
- ),
1102
- )
1103
-
1104
- styles.add_style(Wordprocessingml::Style.new(
1105
- type: "table", default: true, styleId: "TableNormal",
1106
- name: Wordprocessingml::StyleName.new(val: "Normal Table"),
1107
- uiPriority: Wordprocessingml::UiPriority.new(val: 99),
1108
- semiHidden: Wordprocessingml::SemiHidden.new,
1109
- unhideWhenUsed: Wordprocessingml::UnhideWhenUsed.new,
1110
- tblPr: tbl_pr
1111
- ))
1112
- end
1113
-
1114
- return if style_ids.include?("NoList")
1115
-
1116
- styles.add_style(Wordprocessingml::Style.new(
1117
- type: "numbering", default: true, styleId: "NoList",
1118
- name: Wordprocessingml::StyleName.new(val: "No List"),
1119
- uiPriority: Wordprocessingml::UiPriority.new(val: 99),
1120
- semiHidden: Wordprocessingml::SemiHidden.new,
1121
- unhideWhenUsed: Wordprocessingml::UnhideWhenUsed.new
1122
- ))
1123
- end
1124
-
1125
- def load_font_metadata
1126
- path = File.join(CONFIG_DIR, "font_metadata.yml")
1127
- YAML.load_file(path)["fonts"]
1128
- rescue StandardError => e
1129
- Uniword.logger&.warn { "Font metadata load failed: #{e.message}" }
1130
- nil
1131
- end
1132
-
1133
- def load_latent_styles_config
1134
- path = File.join(CONFIG_DIR, "latent_styles.yml")
1135
- YAML.load_file(path)
1136
- rescue StandardError => e
1137
- Uniword.logger&.warn do
1138
- "Latent styles config load failed: #{e.message}"
1139
- end
1140
- nil
1141
- end
1142
-
1143
- def font_names_for_profile
1144
- names = []
1145
- fs = profile.system.font_scheme
1146
- loc = profile.locale
1147
-
1148
- # Order matches Word's canonical output:
1149
- # 1. Minor font (body), 2. East Asian font, 3. Legacy serif,
1150
- # 4. East Asian light font, 5. Major font (headings)
1151
- names << fs&.minor_font if fs&.minor_font
1152
-
1153
- ea_font = loc&.east_asian_font
1154
- ea_light = loc&.east_asian_light_font
1155
-
1156
- # Default East Asian fonts for zh-CN when locale profile omits them
1157
- if loc.east_asia_lang == "zh-CN"
1158
- ea_font ||= "DengXian"
1159
- ea_light ||= "DengXian Light"
1160
- end
1161
-
1162
- names << ea_font if ea_font
1163
- names << "Times New Roman"
1164
- names << ea_light if ea_light
1165
- names << fs&.major_font if fs&.major_font
1166
-
1167
- names.uniq
114
+ parts.each(&:clear_xml_parse_state!)
1168
115
  end
1169
116
  end
1170
117
  end