asciidoctor-diagram 1.5.19 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
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