Skip to content

Commit 8fe806b

Browse files
authored
refactor: centralize SVG Rendering via Service Wrapper with Priority-Based Fallback (#2856)
introduces a **Service Wrapper** for the SVG rendering pipeline, providing a unified and fault-tolerant way to render SVGs. ## Key Changes - All direct usages of rendering services (**Indigo**, **KetcherSVG**, and **OpenBabel**) have been replaced with a centralized **Service Wrapper**. - The wrapper automatically handles service selection, prioritization, and fallback. ## Rendering Logic SVG rendering follows this priority order: 1. **Indigo Service** 2. **KetcherSVG Service** 3. **OpenBabel Service** (fallback) - If a higher-priority service is **disabled** or **fails to return a successful response**, the wrapper automatically falls back to the next available service. - If both **Indigo** and **KetcherSVG** are disabled or unavailable, SVG rendering is performed using **OpenBabel**.
1 parent 7728a96 commit 8fe806b

File tree

11 files changed

+460
-38
lines changed

11 files changed

+460
-38
lines changed

app/api/chemotion/molecule_api.rb

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ class MoleculeAPI < Grape::API
7070
svg_file_src = Rails.public_path.join('images', 'molecules', molecule.molecule_svg_file)
7171
if File.exist?(svg_file_src)
7272
if svg.nil? || svg&.include?('Open Babel')
73-
indigo_served_svg = Molecule.svg_reprocess(svg, molecule.molfile)
74-
if indigo_served_svg
75-
svg_process = SVG::Processor.new.structure_svg('ketcher', indigo_served_svg, svg_digest, true)
73+
svg = Molecule.svg_reprocess(svg, molecule.molfile)
74+
if svg
75+
svg_process = SVG::Processor.new.structure_svg('ketcher', svg, svg_digest, true)
7676
FileUtils.cp(svg_process[:svg_file_path], svg_file_src)
7777
end
7878
else
@@ -196,8 +196,9 @@ class MoleculeAPI < Grape::API
196196
if File.exist?(svg_file_src)
197197
mol = molecule.molfile.lines.first(2)
198198
if mol[1]&.strip&.match?('OpenBabel')
199-
svg = File.read(svg_file_src)
200-
svg_process = SVG::Processor.new.structure_svg('openbabel', svg, molfile)
199+
svg = Molecule.svg_reprocess(svg, molecule.molfile)
200+
svg_digest = "#{molecule.inchikey}#{Time.zone.now}"
201+
svg_process = SVG::Processor.new.structure_svg('ketcher', svg, svg_digest, true)
201202
else
202203
svg_process = SVG::Processor.new.generate_svg_info('samples', molfile)
203204
FileUtils.cp(svg_file_src, svg_process[:svg_file_path])
@@ -297,7 +298,7 @@ class MoleculeAPI < Grape::API
297298
processor = if params[:is_chemdraw]
298299
Chemotion::ChemdrawSvgProcessor.new(svg)
299300
else
300-
Chemotion::KetcherSvgProcessor.new(svg)
301+
KetcherService::SVGProcessor.new(svg)
301302
end
302303
svg = processor.centered_and_scaled_svg
303304
molecule = Molecule.find(params[:id])

app/api/chemotion/research_plan_api.rb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,14 +228,12 @@ class ResearchPlanAPI < Grape::API
228228
end
229229
post :svg do
230230
svg = params[:svg_file]
231-
error!({ error: 'Invalid SVG' }, 422) unless IndigoService.new(nil).valid_indigo_svg?(svg)
232231
processor = if params[:is_chemdraw]
233232
Chemotion::ChemdrawSvgProcessor.new(svg)
234233
else
235-
Chemotion::KetcherSvgProcessor.new(svg)
234+
KetcherService::SVGProcessor.new(svg)
236235
end
237236
svg = processor.centered_and_scaled_svg
238-
239237
digest = Digest::SHA256.hexdigest svg
240238
digest = Digest::SHA256.hexdigest digest
241239
svg_file_name = "#{digest}.svg"

app/models/molecule.rb

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -252,15 +252,10 @@ def unique_molecule_name(new_name)
252252
end
253253

254254
def self.svg_reprocess(svg, struct)
255-
return svg if indigo_disabled?
256255
return svg if svg_valid_and_not_openbabel?(svg)
257256

258-
rendered_svg = IndigoService.new(struct, 'image/svg+xml').render_structure
259-
rendered_svg || Chemotion::OpenBabelService.svg_from_molfile(struct)
260-
end
261-
262-
def self.indigo_disabled?
263-
Rails.configuration.indigo_service.disabled?
257+
# Use unified SVG renderer service with fallback chain
258+
Chemotion::SvgRenderer.render_svg_from_molfile(struct)
264259
end
265260

266261
def self.svg_valid_and_not_openbabel?(svg)

app/services/indigo_service.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ def initialize(struct, output_format = 'image/svg+xml', options = nil)
2929
@service_url = Rails.configuration.indigo_service.indigo_service_url
3030
end
3131

32+
# Checks if Indigo service is disabled
33+
#
34+
# @return [Boolean] True if service is disabled, false otherwise
35+
def self.disabled?
36+
Rails.configuration.indigo_service&.disabled? || false
37+
end
38+
3239
# Renders the chemical structure using the Indigo service.
3340
#
3441
# @return [String, nil] The rendered SVG (or other format), or nil if rendering fails.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
3+
# This initializer loads the optional configuration for:
4+
# the ketcher rendering service
5+
6+
# Specific
7+
validations = lambda do |config, service|
8+
url = URI.parse(config.send(service)&.url)
9+
raise ArgumentError, "Invalid URL: #{url}" unless url.host && %w[http https].include?(url.scheme)
10+
11+
# set description
12+
config.send(service).desc = "service hosted at: #{url}"
13+
end
14+
15+
# Generic initialization
16+
service = File.basename(__FILE__, '.rb').to_sym # Service name
17+
service_setter = :"#{service}=" # Service setter
18+
ref = "Initializing #{service}:" # Message prefix
19+
20+
Rails.application.configure do
21+
config.send(service_setter, config_for(service)) # Load config/.yml
22+
validations.call(config, service) # Validate configuration
23+
# Rescue:
24+
# - RuntimeError is raised if the file is not found
25+
# - NoMethodError is raised if the yml file cannot be parsed
26+
rescue RuntimeError, NoMethodError, ArgumentError, URI::InvalidURIError => e
27+
Rails.logger.warn "#{ref} Error while loading configuration #{e.message}"
28+
# Create service key or clear config
29+
config.send(service_setter, nil)
30+
ensure
31+
# Load default missing configuration if the yml file not found or no config is defined for the environment
32+
config.send(service_setter, config_for(:default_missing)) unless config.send(service)
33+
Rails.logger.info "#{ref} #{config.send(service).desc}"
34+
end

config/ketcher_service.yml.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
development:
2+
:url: 'http://ketchersvc:4000/render'
3+
4+
production:
5+
:url: 'http://ketchersvc:4000/render'

lib/chemotion/svg_renderer.rb

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# frozen_string_literal: true
2+
3+
require Rails.root.join('lib/ketcher_service/render_svg.rb')
4+
5+
# rubocop:disable Rails/Output
6+
module Chemotion
7+
# Unified SVG renderer with fallback chain:
8+
# 1. IndigoService (if enabled)
9+
# 2. KetcherService (if Indigo fails or is disabled)
10+
# 3. OpenBabelService (if both fail)
11+
class SvgRenderer
12+
# Renders SVG from molfile using the full fallback chain:
13+
# Indigo -> Ketcher -> OpenBabel
14+
#
15+
# @param struct [String] The molfile structure to render
16+
# @return [String, nil] The rendered SVG, or nil if all services fail
17+
def self.render_svg_from_molfile(struct)
18+
rendered_svg = indigo_service(struct)
19+
return rendered_svg if rendered_svg
20+
21+
rendered_svg = ketcher_service(struct)
22+
return rendered_svg if rendered_svg
23+
24+
open_babel_service(struct)
25+
end
26+
27+
# Attempts to render SVG using IndigoService
28+
#
29+
# @param struct [String] The molfile structure to render
30+
# @return [String, nil] The rendered SVG, or nil if rendering fails or is disabled
31+
def self.indigo_service(struct)
32+
if IndigoService.disabled?
33+
puts '❌ IndigoService is disabled, falling back to KetcherService'
34+
return nil
35+
end
36+
37+
rendered_svg = IndigoService.new(struct, 'image/svg+xml').render_structure
38+
if rendered_svg.present?
39+
puts '✅ SVG rendered using IndigoService'
40+
return rendered_svg
41+
end
42+
43+
puts '❌ IndigoService returned nil or empty, falling back to KetcherService'
44+
nil
45+
end
46+
47+
# Attempts to render SVG using KetcherService
48+
#
49+
# @param struct [String] The molfile structure to render
50+
# @return [String, nil] The rendered SVG, or nil if rendering fails or is disabled
51+
def self.ketcher_service(struct)
52+
if KetcherService.disabled?
53+
puts '❌ KetcherService is disabled, falling back to OpenBabelService'
54+
return nil
55+
end
56+
57+
rendered_svg = render_svg(struct)
58+
if rendered_svg.present?
59+
puts '✅ SVG rendered using KetcherService'
60+
rendered_svg
61+
else
62+
puts 'KetcherService returned nil or empty, falling back to OpenBabelService'
63+
nil
64+
end
65+
rescue StandardError => e
66+
Rails.logger.error("KetcherService exception: #{e.message}")
67+
nil
68+
end
69+
70+
# Attempts to render SVG using OpenBabelService
71+
#
72+
# @param struct [String] The molfile structure to render
73+
# @return [String, nil] The rendered SVG, or nil if rendering fails
74+
def self.open_babel_service(struct)
75+
rendered_svg = OpenBabelService.svg_from_molfile(struct)
76+
if rendered_svg.present?
77+
puts '✅ SVG rendered using OpenBabelService'
78+
rendered_svg
79+
else
80+
puts 'OpenBabelService returned nil or empty.'
81+
nil
82+
end
83+
rescue StandardError => e
84+
Rails.logger.error("❌ OpenBabelService failed: #{e.message}")
85+
nil
86+
end
87+
88+
# Renders SVG from molfile using Ketcher service only
89+
#
90+
# @param molfile [String] The molfile structure to render
91+
# @return [String, nil] The rendered and processed SVG, or nil if rendering fails
92+
def self.render_svg(molfile)
93+
rendered_svg = KetcherService::RenderSvg.svg(molfile)
94+
return nil if rendered_svg.blank?
95+
96+
svg = KetcherService::SVGProcessor.new(rendered_svg)
97+
svg.centered_and_scaled_svg
98+
rescue StandardError => e
99+
Rails.logger.error("Chemotion::SvgRenderer failed: #{e.message}")
100+
nil
101+
end
102+
end
103+
# rubocop:enable Rails/Output
104+
end

lib/ketcher_service/render_svg.rb

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
require 'uri'
4+
require 'net/http'
5+
require 'json'
6+
7+
module KetcherService
8+
# Checks if Ketcher service is disabled
9+
#
10+
# @return [Boolean] True if service is disabled, false otherwise
11+
def self.disabled?
12+
Rails.configuration.ketcher_service&.disabled? || false
13+
end
14+
15+
# Use Ketcher-as-a-Service to render molfiles to SVG
16+
module RenderSvg
17+
def self.call_render_service(url, request)
18+
use_ssl = url.instance_of? URI::HTTPS
19+
Rails.logger.info("Sending molfile to render service at: #{url} (SSL: #{use_ssl})")
20+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
21+
res = Net::HTTP.start(url.host, url.port, read_timeout: 1.5, use_ssl: use_ssl) do |http|
22+
http.request(request)
23+
end
24+
finish = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25+
Rails.logger.info("Render service response: #{res.code} in #{finish - start} seconds")
26+
raise Net::HTTPError.new("Server replied #{res.code}.", res) if res.code != '200'
27+
28+
svg = JSON.parse(res.body)['svg']
29+
Rails.logger.info('Render service replied with SVG.')
30+
svg
31+
rescue Errno::ECONNREFUSED
32+
Rails.logger.error('Errno::ECONNREFUSED: ketcher_service unreachable')
33+
raise
34+
rescue Errno::ENOENT
35+
Rails.logger.error('IOError')
36+
raise
37+
rescue Net::ReadTimeout
38+
Rails.logger.error('Timeout.')
39+
raise
40+
rescue Net::HTTPError
41+
Rails.logger.error('HTTP error')
42+
raise
43+
rescue JSON::ParserError => e
44+
Rails.logger.error("Can't parse reply: #{e.message}")
45+
raise
46+
end
47+
48+
def self.svg(molfile)
49+
url = URI(Rails.configuration.ketcher_service.url)
50+
request = Net::HTTP::Post.new(url.path, { 'Content-Type' => 'application/json' })
51+
request.body = { molfile: molfile.force_encoding('utf-8') }.to_json
52+
svg = RenderSvg.call_render_service(url, request)
53+
svg.force_encoding('utf-8')
54+
rescue StandardError
55+
nil
56+
end
57+
end
58+
end

0 commit comments

Comments
 (0)