uniword 1.3.0 → 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 (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
@@ -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
@@ -98,45 +98,24 @@ module Uniword
98
98
  end
99
99
  end
100
100
 
101
- # -- Element order --
101
+ # -- Element order (delegates to Ooxml::ElementOrder) --
102
102
 
103
+ # Idempotent singleton insert in schema position.
103
104
  def ensure_element_in_order(model, tag_name, after: nil, before: nil)
104
- order = model.element_order
105
- return unless order
106
-
107
- return if order.any? { |e| e.name == tag_name }
108
-
109
- entry = Lutaml::Xml::Element.new("Element", tag_name)
110
-
111
- if after
112
- idx = order.index { |e| e.name == after }
113
- thaw_and_insert(model, order, idx ? idx + 1 : order.size, entry)
114
- elsif before
115
- idx = order.index { |e| e.name == before }
116
- thaw_and_insert(model, order, idx || 0, entry)
117
- else
118
- thaw_and_append(model, order, entry)
119
- end
105
+ Ooxml::ElementOrder.insert_once(model, tag_name,
106
+ after: after, before: before)
120
107
  end
121
108
 
109
+ # Positional insert for repeatable elements.
122
110
  def insert_element_order(obj, name, position)
123
111
  order = obj.element_order
124
112
  return unless order
125
-
126
113
  return if order.any? { |e| e.name == name }
127
114
 
128
- entry = Lutaml::Xml::Element.new("Element", name, node_type: :element)
129
- thaw_and_insert(obj, order, position, entry)
130
- end
131
-
132
- # lutaml-model freezes element_order after XML parsing.
133
- # Replace the frozen array with a mutable copy when modification is needed.
134
- def thaw_and_insert(model, order, position, entry)
135
- model.element_order = order.dup.insert(position, entry)
136
- end
137
-
138
- def thaw_and_append(model, order, entry)
139
- model.element_order = order.dup << entry
115
+ Ooxml::ElementOrder.insert_at(
116
+ obj, position, Lutaml::Xml::Element.new("Element", name,
117
+ node_type: :element)
118
+ )
140
119
  end
141
120
 
142
121
  # -- Run utilities (delegate to RunUtils) --
@@ -110,8 +110,7 @@ module Uniword
110
110
  [Ooxml::PartRegistry.find_by_key(:comments), package.comments],
111
111
  ]
112
112
 
113
- sources = package.bibliography_sources ||
114
- package.document&.bibliography_sources
113
+ sources = package.document&.bibliography_sources
115
114
  if sources
116
115
  defs << [Ooxml::PartRegistry.find_by_key(:bibliography), sources]
117
116
  end
@@ -172,10 +171,10 @@ module Uniword
172
171
  end
173
172
 
174
173
  chart = Ooxml::PartRegistry.find_by_key(:chart)
175
- (package.document&.chart_parts&.values || []).each do |data|
176
- next unless data[:target]
174
+ (package.document&.chart_parts&.values || []).each do |part|
175
+ next unless part.target
177
176
 
178
- alloc.alloc_rid(target: data[:target], type: chart.rel_type)
177
+ alloc.alloc_rid(target: part.target, type: chart.rel_type)
179
178
  end
180
179
  end
181
180
 
@@ -483,86 +483,11 @@ module Uniword
483
483
  segments.join("/")
484
484
  end
485
485
 
486
- # Package paths the save path emits, derived from the model —
487
- # mirrors serialize_package_parts / inject_* emission.
486
+ # Package paths the save path emits — answered by the
487
+ # PartRegistry (single authority), derived from model presence
488
+ # and part collections.
488
489
  def carried_part_paths
489
- paths = carried_word_parts + carried_docprops_parts
490
- paths.concat(custom_xml_item_paths)
491
- paths.concat(document_part_paths)
492
- paths.to_set
493
- end
494
-
495
- # [model, package path] pairs for single-instance word/ parts.
496
- def carried_word_parts
497
- pairs = core_word_pairs + note_word_pairs
498
- pairs.filter_map { |model, path| path if model }
499
- end
500
-
501
- def core_word_pairs
502
- [
503
- [package.document, "word/document.xml"],
504
- [package.styles, "word/styles.xml"],
505
- [package.numbering, "word/numbering.xml"],
506
- [package.settings, "word/settings.xml"],
507
- [package.font_table, "word/fontTable.xml"],
508
- [package.web_settings, "word/webSettings.xml"],
509
- ]
510
- end
511
-
512
- def note_word_pairs
513
- [
514
- [package.theme, "word/theme/theme1.xml"],
515
- [package.footnotes, "word/footnotes.xml"],
516
- [package.endnotes, "word/endnotes.xml"],
517
- [package.comments, "word/comments.xml"],
518
- [package.document&.bibliography_sources, "word/sources.xml"],
519
- ]
520
- end
521
-
522
- # [model, package path] pairs for single-instance docProps/ parts.
523
- def carried_docprops_parts
524
- pairs = [
525
- [package.core_properties, "docProps/core.xml"],
526
- [package.app_properties, "docProps/app.xml"],
527
- [package.custom_properties, "docProps/custom.xml"],
528
- ]
529
- pairs.filter_map { |model, path| path if model }
530
- end
531
-
532
- def custom_xml_item_paths
533
- (package.custom_xml_items || []).flat_map do |item|
534
- paths = ["customXml/item#{item[:index]}.xml"]
535
- if item[:props_xml]
536
- paths << "customXml/itemProps#{item[:index]}.xml"
537
- end
538
- paths
539
- end
540
- end
541
-
542
- # Paths emitted from the document model: media, charts,
543
- # embeddings, headers and footers (unified part store).
544
- def document_part_paths
545
- doc = package.document
546
- return [] unless doc
547
-
548
- media_chart_paths(doc) + header_footer_paths(doc)
549
- end
550
-
551
- def media_chart_paths(doc)
552
- paths = [doc.image_parts, doc.chart_parts].compact.flat_map do |parts|
553
- parts.values.map { |data| "word/#{data[:target]}" }
554
- end
555
- paths.concat(embedding_paths)
556
- end
557
-
558
- def embedding_paths
559
- (package.embeddings || {}).keys.map { |target| "word/#{target}" }
560
- end
561
-
562
- def header_footer_paths(doc)
563
- doc.header_footer_parts.filter_map do |part|
564
- part.target && "word/#{part.target}"
565
- end
490
+ Ooxml::PartRegistry.emitted_paths(package)
566
491
  end
567
492
 
568
493
  # -- Traversal helpers --
data/lib/uniword/docx.rb CHANGED
@@ -9,7 +9,9 @@ module Uniword
9
9
  module Docx
10
10
  autoload :IdAllocator, "#{__dir__}/docx/id_allocator"
11
11
  autoload :Part, "#{__dir__}/docx/part"
12
+ autoload :RawPart, "#{__dir__}/docx/raw_part"
12
13
  autoload :ChartPart, "#{__dir__}/docx/chart_part"
14
+ autoload :ImagePart, "#{__dir__}/docx/image_part"
13
15
  autoload :HeaderFooterPart, "#{__dir__}/docx/header_footer_part"
14
16
  autoload :CustomXmlItem, "#{__dir__}/docx/custom_xml_item"
15
17
  autoload :PartCollection, "#{__dir__}/docx/part_collection"
@@ -21,6 +23,7 @@ module Uniword
21
23
  autoload :PackageIntegrityChecker,
22
24
  "#{__dir__}/docx/package_integrity_checker"
23
25
  autoload :PackageSerialization, "#{__dir__}/docx/package_serialization"
26
+ autoload :PartLoader, "#{__dir__}/docx/part_loader"
24
27
  autoload :Profile, "#{__dir__}/docx/profile"
25
28
  autoload :DocumentStatistics, "#{__dir__}/docx/document_statistics"
26
29
  autoload :Reconciler, "#{__dir__}/docx/reconciler"
@@ -7,9 +7,9 @@ module Uniword
7
7
  module Images
8
8
  # Orchestrator for image operations on a DocumentRoot.
9
9
  #
10
- # Works with the document's +image_parts+ hash (populated during DOCX
11
- # loading) and the package ZIP to enumerate, extract, insert, and
12
- # remove images.
10
+ # Works with the document's +image_parts+ collection (populated
11
+ # during DOCX loading) and the package ZIP to enumerate, extract,
12
+ # insert, and remove images.
13
13
  #
14
14
  # @example List images
15
15
  # doc = Uniword::DocumentFactory.from_file("report.docx")
@@ -50,15 +50,15 @@ module Uniword
50
50
  parts = @document.image_parts
51
51
  return [] unless parts && !parts.empty?
52
52
 
53
- parts.map do |_r_id, entry|
54
- data = entry[:data]
53
+ parts.map do |_r_id, part|
54
+ data = part.data
55
55
  px_w, px_h = detect_pixel_dimensions(data) if data
56
- name = File.basename(entry[:target].to_s)
56
+ name = File.basename(part.target.to_s)
57
57
 
58
58
  ImageInfo.new(
59
59
  name: name,
60
- path: "word/#{entry[:target]}",
61
- content_type: entry[:content_type].to_s,
60
+ path: "word/#{part.target}",
61
+ content_type: part.content_type.to_s,
62
62
  size: data ? data.bytesize : 0,
63
63
  width: px_w,
64
64
  height: px_h,
@@ -79,11 +79,11 @@ module Uniword
79
79
  parts = @document.image_parts
80
80
  return 0 unless parts && !parts.empty?
81
81
 
82
- parts.each_value do |entry|
83
- data = entry[:data]
82
+ parts.each_value do |part|
83
+ data = part.data
84
84
  next unless data
85
85
 
86
- filename = File.basename(entry[:target].to_s)
86
+ filename = File.basename(part.target.to_s)
87
87
  File.binwrite(File.join(output_dir, filename), data)
88
88
  end
89
89
 
@@ -160,8 +160,8 @@ module Uniword
160
160
  parts = @document.image_parts
161
161
  return false unless parts && !parts.empty?
162
162
 
163
- key = parts.find do |_k, entry|
164
- File.basename(entry[:target].to_s) == image_name
163
+ key = parts.find do |_k, part|
164
+ File.basename(part.target.to_s) == image_name
165
165
  end&.first
166
166
 
167
167
  return false unless key
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Ooxml
5
+ # Safe mutation of lutaml-model `element_order` arrays.
6
+ #
7
+ # lutaml-model freezes `element_order` on parsed models; mutating
8
+ # it raises FrozenError. These helpers thaw on demand (dup +
9
+ # assign back) and insert entries in schema position — the single
10
+ # mechanism for the whole library, not a monkey-patch.
11
+ #
12
+ # @example Thaw and append
13
+ # order = Ooxml::ElementOrder.mutable_order(footnotes)
14
+ # order << Lutaml::Xml::Element.new("Element", "footnote")
15
+ #
16
+ # @example Idempotent singleton insert
17
+ # Ooxml::ElementOrder.insert_once(settings, "updateFields",
18
+ # after: "characterSpacingControl")
19
+ module ElementOrder
20
+ # Return the model's element_order as a mutable array.
21
+ #
22
+ # The frozen parsed array is replaced by a dup assigned back to
23
+ # the model; already-mutable arrays are returned as-is (so
24
+ # repeated calls keep mutating the same registered array).
25
+ #
26
+ # @param model [Lutaml::Model::Serializable] model to thaw
27
+ # @return [Array, nil] mutable element_order, or nil when the
28
+ # model carries none
29
+ def mutable_order(model)
30
+ order = model.element_order
31
+ return unless order
32
+ return order unless order.frozen?
33
+
34
+ model.element_order = order.dup
35
+ end
36
+ module_function :mutable_order
37
+
38
+ # Append an entry (repeatable elements allowed).
39
+ #
40
+ # @param model [Lutaml::Model::Serializable] model to mutate
41
+ # @param entry [Lutaml::Xml::Element] entry to append
42
+ # @return [Array, nil] the mutated order
43
+ def append(model, entry)
44
+ mutable_order(model)&.<<(entry)
45
+ end
46
+ module_function :append
47
+
48
+ # Insert an entry at an index (repeatable elements allowed).
49
+ #
50
+ # @param model [Lutaml::Model::Serializable] model to mutate
51
+ # @param position [Integer] insertion index
52
+ # @param entry [Lutaml::Xml::Element] entry to insert
53
+ # @return [Array, nil] the mutated order
54
+ def insert_at(model, position, entry)
55
+ mutable_order(model)&.insert(position, entry)
56
+ end
57
+ module_function :insert_at
58
+
59
+ # Insert a singleton element in schema position, by name.
60
+ # Idempotent: no-op when an entry of that name already exists.
61
+ # Missing anchors fall back to end (after:) or start (before:).
62
+ #
63
+ # @param model [Lutaml::Model::Serializable] model to mutate
64
+ # @param name [String] element name to insert
65
+ # @param after [String, nil] insert after this element name
66
+ # @param before [String, nil] insert before this element name
67
+ # @param position [Integer, nil] explicit index (when neither
68
+ # after nor before is given; defaults to end)
69
+ # @return [Array, nil] the mutated order
70
+ def insert_once(model, name, after: nil, before: nil, position: nil)
71
+ order = mutable_order(model)
72
+ return unless order
73
+ return if order.any? { |e| e.name == name }
74
+
75
+ idx = insertion_index(order, after, before, position)
76
+ order.insert(idx, Lutaml::Xml::Element.new("Element", name))
77
+ end
78
+ module_function :insert_once
79
+
80
+ # @return [Integer] index for the insert
81
+ def insertion_index(order, after, before, position)
82
+ if after
83
+ anchor = order.index { |e| e.name == after }
84
+ anchor ? anchor + 1 : order.size
85
+ elsif before
86
+ order.index { |e| e.name == before } || 0
87
+ else
88
+ position || order.size
89
+ end
90
+ end
91
+ module_function :insertion_index
92
+ end
93
+ end
94
+ end