metanorma-mirror 1.0.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +25 -0
  3. data/README.adoc +41 -0
  4. data/lib/metanorma/mirror/class_table.rb +50 -0
  5. data/lib/metanorma/mirror/default_registry.rb +23 -0
  6. data/lib/metanorma/mirror/handler_registry.rb +60 -0
  7. data/lib/metanorma/mirror/handler_result.rb +37 -0
  8. data/lib/metanorma/mirror/handlers/admonition.rb +18 -0
  9. data/lib/metanorma/mirror/handlers/example.rb +21 -0
  10. data/lib/metanorma/mirror/handlers/figure.rb +53 -0
  11. data/lib/metanorma/mirror/handlers/formula.rb +35 -0
  12. data/lib/metanorma/mirror/handlers/imagemap.rb +43 -0
  13. data/lib/metanorma/mirror/handlers/inline/catalog.rb +36 -0
  14. data/lib/metanorma/mirror/handlers/inline/rich_html_renderer.rb +132 -0
  15. data/lib/metanorma/mirror/handlers/inline/text_extractor.rb +114 -0
  16. data/lib/metanorma/mirror/handlers/inline.rb +155 -0
  17. data/lib/metanorma/mirror/handlers/list.rb +110 -0
  18. data/lib/metanorma/mirror/handlers/note.rb +26 -0
  19. data/lib/metanorma/mirror/handlers/paragraph.rb +19 -0
  20. data/lib/metanorma/mirror/handlers/quote.rb +16 -0
  21. data/lib/metanorma/mirror/handlers/review.rb +19 -0
  22. data/lib/metanorma/mirror/handlers/section.rb +149 -0
  23. data/lib/metanorma/mirror/handlers/sourcecode.rb +27 -0
  24. data/lib/metanorma/mirror/handlers/structural.rb +68 -0
  25. data/lib/metanorma/mirror/handlers/svgmap.rb +52 -0
  26. data/lib/metanorma/mirror/handlers/table.rb +81 -0
  27. data/lib/metanorma/mirror/handlers/term.rb +100 -0
  28. data/lib/metanorma/mirror/handlers.rb +62 -0
  29. data/lib/metanorma/mirror/id_strategy/positional.rb +151 -0
  30. data/lib/metanorma/mirror/id_strategy/preserve.rb +12 -0
  31. data/lib/metanorma/mirror/id_strategy.rb +34 -0
  32. data/lib/metanorma/mirror/math_util.rb +53 -0
  33. data/lib/metanorma/mirror/metadata.rb +37 -0
  34. data/lib/metanorma/mirror/model/container.rb +50 -0
  35. data/lib/metanorma/mirror/model/factory.rb +34 -0
  36. data/lib/metanorma/mirror/model/guide.rb +29 -0
  37. data/lib/metanorma/mirror/model/leaf.rb +17 -0
  38. data/lib/metanorma/mirror/model/mark.rb +39 -0
  39. data/lib/metanorma/mirror/model/node.rb +42 -0
  40. data/lib/metanorma/mirror/model/soft_break.rb +32 -0
  41. data/lib/metanorma/mirror/model/text.rb +27 -0
  42. data/lib/metanorma/mirror/model.rb +16 -0
  43. data/lib/metanorma/mirror/output/builder.rb +39 -0
  44. data/lib/metanorma/mirror/output/formats/base_format.rb +90 -0
  45. data/lib/metanorma/mirror/output/formats/inline_format.rb +142 -0
  46. data/lib/metanorma/mirror/output/formats.rb +53 -0
  47. data/lib/metanorma/mirror/output/pipeline.rb +109 -0
  48. data/lib/metanorma/mirror/output/pipeline_context.rb +22 -0
  49. data/lib/metanorma/mirror/output.rb +12 -0
  50. data/lib/metanorma/mirror/rewriter.rb +123 -0
  51. data/lib/metanorma/mirror/safe_attr.rb +16 -0
  52. data/lib/metanorma/mirror/serialization/json_serializer.rb +27 -0
  53. data/lib/metanorma/mirror/serialization/yaml_serializer.rb +20 -0
  54. data/lib/metanorma/mirror/serialization.rb +10 -0
  55. data/lib/metanorma/mirror/transformer.rb +110 -0
  56. data/lib/metanorma/mirror/version.rb +7 -0
  57. data/lib/metanorma/mirror.rb +98 -0
  58. metadata +197 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5461b164add6bdfedaeef8996c381ce470a4be96d23d289282ede46e46be49f2
4
+ data.tar.gz: 70fa254f365a47204598f6905e19d9a304690c911eb914767cfbbb3e163a4b2e
5
+ SHA512:
6
+ metadata.gz: 259835bf04a8dca813ad3f9d15fedd088a9493fc0897ffde4ccb66e33ee9bae4c76b2d3fd84cf4e945ffc7aa4d59b8b5f2d65c35ee4704447ce74864fe75eba1
7
+ data.tar.gz: 42b0b8fe0f6678a2f3258cba62ccd8e65e8098a5911390612a1a11fda8a10a6db3207d3fd18335b2191d03e555a7b472840a83eb8e483b1574111418127182f8
data/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, Ribose Inc.
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.adoc ADDED
@@ -0,0 +1,41 @@
1
+ = metanorma-mirror
2
+
3
+ The Mirror format: a lossless JSON projection of a Metanorma document
4
+ as a typed node tree — blocks, inline runs, marks — preserving document
5
+ order, attributes, and provenance. It is the wire contract consumed by
6
+ the SMART document reader and the HTML JS renderer, and it provides the
7
+ `mirror` provenance object embedded in MKO bundles.
8
+
9
+ == What lives here
10
+
11
+ * Node models (`Metanorma::Mirror::Model`) — lutaml-model Serializables
12
+ with key_value mappings; the JSON wire is the contract.
13
+ * The transformer walk, handler registry, and rewriter.
14
+ * Serializers (JSON, YAML) and the output pipeline.
15
+
16
+ == What does NOT live here
17
+
18
+ Model knowledge. Which document classes map to which handlers, mark
19
+ types, rich-HTML renderers, and id categories is *registered* by the
20
+ model gems: metanorma-document and metanorma-standoc seed the defaults
21
+ through the registration seams (`Mirror.register_default`,
22
+ `Mirror.mark_builders`, `Mirror.rich_html_renderers`,
23
+ `Mirror.simple_inline_elements`, `Mirror.inline_text_substitutions`,
24
+ `Mirror.semx_elements`, `Mirror.inline_content_iterator`,
25
+ `Mirror::IdStrategy::Positional.register_category`). A new flavor or
26
+ model registers; this gem never changes (OCP).
27
+
28
+ == Usage
29
+
30
+ [source,ruby]
31
+ ----
32
+ require "metanorma/mirror"
33
+ require "metanorma/document" # seeds the default registrations
34
+
35
+ tree = Metanorma::Mirror::Transformer.new.call(model)
36
+ json = Metanorma::Mirror::Serialization::JsonSerializer.serialize(tree)
37
+ ----
38
+
39
+ == License
40
+
41
+ BSD-2-Clause.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ # A class-keyed lookup with ancestor fallback, in registration
6
+ # order. Consumers register their own model classes to payloads
7
+ # (lambdas, strings, prefixes); the table — and therefore this gem —
8
+ # carries no model knowledge. The metanorma-document and
9
+ # metanorma-standoc seeds register their classes at load time.
10
+ class ClassTable
11
+ def initialize
12
+ @entries = {}
13
+ end
14
+
15
+ def register(klass, payload)
16
+ @entries[klass] = payload
17
+ self
18
+ end
19
+
20
+ def lookup(instance)
21
+ entry_for(instance.class) || ancestor_entry(instance.class)
22
+ end
23
+
24
+ def registered?(klass)
25
+ @entries.key?(klass)
26
+ end
27
+
28
+ def empty?
29
+ @entries.empty?
30
+ end
31
+
32
+ private
33
+
34
+ def entry_for(klass)
35
+ @entries[klass]
36
+ end
37
+
38
+ def ancestor_entry(klass)
39
+ klass.ancestors.each do |ancestor|
40
+ next if ancestor == klass
41
+ break if ancestor == Object
42
+
43
+ entry = @entries[ancestor]
44
+ return entry if entry
45
+ end
46
+ nil
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ # Builds the default handler registry from the registration hooks.
6
+ # Model gems (metanorma-document, metanorma-standoc, flavors) add
7
+ # their class-to-handler entries via Mirror.register_default blocks,
8
+ # run in registration order. The format layer carries no model
9
+ # knowledge (OCP: new model classes register, this file never
10
+ # changes).
11
+ module DefaultRegistry
12
+ class << self
13
+ def build
14
+ registry = HandlerRegistry.new
15
+ Mirror.default_hooks.each do |hook|
16
+ hook.call(registry)
17
+ end
18
+ registry
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ class HandlerRegistry
6
+ Entry = Struct.new(:callable, :concat, :extra_kwargs, keyword_init: true)
7
+
8
+ def initialize
9
+ @handlers = {}
10
+ end
11
+
12
+ def register(model_class, handler, method_name: :call, concat: false,
13
+ extra_kwargs: {})
14
+ callable = resolve_callable(handler, method_name)
15
+ @handlers[model_class] = Entry.new(
16
+ callable: callable,
17
+ concat: concat,
18
+ extra_kwargs: extra_kwargs,
19
+ )
20
+ end
21
+
22
+ def registered?(model_class)
23
+ @handlers.key?(model_class)
24
+ end
25
+
26
+ def entry_for(model_element)
27
+ @handlers[model_element.class] || ancestor_entry(model_element)
28
+ end
29
+
30
+ def handle(model_element, context:)
31
+ entry = entry_for(model_element)
32
+ return HandlerResult.none unless entry
33
+
34
+ kwargs = { context: context }.merge(entry.extra_kwargs || {})
35
+ result = entry.callable.call(model_element, **kwargs)
36
+ HandlerResult.new(result, concat: entry.concat)
37
+ end
38
+
39
+ private
40
+
41
+ def resolve_callable(handler, method_name)
42
+ return handler if handler.is_a?(Proc)
43
+ return handler.method(:call) if method_name == :call
44
+
45
+ handler.method(method_name)
46
+ end
47
+
48
+ def ancestor_entry(model_element)
49
+ model_element.class.ancestors.each do |ancestor|
50
+ next if ancestor == model_element.class
51
+ break if ancestor == Object
52
+
53
+ entry = @handlers[ancestor]
54
+ return entry if entry
55
+ end
56
+ nil
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ class HandlerResult
6
+ attr_reader :nodes
7
+
8
+ def self.none
9
+ new(nil, concat: false)
10
+ end
11
+
12
+ def initialize(nodes, concat: false)
13
+ @nodes = nodes
14
+ @concat = concat
15
+ end
16
+
17
+ def none?
18
+ @nodes.nil?
19
+ end
20
+
21
+ def concat?
22
+ @concat
23
+ end
24
+
25
+ def append_to(content)
26
+ return content if none?
27
+
28
+ if concat?
29
+ content.concat(Array(@nodes))
30
+ else
31
+ content << @nodes
32
+ end
33
+ content
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Handlers
6
+ module Admonition
7
+ EXTRA = { type: nil, target: nil, unnumbered: nil }.freeze
8
+
9
+ def self.call(element, context:)
10
+ attrs = Handlers.extract_attrs(element, extra_attrs: EXTRA)
11
+ content = context.extract_blocks(element)
12
+
13
+ Handlers.build_node("admonition", attrs: attrs, content: content)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Handlers
6
+ module Example
7
+ EXTRA = { unnumbered: nil, subsequence: nil }.freeze
8
+
9
+ def self.call(element, context:)
10
+ attrs = Handlers.extract_attrs(element, extra_attrs: EXTRA)
11
+ content = context.extract_named_collections(element,
12
+ %i[paragraphs formula
13
+ ul ol quote sourcecode
14
+ table figure dl])
15
+
16
+ Handlers.build_node("example", attrs: attrs, content: content)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Handlers
6
+ module Figure
7
+ def self.call(element, context:)
8
+ attrs = figure_attrs(element)
9
+ content = []
10
+
11
+ img = SafeAttr.read(element, :image)
12
+ if img
13
+ img_attrs = {}
14
+ src = SafeAttr.read(img, :source)
15
+ img_attrs[:src] =
16
+ src && !src.strip.empty? ? src : SafeAttr.read(img, :filename)
17
+ img_attrs[:alt] = SafeAttr.read(img, :alt)
18
+ img_attrs[:height] = SafeAttr.read(img, :height)
19
+ img_attrs[:width] = SafeAttr.read(img, :width)
20
+ content << Handlers.build_node("image", attrs: img_attrs.compact)
21
+ end
22
+
23
+ subfigures = SafeAttr.read(element, :figure)
24
+ subfigures&.each do |sub|
25
+ result = context.registry.handle(sub, context: context)
26
+ result.append_to(content)
27
+ end
28
+
29
+ notes = SafeAttr.read(element, :note)
30
+ notes&.each do |n|
31
+ result = context.registry.handle(n, context: context)
32
+ result.append_to(content)
33
+ end
34
+
35
+ Handlers.build_node("figure", attrs: attrs, content: content)
36
+ end
37
+
38
+ def self.figure_attrs(element)
39
+ attrs = {}
40
+ attrs[:id] = SafeAttr.read(element, :id)
41
+ attrs[:unnumbered] = SafeAttr.read(element, :unnumbered)
42
+ attrs[:width] = SafeAttr.read(element, :width)
43
+ attrs[:align] = SafeAttr.read(element, :align)
44
+ attrs[:semx_id] = SafeAttr.read(element, :semx_id)
45
+
46
+ name = SafeAttr.read(element, :name)
47
+ attrs[:title] = Handlers.extract_name_text(name) if name
48
+ attrs.compact
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Handlers
6
+ module Formula
7
+ EXTRA = { unnumbered: nil, inequality: nil }.freeze
8
+
9
+ def self.call(element, context:)
10
+ attrs = Handlers.extract_attrs(element, extra_attrs: EXTRA)
11
+
12
+ stem = SafeAttr.read(element, :stem)
13
+ if stem
14
+ attrs[:stem_type] = SafeAttr.read(stem, :stem_type)
15
+
16
+ asciimath = SafeAttr.read(stem, :asciimath)
17
+ if asciimath&.value && !asciimath.value.strip.empty?
18
+ attrs[:asciimath] =
19
+ asciimath.value
20
+ end
21
+
22
+ math = SafeAttr.read(stem, :math)
23
+ attrs[:mathml] = MathUtil.mathml_from_math(math) if math
24
+ end
25
+
26
+ # Legacy fallback: some older documents store text directly
27
+ text = SafeAttr.read(element, :text)
28
+ attrs[:math_text] = Array(text).join if text && !attrs[:asciimath]
29
+
30
+ Handlers.build_node("formula", attrs: attrs.compact)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Handlers
6
+ # Image map: a wrapped figure plus hyperlinked areas (shape,
7
+ # link, geometry). Duck-typed over SafeAttr reads.
8
+ module Imagemap
9
+ def self.call(element, context:)
10
+ attrs = {
11
+ id: SafeAttr.read(element, :id),
12
+ semx_id: SafeAttr.read(element, :semx_id),
13
+ areas: area_list(element),
14
+ }.compact
15
+ content = Svgmap.figure_content(element, context)
16
+ Handlers.build_node("imagemap", attrs: attrs, content: content)
17
+ end
18
+
19
+ def self.area_list(element)
20
+ areas = Array(SafeAttr.read(element, :area)).map { |a| area_attrs(a) }
21
+ areas.empty? ? nil : areas
22
+ end
23
+
24
+ def self.area_attrs(area)
25
+ ref = Svgmap.reference_of(area)
26
+ radius = SafeAttr.read(area, :radius)
27
+ {
28
+ type: SafeAttr.read(area, :area_type) || SafeAttr.read(area, :type),
29
+ target: ref,
30
+ coords: Array(SafeAttr.read(area, :coords)).map do |c|
31
+ [SafeAttr.read(c, :x), SafeAttr.read(c, :y)]
32
+ end,
33
+ radius: radius && radius_attrs(radius),
34
+ }.compact
35
+ end
36
+
37
+ def self.radius_attrs(radius)
38
+ [SafeAttr.read(radius, :x), SafeAttr.read(radius, :y)]
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Handlers
6
+ module Inline
7
+ # Shared declarative table of inline marks whose HTML rendering is
8
+ # a fixed tag with optional static attrs. It is consumed by
9
+ # RichHtmlRenderer, which renders Metanorma XML elements to HTML
10
+ # directly (the fallback / attribute-value path) during the
11
+ # forward transform into the mirror IR.
12
+ #
13
+ # Marks whose attrs vary per instance (link, xref, eref, span)
14
+ # carry data in mark.attrs and are not in this table; each
15
+ # consumer keeps custom handlers for those.
16
+ module Catalog
17
+ SIMPLE_WRAPS = {
18
+ "emphasis" => { tag: :em },
19
+ "strong" => { tag: :strong },
20
+ "subscript" => { tag: :sub },
21
+ "superscript" => { tag: :sup },
22
+ "code" => { tag: :code },
23
+ "underline" => { tag: :u },
24
+ "strike" => { tag: :s },
25
+ "smallcap" => { tag: :span, style: "font-variant: small-caps" },
26
+ "concept" => { tag: :span, class: "concept" },
27
+ "bcp14" => { tag: :span, class: "bcp14" },
28
+ "footnote" => { tag: :sup, class: "footnote-inline" },
29
+ "stem" => { tag: :span, class: "stem" },
30
+ }.freeze
31
+
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Metanorma
6
+ module Mirror
7
+ module Handlers
8
+ module Inline
9
+ # Renders inline Metanorma elements as HTML strings. Used when an HTML
10
+ # representation of inline content is required (e.g., attribute values,
11
+ # fallback rendering). All HTML construction goes through
12
+ # Nokogiri::HTML5::Builder to guarantee well-formed, escaped output.
13
+ #
14
+ # Simple-wrap elements (em, strong, sub, sup, code, u, s, smallcap,
15
+ # bcp14, concept) take their tag-and-attrs mapping from the shared
16
+ # Inline::Catalog table.
17
+ module RichHtmlRenderer
18
+ # Complex element handlers — these have per-instance attrs or
19
+ # multi-step rendering and bypass the shared Catalog.
20
+
21
+ def self.extract(element)
22
+ return "" unless element
23
+
24
+ unless element.is_a?(Lutaml::Model::Serializable)
25
+ return CGI.escapeHTML(element.to_s)
26
+ end
27
+
28
+ parts = []
29
+ element.each_mixed_content { |node| parts << render_node(node) }
30
+ result = parts.join.strip
31
+ result.empty? ? TextExtractor.extract_element_text(element) : result
32
+ end
33
+
34
+ def self.render_node(node)
35
+ case node
36
+ when String
37
+ CGI.escapeHTML(node)
38
+ when Lutaml::Model::Serializable
39
+ render_element(node)
40
+ else
41
+ ""
42
+ end
43
+ end
44
+
45
+ def self.render_element(element)
46
+ complex = Mirror.rich_html_renderers.lookup(element)
47
+ return complex.call(element) if complex
48
+
49
+ mark_type = Mirror.simple_inline_elements.lookup(element)
50
+ return render_simple(mark_type, element) if mark_type
51
+
52
+ extract(element)
53
+ end
54
+
55
+ def self.render_simple(mark_type, element)
56
+ spec = Catalog::SIMPLE_WRAPS[mark_type]
57
+ attrs = spec.except(:tag)
58
+ wrap do |d|
59
+ if attrs.empty?
60
+ d.public_send(spec[:tag]) { d << raw(extract(element)) }
61
+ else
62
+ d.public_send(spec[:tag], attrs) { d << raw(extract(element)) }
63
+ end
64
+ end
65
+ end
66
+
67
+ def self.for_stem(element)
68
+ math = SafeAttr.read(element, :math)
69
+ return TextExtractor.extract_element_text(element) unless math
70
+
71
+ mathml = MathUtil.mathml_from_math(math)
72
+ .sub(/\s*xmlns(:\w+)?="[^"]*"\s*/, " ")
73
+ .gsub(/\s+/, " ")
74
+ .strip
75
+ spec = Catalog::SIMPLE_WRAPS["stem"]
76
+ wrap do |d|
77
+ d.public_send(spec[:tag], spec.except(:tag)) { d << raw(mathml) }
78
+ end
79
+ end
80
+
81
+ def self.for_xref(element)
82
+ target = SafeAttr.read(element, :target) || ""
83
+ label = TextExtractor.extract_formatted_text(element)
84
+ label = target if label.strip.empty?
85
+ wrap { |d| d.a(href: "##{target}") { d.text label } }
86
+ end
87
+
88
+ def self.for_link(element)
89
+ href = SafeAttr.read(element,
90
+ :target) || SafeAttr.read(element, :href) || ""
91
+ label = TextExtractor.extract_formatted_text(element)
92
+ wrap { |d| d.a(href: href) { d.text label } }
93
+ end
94
+
95
+ def self.for_eref(element)
96
+ citeas = SafeAttr.read(element, :citeas) || ""
97
+ label = TextExtractor.extract_formatted_text(element)
98
+ wrap { |d| d.a(class: "eref", cite: citeas) { d.text label } }
99
+ end
100
+
101
+ def self.for_fn(element)
102
+ reference = SafeAttr.read(element, :reference) || ""
103
+ spec = Catalog::SIMPLE_WRAPS["footnote"]
104
+ wrap do |d|
105
+ d.public_send(spec[:tag], spec.except(:tag)) { d.text reference }
106
+ end
107
+ end
108
+
109
+ def self.for_span(element)
110
+ cls = SafeAttr.read(element, :class_attr)
111
+ inner = extract(element)
112
+ wrap do |d|
113
+ if cls
114
+ d.span(class: cls) { d << raw(inner) }
115
+ else
116
+ d.span { d << raw(inner) }
117
+ end
118
+ end
119
+ end
120
+
121
+ def self.wrap(&)
122
+ Nokogiri::HTML5::Builder.new(&).doc.root.to_html
123
+ end
124
+
125
+ def self.raw(html_string)
126
+ Nokogiri::HTML5::DocumentFragment.parse(html_string.to_s)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end