caracal 1.4.1 → 1.6.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +28 -0
  3. data/.github/workflows/push_gem.yml +29 -0
  4. data/CHANGELOG.md +50 -1
  5. data/Gemfile +3 -0
  6. data/LICENSE.txt +1 -1
  7. data/README.md +138 -15
  8. data/caracal.gemspec +15 -7
  9. data/lib/caracal/core/footer.rb +50 -0
  10. data/lib/caracal/core/header.rb +49 -0
  11. data/lib/caracal/core/iframes.rb +10 -5
  12. data/lib/caracal/core/models/border_model.rb +1 -1
  13. data/lib/caracal/core/models/field_model.rb +131 -0
  14. data/lib/caracal/core/models/footer_model.rb +43 -0
  15. data/lib/caracal/core/models/header_model.rb +43 -0
  16. data/lib/caracal/core/models/iframe_model.rb +45 -11
  17. data/lib/caracal/core/models/image_model.rb +1 -1
  18. data/lib/caracal/core/models/list_item_model.rb +57 -3
  19. data/lib/caracal/core/models/list_model.rb +1 -7
  20. data/lib/caracal/core/models/list_style_model.rb +1 -1
  21. data/lib/caracal/core/models/page_number_model.rb +17 -2
  22. data/lib/caracal/core/models/paragraph_model.rb +18 -2
  23. data/lib/caracal/core/models/relationship_model.rb +29 -5
  24. data/lib/caracal/core/models/table_model.rb +44 -7
  25. data/lib/caracal/core/page_numbers.rb +5 -1
  26. data/lib/caracal/document.rb +123 -31
  27. data/lib/caracal/renderers/content_types_renderer.rb +7 -1
  28. data/lib/caracal/renderers/custom_renderer.rb +3 -3
  29. data/lib/caracal/renderers/document_renderer.rb +69 -21
  30. data/lib/caracal/renderers/footer_renderer.rb +57 -50
  31. data/lib/caracal/renderers/header_footer_renderer.rb +90 -0
  32. data/lib/caracal/renderers/header_renderer.rb +28 -0
  33. data/lib/caracal/renderers/relationships_renderer.rb +22 -4
  34. data/lib/caracal/renderers/xml_renderer.rb +8 -0
  35. data/lib/caracal/utilities.rb +16 -1
  36. data/lib/caracal/version.rb +1 -1
  37. data/lib/caracal.rb +17 -0
  38. data/spec/lib/caracal/core/footer_spec.rb +37 -0
  39. data/spec/lib/caracal/core/header_spec.rb +48 -0
  40. data/spec/lib/caracal/core/images_spec.rb +7 -0
  41. data/spec/lib/caracal/core/models/footer_model_spec.rb +41 -0
  42. data/spec/lib/caracal/core/models/header_model_spec.rb +41 -0
  43. data/spec/lib/caracal/core/models/image_model_spec.rb +1 -1
  44. data/spec/lib/caracal/core/models/list_item_model_spec.rb +18 -0
  45. data/spec/lib/caracal/core/models/list_style_model_spec.rb +1 -1
  46. data/spec/lib/caracal/core/models/option_keys_spec.rb +47 -0
  47. data/spec/lib/caracal/core/models/page_number_model_spec.rb +16 -1
  48. data/spec/lib/caracal/core/models/paragraph_model_spec.rb +29 -1
  49. data/spec/lib/caracal/core/models/table_model_spec.rb +27 -2
  50. data/spec/lib/caracal/core/relationships_spec.rb +1 -1
  51. data/spec/lib/caracal/core/tables_spec.rb +8 -1
  52. data/spec/lib/caracal/document_output_spec.rb +715 -0
  53. metadata +57 -26
  54. data/.travis.yml +0 -20
@@ -0,0 +1,131 @@
1
+ require 'caracal/core/models/base_model'
2
+
3
+
4
+ module Caracal
5
+ module Core
6
+ module Models
7
+
8
+ # This class encapsulates the logic needed to store and render a
9
+ # field, such as the current page number, inside a paragraph.
10
+ #
11
+ class FieldModel < BaseModel
12
+
13
+ #--------------------------------------------------
14
+ # Configuration
15
+ #--------------------------------------------------
16
+
17
+ # constants
18
+ const_set(:TYPE_MAP, { page: 'PAGE', numpages: 'NUMPAGES' })
19
+
20
+ # accessors
21
+ attr_reader :field_type
22
+ attr_reader :field_style
23
+ attr_reader :field_font
24
+ attr_reader :field_color
25
+ attr_reader :field_size
26
+ attr_reader :field_bold
27
+ attr_reader :field_italic
28
+ attr_reader :field_underline
29
+ attr_reader :field_bgcolor
30
+ attr_reader :field_highlight_color
31
+ attr_reader :field_vertical_align
32
+
33
+ #-------------------------------------------------------------
34
+ # Public Class Methods
35
+ #-------------------------------------------------------------
36
+
37
+ def self.formatted_type(type)
38
+ TYPE_MAP.fetch(type.to_s.to_sym)
39
+ end
40
+
41
+
42
+ #-------------------------------------------------------------
43
+ # Public Instance Methods
44
+ #-------------------------------------------------------------
45
+
46
+ #========== GETTERS ===============================
47
+
48
+ def formatted_type
49
+ self.class.formatted_type(field_type)
50
+ end
51
+
52
+ # .run_attributes
53
+ def run_attributes
54
+ {
55
+ style: field_style,
56
+ font: field_font,
57
+ color: field_color,
58
+ size: field_size,
59
+ bold: field_bold,
60
+ italic: field_italic,
61
+ underline: field_underline,
62
+ bgcolor: field_bgcolor,
63
+ highlight_color: field_highlight_color,
64
+ vertical_align: field_vertical_align
65
+ }
66
+ end
67
+
68
+
69
+ #========== SETTERS ===============================
70
+
71
+ # booleans
72
+ [:bold, :italic, :underline].each do |m|
73
+ define_method "#{ m }" do |value|
74
+ instance_variable_set("@field_#{ m }", !!value)
75
+ end
76
+ end
77
+
78
+ # integers
79
+ [:size].each do |m|
80
+ define_method "#{ m }" do |value|
81
+ instance_variable_set("@field_#{ m }", value.to_i)
82
+ end
83
+ end
84
+
85
+ # strings
86
+ [:bgcolor, :color, :font, :highlight_color, :style, :type].each do |m|
87
+ define_method "#{ m }" do |value|
88
+ instance_variable_set("@field_#{ m }", value.to_s)
89
+ end
90
+ end
91
+
92
+ # symbols
93
+ [:vertical_align].each do |m|
94
+ define_method "#{ m }" do |value|
95
+ instance_variable_set("@field_#{ m }", value.to_s.to_sym)
96
+ end
97
+ end
98
+
99
+
100
+ #========== VALIDATION ============================
101
+
102
+ def valid?
103
+ TYPE_MAP.key?(field_type.to_s.to_sym)
104
+ end
105
+
106
+
107
+ #--------------------------------------------------
108
+ # Private Methods
109
+ #--------------------------------------------------
110
+ private
111
+
112
+ def option_keys
113
+ [:type, :style, :font, :color, :size, :bold, :italic, :underline, :bgcolor, :highlight_color, :vertical_align]
114
+ end
115
+
116
+ def method_missing(method, *args, &block)
117
+ # I'm on the fence with respect to this implementation. We're ignoring
118
+ # :method_missing errors to allow syntax flexibility for paragraph-type
119
+ # models. The issue is the syntax format of those models--the way we pass
120
+ # the content value as a special argument--coupled with the model's
121
+ # ability to accept nested instructions.
122
+ #
123
+ # By ignoring method missing errors here, we can pass the entire paragraph
124
+ # block in the initial, built-in call to :text.
125
+ end
126
+
127
+ end
128
+
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,43 @@
1
+ require 'caracal/core/models/base_model'
2
+
3
+
4
+ module Caracal
5
+ module Core
6
+ module Models
7
+
8
+ # This class holds the contents of the footer that appears on
9
+ # every page of the document.
10
+ #
11
+ class FooterModel < BaseModel
12
+
13
+ #-------------------------------------------------------------
14
+ # Public Methods
15
+ #-------------------------------------------------------------
16
+
17
+ #=============== DATA ACCESSORS =======================
18
+
19
+ def contents
20
+ @contents ||= []
21
+ end
22
+
23
+ # Images and links in the footer are registered with the document,
24
+ # which numbers them and writes the media, but Word resolves them
25
+ # against the footer part's own relationships. The renderer collects
26
+ # them here so they can be written to that part.
27
+ #
28
+ def relationships
29
+ @relationships ||= []
30
+ end
31
+
32
+
33
+ #=============== VALIDATION ===========================
34
+
35
+ def valid?
36
+ contents.size > 0
37
+ end
38
+
39
+ end
40
+
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,43 @@
1
+ require 'caracal/core/models/base_model'
2
+
3
+
4
+ module Caracal
5
+ module Core
6
+ module Models
7
+
8
+ # This class holds the contents of the header that appears on
9
+ # every page of the document.
10
+ #
11
+ class HeaderModel < BaseModel
12
+
13
+ #-------------------------------------------------------------
14
+ # Public Methods
15
+ #-------------------------------------------------------------
16
+
17
+ #=============== DATA ACCESSORS =======================
18
+
19
+ def contents
20
+ @contents ||= []
21
+ end
22
+
23
+ # Images and links in the header are registered with the document,
24
+ # which numbers them and writes the media, but Word resolves them
25
+ # against the header part's own relationships. The renderer collects
26
+ # them here so they can be written to that part.
27
+ #
28
+ def relationships
29
+ @relationships ||= []
30
+ end
31
+
32
+
33
+ #=============== VALIDATION ===========================
34
+
35
+ def valid?
36
+ contents.size > 0
37
+ end
38
+
39
+ end
40
+
41
+ end
42
+ end
43
+ end
@@ -1,10 +1,12 @@
1
+ require 'tempfile'
1
2
  require 'caracal/core/models/base_model'
3
+ require 'caracal/errors'
2
4
 
3
5
  module Caracal
4
6
  module Core
5
7
  module Models
6
8
 
7
- # This class handles block options passed to the img method.
9
+ # This class handles block options passed to the iframe method.
8
10
  #
9
11
  class IFrameModel < BaseModel
10
12
 
@@ -12,6 +14,9 @@ module Caracal
12
14
  # Configuration
13
15
  #--------------------------------------------------
14
16
 
17
+ # constants
18
+ const_set(:DEFAULT_MAX_ENTRY_SIZE, 50 * 1024 * 1024) # 50MB
19
+
15
20
  # accessors
16
21
  attr_reader :iframe_url
17
22
  attr_reader :iframe_data
@@ -25,6 +30,37 @@ module Caracal
25
30
  end
26
31
 
27
32
 
33
+ #--------------------------------------------------
34
+ # Class Methods
35
+ #--------------------------------------------------
36
+
37
+ # The largest single entry Caracal will read out of an embedded
38
+ # document. Zip entries expand to many times their stored size, so
39
+ # without a limit a small file can exhaust memory. Set to nil to
40
+ # disable the check.
41
+ #
42
+ def self.max_entry_size
43
+ defined?(@max_entry_size) ? @max_entry_size : DEFAULT_MAX_ENTRY_SIZE
44
+ end
45
+
46
+ def self.max_entry_size=(value)
47
+ @max_entry_size = value
48
+ end
49
+
50
+ # This method reads a single entry from an embedded document,
51
+ # refusing anything larger than :max_entry_size.
52
+ #
53
+ def self.read_entry(entry)
54
+ limit = max_entry_size
55
+
56
+ if limit && entry.size > limit
57
+ raise Caracal::Errors::InvalidModelError, "iframe entry #{ entry.name } is #{ entry.size } bytes, over the #{ limit } byte limit."
58
+ end
59
+
60
+ entry.get_input_stream.read
61
+ end
62
+
63
+
28
64
  #--------------------------------------------------
29
65
  # Public Methods
30
66
  #--------------------------------------------------
@@ -35,12 +71,12 @@ module Caracal
35
71
  ::Zip::File.open(file) do |zip|
36
72
  # locate relationships xml
37
73
  entry = zip.glob('word/_rels/document.xml.rels').first
38
- content = entry.get_input_stream.read
74
+ content = self.class.read_entry(entry)
39
75
  rel_xml = Nokogiri::XML(content)
40
76
 
41
77
  # locate document xml
42
78
  entry = zip.glob('word/document.xml').first
43
- content = entry.get_input_stream.read
79
+ content = self.class.read_entry(entry)
44
80
  doc_xml = Nokogiri::XML(content)
45
81
 
46
82
  # master nodesets
@@ -74,7 +110,7 @@ module Caracal
74
110
  p_node = node.children[0].children[0]
75
111
  p_id = p_node.attributes['id'].to_s.to_i
76
112
  p_name = p_node.attributes['name'].to_s
77
- p_data = zip.glob(r_media).first.get_input_stream.read
113
+ p_data = self.class.read_entry(zip.glob(r_media).first)
78
114
 
79
115
  # register relationship
80
116
  array << { id: r_id, type: 'image', target: p_name, data: p_data }
@@ -88,13 +124,11 @@ module Caracal
88
124
 
89
125
  def file
90
126
  @file ||= begin
91
- if iframe_url.nil?
92
- file = Tempfile.new('iframe')
93
- file.write iframe_data
94
- file.rewind
95
- else
96
- file = open(iframe_url)
97
- end
127
+ content = iframe_url.to_s.empty? ? iframe_data : ::Caracal::Utilities.read_resource(iframe_url)
128
+ file = Tempfile.new(['iframe', '.docx'])
129
+ file.binmode
130
+ file.write content
131
+ file.close # flushes the write; rubyzip reopens by path
98
132
  file
99
133
  end
100
134
  end
@@ -115,7 +115,7 @@ module Caracal
115
115
  private
116
116
 
117
117
  def option_keys
118
- [:url, :width, :height, :align, :top, :bottom, :left, :right]
118
+ [:url, :data, :ppi, :width, :height, :align, :top, :bottom, :left, :right]
119
119
  end
120
120
 
121
121
  def pixels_to_emus(value, ppi)
@@ -17,7 +17,7 @@ module Caracal
17
17
  #-------------------------------------------------------------
18
18
 
19
19
  # accessors
20
- attr_accessor :nested_list
20
+ attr_writer :continuation
21
21
 
22
22
  # readers (create aliases for superclass methods to conform
23
23
  # to expected naming convention.)
@@ -54,6 +54,51 @@ module Caracal
54
54
  end
55
55
 
56
56
 
57
+ #=============== NESTED LISTS =========================
58
+
59
+ # This method returns the nested lists in the order they were
60
+ # added, each with the number of runs that came before it, so text
61
+ # after a nested list can be rendered after it.
62
+ #
63
+ def nested_lists
64
+ @nested_lists ||= []
65
+ end
66
+
67
+ def nested_list
68
+ (pair = nested_lists.last) && pair.last
69
+ end
70
+
71
+ def nested_list=(model)
72
+ nested_lists << [runs.size, model]
73
+ end
74
+
75
+ # This method returns whether this is the text that follows a
76
+ # nested list, which renders as an unnumbered paragraph indented
77
+ # with the item.
78
+ #
79
+ def continuation?
80
+ !!@continuation
81
+ end
82
+
83
+ # This method returns this item and everything nested in it in
84
+ # document order: the item's text up to its first nested list, the
85
+ # nested list's items, then any text that follows, and so on.
86
+ #
87
+ def recursive_items
88
+ return [self] if nested_lists.empty?
89
+
90
+ items = []
91
+ start = 0
92
+ nested_lists.each do |(index, list)|
93
+ items << segment(start...index, items.any?) if items.empty? || index > start
94
+ items.concat(list.recursive_items)
95
+ start = index
96
+ end
97
+ items << segment(start...runs.size, true) if runs.size > start
98
+ items
99
+ end
100
+
101
+
57
102
  #=============== SUB-METHODS ===========================
58
103
 
59
104
  # .ol
@@ -62,7 +107,7 @@ module Caracal
62
107
 
63
108
  model = Caracal::Core::Models::ListModel.new(options, &block)
64
109
  if model.valid?
65
- @nested_list = model
110
+ self.nested_list = model
66
111
  else
67
112
  raise Caracal::Errors::InvalidModelError, 'Ordered lists require at least one list item.'
68
113
  end
@@ -75,7 +120,7 @@ module Caracal
75
120
 
76
121
  model = Caracal::Core::Models::ListModel.new(options, &block)
77
122
  if model.valid?
78
- @nested_list = model
123
+ self.nested_list = model
79
124
  else
80
125
  raise Caracal::Errors::InvalidModelError, 'Unordered lists require at least one list item.'
81
126
  end
@@ -97,6 +142,15 @@ module Caracal
97
142
  #-------------------------------------------------------------
98
143
  private
99
144
 
145
+ # This method returns a copy of the item holding only the given runs.
146
+ def segment(range, continuation)
147
+ copy = dup
148
+ copy.instance_variable_set(:@runs, runs[range])
149
+ copy.instance_variable_set(:@nested_lists, [])
150
+ copy.continuation = continuation
151
+ copy
152
+ end
153
+
100
154
  def option_keys
101
155
  [:type, :level, :content, :style, :color, :size, :bold, :italic, :underline, :bgcolor]
102
156
  end
@@ -61,13 +61,7 @@ module Caracal
61
61
  # item within this list's tree.
62
62
  #
63
63
  def recursive_items
64
- items.map do |model|
65
- if model.nested_list.nil?
66
- model
67
- else
68
- [model, model.nested_list.recursive_items]
69
- end
70
- end.flatten
64
+ items.flat_map(&:recursive_items)
71
65
  end
72
66
 
73
67
 
@@ -108,7 +108,7 @@ module Caracal
108
108
  private
109
109
 
110
110
  def option_keys
111
- [:type, :level, :format, :value, :align, :left, :indent, :start]
111
+ [:type, :level, :format, :value, :align, :left, :indent, :start, :restart]
112
112
  end
113
113
 
114
114
  end
@@ -24,6 +24,8 @@ module Caracal
24
24
  attr_reader :page_number_label_size
25
25
  attr_reader :page_number_number_size
26
26
  attr_reader :page_number_show
27
+ attr_reader :page_number_first
28
+ attr_reader :page_number_start
27
29
 
28
30
  # initialization
29
31
  def initialize(options={}, &block)
@@ -32,6 +34,8 @@ module Caracal
32
34
  @page_number_label_size = nil
33
35
  @page_number_number_size = nil
34
36
  @page_number_show = DEFAULT_PAGE_NUMBER_SHOW
37
+ @page_number_first = true
38
+ @page_number_start = nil
35
39
 
36
40
  super options, &block
37
41
  end
@@ -65,6 +69,15 @@ module Caracal
65
69
  @page_number_show = !!value
66
70
  end
67
71
 
72
+ # false leaves the page number off the first page
73
+ def first_page(value)
74
+ @page_number_first = !!value
75
+ end
76
+
77
+ def start(value)
78
+ @page_number_start = value.nil? ? nil : value.to_i
79
+ end
80
+
68
81
  def size(value)
69
82
  v = value.to_i
70
83
  @page_number_label_size = (v == 0) ? nil : v
@@ -75,7 +88,9 @@ module Caracal
75
88
  #=============== VALIDATION ===========================
76
89
 
77
90
  def valid?
78
- (!page_number_show || [:left, :center, :right].include?(page_number_align))
91
+ valid_align = (!page_number_show || [:left, :center, :right].include?(page_number_align))
92
+ valid_start = (page_number_start.nil? || page_number_start >= 0)
93
+ valid_align && valid_start
79
94
  end
80
95
 
81
96
 
@@ -85,7 +100,7 @@ module Caracal
85
100
  private
86
101
 
87
102
  def option_keys
88
- [:align, :label, :label_size, :number_size, :show]
103
+ [:align, :label, :label_size, :number_size, :show, :size, :start, :first_page]
89
104
  end
90
105
 
91
106
  end
@@ -2,6 +2,7 @@ require 'caracal/core/models/base_model'
2
2
  require 'caracal/core/models/bookmark_model'
3
3
  require 'caracal/core/models/link_model'
4
4
  require 'caracal/core/models/text_model'
5
+ require 'caracal/core/models/field_model'
5
6
  require 'caracal/errors'
6
7
 
7
8
 
@@ -27,6 +28,7 @@ module Caracal
27
28
  attr_reader :paragraph_italic
28
29
  attr_reader :paragraph_underline
29
30
  attr_reader :paragraph_bgcolor
31
+ attr_reader :paragraph_keep_next
30
32
 
31
33
  # initialization
32
34
  def initialize(options={}, &block)
@@ -63,7 +65,7 @@ module Caracal
63
65
  #========== SETTERS ===============================
64
66
 
65
67
  # booleans
66
- [:bold, :italic, :underline].each do |m|
68
+ [:bold, :italic, :keep_next, :underline].each do |m|
67
69
  define_method "#{ m }" do |value|
68
70
  instance_variable_set("@paragraph_#{ m }", !!value)
69
71
  end
@@ -126,6 +128,20 @@ module Caracal
126
128
  model
127
129
  end
128
130
 
131
+ # .field
132
+ def field(*args, &block)
133
+ options = Caracal::Utilities.extract_options!(args)
134
+ options = options.merge({ type: args[0] })
135
+
136
+ model = Caracal::Core::Models::FieldModel.new(options, &block)
137
+ if model.valid?
138
+ runs << model
139
+ else
140
+ raise Caracal::Errors::InvalidModelError, ':field method must receive :page or :numpages.'
141
+ end
142
+ model
143
+ end
144
+
129
145
  # .link
130
146
  def link(*args, &block)
131
147
  options = Caracal::Utilities.extract_options!(args)
@@ -176,7 +192,7 @@ module Caracal
176
192
  private
177
193
 
178
194
  def option_keys
179
- [:content, :style, :align, :color, :size, :bold, :italic, :underline, :bgcolor]
195
+ [:content, :style, :align, :color, :size, :bold, :italic, :underline, :bgcolor, :keep_next]
180
196
  end
181
197
 
182
198
  end
@@ -18,13 +18,24 @@ module Caracal
18
18
  TYPE_MAP = {
19
19
  font: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable',
20
20
  footer: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer',
21
+ header: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header',
21
22
  image: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
22
23
  link: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink',
23
24
  numbering: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering',
24
25
  setting: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings',
25
26
  style: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles'
26
27
  }
27
-
28
+
29
+ IMAGE_SIGNATURES = {
30
+ "\x89PNG\r\n\x1A\n".b => 'png',
31
+ "\xFF\xD8\xFF".b => 'jpeg',
32
+ 'GIF8'.b => 'gif',
33
+ 'BM'.b => 'bmp',
34
+ "II*\x00".b => 'tiff',
35
+ "MM\x00*".b => 'tiff'
36
+ }
37
+ IMAGE_EXTENSIONS = %w(png jpeg jpg gif bmp tif tiff svg)
38
+
28
39
  # accessors
29
40
  attr_reader :relationship_id
30
41
  attr_reader :relationship_key
@@ -46,14 +57,27 @@ module Caracal
46
57
 
47
58
  def formatted_target
48
59
  if relationship_type == :image
49
- ext = relationship_target.to_s.split('.').last
50
- ext = ext.split('?').first
51
- "media/image#{ relationship_id }.#{ ext }"
60
+ "media/image#{ relationship_id }.#{ image_extension }"
52
61
  else
53
62
  relationship_target
54
63
  end
55
64
  end
56
-
65
+
66
+ # Prefers the type detected from the image bytes, then the
67
+ # extension of the target's path (ignoring any query string),
68
+ # and falls back to png so the part name is always valid.
69
+ #
70
+ def image_extension
71
+ data = relationship_data.to_s.b
72
+ if (match = IMAGE_SIGNATURES.find { |sig, _| data.start_with?(sig) })
73
+ return match.last
74
+ end
75
+
76
+ path = relationship_target.to_s.split(/[?#]/).first.to_s
77
+ ext = File.extname(path).delete('.').downcase
78
+ IMAGE_EXTENSIONS.include?(ext) ? ext : 'png'
79
+ end
80
+
57
81
  def formatted_type
58
82
  TYPE_MAP.fetch(relationship_type)
59
83
  end