uniword 1.2.5 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +223 -0
  3. data/CONTRIBUTING.md +1 -1
  4. data/config/ooxml/schemas/shared_types.yml +5 -4
  5. data/data/schemas/ecma/dc.xsd +118 -0
  6. data/data/schemas/ecma/dcmitype.xsd +50 -0
  7. data/data/schemas/ecma/dcterms.xsd +331 -0
  8. data/data/schemas/ecma/opc-coreProperties.xsd +2 -2
  9. data/data/schemas/ecma/xml.xsd +117 -0
  10. data/lib/uniword/builder/bibliography_builder.rb +1 -1
  11. data/lib/uniword/builder/chart_builder.rb +19 -9
  12. data/lib/uniword/builder/comment_anchorer.rb +221 -0
  13. data/lib/uniword/builder/document_builder.rb +97 -6
  14. data/lib/uniword/builder/image_builder.rb +6 -5
  15. data/lib/uniword/builder/run_utils.rb +2 -0
  16. data/lib/uniword/builder.rb +1 -0
  17. data/lib/uniword/cli/fonts_cli.rb +52 -0
  18. data/lib/uniword/cli/main.rb +110 -11
  19. data/lib/uniword/cli/page_cli.rb +85 -0
  20. data/lib/uniword/cli/styles_cli.rb +204 -0
  21. data/lib/uniword/cli/theme_cli.rb +88 -0
  22. data/lib/uniword/cli/toc_cli.rb +22 -2
  23. data/lib/uniword/comment.rb +20 -15
  24. data/lib/uniword/comment_range.rb +3 -3
  25. data/lib/uniword/comments_part.rb +51 -19
  26. data/lib/uniword/configuration/configuration_loader.rb +5 -3
  27. data/lib/uniword/configuration.rb +93 -1
  28. data/lib/uniword/content_types.rb +19 -39
  29. data/lib/uniword/document_factory.rb +9 -1
  30. data/lib/uniword/document_writer.rb +16 -5
  31. data/lib/uniword/docx/chart_part.rb +34 -0
  32. data/lib/uniword/docx/custom_xml_item.rb +83 -0
  33. data/lib/uniword/docx/header_footer_part.rb +140 -0
  34. data/lib/uniword/docx/header_footer_part_collection.rb +135 -0
  35. data/lib/uniword/docx/header_footer_view.rb +225 -0
  36. data/lib/uniword/docx/id_allocator.rb +130 -36
  37. data/lib/uniword/docx/package.rb +223 -35
  38. data/lib/uniword/docx/package_defaults.rb +58 -78
  39. data/lib/uniword/docx/package_integrity_checker.rb +312 -0
  40. data/lib/uniword/docx/package_serialization.rb +103 -231
  41. data/lib/uniword/docx/part.rb +106 -0
  42. data/lib/uniword/docx/part_collection.rb +123 -0
  43. data/lib/uniword/docx/reconciler/body.rb +59 -68
  44. data/lib/uniword/docx/reconciler/fix.rb +46 -0
  45. data/lib/uniword/docx/reconciler/fix_codes.rb +37 -30
  46. data/lib/uniword/docx/reconciler/helpers.rb +9 -1
  47. data/lib/uniword/docx/reconciler/notes.rb +17 -8
  48. data/lib/uniword/docx/reconciler/package_structure.rb +169 -221
  49. data/lib/uniword/docx/reconciler/parts.rb +36 -13
  50. data/lib/uniword/docx/reconciler/referential_integrity.rb +297 -123
  51. data/lib/uniword/docx/reconciler/tables.rb +78 -16
  52. data/lib/uniword/docx/reconciler/theme.rb +7 -4
  53. data/lib/uniword/docx/reconciler.rb +36 -14
  54. data/lib/uniword/docx.rb +10 -0
  55. data/lib/uniword/drawingml/blip.rb +0 -1
  56. data/lib/uniword/drawingml/color_scheme.rb +5 -6
  57. data/lib/uniword/drawingml/font_scheme.rb +4 -8
  58. data/lib/uniword/drawingml/theme.rb +12 -6
  59. data/lib/uniword/errors.rb +8 -1
  60. data/lib/uniword/footer.rb +4 -0
  61. data/lib/uniword/header.rb +4 -0
  62. data/lib/uniword/hyperlink.rb +1 -1
  63. data/lib/uniword/image.rb +6 -6
  64. data/lib/uniword/lazy_loader.rb +1 -2
  65. data/lib/uniword/model_attribute_access.rb +20 -4
  66. data/lib/uniword/ooxml/dotx_package.rb +42 -2
  67. data/lib/uniword/ooxml/part_definition.rb +171 -0
  68. data/lib/uniword/ooxml/part_registry.rb +311 -0
  69. data/lib/uniword/ooxml/relationships/image_relationship.rb +1 -1
  70. data/lib/uniword/ooxml/relationships/package_relationships.rb +20 -22
  71. data/lib/uniword/ooxml/schema/element_serializer.rb +8 -7
  72. data/lib/uniword/ooxml/types/hex_color_value.rb +38 -0
  73. data/lib/uniword/ooxml/types/ooxml_boolean.rb +27 -14
  74. data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +21 -16
  75. data/lib/uniword/ooxml/types/theme_color_value.rb +22 -0
  76. data/lib/uniword/ooxml/types/unsigned_decimal_number.rb +30 -0
  77. data/lib/uniword/ooxml/types.rb +10 -0
  78. data/lib/uniword/ooxml.rb +2 -0
  79. data/lib/uniword/properties/alignment.rb +8 -3
  80. data/lib/uniword/properties/border.rb +52 -6
  81. data/lib/uniword/properties/cell_vertical_align.rb +4 -1
  82. data/lib/uniword/properties/color_value.rb +8 -3
  83. data/lib/uniword/properties/highlight.rb +9 -5
  84. data/lib/uniword/properties/shading.rb +4 -3
  85. data/lib/uniword/properties/tab_stop.rb +6 -1
  86. data/lib/uniword/properties/table_justification.rb +4 -1
  87. data/lib/uniword/properties/underline.rb +4 -2
  88. data/lib/uniword/properties/vertical_align.rb +6 -3
  89. data/lib/uniword/review/review_manager.rb +23 -9
  90. data/lib/uniword/revision.rb +9 -9
  91. data/lib/uniword/schema/model_generator.rb +31 -15
  92. data/lib/uniword/shared_types/hex_color.rb +4 -1
  93. data/lib/uniword/shared_types/pixel_measure.rb +4 -1
  94. data/lib/uniword/shared_types/point_measure.rb +4 -1
  95. data/lib/uniword/shared_types/text_alignment.rb +6 -1
  96. data/lib/uniword/shared_types/twips_measure.rb +4 -1
  97. data/lib/uniword/spreadsheetml/phonetic_pr.rb +1 -1
  98. data/lib/uniword/spreadsheetml/shared_string_table.rb +2 -2
  99. data/lib/uniword/spreadsheetml/table_formula.rb +1 -1
  100. data/lib/uniword/template/variable_resolver.rb +40 -7
  101. data/lib/uniword/themes/theme_transformation.rb +55 -41
  102. data/lib/uniword/toc/toc_generator.rb +2 -0
  103. data/lib/uniword/validation/engine.rb +35 -0
  104. data/lib/uniword/validation/link_checker.rb +1 -1
  105. data/lib/uniword/validation/opc_validator.rb +0 -1
  106. data/lib/uniword/validation/report/layer_result.rb +0 -1
  107. data/lib/uniword/validation/report/terminal_formatter.rb +0 -1
  108. data/lib/uniword/validation/report/verification_report.rb +0 -2
  109. data/lib/uniword/validation/report.rb +20 -0
  110. data/lib/uniword/validation/rules/base.rb +10 -2
  111. data/lib/uniword/validation/rules/bookmark_pairing_rule.rb +38 -0
  112. data/lib/uniword/validation/rules/bookmark_uniqueness_rule.rb +48 -0
  113. data/lib/uniword/validation/rules/bookmarks_rule.rb +0 -2
  114. data/lib/uniword/validation/rules/content_types_coverage_rule.rb +0 -2
  115. data/lib/uniword/validation/rules/core_properties_namespace_rule.rb +0 -2
  116. data/lib/uniword/validation/rules/document_body_rule.rb +25 -0
  117. data/lib/uniword/validation/rules/document_context.rb +7 -0
  118. data/lib/uniword/validation/rules/empty_paragraphs_rule.rb +27 -0
  119. data/lib/uniword/validation/rules/font_table_signature_rule.rb +0 -2
  120. data/lib/uniword/validation/rules/fonts_rule.rb +0 -2
  121. data/lib/uniword/validation/rules/footnotes_rule.rb +0 -2
  122. data/lib/uniword/validation/rules/headers_footers_rule.rb +0 -2
  123. data/lib/uniword/validation/rules/images_rule.rb +0 -2
  124. data/lib/uniword/validation/rules/mc_ignorable_namespace_rule.rb +0 -2
  125. data/lib/uniword/validation/rules/model_context.rb +32 -0
  126. data/lib/uniword/validation/rules/model_rule.rb +34 -0
  127. data/lib/uniword/validation/rules/numbering_preservation_rule.rb +0 -2
  128. data/lib/uniword/validation/rules/numbering_rule.rb +0 -2
  129. data/lib/uniword/validation/rules/relationship_integrity_rule.rb +0 -1
  130. data/lib/uniword/validation/rules/rsid_rule.rb +0 -2
  131. data/lib/uniword/validation/rules/section_properties_rule.rb +0 -2
  132. data/lib/uniword/validation/rules/settings_rule.rb +0 -2
  133. data/lib/uniword/validation/rules/settings_values_rule.rb +0 -2
  134. data/lib/uniword/validation/rules/style_references_rule.rb +0 -2
  135. data/lib/uniword/validation/rules/table_grid_rule.rb +27 -0
  136. data/lib/uniword/validation/rules/table_properties_rule.rb +27 -0
  137. data/lib/uniword/validation/rules/tables_rule.rb +0 -2
  138. data/lib/uniword/validation/rules/theme_completeness_rule.rb +0 -2
  139. data/lib/uniword/validation/rules/theme_rule.rb +0 -2
  140. data/lib/uniword/validation/rules.rb +61 -23
  141. data/lib/uniword/validation/schema_registry.rb +11 -0
  142. data/lib/uniword/validation/validators/document_semantics_validator.rb +1 -9
  143. data/lib/uniword/validation/validators/xml_schema_validator.rb +0 -2
  144. data/lib/uniword/validation/validators.rb +0 -9
  145. data/lib/uniword/validation/verify_orchestrator.rb +0 -7
  146. data/lib/uniword/validation.rb +7 -2
  147. data/lib/uniword/version.rb +1 -1
  148. data/lib/uniword/vml/imagedata.rb +0 -1
  149. data/lib/uniword/wordprocessingml/attached_template.rb +0 -1
  150. data/lib/uniword/wordprocessingml/deleted_text.rb +1 -1
  151. data/lib/uniword/wordprocessingml/document_root.rb +211 -21
  152. data/lib/uniword/wordprocessingml/endnotes.rb +4 -0
  153. data/lib/uniword/wordprocessingml/font_replacer.rb +147 -0
  154. data/lib/uniword/wordprocessingml/footnotes.rb +4 -0
  155. data/lib/uniword/wordprocessingml/hdr_shape_defaults.rb +0 -1
  156. data/lib/uniword/wordprocessingml/hyperlink.rb +0 -1
  157. data/lib/uniword/wordprocessingml/level.rb +6 -20
  158. data/lib/uniword/wordprocessingml/math_pr.rb +0 -11
  159. data/lib/uniword/wordprocessingml/numbering_definition.rb +16 -8
  160. data/lib/uniword/wordprocessingml/page_setup.rb +181 -0
  161. data/lib/uniword/wordprocessingml/paragraph_properties.rb +33 -28
  162. data/lib/uniword/wordprocessingml/rsids.rb +0 -2
  163. data/lib/uniword/wordprocessingml/run.rb +3 -0
  164. data/lib/uniword/wordprocessingml/section_properties.rb +2 -2
  165. data/lib/uniword/wordprocessingml/settings.rb +8 -34
  166. data/lib/uniword/wordprocessingml/shape_defaults.rb +0 -1
  167. data/lib/uniword/wordprocessingml/style.rb +2 -1
  168. data/lib/uniword/wordprocessingml/style_cleanup.rb +198 -0
  169. data/lib/uniword/wordprocessingml/table_cell_properties.rb +6 -6
  170. data/lib/uniword/wordprocessingml/update_fields.rb +27 -0
  171. data/lib/uniword/wordprocessingml/w14_attributes.rb +30 -2
  172. data/lib/uniword/wordprocessingml.rb +4 -0
  173. data/lib/uniword.rb +27 -2
  174. metadata +38 -19
  175. data/config/validation_rules.yml +0 -60
  176. data/config/warning_rules.yml +0 -57
  177. data/lib/uniword/validation/document_validator.rb +0 -272
  178. data/lib/uniword/validation/structural_validator.rb +0 -116
  179. data/lib/uniword/validation/validators/content_type_validator.rb +0 -165
  180. data/lib/uniword/validation/validators/file_structure_validator.rb +0 -104
  181. data/lib/uniword/validation/validators/ooxml_part_validator.rb +0 -128
  182. data/lib/uniword/validation/validators/relationship_validator.rb +0 -147
  183. data/lib/uniword/validation/validators/zip_integrity_validator.rb +0 -110
  184. data/lib/uniword/validators/element_validator.rb +0 -93
  185. data/lib/uniword/validators/paragraph_validator.rb +0 -116
  186. data/lib/uniword/validators/table_validator.rb +0 -134
  187. data/lib/uniword/validators.rb +0 -9
  188. data/lib/uniword/warnings/warning.rb +0 -130
  189. data/lib/uniword/warnings/warning_collector.rb +0 -234
  190. data/lib/uniword/warnings/warning_report.rb +0 -159
  191. data/lib/uniword/warnings.rb +0 -9
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Ooxml
5
+ # Declarative description of one OOXML package part kind.
6
+ #
7
+ # A PartDefinition is the single source of truth for a part's
8
+ # package path, content type, relationship type, and the form its
9
+ # [Content_Types].xml entry takes (Default by extension or Override
10
+ # by part name). Instances are immutable value objects registered
11
+ # in {PartRegistry}; consumers derive every literal from them.
12
+ #
13
+ # Parts with instance-numbered paths (headers, footers, charts,
14
+ # customXml items) carry a +path_pattern+/+target_pattern+ with
15
+ # +format+-style placeholders (e.g. "word/header%<counter>d.xml")
16
+ # instead of a fixed path.
17
+ #
18
+ # @example Fixed part
19
+ # PartDefinition.new(
20
+ # key: :styles, path: "word/styles.xml", target: "styles.xml",
21
+ # content_type: "...styles+xml",
22
+ # rel_type: ".../relationships/styles",
23
+ # required: true, kind: :override, rels_scope: :document,
24
+ # )
25
+ class PartDefinition
26
+ # @return [Symbol] unique registry key (e.g. :styles)
27
+ attr_reader :key
28
+
29
+ # @return [String, nil] package-relative path ("word/styles.xml")
30
+ attr_reader :path
31
+
32
+ # @return [String, nil] path template for instance-numbered parts
33
+ attr_reader :path_pattern
34
+
35
+ # @return [String, nil] relationship Target as written in .rels
36
+ # (relative to the owning part; defaults to +path+)
37
+ attr_reader :target
38
+
39
+ # @return [String, nil] Target template for numbered parts
40
+ attr_reader :target_pattern
41
+
42
+ # @return [String, nil] MIME content type (nil when dynamic,
43
+ # e.g. images whose type depends on the file extension)
44
+ attr_reader :content_type
45
+
46
+ # @return [String, nil] relationship type URI (nil when the part
47
+ # is not referenced by a relationship)
48
+ attr_reader :rel_type
49
+
50
+ # @return [String, nil] file extension for Default entries
51
+ attr_reader :extension
52
+
53
+ # @return [Boolean] whether a minimal valid package carries it
54
+ attr_reader :required
55
+
56
+ # @return [Symbol] :override, :default, or :none (no
57
+ # [Content_Types].xml entry of its own)
58
+ attr_reader :kind
59
+
60
+ # @return [Symbol, nil] :package or :document — which .rels part
61
+ # the relationship lives in (nil when +rel_type+ is nil)
62
+ attr_reader :rels_scope
63
+
64
+ # @return [Boolean] whether ContentTypes.generate includes it in
65
+ # the comprehensive [Content_Types].xml for new DOCX packages
66
+ attr_reader :standard
67
+
68
+ # rubocop:disable Metrics/ParameterLists
69
+ def initialize(key:, kind:, path: nil, path_pattern: nil,
70
+ target: nil, target_pattern: nil,
71
+ content_type: nil, rel_type: nil, extension: nil,
72
+ required: false, rels_scope: nil, standard: false)
73
+ @key = key.to_sym
74
+ @kind = kind
75
+ @path = path
76
+ @path_pattern = path_pattern
77
+ @target = target || path
78
+ @target_pattern = target_pattern
79
+ @content_type = content_type
80
+ @rel_type = rel_type
81
+ @extension = extension
82
+ @required = required
83
+ @rels_scope = rels_scope
84
+ @standard = standard
85
+ end
86
+ # rubocop:enable Metrics/ParameterLists
87
+
88
+ # @return [Boolean] true for [Content_Types].xml Override entries
89
+ def override?
90
+ kind == :override
91
+ end
92
+
93
+ # @return [Boolean] true for [Content_Types].xml Default entries
94
+ def default?
95
+ kind == :default
96
+ end
97
+
98
+ # @return [Boolean] true when a minimal valid package carries it
99
+ def required?
100
+ !!required
101
+ end
102
+
103
+ # @return [Boolean] true when ContentTypes.generate includes it
104
+ def standard?
105
+ !!standard
106
+ end
107
+
108
+ # @return [Boolean] true when the relationship lives in _rels/.rels
109
+ def package_rel?
110
+ rels_scope == :package
111
+ end
112
+
113
+ # Resolve the package-relative path, interpolating pattern
114
+ # placeholders for instance-numbered parts.
115
+ #
116
+ # @param vars [Hash] placeholder values (e.g. counter: 1)
117
+ # @return [String, nil] e.g. "word/header1.xml"
118
+ def path_for(**vars)
119
+ expand(path_pattern || path, vars)
120
+ end
121
+
122
+ # Resolve the content-type Override part name (leading slash).
123
+ #
124
+ # @param vars [Hash] placeholder values for numbered parts
125
+ # @return [String, nil] e.g. "/word/header1.xml"
126
+ def part_name_for(**vars)
127
+ resolved = path_for(**vars)
128
+ resolved && "/#{resolved}"
129
+ end
130
+
131
+ # Resolve the relationship Target attribute.
132
+ #
133
+ # @param vars [Hash] placeholder values for numbered parts
134
+ # @return [String, nil] e.g. "header1.xml"
135
+ def target_for(**vars)
136
+ expand(target_pattern || target, vars)
137
+ end
138
+
139
+ # Fixed-path Override part name (leading slash).
140
+ #
141
+ # @return [String, nil] e.g. "/word/styles.xml"
142
+ def part_name
143
+ path && "/#{path}"
144
+ end
145
+
146
+ # Two definitions are equal when every field matches.
147
+ def ==(other)
148
+ other.is_a?(PartDefinition) && fields == other.fields
149
+ end
150
+
151
+ protected
152
+
153
+ # @return [Array] field values in declaration order, used for
154
+ # value equality
155
+ def fields
156
+ [key, kind, path, path_pattern, target, target_pattern,
157
+ content_type, rel_type, extension, required, rels_scope,
158
+ standard]
159
+ end
160
+
161
+ private
162
+
163
+ def expand(template, vars)
164
+ return nil unless template
165
+ return template if vars.empty?
166
+
167
+ format(template, vars)
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Ooxml
5
+ # Single source of truth for OOXML package part metadata.
6
+ #
7
+ # Maps every part kind the library writes to its package path,
8
+ # content type, relationship type, and [Content_Types].xml entry
9
+ # form (Default vs Override). Content type and relationship type
10
+ # literals live ONLY here; consumers (Uniword::ContentTypes,
11
+ # Docx::PackageDefaults, Docx::Reconciler::PackageStructure, and
12
+ # the Docx::PackageSerialization inject_* methods) derive from
13
+ # this registry instead of holding their own literals.
14
+ #
15
+ # Open/closed: a new part kind is added by registering a
16
+ # PartDefinition — consumers need no further changes.
17
+ #
18
+ # @example Look up a part by key
19
+ # defn = PartRegistry.find_by_key(:styles)
20
+ # defn.part_name # => "/word/styles.xml"
21
+ # defn.rel_type # => ".../relationships/styles"
22
+ #
23
+ # @example Register a custom part kind
24
+ # PartRegistry.register(
25
+ # PartDefinition.new(key: :glossary, path: "word/glossary/document.xml",
26
+ # target: "glossary/document.xml",
27
+ # content_type: "...document.glossary+xml",
28
+ # rel_type: ".../glossaryDocument",
29
+ # kind: :override, rels_scope: :document)
30
+ # )
31
+ module PartRegistry
32
+ # Relationship type base URIs. These literals appear only here.
33
+ OFFICE_REL_BASE =
34
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
35
+ PACKAGE_REL_BASE =
36
+ "http://schemas.openxmlformats.org/package/2006/relationships"
37
+
38
+ # Content type prefixes (literals appear only here).
39
+ CT_OFFICE = "application/vnd.openxmlformats-officedocument"
40
+ CT_WML = "#{CT_OFFICE}.wordprocessingml".freeze
41
+ CT_PACKAGE = "application/vnd.openxmlformats-package"
42
+
43
+ # Built-in registrations. Registration order is observable in
44
+ # emitted output: the :standard entries reproduce the historic
45
+ # ContentTypes.generate ordering exactly (defaults first —
46
+ # jpeg, png, gif, rels, xml — then overrides in the historic
47
+ # order). Non-standard parts follow; consumers select their own
48
+ # ordered subsets by key.
49
+ BUILT_INS = [
50
+ # -- Content-type Defaults (extension → content type) --
51
+ { key: :jpeg, kind: :default, extension: "jpeg",
52
+ content_type: "image/jpeg", standard: true },
53
+ { key: :png, kind: :default, extension: "png",
54
+ content_type: "image/png", standard: true },
55
+ { key: :gif, kind: :default, extension: "gif",
56
+ content_type: "image/gif", standard: true },
57
+ { key: :rels, kind: :default, extension: "rels",
58
+ content_type: "#{CT_PACKAGE}.relationships+xml",
59
+ required: true, standard: true },
60
+ { key: :xml, kind: :default, extension: "xml",
61
+ content_type: "application/xml",
62
+ required: true, standard: true },
63
+
64
+ # -- Standard Override parts (historic generate order) --
65
+ { key: :document, kind: :override, path: "word/document.xml",
66
+ content_type: "#{CT_WML}.document.main+xml",
67
+ rel_type: "#{OFFICE_REL_BASE}/officeDocument",
68
+ required: true, standard: true, rels_scope: :package },
69
+ { key: :numbering, kind: :override, path: "word/numbering.xml",
70
+ target: "numbering.xml", content_type: "#{CT_WML}.numbering+xml",
71
+ rel_type: "#{OFFICE_REL_BASE}/numbering",
72
+ standard: true, rels_scope: :document },
73
+ { key: :styles, kind: :override, path: "word/styles.xml",
74
+ target: "styles.xml", content_type: "#{CT_WML}.styles+xml",
75
+ rel_type: "#{OFFICE_REL_BASE}/styles",
76
+ required: true, standard: true, rels_scope: :document },
77
+ { key: :settings, kind: :override, path: "word/settings.xml",
78
+ target: "settings.xml", content_type: "#{CT_WML}.settings+xml",
79
+ rel_type: "#{OFFICE_REL_BASE}/settings",
80
+ required: true, standard: true, rels_scope: :document },
81
+ { key: :web_settings, kind: :override, path: "word/webSettings.xml",
82
+ target: "webSettings.xml",
83
+ content_type: "#{CT_WML}.webSettings+xml",
84
+ rel_type: "#{OFFICE_REL_BASE}/webSettings",
85
+ required: true, standard: true, rels_scope: :document },
86
+ { key: :font_table, kind: :override, path: "word/fontTable.xml",
87
+ target: "fontTable.xml",
88
+ content_type: "#{CT_WML}.fontTable+xml",
89
+ rel_type: "#{OFFICE_REL_BASE}/fontTable",
90
+ required: true, standard: true, rels_scope: :document },
91
+ { key: :theme, kind: :override, path: "word/theme/theme1.xml",
92
+ target: "theme/theme1.xml",
93
+ content_type: "#{CT_OFFICE}.theme+xml",
94
+ rel_type: "#{OFFICE_REL_BASE}/theme",
95
+ standard: true, rels_scope: :document },
96
+ { key: :core_properties, kind: :override, path: "docProps/core.xml",
97
+ content_type: "#{CT_PACKAGE}.core-properties+xml",
98
+ rel_type: "#{PACKAGE_REL_BASE}/metadata/core-properties",
99
+ required: true, standard: true, rels_scope: :package },
100
+ { key: :app_properties, kind: :override, path: "docProps/app.xml",
101
+ content_type: "#{CT_OFFICE}.extended-properties+xml",
102
+ rel_type: "#{OFFICE_REL_BASE}/extended-properties",
103
+ required: true, standard: true, rels_scope: :package },
104
+
105
+ # -- Optional document parts --
106
+ { key: :footnotes, kind: :override, path: "word/footnotes.xml",
107
+ target: "footnotes.xml",
108
+ content_type: "#{CT_WML}.footnotes+xml",
109
+ rel_type: "#{OFFICE_REL_BASE}/footnotes",
110
+ rels_scope: :document },
111
+ { key: :endnotes, kind: :override, path: "word/endnotes.xml",
112
+ target: "endnotes.xml",
113
+ content_type: "#{CT_WML}.endnotes+xml",
114
+ rel_type: "#{OFFICE_REL_BASE}/endnotes",
115
+ rels_scope: :document },
116
+ { key: :comments, kind: :override, path: "word/comments.xml",
117
+ target: "comments.xml",
118
+ content_type: "#{CT_WML}.comments+xml",
119
+ rel_type: "#{OFFICE_REL_BASE}/comments",
120
+ rels_scope: :document },
121
+ { key: :bibliography, kind: :override, path: "word/sources.xml",
122
+ target: "sources.xml",
123
+ content_type: "#{CT_OFFICE}.bibliography+xml",
124
+ rel_type: "#{OFFICE_REL_BASE}/bibliography",
125
+ rels_scope: :document },
126
+ { key: :header, kind: :override,
127
+ path_pattern: "word/header%<counter>d.xml",
128
+ target_pattern: "header%<counter>d.xml",
129
+ content_type: "#{CT_WML}.header+xml",
130
+ rel_type: "#{OFFICE_REL_BASE}/header",
131
+ rels_scope: :document },
132
+ { key: :footer, kind: :override,
133
+ path_pattern: "word/footer%<counter>d.xml",
134
+ target_pattern: "footer%<counter>d.xml",
135
+ content_type: "#{CT_WML}.footer+xml",
136
+ rel_type: "#{OFFICE_REL_BASE}/footer",
137
+ rels_scope: :document },
138
+ { key: :custom_properties, kind: :override,
139
+ path: "docProps/custom.xml",
140
+ content_type: "#{CT_OFFICE}.custom-properties+xml",
141
+ rel_type: "#{OFFICE_REL_BASE}/custom-properties",
142
+ rels_scope: :package },
143
+ { key: :chart, kind: :override,
144
+ path_pattern: "word/charts/chart%<n>d.xml",
145
+ target_pattern: "charts/chart%<n>d.xml",
146
+ content_type: "#{CT_OFFICE}.drawingml.chart+xml",
147
+ rel_type: "#{OFFICE_REL_BASE}/chart",
148
+ rels_scope: :document },
149
+ # Images carry a per-extension Default content type resolved
150
+ # from the image data at save time, so content_type is nil.
151
+ { key: :image, kind: :default,
152
+ path_pattern: "word/media/%<name>s",
153
+ target_pattern: "media/%<name>s",
154
+ rel_type: "#{OFFICE_REL_BASE}/image",
155
+ rels_scope: :document },
156
+ { key: :ole_object, kind: :override,
157
+ path_pattern: "word/embeddings/%<name>s",
158
+ target_pattern: "embeddings/%<name>s",
159
+ content_type: "#{CT_OFFICE}.oleObject",
160
+ rel_type: "#{OFFICE_REL_BASE}/oleObject",
161
+ rels_scope: :document },
162
+ # customXml items themselves fall under the "xml" Default;
163
+ # only their itemProps parts get an Override.
164
+ { key: :custom_xml_item, kind: :none,
165
+ path_pattern: "customXml/item%<index>d.xml" },
166
+ { key: :custom_xml_item_props, kind: :override,
167
+ path_pattern: "customXml/itemProps%<index>d.xml",
168
+ content_type: "#{CT_OFFICE}.customXmlProperties+xml" },
169
+ # Hyperlink relationships target external URLs; no part, no
170
+ # content type.
171
+ { key: :hyperlink, kind: :none,
172
+ rel_type: "#{OFFICE_REL_BASE}/hyperlink",
173
+ rels_scope: :document },
174
+ # Theme part of THMX (theme) packages, rooted at /theme.
175
+ { key: :thmx_theme, kind: :override, path: "theme/theme1.xml",
176
+ content_type: "#{CT_OFFICE}.theme+xml", required: true },
177
+ ].freeze
178
+
179
+ class << self
180
+ # Register a part definition. Re-registering an existing key
181
+ # replaces it in place (preserving position); new keys append.
182
+ #
183
+ # @param definition [PartDefinition] definition to register
184
+ # @return [PartDefinition] the registered definition
185
+ # @raise [ArgumentError] when not given a PartDefinition
186
+ def register(definition)
187
+ unless definition.is_a?(PartDefinition)
188
+ raise ArgumentError,
189
+ "expected PartDefinition, got #{definition.class}"
190
+ end
191
+
192
+ index = definitions.index { |d| d.key == definition.key }
193
+ if index
194
+ definitions[index] = definition
195
+ else
196
+ definitions << definition
197
+ end
198
+ definition
199
+ end
200
+
201
+ # Remove a registered definition (primarily for tests).
202
+ #
203
+ # @param key [Symbol] registry key
204
+ # @return [Array<PartDefinition>, nil] the definitions list if
205
+ # a definition was removed, nil when the key was absent
206
+ def unregister(key)
207
+ definitions.reject! { |d| d.key == key.to_sym }
208
+ end
209
+
210
+ # @return [Array<PartDefinition>] all definitions, in
211
+ # registration order (a copy; safe to mutate)
212
+ def all
213
+ definitions.dup
214
+ end
215
+
216
+ # @param key [Symbol] registry key (e.g. :styles)
217
+ # @return [PartDefinition, nil]
218
+ def find_by_key(key)
219
+ definitions.find { |d| d.key == key.to_sym }
220
+ end
221
+
222
+ # Find by package path; accepts both "word/styles.xml" and
223
+ # "/word/styles.xml" forms, and matches numbered paths against
224
+ # registered patterns ("word/header2.xml" → :header).
225
+ #
226
+ # @param path [String] package-relative path
227
+ # @return [PartDefinition, nil]
228
+ def find_by_path(path)
229
+ normalized = path.to_s.delete_prefix("/")
230
+ definitions.find { |d| path_match?(d, normalized) }
231
+ end
232
+
233
+ # @param content_type [String] MIME content type
234
+ # @return [PartDefinition, nil] first definition with this type
235
+ def find_by_content_type(content_type)
236
+ definitions.find { |d| d.content_type == content_type }
237
+ end
238
+
239
+ # Find an Override definition by content-type part name.
240
+ #
241
+ # @param part_name [String] e.g. "/word/styles.xml"
242
+ # @return [PartDefinition, nil]
243
+ def override_for(part_name)
244
+ normalized = part_name.to_s.delete_prefix("/")
245
+ definitions.find do |d|
246
+ d.override? && path_match?(d, normalized)
247
+ end
248
+ end
249
+
250
+ # Find a Default definition by file extension.
251
+ #
252
+ # @param extension [String] e.g. "png"
253
+ # @return [PartDefinition, nil]
254
+ def default_for(extension)
255
+ definitions.find do |d|
256
+ d.default? && d.extension == extension.to_s
257
+ end
258
+ end
259
+
260
+ # Definitions included in the comprehensive [Content_Types].xml
261
+ # that ContentTypes.generate emits for new DOCX packages.
262
+ #
263
+ # @return [Array<PartDefinition>] in registration order
264
+ def standard_defaults
265
+ definitions.select { |d| d.standard? && d.default? }
266
+ end
267
+
268
+ # @return [Array<PartDefinition>] in registration order
269
+ def standard_overrides
270
+ definitions.select { |d| d.standard? && d.override? }
271
+ end
272
+
273
+ # Relationship type URIs whose relationships belong in the
274
+ # package-level _rels/.rels part.
275
+ #
276
+ # @return [Array<String>] in registration order
277
+ def package_rel_types
278
+ definitions.filter_map { |d| d.rel_type if d.package_rel? }
279
+ end
280
+
281
+ private
282
+
283
+ def definitions
284
+ @definitions ||= []
285
+ end
286
+
287
+ def path_match?(definition, normalized_path)
288
+ return true if definition.path == normalized_path
289
+
290
+ pattern = definition.path_pattern
291
+ !pattern.nil? && normalized_path.match?(template_regex(pattern))
292
+ end
293
+
294
+ # Convert a pattern like "word/header%<counter>d.xml" into a
295
+ # matching regexp: %<name>d → digits, %<name>s → any path run.
296
+ def template_regex(template)
297
+ source = template.split(/(%<\w+>[ds])/).map do |part|
298
+ case part
299
+ when /%<\w+>d/ then "\\d+"
300
+ when /%<\w+>s/ then ".+?"
301
+ else Regexp.escape(part)
302
+ end
303
+ end.join
304
+ /\A#{source}\z/
305
+ end
306
+ end
307
+
308
+ BUILT_INS.each { |attrs| register(PartDefinition.new(**attrs)) }
309
+ end
310
+ end
311
+ end
@@ -12,7 +12,7 @@ module Uniword
12
12
  def initialize(target:)
13
13
  super(
14
14
  id: "rId#{SecureRandom.hex(4)}",
15
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
15
+ type: PartRegistry.find_by_key(:image).rel_type,
16
16
  target: target
17
17
  )
18
18
  end
@@ -23,35 +23,33 @@ module Uniword
23
23
  #
24
24
  # Generate package-level .rels file
25
25
  #
26
+ # Historical rId assignment: rId3 = app, rId2 = core,
27
+ # rId1 = document (kept for byte-identical DOTX output).
28
+ #
26
29
  # @return [PackageRelationships] Relationships object for _rels/.rels
27
- def self.next_available_rid(relationships)
28
- max = relationships.relationships.filter_map do |r|
29
- r.id[/\ArId(\d+)\z/, 1]&.to_i
30
- end.max || 0
31
- "rId#{max + 1}"
32
- end
33
-
34
30
  def self.generate_package_rels
35
31
  new(
36
32
  relationships: [
37
- PackageRelationship.new(
38
- id: "rId3",
39
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties",
40
- target: "docProps/app.xml",
41
- ),
42
- PackageRelationship.new(
43
- id: "rId2",
44
- type: "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties",
45
- target: "docProps/core.xml",
46
- ),
47
- PackageRelationship.new(
48
- id: "rId1",
49
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
50
- target: "word/document.xml",
51
- ),
33
+ package_relationship("rId3", :app_properties),
34
+ package_relationship("rId2", :core_properties),
35
+ package_relationship("rId1", :document),
52
36
  ],
53
37
  )
54
38
  end
39
+
40
+ # Build one package-level relationship from the registry.
41
+ #
42
+ # @param rid [String] relationship ID
43
+ # @param key [Symbol] PartRegistry key
44
+ # @return [PackageRelationship]
45
+ def self.package_relationship(rid, key)
46
+ definition = PartRegistry.find_by_key(key)
47
+ PackageRelationship.new(
48
+ id: rid,
49
+ type: definition.rel_type,
50
+ target: definition.target,
51
+ )
52
+ end
55
53
  end
56
54
  end
57
55
  end
@@ -301,12 +301,12 @@ module Uniword
301
301
  def get_attribute_value(element, attr_def)
302
302
  property_name = attr_def.property_name
303
303
 
304
- # public_send is the canonical Ruby idiom for schema-driven
305
- # serialization: reading attribute values by name from any
306
- # Lutaml::Model::Serializable subclass. The property_name comes
307
- # from the schema definition, not user input.
304
+ # Schema-driven read: the model's declared attributes are the
305
+ # allowlist, and the value is fetched through the model's
306
+ # public method table. The property_name comes from the schema
307
+ # definition, not user input.
308
308
  if element.is_a?(Lutaml::Model::Serializable) && element.class.attributes.key?(property_name)
309
- element.public_send(property_name)
309
+ element.method(property_name).call
310
310
  elsif element.is_a?(Hash) && element.key?(property_name)
311
311
  element[property_name]
312
312
  end
@@ -320,9 +320,10 @@ module Uniword
320
320
  def get_child_elements(element, child_def)
321
321
  property_name = child_def.property_name
322
322
 
323
- # Try to get children using property name
323
+ # Schema-driven read via the model's public method table,
324
+ # guarded by the declared attribute names.
324
325
  if element.is_a?(Lutaml::Model::Serializable) && element.class.attributes.key?(property_name)
325
- element.public_send(property_name)
326
+ element.method(property_name).call
326
327
  elsif element.is_a?(Hash) && element.key?(property_name)
327
328
  element[property_name]
328
329
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Uniword
6
+ module Ooxml
7
+ module Types
8
+ # ST_HexColor (ECMA-376): RGB hex color or "auto"
9
+ #
10
+ # Constrained string type: six hexadecimal digits (case-insensitive,
11
+ # ST_HexColorRGB) or the literal "auto" (ST_HexColorAuto). Invalid
12
+ # values raise Lutaml::Model::Type::InvalidValueError at cast time
13
+ # (attribute assignment and XML parsing).
14
+ class HexColorValue < Lutaml::Model::Type::String
15
+ # ST_HexColor value shape: "auto" or RRGGBB hex digits
16
+ HEX_COLOR_PATTERN = /\A(?:auto|[0-9A-Fa-f]{6})\z/
17
+
18
+ # Cast a value to a valid ST_HexColor string
19
+ #
20
+ # @param value [Object] the raw value
21
+ # @param options [Hash] cast options (unused)
22
+ # @return [String, nil] the validated color string
23
+ # @raise [Lutaml::Model::Type::InvalidValueError] when the value
24
+ # is neither "auto" nor six hex digits
25
+ def self.cast(value, options = {})
26
+ casted = super
27
+ return casted if casted.nil? ||
28
+ Lutaml::Model::Utils.uninitialized?(casted)
29
+ return casted if HEX_COLOR_PATTERN.match?(casted)
30
+
31
+ raise Lutaml::Model::Type::InvalidValueError.new(
32
+ value, ["auto", "RRGGBB (6 hexadecimal digits)"]
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -5,29 +5,42 @@ require "lutaml/model"
5
5
  module Uniword
6
6
  module Ooxml
7
7
  module Types
8
- # OOXML Boolean type for attributes
9
- # OOXML uses "1"/"0" encoding for boolean attributes, not "true"/"false"
8
+ # OOXML Boolean type for attributes (ST_OnOff, ECMA-376)
9
+ # OOXML uses "1"/"0" encoding for boolean attributes; ST_OnOff also
10
+ # accepts the xsd:boolean spellings "true"/"false" and "on"/"off".
10
11
  #
11
- # Parsing: "1" -> true, "0" -> false
12
+ # Parsing: "1"/"true"/"on" -> true, "0"/"false"/"off"/nil -> false
12
13
  # Serialization: true -> "1", false -> "0"
14
+ # Anything else raises Lutaml::Model::Type::InvalidValueError
15
+ # instead of passing through unchanged.
13
16
  class OoxmlBoolean < Lutaml::Model::Type::Boolean
17
+ # Accepted ST_OnOff spellings for Boolean true
18
+ TRUE_VALUES = [true, 1, "1", "true", "on"].freeze
19
+
20
+ # Accepted ST_OnOff spellings for Boolean false
21
+ FALSE_VALUES = [false, 0, "0", "false", "off"].freeze
22
+
23
+ # All accepted ST_OnOff spellings
24
+ ON_OFF_VALUES = (TRUE_VALUES + FALSE_VALUES).freeze
25
+
14
26
  def self.cast(value, _options = {})
15
- case value
16
- when true, "1", 1
17
- true
18
- when false, "0", 0, nil
19
- false
20
- else
21
- value
22
- end
27
+ return value if Lutaml::Model::Utils.uninitialized?(value)
28
+ return true if TRUE_VALUES.include?(value)
29
+ return false if FALSE_VALUES.include?(value) || value.nil?
30
+
31
+ raise Lutaml::Model::Type::InvalidValueError.new(
32
+ value, ON_OFF_VALUES
33
+ )
23
34
  end
24
35
 
25
36
  def self.serialize(value)
26
37
  return nil if value.nil?
27
- return "1" if value == true || value.to_s.match?(/^(true|t|yes|y|1)$/i)
28
- return "0" if value == false || value.to_s.match?(/^(false|f|no|n|0)$/i)
38
+ return "1" if TRUE_VALUES.include?(value)
39
+ return "0" if FALSE_VALUES.include?(value)
29
40
 
30
- value
41
+ raise Lutaml::Model::Type::InvalidValueError.new(
42
+ value, ON_OFF_VALUES
43
+ )
31
44
  end
32
45
 
33
46
  # Override instance to_xml for OOXML boolean serialization