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
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Output
6
+ class Pipeline
7
+ attr_reader :steps, :context
8
+
9
+ def initialize(xml_path:, steps: nil, flavor: nil, title: nil,
10
+ id_strategy: nil)
11
+ @steps = steps || [Steps::ParseXml, Steps::TransformMirror, Steps::AttachMetadata]
12
+ @context = PipelineContext.new(
13
+ xml_path: xml_path,
14
+ flavor: flavor,
15
+ title: title || File.basename(xml_path, ".*"),
16
+ id_strategy: id_strategy,
17
+ )
18
+ end
19
+
20
+ def process
21
+ @steps.each { |step_class| step_class.new.call(@context) }
22
+ Model::Guide.new(
23
+ content: @context.content,
24
+ meta: @context.meta,
25
+ title: @context.title,
26
+ document: @context.parsed,
27
+ )
28
+ end
29
+
30
+ module Steps
31
+ class ParseXml
32
+ def call(context)
33
+ flavor = context.flavor || infer_flavor(context.xml_path)
34
+ doc_class = flavor_class(flavor)
35
+ xml_content = File.read(context.xml_path)
36
+ context.parsed = doc_class.from_xml(xml_content)
37
+ end
38
+
39
+ def self.flavor_map
40
+ @flavor_map ||= Metanorma.constants.each_with_object({}) do |c, map|
41
+ next unless c.to_s.end_with?("Document")
42
+
43
+ flavor = c.to_s.delete_suffix("Document").downcase
44
+ map[flavor] = c.to_s
45
+ end.freeze
46
+ end
47
+
48
+ def infer_flavor(xml_path)
49
+ basename = File.basename(xml_path, ".*")
50
+ from_name = basename.split("-").first.to_s.downcase
51
+ return from_name if self.class.flavor_map.key?(from_name)
52
+
53
+ File.dirname(xml_path).split("/").reverse_each do |seg|
54
+ prefix = seg.split("-").first.to_s.downcase
55
+ return prefix if self.class.flavor_map.key?(prefix)
56
+ end
57
+
58
+ nil
59
+ end
60
+
61
+ def flavor_class(flavor)
62
+ camel = flavor.split("_").map(&:capitalize).join
63
+ new_namespace = "Metanorma::#{camel}::Document"
64
+ begin
65
+ require "metanorma/#{flavor}/document"
66
+ rescue LoadError
67
+ nil
68
+ end
69
+ if Object.const_defined?(new_namespace)
70
+ return Object.const_get(new_namespace).const_get(:Root)
71
+ end
72
+
73
+ begin
74
+ require "metanorma/standoc"
75
+ rescue LoadError
76
+ nil
77
+ end
78
+ class_name = self.class.flavor_map[flavor] || "StandardDocument"
79
+ Metanorma.const_get(class_name).const_get(:Root)
80
+ end
81
+ end
82
+
83
+ class TransformMirror
84
+ def call(context)
85
+ id_strategy = context.id_strategy || Mirror::DEFAULT_ID_STRATEGY
86
+ transformer = Mirror::Transformer.new(id_strategy: id_strategy)
87
+ context.content = transformer.from_metanorma(context.parsed)
88
+ end
89
+ end
90
+
91
+ class AttachMetadata
92
+ def call(context)
93
+ parsed = context.parsed
94
+ return unless parsed
95
+
96
+ bibdata = SafeAttr.read(parsed, :bibdata)
97
+ return unless bibdata
98
+
99
+ meta = {}
100
+ meta["title"] = Metadata.title_from_bibdata(bibdata)
101
+ meta["flavor"] = context.flavor if context.flavor
102
+ context.meta = meta
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Output
6
+ class PipelineContext
7
+ attr_reader :xml_path, :flavor, :title, :id_strategy
8
+ attr_accessor :parsed, :content, :meta
9
+
10
+ def initialize(xml_path:, flavor: nil, title: nil, id_strategy: nil)
11
+ @xml_path = xml_path
12
+ @flavor = flavor
13
+ @title = title
14
+ @id_strategy = id_strategy
15
+ @parsed = nil
16
+ @content = nil
17
+ @meta = {}
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Output
6
+ autoload :Pipeline, "#{__dir__}/output/pipeline"
7
+ autoload :PipelineContext, "#{__dir__}/output/pipeline_context"
8
+ autoload :Builder, "#{__dir__}/output/builder"
9
+ autoload :Formats, "#{__dir__}/output/formats"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ # Rewrites a mirror Model graph with configurable type-skipping and
6
+ # per-type customization. Despite living in the reverse-conversion
7
+ # namespace, this class produces a Model graph (not Metanorma XML) —
8
+ # the output is a fully model-driven representation that can be
9
+ # serialized via Model#to_hash or embedded into HTML output by
10
+ # Output::Formats::InlineFormat (which renders the source document
11
+ # through the classic Metanorma::Html renderer).
12
+ #
13
+ # Instance API:
14
+ # Rewriter.new(skip: Set.new(%w[review footnotes]))
15
+ # rewriter.skip("my_type")
16
+ # rewriter.register("my_type") { |n, r| ... }
17
+ #
18
+ # Class API (seeds defaults copied into each new instance):
19
+ # Rewriter.skip("review") # adds to defaults
20
+ # Rewriter.register("my_type") { |n, r| ... }
21
+ #
22
+ # Two Rewriter instances do not share state. Class-level mutation only
23
+ # affects instances created afterwards (the default snapshot is copied
24
+ # at `new` time).
25
+ class Rewriter
26
+ DEFAULT_SKIPPED_TYPES = %w[review footnotes].freeze
27
+
28
+ class << self
29
+ # Default skip set, copied into each new instance.
30
+ def default_skipped_types
31
+ @default_skipped_types ||= Set.new(DEFAULT_SKIPPED_TYPES)
32
+ end
33
+
34
+ # Default builder map, copied into each new instance.
35
+ def default_builders
36
+ @default_builders ||= {}
37
+ end
38
+
39
+ # Add a type to the default skip set. Affects instances created
40
+ # afterwards; existing instances are unchanged.
41
+ def skip(type)
42
+ default_skipped_types << type
43
+ self
44
+ end
45
+
46
+ # Register a builder for a type at the default level. Affects
47
+ # instances created afterwards; existing instances are unchanged.
48
+ def register(type, &block)
49
+ default_builders[type] = block
50
+ self
51
+ end
52
+ end
53
+
54
+ def initialize(skip: nil, builders: nil)
55
+ @skipped_types = skip || self.class.default_skipped_types.dup
56
+ @builders = builders || self.class.default_builders.dup
57
+ end
58
+
59
+ attr_reader :skipped_types, :builders
60
+
61
+ def skip(type)
62
+ skipped_types << type
63
+ self
64
+ end
65
+
66
+ def skipped?(type)
67
+ skipped_types.include?(type)
68
+ end
69
+
70
+ def register(type, &block)
71
+ builders[type] = block
72
+ self
73
+ end
74
+
75
+ def call(mirror_node)
76
+ build(mirror_node)
77
+ end
78
+
79
+ def build(node)
80
+ return nil unless node
81
+
82
+ return rewrite_string(node) if node.is_a?(String)
83
+
84
+ node.accept_rewriter(self)
85
+ end
86
+
87
+ def rewrite_container(node)
88
+ return nil if skipped?(node.type)
89
+
90
+ builder = builders[node.type]
91
+ return builder.call(node, self) if builder
92
+
93
+ Model::Container.new(
94
+ type: node.type,
95
+ attrs: node.attrs,
96
+ content: build_children(node.content),
97
+ )
98
+ end
99
+
100
+ def rewrite_leaf(node)
101
+ return nil if skipped?(node.type)
102
+
103
+ Model::Leaf.new(type: node.type, attrs: node.attrs)
104
+ end
105
+
106
+ def rewrite_text(node)
107
+ Model::Text.new(text: node.text, marks: node.marks)
108
+ end
109
+
110
+ def rewrite_soft_break(_node)
111
+ Model::SoftBreak.new
112
+ end
113
+
114
+ def rewrite_string(string)
115
+ Model::Text.new(text: string)
116
+ end
117
+
118
+ def build_children(content)
119
+ Array(content).filter_map { |child| build(child) }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module SafeAttr
6
+ def self.read(element, attr_name)
7
+ return nil unless element.is_a?(Lutaml::Model::Serializable)
8
+
9
+ klass = element.class
10
+ return nil unless klass.attributes.key?(attr_name)
11
+
12
+ element.public_send(attr_name)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Metanorma
6
+ module Mirror
7
+ module Serialization
8
+ class JsonSerializer
9
+ # Every Model::Node (Leaf included) has a data to_hash; the former
10
+ # Container-only guard serialized bare leaves as inspect strings.
11
+ def self.serialize(node)
12
+ data = node.is_a?(Model::Node) ? node.to_hash : node
13
+ data.to_json
14
+ end
15
+
16
+ def self.serialize_pretty(node)
17
+ data = node.is_a?(Model::Node) ? node.to_hash : node
18
+ JSON.pretty_generate(data)
19
+ end
20
+
21
+ def self.deserialize(json_string)
22
+ Model::Factory.from_hash(JSON.parse(json_string))
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Metanorma
6
+ module Mirror
7
+ module Serialization
8
+ class YamlSerializer
9
+ def self.serialize(node)
10
+ data = node.is_a?(Model::Container) ? node.to_hash : node
11
+ data.to_yaml
12
+ end
13
+
14
+ def self.deserialize(yaml_string)
15
+ Model::Factory.from_hash(YAML.safe_load(yaml_string))
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Serialization
6
+ autoload :JsonSerializer, "#{__dir__}/serialization/json_serializer"
7
+ autoload :YamlSerializer, "#{__dir__}/serialization/yaml_serializer"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ class Transformer
6
+ attr_reader :registry, :id_strategy
7
+
8
+ def initialize(registry: Mirror.default_registry,
9
+ id_strategy: Mirror::DEFAULT_ID_STRATEGY)
10
+ @registry = registry
11
+ @id_strategy = id_strategy
12
+ end
13
+
14
+ def call(root)
15
+ attrs = {}
16
+ attrs[:flavor] = root.flavor if root.flavor
17
+ attrs[:type] = root.type if root.type
18
+ attrs[:schema_version] = root.schema_version if root.schema_version
19
+
20
+ title = extract_root_title(root)
21
+ attrs[:title] = title if title
22
+
23
+ content = []
24
+
25
+ preface = root.preface
26
+ if preface
27
+ result = @registry.handle(preface, context: self)
28
+ result.append_to(content)
29
+ end
30
+
31
+ sections = root.sections
32
+ if sections
33
+ result = @registry.handle(sections, context: self)
34
+ result.append_to(content)
35
+ end
36
+
37
+ annex = root.annex
38
+ annex&.each do |a|
39
+ result = @registry.handle(a, context: self)
40
+ result.append_to(content)
41
+ end
42
+
43
+ bibliography = root.bibliography
44
+ if bibliography
45
+ result = @registry.handle(bibliography, context: self)
46
+ result.append_to(content)
47
+ end
48
+
49
+ Handlers.build_node("doc", attrs: attrs, content: content)
50
+ end
51
+
52
+ def from_metanorma(root)
53
+ document = call(root)
54
+ @id_strategy.finalize!(document)
55
+ end
56
+
57
+ def rewrite(mirror_node)
58
+ Rewriter.new.call(mirror_node)
59
+ end
60
+
61
+ def extract_blocks(element)
62
+ content = []
63
+
64
+ element.each_mixed_content do |node|
65
+ next if node.is_a?(String)
66
+
67
+ result = @registry.handle(node, context: self)
68
+ result.append_to(content)
69
+ end
70
+ content
71
+ end
72
+
73
+ def extract_section_children(element)
74
+ content = []
75
+ %i[clause terms definitions references floating_title].each do |attr|
76
+ collection = SafeAttr.read(element, attr)
77
+ next unless collection
78
+
79
+ collection.each do |child|
80
+ result = @registry.handle(child, context: self)
81
+ result.append_to(content)
82
+ end
83
+ end
84
+ content
85
+ end
86
+
87
+ def extract_named_collections(element, collection_attrs)
88
+ content = []
89
+ collection_attrs.each do |attr|
90
+ collection = SafeAttr.read(element, attr)
91
+ next unless collection
92
+
93
+ collection.each do |child|
94
+ result = @registry.handle(child, context: self)
95
+ result.append_to(content)
96
+ end
97
+ end
98
+ content
99
+ end
100
+
101
+ def text_node(text, marks: [])
102
+ Handlers.build_text(text, marks: marks)
103
+ end
104
+
105
+ def extract_root_title(root)
106
+ Metadata.title_from_bibdata(SafeAttr.read(root, :bibdata))
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Metanorma
6
+ # The Mirror format: a lossless JSON projection of a Metanorma
7
+ # document as a typed node tree (blocks, inlines, marks), for
8
+ # renderers and tooling (the SMART document reader, the HTML JS
9
+ # renderer, MKO payload provenance).
10
+ #
11
+ # This gem is format + mechanism only: node models, the handler
12
+ # registry, the transformer walk, serializers, and the output
13
+ # pipeline. Model knowledge (which document classes map to which
14
+ # handlers, marks, renderers, id categories) is REGISTERED by the
15
+ # model gems — metanorma-document and metanorma-standoc seed the
16
+ # defaults at load time via the registration seams below.
17
+ module Mirror
18
+ class Error < StandardError; end
19
+
20
+ autoload :VERSION, "#{__dir__}/mirror/version"
21
+ autoload :SafeAttr, "#{__dir__}/mirror/safe_attr"
22
+ autoload :MathUtil, "#{__dir__}/mirror/math_util"
23
+ autoload :Metadata, "#{__dir__}/mirror/metadata"
24
+ autoload :Model, "#{__dir__}/mirror/model"
25
+ autoload :ClassTable, "#{__dir__}/mirror/class_table"
26
+ autoload :HandlerResult, "#{__dir__}/mirror/handler_result"
27
+ autoload :Transformer, "#{__dir__}/mirror/transformer"
28
+ autoload :Rewriter, "#{__dir__}/mirror/rewriter"
29
+ autoload :HandlerRegistry, "#{__dir__}/mirror/handler_registry"
30
+ autoload :Handlers, "#{__dir__}/mirror/handlers"
31
+ autoload :IdStrategy, "#{__dir__}/mirror/id_strategy"
32
+ autoload :Output, "#{__dir__}/mirror/output"
33
+ autoload :Serialization, "#{__dir__}/mirror/serialization"
34
+ autoload :DefaultRegistry, "#{__dir__}/mirror/default_registry"
35
+
36
+ DEFAULT_ID_STRATEGY = IdStrategy::Preserve.new
37
+
38
+ # Registration seams. Model gems call these at load time so the
39
+ # format layer stays free of model constants:
40
+
41
+ # Handler entries for the default registry, in registration order.
42
+ @default_hooks = []
43
+
44
+ def self.register_default(&block)
45
+ @default_hooks << block
46
+ end
47
+
48
+ def self.default_hooks
49
+ @default_hooks
50
+ end
51
+
52
+ # Inline element classes -> mark builders (e.g. EmRawElement ->
53
+ # emphasis mark).
54
+ def self.mark_builders
55
+ @mark_builders ||= ClassTable.new
56
+ end
57
+
58
+ # Inline element classes -> rich-HTML renderer lambdas (stem, xref,
59
+ # link, eref, fn, span, br).
60
+ def self.rich_html_renderers
61
+ @rich_html_renderers ||= ClassTable.new
62
+ end
63
+
64
+ # Inline element classes -> plain-text substitutions (TabElement ->
65
+ # " ", BrElement -> "\n").
66
+ def self.inline_text_substitutions
67
+ @inline_text_substitutions ||= ClassTable.new
68
+ end
69
+
70
+ # Inline element classes -> SIMPLE_WRAPS mark types (see
71
+ # Handlers::Inline::Catalog).
72
+ def self.simple_inline_elements
73
+ @simple_inline_elements ||= ClassTable.new
74
+ end
75
+
76
+ # Element classes recognized as semx wrappers (cross-reference
77
+ # carriers); payload is always true.
78
+ def self.semx_elements
79
+ @semx_elements ||= ClassTable.new
80
+ end
81
+
82
+ # The mixed-content walker for inline containers: an object
83
+ # responding to each(element) { |node| } yielding semantic children
84
+ # in document order (document seeds register
85
+ # Components::Inline::SemanticContent).
86
+ class << self
87
+ attr_accessor :inline_content_iterator
88
+ end
89
+
90
+ def self.default_registry
91
+ @default_registry ||= build_default_registry
92
+ end
93
+
94
+ def self.build_default_registry
95
+ DefaultRegistry.build
96
+ end
97
+ end
98
+ end