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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.adoc +291 -0
- data/data/themes/oiml/assets/oiml-logo-full-dark.svg +1 -0
- data/data/themes/oiml/assets/oiml-logo-full-light.svg +1 -0
- data/data/themes/oiml/assets/oiml-logo-icon-dark.svg +1 -0
- data/data/themes/oiml/assets/oiml-logo-icon-light.svg +1 -0
- data/data/themes/oiml/assets/oiml-logo.svg +1 -0
- data/data/themes/oiml/theme.yaml +56 -0
- data/exe/oiml-sts +10 -0
- data/lib/metanorma/oiml/document/root.rb +29 -0
- data/lib/metanorma/oiml/document.rb +35 -0
- data/lib/metanorma/oiml/html/renderer.rb +14 -0
- data/lib/metanorma/oiml/html.rb +14 -0
- data/lib/metanorma/oiml/sts/cli.rb +89 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/metanorma-icon-dark.svg +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/metanorma-icon-light.svg +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-logo-dark.svg +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-logo.svg +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-smart-logo-dark.svg +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-smart-logo.svg +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/page.js +145 -0
- data/lib/metanorma/oiml/sts/html_renderer/assets/theme.css +509 -0
- data/lib/metanorma/oiml/sts/html_renderer/ruby.rb +1071 -0
- data/lib/metanorma/oiml/sts/html_renderer/templates/_element.html.liquid +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/templates/_footer.html.liquid +21 -0
- data/lib/metanorma/oiml/sts/html_renderer/templates/_hero.html.liquid +9 -0
- data/lib/metanorma/oiml/sts/html_renderer/templates/_img.html.liquid +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/templates/_link.html.liquid +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/templates/_meta_header.html.liquid +1 -0
- data/lib/metanorma/oiml/sts/html_renderer/templates/document.html.liquid +43 -0
- data/lib/metanorma/oiml/sts/html_renderer.rb +19 -0
- data/lib/metanorma/oiml/sts/mathml_builder.rb +93 -0
- data/lib/metanorma/oiml/sts/parity_validator/normaliser.rb +95 -0
- data/lib/metanorma/oiml/sts/parity_validator/report.rb +137 -0
- data/lib/metanorma/oiml/sts/parity_validator.rb +23 -0
- data/lib/metanorma/oiml/sts/transformer/back_transformer.rb +55 -0
- data/lib/metanorma/oiml/sts/transformer/base.rb +71 -0
- data/lib/metanorma/oiml/sts/transformer/block_dispatcher.rb +145 -0
- data/lib/metanorma/oiml/sts/transformer/body_transformer.rb +23 -0
- data/lib/metanorma/oiml/sts/transformer/context.rb +27 -0
- data/lib/metanorma/oiml/sts/transformer/dl_transformer.rb +49 -0
- data/lib/metanorma/oiml/sts/transformer/document_transformer.rb +65 -0
- data/lib/metanorma/oiml/sts/transformer/figure_transformer.rb +49 -0
- data/lib/metanorma/oiml/sts/transformer/footnote_collector.rb +33 -0
- data/lib/metanorma/oiml/sts/transformer/formula_transformer.rb +45 -0
- data/lib/metanorma/oiml/sts/transformer/front_transformer.rb +84 -0
- data/lib/metanorma/oiml/sts/transformer/id_generator.rb +39 -0
- data/lib/metanorma/oiml/sts/transformer/inline_transformer.rb +14 -0
- data/lib/metanorma/oiml/sts/transformer/list_transformer.rb +82 -0
- data/lib/metanorma/oiml/sts/transformer/model_builder.rb +322 -0
- data/lib/metanorma/oiml/sts/transformer/note_transformer.rb +103 -0
- data/lib/metanorma/oiml/sts/transformer/paragraph_transformer.rb +574 -0
- data/lib/metanorma/oiml/sts/transformer/reference_transformer.rb +107 -0
- data/lib/metanorma/oiml/sts/transformer/rendered_text_extractor.rb +168 -0
- data/lib/metanorma/oiml/sts/transformer/section_transformer.rb +233 -0
- data/lib/metanorma/oiml/sts/transformer/source_document.rb +246 -0
- data/lib/metanorma/oiml/sts/transformer/standard.rb +37 -0
- data/lib/metanorma/oiml/sts/transformer/table_transformer.rb +290 -0
- data/lib/metanorma/oiml/sts/transformer/term_transformer.rb +236 -0
- data/lib/metanorma/oiml/sts/transformer.rb +47 -0
- data/lib/metanorma/oiml/sts/validation_report.rb +43 -0
- data/lib/metanorma/oiml/sts/validator.rb +126 -0
- data/lib/metanorma/oiml/sts/version.rb +9 -0
- data/lib/metanorma/oiml/sts.rb +84 -0
- data/lib/metanorma-oiml.rb +10 -0
- data/schematron/oiml-sts.sch +70 -0
- 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
|