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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Uniword
6
+ # Page subcommands for Uniword CLI.
7
+ #
8
+ # Uniform page setup across a document — the CLI equivalent of Word's
9
+ # Layout dialog (size, orientation, margins).
10
+ class PageCLI < Thor
11
+ include CLIHelpers
12
+
13
+ desc "setup INPUT OUTPUT", "Apply page setup to every section"
14
+ long_desc <<~DESC
15
+ Apply uniform page setup — paper size, orientation, and margins —
16
+ to every section of a document, the CLI equivalent of Word's
17
+ Layout dialog.
18
+
19
+ Examples:
20
+ $ uniword page setup input.docx output.docx --size a4
21
+ $ uniword page setup input.docx output.docx --size a4 --orientation landscape
22
+ $ uniword page setup input.docx output.docx --margins 1in
23
+ $ uniword page setup input.docx output.docx --margins 2.5cm --margin-top 3cm
24
+ DESC
25
+ option :size, type: :string,
26
+ desc: "Paper size: letter, legal, a4, a5, executive"
27
+ option :orientation, type: :string,
28
+ desc: "portrait or landscape"
29
+ option :margins, type: :string,
30
+ desc: "Uniform margin for all sides " \
31
+ "(1in, 2.5cm, 25mm, 1440)"
32
+ option :margin_top, type: :string, desc: "Top margin override"
33
+ option :margin_right, type: :string, desc: "Right margin override"
34
+ option :margin_bottom, type: :string, desc: "Bottom margin override"
35
+ option :margin_left, type: :string, desc: "Left margin override"
36
+ option :verbose, aliases: "-v", desc: "Verbose output", type: :boolean,
37
+ default: false
38
+ def setup(input_path, output_path)
39
+ opts = setup_options
40
+ unless opts
41
+ say "Error: specify at least one of --size, --orientation, " \
42
+ "--margins, --margin-top/right/bottom/left", :red
43
+ exit 1
44
+ end
45
+
46
+ say "Loading document #{input_path}...", :green if options[:verbose]
47
+
48
+ sections = apply_setup(input_path, output_path, opts)
49
+ say "Page setup applied to #{sections} section(s) in #{output_path}",
50
+ :green
51
+ rescue Uniword::Error => e
52
+ handle_error(e)
53
+ rescue StandardError => e
54
+ handle_error(e, verbose: options[:verbose])
55
+ end
56
+
57
+ private
58
+
59
+ def setup_options
60
+ map = base_setup_options.merge(margin_setup_options)
61
+ map unless map.values.all?(&:nil?)
62
+ end
63
+
64
+ def base_setup_options
65
+ {
66
+ size: options[:size], orientation: options[:orientation],
67
+ margins: options[:margins]
68
+ }
69
+ end
70
+
71
+ def margin_setup_options
72
+ {
73
+ top: options[:margin_top], right: options[:margin_right],
74
+ bottom: options[:margin_bottom], left: options[:margin_left]
75
+ }
76
+ end
77
+
78
+ def apply_setup(input_path, output_path, opts)
79
+ doc = load_document(input_path)
80
+ sections = doc.apply_page_setup(**opts)
81
+ doc.save(output_path)
82
+ sections
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Uniword
6
+ # Styles subcommands for Uniword CLI.
7
+ #
8
+ # Inspect the styles inside a document — the CLI equivalent of Word's
9
+ # Styles pane.
10
+ class StylesCLI < Thor
11
+ include CLIHelpers
12
+
13
+ desc "list FILE", "List styles in a document"
14
+ long_desc <<~DESC
15
+ List all styles defined in a document with their type, base
16
+ style, and key formatting — the CLI equivalent of Word's Styles
17
+ pane.
18
+
19
+ Examples:
20
+ $ uniword styles list report.docx
21
+ $ uniword styles list report.docx --type paragraph
22
+ $ uniword styles list report.docx --verbose
23
+ DESC
24
+ option :type, type: :string,
25
+ desc: "Filter by type: paragraph, character, table, numbering"
26
+ option :verbose, aliases: "-v", desc: "Show formatting details",
27
+ type: :boolean, default: false
28
+ def list(path)
29
+ styles = gather_styles(path)
30
+ if styles.empty?
31
+ say "No styles found.", :yellow
32
+ return
33
+ end
34
+
35
+ say "Styles in #{File.basename(path)} (#{styles.size}):", :green
36
+ styles.each { |style| print_style(style) }
37
+ rescue Uniword::Error => e
38
+ handle_error(e)
39
+ rescue StandardError => e
40
+ handle_error(e, verbose: options[:verbose])
41
+ end
42
+
43
+ desc "remove FILE OUTPUT", "Remove styles from a document"
44
+ long_desc <<~DESC
45
+ Remove styles from a document: one style with --id, or every
46
+ style that no content references with --unused (Word's Styles-pane
47
+ decluttering as a one-shot command). Default styles are kept.
48
+
49
+ Examples:
50
+ $ uniword styles remove input.docx output.docx --id ObsoleteStyle
51
+ $ uniword styles remove input.docx output.docx --unused
52
+ $ uniword styles remove input.docx output.docx --unused --dry-run
53
+ DESC
54
+ option :id, type: :string, desc: "Style id to remove"
55
+ option :unused, type: :boolean, default: false,
56
+ desc: "Remove all unreferenced styles"
57
+ option :dry_run, type: :boolean, default: false,
58
+ desc: "List what would be removed, without saving"
59
+ option :verbose, aliases: "-v", desc: "Verbose output",
60
+ type: :boolean, default: false
61
+ def remove(input_path, output_path)
62
+ doc = load_document(input_path)
63
+ removed = run_removal(doc)
64
+ report_removal(doc, removed, output_path)
65
+ rescue Uniword::Error => e
66
+ handle_error(e)
67
+ rescue StandardError => e
68
+ handle_error(e, verbose: options[:verbose])
69
+ end
70
+
71
+ desc "rename FILE OUTPUT", "Rename a style's display name"
72
+ long_desc <<~DESC
73
+ Rename a style's display name (w:name) — Word's style rename.
74
+ References target the styleId, so renamed styles stay linked from
75
+ all content. Identify the style by --id (w:styleId) or --from
76
+ (current display name).
77
+
78
+ Examples:
79
+ $ uniword styles rename input.docx output.docx --id Heading1 --name "Chapter Title"
80
+ $ uniword styles rename input.docx output.docx --from "Old Name" --name "New Name"
81
+ DESC
82
+ option :id, type: :string, desc: "Style id (w:styleId) to rename"
83
+ option :from, type: :string, desc: "Current display name of the style"
84
+ option :name, type: :string, required: true, desc: "New display name"
85
+ option :verbose, aliases: "-v", desc: "Verbose output",
86
+ type: :boolean, default: false
87
+ def rename(input_path, output_path)
88
+ identifier = rename_identifier
89
+ doc = load_document(input_path)
90
+ perform_rename(doc, identifier, output_path)
91
+ rescue Uniword::Error, StandardError => e
92
+ handle_error(e, verbose: options[:verbose])
93
+ end
94
+
95
+ private
96
+
97
+ def rename_identifier
98
+ identifier = options[:id] || options[:from]
99
+ return identifier if identifier
100
+
101
+ say "Error: specify --id or --from to identify the style", :red
102
+ exit 1
103
+ end
104
+
105
+ def perform_rename(doc, identifier, output_path)
106
+ unless doc.rename_style(identifier, options[:name])
107
+ say "Style '#{identifier}' not found", :yellow
108
+ exit 1
109
+ end
110
+
111
+ doc.save(output_path)
112
+ say "Renamed style '#{identifier}' to '#{options[:name]}' " \
113
+ "in #{output_path}", :green
114
+ end
115
+
116
+ def run_removal(doc)
117
+ cleanup = Wordprocessingml::StyleCleanup.new(doc)
118
+ removal_error unless options[:unused] || options[:id]
119
+
120
+ removed_ids(cleanup)
121
+ end
122
+
123
+ def removed_ids(cleanup)
124
+ if options[:unused]
125
+ options[:dry_run] ? cleanup.unused_ids : cleanup.remove_unused
126
+ else
127
+ [options[:id]].select { |id| options[:dry_run] || cleanup.remove?(id) }
128
+ end
129
+ end
130
+
131
+ def removal_error
132
+ say "Error: specify --id or --unused", :red
133
+ exit 1
134
+ end
135
+
136
+ def report_removal(doc, removed, output_path)
137
+ if removed.empty?
138
+ say "No styles removed.", :yellow
139
+ return
140
+ end
141
+
142
+ verb = options[:dry_run] ? "Would remove" : "Removed"
143
+ say "#{verb} #{removed.size} style(s): #{removed.join(', ')}", :green
144
+ return if options[:dry_run]
145
+
146
+ doc.save(output_path)
147
+ say "Saved to #{output_path}", :green
148
+ end
149
+
150
+ def gather_styles(path)
151
+ doc = load_document(path)
152
+ filter_type(doc.styles_configuration.styles || [])
153
+ end
154
+
155
+ def filter_type(styles)
156
+ return styles unless options[:type]
157
+
158
+ styles.select { |s| s.type == options[:type] }
159
+ end
160
+
161
+ def print_style(style)
162
+ base = style.basedOn&.val || "-"
163
+ line = format(" %-24<id>s %-10<type>s base: %<base>s",
164
+ id: style.styleId, type: style.type, base: base)
165
+ line += " [#{style_details(style)}]" if options[:verbose]
166
+ say line
167
+ end
168
+
169
+ def style_details(style)
170
+ (name_detail(style) + font_details(style) + format_details(style))
171
+ .join(", ")
172
+ end
173
+
174
+ def name_detail(style)
175
+ style.name&.val ? ["name=#{style.name.val}"] : []
176
+ end
177
+
178
+ def font_details(style)
179
+ rpr = style.rPr
180
+ return [] unless rpr
181
+
182
+ [font_name(rpr), font_size(rpr)].compact
183
+ end
184
+
185
+ def font_name(rpr)
186
+ rpr.fonts&.ascii
187
+ end
188
+
189
+ def font_size(rpr)
190
+ size = rpr.size&.value
191
+ "#{size.to_i / 2.0}pt" if size
192
+ end
193
+
194
+ def format_details(style)
195
+ rpr = style.rPr
196
+ return [] unless rpr
197
+
198
+ flags = []
199
+ flags << "bold" if rpr.bold&.val
200
+ flags << "italic" if rpr.italic&.val
201
+ flags
202
+ end
203
+ end
204
+ end
@@ -147,6 +147,46 @@ module Uniword
147
147
  handle_error(e, verbose: options[:verbose])
148
148
  end
149
149
 
150
+ desc "fonts INPUT OUTPUT", "Apply a bundled font scheme to a document"
151
+ long_desc <<~DESC
152
+ Replace the document theme's font scheme (major/minor fonts) while
153
+ keeping colors and formats — the equivalent of Word's Design →
154
+ Fonts gallery. Use --list to see available font schemes.
155
+
156
+ Examples:
157
+ $ uniword theme fonts input.docx output.docx --name carlito_sans
158
+ $ uniword theme fonts --list
159
+ DESC
160
+ option :name, type: :string,
161
+ desc: "Bundled font scheme name (e.g., carlito_sans)"
162
+ option :list, type: :boolean, default: false,
163
+ desc: "List available bundled font schemes"
164
+ option :verbose, aliases: "-v", desc: "Verbose output", type: :boolean,
165
+ default: false
166
+ def fonts(*args)
167
+ run_scheme_command(:fonts, args)
168
+ end
169
+
170
+ desc "colors INPUT OUTPUT", "Apply a bundled color scheme to a document"
171
+ long_desc <<~DESC
172
+ Replace the document theme's color scheme while keeping fonts and
173
+ formats — the equivalent of Word's Design → Colors gallery. Use
174
+ --list to see available color schemes.
175
+
176
+ Examples:
177
+ $ uniword theme colors input.docx output.docx --name emerald
178
+ $ uniword theme colors --list
179
+ DESC
180
+ option :name, type: :string,
181
+ desc: "Bundled color scheme name (e.g., emerald)"
182
+ option :list, type: :boolean, default: false,
183
+ desc: "List available bundled color schemes"
184
+ option :verbose, aliases: "-v", desc: "Verbose output", type: :boolean,
185
+ default: false
186
+ def colors(*args)
187
+ run_scheme_command(:colors, args)
188
+ end
189
+
150
190
  desc "auto INPUT OUTPUT", "Auto-transition MS theme to Uniword equivalent"
151
191
  long_desc <<~DESC
152
192
  Detect the Microsoft Word theme in a document and automatically
@@ -192,8 +232,56 @@ module Uniword
192
232
  handle_error(e, verbose: options[:verbose])
193
233
  end
194
234
 
235
+ # Scheme-switching command configuration: loader, document method,
236
+ # and output label per command kind.
237
+ SCHEME_COMMANDS = {
238
+ fonts: {
239
+ loader: Resource::FontSchemeLoader,
240
+ doc_method: :apply_font_scheme,
241
+ label: "Font scheme",
242
+ },
243
+ colors: {
244
+ loader: Resource::ColorSchemeLoader,
245
+ doc_method: :apply_color_scheme,
246
+ label: "Color scheme",
247
+ },
248
+ }.freeze
249
+
195
250
  private
196
251
 
252
+ def run_scheme_command(kind, args)
253
+ config = SCHEME_COMMANDS.fetch(kind)
254
+ if options[:list]
255
+ config[:loader].available_schemes.each { |s| say s }
256
+ return
257
+ end
258
+
259
+ require_name!(:name, config[:loader])
260
+ apply_scheme_to_file(config, args)
261
+ rescue Uniword::Error => e
262
+ handle_error(e)
263
+ rescue StandardError => e
264
+ handle_error(e, verbose: options[:verbose])
265
+ end
266
+
267
+ def apply_scheme_to_file(config, (input_path, output_path))
268
+ say "Loading document #{input_path}...", :green if options[:verbose]
269
+
270
+ doc = load_document(input_path)
271
+ doc.method(config[:doc_method]).call(options[:name])
272
+ doc.save(output_path)
273
+ say "#{config[:label]} '#{options[:name]}' applied to #{output_path}",
274
+ :green
275
+ end
276
+
277
+ def require_name!(key, loader)
278
+ return if options[key]
279
+
280
+ say "Error: specify --name for a bundled scheme " \
281
+ "(available: #{loader.available_schemes.join(', ')})", :red
282
+ exit 1
283
+ end
284
+
197
285
  def apply_theme_to(doc)
198
286
  if options[:name]
199
287
  if options[:verbose]
@@ -68,6 +68,9 @@ module Uniword
68
68
  type: :numeric, default: 0
69
69
  option :max_level, desc: "Maximum heading level (1-6)", type: :numeric,
70
70
  default: 3
71
+ option :update_fields, type: :boolean, default: true,
72
+ desc: "Set w:updateFields so Word " \
73
+ "refreshes fields on open (default: true)"
71
74
  option :verbose, aliases: "-v", desc: "Show verbose output",
72
75
  type: :boolean, default: false
73
76
  def insert(path)
@@ -84,7 +87,7 @@ module Uniword
84
87
  position: options[:position],
85
88
  max_level: options[:max_level])
86
89
 
87
- doc.save(options[:output])
90
+ save_with_field_updates(doc, options[:output])
88
91
 
89
92
  say "TOC inserted with #{entries.count} entries at " \
90
93
  "position #{options[:position]}.", :green
@@ -109,6 +112,9 @@ module Uniword
109
112
  type: :string
110
113
  option :max_level, desc: "Maximum heading level (1-6)", type: :numeric,
111
114
  default: 6
115
+ option :update_fields, type: :boolean, default: true,
116
+ desc: "Set w:updateFields so Word " \
117
+ "refreshes fields on open (default: true)"
112
118
  option :verbose, aliases: "-v", desc: "Show verbose output",
113
119
  type: :boolean, default: false
114
120
  def update(path)
@@ -121,7 +127,7 @@ module Uniword
121
127
  return
122
128
  end
123
129
 
124
- doc.save(options[:output])
130
+ save_with_field_updates(doc, options[:output])
125
131
 
126
132
  say "TOC updated with #{entries.count} entries.", :green
127
133
  say "Saved to: #{options[:output]}", :green
@@ -133,6 +139,20 @@ module Uniword
133
139
 
134
140
  private
135
141
 
142
+ # Set w:updateFields (unless disabled) and save the document.
143
+ def save_with_field_updates(doc, output)
144
+ enable_field_updates(doc) if options[:update_fields]
145
+ doc.save(output)
146
+ end
147
+
148
+ # Set w:updateFields so Word refreshes all fields (TOC, page
149
+ # numbers, references) when the document is opened. Word may show a
150
+ # one-time prompt about updating fields.
151
+ def enable_field_updates(doc)
152
+ doc.settings ||= Wordprocessingml::Settings.new
153
+ doc.settings.update_fields = Wordprocessingml::UpdateFields.new
154
+ end
155
+
136
156
  # Display TOC entries in terminal format.
137
157
  #
138
158
  # @param entries [Array<Uniword::Toc::TocEntry>] Entries to display
@@ -19,19 +19,6 @@ module Uniword
19
19
  # @see CommentRange For comment range markers
20
20
  # @see CommentsPart For comments collection
21
21
  class Comment < Element
22
- # OOXML namespace configuration for comments
23
- xml do
24
- element "comment"
25
- namespace Ooxml::Namespaces::WordProcessingML
26
-
27
- map_attribute "id", to: :comment_id
28
- map_attribute "author", to: :author
29
- map_attribute "date", to: :date
30
- map_attribute "initials", to: :initials
31
-
32
- map_element "p", to: :paragraphs
33
- end
34
-
35
22
  # Unique comment identifier (required in OOXML)
36
23
  attribute :comment_id, :string
37
24
 
@@ -48,6 +35,19 @@ module Uniword
48
35
  attribute :paragraphs, Uniword::Wordprocessingml::Paragraph, collection: true,
49
36
  initialize_empty: true
50
37
 
38
+ # OOXML namespace configuration for comments
39
+ xml do
40
+ element "comment"
41
+ namespace Ooxml::Namespaces::WordProcessingML
42
+
43
+ map_attribute "id", to: :comment_id
44
+ map_attribute "author", to: :author
45
+ map_attribute "date", to: :date
46
+ map_attribute "initials", to: :initials
47
+
48
+ map_element "p", to: :paragraphs
49
+ end
50
+
51
51
  # Initialize a new comment
52
52
  #
53
53
  # @param attributes [Hash] Comment attributes
@@ -129,9 +129,14 @@ module Uniword
129
129
 
130
130
  # Generate a unique comment ID
131
131
  #
132
- # @return [String] A unique comment ID
132
+ # OOXML w:id is ST_DecimalNumber (a 32-bit integer), so the fallback
133
+ # ID must be decimal digits only; CommentsPart#add_comment reassigns
134
+ # the ID when it collides inside the part, so time+random precision
135
+ # is sufficient here.
136
+ #
137
+ # @return [String] A unique decimal comment ID
133
138
  def generate_comment_id
134
- "#{Time.now.to_i}_#{rand(10_000)}"
139
+ "#{Time.now.strftime('%H%M%S')}#{format('%03d', rand(1000))}".to_i.to_s
135
140
  end
136
141
 
137
142
  # Format date for OOXML
@@ -27,6 +27,9 @@ module Uniword
27
27
  #
28
28
  # @see Comment For the comment content
29
29
  class CommentRange < Element
30
+ # Comment ID this range marker refers to
31
+ attribute :comment_id, :string
32
+
30
33
  # OOXML namespace configuration
31
34
  xml do
32
35
  element "commentRangeStart"
@@ -35,9 +38,6 @@ module Uniword
35
38
  map_attribute "id", to: :comment_id
36
39
  end
37
40
 
38
- # Comment ID this range marker refers to
39
- attribute :comment_id, :string
40
-
41
41
  # Type of marker (:start, :end, :reference)
42
42
  attr_accessor :marker_type
43
43
 
@@ -22,6 +22,11 @@ module Uniword
22
22
  #
23
23
  # @see Comment For individual comment structure
24
24
  class CommentsPart < Lutaml::Model::Serializable
25
+ include Enumerable
26
+
27
+ # Collection of all comments
28
+ attribute :comments, Comment, collection: true, initialize_empty: true
29
+
25
30
  # OOXML namespace configuration for comments
26
31
  xml do
27
32
  element "comments"
@@ -30,19 +35,12 @@ module Uniword
30
35
  map_element "comment", to: :comments
31
36
  end
32
37
 
33
- # Collection of all comments
34
- attribute :comments, Comment, collection: true, initialize_empty: true
35
-
36
- # Initialize a new comments part
37
- #
38
- # @param attributes [Hash] Comments part attributes
39
- def initialize(attributes = {})
40
- super
41
- @comment_counter = 0
42
- end
43
-
44
38
  # Add a comment to the collection
45
39
  #
40
+ # Assigns the next sequential decimal ID when the comment has no ID
41
+ # yet or its ID collides with one already in the collection; explicit
42
+ # unique IDs are preserved.
43
+ #
46
44
  # @param comment [Comment] The comment to add
47
45
  # @return [Comment] The added comment with assigned ID
48
46
  def add_comment(comment)
@@ -51,9 +49,7 @@ module Uniword
51
49
  "comment must be a Comment instance"
52
50
  end
53
51
 
54
- # Assign sequential ID if not already set
55
- comment.comment_id = next_comment_id unless comment.comment_id && !comment.comment_id.empty?
56
-
52
+ comment.comment_id = next_comment_id if assign_id?(comment)
57
53
  comments << comment
58
54
  comment
59
55
  end
@@ -91,6 +87,29 @@ module Uniword
91
87
  comments.size
92
88
  end
93
89
 
90
+ # Get the number of comments (Array compatibility)
91
+ #
92
+ # @return [Integer] The count of comments
93
+ def size
94
+ comments.size
95
+ end
96
+
97
+ # Iterate over the comments (Array compatibility)
98
+ #
99
+ # @yield [Comment] Each comment in insertion order
100
+ # @return [Enumerator, Array<Comment>]
101
+ def each(&block)
102
+ comments.each(&block)
103
+ end
104
+
105
+ # Array-style access to comments (Array compatibility)
106
+ #
107
+ # @param index [Integer] Position in the collection
108
+ # @return [Comment, nil] The comment at the position
109
+ def [](index)
110
+ comments[index]
111
+ end
112
+
94
113
  # Check if there are any comments
95
114
  #
96
115
  # @return [Boolean] true if empty
@@ -110,7 +129,6 @@ module Uniword
110
129
  # @return [void]
111
130
  def clear
112
131
  comments.clear
113
- @comment_counter = 0
114
132
  end
115
133
 
116
134
  # Provide detailed inspection for debugging
@@ -122,13 +140,27 @@ module Uniword
122
140
 
123
141
  private
124
142
 
125
- # Generate next sequential comment ID
143
+ # Whether the comment needs an ID assigned by the collection: it has
144
+ # none, or another comment in the collection already carries it.
145
+ #
146
+ # @param comment [Comment] The comment being added
147
+ # @return [Boolean] true when an ID must be assigned
148
+ def assign_id?(comment)
149
+ id = comment.comment_id.to_s
150
+ id.empty? || !find_comment(id).nil?
151
+ end
152
+
153
+ # Generate the next sequential comment ID
154
+ #
155
+ # Decimal IDs already in the collection (loaded or assigned) set the
156
+ # high-water mark, so assigned IDs never collide with existing ones.
126
157
  #
127
158
  # @return [String] The next comment ID
128
159
  def next_comment_id
129
- @comment_counter ||= 0
130
- @comment_counter += 1
131
- @comment_counter.to_s
160
+ max = comments.filter_map do |c|
161
+ Integer(c.comment_id, exception: false)
162
+ end.max
163
+ ((max || 0) + 1).to_s
132
164
  end
133
165
  end
134
166
  end
@@ -3,7 +3,7 @@
3
3
  require "yaml"
4
4
 
5
5
  module Uniword
6
- module Configuration
6
+ class Configuration
7
7
  # Loads and manages external configuration files.
8
8
  #
9
9
  # Responsibility: Load configuration from external YAML files.
@@ -65,12 +65,14 @@ module Uniword
65
65
  # Get a configuration value with dot notation
66
66
  #
67
67
  # @param config [Hash] Configuration hash
68
- # @param key_path [String] Dot-separated key path (e.g., 'format_defaults.docx.default_font')
68
+ # @param key_path [String] Dot-separated key path
69
+ # (e.g., 'format_defaults.docx.default_font')
69
70
  # @param default [Object] Default value if key not found
70
71
  # @return [Object] Configuration value or default
71
72
  #
72
73
  # @example Get nested value
73
- # font = ConfigurationLoader.get(config, 'format_defaults.docx.default_font')
74
+ # font = ConfigurationLoader.get(config,
75
+ # 'format_defaults.docx.default_font')
74
76
  def get(config, key_path, default = nil)
75
77
  keys = key_path.split(".")
76
78
  value = keys.reduce(config) do |hash, key|