uniword 1.3.0 → 1.4.1

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -0
  3. data/lib/uniword/builder/chart_builder.rb +3 -5
  4. data/lib/uniword/builder/image_builder.rb +5 -5
  5. data/lib/uniword/document_factory.rb +10 -24
  6. data/lib/uniword/docx/custom_xml_item.rb +8 -0
  7. data/lib/uniword/docx/image_part.rb +83 -0
  8. data/lib/uniword/docx/package.rb +22 -402
  9. data/lib/uniword/docx/package_defaults.rb +36 -32
  10. data/lib/uniword/docx/package_serialization.rb +46 -7
  11. data/lib/uniword/docx/part.rb +19 -0
  12. data/lib/uniword/docx/part_collection.rb +15 -7
  13. data/lib/uniword/docx/part_loader/chart_loader.rb +50 -0
  14. data/lib/uniword/docx/part_loader/custom_xml_loader.rb +53 -0
  15. data/lib/uniword/docx/part_loader/embedding_loader.rb +26 -0
  16. data/lib/uniword/docx/part_loader/header_footer_loader.rb +101 -0
  17. data/lib/uniword/docx/part_loader/image_loader.rb +105 -0
  18. data/lib/uniword/docx/part_loader/load_context.rb +85 -0
  19. data/lib/uniword/docx/part_loader/raw_part_loader.rb +183 -0
  20. data/lib/uniword/docx/part_loader/theme_media_loader.rb +39 -0
  21. data/lib/uniword/docx/part_loader/xml_model_loader.rb +56 -0
  22. data/lib/uniword/docx/part_loader.rb +92 -0
  23. data/lib/uniword/docx/raw_part.rb +75 -0
  24. data/lib/uniword/docx/reconciler/helpers.rb +9 -30
  25. data/lib/uniword/docx/reconciler/package_structure.rb +4 -5
  26. data/lib/uniword/docx/reconciler/referential_integrity.rb +4 -79
  27. data/lib/uniword/docx.rb +3 -0
  28. data/lib/uniword/images/image_manager.rb +13 -13
  29. data/lib/uniword/ooxml/element_order.rb +94 -0
  30. data/lib/uniword/ooxml/part_definition.rb +164 -6
  31. data/lib/uniword/ooxml/part_registry.rb +238 -45
  32. data/lib/uniword/ooxml/relationships/image_relationship.rb +5 -2
  33. data/lib/uniword/ooxml.rb +1 -0
  34. data/lib/uniword/toc/toc_generator.rb +6 -7
  35. data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -5
  36. data/lib/uniword/transformation/mhtml_metadata_builder.rb +6 -6
  37. data/lib/uniword/validation/opc_validator.rb +16 -1
  38. data/lib/uniword/validation/validators/xml_schema_validator.rb +46 -0
  39. data/lib/uniword/version.rb +1 -1
  40. data/lib/uniword/wordprocessingml/document_root.rb +29 -220
  41. data/lib/uniword/wordprocessingml/document_styling.rb +246 -0
  42. data/lib/uniword/wordprocessingml/endnotes.rb +2 -3
  43. data/lib/uniword/wordprocessingml/footnotes.rb +2 -3
  44. data/lib/uniword/wordprocessingml/numbering_elements.rb +0 -15
  45. data/lib/uniword/wordprocessingml.rb +1 -2
  46. metadata +16 -2
@@ -91,14 +91,13 @@ module Uniword
91
91
  # Add the SDT to element_order so it serializes correctly
92
92
  return unless body.element_order
93
93
 
94
- # element_order from the parser may be frozen; never mutate it.
95
- body.element_order = body.element_order.dup
96
94
  insert_element = Lutaml::Xml::Element.new("Element", "sdt")
97
- if position.positive? && body.element_order.size >= position
98
- body.element_order.insert(position, insert_element)
99
- else
100
- body.element_order.insert(0, insert_element)
101
- end
95
+ index = if position.positive? && body.element_order.size >= position
96
+ position
97
+ else
98
+ 0
99
+ end
100
+ Ooxml::ElementOrder.insert_at(body, index, insert_element)
102
101
  end
103
102
 
104
103
  # Update an existing TOC in the document.
@@ -219,11 +219,7 @@ module Uniword
219
219
  def resolve_image_target(embed_id)
220
220
  return nil unless @image_parts
221
221
 
222
- entry = @image_parts.find { |pair| pair[0] == embed_id }
223
- return nil unless entry
224
-
225
- image_data = entry[1]
226
- image_data[:target]
222
+ @image_parts[embed_id]&.target
227
223
  end
228
224
 
229
225
  # Apply run formatting (text must already be HTML-escaped)
@@ -63,8 +63,8 @@ module Uniword
63
63
  # Build filelist.xml part
64
64
  def build_filelist_part
65
65
  image_entries = if @document.image_parts && !@document.image_parts.empty?
66
- @document.image_parts.map do |_r_id, image_data|
67
- %(<o:File HRef="#{image_data[:target]}"/>)
66
+ @document.image_parts.map do |_r_id, part|
67
+ %(<o:File HRef="#{part.target}"/>)
68
68
  end.join("\n ")
69
69
  else
70
70
  ""
@@ -91,12 +91,12 @@ module Uniword
91
91
  def build_image_parts
92
92
  return [] unless @document.image_parts && !@document.image_parts.empty?
93
93
 
94
- @document.image_parts.map do |_r_id, image_data|
94
+ @document.image_parts.map do |_r_id, image_part|
95
95
  part = Uniword::Mhtml::ImagePart.new
96
- part.content_type = image_data[:content_type] || "image/png"
96
+ part.content_type = image_part.content_type || "image/png"
97
97
  part.content_transfer_encoding = "base64"
98
- part.raw_content = image_data[:data]
99
- part.content_location = "file:///C:/D057922B/#{document_name}.fld/#{image_data[:target]}"
98
+ part.raw_content = image_part.data
99
+ part.content_location = "file:///C:/D057922B/#{document_name}.fld/#{image_part.target}"
100
100
  part
101
101
  end
102
102
  end
@@ -221,7 +221,22 @@ module Uniword
221
221
  def resolve_target(base_dir, target)
222
222
  return target[1..] if target.start_with?("/")
223
223
 
224
- base_dir.empty? ? target : File.join(base_dir, target)
224
+ normalize_package_path(
225
+ base_dir.empty? ? target : File.join(base_dir, target),
226
+ )
227
+ end
228
+
229
+ # Lexically normalize a package-relative path (resolve "." and
230
+ # ".." segments). Deliberately avoids File.expand_path, which
231
+ # prepends a drive letter on Windows.
232
+ def normalize_package_path(path)
233
+ segments = []
234
+ path.split("/").each do |segment|
235
+ next if segment.empty? || segment == "."
236
+
237
+ segment == ".." ? segments.pop : segments << segment
238
+ end
239
+ segments.join("/")
225
240
  end
226
241
  end
227
242
  end
@@ -20,6 +20,11 @@ module Uniword
20
20
  # validator = XmlSchemaValidator.new("xml_schema" => { "xsd_validation" => true })
21
21
  # result = validator.validate('/path/to/document.docx')
22
22
  class XmlSchemaValidator < LayerValidator
23
+ # Markup Compatibility namespace — the mc:Ignorable attribute
24
+ # itself lives here and is stripped during MCE preprocessing.
25
+ MCE_NAMESPACE_URI =
26
+ "http://schemas.openxmlformats.org/markup-compatibility/2006"
27
+
23
28
  def layer_name
24
29
  "XSD Schema"
25
30
  end
@@ -83,6 +88,7 @@ module Uniword
83
88
  content = entry.get_input_stream.read
84
89
  schema = registry.load_schema(schema_path)
85
90
  doc = Nokogiri::XML(content)
91
+ preprocess_mce(doc)
86
92
 
87
93
  schema.validate(doc).each do |error|
88
94
  issues << Report::ValidationIssue.new(
@@ -114,6 +120,46 @@ module Uniword
114
120
  end
115
121
  end
116
122
 
123
+ # Strip MCE-ignorable ATTRIBUTES per the part's own mc:Ignorable
124
+ # declaration (w14:paraId/textId and the mc:Ignorable attribute
125
+ # itself), so XSD validation is not drowned in false errors for
126
+ # content every real document carries.
127
+ #
128
+ # Extension ELEMENTS are deliberately kept: removing them changes
129
+ # libxml2's content-model evaluation and can expose pre-existing
130
+ # Word-vs-schema quirks (e.g. rsids placed late, tolerated via the
131
+ # sequence's trailing xsd:any) as false baseline errors.
132
+ # Operates in place on the parsed document; never touches the
133
+ # packaged bytes.
134
+ #
135
+ # @param doc [Nokogiri::XML::Document] parsed part to clean
136
+ # @return [void]
137
+ def preprocess_mce(doc)
138
+ root = doc.root
139
+ return unless root
140
+
141
+ ignorable = root["Ignorable"] || root["mc:Ignorable"]
142
+ return if ignorable.nil? || ignorable.empty?
143
+
144
+ uris = ignorable.split(/\s+/).filter_map do |prefix|
145
+ root.namespaces["xmlns:#{prefix}"]
146
+ end
147
+ # The mc:Ignorable attribute itself is MCE machinery and is
148
+ # undeclared on most root elements in the transitional XSDs.
149
+ strip_mce_attributes(root, uris + [MCE_NAMESPACE_URI])
150
+ end
151
+
152
+ # Remove attributes in ignorable namespaces from every element.
153
+ def strip_mce_attributes(doc, uris)
154
+ doc.traverse do |node|
155
+ next unless node.element?
156
+
157
+ node.attribute_nodes.each do |attr|
158
+ attr.remove if uris.include?(attr.namespace&.href)
159
+ end
160
+ end
161
+ end
162
+
117
163
  def check_unknown_namespaces(registry, content, part_name, issues)
118
164
  ns_uris = registry.detect_namespaces(content)
119
165
  unknown = registry.unknown_namespaces(ns_uris)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Uniword
4
- VERSION = "1.3.0"
4
+ VERSION = "1.4.1"
5
5
  end
@@ -12,6 +12,7 @@ module Uniword
12
12
  autoload :FeatureFacade, "#{__dir__}/document_root/feature_facade"
13
13
 
14
14
  include FeatureFacade
15
+ include DocumentStyling
15
16
  include Uniword::DocumentInput
16
17
 
17
18
  attribute :mc_ignorable, Uniword::Ooxml::Types::McIgnorable
@@ -96,9 +97,6 @@ module Uniword
96
97
  attr_accessor :theme, :raw_html, :revisions, :comments
97
98
  # Footnotes and endnotes (separate XML parts in DOCX package)
98
99
  attr_accessor :footnotes, :endnotes
99
- # Image parts to embed in the DOCX package
100
- # Hash: r_id => { path: String, data: String, content_type: String, target: String }
101
- attr_accessor :image_parts
102
100
  # Bibliography sources for sources.xml
103
101
  attr_accessor :bibliography_sources
104
102
  # Round-trip parts (copied from DocxPackage during load)
@@ -154,6 +152,19 @@ module Uniword
154
152
  footers.replace(value)
155
153
  end
156
154
 
155
+ # Image parts (word/media/* binaries), keyed by rId.
156
+ #
157
+ # @return [Docx::PartCollection] rId => Docx::ImagePart
158
+ def image_parts
159
+ @image_parts ||= Docx::PartCollection.new(:r_id, Docx::ImagePart)
160
+ end
161
+
162
+ # Bulk-assign image parts (Hash of rId => ImagePart/legacy part
163
+ # hash; nil clears).
164
+ def image_parts=(value)
165
+ image_parts.replace_all(value)
166
+ end
167
+
157
168
  # Chart parts to embed in the DOCX package, keyed by rId.
158
169
  #
159
170
  # @return [Docx::PartCollection] rId => ChartPart
@@ -178,6 +189,21 @@ module Uniword
178
189
  embeddings.replace_all(value)
179
190
  end
180
191
 
192
+ # Raw passthrough parts (unmodelled package parts carried
193
+ # byte-for-byte), keyed by package path. Mirrored from the
194
+ # package on load and back on save via the registry-driven
195
+ # document↔package copies (:raw_parts definition).
196
+ #
197
+ # @return [Docx::PartCollection] package path => Docx::RawPart
198
+ def raw_parts
199
+ @raw_parts ||= Docx::PartCollection.new(:path, Docx::RawPart)
200
+ end
201
+
202
+ # Bulk-assign raw parts (Hash of path => RawPart/hash; nil clears).
203
+ def raw_parts=(value)
204
+ raw_parts.replace_all(value)
205
+ end
206
+
181
207
  # Get app_properties (lazy initialization)
182
208
  #
183
209
  # @return [Uniword::Ooxml::AppProperties] The app properties object
@@ -327,211 +353,6 @@ module Uniword
327
353
 
328
354
  # Apply theme to document
329
355
  #
330
- # Applies a Uniword theme by name, updating doc defaults and
331
- # built-in heading/hyperlink styles to reference the theme.
332
- #
333
- # @param name [String, Symbol] Theme slug (e.g., 'meridian', 'corporate')
334
- # @param options [Hash] optional overrides
335
- # @option options [Hash] :colors override specific color keys
336
- # @option options [String] :major_font override major font
337
- # @option options [String] :minor_font override minor font
338
- # @return [self] For method chaining
339
- def apply_theme(name, **options)
340
- friendly = Themes::Theme.load(name.to_s)
341
-
342
- options[:colors]&.each do |key, value|
343
- friendly.color_scheme[key.to_s] = value
344
- end
345
- friendly.font_scheme.major_font = options[:major_font] if options[:major_font]
346
- friendly.font_scheme.minor_font = options[:minor_font] if options[:minor_font]
347
-
348
- word_theme = Themes::ThemeTransformation.new.to_word(friendly)
349
- Themes::ThemeApplicator.new.apply(word_theme, self)
350
- self
351
- end
352
-
353
- # Apply theme from .thmx file
354
- #
355
- # @param path [String] Path to .thmx file
356
- # @param variant [String, Integer, nil] Optional variant
357
- # @return [self] For method chaining
358
- def apply_theme_file(path, variant: nil)
359
- loader = Themes::ThemeLoader.new
360
- self.theme = if variant
361
- loader.load_with_variant(path, variant)
362
- else
363
- loader.load(path)
364
- end
365
- self
366
- end
367
-
368
- # Apply StyleSet to document
369
- #
370
- # @param name [String, Symbol] StyleSet slug (e.g., 'signature', 'heritage')
371
- # @param strategy [Symbol] Application strategy (:keep_existing, :replace, :rename)
372
- # @return [self] For method chaining
373
- def apply_styleset(name, strategy: :keep_existing)
374
- styleset = Uniword::Stylesets::YamlStyleSetLoader.load_bundled(name.to_s)
375
- styleset.apply_to(self, strategy: strategy)
376
- self
377
- end
378
-
379
- # Apply a bundled font scheme to the document's theme
380
- #
381
- # Mirrors Word's Design → Fonts gallery: replaces only the theme's
382
- # fontScheme — colors and formats are untouched. Creates the theme
383
- # part from the bundled Office theme when the document has none.
384
- #
385
- # @param name [String, Symbol] Font scheme slug (data/font_schemes/)
386
- # @return [self] For method chaining
387
- # @raise [ArgumentError] if the scheme is unknown
388
- def apply_font_scheme(name)
389
- fonts = Resource::FontSchemeLoader.load(name.to_s)
390
- transformation = Themes::ThemeTransformation.new
391
- ensure_theme!(transformation)
392
- theme.theme_elements.font_scheme =
393
- transformation.build_font_scheme(fonts)
394
- self
395
- end
396
-
397
- # Apply a bundled color scheme to the document's theme
398
- #
399
- # Mirrors Word's Design → Colors gallery: replaces only the theme's
400
- # clrScheme — fonts and formats are untouched. Creates the theme
401
- # part from the bundled Office theme when the document has none.
402
- #
403
- # @param name [String, Symbol] Color scheme slug (data/color_schemes/)
404
- # @return [self] For method chaining
405
- # @raise [ArgumentError] if the scheme is unknown
406
- def apply_color_scheme(name)
407
- colors = Resource::ColorSchemeLoader.load(name.to_s)
408
- transformation = Themes::ThemeTransformation.new
409
- ensure_theme!(transformation)
410
- theme.theme_elements.clr_scheme =
411
- transformation.build_color_scheme(colors)
412
- self
413
- end
414
-
415
- # Replace one font family with another across the document
416
- #
417
- # Mirrors Word's Home → Replace → Replace Fonts: rewrites rFonts
418
- # references in styles and defaults, body content, headers,
419
- # footers, notes, comments, and numbering. Theme font references
420
- # (asciiTheme etc.) are untouched — use #apply_font_scheme for
421
- # those.
422
- #
423
- # @param from [String] Font family to replace (exact match)
424
- # @param to [String] Replacement font family
425
- # @return [Integer] Number of rFonts attribute values rewritten
426
- def replace_font(from:, to:)
427
- replacer = FontReplacer.new(from: from, to: to)
428
- replacer.replace(self)
429
- end
430
-
431
- # Apply uniform page setup to every section of the document
432
- #
433
- # Mirrors Word's Layout dialog: named paper sizes, orientation
434
- # (with Word-style dimension swap), and margins.
435
- #
436
- # @param size [String, nil] Paper size: letter, legal, a4, a5,
437
- # executive
438
- # @param orientation [String, nil] "portrait" or "landscape"
439
- # @param margins [Integer, String, nil] Uniform margin for all
440
- # sides (twips, or "1in" / "2.5cm" / "25mm")
441
- # @param top, right, bottom, left [Integer, String, nil] Per-side
442
- # margin overrides
443
- # @return [Integer] Number of sections updated
444
- def apply_page_setup(size: nil, orientation: nil, margins: nil,
445
- top: nil, right: nil, bottom: nil, left: nil)
446
- setup = PageSetup.new(size: size, orientation: orientation,
447
- margins: margins, top: top, right: right,
448
- bottom: bottom, left: left)
449
- setup.apply(self)
450
- end
451
-
452
- # Remove one style from the document's style definitions
453
- #
454
- # Default styles (w:default="1") are never removed.
455
- #
456
- # @param style_id [String] Style id (w:styleId)
457
- # @return [Boolean] true when the style was removed
458
- def remove_style(style_id)
459
- StyleCleanup.new(self).remove?(style_id)
460
- end
461
-
462
- # Rename a style's display name (w:name) — Word's style rename.
463
- # References (pStyle/rStyle/tblStyle) target the styleId, so they
464
- # keep working unchanged.
465
- #
466
- # @param identifier [String] Style id (w:styleId) or current
467
- # display name (w:name)
468
- # @param new_name [String] New display name
469
- # @return [Style, nil] The renamed style, or nil when not found
470
- def rename_style(identifier, new_name)
471
- style = styles_configuration.style(identifier)
472
- return unless style
473
-
474
- style.name = StyleName.new(val: new_name)
475
- style
476
- end
477
-
478
- # Remove every style that no content references — Word's
479
- # Styles-pane decluttering as one call. Default styles are kept.
480
- #
481
- # @return [Array<String>] Ids of removed styles
482
- def remove_unused_styles
483
- StyleCleanup.new(self).remove_unused
484
- end
485
-
486
- # Auto-transition from MS theme to Uniword equivalent
487
- #
488
- # Detects the MS theme in the document's embedded theme and replaces
489
- # it with the corresponding Uniword theme (font-substituted, renamed).
490
- #
491
- # @return [Hash, nil] { uniword_slug:, ms_name: } or nil if no match
492
- #
493
- # @example
494
- # result = doc.auto_transition_theme
495
- # puts "Transitioned from #{result[:ms_name]} to #{result[:uniword_slug]}"
496
- def auto_transition_theme
497
- Resource::ThemeTransition.auto_transition!(self)
498
- end
499
-
500
- # Apply theme from another document
501
- #
502
- # @param source_path [String] Path to source .docx file
503
- # @return [self] For method chaining
504
- def apply_theme_from(source_path)
505
- source_doc = Uniword.load(source_path)
506
- self.theme = source_doc.theme.dup if source_doc.theme
507
- self
508
- end
509
-
510
- # Apply styles from another document
511
- #
512
- # @param source_path [String] Path to source .docx file
513
- # @param strategy [Symbol] Conflict resolution strategy
514
- # @return [self] For method chaining
515
- def apply_styles_from(source_path, strategy: :keep_existing)
516
- source_doc = Uniword.load(source_path)
517
- styles_configuration.merge(source_doc.styles_configuration,
518
- conflict_resolution: strategy)
519
- self
520
- end
521
-
522
- # Apply both theme and styles from a template document
523
- #
524
- # @param template_path [String] Path to template .docx file
525
- # @param strategy [Symbol] Conflict resolution strategy for styles
526
- # @return [self] For method chaining
527
- def apply_template(template_path, strategy: :keep_existing)
528
- template_doc = Uniword.load(template_path)
529
- self.theme = template_doc.theme.dup if template_doc.theme
530
- styles_configuration.merge(template_doc.styles_configuration,
531
- conflict_resolution: strategy)
532
- self
533
- end
534
-
535
356
  # Custom inspect for readable output
536
357
  #
537
358
  # @return [String] Human-readable representation
@@ -548,18 +369,6 @@ module Uniword
548
369
 
549
370
  private
550
371
 
551
- # Ensure the document carries a theme part, creating it from a
552
- # fresh parse of the bundled Office theme when absent (every
553
- # document gets its own copy — no shared state).
554
- #
555
- # @param transformation [Themes::ThemeTransformation] Converter
556
- # @return [void]
557
- def ensure_theme!(transformation)
558
- return if theme&.theme_elements
559
-
560
- self.theme = transformation.default_office_theme
561
- end
562
-
563
372
  # Run model-level validation rules against this document.
564
373
  #
565
374
  # @return [Array<Validation::Report::ValidationIssue>] issues found