metanorma-plugin-plantuml 1.0.0 → 1.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82db5cce760911ac43ad666a3d25e800341ff7b4f591d0905562f2e8bccb99e2
4
- data.tar.gz: 170923ac6b7ace25020922e57904417ced9a3ef6d6f311a83be9ce0ab65b281f
3
+ metadata.gz: 3b3797ddb4db236753261836d74b360e635c58ea3b0eeaa09dfc1c7192dacf78
4
+ data.tar.gz: 0aba3ecbbc90f15570887ea206fa0a0c19b6d3179a7d9481438fb480df305873
5
5
  SHA512:
6
- metadata.gz: 1347f4e1aab08371499c23dd2b39dfdc3aa3371f1db258ffa36ad557513a915e30b70e470df5f37d5766df38ca106dc54cbaad146f09cb135dfa1c41cab5e5f7
7
- data.tar.gz: 1bb6c111325ab23cbe83f3c1903701740c31573f1baa80760c03f467559c07111b81ab80fc99afbda6de0445e5dab9e1c4cf58f96fb92e24a8552f7e1946a1d6
6
+ metadata.gz: ed86eced1c2c434f17ce4f3f5307c88f349c30275ad2051ea7c6063feb1d13ca6c64480ea406951221a75ceececd035ffed82519d9dfe169205f55a160996c9b
7
+ data.tar.gz: 2e1488a69e6c3ff71a88c17778fead0b264ed2b6c8d2a7d8803e04441d0d7f1b4b2cebdd102050e6428500a477a876d6f3cc8cdf9882da007a7bc6900a19cffa
data/Gemfile CHANGED
@@ -13,9 +13,11 @@ rescue StandardError
13
13
  end
14
14
 
15
15
  gem "byebug"
16
+ gem "canon"
16
17
  gem "debug"
17
18
  gem "metanorma"
18
- gem "metanorma-standoc", github: "metanorma/metanorma-standoc", branch: "feature/extract-plantuml"
19
+ gem "metanorma-standoc", github: "metanorma/metanorma-standoc",
20
+ branch: "main"
19
21
  gem "rake"
20
22
  gem "rspec"
21
23
  gem "rspec-html-matchers"
@@ -25,4 +27,3 @@ gem "simplecov"
25
27
  gem "timecop"
26
28
  gem "vcr"
27
29
  gem "webmock"
28
- gem "canon"
data/README.adoc CHANGED
@@ -65,6 +65,8 @@ the `plantuml` block directive.
65
65
 
66
66
  === Basic syntax
67
67
 
68
+ ==== Block style
69
+
68
70
  [source,asciidoc]
69
71
  ----
70
72
  [plantuml]
@@ -76,6 +78,25 @@ Bob -> Alice: Hi there
76
78
  ....
77
79
  ----
78
80
 
81
+ ==== Block macro style
82
+
83
+ [source,asciidoc]
84
+ ----
85
+ plantuml_image::path/to/my-plantuml.puml[]
86
+ ----
87
+
88
+ The file `path/to/my-plantuml.puml` looks like:
89
+ [source,plantuml]
90
+ ----
91
+ @startuml
92
+ Alice -> Bob: Hello
93
+ Bob -> Alice: Hi there
94
+ @enduml
95
+ ----
96
+
97
+ `plantuml_image` block macro performs the same function as the `plantuml` block,
98
+ but the PlantUML diagram is defined in a separate file instead of a block.
99
+
79
100
  === Supported diagram types
80
101
 
81
102
  PlantUML supports various diagram types, each with its own `@start` and `@end` directives:
@@ -183,6 +204,132 @@ Alice -> Bob: Hello
183
204
  ....
184
205
  ----
185
206
 
207
+ ==== Document-level includedirs configuration
208
+
209
+ When using `!include` or `!includesub` in your PlantUML diagrams, you can set
210
+ the default include directories (separated by semicolons) by
211
+ `plantuml-includedirs` to search for files for all PlantUML diagrams in your
212
+ document:
213
+
214
+ [source,asciidoc]
215
+ ----
216
+ :plantuml-includedirs: path/to/plantuml/include-1;path/to/plantuml/include-2
217
+
218
+ [plantuml]
219
+ ....
220
+ @startuml
221
+ !include sequences.puml!1
222
+ @enduml
223
+ ....
224
+
225
+ [plantuml]
226
+ ....
227
+ @startuml
228
+ !include components.puml!FRONTEND
229
+ !include components.puml!BACKEND
230
+
231
+ WebApp --> APIGateway
232
+ MobileApp --> APIGateway
233
+ APIGateway --> DB
234
+ @enduml
235
+ ....
236
+
237
+ [plantuml]
238
+ ....
239
+ @startuml
240
+ title this contains only B and D
241
+ !includesub subpart.puml!BASIC
242
+ @enduml
243
+ ....
244
+ ----
245
+
246
+ This plugin will search the include directories specified by
247
+ `includedirs` options (i.e. `path/to/plantuml/include-1` and
248
+ `path/to/plantuml/include-2`) for the files referenced in `!include` or
249
+ `!includesub` directives (i.e. `sequences.puml`, `components.puml` and
250
+ `subpart.puml`).
251
+
252
+ You can also use `plantuml_image` to include external PlantUML files as images:
253
+
254
+ [source,asciidoc]
255
+ ----
256
+ :plantuml-includedirs: path/to/plantuml/include-1;path/to/plantuml/include-2
257
+
258
+ plantuml_image::path/to/my-plantuml-1.puml[]
259
+
260
+ plantuml_image::path/to/my-plantuml-2.puml[]
261
+ ----
262
+
263
+ The file `path/to/my-plantuml-1.puml` looks like:
264
+ [source,plantuml]
265
+ ----
266
+ @startuml
267
+ !include sequences.puml!1
268
+ @enduml
269
+ ----
270
+
271
+ The file `path/to/my-plantuml-2.puml` looks like:
272
+ [source,plantuml]
273
+ ----
274
+ @startuml
275
+ !include components.puml!FRONTEND
276
+ !include components.puml!BACKEND
277
+
278
+ WebApp --> APIGateway
279
+ MobileApp --> APIGateway
280
+ APIGateway --> DB
281
+ @enduml
282
+ ----
283
+
284
+ When using `plantuml_image`, the path of the directory of the PlantUML file will
285
+ also be added into the `includedirs`. (i.e. `path/to` will be added to
286
+ `includedirs`)
287
+
288
+ ==== Block-level includedirs configuration
289
+
290
+ When using `!include` or `!includesub` in your PlantUML diagrams, you can set
291
+ the default include directories (separated by semicolons) by `includedirs` in
292
+ block-level to search for files for the PlantUML diagram defined in your block:
293
+
294
+ [source,asciidoc]
295
+ ----
296
+ [plantuml,includedirs="path/to/plantuml/include-1"]
297
+ ....
298
+ @startuml
299
+ !include sequences.puml!1
300
+ @enduml
301
+ ....
302
+
303
+ [plantuml,includedirs="path/to/plantuml/include-2"]
304
+ ....
305
+ @startuml
306
+ !include components.puml!FRONTEND
307
+ !include components.puml!BACKEND
308
+
309
+ WebApp --> APIGateway
310
+ MobileApp --> APIGateway
311
+ APIGateway --> DB
312
+ @enduml
313
+ ....
314
+ ----
315
+
316
+ This plugin will search `sequences.puml` in `path/to/plantuml/include-1` and
317
+ `components.puml` in `path/to/plantuml/include-2`.
318
+
319
+ The block-level `includedirs` configuration can be used together with the
320
+ document-level configuration to provide more granular control over include
321
+ paths. You can set multiple paths by separating them with semicolons.
322
+
323
+ You can also use `plantuml_image` to set `includedirs` option to include
324
+ external PlantUML files:
325
+
326
+ [source,asciidoc]
327
+ ----
328
+ plantuml_image::path/to/my-plantuml-1.puml[includedirs=path/to/plantuml/include-1]
329
+
330
+ plantuml_image::path/to/my-plantuml-2.puml[includedirs=path/to/plantuml/include-2]
331
+ ----
332
+
186
333
  === Image attributes
187
334
 
188
335
  Standard AsciiDoc image attributes are supported:
@@ -272,7 +419,7 @@ Metanorma integration and PlantUML execution:
272
419
  ----
273
420
  Metanorma Document
274
421
 
275
- BlockProcessor ← (processes [plantuml] blocks)
422
+ BlockProcessor ← (processes [plantuml] blocks) or ImageBlockMacroProcessor ← (processes plantuml_image::{path}[{options}] macros)
276
423
 
277
424
  Backend ← (Metanorma integration, paths, validation)
278
425
 
@@ -284,6 +431,9 @@ PlantUML JAR ← (diagram generation)
284
431
  `BlockProcessor`:: Processes `[plantuml]` blocks in Metanorma documents and
285
432
  integrates with the Metanorma rendering pipeline.
286
433
 
434
+ `ImageBlockMacroProcessor`:: Processes `plantuml_image::{path}[{options}]` macros
435
+ in Metanorma documents and integrates with the Metanorma rendering pipeline.
436
+
287
437
  `Backend`:: Handles Metanorma-specific logic including document paths, PlantUML
288
438
  source validation, filename extraction, and attribute mapping.
289
439
 
data/Rakefile CHANGED
@@ -10,7 +10,7 @@ task default: [
10
10
  ]
11
11
 
12
12
  def uri_open(url)
13
- require 'open-uri'
13
+ require "open-uri"
14
14
  URI.parse(url).open
15
15
  end
16
16
 
@@ -29,7 +29,7 @@ file "data/plantuml.jar" do |file|
29
29
  end
30
30
 
31
31
  desc "Download PlantUML JAR file"
32
- task :download_jar => "data/plantuml.jar"
32
+ task download_jar: "data/plantuml.jar"
33
33
 
34
34
  desc "Clean downloaded JAR file"
35
35
  task :clean_jar do
@@ -14,31 +14,38 @@ module Metanorma
14
14
  class Backend
15
15
  class << self
16
16
  def plantuml_installed?
17
- unless Wrapper.available?
18
- raise "PlantUML not installed"
19
- end
17
+ return true if plantuml_available?
18
+
19
+ raise "PlantUML not installed"
20
20
  end
21
21
 
22
22
  def plantuml_available?
23
23
  Wrapper.available?
24
24
  end
25
25
 
26
- def generate_file(parent, reader, format_override = nil)
26
+ def generate_file( # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
27
+ parent, reader, format_override: nil, options: {}
28
+ )
27
29
  ldir, imagesdir, fmt = generate_file_prep(parent)
28
30
  fmt = format_override if format_override
29
- plantuml_content = prep_source(reader)
31
+ plantuml_content = prep_source(parent, reader)
30
32
 
31
33
  # Extract filename from PlantUML source if specified
34
+ filename = generate_unique_filename(fmt)
32
35
  extracted_filename = extract_plantuml_filename(plantuml_content)
33
36
 
37
+ if extracted_filename
38
+ filename = "#{extracted_filename}.#{fmt}"
39
+ end
40
+
34
41
  absolute_path, relative_path = path_prep(ldir, imagesdir)
35
- filename = extracted_filename ? "#{extracted_filename}.#{fmt}" : generate_unique_filename(fmt)
36
42
  output_file = File.join(absolute_path, filename)
37
43
 
38
44
  result = Wrapper.generate(
39
45
  plantuml_content,
40
46
  format: fmt,
41
- output_file: output_file
47
+ output_file: output_file,
48
+ includedirs: options[:includedirs],
42
49
  )
43
50
 
44
51
  unless result[:success]
@@ -48,10 +55,12 @@ module Metanorma
48
55
  File.join(relative_path, filename)
49
56
  end
50
57
 
51
- def generate_multiple_files(parent, reader, formats, attrs)
58
+ def generate_multiple_files(
59
+ parent, reader, formats, attrs, options: {}
60
+ )
52
61
  # Generate files for each format
53
62
  filenames = formats.map do |format|
54
- generate_file(parent, reader, format)
63
+ generate_file(parent, reader, format, options: options)
55
64
  end
56
65
 
57
66
  # Return data for BlockProcessor to create image block
@@ -68,28 +77,44 @@ module Metanorma
68
77
  def generate_file_prep(parent)
69
78
  ldir = localdir(parent)
70
79
  imagesdir = parent.document.attr("imagesdir")
71
- fmt = parent.document.attr("plantuml-image-format")&.strip&.downcase || "png"
80
+ fmt = parent.document
81
+ .attr("plantuml-image-format")&.strip&.downcase ||
82
+ Wrapper::DEFAULT_FORMAT
72
83
  [ldir, imagesdir, fmt]
73
84
  end
74
85
 
75
86
  def localdir(parent)
76
87
  ret = Utils.localdir(parent.document)
77
- File.writable?(ret) or
78
- raise "Destination directory #{ret} not writable for PlantUML!"
79
- ret
88
+ return ret if File.writable?(ret)
89
+
90
+ raise "Destination directory #{ret} not writable for PlantUML!"
80
91
  end
81
92
 
82
93
  def path_prep(localdir, imagesdir)
83
94
  path = Pathname.new(File.join(localdir, "_plantuml_images"))
84
95
  sourcepath = imagesdir ? File.join(localdir, imagesdir) : localdir
85
96
  path.mkpath
86
- File.writable?(path) or
97
+
98
+ unless File.writable?(path)
87
99
  raise "Destination path #{path} not writable for PlantUML!"
88
- [path, Pathname.new(path).relative_path_from(Pathname.new(sourcepath)).to_s]
100
+ end
101
+
102
+ [
103
+ path,
104
+ Pathname.new(path)
105
+ .relative_path_from(Pathname.new(sourcepath)).to_s,
106
+ ]
89
107
  end
90
108
 
91
- def prep_source(reader)
92
- src = reader.source
109
+ def prep_source(parent, reader) # rubocop:disable Metrics/MethodLength
110
+ src = if reader.respond_to?(:source)
111
+ # get content from BlockProcessor
112
+ reader.source
113
+ else
114
+ # get content from ImageBlockMacroProcessor
115
+ docdir = parent.document.attributes["docdir"]
116
+ File.read(File.join(docdir, reader))
117
+ end
93
118
 
94
119
  # Validate that we have matching start/end pairs
95
120
  validate_plantuml_delimiters(src)
@@ -106,8 +131,9 @@ module Metanorma
106
131
 
107
132
  private
108
133
 
109
- def validate_plantuml_delimiters(src)
110
- # Find @start... pattern (case insensitive, support all PlantUML diagram types)
134
+ def validate_plantuml_delimiters(src) # rubocop:disable Metrics/MethodLength
135
+ # Find @start... pattern
136
+ # (case insensitive, support all PlantUML diagram types)
111
137
  start_match = src.match(/^@start(\w+)/i)
112
138
  unless start_match
113
139
  raise "PlantUML content must start with @start... directive!"
@@ -117,14 +143,16 @@ module Metanorma
117
143
  end_pattern = "@end#{diagram_type}"
118
144
 
119
145
  # 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!"
146
+ unless /#{Regexp.escape(end_pattern)}\s*$/mi.match?(src)
147
+ raise "@start#{diagram_type} without matching #{end_pattern} " \
148
+ "in PlantUML!"
122
149
  end
123
150
  end
124
151
 
125
152
  def extract_plantuml_filename(plantuml_content)
126
153
  # Extract filename from any @start... line if specified
127
- # Only match when filename is on the same line as @start... (no newlines)
154
+ # Only match when filename is on the same line as @start...
155
+ # (no newlines)
128
156
  lines = plantuml_content.lines
129
157
  first_line = lines.first&.strip
130
158
  return nil unless first_line
@@ -142,11 +170,12 @@ module Metanorma
142
170
  def sanitize_filename(filename)
143
171
  # Remove quotes and sanitize for filesystem
144
172
  filename = filename.gsub(/^["']|["']$/, "")
145
- # Replace any non-alphanumeric characters (except dash, underscore, dot) with underscore
173
+ # Replace any non-alphanumeric characters
174
+ # (except dash, underscore, dot) with underscore
146
175
  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
176
+ .gsub(/\.{2,}/, "_") # Replace multiple dots with underscore
177
+ .gsub(/_{2,}/, "_") # Replace multiple underscores with single
178
+ .gsub(/^[_\-.]+|[_\-.]+$/, "") # Remove leading/trailing chars
150
179
  end
151
180
 
152
181
  def generate_unique_filename(format)
@@ -1,30 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "asciidoctor"
4
- require "asciidoctor/extensions"
5
- require_relative "backend"
6
-
7
3
  module Metanorma
8
4
  module Plugin
9
5
  module Plantuml
10
6
  # PlantUML block processor for Asciidoctor
11
7
  class BlockProcessor < ::Asciidoctor::Extensions::BlockProcessor
8
+ include ::Metanorma::Plugin::Plantuml::BlockProcessorBase
12
9
  use_dsl
13
10
  named :plantuml
14
11
  on_context :literal
15
12
  parse_content_as :raw
16
13
 
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)
14
+ def process(parent, reader, attrs) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
28
15
  # Check for document-level disable flag
29
16
  if parent.document.attr("plantuml-disabled")
30
17
  return abort(parent, reader, attrs, "PlantUML processing disabled")
@@ -37,44 +24,25 @@ module Metanorma
37
24
 
38
25
  # Parse format specifications
39
26
  formats = parse_formats(attrs, parent.document)
27
+ options = parse_options(parent, reader, attrs)
40
28
 
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
29
+ process_image_block(parent, reader, attrs, formats, options)
52
30
  rescue StandardError => e
53
31
  abort(parent, reader, attrs, e.message)
54
32
  end
55
33
 
56
34
  private
57
35
 
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
36
+ def parse_options(parent, _reader, attrs)
37
+ options = {}
70
38
 
71
- # Fall back to document attribute or default
72
- default_format = document.attr("plantuml-image-format")&.strip&.downcase || "png"
73
- [default_format]
74
- end
39
+ # Parse include directory
40
+ options[:includedirs] = parse_doc_includedirs(parent.document)
41
+ options[:includedirs] = add_attrs_to_includedirs(
42
+ parent.document, attrs, options[:includedirs]
43
+ )
75
44
 
76
- def valid_format?(format)
77
- %w[png svg pdf txt eps].include?(format)
45
+ options
78
46
  end
79
47
  end
80
48
  end
@@ -0,0 +1,99 @@
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
+ module BlockProcessorBase
11
+ def abort(parent, reader, attrs, msg)
12
+ warn msg
13
+ attrs["language"] = "plantuml"
14
+ create_listing_block(
15
+ parent,
16
+ reader.respond_to?(:source) ? reader.source : reader,
17
+ attrs.reject { |k, _v| k.to_s.match?(/^\d+$/) },
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def parse_doc_includedirs(document)
24
+ docdir = document.attributes["docdir"]
25
+ includedirs = document.attr("plantuml-includedirs")&.split(";") || []
26
+
27
+ includedirs.map! do |includedir|
28
+ if Pathname.new(includedir).relative?
29
+ Pathname.new(docdir).join(includedir).to_s
30
+ else
31
+ includedir
32
+ end
33
+ end
34
+
35
+ includedirs.compact.uniq
36
+ end
37
+
38
+ def parse_formats(attrs, document) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/MethodLength
39
+ # Check for formats attribute (multiple formats)
40
+ if attrs["formats"]
41
+ formats = attrs["formats"].split(",").map(&:strip).map(&:downcase)
42
+ return formats.select { |f| valid_format?(f) }
43
+ end
44
+
45
+ # Check for format attribute (single format override)
46
+ if attrs["format"]
47
+ format = attrs["format"].strip.downcase
48
+ return [format] if valid_format?(format)
49
+ end
50
+
51
+ # Fall back to document attribute or default
52
+ default_format = document
53
+ .attr("plantuml-image-format")&.strip&.downcase ||
54
+ Wrapper::DEFAULT_FORMAT
55
+
56
+ [default_format]
57
+ end
58
+
59
+ def valid_format?(format)
60
+ Wrapper::SUPPORTED_FORMATS.include?(format)
61
+ end
62
+
63
+ def add_attrs_to_includedirs(document, attrs, includedirs)
64
+ docdir = document.attributes["docdir"]
65
+ attrs_includedirs = attrs["includedirs"]&.split(";") || []
66
+
67
+ attrs_includedirs.each do |attrs_includedir|
68
+ includedirs << if Pathname.new(attrs_includedir).relative?
69
+ Pathname.new(docdir).join(attrs_includedir).to_s
70
+ else
71
+ attrs_includedir
72
+ end
73
+ end
74
+
75
+ includedirs.compact.uniq
76
+ end
77
+
78
+ def process_image_block(parent, reader, attrs, formats, options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
79
+ if formats.length == 1
80
+ # Single format - original behavior
81
+ filename = Backend.generate_file(
82
+ parent, reader, format_override: formats.first, options: options
83
+ )
84
+ through_attrs = Backend.generate_attrs(attrs)
85
+ through_attrs["target"] = filename
86
+ else
87
+ # Multiple formats - generate multiple files
88
+ through_attrs = Backend
89
+ .generate_multiple_files(
90
+ parent, reader, formats, attrs, options: options
91
+ )
92
+ end
93
+
94
+ create_image_block parent, through_attrs
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -17,7 +17,7 @@ module Metanorma
17
17
  options = [
18
18
  "-Xss5m",
19
19
  "-Xmx#{memory_limit}",
20
- "-Djava.awt.headless=true"
20
+ "-Djava.awt.headless=true",
21
21
  ]
22
22
 
23
23
  if RbConfig::CONFIG["host_os"].match?(/darwin|mac os/)
@@ -12,16 +12,21 @@ module Metanorma
12
12
 
13
13
  class JarNotFoundError < PlantumlError
14
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"
15
+ message = if jar_path
16
+ "PlantUML JAR file not found at: #{jar_path}"
17
+ else
18
+ "PlantUML JAR file not found"
19
+ end
18
20
  super(message)
19
21
  end
20
22
  end
21
23
 
22
24
  class JavaNotFoundError < PlantumlError
23
25
  def initialize
24
- super("Java runtime not found. Please ensure Java is installed and available in PATH")
26
+ super(
27
+ "Java runtime not found. Please ensure Java is installed and " \
28
+ "available in PATH",
29
+ )
25
30
  end
26
31
  end
27
32
 
@@ -34,7 +39,10 @@ module Metanorma
34
39
 
35
40
  class InvalidFormatError < PlantumlError
36
41
  def initialize(format, available_formats)
37
- super("Invalid format '#{format}'. Available formats: #{available_formats.join(', ')}")
42
+ super(
43
+ "Invalid format '#{format}'. Available formats: " \
44
+ "#{available_formats.join(', ')}",
45
+ )
38
46
  end
39
47
  end
40
48
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Plugin
5
+ module Plantuml
6
+ # PlantUML block processor for Asciidoctor
7
+ class ImageBlockMacroProcessor < ::Asciidoctor::Extensions::BlockMacroProcessor
8
+ include ::Metanorma::Plugin::Plantuml::BlockProcessorBase
9
+ use_dsl
10
+ named :plantuml_image
11
+
12
+ def process(parent, reader, attrs) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
13
+ # Check for document-level disable flag
14
+ if parent.document.attr("plantuml-disabled")
15
+ return abort(parent, reader, attrs, "PlantUML processing disabled")
16
+ end
17
+
18
+ # Check PlantUML availability explicitly
19
+ unless Backend.plantuml_available?
20
+ return abort(parent, reader, attrs, "PlantUML not installed")
21
+ end
22
+
23
+ # Parse format specifications
24
+ formats = parse_formats(attrs, parent.document)
25
+ options = parse_options(parent, reader, attrs)
26
+
27
+ process_image_block(parent, reader, attrs, formats, options)
28
+ rescue StandardError => e
29
+ abort(parent, reader, attrs, e.message)
30
+ end
31
+
32
+ private
33
+
34
+ def add_image_path_to_includedirs(document, image_path, includedirs)
35
+ docdir = document.attributes["docdir"]
36
+ includedirs << File.dirname(File.join(docdir, image_path))
37
+ includedirs.compact.uniq
38
+ end
39
+
40
+ def parse_options(parent, reader, attrs)
41
+ options = {}
42
+
43
+ # Parse include directory
44
+ options[:includedirs] = parse_doc_includedirs(parent.document)
45
+ options[:includedirs] = add_attrs_to_includedirs(
46
+ parent.document, attrs, options[:includedirs]
47
+ )
48
+ options[:includedirs] = add_image_path_to_includedirs(
49
+ parent.document, reader, options[:includedirs]
50
+ )
51
+
52
+ options
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -13,14 +13,6 @@ module Metanorma
13
13
  document.attributes["docdir"] ||
14
14
  File.dirname(document.attributes["docfile"] || ".")
15
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
16
  end
25
17
  end
26
18
  end
@@ -1,8 +1,8 @@
1
1
  module Metanorma
2
2
  module Plugin
3
3
  module Plantuml
4
- VERSION = "1.0.0"
5
- PLANTUML_JAR_VERSION = "1.2025.4"
4
+ VERSION = "1.0.2".freeze
5
+ PLANTUML_JAR_VERSION = "1.2025.4".freeze
6
6
  end
7
7
  end
8
8
  end
@@ -12,11 +12,14 @@ module Metanorma
12
12
  module Plugin
13
13
  module Plantuml
14
14
  class Wrapper
15
- PLANTUML_JAR_NAME = "plantuml.jar"
16
- PLANTUML_JAR_PATH = File.join(File.dirname(__FILE__), "..", "..", "..", "..", "data", PLANTUML_JAR_NAME)
15
+ PLANTUML_JAR_NAME = "plantuml.jar".freeze
16
+ PLANTUML_JAR_PATH = File.join(
17
+ File.dirname(__FILE__), "..", "..", "..",
18
+ "..", "data", PLANTUML_JAR_NAME
19
+ )
17
20
 
18
21
  SUPPORTED_FORMATS = %w[png svg pdf txt eps].freeze
19
- DEFAULT_FORMAT = "png"
22
+ DEFAULT_FORMAT = "png".freeze
20
23
 
21
24
  class << self
22
25
  def jvm_options
@@ -29,11 +32,19 @@ module Metanorma
29
32
  options
30
33
  end
31
34
 
32
- def generate(content, format: DEFAULT_FORMAT, output_file: nil, base64: false, **options)
35
+ def generate( # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
36
+ content,
37
+ format: DEFAULT_FORMAT,
38
+ output_file: nil,
39
+ base64: false, **options
40
+ )
33
41
  validate_format!(format)
34
42
  ensure_jar_available!
35
43
  ensure_java_available!
36
44
 
45
+ include_files = get_include_files(content, options)
46
+ options[:include_files] = include_files unless include_files.empty?
47
+
37
48
  result = if output_file
38
49
  generate_to_file(content, format, output_file, options)
39
50
  elsif base64
@@ -47,27 +58,48 @@ module Metanorma
47
58
  { success: false, error: e }
48
59
  end
49
60
 
50
- def generate_from_file(input_file, format: DEFAULT_FORMAT, output_file: nil, base64: false, **options)
61
+ def get_include_files(content, _options) # rubocop:disable Metrics/MethodLength
62
+ include_files = []
63
+ content.each_line do |line|
64
+ case line
65
+ when /(!include|!includesub)\s(.+){1}/
66
+ found_file = $2.split("!").first
67
+
68
+ # skip web links and standard libraries
69
+ if found_file.start_with?("<", "http")
70
+ found_file = nil
71
+ end
72
+
73
+ include_files << found_file
74
+ end
75
+ end
76
+ include_files.compact.uniq
77
+ end
78
+
79
+ def generate_from_file(
80
+ input_file, format: DEFAULT_FORMAT,
81
+ output_file: nil, base64: false, **options
82
+ )
51
83
  unless File.exist?(input_file)
52
84
  raise GenerationError.new("Input file not found: #{input_file}")
53
85
  end
54
86
 
55
87
  content = File.read(input_file)
56
- generate(content, format: format, output_file: output_file, base64: base64, **options)
88
+ generate(content, format: format, output_file: output_file,
89
+ base64: base64, **options)
57
90
  end
58
91
 
59
92
  def version
60
93
  return nil unless available?
61
94
 
62
- cmd = [configuration.java_path, *jvm_options, "-jar", PLANTUML_JAR_PATH, "-version"]
63
- output, error, status = Open3.capture3(*cmd)
95
+ cmd = [configuration.java_path, *jvm_options, "-jar",
96
+ PLANTUML_JAR_PATH, "-version"]
97
+ output, _, status = Open3.capture3(*cmd)
64
98
 
65
99
  if status.success?
66
100
  # Extract version from output
67
101
  version_match = output.match(/PlantUML version ([\d.]+)/)
68
102
  version_match ? version_match[1] : PLANTUML_JAR_VERSION
69
- else
70
- nil
71
103
  end
72
104
  rescue StandardError
73
105
  nil
@@ -75,6 +107,7 @@ module Metanorma
75
107
 
76
108
  def available?
77
109
  return false if ENV["PLANTUML_DISABLED"] == "true"
110
+
78
111
  File.exist?(PLANTUML_JAR_PATH) && java_available?
79
112
  end
80
113
 
@@ -122,23 +155,28 @@ module Metanorma
122
155
  execute_plantuml(content, format, output_file, options)
123
156
 
124
157
  unless File.exist?(output_file)
125
- raise GenerationError.new("Output file was not created: #{output_file}")
158
+ raise GenerationError.new(
159
+ "Output file was not created: #{output_file}",
160
+ )
126
161
  end
127
162
 
128
163
  { output_path: File.expand_path(output_file) }
129
164
  end
130
165
 
131
- def generate_to_base64(content, format, options)
132
- Tempfile.create(['plantuml_output', ".#{format}"]) do |temp_file|
166
+ def generate_to_base64(content, format, options) # rubocop:disable Metrics/MethodLength
167
+ Tempfile.create(["plantuml_output", ".#{format}"]) do |temp_file|
133
168
  temp_file.close
134
169
 
135
170
  execute_plantuml(content, format, temp_file.path, options)
136
171
 
137
172
  unless File.exist?(temp_file.path)
138
- raise GenerationError.new("Temporary output file was not created")
173
+ raise GenerationError.new(
174
+ "Temporary output file was not created",
175
+ )
139
176
  end
140
177
 
141
- encoded_content = Base64.strict_encode64(File.read(temp_file.path))
178
+ encoded_content = Base64
179
+ .strict_encode64(File.read(temp_file.path))
142
180
  { base64: encoded_content }
143
181
  end
144
182
  end
@@ -151,45 +189,92 @@ module Metanorma
151
189
  generate_to_file(content, format, output_file, options)
152
190
  end
153
191
 
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
192
+ def execute_plantuml(content, format, output_file, options) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/MethodLength
193
+ # PlantUML generates output files based on filename specified in
194
+ # @start... line
195
+ # We need to use a temp directory and then move the file
196
+ Dir.mktmpdir do |temp_dir| # rubocop:disable Metrics/BlockLength
197
+ # create input file
198
+ File.open("#{temp_dir}/plantuml_input.puml", "w") do |f|
199
+ f.write(content)
200
+ end
158
201
 
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)
202
+ # Handle include files
203
+ if options[:include_files] && !options[:include_files].empty?
204
+ if options[:includedirs].empty?
205
+ # raise error when include files are found but includedirs
206
+ # is nil
207
+ raise PlantumlError.new(
208
+ "includedirs is required when include files are specified",
209
+ )
210
+ end
163
211
 
164
- output, error, status = Open3.capture3(*cmd)
212
+ options[:include_files].each do |include_file|
213
+ # find local include file in includedirs
214
+ found_include_file = nil
215
+ options[:includedirs].each do |includedir|
216
+ include_file_path = File.join(includedir, include_file)
217
+ if File.exist?(include_file_path)
218
+ found_include_file = include_file_path
219
+ break
220
+ end
221
+ end
165
222
 
166
- unless status.success?
167
- error_message = error.empty? ? "Unknown PlantUML error" : error.strip
168
- raise GenerationError.new(error_message, error)
169
- end
223
+ if found_include_file
224
+ # create include file in temp directory
225
+ temp_include_file = File.join(temp_dir, include_file)
226
+ FileUtils.mkdir_p(File.dirname(temp_include_file))
170
227
 
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)
228
+ File.open(temp_include_file, "w") do |f|
229
+ f.write(File.read(found_include_file))
230
+ end
183
231
  end
184
232
  end
233
+ end
185
234
 
186
- output
235
+ cmd = build_command(
236
+ "#{temp_dir}/plantuml_input.puml",
237
+ format,
238
+ temp_dir,
239
+ options,
240
+ )
241
+
242
+ output, error, status = Open3.capture3(*cmd)
243
+
244
+ unless status.success?
245
+ error_message = if error.empty?
246
+ "Unknown PlantUML error"
247
+ else
248
+ error.strip
249
+ end
250
+ raise GenerationError.new(error_message, error)
187
251
  end
252
+
253
+ # Find the generated file and move it to the desired location
254
+ if output_file
255
+ generated_file = find_generated_file(temp_dir, content,
256
+ format)
257
+ if generated_file && File.exist?(generated_file)
258
+ FileUtils.mv(generated_file, output_file)
259
+ else
260
+ # Debug: List what files were actually generated
261
+ generated_files = Dir.glob(File.join(temp_dir, "*"))
262
+ error_msg = "Generated file not found in temp directory. "
263
+ error_msg += "Expected: #{generated_file}. "
264
+ error_msg += "Found files: #{generated_files.map do |f|
265
+ File.basename(f)
266
+ end.join(', ')}"
267
+ raise GenerationError.new(error_msg)
268
+ end
269
+ end
270
+
271
+ output
188
272
  end
189
273
  end
190
274
 
191
- def find_generated_file(temp_dir, content, format)
192
- # PlantUML generates files based on the filename specified in @start... line
275
+ def find_generated_file(temp_dir, content, format) # rubocop:disable Metrics/MethodLength
276
+ # PlantUML generates files based on the filename specified in
277
+ # @start... line
193
278
  extension = format.to_s.downcase
194
279
 
195
280
  # First, try to extract filename from PlantUML content
@@ -197,17 +282,19 @@ module Metanorma
197
282
 
198
283
  if plantuml_filename
199
284
  # Look for file with PlantUML-specified name
200
- generated_file = File.join(temp_dir, "#{plantuml_filename}.#{extension}")
285
+ generated_file = File.join(temp_dir,
286
+ "#{plantuml_filename}.#{extension}")
201
287
  return generated_file if File.exist?(generated_file)
202
288
  end
203
289
 
204
- # Fallback: scan directory for any generated files with correct extension
290
+ # Fallback: scan directory for any generated files with
291
+ # the correct extension
205
292
  Dir.glob(File.join(temp_dir, "*.#{extension}")).first
206
293
  end
207
294
 
208
295
  def extract_plantuml_filename_from_content(content)
209
296
  # Extract the raw filename from @start... line (don't sanitize)
210
- match = content.match(/^@start\w+\s+(.+)$/mi)
297
+ match = content.match(/^@start\w+\s+(.{1,999})$/mi)
211
298
  return nil unless match
212
299
 
213
300
  filename = match[1].strip.split("\n").first&.strip
@@ -217,7 +304,7 @@ module Metanorma
217
304
  filename.gsub(/^["']|["']$/, "")
218
305
  end
219
306
 
220
- def build_command(input_file, format, output_dir, options)
307
+ def build_command(input_file, format, output_dir, _options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
221
308
  cmd = [
222
309
  configuration.java_path,
223
310
  *jvm_options,
@@ -225,17 +312,9 @@ module Metanorma
225
312
  ]
226
313
 
227
314
  # 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"
315
+ format_str = format.to_s.downcase
316
+ if SUPPORTED_FORMATS.include?(format_str)
317
+ cmd << "-t#{format_str}"
239
318
  end
240
319
 
241
320
  # Add output directory option
@@ -1,4 +1,3 @@
1
-
2
1
  module Metanorma
3
2
  module Plugin
4
3
  module Plantuml
@@ -12,4 +11,6 @@ require "metanorma/plugin/plantuml/config"
12
11
  require "metanorma/plugin/plantuml/wrapper"
13
12
  require "metanorma/plugin/plantuml/utils"
14
13
  require "metanorma/plugin/plantuml/backend"
14
+ require "metanorma/plugin/plantuml/block_processor_base"
15
15
  require "metanorma/plugin/plantuml/block_processor"
16
+ require "metanorma/plugin/plantuml/image_block_macro_processor"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metanorma-plugin-plantuml
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-08-26 00:00:00.000000000 Z
11
+ date: 2025-09-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: asciidoctor
@@ -58,8 +58,10 @@ files:
58
58
  - lib/metanorma-plugin-plantuml.rb
59
59
  - lib/metanorma/plugin/plantuml/backend.rb
60
60
  - lib/metanorma/plugin/plantuml/block_processor.rb
61
+ - lib/metanorma/plugin/plantuml/block_processor_base.rb
61
62
  - lib/metanorma/plugin/plantuml/config.rb
62
63
  - lib/metanorma/plugin/plantuml/errors.rb
64
+ - lib/metanorma/plugin/plantuml/image_block_macro_processor.rb
63
65
  - lib/metanorma/plugin/plantuml/utils.rb
64
66
  - lib/metanorma/plugin/plantuml/version.rb
65
67
  - lib/metanorma/plugin/plantuml/wrapper.rb