asciidoctor-diagram 1.5.19 → 2.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.
Files changed (69) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.adoc +10 -0
  3. data/README.adoc +22 -9
  4. data/examples/features.adoc +2 -2
  5. data/lib/asciidoctor-diagram.rb +1 -0
  6. data/lib/asciidoctor-diagram/a2s/converter.rb +55 -0
  7. data/lib/asciidoctor-diagram/a2s/extension.rb +6 -52
  8. data/lib/asciidoctor-diagram/blockdiag/converter.rb +37 -0
  9. data/lib/asciidoctor-diagram/blockdiag/extension.rb +9 -116
  10. data/lib/asciidoctor-diagram/bpmn.rb +7 -0
  11. data/lib/asciidoctor-diagram/bpmn/converter.rb +62 -0
  12. data/lib/asciidoctor-diagram/bpmn/extension.rb +14 -0
  13. data/lib/asciidoctor-diagram/diagram_converter.rb +19 -0
  14. data/lib/asciidoctor-diagram/diagram_processor.rb +320 -0
  15. data/lib/asciidoctor-diagram/diagram_source.rb +275 -0
  16. data/lib/asciidoctor-diagram/ditaa/converter.rb +86 -0
  17. data/lib/asciidoctor-diagram/ditaa/extension.rb +6 -71
  18. data/lib/asciidoctor-diagram/erd/converter.rb +31 -0
  19. data/lib/asciidoctor-diagram/erd/extension.rb +6 -35
  20. data/lib/asciidoctor-diagram/gnuplot/converter.rb +63 -0
  21. data/lib/asciidoctor-diagram/gnuplot/extension.rb +6 -62
  22. data/lib/asciidoctor-diagram/graphviz/converter.rb +32 -0
  23. data/lib/asciidoctor-diagram/graphviz/extension.rb +6 -35
  24. data/lib/asciidoctor-diagram/http/server.rb +127 -0
  25. data/lib/asciidoctor-diagram/lilypond/converter.rb +54 -0
  26. data/lib/asciidoctor-diagram/lilypond/extension.rb +6 -53
  27. data/lib/asciidoctor-diagram/meme/converter.rb +122 -0
  28. data/lib/asciidoctor-diagram/meme/extension.rb +5 -107
  29. data/lib/asciidoctor-diagram/mermaid/converter.rb +178 -0
  30. data/lib/asciidoctor-diagram/mermaid/extension.rb +6 -159
  31. data/lib/asciidoctor-diagram/msc/converter.rb +35 -0
  32. data/lib/asciidoctor-diagram/msc/extension.rb +6 -36
  33. data/lib/asciidoctor-diagram/nomnoml/converter.rb +25 -0
  34. data/lib/asciidoctor-diagram/nomnoml/extension.rb +6 -28
  35. data/lib/asciidoctor-diagram/plantuml/converter.rb +115 -0
  36. data/lib/asciidoctor-diagram/plantuml/extension.rb +10 -119
  37. data/lib/asciidoctor-diagram/shaape/converter.rb +25 -0
  38. data/lib/asciidoctor-diagram/shaape/extension.rb +6 -28
  39. data/lib/asciidoctor-diagram/smcat/converter.rb +44 -0
  40. data/lib/asciidoctor-diagram/smcat/extension.rb +6 -42
  41. data/lib/asciidoctor-diagram/svgbob/converter.rb +25 -0
  42. data/lib/asciidoctor-diagram/svgbob/extension.rb +6 -28
  43. data/lib/asciidoctor-diagram/syntrax/converter.rb +55 -0
  44. data/lib/asciidoctor-diagram/syntrax/extension.rb +6 -51
  45. data/lib/asciidoctor-diagram/tikz/converter.rb +56 -0
  46. data/lib/asciidoctor-diagram/tikz/extension.rb +6 -60
  47. data/lib/asciidoctor-diagram/umlet/converter.rb +24 -0
  48. data/lib/asciidoctor-diagram/umlet/extension.rb +6 -28
  49. data/lib/asciidoctor-diagram/util/java.rb +1 -1
  50. data/lib/asciidoctor-diagram/util/java_socket.rb +7 -9
  51. data/lib/asciidoctor-diagram/util/which.rb +0 -29
  52. data/lib/asciidoctor-diagram/vega/converter.rb +47 -0
  53. data/lib/asciidoctor-diagram/vega/extension.rb +6 -44
  54. data/lib/asciidoctor-diagram/version.rb +1 -1
  55. data/lib/asciidoctor-diagram/wavedrom/converter.rb +50 -0
  56. data/lib/asciidoctor-diagram/wavedrom/extension.rb +6 -54
  57. data/lib/ditaa-1.3.14.jar +0 -0
  58. data/lib/plantuml-1.3.14.jar +0 -0
  59. data/lib/plantuml.jar +0 -0
  60. data/lib/server-1.3.14.jar +0 -0
  61. data/spec/bpmn-example.xml +44 -0
  62. data/spec/bpmn_spec.rb +96 -0
  63. data/spec/mermaid_spec.rb +33 -1
  64. data/spec/plantuml_spec.rb +89 -0
  65. metadata +37 -8
  66. data/lib/asciidoctor-diagram/extensions.rb +0 -568
  67. data/lib/ditaa-1.3.13.jar +0 -0
  68. data/lib/plantuml-1.3.13.jar +0 -0
  69. data/lib/server-1.3.13.jar +0 -0
@@ -0,0 +1,14 @@
1
+ require_relative 'converter'
2
+ require_relative '../diagram_processor'
3
+
4
+ module Asciidoctor
5
+ module Diagram
6
+ class BpmnBlockProcessor < DiagramBlockProcessor
7
+ use_converter BpmnConverter
8
+ end
9
+
10
+ class BpmnBlockMacroProcessor < DiagramBlockMacroProcessor
11
+ use_converter BpmnConverter
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ module Asciidoctor
2
+ module Diagram
3
+ # This module describes the duck-typed interface that diagram converters must implement. Implementations
4
+ # may include this module but it is not required.
5
+ module DiagramConverter
6
+ def supported_formats
7
+ raise NotImplementedError.new
8
+ end
9
+
10
+ def collect_options(source, name)
11
+ {}
12
+ end
13
+
14
+ def convert(source, format, options)
15
+ raise NotImplementedError.new
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,320 @@
1
+ require 'asciidoctor' unless defined? ::Asciidoctor::VERSION
2
+ require 'asciidoctor/extensions'
3
+ require 'digest'
4
+ require 'json'
5
+ require 'fileutils'
6
+ require_relative 'diagram_source.rb'
7
+ require_relative 'version'
8
+ require_relative 'util/java'
9
+ require_relative 'util/gif'
10
+ require_relative 'util/pdf'
11
+ require_relative 'util/png'
12
+ require_relative 'util/svg'
13
+
14
+ module Asciidoctor
15
+ module Diagram
16
+ # Mixin that provides the basic machinery for image generation.
17
+ # When this module is included it will include the FormatRegistry into the singleton class of the target class.
18
+ module DiagramProcessor
19
+ include Asciidoctor::Logging
20
+
21
+ module ClassMethods
22
+ def use_converter(converter_type)
23
+ config[:converter] = converter_type
24
+ end
25
+ end
26
+
27
+ def self.included(host_class)
28
+ host_class.use_dsl
29
+ host_class.extend(ClassMethods)
30
+ end
31
+
32
+ IMAGE_PARAMS = {
33
+ :svg => {
34
+ :encoding => Encoding::UTF_8,
35
+ :decoder => SVG
36
+ },
37
+ :gif => {
38
+ :encoding => Encoding::ASCII_8BIT,
39
+ :decoder => GIF
40
+ },
41
+ :png => {
42
+ :encoding => Encoding::ASCII_8BIT,
43
+ :decoder => PNG
44
+ },
45
+ :pdf => {
46
+ :encoding => Encoding::ASCII_8BIT,
47
+ :decoder => PDF
48
+ }
49
+ }
50
+
51
+ # Processes the diagram block or block macro by converting it into an image or literal block.
52
+ #
53
+ # @param parent [Asciidoctor::AbstractBlock] the parent asciidoc block of the block or block macro being processed
54
+ # @param reader_or_target [Asciidoctor::Reader, String] a reader that provides the contents of a block or the
55
+ # target value of a block macro
56
+ # @param attributes [Hash] the attributes of the block or block macro
57
+ # @return [Asciidoctor::AbstractBlock] a new block that replaces the original block or block macro
58
+ def process(parent, reader_or_target, attributes)
59
+ location = parent.document.reader.cursor_at_mark
60
+
61
+ normalised_attributes = attributes.inject({}) { |h, (k, v)| h[normalise_attribute_name(k)] = v; h }
62
+ source = create_source(parent, reader_or_target, normalised_attributes)
63
+
64
+ converter = config[:converter].new
65
+
66
+ supported_formats = converter.supported_formats
67
+
68
+ begin
69
+ format = source.attributes.delete('format') || source.attr('format', supported_formats[0], name)
70
+ format = format.to_sym if format.respond_to?(:to_sym)
71
+
72
+ raise "Format undefined" unless format
73
+
74
+ raise "#{self.class.name} does not support output format #{format}" unless supported_formats.include?(format)
75
+
76
+
77
+ title = source.attributes.delete 'title'
78
+ caption = source.attributes.delete 'caption'
79
+
80
+ case format
81
+ when :txt
82
+ block = create_literal_block(parent, source, converter)
83
+ else
84
+ block = create_image_block(parent, source, format, converter)
85
+ end
86
+
87
+ block.title = title
88
+ block.assign_caption(caption, 'figure')
89
+ block
90
+ rescue => e
91
+ case source.attr('on-error', 'log', 'diagram')
92
+ when 'abort'
93
+ raise e
94
+ else
95
+ text = "Failed to generate image: #{e.message}"
96
+ warn_msg = text.dup
97
+ if $VERBOSE
98
+ warn_msg << "\n" << e.backtrace.join("\n")
99
+ end
100
+
101
+ logger.error message_with_context warn_msg, source_location: location
102
+
103
+ text << "\n"
104
+ text << source.code
105
+ Asciidoctor::Block.new parent, :listing, :source => text, :attributes => attributes
106
+ end
107
+
108
+ end
109
+ end
110
+
111
+ protected
112
+
113
+ # Creates a DiagramSource object for the block or block macro being processed. Classes using this
114
+ # mixin must implement this method.
115
+ #
116
+ # @param parent_block [Asciidoctor::AbstractBlock] the parent asciidoc block of the block or block macro being processed
117
+ # @param reader_or_target [Asciidoctor::Reader, String] a reader that provides the contents of a block or the
118
+ # target value of a block macro
119
+ # @param attributes [Hash] the attributes of the block or block macro
120
+ #
121
+ # @return [DiagramSource] an object that implements the interface described by DiagramSource
122
+ #
123
+ # @abstract
124
+ def create_source(parent_block, reader_or_target, attributes)
125
+ raise NotImplementedError.new
126
+ end
127
+
128
+ private
129
+
130
+ def normalise_attribute_name(k)
131
+ case k
132
+ when String
133
+ k.downcase
134
+ when Symbol
135
+ k.to_s.downcase.to_sym
136
+ else
137
+ k
138
+ end
139
+ end
140
+
141
+ DIGIT_CHAR_RANGE = ('0'.ord)..('9'.ord)
142
+
143
+ def create_image_block(parent, source, format, converter)
144
+ image_name = "#{source.image_name}.#{format}"
145
+ image_dir = image_output_dir(parent)
146
+ cache_dir = cache_dir(parent)
147
+ image_file = parent.normalize_system_path image_name, image_dir
148
+ metadata_file = parent.normalize_system_path "#{image_name}.cache", cache_dir
149
+
150
+ if File.exist? metadata_file
151
+ metadata = File.open(metadata_file, 'r') {|f| JSON.load f}
152
+ else
153
+ metadata = {}
154
+ end
155
+
156
+ image_attributes = source.attributes
157
+
158
+ if !File.exist?(image_file) || source.should_process?(image_file, metadata)
159
+ params = IMAGE_PARAMS[format]
160
+
161
+ options = converter.collect_options(source, name)
162
+ result = converter.convert(source, format, options)
163
+
164
+ result.force_encoding(params[:encoding])
165
+
166
+ metadata = source.create_image_metadata
167
+ metadata['width'], metadata['height'] = params[:decoder].get_image_size(result)
168
+
169
+ FileUtils.mkdir_p(File.dirname(image_file)) unless Dir.exist?(File.dirname(image_file))
170
+ File.open(image_file, 'wb') {|f| f.write result}
171
+
172
+ FileUtils.mkdir_p(File.dirname(metadata_file)) unless Dir.exist?(File.dirname(metadata_file))
173
+ File.open(metadata_file, 'w') {|f| JSON.dump(metadata, f)}
174
+ end
175
+
176
+ image_attributes['target'] = source.attr('data-uri', nil, true) ? image_file : image_name
177
+ if format == :svg
178
+ svg_type = source.attr('svg-type', nil, 'diagram')
179
+ image_attributes['opts'] = svg_type if svg_type && svg_type != 'static'
180
+ end
181
+
182
+ scale = image_attributes['scale']
183
+ if scalematch = /(\d+(?:\.\d+))/.match(scale)
184
+ scale_factor = scalematch[1].to_f
185
+ else
186
+ scale_factor = 1.0
187
+ end
188
+
189
+ if /html/i =~ parent.document.attributes['backend']
190
+ image_attributes.delete('scale')
191
+ if metadata['width'] && !image_attributes['width']
192
+ image_attributes['width'] = (metadata['width'] * scale_factor).to_i
193
+ end
194
+ if metadata['height'] && !image_attributes['height']
195
+ image_attributes['height'] = (metadata['height'] * scale_factor).to_i
196
+ end
197
+ end
198
+
199
+ image_attributes['alt'] ||= if title_text = image_attributes['title']
200
+ title_text
201
+ elsif target = image_attributes['target']
202
+ (File.basename(target, File.extname(target)) || '').tr '_-', ' '
203
+ else
204
+ 'Diagram'
205
+ end
206
+
207
+ image_attributes['alt'] = parent.sub_specialchars image_attributes['alt']
208
+
209
+ parent.document.register(:images, image_name)
210
+ if (scaledwidth = image_attributes['scaledwidth'])
211
+ # append % to scaledwidth if ends in number (no units present)
212
+ if DIGIT_CHAR_RANGE.include?((scaledwidth[-1] || 0).ord)
213
+ image_attributes['scaledwidth'] = %(#{scaledwidth}%)
214
+ end
215
+ end
216
+
217
+ Asciidoctor::Block.new parent, :image, :content_model => :empty, :attributes => image_attributes
218
+ end
219
+
220
+ def scale(size, factor)
221
+ if match = /(\d+)(.*)/.match(size)
222
+ value = match[1].to_i
223
+ unit = match[2]
224
+ (value * factor).to_i.to_s + unit
225
+ else
226
+ size
227
+ end
228
+ end
229
+
230
+ def image_output_dir(parent)
231
+ document = parent.document
232
+
233
+ images_dir = parent.attr('imagesoutdir', nil, true)
234
+
235
+ if images_dir
236
+ base_dir = nil
237
+ else
238
+ base_dir = parent.attr('outdir', nil, true) || doc_option(document, :to_dir)
239
+ images_dir = parent.attr('imagesdir', nil, true)
240
+ end
241
+
242
+ parent.normalize_system_path(images_dir, base_dir)
243
+ end
244
+
245
+ def cache_dir(parent)
246
+ document = parent.document
247
+ cache_dir = '.asciidoctor/diagram'
248
+ base_dir = parent.attr('outdir', nil, true) || doc_option(document, :to_dir)
249
+ parent.normalize_system_path(cache_dir, base_dir)
250
+ end
251
+
252
+ def create_literal_block(parent, source, converter)
253
+ literal_attributes = source.attributes
254
+ literal_attributes.delete('target')
255
+
256
+ options = converter.collect_options(source, name)
257
+ result = converter.convert(source, :txt, options)
258
+
259
+ result.force_encoding(Encoding::UTF_8)
260
+ Asciidoctor::Block.new parent, :literal, :source => result, :attributes => literal_attributes
261
+ end
262
+
263
+ def doc_option(document, key)
264
+ if document.respond_to?(:options)
265
+ value = document.options[key]
266
+ else
267
+ value = nil
268
+ end
269
+
270
+ if document.nested? && value.nil?
271
+ doc_option(document.parent_document, key)
272
+ else
273
+ value
274
+ end
275
+ end
276
+ end
277
+
278
+ # Base class for diagram block processors.
279
+ class DiagramBlockProcessor < Asciidoctor::Extensions::BlockProcessor
280
+ include DiagramProcessor
281
+
282
+ def self.inherited(subclass)
283
+ subclass.name_positional_attributes ['target', 'format']
284
+ subclass.contexts [:listing, :literal, :open]
285
+ subclass.content_model :simple
286
+ end
287
+
288
+ # Creates a ReaderSource from the given reader.
289
+ #
290
+ # @return [ReaderSource] a ReaderSource
291
+ def create_source(parent_block, reader, attributes)
292
+ ReaderSource.new(self, parent_block, reader, attributes)
293
+ end
294
+ end
295
+
296
+ # Base class for diagram block macro processors.
297
+ class DiagramBlockMacroProcessor < Asciidoctor::Extensions::BlockMacroProcessor
298
+ include DiagramProcessor
299
+
300
+ def self.inherited(subclass)
301
+ subclass.name_positional_attributes ['target', 'format']
302
+ end
303
+
304
+ def apply_target_subs(parent, target)
305
+ if target
306
+ parent.normalize_system_path(parent.sub_attributes(target, :attribute_missing => 'warn'))
307
+ else
308
+ nil
309
+ end
310
+ end
311
+
312
+ # Creates a FileSource using target as the file name.
313
+ #
314
+ # @return [FileSource] a FileSource
315
+ def create_source(parent, target, attributes)
316
+ FileSource.new(self, parent, apply_target_subs(parent, target), attributes)
317
+ end
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,275 @@
1
+ require_relative 'util/which'
2
+
3
+ module Asciidoctor
4
+ module Diagram
5
+ # This module describes the duck-typed interface that diagram sources must implement. Implementations
6
+ # may include this module but it is not required.
7
+ module DiagramSource
8
+ def image_name
9
+ raise NotImplementedError.new
10
+ end
11
+
12
+ # @return [String] the String representation of the source code for the diagram
13
+ # @abstract
14
+ def code
15
+ raise NotImplementedError.new
16
+ end
17
+
18
+ # Get the value for the specified attribute. First look in the attributes on
19
+ # this document and return the value of the attribute if found. Otherwise, if
20
+ # this document is a child of the Document document, look in the attributes of the
21
+ # Document document and return the value of the attribute if found. Otherwise,
22
+ # return the default value, which defaults to nil.
23
+ #
24
+ # @param name [String, Symbol] the name of the attribute to lookup
25
+ # @param default_value [Object] the value to return if the attribute is not found
26
+ # @inherit [Boolean, String] indicates whether to check for the attribute on the AsciiDoctor::Document if not found on this document.
27
+ # When a non-nil String is given the an attribute name "#{inherit}-#{name}" is looked for on the document.
28
+ #
29
+ # @return the value of the attribute or the default value if the attribute is not found in the attributes of this node or the document node
30
+ # @abstract
31
+ def attr(name, default_value = nil, inherit = nil)
32
+ raise NotImplementedError.new
33
+ end
34
+
35
+ # @return [String] the base directory against which relative paths in this diagram should be resolved
36
+ # @abstract
37
+ def base_dir
38
+ attr('docdir', nil, true) || Dir.pwd
39
+ end
40
+
41
+ # Alias for code
42
+ def to_s
43
+ code
44
+ end
45
+
46
+ # Determines if the diagram should be regenerated or not. The default implementation of this method simply
47
+ # returns true.
48
+ #
49
+ # @param image_file [String] the path to the previously generated version of the image
50
+ # @param image_metadata [Hash] the image metadata Hash that was stored during the previous diagram generation pass
51
+ # @return [Boolean] true if the diagram should be regenerated; false otherwise
52
+ def should_process?(image_file, image_metadata)
53
+ true
54
+ end
55
+
56
+ # Creates an image metadata Hash that will be stored to disk alongside the generated image file. The contents
57
+ # of this Hash are reread during subsequent document processing and then passed to the should_process? method
58
+ # where it can be used to determine if the diagram should be regenerated or not.
59
+ # The default implementation returns an empty Hash.
60
+ # @return [Hash] a Hash containing metadata
61
+ def create_image_metadata
62
+ {}
63
+ end
64
+
65
+ def config
66
+ raise NotImplementedError.new
67
+ end
68
+
69
+ def find_command(cmd, options = {})
70
+ attr_names = options[:attrs] || options.fetch(:alt_attrs, []) + [cmd]
71
+ cmd_names = [cmd] + options.fetch(:alt_cmds, [])
72
+
73
+ cmd_var = 'cmd-' + attr_names[0]
74
+
75
+ if config.key? cmd_var
76
+ cmd_path = config[cmd_var]
77
+ else
78
+ cmd_path = attr_names.map { |attr_name| attr(attr_name, nil, true) }.find { |attr| !attr.nil? }
79
+
80
+ unless cmd_path && File.executable?(cmd_path)
81
+ cmd_paths = cmd_names.map do |c|
82
+ ::Asciidoctor::Diagram::Which.which(c, :path => options[:path])
83
+ end
84
+
85
+ cmd_path = cmd_paths.reject { |c| c.nil? }.first
86
+ end
87
+
88
+ config[cmd_var] = cmd_path
89
+
90
+ if cmd_path.nil? && options.fetch(:raise_on_error, true)
91
+ raise "Could not find the #{cmd_names.map { |c| "'#{c}'" }.join(', ')} executable in PATH; add it to the PATH or specify its location using the '#{attr_names[0]}' document attribute"
92
+ end
93
+ end
94
+
95
+ cmd_path
96
+ end
97
+
98
+ def resolve_path target, start = base_dir
99
+ raise NotImplementedError.new
100
+ end
101
+ end
102
+
103
+ # Base class for diagram source implementations that uses an md5 checksum of the source code of a diagram to
104
+ # determine if it has been updated or not.
105
+ class BasicSource
106
+ include DiagramSource
107
+
108
+ attr_reader :attributes
109
+
110
+ def initialize(block_processor, parent_block, attributes)
111
+ @block_processor = block_processor
112
+ @parent_block = parent_block
113
+ @attributes = attributes
114
+ end
115
+
116
+ def resolve_path target, start = base_dir
117
+ @parent_block.normalize_system_path(target, start)
118
+ end
119
+
120
+ def config
121
+ @block_processor.config
122
+ end
123
+
124
+ def image_name
125
+ attr('target', 'diag-' + checksum)
126
+ end
127
+
128
+ def attr(name, default_value = nil, inherit = nil)
129
+ name = name.to_s if ::Symbol === name
130
+
131
+ value = @attributes[name]
132
+
133
+ if value.nil? && inherit
134
+ case inherit
135
+ when String, Symbol
136
+ value = @parent_block.attr("#{inherit.to_s}-#{name}", default_value, true)
137
+ else
138
+ value = @parent_block.attr(name, default_value, inherit)
139
+ end
140
+ end
141
+
142
+ value || default_value
143
+ end
144
+
145
+ def should_process?(image_file, image_metadata)
146
+ image_metadata['checksum'] != checksum
147
+ end
148
+
149
+ def create_image_metadata
150
+ {'checksum' => checksum}
151
+ end
152
+
153
+ def checksum
154
+ @checksum ||= compute_checksum(code)
155
+ end
156
+
157
+ protected
158
+
159
+ def resolve_diagram_subs
160
+ if @attributes.key? 'subs'
161
+ @parent_block.resolve_block_subs @attributes['subs'], nil, 'diagram'
162
+ else
163
+ []
164
+ end
165
+ end
166
+
167
+ private
168
+
169
+ def compute_checksum(code)
170
+ md5 = Digest::MD5.new
171
+ md5 << code
172
+ @attributes.each do |k, v|
173
+ md5 << k.to_s if k
174
+ md5 << v.to_s if v
175
+ end
176
+ md5.hexdigest
177
+ end
178
+ end
179
+
180
+ # A diagram source that retrieves the code for the diagram from the contents of a block.
181
+ class ReaderSource < BasicSource
182
+ include DiagramSource
183
+
184
+ def initialize(block_processor, parent_block, reader, attributes)
185
+ super(block_processor, parent_block, attributes)
186
+ @reader = reader
187
+ end
188
+
189
+ def code
190
+ @code ||= @parent_block.apply_subs(@reader.lines, resolve_diagram_subs).join("\n")
191
+ end
192
+ end
193
+
194
+ # A diagram source that retrieves the code for a diagram from an external source file.
195
+ class FileSource < BasicSource
196
+ def initialize(block_processor, parent_block, file_name, attributes)
197
+ super(block_processor, parent_block, attributes)
198
+ @file_name = file_name
199
+ end
200
+
201
+ def base_dir
202
+ if @file_name
203
+ File.dirname(@file_name)
204
+ else
205
+ super
206
+ end
207
+ end
208
+
209
+ def image_name
210
+ if @attributes['target']
211
+ super
212
+ elsif @file_name
213
+ File.basename(@file_name, File.extname(@file_name))
214
+ else
215
+ checksum
216
+ end
217
+ end
218
+
219
+ def should_process?(image_file, image_metadata)
220
+ (@file_name && File.mtime(@file_name) > File.mtime(image_file)) || super
221
+ end
222
+
223
+ def code
224
+ @code ||= read_code
225
+ end
226
+
227
+ def read_code
228
+ if @file_name
229
+ lines = File.readlines(@file_name)
230
+ lines = prepare_source_array(lines)
231
+ @parent_block.apply_subs(lines, resolve_diagram_subs).join("\n")
232
+ else
233
+ ''
234
+ end
235
+ end
236
+
237
+ private
238
+
239
+ # Byte arrays for UTF-* Byte Order Marks
240
+ BOM_BYTES_UTF_8 = [0xef, 0xbb, 0xbf]
241
+ BOM_BYTES_UTF_16LE = [0xff, 0xfe]
242
+ BOM_BYTES_UTF_16BE = [0xfe, 0xff]
243
+
244
+ # Prepare the source data Array for parsing.
245
+ #
246
+ # Encodes the data to UTF-8, if necessary, and removes any trailing
247
+ # whitespace from every line.
248
+ #
249
+ # If a BOM is found at the beginning of the data, a best attempt is made to
250
+ # encode it to UTF-8 from the specified source encoding.
251
+ #
252
+ # data - the source data Array to prepare (no nil entries allowed)
253
+ #
254
+ # returns a String Array of prepared lines
255
+ def prepare_source_array data
256
+ return [] if data.empty?
257
+ if (leading_2_bytes = (leading_bytes = (first = data[0]).unpack 'C3').slice 0, 2) == BOM_BYTES_UTF_16LE
258
+ data[0] = first.byteslice 2, first.bytesize
259
+ # NOTE you can't split a UTF-16LE string using .lines when encoding is UTF-8; doing so will cause this line to fail
260
+ return data.map {|line| (line.encode ::Encoding::UTF_8, ::Encoding::UTF_16LE).rstrip}
261
+ elsif leading_2_bytes == BOM_BYTES_UTF_16BE
262
+ data[0] = first.byteslice 2, first.bytesize
263
+ return data.map {|line| (line.encode ::Encoding::UTF_8, ::Encoding::UTF_16BE).rstrip}
264
+ elsif leading_bytes == BOM_BYTES_UTF_8
265
+ data[0] = first.byteslice 3, first.bytesize
266
+ end
267
+ if first.encoding == ::Encoding::UTF_8
268
+ data.map {|line| line.rstrip}
269
+ else
270
+ data.map {|line| (line.encode ::Encoding::UTF_8).rstrip}
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end