metanorma-plugin-plantuml 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "fileutils"
5
+ require_relative "wrapper"
6
+ require_relative "utils"
7
+ require_relative "errors"
8
+
9
+ module Metanorma
10
+ module Plugin
11
+ module Plantuml
12
+ # Backend class for PlantUML diagram generation
13
+ # Adapted from metanorma-standoc's PlantUMLBlockMacroBackend
14
+ class Backend
15
+ class << self
16
+ def plantuml_installed?
17
+ unless Wrapper.available?
18
+ raise "PlantUML not installed"
19
+ end
20
+ end
21
+
22
+ def plantuml_available?
23
+ Wrapper.available?
24
+ end
25
+
26
+ def generate_file(parent, reader, format_override = nil)
27
+ ldir, imagesdir, fmt = generate_file_prep(parent)
28
+ fmt = format_override if format_override
29
+ plantuml_content = prep_source(reader)
30
+
31
+ # Extract filename from PlantUML source if specified
32
+ extracted_filename = extract_plantuml_filename(plantuml_content)
33
+
34
+ absolute_path, relative_path = path_prep(ldir, imagesdir)
35
+ filename = extracted_filename ? "#{extracted_filename}.#{fmt}" : generate_unique_filename(fmt)
36
+ output_file = File.join(absolute_path, filename)
37
+
38
+ result = Wrapper.generate(
39
+ plantuml_content,
40
+ format: fmt,
41
+ output_file: output_file
42
+ )
43
+
44
+ unless result[:success]
45
+ raise "No image output from PlantUML: #{result[:error].message}"
46
+ end
47
+
48
+ File.join(relative_path, filename)
49
+ end
50
+
51
+ def generate_multiple_files(parent, reader, formats, attrs)
52
+ # Generate files for each format
53
+ filenames = formats.map do |format|
54
+ generate_file(parent, reader, format)
55
+ end
56
+
57
+ # Return data for BlockProcessor to create image block
58
+ through_attrs = generate_attrs attrs
59
+ through_attrs["target"] = filenames.first
60
+
61
+ # Store additional formats for potential future use
62
+ through_attrs["data-formats"] = formats.join(",")
63
+ through_attrs["data-files"] = filenames.join(",")
64
+
65
+ through_attrs
66
+ end
67
+
68
+ def generate_file_prep(parent)
69
+ ldir = localdir(parent)
70
+ imagesdir = parent.document.attr("imagesdir")
71
+ fmt = parent.document.attr("plantuml-image-format")&.strip&.downcase || "png"
72
+ [ldir, imagesdir, fmt]
73
+ end
74
+
75
+ def localdir(parent)
76
+ ret = Utils.localdir(parent.document)
77
+ File.writable?(ret) or
78
+ raise "Destination directory #{ret} not writable for PlantUML!"
79
+ ret
80
+ end
81
+
82
+ def path_prep(localdir, imagesdir)
83
+ path = Pathname.new(File.join(localdir, "_plantuml_images"))
84
+ sourcepath = imagesdir ? File.join(localdir, imagesdir) : localdir
85
+ path.mkpath
86
+ File.writable?(path) or
87
+ raise "Destination path #{path} not writable for PlantUML!"
88
+ [path, Pathname.new(path).relative_path_from(Pathname.new(sourcepath)).to_s]
89
+ end
90
+
91
+ def prep_source(reader)
92
+ src = reader.source
93
+
94
+ # Validate that we have matching start/end pairs
95
+ validate_plantuml_delimiters(src)
96
+ src
97
+ end
98
+
99
+ def generate_attrs(attrs)
100
+ %w(id align float title role width height alt)
101
+ .inject({}) do |memo, key|
102
+ memo[key] = attrs[key] if attrs.has_key?(key)
103
+ memo
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def validate_plantuml_delimiters(src)
110
+ # Find @start... pattern (case insensitive, support all PlantUML diagram types)
111
+ start_match = src.match(/^@start(\w+)/i)
112
+ unless start_match
113
+ raise "PlantUML content must start with @start... directive!"
114
+ end
115
+
116
+ diagram_type = start_match[1].downcase
117
+ end_pattern = "@end#{diagram_type}"
118
+
119
+ # Look for matching @end... directive (case insensitive)
120
+ unless src.match(/#{Regexp.escape(end_pattern)}\s*$/mi)
121
+ raise "@start#{diagram_type} without matching #{end_pattern} in PlantUML!"
122
+ end
123
+ end
124
+
125
+ def extract_plantuml_filename(plantuml_content)
126
+ # Extract filename from any @start... line if specified
127
+ # Only match when filename is on the same line as @start... (no newlines)
128
+ lines = plantuml_content.lines
129
+ first_line = lines.first&.strip
130
+ return nil unless first_line
131
+
132
+ match = first_line.match(/^@start\w+\s+(.+)$/i)
133
+ return nil unless match
134
+
135
+ filename = match[1].strip
136
+ return nil if filename.nil? || filename.empty?
137
+
138
+ # Sanitize filename for filesystem use
139
+ sanitize_filename(filename)
140
+ end
141
+
142
+ def sanitize_filename(filename)
143
+ # Remove quotes and sanitize for filesystem
144
+ filename = filename.gsub(/^["']|["']$/, "")
145
+ # Replace any non-alphanumeric characters (except dash, underscore, dot) with underscore
146
+ filename.gsub(/[^\w\-.]/, "_")
147
+ .gsub(/\.{2,}/, "_") # Replace multiple dots with underscore
148
+ .gsub(/_{2,}/, "_") # Replace multiple underscores with single
149
+ .gsub(/^[_\-\.]+|[_\-\.]+$/, "") # Remove leading/trailing special chars
150
+ end
151
+
152
+ def generate_unique_filename(format)
153
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S_%L")
154
+ "plantuml_#{timestamp}.#{format}"
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "asciidoctor"
4
+ require "asciidoctor/extensions"
5
+ require_relative "backend"
6
+
7
+ module Metanorma
8
+ module Plugin
9
+ module Plantuml
10
+ # PlantUML block processor for Asciidoctor
11
+ class BlockProcessor < ::Asciidoctor::Extensions::BlockProcessor
12
+ use_dsl
13
+ named :plantuml
14
+ on_context :literal
15
+ parse_content_as :raw
16
+
17
+ def abort(parent, reader, attrs, msg)
18
+ warn msg
19
+ attrs["language"] = "plantuml"
20
+ create_listing_block(
21
+ parent,
22
+ reader.source,
23
+ (attrs.reject { |k, _v| k.to_s.match?(/^\d+$/) })
24
+ )
25
+ end
26
+
27
+ def process(parent, reader, attrs)
28
+ # Check for document-level disable flag
29
+ if parent.document.attr("plantuml-disabled")
30
+ return abort(parent, reader, attrs, "PlantUML processing disabled")
31
+ end
32
+
33
+ # Check PlantUML availability explicitly
34
+ unless Backend.plantuml_available?
35
+ return abort(parent, reader, attrs, "PlantUML not installed")
36
+ end
37
+
38
+ # Parse format specifications
39
+ formats = parse_formats(attrs, parent.document)
40
+
41
+ if formats.length == 1
42
+ # Single format - original behavior
43
+ filename = Backend.generate_file(parent, reader, formats.first)
44
+ through_attrs = Backend.generate_attrs attrs
45
+ through_attrs["target"] = filename
46
+ create_image_block parent, through_attrs
47
+ else
48
+ # Multiple formats - generate multiple files
49
+ through_attrs = Backend.generate_multiple_files(parent, reader, formats, attrs)
50
+ create_image_block parent, through_attrs
51
+ end
52
+ rescue StandardError => e
53
+ abort(parent, reader, attrs, e.message)
54
+ end
55
+
56
+ private
57
+
58
+ def parse_formats(attrs, document)
59
+ # Check for formats attribute (multiple formats)
60
+ if attrs["formats"]
61
+ formats = attrs["formats"].split(",").map(&:strip).map(&:downcase)
62
+ return formats.select { |f| valid_format?(f) }
63
+ end
64
+
65
+ # Check for format attribute (single format override)
66
+ if attrs["format"]
67
+ format = attrs["format"].strip.downcase
68
+ return [format] if valid_format?(format)
69
+ end
70
+
71
+ # Fall back to document attribute or default
72
+ default_format = document.attr("plantuml-image-format")&.strip&.downcase || "png"
73
+ [default_format]
74
+ end
75
+
76
+ def valid_format?(format)
77
+ %w[png svg pdf txt eps].include?(format)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,48 @@
1
+ require "rbconfig"
2
+
3
+ module Metanorma
4
+ module Plugin
5
+ module Plantuml
6
+ class Config
7
+ attr_accessor :java_path, :memory_limit, :output_dir, :temp_dir
8
+
9
+ def initialize
10
+ @java_path = "java"
11
+ @memory_limit = "1024m"
12
+ @output_dir = nil
13
+ @temp_dir = nil
14
+ end
15
+
16
+ def jvm_options
17
+ options = [
18
+ "-Xss5m",
19
+ "-Xmx#{memory_limit}",
20
+ "-Djava.awt.headless=true"
21
+ ]
22
+
23
+ if RbConfig::CONFIG["host_os"].match?(/darwin|mac os/)
24
+ options << "-Dapple.awt.UIElement=true"
25
+ end
26
+
27
+ options
28
+ end
29
+
30
+ def java_command
31
+ [java_path, *jvm_options]
32
+ end
33
+ end
34
+
35
+ class << self
36
+ attr_writer :configuration
37
+ end
38
+
39
+ def self.configuration
40
+ @configuration ||= Config.new
41
+ end
42
+
43
+ def self.configure
44
+ yield(configuration)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,42 @@
1
+ module Metanorma
2
+ module Plugin
3
+ module Plantuml
4
+ class PlantumlError < StandardError
5
+ def initialize(message = nil, original_error = nil)
6
+ super(message)
7
+ @original_error = original_error
8
+ end
9
+
10
+ attr_reader :original_error
11
+ end
12
+
13
+ class JarNotFoundError < PlantumlError
14
+ def initialize(jar_path = nil)
15
+ message = jar_path ?
16
+ "PlantUML JAR file not found at: #{jar_path}" :
17
+ "PlantUML JAR file not found"
18
+ super(message)
19
+ end
20
+ end
21
+
22
+ class JavaNotFoundError < PlantumlError
23
+ def initialize
24
+ super("Java runtime not found. Please ensure Java is installed and available in PATH")
25
+ end
26
+ end
27
+
28
+ class GenerationError < PlantumlError
29
+ def initialize(message, java_error = nil)
30
+ super("PlantUML generation failed: #{message}")
31
+ @original_error = java_error
32
+ end
33
+ end
34
+
35
+ class InvalidFormatError < PlantumlError
36
+ def initialize(format, available_formats)
37
+ super("Invalid format '#{format}'. Available formats: #{available_formats.join(', ')}")
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "liquid"
4
+ require "pathname"
5
+
6
+ module Metanorma
7
+ module Plugin
8
+ module Plantuml
9
+ module Utils
10
+ class << self
11
+ def localdir(document)
12
+ document.attributes["localdir"] ||
13
+ document.attributes["docdir"] ||
14
+ File.dirname(document.attributes["docfile"] || ".")
15
+ end
16
+
17
+ def relative_file_path(document, file_path)
18
+ return file_path if Pathname.new(file_path).absolute?
19
+
20
+ docdir = document.attributes["docdir"] || Dir.pwd
21
+ File.expand_path(file_path, docdir)
22
+ end
23
+
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,8 @@
1
+ module Metanorma
2
+ module Plugin
3
+ module Plantuml
4
+ VERSION = "1.0.0"
5
+ PLANTUML_JAR_VERSION = "1.2025.4"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,256 @@
1
+ require "open3"
2
+ require "base64"
3
+ require "tempfile"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+ require "rbconfig"
7
+ require_relative "version"
8
+ require_relative "errors"
9
+ require_relative "config"
10
+
11
+ module Metanorma
12
+ module Plugin
13
+ module Plantuml
14
+ class Wrapper
15
+ PLANTUML_JAR_NAME = "plantuml.jar"
16
+ PLANTUML_JAR_PATH = File.join(File.dirname(__FILE__), "..", "..", "..", "..", "data", PLANTUML_JAR_NAME)
17
+
18
+ SUPPORTED_FORMATS = %w[png svg pdf txt eps].freeze
19
+ DEFAULT_FORMAT = "png"
20
+
21
+ class << self
22
+ def jvm_options
23
+ options = ["-Xss5m", "-Xmx1024m"]
24
+
25
+ if RbConfig::CONFIG["host_os"].match?(/darwin|mac os/)
26
+ options << "-Dapple.awt.UIElement=true"
27
+ end
28
+
29
+ options
30
+ end
31
+
32
+ def generate(content, format: DEFAULT_FORMAT, output_file: nil, base64: false, **options)
33
+ validate_format!(format)
34
+ ensure_jar_available!
35
+ ensure_java_available!
36
+
37
+ result = if output_file
38
+ generate_to_file(content, format, output_file, options)
39
+ elsif base64
40
+ generate_to_base64(content, format, options)
41
+ else
42
+ generate_to_temp_file(content, format, options)
43
+ end
44
+
45
+ { success: true }.merge(result)
46
+ rescue PlantumlError => e
47
+ { success: false, error: e }
48
+ end
49
+
50
+ def generate_from_file(input_file, format: DEFAULT_FORMAT, output_file: nil, base64: false, **options)
51
+ unless File.exist?(input_file)
52
+ raise GenerationError.new("Input file not found: #{input_file}")
53
+ end
54
+
55
+ content = File.read(input_file)
56
+ generate(content, format: format, output_file: output_file, base64: base64, **options)
57
+ end
58
+
59
+ def version
60
+ return nil unless available?
61
+
62
+ cmd = [configuration.java_path, *jvm_options, "-jar", PLANTUML_JAR_PATH, "-version"]
63
+ output, error, status = Open3.capture3(*cmd)
64
+
65
+ if status.success?
66
+ # Extract version from output
67
+ version_match = output.match(/PlantUML version ([\d.]+)/)
68
+ version_match ? version_match[1] : PLANTUML_JAR_VERSION
69
+ else
70
+ nil
71
+ end
72
+ rescue StandardError
73
+ nil
74
+ end
75
+
76
+ def available?
77
+ return false if ENV["PLANTUML_DISABLED"] == "true"
78
+ File.exist?(PLANTUML_JAR_PATH) && java_available?
79
+ end
80
+
81
+ def jar_path
82
+ PLANTUML_JAR_PATH
83
+ end
84
+
85
+ private
86
+
87
+ def configuration
88
+ Plantuml.configuration
89
+ end
90
+
91
+ def validate_format!(format)
92
+ format_str = format.to_s.downcase
93
+ unless SUPPORTED_FORMATS.include?(format_str)
94
+ raise InvalidFormatError.new(format, SUPPORTED_FORMATS)
95
+ end
96
+ end
97
+
98
+ def ensure_jar_available!
99
+ unless File.exist?(PLANTUML_JAR_PATH)
100
+ raise JarNotFoundError.new(PLANTUML_JAR_PATH)
101
+ end
102
+ end
103
+
104
+ def ensure_java_available!
105
+ unless java_available?
106
+ raise JavaNotFoundError.new
107
+ end
108
+ end
109
+
110
+ def java_available?
111
+ cmd = [configuration.java_path, "-version"]
112
+ _, _, status = Open3.capture3(*cmd)
113
+ status.success?
114
+ rescue StandardError
115
+ false
116
+ end
117
+
118
+ def generate_to_file(content, format, output_file, options)
119
+ output_dir = File.dirname(output_file)
120
+ FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
121
+
122
+ execute_plantuml(content, format, output_file, options)
123
+
124
+ unless File.exist?(output_file)
125
+ raise GenerationError.new("Output file was not created: #{output_file}")
126
+ end
127
+
128
+ { output_path: File.expand_path(output_file) }
129
+ end
130
+
131
+ def generate_to_base64(content, format, options)
132
+ Tempfile.create(['plantuml_output', ".#{format}"]) do |temp_file|
133
+ temp_file.close
134
+
135
+ execute_plantuml(content, format, temp_file.path, options)
136
+
137
+ unless File.exist?(temp_file.path)
138
+ raise GenerationError.new("Temporary output file was not created")
139
+ end
140
+
141
+ encoded_content = Base64.strict_encode64(File.read(temp_file.path))
142
+ { base64: encoded_content }
143
+ end
144
+ end
145
+
146
+ def generate_to_temp_file(content, format, options)
147
+ temp_dir = configuration.temp_dir || Dir.tmpdir
148
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S_%L")
149
+ output_file = File.join(temp_dir, "plantuml_#{timestamp}.#{format}")
150
+
151
+ generate_to_file(content, format, output_file, options)
152
+ end
153
+
154
+ def execute_plantuml(content, format, output_file, options)
155
+ Tempfile.create(['plantuml_input', '.puml']) do |input_file|
156
+ input_file.write(content)
157
+ input_file.close
158
+
159
+ # PlantUML generates output files based on filename specified in @start... line
160
+ # We need to use a temp directory and then move the file
161
+ Dir.mktmpdir do |temp_dir|
162
+ cmd = build_command(input_file.path, format, temp_dir, options)
163
+
164
+ output, error, status = Open3.capture3(*cmd)
165
+
166
+ unless status.success?
167
+ error_message = error.empty? ? "Unknown PlantUML error" : error.strip
168
+ raise GenerationError.new(error_message, error)
169
+ end
170
+
171
+ # Find the generated file and move it to the desired location
172
+ if output_file
173
+ generated_file = find_generated_file(temp_dir, content, format)
174
+ if generated_file && File.exist?(generated_file)
175
+ FileUtils.mv(generated_file, output_file)
176
+ else
177
+ # Debug: List what files were actually generated
178
+ generated_files = Dir.glob(File.join(temp_dir, "*"))
179
+ error_msg = "Generated file not found in temp directory. "
180
+ error_msg += "Expected: #{generated_file}. "
181
+ error_msg += "Found files: #{generated_files.map { |f| File.basename(f) }.join(', ')}"
182
+ raise GenerationError.new(error_msg)
183
+ end
184
+ end
185
+
186
+ output
187
+ end
188
+ end
189
+ end
190
+
191
+ def find_generated_file(temp_dir, content, format)
192
+ # PlantUML generates files based on the filename specified in @start... line
193
+ extension = format.to_s.downcase
194
+
195
+ # First, try to extract filename from PlantUML content
196
+ plantuml_filename = extract_plantuml_filename_from_content(content)
197
+
198
+ if plantuml_filename
199
+ # Look for file with PlantUML-specified name
200
+ generated_file = File.join(temp_dir, "#{plantuml_filename}.#{extension}")
201
+ return generated_file if File.exist?(generated_file)
202
+ end
203
+
204
+ # Fallback: scan directory for any generated files with correct extension
205
+ Dir.glob(File.join(temp_dir, "*.#{extension}")).first
206
+ end
207
+
208
+ def extract_plantuml_filename_from_content(content)
209
+ # Extract the raw filename from @start... line (don't sanitize)
210
+ match = content.match(/^@start\w+\s+(.+)$/mi)
211
+ return nil unless match
212
+
213
+ filename = match[1].strip.split("\n").first&.strip
214
+ return nil if filename.nil? || filename.empty?
215
+
216
+ # Remove quotes but keep the original name as PlantUML will use it
217
+ filename.gsub(/^["']|["']$/, "")
218
+ end
219
+
220
+ def build_command(input_file, format, output_dir, options)
221
+ cmd = [
222
+ configuration.java_path,
223
+ *jvm_options,
224
+ "-jar", PLANTUML_JAR_PATH
225
+ ]
226
+
227
+ # Add format-specific options
228
+ case format.to_s.downcase
229
+ when "png"
230
+ cmd << "-tpng"
231
+ when "svg"
232
+ cmd << "-tsvg"
233
+ when "pdf"
234
+ cmd << "-tpdf"
235
+ when "txt"
236
+ cmd << "-ttxt"
237
+ when "eps"
238
+ cmd << "-teps"
239
+ end
240
+
241
+ # Add output directory option
242
+ cmd << "-o" << output_dir
243
+
244
+ # Add charset option for better compatibility
245
+ cmd << "-charset" << "UTF-8"
246
+
247
+ # Add input file
248
+ cmd << input_file
249
+
250
+ cmd
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,15 @@
1
+
2
+ module Metanorma
3
+ module Plugin
4
+ module Plantuml
5
+ end
6
+ end
7
+ end
8
+
9
+ require "metanorma/plugin/plantuml/version"
10
+ require "metanorma/plugin/plantuml/errors"
11
+ require "metanorma/plugin/plantuml/config"
12
+ require "metanorma/plugin/plantuml/wrapper"
13
+ require "metanorma/plugin/plantuml/utils"
14
+ require "metanorma/plugin/plantuml/backend"
15
+ require "metanorma/plugin/plantuml/block_processor"
@@ -0,0 +1,35 @@
1
+ lib = File.expand_path("lib", __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "metanorma/plugin/plantuml/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "metanorma-plugin-plantuml"
7
+ spec.version = Metanorma::Plugin::Plantuml::VERSION
8
+ spec.authors = ["Ribose Inc."]
9
+ spec.email = ["open.source@ribose.com"]
10
+
11
+ spec.summary = "Metanorma plugin for PlantUML"
12
+ spec.description = "Metanorma plugin for PlantUML"
13
+
14
+ spec.homepage = "https://github.com/metanorma/metanorma-plugin-plantuml"
15
+ spec.license = "BSD-2-Clause"
16
+
17
+ # Specify which files should be added to the gem when it is released.
18
+ # The `git ls-files -z` loads the files in the RubyGem that have been
19
+ # added into git.
20
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
21
+ `git ls-files -z`.split("\x0").reject do |f|
22
+ f.match(%r{^(test|spec|features)/})
23
+ end
24
+ end + ["data/plantuml.jar"]
25
+ spec.bindir = "exe"
26
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
27
+ spec.require_paths = ["lib"]
28
+
29
+ spec.required_ruby_version = ">= 2.7.0" # rubocop:disable Gemspec/RequiredRubyVersion
30
+
31
+ spec.add_dependency "asciidoctor"
32
+ spec.add_dependency "isodoc"
33
+
34
+ spec.metadata["rubygems_mfa_required"] = "false"
35
+ end