uniword 1.2.5 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +313 -0
  3. data/CONTRIBUTING.md +1 -1
  4. data/config/ooxml/schemas/shared_types.yml +5 -4
  5. data/data/schemas/ecma/dc.xsd +118 -0
  6. data/data/schemas/ecma/dcmitype.xsd +50 -0
  7. data/data/schemas/ecma/dcterms.xsd +331 -0
  8. data/data/schemas/ecma/opc-coreProperties.xsd +2 -2
  9. data/data/schemas/ecma/xml.xsd +117 -0
  10. data/lib/uniword/builder/bibliography_builder.rb +1 -1
  11. data/lib/uniword/builder/chart_builder.rb +22 -14
  12. data/lib/uniword/builder/comment_anchorer.rb +221 -0
  13. data/lib/uniword/builder/document_builder.rb +97 -6
  14. data/lib/uniword/builder/image_builder.rb +11 -10
  15. data/lib/uniword/builder/run_utils.rb +2 -0
  16. data/lib/uniword/builder.rb +1 -0
  17. data/lib/uniword/cli/fonts_cli.rb +52 -0
  18. data/lib/uniword/cli/main.rb +110 -11
  19. data/lib/uniword/cli/page_cli.rb +85 -0
  20. data/lib/uniword/cli/styles_cli.rb +204 -0
  21. data/lib/uniword/cli/theme_cli.rb +88 -0
  22. data/lib/uniword/cli/toc_cli.rb +22 -2
  23. data/lib/uniword/comment.rb +20 -15
  24. data/lib/uniword/comment_range.rb +3 -3
  25. data/lib/uniword/comments_part.rb +51 -19
  26. data/lib/uniword/configuration/configuration_loader.rb +5 -3
  27. data/lib/uniword/configuration.rb +93 -1
  28. data/lib/uniword/content_types.rb +19 -39
  29. data/lib/uniword/document_factory.rb +12 -18
  30. data/lib/uniword/document_writer.rb +16 -5
  31. data/lib/uniword/docx/chart_part.rb +34 -0
  32. data/lib/uniword/docx/custom_xml_item.rb +91 -0
  33. data/lib/uniword/docx/header_footer_part.rb +140 -0
  34. data/lib/uniword/docx/header_footer_part_collection.rb +135 -0
  35. data/lib/uniword/docx/header_footer_view.rb +225 -0
  36. data/lib/uniword/docx/id_allocator.rb +130 -36
  37. data/lib/uniword/docx/image_part.rb +83 -0
  38. data/lib/uniword/docx/package.rb +141 -333
  39. data/lib/uniword/docx/package_defaults.rb +83 -99
  40. data/lib/uniword/docx/package_integrity_checker.rb +312 -0
  41. data/lib/uniword/docx/package_serialization.rb +144 -233
  42. data/lib/uniword/docx/part.rb +125 -0
  43. data/lib/uniword/docx/part_collection.rb +131 -0
  44. data/lib/uniword/docx/part_loader/chart_loader.rb +50 -0
  45. data/lib/uniword/docx/part_loader/custom_xml_loader.rb +53 -0
  46. data/lib/uniword/docx/part_loader/embedding_loader.rb +26 -0
  47. data/lib/uniword/docx/part_loader/header_footer_loader.rb +101 -0
  48. data/lib/uniword/docx/part_loader/image_loader.rb +105 -0
  49. data/lib/uniword/docx/part_loader/load_context.rb +85 -0
  50. data/lib/uniword/docx/part_loader/raw_part_loader.rb +183 -0
  51. data/lib/uniword/docx/part_loader/theme_media_loader.rb +39 -0
  52. data/lib/uniword/docx/part_loader/xml_model_loader.rb +56 -0
  53. data/lib/uniword/docx/part_loader.rb +92 -0
  54. data/lib/uniword/docx/raw_part.rb +75 -0
  55. data/lib/uniword/docx/reconciler/body.rb +59 -68
  56. data/lib/uniword/docx/reconciler/fix.rb +46 -0
  57. data/lib/uniword/docx/reconciler/fix_codes.rb +37 -30
  58. data/lib/uniword/docx/reconciler/helpers.rb +18 -31
  59. data/lib/uniword/docx/reconciler/notes.rb +17 -8
  60. data/lib/uniword/docx/reconciler/package_structure.rb +168 -221
  61. data/lib/uniword/docx/reconciler/parts.rb +36 -13
  62. data/lib/uniword/docx/reconciler/referential_integrity.rb +222 -123
  63. data/lib/uniword/docx/reconciler/tables.rb +78 -16
  64. data/lib/uniword/docx/reconciler/theme.rb +7 -4
  65. data/lib/uniword/docx/reconciler.rb +36 -14
  66. data/lib/uniword/docx.rb +13 -0
  67. data/lib/uniword/drawingml/blip.rb +0 -1
  68. data/lib/uniword/drawingml/color_scheme.rb +5 -6
  69. data/lib/uniword/drawingml/font_scheme.rb +4 -8
  70. data/lib/uniword/drawingml/theme.rb +12 -6
  71. data/lib/uniword/errors.rb +8 -1
  72. data/lib/uniword/footer.rb +4 -0
  73. data/lib/uniword/header.rb +4 -0
  74. data/lib/uniword/hyperlink.rb +1 -1
  75. data/lib/uniword/image.rb +6 -6
  76. data/lib/uniword/images/image_manager.rb +13 -13
  77. data/lib/uniword/lazy_loader.rb +1 -2
  78. data/lib/uniword/model_attribute_access.rb +20 -4
  79. data/lib/uniword/ooxml/dotx_package.rb +42 -2
  80. data/lib/uniword/ooxml/element_order.rb +94 -0
  81. data/lib/uniword/ooxml/part_definition.rb +329 -0
  82. data/lib/uniword/ooxml/part_registry.rb +504 -0
  83. data/lib/uniword/ooxml/relationships/image_relationship.rb +6 -3
  84. data/lib/uniword/ooxml/relationships/package_relationships.rb +20 -22
  85. data/lib/uniword/ooxml/schema/element_serializer.rb +8 -7
  86. data/lib/uniword/ooxml/types/hex_color_value.rb +38 -0
  87. data/lib/uniword/ooxml/types/ooxml_boolean.rb +27 -14
  88. data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +21 -16
  89. data/lib/uniword/ooxml/types/theme_color_value.rb +22 -0
  90. data/lib/uniword/ooxml/types/unsigned_decimal_number.rb +30 -0
  91. data/lib/uniword/ooxml/types.rb +10 -0
  92. data/lib/uniword/ooxml.rb +3 -0
  93. data/lib/uniword/properties/alignment.rb +8 -3
  94. data/lib/uniword/properties/border.rb +52 -6
  95. data/lib/uniword/properties/cell_vertical_align.rb +4 -1
  96. data/lib/uniword/properties/color_value.rb +8 -3
  97. data/lib/uniword/properties/highlight.rb +9 -5
  98. data/lib/uniword/properties/shading.rb +4 -3
  99. data/lib/uniword/properties/tab_stop.rb +6 -1
  100. data/lib/uniword/properties/table_justification.rb +4 -1
  101. data/lib/uniword/properties/underline.rb +4 -2
  102. data/lib/uniword/properties/vertical_align.rb +6 -3
  103. data/lib/uniword/review/review_manager.rb +23 -9
  104. data/lib/uniword/revision.rb +9 -9
  105. data/lib/uniword/schema/model_generator.rb +31 -15
  106. data/lib/uniword/shared_types/hex_color.rb +4 -1
  107. data/lib/uniword/shared_types/pixel_measure.rb +4 -1
  108. data/lib/uniword/shared_types/point_measure.rb +4 -1
  109. data/lib/uniword/shared_types/text_alignment.rb +6 -1
  110. data/lib/uniword/shared_types/twips_measure.rb +4 -1
  111. data/lib/uniword/spreadsheetml/phonetic_pr.rb +1 -1
  112. data/lib/uniword/spreadsheetml/shared_string_table.rb +2 -2
  113. data/lib/uniword/spreadsheetml/table_formula.rb +1 -1
  114. data/lib/uniword/template/variable_resolver.rb +40 -7
  115. data/lib/uniword/themes/theme_transformation.rb +55 -41
  116. data/lib/uniword/toc/toc_generator.rb +6 -5
  117. data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -5
  118. data/lib/uniword/transformation/mhtml_metadata_builder.rb +6 -6
  119. data/lib/uniword/validation/engine.rb +35 -0
  120. data/lib/uniword/validation/link_checker.rb +1 -1
  121. data/lib/uniword/validation/opc_validator.rb +16 -2
  122. data/lib/uniword/validation/report/layer_result.rb +0 -1
  123. data/lib/uniword/validation/report/terminal_formatter.rb +0 -1
  124. data/lib/uniword/validation/report/verification_report.rb +0 -2
  125. data/lib/uniword/validation/report.rb +20 -0
  126. data/lib/uniword/validation/rules/base.rb +10 -2
  127. data/lib/uniword/validation/rules/bookmark_pairing_rule.rb +38 -0
  128. data/lib/uniword/validation/rules/bookmark_uniqueness_rule.rb +48 -0
  129. data/lib/uniword/validation/rules/bookmarks_rule.rb +0 -2
  130. data/lib/uniword/validation/rules/content_types_coverage_rule.rb +0 -2
  131. data/lib/uniword/validation/rules/core_properties_namespace_rule.rb +0 -2
  132. data/lib/uniword/validation/rules/document_body_rule.rb +25 -0
  133. data/lib/uniword/validation/rules/document_context.rb +7 -0
  134. data/lib/uniword/validation/rules/empty_paragraphs_rule.rb +27 -0
  135. data/lib/uniword/validation/rules/font_table_signature_rule.rb +0 -2
  136. data/lib/uniword/validation/rules/fonts_rule.rb +0 -2
  137. data/lib/uniword/validation/rules/footnotes_rule.rb +0 -2
  138. data/lib/uniword/validation/rules/headers_footers_rule.rb +0 -2
  139. data/lib/uniword/validation/rules/images_rule.rb +0 -2
  140. data/lib/uniword/validation/rules/mc_ignorable_namespace_rule.rb +0 -2
  141. data/lib/uniword/validation/rules/model_context.rb +32 -0
  142. data/lib/uniword/validation/rules/model_rule.rb +34 -0
  143. data/lib/uniword/validation/rules/numbering_preservation_rule.rb +0 -2
  144. data/lib/uniword/validation/rules/numbering_rule.rb +0 -2
  145. data/lib/uniword/validation/rules/relationship_integrity_rule.rb +0 -1
  146. data/lib/uniword/validation/rules/rsid_rule.rb +0 -2
  147. data/lib/uniword/validation/rules/section_properties_rule.rb +0 -2
  148. data/lib/uniword/validation/rules/settings_rule.rb +0 -2
  149. data/lib/uniword/validation/rules/settings_values_rule.rb +0 -2
  150. data/lib/uniword/validation/rules/style_references_rule.rb +0 -2
  151. data/lib/uniword/validation/rules/table_grid_rule.rb +27 -0
  152. data/lib/uniword/validation/rules/table_properties_rule.rb +27 -0
  153. data/lib/uniword/validation/rules/tables_rule.rb +0 -2
  154. data/lib/uniword/validation/rules/theme_completeness_rule.rb +0 -2
  155. data/lib/uniword/validation/rules/theme_rule.rb +0 -2
  156. data/lib/uniword/validation/rules.rb +61 -23
  157. data/lib/uniword/validation/schema_registry.rb +11 -0
  158. data/lib/uniword/validation/validators/document_semantics_validator.rb +1 -9
  159. data/lib/uniword/validation/validators/xml_schema_validator.rb +46 -2
  160. data/lib/uniword/validation/validators.rb +0 -9
  161. data/lib/uniword/validation/verify_orchestrator.rb +0 -7
  162. data/lib/uniword/validation.rb +7 -2
  163. data/lib/uniword/version.rb +1 -1
  164. data/lib/uniword/vml/imagedata.rb +0 -1
  165. data/lib/uniword/wordprocessingml/attached_template.rb +0 -1
  166. data/lib/uniword/wordprocessingml/deleted_text.rb +1 -1
  167. data/lib/uniword/wordprocessingml/document_root.rb +121 -122
  168. data/lib/uniword/wordprocessingml/document_styling.rb +246 -0
  169. data/lib/uniword/wordprocessingml/endnotes.rb +4 -1
  170. data/lib/uniword/wordprocessingml/font_replacer.rb +147 -0
  171. data/lib/uniword/wordprocessingml/footnotes.rb +4 -1
  172. data/lib/uniword/wordprocessingml/hdr_shape_defaults.rb +0 -1
  173. data/lib/uniword/wordprocessingml/hyperlink.rb +0 -1
  174. data/lib/uniword/wordprocessingml/level.rb +6 -20
  175. data/lib/uniword/wordprocessingml/math_pr.rb +0 -11
  176. data/lib/uniword/wordprocessingml/numbering_definition.rb +16 -8
  177. data/lib/uniword/wordprocessingml/numbering_elements.rb +0 -15
  178. data/lib/uniword/wordprocessingml/page_setup.rb +181 -0
  179. data/lib/uniword/wordprocessingml/paragraph_properties.rb +33 -28
  180. data/lib/uniword/wordprocessingml/rsids.rb +0 -2
  181. data/lib/uniword/wordprocessingml/run.rb +3 -0
  182. data/lib/uniword/wordprocessingml/section_properties.rb +2 -2
  183. data/lib/uniword/wordprocessingml/settings.rb +8 -34
  184. data/lib/uniword/wordprocessingml/shape_defaults.rb +0 -1
  185. data/lib/uniword/wordprocessingml/style.rb +2 -1
  186. data/lib/uniword/wordprocessingml/style_cleanup.rb +198 -0
  187. data/lib/uniword/wordprocessingml/table_cell_properties.rb +6 -6
  188. data/lib/uniword/wordprocessingml/update_fields.rb +27 -0
  189. data/lib/uniword/wordprocessingml/w14_attributes.rb +30 -2
  190. data/lib/uniword/wordprocessingml.rb +5 -2
  191. data/lib/uniword.rb +27 -2
  192. metadata +52 -19
  193. data/config/validation_rules.yml +0 -60
  194. data/config/warning_rules.yml +0 -57
  195. data/lib/uniword/validation/document_validator.rb +0 -272
  196. data/lib/uniword/validation/structural_validator.rb +0 -116
  197. data/lib/uniword/validation/validators/content_type_validator.rb +0 -165
  198. data/lib/uniword/validation/validators/file_structure_validator.rb +0 -104
  199. data/lib/uniword/validation/validators/ooxml_part_validator.rb +0 -128
  200. data/lib/uniword/validation/validators/relationship_validator.rb +0 -147
  201. data/lib/uniword/validation/validators/zip_integrity_validator.rb +0 -110
  202. data/lib/uniword/validators/element_validator.rb +0 -93
  203. data/lib/uniword/validators/paragraph_validator.rb +0 -116
  204. data/lib/uniword/validators/table_validator.rb +0 -134
  205. data/lib/uniword/validators.rb +0 -9
  206. data/lib/uniword/warnings/warning.rb +0 -130
  207. data/lib/uniword/warnings/warning_collector.rb +0 -234
  208. data/lib/uniword/warnings/warning_report.rb +0 -159
  209. data/lib/uniword/warnings.rb +0 -9
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ class PartLoader
6
+ # Fallback loader: preserves ZIP entries no registry definition
7
+ # claims as raw parts (RawPart) on the package, so unmodelled
8
+ # parts (docProps/meta.xml, glossary documents, VBA projects,
9
+ # unmodelled .rels sidecars, ...) round-trip byte-for-byte with
10
+ # their content types and relationships intact.
11
+ #
12
+ # Runs last (load_priority 130 on the :raw_parts definition), so
13
+ # the registry claims every path it models first and this loader
14
+ # carries only the remainder — no double emission. A path is
15
+ # claimed when any PartDefinition matches it (loadable or not),
16
+ # when it is the resolved main document part (or its rels
17
+ # sidecar) at a non-standard location, or when it is a customXml
18
+ # item rels sidecar consumed by CustomXmlLoader.
19
+ #
20
+ # Bytes are re-read from the original ZIP when available (the
21
+ # extracted hash may carry corrupted UTF-8 binary); content
22
+ # types come from the loaded [Content_Types].xml model.
23
+ class RawPartLoader
24
+ # customXml item rels sidecars are consumed by CustomXmlLoader
25
+ # together with their item part (captured group: item index).
26
+ CUSTOM_XML_ITEM_RELS =
27
+ %r{\AcustomXml/_rels/item(\d+)\.xml\.rels\z}
28
+
29
+ # @param context [LoadContext] shared load state
30
+ # @param _definition [Ooxml::PartDefinition] :raw_parts
31
+ # (strategy interface; the loader claims by remainder, not
32
+ # by definition matching)
33
+ # @return [void]
34
+ def load(context, _definition)
35
+ paths = unclaimed_paths(context)
36
+ return if paths.empty?
37
+
38
+ bytes = raw_bytes(context, paths)
39
+ paths.each do |path|
40
+ next unless bytes[path]
41
+
42
+ context.package.raw_parts[path] = build_part(
43
+ context.package, path, bytes[path]
44
+ )
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # @return [Array<String>] ZIP entries claimed by no registry
51
+ # loader, in zip_content order
52
+ def unclaimed_paths(context)
53
+ context.zip_content.keys.reject { |path| claimed?(context, path) }
54
+ end
55
+
56
+ def claimed?(context, path)
57
+ return true if Ooxml::PartRegistry.find_by_path(path)
58
+ return true if path == context.main_document_path
59
+ return true if path == context.main_document_rels_path
60
+
61
+ custom_xml_item_rels?(context, path)
62
+ end
63
+
64
+ # A customXml item rels sidecar is claimed (via CustomXmlItem)
65
+ # when its item part is present in the ZIP.
66
+ def custom_xml_item_rels?(context, path)
67
+ match = CUSTOM_XML_ITEM_RELS.match(path)
68
+ return false unless match
69
+
70
+ context.zip_content.key?("customXml/item#{match[1]}.xml")
71
+ end
72
+
73
+ # @return [RawPart]
74
+ def build_part(package, path, content)
75
+ rel = referencing_relationship(package, path)
76
+ RawPart.new(
77
+ path: path,
78
+ content: content,
79
+ content_type: content_type_for(package, path),
80
+ r_id: rel&.id,
81
+ rel_type: rel&.type,
82
+ )
83
+ end
84
+
85
+ # Bytes for each path: re-read from the original ZIP when
86
+ # available (binary-safe), else the extracted content.
87
+ #
88
+ # @return [Hash] package path => bytes
89
+ def raw_bytes(context, paths)
90
+ unless context.zip_path
91
+ return paths.to_h { |p| [p, context.zip_content[p]] }
92
+ end
93
+
94
+ read_from_zip(context.zip_path, paths)
95
+ end
96
+
97
+ def read_from_zip(zip_path, paths)
98
+ require "zip"
99
+ Zip::File.open(zip_path) do |zip_file|
100
+ paths.to_h do |path|
101
+ entry = zip_file.find_entry(path)
102
+ [path, entry&.get_input_stream&.read]
103
+ end
104
+ end
105
+ end
106
+
107
+ # Content type the source [Content_Types].xml declared for the
108
+ # part: its Override first, else the Default for its extension.
109
+ #
110
+ # @return [String, nil] nil when the source declared neither
111
+ def content_type_for(package, path)
112
+ content_types = package.content_types
113
+ return nil unless content_types
114
+
115
+ override_type(content_types, path) ||
116
+ default_type(content_types, path)
117
+ end
118
+
119
+ def override_type(content_types, path)
120
+ part_name = "/#{path}"
121
+ content_types.overrides.find do |o|
122
+ o.part_name == part_name
123
+ end&.content_type
124
+ end
125
+
126
+ def default_type(content_types, path)
127
+ ext = File.extname(path)[1..]
128
+ return nil unless ext
129
+
130
+ content_types.defaults.find do |d|
131
+ d.extension == ext
132
+ end&.content_type
133
+ end
134
+
135
+ # The first relationship in a modelled rels part whose
136
+ # resolved target is this part, or nil. Recorded as metadata
137
+ # only; the rel itself stays in its rels collection.
138
+ def referencing_relationship(package, path)
139
+ rels_collections(package).each do |rels, base_dir|
140
+ rel = rels&.relationships&.find do |r|
141
+ resolve_target(base_dir, r.target.to_s) == path
142
+ end
143
+ return rel if rel
144
+ end
145
+ nil
146
+ end
147
+
148
+ # [rels collection, base directory] pairs mirroring the rels
149
+ # parts the package models.
150
+ def rels_collections(package)
151
+ [
152
+ [package.package_rels, ""],
153
+ [package.document_rels, "word"],
154
+ [package.settings_rels, "word"],
155
+ [package.theme_rels, "word/theme"],
156
+ [package.footnotes_rels, "word"],
157
+ [package.endnotes_rels, "word"],
158
+ ]
159
+ end
160
+
161
+ # Resolve a relationship target to a normalized package path,
162
+ # handling package-absolute (leading slash) targets and "..".
163
+ def resolve_target(base_dir, target)
164
+ return target[1..] if target.start_with?("/")
165
+
166
+ normalize_package_path(File.join(base_dir, target))
167
+ end
168
+
169
+ # Lexically normalize a package-relative path (resolve "." and
170
+ # ".." segments).
171
+ def normalize_package_path(path)
172
+ segments = []
173
+ path.split("/").each do |segment|
174
+ next if segment.empty? || segment == "."
175
+
176
+ segment == ".." ? segments.pop : segments << segment
177
+ end
178
+ segments.join("/")
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ class PartLoader
6
+ # Loads theme media files (word/theme/media/*) into the parsed
7
+ # theme's media_files. Runs only when a theme part was loaded —
8
+ # theme media without a theme is ignored, as before.
9
+ class ThemeMediaLoader
10
+ # @param context [LoadContext] shared load state
11
+ # @param definition [Ooxml::PartDefinition] :theme_media
12
+ # @return [void]
13
+ def load(context, definition)
14
+ theme = context.package.theme
15
+ return unless theme
16
+
17
+ media = extract_media(context, definition)
18
+ theme.media_files = media if media.any?
19
+ end
20
+
21
+ private
22
+
23
+ # @return [Hash] filename => Themes::MediaFile
24
+ def extract_media(context, definition)
25
+ prefix = definition.pattern_prefix
26
+ paths = context.matching_paths(definition)
27
+ paths.each_with_object({}) do |path, media|
28
+ filename = path.delete_prefix(prefix)
29
+ media[filename] = Uniword::Themes::MediaFile.new(
30
+ filename: filename,
31
+ content: context.zip_content[path],
32
+ source_path: path,
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ class PartLoader
6
+ # Loads fixed-path XML parts: parses the part's XML with the
7
+ # definition's loader_model and assigns the model to the
8
+ # definition's package attribute. Covers every part whose load
9
+ # is "parse one part into one attribute": the content types,
10
+ # all rels parts, docProps, the main document, styles,
11
+ # numbering, settings, font table, web settings, theme,
12
+ # footnotes, endnotes, and comments.
13
+ #
14
+ # Definitions with a path_resolution rule resolve their path
15
+ # dynamically (main document via the package officeDocument
16
+ # relationship; its rels part as the sidecar of the resolved
17
+ # path), falling back to the definition's fixed path.
18
+ class XmlModelLoader
19
+ # @param context [LoadContext] shared load state
20
+ # @param definition [Ooxml::PartDefinition] part to load
21
+ # @return [void]
22
+ def load(context, definition)
23
+ content = read_content(context, definition)
24
+ return unless content
25
+
26
+ model = definition.loader_model.from_xml(content)
27
+ context.package.method(:"#{definition.package_attribute}=")
28
+ .call(model)
29
+ end
30
+
31
+ private
32
+
33
+ # Part content from the first candidate path present in the
34
+ # ZIP, preserving the historic resolved-then-fixed fallback.
35
+ def read_content(context, definition)
36
+ candidate_paths(context, definition).each do |path|
37
+ content = context.zip_content[path]
38
+ return content if content
39
+ end
40
+ nil
41
+ end
42
+
43
+ def candidate_paths(context, definition)
44
+ case definition.path_resolution
45
+ when :office_document
46
+ [context.main_document_path, definition.path].compact
47
+ when :office_document_rels
48
+ [context.main_document_rels_path, definition.path].compact
49
+ else
50
+ [definition.path]
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ # Registry-driven loader for DOCX package parts.
6
+ #
7
+ # Replaces the former hand-written per-part sequence in
8
+ # Package.from_zip_content: parts load by iterating
9
+ # Ooxml::PartRegistry.loadable (ordered by
10
+ # Ooxml::PartDefinition#load_priority) and dispatching each
11
+ # definition to the loader strategy registered under
12
+ # Ooxml::PartDefinition#loader.
13
+ #
14
+ # Open/closed: a new part kind adds a strategy class plus a
15
+ # register_loader call and a PartRegistry registration — this loop
16
+ # never changes.
17
+ #
18
+ # @example Load parts into a package
19
+ # package = Package.new
20
+ # PartLoader.load(zip_content, package, zip_path: "doc.docx")
21
+ class PartLoader
22
+ autoload :LoadContext, "#{__dir__}/part_loader/load_context"
23
+ autoload :XmlModelLoader, "#{__dir__}/part_loader/xml_model_loader"
24
+ autoload :CustomXmlLoader,
25
+ "#{__dir__}/part_loader/custom_xml_loader"
26
+ autoload :HeaderFooterLoader,
27
+ "#{__dir__}/part_loader/header_footer_loader"
28
+ autoload :ChartLoader, "#{__dir__}/part_loader/chart_loader"
29
+ autoload :ImageLoader, "#{__dir__}/part_loader/image_loader"
30
+ autoload :EmbeddingLoader,
31
+ "#{__dir__}/part_loader/embedding_loader"
32
+ autoload :ThemeMediaLoader,
33
+ "#{__dir__}/part_loader/theme_media_loader"
34
+ autoload :RawPartLoader,
35
+ "#{__dir__}/part_loader/raw_part_loader"
36
+
37
+ class << self
38
+ # Load every registered part from extracted ZIP content into
39
+ # the package.
40
+ #
41
+ # @param zip_content [Hash] extracted ZIP entries
42
+ # (package path => content)
43
+ # @param package [Package] package to populate
44
+ # @param zip_path [String, nil] original ZIP path for binary
45
+ # re-extraction (image bytes)
46
+ # @return [Package] the populated package
47
+ def load(zip_content, package, zip_path: nil)
48
+ context = LoadContext.new(zip_content: zip_content,
49
+ package: package, zip_path: zip_path)
50
+ Ooxml::PartRegistry.loadable.each do |definition|
51
+ loader_for(definition.loader).load(context, definition)
52
+ end
53
+ package
54
+ end
55
+
56
+ # Register a loader strategy under a key.
57
+ #
58
+ # @param key [Symbol] PartDefinition#loader value
59
+ # @param strategy [#load] strategy responding to
60
+ # +load(context, definition)+
61
+ # @return [Object] the registered strategy
62
+ def register_loader(key, strategy)
63
+ loaders[key.to_sym] = strategy
64
+ end
65
+
66
+ # @param key [Symbol] PartDefinition#loader value
67
+ # @return [#load] the registered strategy
68
+ # @raise [ArgumentError] when no strategy is registered
69
+ def loader_for(key)
70
+ loaders.fetch(key.to_sym) do
71
+ raise ArgumentError, "unknown part loader: #{key.inspect}"
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def loaders
78
+ @loaders ||= {}
79
+ end
80
+ end
81
+
82
+ register_loader(:xml_model, XmlModelLoader.new)
83
+ register_loader(:custom_xml, CustomXmlLoader.new)
84
+ register_loader(:header_footer, HeaderFooterLoader.new)
85
+ register_loader(:chart, ChartLoader.new)
86
+ register_loader(:image, ImageLoader.new)
87
+ register_loader(:embedding, EmbeddingLoader.new)
88
+ register_loader(:theme_media, ThemeMediaLoader.new)
89
+ register_loader(:raw, RawPartLoader.new)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ # A package part no Ooxml::PartDefinition models, carried verbatim
6
+ # for round-trip fidelity (e.g. docProps/meta.xml, glossary
7
+ # documents, VBA projects, unmodelled .rels sidecars).
8
+ #
9
+ # Unlike document-scoped parts (charts, images, embeddings), a raw
10
+ # part keeps its full package-relative +path+ — raw parts live
11
+ # anywhere in the package, not only under word/. Content is the
12
+ # exact source byte stream (binary-safe; never parsed or
13
+ # re-encoded), plus the content type the source
14
+ # [Content_Types].xml declared for it (Override first, then
15
+ # Default by extension; nil when the source declared neither).
16
+ #
17
+ # The relationship referencing the part (when one exists in a
18
+ # modelled .rels part) is recorded as +r_id+/+rel_type+ metadata
19
+ # for introspection parity with other part kinds; emission never
20
+ # uses it — relationships re-serialize from the loaded rels
21
+ # models, so the source rel survives untouched.
22
+ #
23
+ # Raw parts are a fallback: PartLoader claims every registry-known
24
+ # path first and RawPartLoader carries only the remainder, so a
25
+ # part is never emitted twice.
26
+ #
27
+ # @example
28
+ # part = RawPart.new(
29
+ # path: "docProps/meta.xml",
30
+ # content: bytes,
31
+ # content_type: "application/xml",
32
+ # )
33
+ # part.package_paths # => ["docProps/meta.xml"]
34
+ class RawPart < Part
35
+ # @return [String, nil] package-relative path, verbatim
36
+ attr_writer :path
37
+
38
+ # @param path [String, nil] package-relative path
39
+ # (e.g. "docProps/meta.xml")
40
+ # @param content [String, nil] raw part bytes (binary-safe)
41
+ # @param content_type [String, nil] content type declared by the
42
+ # source [Content_Types].xml
43
+ # @param r_id [String, nil] id of the relationship referencing
44
+ # this part (metadata only)
45
+ # @param rel_type [String, nil] type of the relationship
46
+ # referencing this part (metadata only)
47
+ def initialize(path: nil, content: nil, content_type: nil,
48
+ r_id: nil, rel_type: nil)
49
+ super(content: content, content_type: content_type,
50
+ r_id: r_id, rel_type: rel_type)
51
+ @path = path
52
+ end
53
+
54
+ # Wrap a raw-hash entry ({ path:, content:, content_type: })
55
+ # into a RawPart. Used by PartCollection to normalize hash
56
+ # assignments.
57
+ #
58
+ # @param hash [Hash] raw hash entry
59
+ # @return [RawPart]
60
+ def self.from_hash(hash)
61
+ new(path: hash[:path], content: hash[:content],
62
+ content_type: hash[:content_type],
63
+ r_id: hash[:r_id], rel_type: hash[:rel_type])
64
+ end
65
+
66
+ # Package-relative path of the emitted part, carried verbatim
67
+ # (overrides Part's word/-prefixed target derivation).
68
+ #
69
+ # @return [String, nil] e.g. "word/glossary/document.xml"
70
+ def path
71
+ @path
72
+ end
73
+ end
74
+ end
75
+ end
@@ -21,7 +21,8 @@ module Uniword
21
21
  doc_grid: Wordprocessingml::PageDefaults.default_doc_grid,
22
22
  )
23
23
  record_fix(FixCodes::SECTION_PROPERTIES_DEFAULTED,
24
- "Added default section properties with US Letter page size")
24
+ "Added default section properties with US Letter page size",
25
+ part: "word/document.xml")
25
26
  return
26
27
  end
27
28
 
@@ -41,70 +42,82 @@ module Uniword
41
42
  end
42
43
  if fixed
43
44
  record_fix(FixCodes::SECTION_PROPERTIES_DEFAULTED,
44
- "Filled missing pgSz/pgMar/cols in existing section properties")
45
+ "Filled missing pgSz/pgMar/cols in existing section properties",
46
+ part: "word/document.xml")
45
47
  end
46
48
  end
47
49
 
48
50
  def reconcile_headers_footers
51
+ parts = package.document&.header_footer_parts
49
52
  ignorable = Ooxml::Types::McIgnorable.new(FULL_IGNORABLE)
50
- set_header_footer_ignorable(package.document&.headers, ignorable)
51
- set_header_footer_ignorable(package.document&.footers, ignorable)
52
53
 
53
- unless allocator
54
- rsid = generate_rsid
55
- backfill_header_footer_parts(package.document&.headers, rsid, "hdr")
56
- backfill_header_footer_parts(package.document&.footers, rsid, "ftr")
54
+ parts&.each do |part|
55
+ next unless part.content
56
+ next if part.loaded?
57
57
 
58
- parts = package.document&.header_footer_parts
59
- parts&.each_with_index do |part, pidx|
60
- backfill_paragraphs(part[:content].paragraphs, rsid, "hfp:#{pidx}")
61
- end
58
+ part.content.mc_ignorable = ignorable
59
+ end
60
+
61
+ unless builder_managed?
62
+ backfill_header_footer_paragraphs(parts, generate_rsid)
62
63
  end
63
64
 
64
- wire_builder_headers_footers
65
+ wire_header_footer_parts
65
66
  end
66
67
 
67
68
  private
68
69
 
69
- def set_header_footer_ignorable(parts, ignorable)
70
- return unless parts
71
-
72
- parts.each_value { |part| part.mc_ignorable = ignorable }
73
- end
74
-
75
- def backfill_header_footer_parts(parts, rsid, prefix)
70
+ # Assign rsid/paraId defaults to header/footer paragraphs
71
+ # (legacy, non-builder-managed path only). Seeds preserve the
72
+ # historic per-kind ("hdr:N"/"ftr:N") and per-store ("hfp:N")
73
+ # schemes so generated IDs stay stable across the unified store.
74
+ def backfill_header_footer_paragraphs(parts, rsid)
76
75
  return unless parts
77
76
 
78
- parts.each_with_index do |(_, part), pidx|
79
- backfill_paragraphs(part.paragraphs, rsid, "#{prefix}:#{pidx}")
77
+ fresh_counts = Hash.new(0)
78
+ parts.each_with_index do |part, pidx|
79
+ next unless part.content
80
+
81
+ if part.loaded?
82
+ backfill_paragraphs(part.content.paragraphs, rsid, "hfp:#{pidx}")
83
+ else
84
+ abbrev = part.kind == :footer ? "ftr" : "hdr"
85
+ idx = fresh_counts[part.kind]
86
+ fresh_counts[part.kind] += 1
87
+ backfill_paragraphs(part.content.paragraphs, rsid,
88
+ "#{abbrev}:#{idx}")
89
+ end
80
90
  end
81
91
  end
82
92
 
83
- HEADER_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"
84
- FOOTER_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"
85
-
86
- # Wire builder-path headers/footers into document_rels and sectPr
87
- # during reconciliation so referential integrity checks see valid rIds.
88
- def wire_builder_headers_footers
93
+ # Wire builder-added (fresh) header/footer parts into
94
+ # document_rels and sectPr during reconciliation — the single
95
+ # wiring implementation. Loaded parts keep the relationships
96
+ # and section references they arrived with. rIds come from the
97
+ # allocator, the single rId authority.
98
+ def wire_header_footer_parts
89
99
  doc = package.document
90
100
  return unless doc&.body
91
- return unless doc.headers || doc.footers
101
+
102
+ parts = doc.header_footer_parts
103
+ return if parts.nil? || parts.empty?
92
104
 
93
105
  rels = package.document_rels
94
106
  return unless rels
95
107
 
96
- counter = 0
97
- wire_parts_to_rels(doc.headers, rels, HEADER_REL_TYPE,
98
- "header", counter) do |type, r_id|
99
- wire_sect_pr_reference(doc, :header, type, r_id)
100
- end
108
+ wired = false
109
+ parts.each do |part|
110
+ next if part.loaded? || part.target.nil?
101
111
 
102
- counter = 0
103
- wire_parts_to_rels(doc.footers, rels, FOOTER_REL_TYPE,
104
- "footer", counter) do |type, r_id|
105
- wire_sect_pr_reference(doc, :footer, type, r_id)
112
+ r_id = allocator.alloc_rid(target: part.target,
113
+ type: part.rel_type)
114
+ part.r_id = r_id
115
+ wire_sect_pr_reference(doc, part.kind, part.type, r_id) if part.type
116
+ wired = true
106
117
  end
107
118
 
119
+ return unless wired
120
+
108
121
  # Clear element_order so header/footer references serialize correctly.
109
122
  # Ordered mode only outputs elements in element_order; the template's
110
123
  # order may not include newly-added references.
@@ -112,33 +125,6 @@ module Uniword
112
125
  sect_pr.element_order = nil if sect_pr&.element_order
113
126
  end
114
127
 
115
- def wire_parts_to_rels(parts, rels, rel_type, file_prefix, counter)
116
- return unless parts && !parts.empty?
117
-
118
- parts.each_key do |type|
119
- counter += 1
120
- target = "#{file_prefix}#{counter}.xml"
121
- r_id = if allocator
122
- allocator.alloc_rid(target: target, type: rel_type)
123
- else
124
- find_or_create_rel(rels, rel_type, target)
125
- end
126
- yield type, r_id
127
- end
128
- end
129
-
130
- def find_or_create_rel(rels, rel_type, target)
131
- existing = rels.relationships.find { |r| r.target == target }
132
- return existing.id if existing
133
-
134
- r_id = Ooxml::Relationships::PackageRelationships
135
- .next_available_rid(rels)
136
- rels.relationships << Ooxml::Relationships::Relationship.new(
137
- id: r_id, type: rel_type, target: target,
138
- )
139
- r_id
140
- end
141
-
142
128
  def wire_sect_pr_reference(doc, kind, type, r_id)
143
129
  sect_pr = doc.body.section_properties
144
130
  return unless sect_pr
@@ -169,14 +155,15 @@ module Uniword
169
155
  doc = package.document
170
156
  set_mc_ignorable(doc, prefixes: FULL_IGNORABLE)
171
157
 
172
- record_fix(FixCodes::MC_IGNORABLE, "Added mc:Ignorable to document body")
158
+ record_fix(FixCodes::MC_IGNORABLE, "Added mc:Ignorable to document body",
159
+ part: "word/document.xml")
173
160
 
174
161
  body = doc.body
175
162
  rsid = generate_rsid
176
163
 
177
164
  body.paragraphs.each_with_index do |para, idx|
178
165
  strip_empty_runs(para)
179
- next if allocator
166
+ next if builder_managed?
180
167
 
181
168
  para.rsid_r ||= rsid
182
169
  para.rsid_r_default ||= "00000000"
@@ -184,7 +171,11 @@ module Uniword
184
171
  para.text_id ||= "77777777"
185
172
  end
186
173
 
187
- record_fix(FixCodes::PARAGRAPH_BACKFILL, "Assigned rsid and paraId to paragraphs") unless allocator
174
+ unless builder_managed?
175
+ record_fix(FixCodes::PARAGRAPH_BACKFILL,
176
+ "Assigned rsid and paraId to paragraphs",
177
+ part: "word/document.xml")
178
+ end
188
179
 
189
180
  sect_pr = body.section_properties
190
181
  return unless sect_pr
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ class Reconciler
6
+ # A single repair applied by the Reconciler during save.
7
+ #
8
+ # Immutable value object exposed as the reconciliation report via
9
+ # `Package#applied_fixes` after a save. The Reconciler stays the only
10
+ # mutating pass; this report is a by-product value, not a global.
11
+ #
12
+ # @example Inspect fixes after save
13
+ # package.to_file("out.docx")
14
+ # package.applied_fixes.each do |fix|
15
+ # puts "#{fix.code} [#{fix.part}] #{fix.message}"
16
+ # end
17
+ class Fix
18
+ # @return [String] Fix code (see Reconciler::FixCodes)
19
+ attr_reader :code
20
+
21
+ # @return [String] Human-readable description of the repair
22
+ attr_reader :message
23
+
24
+ # @return [String, nil] Package part the repair applies to
25
+ attr_reader :part
26
+
27
+ # Create a fix record.
28
+ #
29
+ # @param code [String] Fix code (a FixCodes constant value)
30
+ # @param message [String] Human-readable description of the repair
31
+ # @param part [String, nil] Package part the repair applies to
32
+ def initialize(code:, message:, part: nil)
33
+ @code = code
34
+ @message = message
35
+ @part = part
36
+ end
37
+
38
+ # @return [String] Compact single-line representation
39
+ def to_s
40
+ location = part ? " [#{part}]" : ""
41
+ "#{code}#{location}: #{message}"
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end