metanorma-oiml 0.1.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.adoc +291 -0
  4. data/data/themes/oiml/assets/oiml-logo-full-dark.svg +1 -0
  5. data/data/themes/oiml/assets/oiml-logo-full-light.svg +1 -0
  6. data/data/themes/oiml/assets/oiml-logo-icon-dark.svg +1 -0
  7. data/data/themes/oiml/assets/oiml-logo-icon-light.svg +1 -0
  8. data/data/themes/oiml/assets/oiml-logo.svg +1 -0
  9. data/data/themes/oiml/theme.yaml +56 -0
  10. data/exe/oiml-sts +10 -0
  11. data/lib/metanorma/oiml/document/root.rb +29 -0
  12. data/lib/metanorma/oiml/document.rb +35 -0
  13. data/lib/metanorma/oiml/html/renderer.rb +14 -0
  14. data/lib/metanorma/oiml/html.rb +14 -0
  15. data/lib/metanorma/oiml/sts/cli.rb +89 -0
  16. data/lib/metanorma/oiml/sts/html_renderer/assets/metanorma-icon-dark.svg +1 -0
  17. data/lib/metanorma/oiml/sts/html_renderer/assets/metanorma-icon-light.svg +1 -0
  18. data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-logo-dark.svg +1 -0
  19. data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-logo.svg +1 -0
  20. data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-smart-logo-dark.svg +1 -0
  21. data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-smart-logo.svg +1 -0
  22. data/lib/metanorma/oiml/sts/html_renderer/assets/page.js +145 -0
  23. data/lib/metanorma/oiml/sts/html_renderer/assets/theme.css +509 -0
  24. data/lib/metanorma/oiml/sts/html_renderer/ruby.rb +1071 -0
  25. data/lib/metanorma/oiml/sts/html_renderer/templates/_element.html.liquid +1 -0
  26. data/lib/metanorma/oiml/sts/html_renderer/templates/_footer.html.liquid +21 -0
  27. data/lib/metanorma/oiml/sts/html_renderer/templates/_hero.html.liquid +9 -0
  28. data/lib/metanorma/oiml/sts/html_renderer/templates/_img.html.liquid +1 -0
  29. data/lib/metanorma/oiml/sts/html_renderer/templates/_link.html.liquid +1 -0
  30. data/lib/metanorma/oiml/sts/html_renderer/templates/_meta_header.html.liquid +1 -0
  31. data/lib/metanorma/oiml/sts/html_renderer/templates/document.html.liquid +43 -0
  32. data/lib/metanorma/oiml/sts/html_renderer.rb +19 -0
  33. data/lib/metanorma/oiml/sts/mathml_builder.rb +93 -0
  34. data/lib/metanorma/oiml/sts/parity_validator/normaliser.rb +95 -0
  35. data/lib/metanorma/oiml/sts/parity_validator/report.rb +137 -0
  36. data/lib/metanorma/oiml/sts/parity_validator.rb +23 -0
  37. data/lib/metanorma/oiml/sts/transformer/back_transformer.rb +55 -0
  38. data/lib/metanorma/oiml/sts/transformer/base.rb +71 -0
  39. data/lib/metanorma/oiml/sts/transformer/block_dispatcher.rb +145 -0
  40. data/lib/metanorma/oiml/sts/transformer/body_transformer.rb +23 -0
  41. data/lib/metanorma/oiml/sts/transformer/context.rb +27 -0
  42. data/lib/metanorma/oiml/sts/transformer/dl_transformer.rb +49 -0
  43. data/lib/metanorma/oiml/sts/transformer/document_transformer.rb +65 -0
  44. data/lib/metanorma/oiml/sts/transformer/figure_transformer.rb +49 -0
  45. data/lib/metanorma/oiml/sts/transformer/footnote_collector.rb +33 -0
  46. data/lib/metanorma/oiml/sts/transformer/formula_transformer.rb +45 -0
  47. data/lib/metanorma/oiml/sts/transformer/front_transformer.rb +84 -0
  48. data/lib/metanorma/oiml/sts/transformer/id_generator.rb +39 -0
  49. data/lib/metanorma/oiml/sts/transformer/inline_transformer.rb +14 -0
  50. data/lib/metanorma/oiml/sts/transformer/list_transformer.rb +82 -0
  51. data/lib/metanorma/oiml/sts/transformer/model_builder.rb +322 -0
  52. data/lib/metanorma/oiml/sts/transformer/note_transformer.rb +103 -0
  53. data/lib/metanorma/oiml/sts/transformer/paragraph_transformer.rb +574 -0
  54. data/lib/metanorma/oiml/sts/transformer/reference_transformer.rb +107 -0
  55. data/lib/metanorma/oiml/sts/transformer/rendered_text_extractor.rb +168 -0
  56. data/lib/metanorma/oiml/sts/transformer/section_transformer.rb +233 -0
  57. data/lib/metanorma/oiml/sts/transformer/source_document.rb +246 -0
  58. data/lib/metanorma/oiml/sts/transformer/standard.rb +37 -0
  59. data/lib/metanorma/oiml/sts/transformer/table_transformer.rb +290 -0
  60. data/lib/metanorma/oiml/sts/transformer/term_transformer.rb +236 -0
  61. data/lib/metanorma/oiml/sts/transformer.rb +47 -0
  62. data/lib/metanorma/oiml/sts/validation_report.rb +43 -0
  63. data/lib/metanorma/oiml/sts/validator.rb +126 -0
  64. data/lib/metanorma/oiml/sts/version.rb +9 -0
  65. data/lib/metanorma/oiml/sts.rb +84 -0
  66. data/lib/metanorma-oiml.rb +10 -0
  67. data/schematron/oiml-sts.sch +70 -0
  68. metadata +221 -0
@@ -0,0 +1 @@
1
+ <{{ tag }}{% if id %} id="{{ id }}"{% endif %}{% if css %} class="{{ css }}"{% endif %}>{{ content }}</{{ tag }}>
@@ -0,0 +1,21 @@
1
+ <div class="footer-grid">
2
+ <div class="footer-block">
3
+ <p class="footer-license">
4
+ {% if copyright %}{{ copyright | escape }} · {{ license | escape }}{% else %}{{ license | escape }}{% endif %}
5
+ </p>
6
+ <p class="footer-publisher">{{ publisher_name | escape }}</p>
7
+ <p class="footer-address">{{ publisher_address | escape }}</p>
8
+ <p class="footer-contact">
9
+ <a href="https://www.oiml.org">www.oiml.org</a> ·
10
+ <a href="mailto:biml@oiml.org">biml@oiml.org</a>
11
+ </p>
12
+ </div>
13
+ <div class="footer-block footer-meta">
14
+ <p><span class="footer-docid">{{ docid | escape }}</span></p>
15
+ <p class="footer-meta-line">{% if series != "" %}Series {{ series | escape }} · {% endif %}{% if year != "" %}{{ year | escape }} · {% endif %}EN</p>
16
+ <p class="footer-generated">
17
+ <span class="mn-icon brand-logo-light">{{ mn_icon_light }}</span><span class="mn-icon brand-logo-dark">{{ mn_icon_dark }}</span>
18
+ Generated by <a href="https://www.metanorma.org" title="Metanorma — aequitate verum">metanorma-oiml</a> {{ version | escape }}
19
+ </p>
20
+ </div>
21
+ </div>
@@ -0,0 +1,9 @@
1
+ <div class="hero">
2
+ <p class="hero-eyebrow">{{ eyebrow | escape }}</p>
3
+ <h1 class="hero-title">{{ title | escape }}</h1>
4
+ <p class="hero-chips">
5
+ {% for chip in chips %}
6
+ <span class="hero-chip{% if forloop.first %} hero-chip-primary{% endif %}">{{ chip | escape }}</span>
7
+ {% endfor %}
8
+ </p>
9
+ </div>
@@ -0,0 +1 @@
1
+ <img src="{{ src }}" alt="{{ alt | escape }}"/>
@@ -0,0 +1 @@
1
+ <a href="{{ href | escape }}">{{ text }}</a>
@@ -0,0 +1 @@
1
+ <header>{% if docid != "" %}<span class="doc-id">{{ docid }}</span>{% endif %}{% if title != "" %}<span class="title">{{ title }}</span>{% endif %}</header>
@@ -0,0 +1,43 @@
1
+ <!DOCTYPE html>
2
+ <html lang="{{ lang }}">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{ title | escape }}</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
8
+ <style type="text/tailwindcss">
9
+ {{ css }}
10
+ </style>
11
+ </head>
12
+ <body class="bg-paper text-ink font-body text-[16px] leading-relaxed m-0">
13
+ <header class="site-nav sticky top-0 z-40 flex items-center gap-3 min-h-[56px] px-5 py-2 bg-nav-bg border-b border-nav-border text-nav-text shadow-sm">
14
+ <span class="brand-logo-light shrink-0 [&>svg]:h-[34px] [&>svg]:w-auto [&>svg]:block">{{ logo_light }}</span>
15
+ <span class="brand-logo-dark shrink-0 [&>svg]:h-[34px] [&>svg]:w-auto [&>svg]:block">{{ logo_dark }}</span>
16
+ <span class="nav-doc-title font-semibold text-nav-text truncate min-w-0">{{ title | escape }}</span>
17
+ <nav class="breadcrumb hidden md:flex items-baseline gap-2 min-w-0 text-sm whitespace-nowrap overflow-hidden ml-auto" aria-label="Breadcrumb">
18
+ <span class="crumb-doc font-mono text-xs font-bold tracking-wider uppercase text-accent">{{ docid | escape }}</span><span class="crumb-sep text-ink-faint" aria-hidden="true">›</span><span class="crumb-current overflow-hidden text-ellipsis text-ink-faint" id="crumb-current"></span>
19
+ </nav>
20
+ <span class="nav-actions ml-auto md:ml-0 flex gap-2">
21
+ <button id="theme-toggle" class="nav-btn inline-flex items-center justify-center w-9 h-9 rounded-md border border-nav-btn-border bg-nav-btn-bg text-nav-text hover:border-accent hover:text-accent transition-colors cursor-pointer" aria-label="Toggle light and dark mode" title="Toggle light / dark mode">
22
+ <svg class="icon-sun hidden dark:block" viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="3"/><line x1="8" y1="1" x2="8" y2="3"/><line x1="8" y1="13" x2="8" y2="15"/><line x1="1" y1="8" x2="3" y2="8"/><line x1="13" y1="8" x2="15" y2="8"/><line x1="3.05" y1="3.05" x2="4.46" y2="4.46"/><line x1="11.54" y1="3.05" x2="12.95" y2="4.46"/><line x1="3.05" y1="12.95" x2="4.46" y2="11.54"/><line x1="11.54" y1="12.95" x2="12.95" y2="11.54"/></svg>
23
+ <svg class="icon-moon dark:hidden" viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 9.5A5.5 5.5 0 0 1 6.5 2.5 5.5 5.5 0 1 0 13.5 9.5z"/></svg>
24
+ </button>
25
+ <button id="toc-toggle" class="nav-btn hidden max-lg:inline-flex items-center justify-center w-9 h-9 rounded-md border border-nav-btn-border bg-nav-btn-bg text-nav-text hover:border-accent hover:text-accent transition-colors cursor-pointer" aria-label="Show contents" aria-expanded="false" aria-controls="toc" title="Contents">☰</button>
26
+ </span>
27
+ </header>
28
+ {{ hero }}
29
+ <div class="page grid max-lg:grid-cols-1 lg:grid-cols-[300px_minmax(0,1fr)] gap-10 max-w-[1200px] mx-auto px-6 pt-7 pb-8 max-lg:px-4">
30
+ {{ toc }}
31
+ <div class="page-body min-w-0">
32
+ {{ content }}
33
+ </div>
34
+ </div>
35
+ <button id="back-to-top" class="back-to-top fixed right-6 bottom-6 z-40 w-10 h-10 rounded-full bg-accent text-white text-lg cursor-pointer shadow-lg opacity-0 translate-y-2 pointer-events-none transition-all hover:brightness-110" aria-label="Back to top" title="Back to top">↑</button>
36
+ <footer class="site-footer px-6 py-6 text-xs text-ink-faint border-t border-line">
37
+ {{ footer }}
38
+ </footer>
39
+ <script>
40
+ {{ js }}
41
+ </script>
42
+ </body>
43
+ </html>
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Oiml
5
+ module Sts
6
+ # HTML rendering for OIML STS XML (Liquid-based; see
7
+ # html_renderer/ruby.rb).
8
+ module HtmlRenderer
9
+ autoload :Ruby, "#{__dir__}/html_renderer/ruby"
10
+
11
+ module_function
12
+
13
+ def render(sts_xml, **)
14
+ Ruby.new.render(sts_xml, **)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ # MathML passthrough helper.
4
+ #
5
+ # Design note: previously this module extracted plain text from MathML via
6
+ # regex symbol rules, subscript/superscript rewriting, and asciimath
7
+ # conversion. That approach was wrong — mnconvert (the production
8
+ # Metanorma→STS converter) copies MathML verbatim into <inline-formula>.
9
+ # We do the same via Mml::V3::Math.from_xml + Sts::NisoSts::InlineFormula.
10
+ #
11
+ # The text-extraction methods (asciimath_to_plain_text, mathml_to_plain_text,
12
+ # apply_symbol_rules, apply_subscript_rules, apply_superscript_rules) were
13
+ # removed. No code should reach back into this module for math rendering —
14
+ # inline/dispatched formulas pass MathML through structurally.
15
+ module Metanorma
16
+ module Oiml
17
+ module Sts
18
+ module MathmlBuilder
19
+ module_function
20
+
21
+ MATHML_NS = "http://www.w3.org/1998/Math/MathML"
22
+
23
+ def wrap_math_content(content)
24
+ "<math xmlns='#{MATHML_NS}'>#{content}</math>"
25
+ end
26
+
27
+ # The Math model class nominally paired with a formula wrapper
28
+ # class. sts >= 0.6.0 unified all MathML on Mml::V3::Math across
29
+ # IsoSts, NisoSts, and TbxIsoTml, so this is always Mml::V3::Math
30
+ # regardless of the formula wrapper's namespace. Kept as a
31
+ # method (not a constant) so callers don't hard-code the class.
32
+ def math_klass_for(_formula_klass)
33
+ Mml::V3::Math
34
+ end
35
+
36
+ # Builds a math model instance from a stem-bearing typed model node.
37
+ # Handles StemInlineElement (.math), StemBlockElement (.math),
38
+ # and FmtStemElement (.semx[].math). Returns nil when the node
39
+ # carries no parseable MathML — callers should silently drop
40
+ # the formula in that case (mnconvert's behavior).
41
+ #
42
+ # `klass:` selects which typed Math model to instantiate.
43
+ # Defaults to Mml::V3::Math — the canonical MathML model used
44
+ # by sts >= 0.6.0 across IsoSts, NisoSts, and TbxIsoTml.
45
+ def math_from_stem(stem_node, klass: Mml::V3::Math)
46
+ math_el = first_math_element(stem_node)
47
+ return nil unless math_el
48
+
49
+ # The stem's `math` attribute already carries a typed MML model
50
+ # (SemanticMathElement inherits Mml::V3::Math). Passing it
51
+ # straight through avoids the earlier stringify-and-reparse
52
+ # roundtrip — which depended on `math_el.content` and broke
53
+ # when the typed model changed shape.
54
+ return math_el if math_el.is_a?(klass)
55
+
56
+ # Fallback for any future stem shape that isn't already an
57
+ # Mml::V3::Math subclass: build from its serialized XML.
58
+ string = math_el.to_s
59
+ return nil if string.empty?
60
+
61
+ klass.from_xml(wrap_math_content(string))
62
+ rescue StandardError
63
+ nil
64
+ end
65
+
66
+ # Wraps the stem's MathML in an InlineFormula for
67
+ # use inside paragraphs and table cells. Returns nil if the
68
+ # stem carries no parseable MathML.
69
+ #
70
+ # Always uses Mml::V3::Math — the canonical MathML model,
71
+ # unified across all STS namespaces in sts >= 0.6.0.
72
+ def inline_formula_from_stem(stem_node, klass: ::Sts::NisoSts::InlineFormula)
73
+ math = math_from_stem(stem_node, klass: Mml::V3::Math)
74
+ return nil unless math
75
+
76
+ klass.new(math: math)
77
+ end
78
+
79
+ # SemanticMathElement is a subclass of Mml::V3::Math — already
80
+ # a fully-typed Math model. The earlier stringify/re-parse path
81
+ # broke because SemanticMathElement doesn't expose a `.content`
82
+ # accessor at the Lutaml level.
83
+ def first_math_element(stem_node)
84
+ if stem_node.is_a?(Metanorma::Document::Components::Inline::FmtStemElement)
85
+ Array(stem_node.semx).flat_map { |semx| Array(semx.math) }.first
86
+ elsif stem_node.class.method_defined?(:math)
87
+ Array(stem_node.math).first
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Oiml
5
+ module Sts
6
+ module ParityValidator
7
+ # Text normalisation shared by all comparison logic.
8
+ class Normaliser
9
+ CHROME_SELECTORS = %w[
10
+ script style head nav
11
+ .title-section .prefatory-section
12
+ .toc #toc
13
+ .coverpage_docnumber .coverpage_techcommittee
14
+ .coverpage_docstage .coverpage_warning
15
+ .doctitle-fr .doc-title
16
+ .btn .collapse-button .collapse-group
17
+ .anchor .h-anchor
18
+ ].freeze
19
+
20
+ # MN wraps heading text in `<a class="header">…</a>`. Removing
21
+ # the node would discard the title text; unwrap it instead so
22
+ # the content survives but the link chrome is gone.
23
+ UNWRAP_SELECTORS = %w[.header].freeze
24
+
25
+ BLOCK_BOUNDARY_ELEMENTS =
26
+ %w[p div td th li h1 h2 h3 h4 h5 h6 section table tr
27
+ ul ol dl dt dd].freeze
28
+
29
+ def self.strip_chrome(html, selectors: CHROME_SELECTORS)
30
+ doc = Nokogiri::HTML(html)
31
+ UNWRAP_SELECTORS.each do |sel|
32
+ doc.css(sel).each { |node| node.replace(node.children) }
33
+ end
34
+ selectors.each { |sel| doc.css(sel).remove }
35
+ doc.css("body").first&.to_html || doc.to_html
36
+ end
37
+
38
+ def self.normalise(text)
39
+ new_text = text.to_s
40
+ new_text = unify_block_boundaries(new_text)
41
+ # Collapse non-breaking spaces (U+00A0) and regular whitespace.
42
+ new_text = new_text.gsub(/[⁡⁢⁣⁤]/, "")
43
+ new_text = new_text.gsub(/[ \s]+/, " ")
44
+ new_text = unify_dashes(new_text)
45
+ new_text = new_text.gsub(/\s+/, " ").strip.downcase
46
+ new_text
47
+ end
48
+
49
+ def self.unify_block_boundaries(text)
50
+ regex = /(<\/(?:#{BLOCK_BOUNDARY_ELEMENTS.join("|")})>)/i
51
+ text.gsub(regex, " \\1")
52
+ end
53
+
54
+ def self.unify_dashes(text)
55
+ text.gsub(/[–—−]/, "-")
56
+ end
57
+
58
+ def self.strip_section_number(text)
59
+ text.to_s.gsub(/\A\s*\d+(\.\d+)*[\s ]+/, "")
60
+ end
61
+
62
+ # Annex headings carry an obligation marker on one side only:
63
+ # "Annex A (Informative) Examples" vs "Annex A Examples".
64
+ # Whitespace handling is intentionally separate from the
65
+ # marker match — the previous combined regex was flagged by
66
+ # CodeQL for polynomial backtracking on adversarial whitespace.
67
+ def self.strip_annex_obligation(text)
68
+ stripped = text.to_s.gsub(/\((?:in|nor)formative\)/i, " ")
69
+ stripped.squeeze(" ")
70
+ end
71
+
72
+ def self.strip_anchor_brackets(text)
73
+ text.to_s.gsub(/\[([A-Z0-9][A-Z0-9_]*)\]/, '\1')
74
+ end
75
+
76
+ def self.heading_text(text)
77
+ # Normalise FIRST: the number/title delimiter is often a
78
+ # non-breaking space, which only the normaliser collapses.
79
+ normalise(strip_annex_obligation(strip_section_number(normalise(text))))
80
+ end
81
+
82
+ def self.paragraph_text(text)
83
+ normalise(strip_anchor_brackets(text))
84
+ end
85
+
86
+ # List-item markers ("—", "•", "1.") are rendered as literal
87
+ # text on one side and CSS on the other; compare the content.
88
+ def self.list_item_text(text)
89
+ normalise(text.to_s.sub(/\A\s*[-–—•‒]\s*/, ""))
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Metanorma
6
+ module Oiml
7
+ module Sts
8
+ module ParityValidator
9
+ # Result of a parity check. Reports which MN HTML elements are
10
+ # missing from the STS HTML.
11
+ class Report
12
+ attr_reader :missing_headings, :missing_paragraphs,
13
+ :missing_table_cells, :missing_list_items,
14
+ :missing_images, :missing_notes,
15
+ :word_coverage_percent, :paragraph_coverage_percent
16
+
17
+ def initialize(mn_html:, sts_html:)
18
+ @mn_html = Normaliser.strip_chrome(mn_html)
19
+ # The STS side gets a lighter strip: its own heading anchors
20
+ # (.h-anchor) and non-content elements. The TOC/nav/footer
21
+ # stay — the MN side keeps its equivalents too, and removing
22
+ # them on one side only would manufacture mismatches.
23
+ @sts_html = Normaliser.strip_chrome(sts_html, selectors: STS_CHROME_SELECTORS)
24
+ compute_missing_sets
25
+ compute_coverage
26
+ end
27
+
28
+ STS_CHROME_SELECTORS = %w[script style head .h-anchor .li-label].freeze
29
+
30
+ def pass?
31
+ [
32
+ @missing_headings, @missing_paragraphs,
33
+ @missing_table_cells, @missing_list_items,
34
+ @missing_images, @missing_notes
35
+ ].all?(&:empty?)
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ pass: pass?,
41
+ missing_headings: @missing_headings.to_a,
42
+ missing_paragraphs: @missing_paragraphs.to_a,
43
+ missing_table_cells: @missing_table_cells.to_a,
44
+ missing_list_items: @missing_list_items.to_a,
45
+ missing_images: @missing_images.to_a,
46
+ missing_notes: @missing_notes.to_a,
47
+ word_coverage_percent: @word_coverage_percent,
48
+ paragraph_coverage_percent: @paragraph_coverage_percent
49
+ }
50
+ end
51
+
52
+ private
53
+
54
+ def compute_missing_sets
55
+ mn = extract_elements(@mn_html)
56
+ sts = extract_elements(@sts_html)
57
+
58
+ @missing_headings = mn[:headings] - sts[:headings]
59
+ @missing_paragraphs = mn[:paragraphs] - sts[:paragraphs]
60
+ @missing_table_cells = mn[:table_cells] - sts[:table_cells]
61
+ @missing_list_items = mn[:list_items] - sts[:list_items]
62
+ @missing_images = mn[:images] - sts[:images]
63
+ @missing_notes = mn[:notes] - sts[:notes]
64
+ end
65
+
66
+ def extract_elements(html)
67
+ doc = Nokogiri::HTML(html)
68
+ {
69
+ headings: extract_set(doc, "//h1 | //h2 | //h3 | //h4 | //h5 | //h6",
70
+ method(:heading_normalised)),
71
+ paragraphs: extract_set(doc, "//p", method(:paragraph_normalised)),
72
+ # Cells that only carry a table note (class table-notes)
73
+ # compare in the notes set, not here.
74
+ table_cells: extract_set(doc, "//td[not(contains(@class,'table-notes'))] | //th",
75
+ method(:Normaliser_paragraph)),
76
+ list_items: extract_set(doc, "//li", method(:list_item_normalised)),
77
+ images: doc.xpath("//img/@src").map(&:value).to_set,
78
+ # Note bodies wherever they live (section flow or tfoot
79
+ # table-notes cells); the kind label is included by both
80
+ # renderers ("NOTE …").
81
+ notes: extract_set(doc, "//div[contains(@class,'note')] | //td[contains(@class,'table-notes')]",
82
+ method(:Normaliser_paragraph))
83
+ }
84
+ end
85
+
86
+ def extract_set(doc, xpath, normaliser_method)
87
+ doc.xpath(xpath).map do |node|
88
+ normaliser_method.call(node.text)
89
+ end.reject(&:empty?).to_set
90
+ end
91
+
92
+ def heading_normalised(text)
93
+ Normaliser.heading_text(text)
94
+ end
95
+
96
+ def paragraph_normalised(text)
97
+ Normaliser.paragraph_text(text)
98
+ end
99
+
100
+ def list_item_normalised(text)
101
+ Normaliser.list_item_text(text)
102
+ end
103
+
104
+ def Normaliser_paragraph(text)
105
+ Normaliser.normalise(text)
106
+ end
107
+
108
+ def compute_coverage
109
+ mn_text = Normaliser.normalise(Nokogiri::HTML(@mn_html).text)
110
+ sts_text = Normaliser.normalise(Nokogiri::HTML(@sts_html).text)
111
+ mn_words = mn_text.split.to_set
112
+ sts_words = sts_text.split.to_set
113
+ covered = mn_words.count { |w| sts_words.include?(w) }
114
+ @word_coverage_percent = if mn_words.empty?
115
+ 100.0
116
+ else
117
+ (covered.to_f * 100.0 / mn_words.size).round(2)
118
+ end
119
+
120
+ mn_paras = Nokogiri::HTML(@mn_html).css("p").map do |p|
121
+ Normaliser.normalise(p.text)
122
+ end.reject(&:empty?).reject { |t| t.length < 30 }.to_set
123
+ sts_paras = Nokogiri::HTML(@sts_html).css("p").map do |p|
124
+ Normaliser.normalise(p.text)
125
+ end.reject(&:empty?).to_set
126
+ pcovered = mn_paras.count { |p| sts_paras.include?(p) }
127
+ @paragraph_coverage_percent = if mn_paras.empty?
128
+ 100.0
129
+ else
130
+ (pcovered.to_f * 100.0 / mn_paras.size).round(2)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "set"
5
+
6
+ module Metanorma
7
+ module Oiml
8
+ module Sts
9
+ # Validates that the STS-rendered HTML contains 100% of the
10
+ # semantic content present in the Metanorma HTML output.
11
+ module ParityValidator
12
+ autoload :Report, "metanorma/oiml/sts/parity_validator/report"
13
+ autoload :Normaliser, "metanorma/oiml/sts/parity_validator/normaliser"
14
+
15
+ module_function
16
+
17
+ def validate(mn_html:, sts_html:)
18
+ Report.new(mn_html: mn_html, sts_html: sts_html)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+ module Metanorma
3
+ module Oiml
4
+ module Sts
5
+ module Transformer
6
+ class BackTransformer < Base
7
+ def transform(source)
8
+ app_group = build_app_group(source)
9
+ ref_lists = build_bibliography(source)
10
+ ModelBuilder.back(app_group: app_group, ref_list: ref_lists)
11
+ end
12
+
13
+ private
14
+
15
+ def build_app_group(source)
16
+ return nil if source.annexes.empty?
17
+
18
+ apps = source.annexes.map do |annex|
19
+ sec = section_transformer.transform(annex)
20
+ attrs = {}
21
+ APP_CONTENT_ATTRS.each do |attr|
22
+ next unless sec.class.method_defined?(attr)
23
+
24
+ value = sec.public_send(attr)
25
+ next if value.nil? || (value.is_a?(Array) && value.empty?)
26
+
27
+ # Section stores body paragraphs as `paragraphs` (plural);
28
+ # App stores them as `paragraph` (singular). Map across
29
+ # the boundary so the annex intro paragraphs survive.
30
+ key = (attr == :paragraphs) ? :paragraph : attr
31
+ attrs[key] = value
32
+ end
33
+ ::Sts::NisoSts::App.new(attrs)
34
+ end
35
+ ModelBuilder.app_group(app: apps)
36
+ end
37
+
38
+ # Everything SectionTransformer can produce: dropping any of
39
+ # these at the app boundary silently loses annex-level content
40
+ # (e.g. the Element map annex's table — or the annex letter
41
+ # label, "Annex A").
42
+ APP_CONTENT_ATTRS = %i[
43
+ id label title paragraphs list fig table_wrap def_list disp_formula
44
+ non_normative_note non_normative_example ref_list sec preformat
45
+ ].freeze
46
+
47
+ def build_bibliography(source)
48
+ return [] if source.bibliography.empty?
49
+ source.bibliography.map { |rs| reference_transformer.transform_section(rs) }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Oiml
5
+ module Sts
6
+ module Transformer
7
+ class Base
8
+ attr_reader :context
9
+
10
+ def initialize(context)
11
+ @context = context
12
+ end
13
+
14
+ protected
15
+
16
+ def source
17
+ context.source
18
+ end
19
+
20
+ def dispatcher
21
+ @dispatcher ||= BlockDispatcher.new(context)
22
+ end
23
+
24
+ def paragraph_transformer
25
+ @paragraph_transformer ||= ParagraphTransformer.new(context)
26
+ end
27
+
28
+ def list_transformer
29
+ @list_transformer ||= ListTransformer.new(context)
30
+ end
31
+
32
+ def table_transformer
33
+ @table_transformer ||= TableTransformer.new(context)
34
+ end
35
+
36
+ def figure_transformer
37
+ @figure_transformer ||= FigureTransformer.new(context)
38
+ end
39
+
40
+ def formula_transformer
41
+ @formula_transformer ||= FormulaTransformer.new(context)
42
+ end
43
+
44
+ def note_transformer
45
+ @note_transformer ||= NoteTransformer.new(context)
46
+ end
47
+
48
+ def reference_transformer
49
+ @reference_transformer ||= ReferenceTransformer.new(context)
50
+ end
51
+
52
+ def section_transformer
53
+ @section_transformer ||= SectionTransformer.new(context)
54
+ end
55
+
56
+ def term_transformer
57
+ @term_transformer ||= TermTransformer.new(context)
58
+ end
59
+
60
+ def dl_transformer
61
+ @dl_transformer ||= DlTransformer.new(context)
62
+ end
63
+
64
+ def front_transformer
65
+ @front_transformer ||= FrontTransformer.new(context)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end