openphar 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 (108) hide show
  1. checksums.yaml +7 -0
  2. data/README.adoc +221 -0
  3. data/lib/openphar/cli/main.rb +137 -0
  4. data/lib/openphar/cli.rb +11 -0
  5. data/lib/openphar/core/slug_generator.rb +107 -0
  6. data/lib/openphar/core.rb +11 -0
  7. data/lib/openphar/errors/configuration_error.rb +8 -0
  8. data/lib/openphar/errors/monograph_not_found_error.rb +15 -0
  9. data/lib/openphar/errors/parse_error.rb +18 -0
  10. data/lib/openphar/errors/unknown_format_error.rb +8 -0
  11. data/lib/openphar/errors/unknown_publisher_error.rb +8 -0
  12. data/lib/openphar/errors/unknown_type_error.rb +8 -0
  13. data/lib/openphar/errors/validation_error.rb +15 -0
  14. data/lib/openphar/errors.rb +17 -0
  15. data/lib/openphar/exporters/coverage_validator.rb +265 -0
  16. data/lib/openphar/exporters/neo4j/model_registry.rb +130 -0
  17. data/lib/openphar/exporters/neo4j/node_builder.rb +84 -0
  18. data/lib/openphar/exporters/neo4j/property_mapper.rb +154 -0
  19. data/lib/openphar/exporters/neo4j/relationship_builder.rb +87 -0
  20. data/lib/openphar/exporters/neo4j.rb +16 -0
  21. data/lib/openphar/exporters/neo4j_exporter.rb +199 -0
  22. data/lib/openphar/exporters.rb +15 -0
  23. data/lib/openphar/linkers/chp/cross_edition_linker.rb +101 -0
  24. data/lib/openphar/linkers/chp.rb +11 -0
  25. data/lib/openphar/linkers/cross_publisher_linker.rb +295 -0
  26. data/lib/openphar/linkers/herbapedia_linker.rb +125 -0
  27. data/lib/openphar/linkers.rb +13 -0
  28. data/lib/openphar/migrators/chp/classifier.rb +86 -0
  29. data/lib/openphar/migrators/chp.rb +15 -0
  30. data/lib/openphar/migrators/chp_migrator.rb +130 -0
  31. data/lib/openphar/migrators/jp_migrator.rb +345 -0
  32. data/lib/openphar/migrators/phint_migrator.rb +426 -0
  33. data/lib/openphar/migrators.rb +15 -0
  34. data/lib/openphar/models/ahp.rb +25 -0
  35. data/lib/openphar/models/amino_acid_monograph.rb +28 -0
  36. data/lib/openphar/models/api.rb +29 -0
  37. data/lib/openphar/models/assay_specification.rb +19 -0
  38. data/lib/openphar/models/base_entity.rb +221 -0
  39. data/lib/openphar/models/biological_substance_monograph.rb +31 -0
  40. data/lib/openphar/models/chemical_drug_monograph.rb +39 -0
  41. data/lib/openphar/models/chp/biologic.rb +28 -0
  42. data/lib/openphar/models/chp/chemical_preparation.rb +28 -0
  43. data/lib/openphar/models/chp/chemical_substance.rb +28 -0
  44. data/lib/openphar/models/chp/general_chapter.rb +30 -0
  45. data/lib/openphar/models/chp/monograph.rb +116 -0
  46. data/lib/openphar/models/chp/section.rb +37 -0
  47. data/lib/openphar/models/chp/tcm_crude_drug.rb +28 -0
  48. data/lib/openphar/models/chp/tcm_extract.rb +29 -0
  49. data/lib/openphar/models/chp/tcm_formulation.rb +28 -0
  50. data/lib/openphar/models/chp.rb +33 -0
  51. data/lib/openphar/models/crude_drug_monograph.rb +31 -0
  52. data/lib/openphar/models/edition.rb +41 -0
  53. data/lib/openphar/models/formulation_monograph.rb +31 -0
  54. data/lib/openphar/models/hkcmms.rb +26 -0
  55. data/lib/openphar/models/identification_specification.rb +19 -0
  56. data/lib/openphar/models/jp/kampo_formula.rb +94 -0
  57. data/lib/openphar/models/jp.rb +21 -0
  58. data/lib/openphar/models/limit.rb +54 -0
  59. data/lib/openphar/models/mineral_substance_monograph.rb +28 -0
  60. data/lib/openphar/models/monograph.rb +58 -0
  61. data/lib/openphar/models/ph_int/buffer_solution.rb +38 -0
  62. data/lib/openphar/models/ph_int/dosage_form_monograph.rb +43 -0
  63. data/lib/openphar/models/ph_int/radiopharmaceutical_monograph.rb +52 -0
  64. data/lib/openphar/models/ph_int/reagent.rb +50 -0
  65. data/lib/openphar/models/ph_int/reference_substance.rb +50 -0
  66. data/lib/openphar/models/ph_int/test_method.rb +50 -0
  67. data/lib/openphar/models/ph_int/test_solution.rb +39 -0
  68. data/lib/openphar/models/ph_int/volumetric_solution.rb +38 -0
  69. data/lib/openphar/models/ph_int.rb +37 -0
  70. data/lib/openphar/models/physical_specification.rb +17 -0
  71. data/lib/openphar/models/publisher.rb +98 -0
  72. data/lib/openphar/models/purity_specification.rb +16 -0
  73. data/lib/openphar/models/supplement.rb +31 -0
  74. data/lib/openphar/models/test_specification.rb +29 -0
  75. data/lib/openphar/models/thp.rb +24 -0
  76. data/lib/openphar/models/vitamin_monograph.rb +28 -0
  77. data/lib/openphar/models.rb +58 -0
  78. data/lib/openphar/monograph_merger.rb +374 -0
  79. data/lib/openphar/parsers/base_monograph_parser.rb +108 -0
  80. data/lib/openphar/parsers/jp_html_parser.rb +92 -0
  81. data/lib/openphar/parsers/jp_html_parser_base.rb +113 -0
  82. data/lib/openphar/parsers/jp_ja_html_parser.rb +220 -0
  83. data/lib/openphar/parsers/phint_json_parser.rb +332 -0
  84. data/lib/openphar/parsers.rb +21 -0
  85. data/lib/openphar/registry/publisher_registry.rb +46 -0
  86. data/lib/openphar/registry/type_registry.rb +147 -0
  87. data/lib/openphar/registry.rb +24 -0
  88. data/lib/openphar/repositories/monograph_repository.rb +350 -0
  89. data/lib/openphar/repositories.rb +11 -0
  90. data/lib/openphar/transformers/jsonld_transformer.rb +106 -0
  91. data/lib/openphar/transformers.rb +11 -0
  92. data/lib/openphar/version.rb +5 -0
  93. data/lib/openphar.rb +119 -0
  94. data/ontology/bibliographic/edition.ttl +267 -0
  95. data/ontology/context/chp.jsonld +69 -0
  96. data/ontology/context/crude-drugs.jsonld +56 -0
  97. data/ontology/context/pharmacopoeia.jsonld +631 -0
  98. data/ontology/core/identification.ttl +456 -0
  99. data/ontology/core/pharmacopoeia.ttl +3427 -0
  100. data/ontology/core/substance-form.ttl +302 -0
  101. data/ontology/core/unit.ttl +493 -0
  102. data/ontology/publisher/chp/extensions.ttl +186 -0
  103. data/ontology/publisher/phint/extensions.ttl +327 -0
  104. data/ontology/quality/test-method.ttl +610 -0
  105. data/shapes/chp/monograph.ttl +153 -0
  106. data/shapes/edition-shapes.ttl +230 -0
  107. data/shapes/monograph-shapes.ttl +1324 -0
  108. metadata +319 -0
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Openphar
4
+ module Parsers
5
+ # Parser for Japanese (JA) Japan Pharmacopoeia HTML monographs
6
+ #
7
+ # Parses JP monographs from Japanese HTML files where:
8
+ # - class="s1" contains Japanese names
9
+ # - class="s2" contains Latin names
10
+ # - class="s5" contains section headers
11
+ # - class="s3" contains content paragraphs
12
+ #
13
+ # Japanese section headers:
14
+ # - 生薬の性状 / 性状 - Macroscopic Description
15
+ # - 顕微鏡的性状 - Microscopic Description
16
+ # - 確認試験 - Identification
17
+ # - 純度試験 - Purity
18
+ # - 異物 - Foreign Matter
19
+ # - 乾燥減量 - Loss on Drying
20
+ # - 灰分 - Total Ash
21
+ # - 酸不溶性灰分 - Acid Insoluble Ash
22
+ # - エキス量 - Extractive
23
+ # - 定量法 - Assay
24
+ # - 貯法 - Storage
25
+ # - 容器 - Container
26
+ class JpJaHtmlParser < JpHtmlParserBase
27
+ # Japanese section patterns for crude drugs
28
+ CRUDE_DRUG_SECTION_PATTERNS = {
29
+ definition: /(?:生薬の性状|性状|成分規格)/,
30
+ macroscopic_description: /生薬の性状/,
31
+ microscopic_description: /顕微鏡的性状/,
32
+ identification: /確認試験/,
33
+ purity: /純度試験/,
34
+ foreign_matter: /異物/,
35
+ loss_on_drying: /乾燥減量/,
36
+ total_ash: /灰分/,
37
+ acid_insoluble_ash: /酸不溶性灰分/,
38
+ extractive: /エキス量/,
39
+ assay: /定量法/,
40
+ storage: /貯法/,
41
+ container: /容器/
42
+ }.freeze
43
+
44
+ # Japanese section patterns for chemical drugs
45
+ CHEMICAL_DRUG_SECTION_PATTERNS = {
46
+ specification: /成分規格/,
47
+ description: /性状/,
48
+ identification: /確認試験/,
49
+ purity: /純度試験/,
50
+ loss_on_drying: /乾燥減量/,
51
+ residue_on_ignition: /強熱残分/,
52
+ assay: /定量法/,
53
+ storage: /貯法/
54
+ }.freeze
55
+
56
+ # CSS selectors for Japanese HTML
57
+ JAPANESE_NAME_SELECTOR = "p.s1"
58
+ LATIN_NAME_SELECTOR = "p.s2"
59
+ CONTENT_SELECTOR = "p.s3"
60
+ SECTION_HEADER_CLASS = "s5"
61
+
62
+ # Reference marker patterns (Japanese uses 〈〉 instead of <>)
63
+ REFERENCE_PATTERN = /〈([^〉]+)〉/
64
+
65
+ attr_reader :monographs
66
+
67
+ # Initialize parser with HTML content
68
+ #
69
+ # @param html_content [String] The HTML content to parse
70
+ # @param monograph_type [Symbol] :crude_drug or :chemical_drug
71
+ def initialize(html_content, monograph_type: :crude_drug)
72
+ super(html_content)
73
+ @monograph_type = monograph_type
74
+ @section_patterns = if monograph_type == :crude_drug
75
+ CRUDE_DRUG_SECTION_PATTERNS
76
+ else
77
+ CHEMICAL_DRUG_SECTION_PATTERNS
78
+ end
79
+ end
80
+
81
+ # Parse all monographs from the document
82
+ #
83
+ # @return [Array<Hash>] Array of parsed monograph data
84
+ def parse
85
+ @monographs = []
86
+ current_monograph = nil
87
+
88
+ # Iterate through all paragraph elements
89
+ document.css("p").each do |paragraph|
90
+ classes = paragraph.classes
91
+
92
+ if classes.include?("s1") && monograph_name?(paragraph.text)
93
+ # Start of a new monograph
94
+ if current_monograph
95
+ @monographs << current_monograph
96
+ end
97
+
98
+ current_monograph = start_new_monograph(paragraph)
99
+ elsif current_monograph && classes.include?("s2")
100
+ # Latin name follows Japanese name
101
+ latin_name = extract_latin_name(paragraph.text)
102
+ current_monograph[:latin_name] = latin_name if latin_name
103
+ elsif current_monograph
104
+ # Check for section headers (s5 class)
105
+ if classes.include?(SECTION_HEADER_CLASS)
106
+ current_section = detect_section(paragraph.text)
107
+ current_monograph[:current_section] = current_section
108
+ else
109
+ # Content for current section
110
+ add_content_to_section(current_monograph, paragraph.text)
111
+ end
112
+ end
113
+ end
114
+
115
+ # Don't forget the last monograph
116
+ @monographs << current_monograph if current_monograph
117
+
118
+ @monographs
119
+ end
120
+
121
+ private
122
+
123
+ # Check if text is a monograph name (not just a section title)
124
+ #
125
+ # @param text [String] The paragraph text
126
+ # @return [Boolean] true if it appears to be a monograph name
127
+ def monograph_name?(text)
128
+ cleaned = text.strip
129
+ # Remove zero-width characters and other invisible Unicode
130
+ cleaned = cleaned.gsub(/\p{Zs}|\p{Cf}/, "").strip
131
+
132
+ return false if cleaned.empty?
133
+
134
+ # Filter out section headers and category titles
135
+ return false if cleaned.match?(/^(生薬等|医薬品|一般試験法|第[一二三四五六七八九十]+版)/)
136
+ return false if cleaned.length > 30
137
+
138
+ # Must contain at least one Japanese character (Hiragana, Katakana, or Kanji)
139
+ # and be a reasonable length for a monograph name
140
+ return false unless cleaned.match?(/[\p{Hiragana}\p{Katakana}\p{Han}]{2,}/)
141
+
142
+ true
143
+ end
144
+
145
+ # Start a new monograph with Japanese name
146
+ #
147
+ # @param paragraph [Nokogiri::XML::Element] The paragraph element
148
+ # @return [Hash] New monograph hash
149
+ def start_new_monograph(paragraph)
150
+ {
151
+ japanese_name: paragraph.text.strip,
152
+ latin_name: nil,
153
+ sections: {},
154
+ current_section: :definition
155
+ }
156
+ end
157
+
158
+ # Extract Latin name from paragraph text
159
+ #
160
+ # @param text [String] The paragraph text
161
+ # @return [String, nil] The Latin name or nil
162
+ def extract_latin_name(text)
163
+ # Latin names typically have uppercase words
164
+ # Format: "Common Name LATIN NAME"
165
+ parts = text.strip.split
166
+ latin_parts = parts.select { |p| p.match?(/^[A-Z]+$/) || p.match?(/^[A-Z][a-z]+$/) }
167
+
168
+ return nil if latin_parts.empty?
169
+
170
+ # Find where Latin name starts (usually after common name)
171
+ latin_start = parts.index { |p| p.match?(/^[A-Z]{2,}/) }
172
+ return nil unless latin_start
173
+
174
+ parts[latin_start..].join(" ")
175
+ end
176
+
177
+ # Detect section type from header text
178
+ #
179
+ # @param text [String] The header text
180
+ # @return [Symbol, nil] The section type
181
+ def detect_section(text)
182
+ cleaned = text.strip
183
+
184
+ @section_patterns.each do |section_type, pattern|
185
+ return section_type if cleaned.match?(pattern)
186
+ end
187
+
188
+ nil
189
+ end
190
+
191
+ # Add content to the current section
192
+ #
193
+ # @param monograph [Hash] The monograph hash
194
+ # @param content [String] The content to add
195
+ def add_content_to_section(monograph, content)
196
+ return unless content && monograph[:current_section]
197
+
198
+ section = monograph[:current_section]
199
+ monograph[:sections][section] ||= []
200
+ monograph[:sections][section] << clean_content(content)
201
+ end
202
+
203
+ # Clean content text
204
+ #
205
+ # @param text [String] Raw text
206
+ # @return [String] Cleaned text
207
+ def clean_content(text)
208
+ text.strip.gsub(/\s+/, " ")
209
+ end
210
+
211
+ # Convert Japanese reference markers to standard format
212
+ #
213
+ # @param text [String] Text with Japanese markers
214
+ # @return [String] Text with standard markers
215
+ def normalize_references(text)
216
+ text.gsub(REFERENCE_PATTERN, '<\1>')
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "nokogiri"
5
+
6
+ module Openphar
7
+ module Parsers
8
+ # Parser for International Pharmacopoeia (Ph.Int.) JSON files
9
+ #
10
+ # This parser creates Lutaml::Model instances (not hashes) for proper
11
+ # model-driven architecture. All output uses model classes.
12
+ #
13
+ # Ph.Int. data files are JSON with embedded HTML content:
14
+ # {
15
+ # "id": "6.1.1",
16
+ # "title": "Abacavir sulfate (Abacaviri sulfas)",
17
+ # "data": {
18
+ # "t": "Title",
19
+ # "b": "<p>HTML body content...</p>",
20
+ # "c": []
21
+ # }
22
+ # }
23
+ class PhIntJsonParser
24
+ attr_reader :monographs, :stats
25
+
26
+ def initialize
27
+ @monographs = []
28
+ @stats = { total: 0, parsed: 0, failed: 0 }
29
+ end
30
+
31
+ # Parse a single Ph.Int. JSON file
32
+ #
33
+ # @param file_path [String] Path to JSON file
34
+ # @return [Monograph, nil] Parsed monograph model instance
35
+ def parse_file(file_path)
36
+ @stats[:total] += 1
37
+
38
+ begin
39
+ content = File.read(file_path, encoding: "UTF-8")
40
+ data = JSON.parse(content)
41
+ monograph = parse_data(data)
42
+ if monograph
43
+ @monographs << monograph
44
+ @stats[:parsed] += 1
45
+ end
46
+ monograph
47
+ rescue JSON::ParserError => e
48
+ puts " ERROR: Invalid JSON - #{e.message}"
49
+ @stats[:failed] += 1
50
+ nil
51
+ rescue StandardError => e
52
+ puts " ERROR: #{e.message}"
53
+ @stats[:failed] += 1
54
+ nil
55
+ end
56
+ end
57
+
58
+ # Parse all Ph.Int. JSON files in a directory
59
+ #
60
+ # @param directory [String] Directory containing JSON files
61
+ # @return [Array<Monograph>] Parsed monograph model instances
62
+ def parse_directory(directory)
63
+ json_files = Dir.glob(File.join(directory, "b.*.json"))
64
+ puts "Found #{json_files.count} Ph.Int. JSON files"
65
+
66
+ json_files.sort.each do |file|
67
+ puts "Parsing: #{File.basename(file)}"
68
+ parse_file(file)
69
+ end
70
+
71
+ @monographs
72
+ end
73
+
74
+ private
75
+
76
+ # Parse Ph.Int. data structure and create model instance
77
+ #
78
+ # @param data [Hash] Raw JSON data
79
+ # @return [Monograph] Model instance
80
+ def parse_data(data)
81
+ return nil unless data["id"] && data["data"]
82
+
83
+ id = data["id"]
84
+ title = data["title"] || data["data"]["t"]
85
+ body_html = data["data"]["b"]
86
+
87
+ # Extract sections from HTML
88
+ sections = parse_html_body(body_html)
89
+
90
+ # Determine monograph type and create appropriate model
91
+ create_monograph_model(id, title, sections)
92
+ end
93
+
94
+ # Parse HTML body content to extract sections
95
+ #
96
+ # @param html [String] HTML content
97
+ # @return [Hash] Extracted sections
98
+ def parse_html_body(html)
99
+ return {} if html.nil? || html.empty?
100
+
101
+ doc = Nokogiri::HTML.fragment(html)
102
+ {
103
+ molecular_formula: extract_molecular_formula(doc),
104
+ molecular_weight: extract_molecular_weight(doc),
105
+ cas_number: extract_cas_number(doc),
106
+ chemical_name: extract_chemical_name(doc),
107
+ description: extract_section_content(doc, "Description"),
108
+ solubility: extract_section_content(doc, "Solubility"),
109
+ storage: extract_section_content(doc, "Storage"),
110
+ definition: extract_section_content(doc, "Definition"),
111
+ identity_tests: extract_section_content(doc, "Identity tests"),
112
+ related_substances: extract_section_content(doc, "Related substances"),
113
+ assay: extract_section_content(doc, "Assay"),
114
+ impurities: extract_impurities(doc)
115
+ }
116
+ end
117
+
118
+ # Create the appropriate model instance based on type
119
+ def create_monograph_model(id, title, sections)
120
+ slug = generate_slug(title)
121
+ en_name, la_name = parse_title(title)
122
+
123
+ base_attrs = {
124
+ id: "https://www.openphar.org/data/phint/monographs/#{slug}",
125
+ monograph_id: "PhInt13-#{id}",
126
+ pref_label: { "en" => en_name, "la" => la_name }.compact,
127
+ publisher: "WHO",
128
+ status: "Official",
129
+ belongs_to_edition: "https://www.openphar.org/data/edition/phint/phint13"
130
+ }
131
+
132
+ case determine_monograph_type(id, sections)
133
+ when "RadiopharmaceuticalMonograph"
134
+ create_radiopharmaceutical_model(base_attrs, sections)
135
+ when "DosageFormMonograph"
136
+ create_dosage_form_model(base_attrs, sections)
137
+ when "TestMethod"
138
+ create_test_method_model(base_attrs, title, sections)
139
+ when "Reagent"
140
+ create_reagent_model(base_attrs, title, sections)
141
+ else
142
+ create_chemical_drug_model(base_attrs, sections)
143
+ end
144
+ end
145
+
146
+ # Create ChemicalDrugMonograph model instance
147
+ def create_chemical_drug_model(attrs, sections)
148
+ # Build attributes hash, only including non-nil values
149
+ model_attrs = attrs.dup
150
+
151
+ # Only add molecular_weight if it's a valid number
152
+ if sections[:molecular_weight] && sections[:molecular_weight] > 0
153
+ model_attrs[:molecular_weight] = sections[:molecular_weight]
154
+ end
155
+
156
+ model_attrs[:molecular_formula] = sections[:molecular_formula] if sections[:molecular_formula]
157
+ model_attrs[:cas_number] = sections[:cas_number] if sections[:cas_number]
158
+ model_attrs[:systematic_name] = { "en" => sections[:chemical_name] } if sections[:chemical_name]
159
+ model_attrs[:definition] = { "en" => sections[:definition] } if sections[:definition]
160
+ model_attrs[:test_specifications] = build_test_specifications(sections)
161
+
162
+ ChemicalDrugMonograph.new(**model_attrs)
163
+ end
164
+
165
+ # Create RadiopharmaceuticalMonograph model instance
166
+ def create_radiopharmaceutical_model(attrs, sections)
167
+ model_attrs = attrs.dup
168
+
169
+ # Only add molecular_weight if it's a valid number
170
+ if sections[:molecular_weight] && sections[:molecular_weight] > 0
171
+ model_attrs[:molecular_weight] = sections[:molecular_weight]
172
+ end
173
+
174
+ model_attrs[:molecular_formula] = sections[:molecular_formula] if sections[:molecular_formula]
175
+ model_attrs[:cas_number] = sections[:cas_number] if sections[:cas_number]
176
+
177
+ PhIntRadiopharmaceuticalMonograph.new(**model_attrs)
178
+ end
179
+
180
+ # Create DosageFormMonograph model instance
181
+ def create_dosage_form_model(attrs, _sections)
182
+ PhIntDosageFormMonograph.new(**attrs)
183
+ end
184
+
185
+ # Create TestMethod model instance
186
+ def create_test_method_model(attrs, title, _sections)
187
+ PhIntTestMethod.new(
188
+ **attrs,
189
+ method_id: attrs[:monograph_id],
190
+ pref_label: { "en" => title }
191
+ )
192
+ end
193
+
194
+ # Create Reagent model instance
195
+ def create_reagent_model(attrs, title, _sections)
196
+ PhIntReagent.new(
197
+ **attrs,
198
+ reagent_id: attrs[:monograph_id],
199
+ pref_label: { "en" => title }
200
+ )
201
+ end
202
+
203
+ # Extract molecular formula from HTML
204
+ def extract_molecular_formula(doc)
205
+ formula_elem = doc.at_xpath(".//p[contains(., 'Relative molecular mass')]")
206
+ return nil unless formula_elem
207
+
208
+ prev_elem = formula_elem.previous_element
209
+ return nil unless prev_elem
210
+
211
+ text = prev_elem.text.gsub(/<sub>/i, "").gsub(/<\/sub>/i, "").strip
212
+ text if text.match?(/\([A-Z][a-z]?\d*\)/)
213
+ end
214
+
215
+ # Extract molecular weight from HTML
216
+ def extract_molecular_weight(doc)
217
+ weight_elem = doc.at_xpath(".//p[contains(., 'Relative molecular mass')]")
218
+ return nil unless weight_elem
219
+
220
+ match = weight_elem.text.match(/(\d+\.?\d*)/)
221
+ match[1].to_f if match
222
+ end
223
+
224
+ # Extract CAS number from HTML
225
+ def extract_cas_number(doc)
226
+ cas_elem = doc.at_xpath(".//p[contains(., 'CAS Reg. No')]")
227
+ return nil unless cas_elem
228
+
229
+ match = cas_elem.text.match(/CAS Reg\. No\.\s*(\d+-\d+-\d+)/i)
230
+ match[1] if match
231
+ end
232
+
233
+ # Extract chemical name from HTML
234
+ def extract_chemical_name(doc)
235
+ name_elem = doc.at_xpath(".//p[contains(., 'Chemical name')]")
236
+ return nil unless name_elem
237
+
238
+ text = name_elem.text.sub(/Chemical name\.\s*/i, "").strip
239
+ text if text.length > 10
240
+ end
241
+
242
+ # Extract content for a specific section
243
+ def extract_section_content(doc, section_name)
244
+ header = doc.at_xpath(".//p[contains(., '#{section_name}')]")
245
+ return nil unless header
246
+
247
+ text = header.text.sub(/.*#{section_name}\.?\s*/i, "").strip
248
+ text unless text.empty?
249
+ end
250
+
251
+ # Extract impurities list
252
+ def extract_impurities(doc)
253
+ impurities = []
254
+
255
+ doc.xpath(".//p[contains(., 'Impurities')]/following-sibling::p").each do |p|
256
+ text = p.text.strip
257
+ next if text.empty?
258
+
259
+ if text.match?(/^[A-Z]\.\s/)
260
+ impurities << {
261
+ letter: text[0],
262
+ description: text[3..].strip
263
+ }
264
+ end
265
+ end
266
+
267
+ impurities
268
+ end
269
+
270
+ # Determine monograph type from ID and sections
271
+ def determine_monograph_type(id, _sections)
272
+ case id
273
+ when /^6\.1\./ then "ChemicalDrugMonograph"
274
+ when /^6\.2\./ then "DosageFormMonograph"
275
+ when /^6\.3\./ then "RadiopharmaceuticalMonograph"
276
+ when /^6\.4\./ then "CrudeDrugMonograph"
277
+ when /^7\./ then "TestMethod"
278
+ when /^9\./ then "Reagent"
279
+ else "ChemicalDrugMonograph"
280
+ end
281
+ end
282
+
283
+ # Parse title into English and Latin names
284
+ def parse_title(title)
285
+ return [title, nil] unless title
286
+
287
+ match = title.match(/^(.+?)\s*\(([^)]+)\)/)
288
+ if match
289
+ [match[1].strip, match[2].strip]
290
+ else
291
+ [title, nil]
292
+ end
293
+ end
294
+
295
+ # Generate URL-safe slug from title using SlugGenerator
296
+ def generate_slug(title)
297
+ Openphar::Core::SlugGenerator.generate_from_title(title)
298
+ end
299
+
300
+ # Build TestSpecification model instances
301
+ def build_test_specifications(sections)
302
+ specs = []
303
+
304
+ if sections[:identity_tests]
305
+ specs << IdentificationSpecification.new(
306
+ test_name: { "en" => "Identity tests" },
307
+ test_type: "TestType-Identification",
308
+ test_conditions: { "en" => sections[:identity_tests] }
309
+ )
310
+ end
311
+
312
+ if sections[:related_substances]
313
+ specs << PuritySpecification.new(
314
+ test_name: { "en" => "Related substances" },
315
+ test_type: "TestType-Purity",
316
+ test_conditions: { "en" => sections[:related_substances] }
317
+ )
318
+ end
319
+
320
+ if sections[:assay]
321
+ specs << AssaySpecification.new(
322
+ test_name: { "en" => "Assay" },
323
+ test_type: "TestType-Assay",
324
+ test_conditions: { "en" => sections[:assay] }
325
+ )
326
+ end
327
+
328
+ specs
329
+ end
330
+ end
331
+ end
332
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Openphar
4
+ module Parsers
5
+ # Parsers for various pharmacopoeia data formats.
6
+ #
7
+ # All parsers inherit from BaseMonographParser and return
8
+ # Lutaml::Model instances (not hashes).
9
+
10
+ # Abstract base class
11
+ autoload :BaseMonographParser, 'openphar/parsers/base_monograph_parser'
12
+
13
+ # Japan Pharmacopoeia parsers
14
+ autoload :JpHtmlParserBase, 'openphar/parsers/jp_html_parser_base'
15
+ autoload :JpHtmlParser, 'openphar/parsers/jp_html_parser'
16
+ autoload :JpJaHtmlParser, 'openphar/parsers/jp_ja_html_parser'
17
+
18
+ # International Pharmacopoeia parser
19
+ autoload :PhIntJsonParser, 'openphar/parsers/phint_json_parser'
20
+ end
21
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Openphar
4
+ module Registry
5
+ # Registry for pharmacopoeia publishers.
6
+ class PublisherRegistry
7
+ class << self
8
+ def get(code)
9
+ return nil if code.nil? || code.empty?
10
+ registry[code.to_s.upcase]
11
+ end
12
+
13
+ def register(code, info)
14
+ registry[code.to_s.upcase] = info
15
+ end
16
+
17
+ def registered?(code)
18
+ !get(code).nil?
19
+ end
20
+
21
+ def all_codes
22
+ registry.keys
23
+ end
24
+
25
+ def namespace_for(code)
26
+ info = get(code)
27
+ return nil unless info
28
+ info[:namespace_module]
29
+ end
30
+
31
+ def clear
32
+ @registry = nil
33
+ end
34
+
35
+ private
36
+
37
+ def registry
38
+ @registry ||= {
39
+ 'JP' => { namespace_module: Models::JP },
40
+ 'PHINT' => { namespace_module: Models::PhInt }
41
+ }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end