coradoc-adoc 2.0.22 → 2.0.23

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42db73896b69e9e5344105625afe528bd2b7b8f57a11822f61881e782be8b974
4
- data.tar.gz: bc3e5e3073992f0d2b71e12ab62fa07d11cdf6ded25299d673f863522cd6291d
3
+ metadata.gz: 69bc04902f8020decc17928a5397de2876f060ed8ee36de95f0920ba6312e8b4
4
+ data.tar.gz: ca56574d1b78863a574551732397ff65944f2b012781def3bb4ea4bcf8b0aaf1
5
5
  SHA512:
6
- metadata.gz: 334c5ae5f1860ccde94bb518e8bc7f91be7dcce338d863d93ec137acbfedce8f742be44336fde3e4cec7ea6709b3f309c484c716ceb8c485da2830312515d59e
7
- data.tar.gz: 3f387aab6c8b6d8862319593b9c2b8c2304758c4c66f15aed0e5ac57209f2f35b4930d1d95a57b2cf4d940834599db35395546c81b8d7760384062d0a7a48d49
6
+ metadata.gz: 708cf53d7a9c6de58e15aac99a1e77343e004b7d014697335cd91f96143584b0701235e84030948a5ab6a09eba8612a361b215b1be9707ca07943d3f7179ad09
7
+ data.tar.gz: ff60d697d4fff0ec229ca5b597f1fe2f37d34129bff79f10276b72824ecd418f770d9229800dee6f143f95345af0c1432aceb100385b6c578fd08abb01a3767a
@@ -87,6 +87,20 @@ module Coradoc
87
87
  )
88
88
  end
89
89
 
90
+ # Remove the first named attribute matching `name` and return its
91
+ # scalar value (or nil if absent). Used by promoters that lift a
92
+ # named attr out of the residual bag into a typed field.
93
+ # @param name [String, Symbol]
94
+ # @return [String, nil]
95
+ def delete_named(name)
96
+ name_str = name.to_s
97
+ idx = named.index { |n| n.name.to_s == name_str }
98
+ return nil unless idx
99
+
100
+ removed = @named.delete_at(idx)
101
+ removed.value.first&.to_s
102
+ end
103
+
90
104
  # Validate named attributes against validators
91
105
  #
92
106
  # @param validators [Hash] Hash of name => matcher pairs
@@ -209,12 +223,24 @@ module Coradoc
209
223
  positional.empty? && named.empty?
210
224
  end
211
225
 
212
- # Get a named attribute value by name
226
+ # Get the scalar value of the first named attribute matching `name`.
227
+ # Returns the first element of the underlying multi-value array, or
228
+ # nil if the name is absent. Use {#fetch_all} to retrieve the full
229
+ # multi-value array.
213
230
  # @param name [String, Symbol] The attribute name
214
- # @return [Object, nil] The attribute value or nil if not found
231
+ # @return [String, nil]
215
232
  def [](name)
216
233
  name_str = name.to_s
217
- named.find { |n| n.name.to_s == name_str }&.value
234
+ named.find { |n| n.name.to_s == name_str }&.value&.first&.to_s
235
+ end
236
+
237
+ # Get the full multi-value array for a named attribute.
238
+ # @param name [String, Symbol]
239
+ # @return [Array<String>] (empty when absent)
240
+ def fetch_all(name)
241
+ name_str = name.to_s
242
+ found = named.find { |n| n.name.to_s == name_str }
243
+ found ? found.value : []
218
244
  end
219
245
 
220
246
  # Get a named attribute value with default
@@ -222,7 +248,8 @@ module Coradoc
222
248
  # @param default [Object] The default value if not found
223
249
  # @return [Object] The attribute value or default
224
250
  def fetch(name, default = nil)
225
- self[name] || default
251
+ value = self[name]
252
+ value.nil? ? default : value
226
253
  end
227
254
  end
228
255
  end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module AsciiDoc
5
+ module Model
6
+ module Image
7
+ # Pure-function promoter: lifts semantically meaningful slots out of
8
+ # a generic {Model::AttributeList} into the typed fields declared on
9
+ # an image class, and the inverse — composes a serialisable
10
+ # AttributeList from typed fields + residual.
11
+ #
12
+ # The promotion map is owned by the target class itself, via
13
+ # +promoted_positional+ and +promoted_named+. This module is the
14
+ # single place that consumes those declarations, so adding a new
15
+ # promoted field is a one-line class-level change (OCP).
16
+ #
17
+ # Positional slots are consumed by index; named slots are removed
18
+ # from the residual list so they don't appear twice. The residual
19
+ # list preserves order and identity for everything that wasn't
20
+ # promoted (e.g. +scaledwidth+, +pdfwidth+, +opts+).
21
+ #
22
+ # @example Extract for an inline image
23
+ # list = Model::AttributeList.new
24
+ # list.add_positional('Alt', 'Thumb')
25
+ # list.add_named('width', '640')
26
+ # extracted, residual = AttributeExtractor.call(list, InlineImage)
27
+ # extracted # => { alt: 'Alt', role: 'Thumb', width: '640' }
28
+ # residual.positional # => []
29
+ # residual.named # => []
30
+ #
31
+ # @example Compose for serialisation
32
+ # AttributeExtractor.compose(image)
33
+ # # => Model::AttributeList with positional [alt, role] + named
34
+ # # [width, height, link] + residual attrs, in declaration order
35
+ #
36
+ module AttributeExtractor
37
+ module_function
38
+
39
+ # @param attribute_list [Model::AttributeList, nil]
40
+ # @param target_class [Class] an Image::Core subclass
41
+ # @return [Array<(Hash{Symbol=>String}, Model::AttributeList)>]
42
+ # a tuple of promoted typed values and the residual list
43
+ def call(attribute_list, target_class)
44
+ source = attribute_list || Model::AttributeList.new
45
+ residual = Model::AttributeList.new
46
+ extracted = {}
47
+
48
+ promote_positional(extracted, residual, source, target_class)
49
+ promote_named(extracted, residual, source, target_class)
50
+
51
+ [extracted, residual]
52
+ end
53
+
54
+ # Inverse of {call}: rebuild a serialisable AttributeList from a
55
+ # model's typed fields plus its residual list. Used by the AsciiDoc
56
+ # image serializer so round-trips reproduce the original syntax.
57
+ #
58
+ # A field that's declared in both +promoted_positional+ and
59
+ # +promoted_named+ (e.g. inline image +role+) is emitted only
60
+ # once — via its positional slot when filled, otherwise via named.
61
+ # @param model [Coradoc::AsciiDoc::Model::Image::Core]
62
+ # @return [Model::AttributeList]
63
+ def compose(model)
64
+ composed = Model::AttributeList.new
65
+ filled = compose_positional(composed, model)
66
+ compose_named(composed, model, filled)
67
+ append_residual(composed, model.attributes)
68
+ composed
69
+ end
70
+
71
+ def compose_positional(composed, model)
72
+ filled = []
73
+ model.class.promoted_positional.each do |attr_name|
74
+ value = model.public_send(attr_name)
75
+ next if value.nil? || value.to_s.empty?
76
+
77
+ composed.add_positional(value.to_s)
78
+ filled << attr_name
79
+ end
80
+ filled
81
+ end
82
+
83
+ def compose_named(composed, model, filled)
84
+ model.class.promoted_named.each do |attr_name|
85
+ next if filled.include?(attr_name)
86
+
87
+ value = model.public_send(attr_name)
88
+ next if value.nil? || value.to_s.empty?
89
+
90
+ composed.add_named(attr_name.to_s, value.to_s)
91
+ end
92
+ end
93
+
94
+ def promote_positional(extracted, residual, source, target_class)
95
+ promoted = target_class.promoted_positional
96
+ source.positional.each_with_index do |positional_attr, index|
97
+ attr_name = promoted[index]
98
+ value = positional_attr.value
99
+ if attr_name && !value.to_s.empty?
100
+ extracted[attr_name] = value.to_s
101
+ else
102
+ residual.add_positional(value)
103
+ end
104
+ end
105
+ end
106
+
107
+ def promote_named(extracted, residual, source, target_class)
108
+ promoted_names = target_class.promoted_named.to_set(&:to_s)
109
+ source.named.each do |named_attr|
110
+ promote_one_named(extracted, residual, named_attr, promoted_names)
111
+ end
112
+ end
113
+
114
+ def promote_one_named(extracted, residual, named_attr, promoted_names)
115
+ name_str = named_attr.name.to_s
116
+ unless promoted_names.include?(name_str)
117
+ residual.add_named(named_attr.name, named_attr.value)
118
+ return
119
+ end
120
+ # Positional promotion wins: a slot already filled positionally
121
+ # is not overwritten by a same-named entry (rare, but possible
122
+ # when both `[alt, role, role=X]` are supplied).
123
+ key = name_str.to_sym
124
+ return if extracted.key?(key)
125
+
126
+ value = named_attr.value.first&.to_s
127
+ return if value.nil? || value.empty?
128
+
129
+ extracted[key] = value
130
+ end
131
+
132
+ def append_residual(composed, residual)
133
+ return unless residual.is_a?(Model::AttributeList)
134
+
135
+ residual.positional.each { |p| composed.add_positional(p.value) }
136
+ residual.named.each { |n| composed.add_named(n.name, n.value) }
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -18,6 +18,14 @@ module Coradoc
18
18
  default: lambda {
19
19
  ::Coradoc::AsciiDoc::Model::AttributeList.new
20
20
  }
21
+
22
+ # Block images support the legacy positional form
23
+ # `image::target[alt, caption, role, ...]`, so the 2nd positional
24
+ # is promoted to `caption` (Asciidoctor image block macro shorthand).
25
+ # @return [Array<Symbol>]
26
+ def self.promoted_positional
27
+ %i[alt caption]
28
+ end
21
29
  end
22
30
  end
23
31
  end
@@ -9,30 +9,21 @@ module Coradoc
9
9
  # Images can be block-level (standalone paragraphs) or inline (within text).
10
10
  # This base class provides common functionality for both types.
11
11
  #
12
- # @!attribute [r] id
13
- # @return [String, nil] Optional identifier for the image
12
+ # Typed promotion of attribute-list slots
13
+ # ---------------------------------------
14
14
  #
15
- # @!attribute [r] title
16
- # @return [String, nil] Optional image title/alt text
17
- #
18
- # @!attribute [r] src
19
- # @return [String] The image source URL or path
20
- #
21
- # @!attribute [r] attributes
22
- # @return [Coradoc::AsciiDoc::Model::Image::Core::AttributeList] Image-specific attributes
23
- #
24
- # @!attribute [r] annotate_missing
25
- # @return [String, nil] Annotation text for missing images
26
- #
27
- # @!attribute [r] line_break
28
- # @return [String] Line break character (default: "")
29
- #
30
- # @!attribute [r] colons
31
- # @return [String, nil] Colon positioning for attributes
32
- #
33
- # @see Coradoc::AsciiDoc::Model::Image::BlockImage Block-level images
34
- # @see Coradoc::AsciiDoc::Model::Image::InlineImage Inline images
15
+ # Semantically meaningful image attributes (`alt`, `role`, `width`,
16
+ # `height`, `link`) are declared as typed lutaml-model fields on
17
+ # `Core` itself — not as validators on a generic bag. The class-level
18
+ # {promoted_positional} and {promoted_named} methods are the single
19
+ # source of truth for which slots get lifted into typed fields and in
20
+ # what order; subclasses override them to reflect syntax differences
21
+ # (e.g. inline images treat the 2nd positional as `role`, block images
22
+ # do not).
35
23
  #
24
+ # The lift itself is performed by {AttributeExtractor}, a pure function
25
+ # over (AttributeList, target_class) → (extracted_hash, residual_list).
26
+ # Anything not promoted stays in `attributes` for round-trip fidelity.
36
27
  class Core < Coradoc::AsciiDoc::Model::Base
37
28
  # Autoload nested AttributeList class
38
29
  autoload :AttributeList, 'coradoc/asciidoc/model/image/core/attribute_list'
@@ -42,6 +33,12 @@ module Coradoc
42
33
  attribute :id, :string
43
34
  attribute :title, :string
44
35
  attribute :src, :string
36
+ attribute :alt, :string
37
+ attribute :caption, :string
38
+ attribute :role, :string
39
+ attribute :width, :string
40
+ attribute :height, :string
41
+ attribute :link, :string
45
42
  attribute :attributes,
46
43
  Coradoc::AsciiDoc::Model::Image::Core::AttributeList,
47
44
  default: lambda {
@@ -51,17 +48,29 @@ module Coradoc
51
48
  attribute :line_break, :string, default: -> { '' }
52
49
  attribute :colons, :string
53
50
 
54
- # Aliases for common attribute accessors
55
51
  alias path src
56
- alias alt title
52
+
53
+ # Positional attribute-list slots that this image class promotes to
54
+ # typed fields, in order. Subclasses override to reflect their
55
+ # syntax. Index 0 → alt for all image kinds; index 1 → role for
56
+ # inline images only.
57
+ # @return [Array<Symbol>]
58
+ def self.promoted_positional
59
+ %i[alt]
60
+ end
61
+
62
+ # Named attribute-list keys that this image class promotes to typed
63
+ # fields. The same set applies to both inline and block images.
64
+ # @return [Array<Symbol>]
65
+ def self.promoted_named
66
+ %i[width height link role]
67
+ end
57
68
 
58
69
  # Custom to_adoc implementation that uses ElementRegistry directly
59
70
  # to avoid recursion issues with image serialization.
60
71
  #
61
72
  # @return [String] AsciiDoc representation of this image
62
73
  def to_adoc
63
- # Use the registered serializer rather than Coradoc::AsciiDoc::Serializer.serialize
64
- # to avoid recursion
65
74
  serializer_class = Coradoc::AsciiDoc::Serializer::ElementRegistry.lookup(self.class)
66
75
  serializer_class.new.to_adoc(self)
67
76
  end
@@ -10,6 +10,13 @@ module Coradoc
10
10
  end
11
11
 
12
12
  attribute :colons, :string, default: -> { ':' }
13
+
14
+ # Inline images use the 2nd positional slot for the role, per
15
+ # Asciidoctor: `image:target[alt, role, width=N, ...]`.
16
+ # @return [Array<Symbol>]
17
+ def self.promoted_positional
18
+ %i[alt role]
19
+ end
13
20
  end
14
21
  end
15
22
  end
@@ -8,6 +8,7 @@ module Coradoc
8
8
  autoload :Core, 'coradoc/asciidoc/model/image/core'
9
9
  autoload :InlineImage, 'coradoc/asciidoc/model/image/inline_image'
10
10
  autoload :BlockImage, 'coradoc/asciidoc/model/image/block_image'
11
+ autoload :AttributeExtractor, 'coradoc/asciidoc/model/image/attribute_extractor'
11
12
  end
12
13
  end
13
14
  end
@@ -44,7 +44,10 @@ module Coradoc
44
44
  end
45
45
 
46
46
  def positional_value_unquoted
47
- match('[^\],]').repeat(1)
47
+ # Exclude `=` so that `key=value` is matched by `named_attribute`
48
+ # rather than swallowed as a single positional token. A positional
49
+ # value that legitimately contains `=` must be quoted.
50
+ match('[^\],=]').repeat(1)
48
51
  end
49
52
 
50
53
  def positional_value_single_quote
@@ -14,7 +14,8 @@ module Coradoc
14
14
  end
15
15
  _anchor = model.anchor.nil? ? '' : "#{serialize_child(model.anchor)}\n"
16
16
  _title = model.title.to_s.empty? ? '' : ".#{model.title}\n"
17
- attrs = serialize_child(model.attributes, options)
17
+ composed = Model::Image::AttributeExtractor.compose(model)
18
+ attrs = serialize_child(composed, options)
18
19
  [missing, _anchor, _title, 'image', model.colons, model.src, attrs,
19
20
  model.line_break].join
20
21
  end
@@ -24,6 +25,7 @@ module Coradoc
24
25
  # Self-register this serializer
25
26
  ElementRegistry.register(Coradoc::AsciiDoc::Model::Image::Core, Image::Core)
26
27
  ElementRegistry.register(Coradoc::AsciiDoc::Model::Image::BlockImage, Image::Core)
28
+ ElementRegistry.register(Coradoc::AsciiDoc::Model::Image::InlineImage, Image::Core)
27
29
  end
28
30
  end
29
31
  end
@@ -26,24 +26,22 @@ module Coradoc
26
26
  end
27
27
 
28
28
  def transform_image(image)
29
- src = image.src.to_s
30
- src = src[1..] if src.start_with?(':')
31
- positional = image.attributes&.positional || []
32
- positional_alt = positional.first&.value&.to_s || ''
33
- caption = positional[1]&.value&.to_s
34
- title = image.title&.to_s
35
- # AsciiDoc block-title (`.Caption`) is semantically a caption,
36
- # but for compatibility with the existing pipeline that treated
37
- # it as alt when no positional alt was supplied, fall back to it.
38
- alt = positional_alt.empty? ? title : positional_alt
39
- Coradoc::CoreModel::Image.new(
40
- src: src,
41
- alt: alt,
42
- caption: caption,
43
- title: title,
44
- width: image.attributes&.[]('width'),
45
- height: image.attributes&.[]('height')
46
- )
29
+ Coradoc::CoreModel::Image.new(**image_attributes(image))
30
+ end
31
+
32
+ def image_attributes(image)
33
+ {
34
+ src: normalize_image_src(image.src),
35
+ alt: image.alt, title: image.title, caption: image.caption,
36
+ width: image.width, height: image.height,
37
+ link: image.link, role: image.role,
38
+ inline: image.is_a?(Coradoc::AsciiDoc::Model::Image::InlineImage)
39
+ }
40
+ end
41
+
42
+ def normalize_image_src(src)
43
+ s = src.to_s
44
+ s.start_with?(':') ? s[1..] : s
47
45
  end
48
46
 
49
47
  def transform_bibliography(bib)
@@ -327,11 +327,17 @@ module Coradoc
327
327
  end
328
328
 
329
329
  def transform_image(image)
330
- Coradoc::AsciiDoc::Model::Image::BlockImage.new(
331
- src: image.src,
332
- title: image.alt,
333
- attributes: build_image_attributes(image)
334
- )
330
+ target_class = image.inline ? Coradoc::AsciiDoc::Model::Image::InlineImage
331
+ : Coradoc::AsciiDoc::Model::Image::BlockImage
332
+ target_class.new(**image_attributes(image))
333
+ end
334
+
335
+ def image_attributes(image)
336
+ {
337
+ src: image.src, alt: image.alt, title: image.title,
338
+ caption: image.caption, width: image.width, height: image.height,
339
+ link: image.link, role: image.role
340
+ }
335
341
  end
336
342
 
337
343
  def transform_bibliography(bib)
@@ -530,6 +536,10 @@ module Coradoc
530
536
  content.map { |item| create_text_elements(item) }
531
537
  when Coradoc::CoreModel::InlineElement
532
538
  transform_inline(content)
539
+ when Coradoc::CoreModel::TextContent
540
+ Coradoc::AsciiDoc::Model::TextElement.new(content: content.text.to_s)
541
+ when Coradoc::CoreModel::Base
542
+ transform(content)
533
543
  when Coradoc::AsciiDoc::Model::Base
534
544
  content
535
545
  when Lutaml::Model::Serializable
@@ -555,13 +565,6 @@ module Coradoc
555
565
  attrs
556
566
  end
557
567
 
558
- def build_image_attributes(image)
559
- attrs = {}
560
- attrs['width'] = image.width if image.width
561
- attrs['height'] = image.height if image.height
562
- attrs
563
- end
564
-
565
568
  def default_marker(marker_type)
566
569
  case marker_type
567
570
  when 'ordered' then '.'
@@ -54,12 +54,16 @@ module Coradoc
54
54
  title = block_image[:title]
55
55
  path = block_image[:path]
56
56
  attrs = AttributeListNormalizer.coerce(block_image[:attribute_list_macro])
57
+ promoted, residual = Model::Image::AttributeExtractor.call(
58
+ attrs, Model::Image::BlockImage
59
+ )
57
60
  Model::Image::BlockImage.new(
58
61
  title: title,
59
62
  id: id,
60
63
  src: path,
61
- attributes: attrs,
62
- line_break: "\n"
64
+ attributes: residual,
65
+ line_break: "\n",
66
+ **promoted
63
67
  )
64
68
  end
65
69
  end
@@ -37,9 +37,13 @@ module Coradoc
37
37
  # Inline image
38
38
  rule(inline_image: subtree(:inline_image)) do
39
39
  attrs = AttributeListNormalizer.coerce(inline_image[:attribute_list])
40
+ promoted, residual = Model::Image::AttributeExtractor.call(
41
+ attrs, Model::Image::InlineImage
42
+ )
40
43
  Model::Image::InlineImage.new(
41
44
  src: inline_image[:path],
42
- attributes: attrs
45
+ attributes: residual,
46
+ **promoted
43
47
  )
44
48
  end
45
49
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Coradoc
4
4
  module AsciiDoc
5
- VERSION = '2.0.22'
5
+ VERSION = '2.0.23'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coradoc-adoc
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.22
4
+ version: 2.0.23
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -146,6 +146,7 @@ files:
146
146
  - lib/coradoc/asciidoc/model/header.rb
147
147
  - lib/coradoc/asciidoc/model/highlight.rb
148
148
  - lib/coradoc/asciidoc/model/image.rb
149
+ - lib/coradoc/asciidoc/model/image/attribute_extractor.rb
149
150
  - lib/coradoc/asciidoc/model/image/block_image.rb
150
151
  - lib/coradoc/asciidoc/model/image/block_image/attribute_list.rb
151
152
  - lib/coradoc/asciidoc/model/image/core.rb