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