ascii_pngfy 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chunky_png'
4
+
5
+ module AsciiPngfy
6
+ # Reponsibilities
7
+ # > Provides acces to Result data points, which are
8
+ # - generated png as ChunkyPNG::Image instance
9
+ # - render width and height
10
+ # - settings snapshot that supports only setting getters
11
+ class Result
12
+ attr_reader(:png, :render_width, :render_height, :settings)
13
+
14
+ def initialize(png, render_width, render_height, settings_snapshot)
15
+ self.png = png
16
+ self.render_width = render_width
17
+ self.render_height = render_height
18
+ self.settings = settings_snapshot
19
+ end
20
+
21
+ private
22
+
23
+ attr_writer(:png, :render_width, :render_height, :settings)
24
+ end
25
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiPngfy
4
+ # Namespace that that contains Setting(s) related functionality
5
+ module Settings; end
6
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiPngfy
4
+ module Settings
5
+ # Reponsibilities
6
+ # - Keeps track of the color setting
7
+ # - Validated through ColorRGBA implicitly
8
+ class ColorSetting
9
+ include SetableGetable
10
+
11
+ def initialize(initial_red, initial_green, initial_blue, initial_alpha)
12
+ self.color = AsciiPngfy::ColorRGBA.new(0, 0, 0, 0)
13
+
14
+ set(
15
+ red: initial_red,
16
+ green: initial_green,
17
+ blue: initial_blue,
18
+ alpha: initial_alpha
19
+ )
20
+ end
21
+
22
+ def get
23
+ color.dup
24
+ end
25
+
26
+ def set(red: nil, green: nil, blue: nil, alpha: nil)
27
+ color.red = red unless red.nil?
28
+ color.green = green unless green.nil?
29
+ color.blue = blue unless blue.nil?
30
+ color.alpha = alpha unless alpha.nil?
31
+
32
+ color.dup
33
+ end
34
+
35
+ def initialize_copy(original_color_setting)
36
+ self.color = original_color_setting.color.dup
37
+ end
38
+
39
+ protected
40
+
41
+ attr_accessor(:color)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiPngfy
4
+ module Settings
5
+ # Reponsibilities
6
+ # - Keeps track of the font_height setting
7
+ # - Validates font_height
8
+ class FontHeightSetting
9
+ include SetableGetable
10
+
11
+ def initialize(initial_font_height)
12
+ self.font_height = GLYPH_DESIGN_HEIGHT
13
+
14
+ set(initial_font_height)
15
+ end
16
+
17
+ def get
18
+ font_height
19
+ end
20
+
21
+ def set(desired_font_height)
22
+ validated_font_height = validate_font_height(desired_font_height)
23
+
24
+ new_font_height =
25
+ if multiple_of_glyph_design_height?(validated_font_height)
26
+ validated_font_height
27
+ else
28
+ lower_bound_distance = (validated_font_height % GLYPH_DESIGN_HEIGHT)
29
+ determine_bound_font_height(validated_font_height, lower_bound_distance)
30
+ end
31
+
32
+ self.font_height = new_font_height
33
+ end
34
+
35
+ private
36
+
37
+ attr_accessor(:font_height)
38
+
39
+ def font_height_valid?(some_font_height)
40
+ some_font_height.is_a?(Integer) && (some_font_height >= GLYPH_DESIGN_HEIGHT)
41
+ end
42
+
43
+ def validate_font_height(some_font_height)
44
+ return some_font_height if font_height_valid?(some_font_height)
45
+
46
+ error_message = String.new
47
+ error_message << "#{some_font_height} is not a valid font size. "
48
+ error_message << "Must be an Integer in the range (#{GLYPH_DESIGN_HEIGHT}..)."
49
+
50
+ raise AsciiPngfy::Exceptions::InvalidFontHeightError, error_message
51
+ end
52
+
53
+ def multiple_of_glyph_design_height?(number)
54
+ (number % GLYPH_DESIGN_HEIGHT).zero?
55
+ end
56
+
57
+ def lower_bound_distance?(distance)
58
+ [1, 2, 3, 4].include?(distance)
59
+ end
60
+
61
+ def higher_bound_distance?(distance)
62
+ [5, 6, 7, 8].include?(distance)
63
+ end
64
+
65
+ def determine_bound_font_height(validated_font_height, lower_bound_distance)
66
+ if lower_bound_distance?(lower_bound_distance)
67
+ (validated_font_height / GLYPH_DESIGN_HEIGHT) * GLYPH_DESIGN_HEIGHT
68
+ elsif higher_bound_distance?(lower_bound_distance)
69
+ ((validated_font_height / GLYPH_DESIGN_HEIGHT) + 1) * GLYPH_DESIGN_HEIGHT
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiPngfy
4
+ module Settings
5
+ # Reponsibilities
6
+ # - Keeps track of the the horizontal_spacing setting
7
+ # - Validates horizontal_spacing
8
+ class HorizontalSpacingSetting
9
+ include SetableGetable
10
+
11
+ def initialize(initial_spacing)
12
+ self.horizontal_spacing = 0
13
+
14
+ set(initial_spacing)
15
+ end
16
+
17
+ def get
18
+ horizontal_spacing
19
+ end
20
+
21
+ def set(desired_spacing)
22
+ validated_horizontal_spacing = validate_horizontal_spacing(desired_spacing)
23
+
24
+ self.horizontal_spacing = validated_horizontal_spacing
25
+ end
26
+
27
+ private
28
+
29
+ attr_accessor(:horizontal_spacing)
30
+
31
+ def horizontal_spacing_valid?(some_spacing)
32
+ some_spacing.is_a?(Integer) && (some_spacing >= 0)
33
+ end
34
+
35
+ def validate_horizontal_spacing(some_spacing)
36
+ return some_spacing if horizontal_spacing_valid?(some_spacing)
37
+
38
+ error_message = String.new
39
+ error_message << "#{some_spacing} is not a valid horizontal spacing. "
40
+ error_message << 'Must be an Integer in the range (0..).'
41
+
42
+ raise AsciiPngfy::Exceptions::InvalidHorizontalSpacingError, error_message
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiPngfy
4
+ module Settings
5
+ # Provides the interface for all the Setting implementations in a way that
6
+ # each inclusion of this module forces the including class to override
7
+ # the behaviour for #set and #get
8
+ #
9
+ # If the #get or #set method is called, but it is not overriden by the object
10
+ # mixing this module in, a NotImplementedError is raised
11
+ module SetableGetable
12
+ def get
13
+ self_class_name = self.class.to_s
14
+
15
+ expected_error_message = String.new
16
+ expected_error_message << "#{self_class_name}#get has not yet been implemented. "
17
+ expected_error_message << "Must override the #{self_class_name}#get method in order to "
18
+ expected_error_message << 'function as Setting.'
19
+
20
+ raise NotImplementedError, expected_error_message
21
+ end
22
+
23
+ def set
24
+ self_class_name = self.class.to_s
25
+
26
+ expected_error_message = String.new
27
+ expected_error_message << "#{self_class_name}#set has not yet been implemented. "
28
+ expected_error_message << "Must override the #{self_class_name}#set method in order to "
29
+ expected_error_message << 'function as Setting.'
30
+
31
+ raise NotImplementedError, expected_error_message
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiPngfy
4
+ module Settings
5
+ # Reponsibilities
6
+ # - Pipes setter and getter calls to a specific Setting instance
7
+ # - Associates supported settings with the respective Setting name
8
+ # - Generates a SettingsSnapshot object for the purpose of describing
9
+ # a specific set of settings at the time of the snapshot generation
10
+ class SetableGetableSettings
11
+ GET = :get
12
+ SET = :set
13
+
14
+ def initialize
15
+ initialize_supported_settings
16
+ end
17
+
18
+ def respond_to_missing?(method_name, _)
19
+ setting_name = determine_setting_name_as_symbol(method_name)
20
+
21
+ setting_exists?(setting_name) || super
22
+ end
23
+
24
+ def method_missing(method_name, *arguments)
25
+ super unless respond_to?(method_name)
26
+
27
+ setting_operation = determine_operation(method_name)
28
+ setting_name = determine_setting_name_as_symbol(method_name)
29
+
30
+ # call the settings #set or #get method depending on the operation
31
+ setting(setting_name).public_send(setting_operation, *arguments)
32
+ end
33
+
34
+ def snapshot
35
+ snapshot_settings = settings.dup
36
+ snapshot_settings.transform_values!(&:get)
37
+
38
+ SettingsSnapshot.new(snapshot_settings)
39
+ end
40
+
41
+ private
42
+
43
+ attr_accessor(:settings)
44
+
45
+ def add(setting_name, setting_instance)
46
+ self.settings ||= {}
47
+
48
+ settings[setting_name] = setting_instance
49
+ end
50
+
51
+ def initialize_supported_settings
52
+ add(:font_color, ColorSetting.new(255, 255, 255, 255))
53
+ add(:background_color, ColorSetting.new(0, 0, 0, 255))
54
+ add(:font_height, FontHeightSetting.new(9))
55
+ add(:horizontal_spacing, HorizontalSpacingSetting.new(1))
56
+ add(:vertical_spacing, VerticalSpacingSetting.new(1))
57
+ add(:text, TextSetting.new(self))
58
+ end
59
+
60
+ def setting(setting_name)
61
+ settings[setting_name]
62
+ end
63
+
64
+ def setting_exists?(setting_name)
65
+ settings.key?(setting_name)
66
+ end
67
+
68
+ def setter?(method_name)
69
+ method_name.start_with?('set_')
70
+ end
71
+
72
+ def determine_operation(method_name)
73
+ setter?(method_name) ? SET : GET
74
+ end
75
+
76
+ def determine_setting_name_as_symbol(method_name)
77
+ if setter?(method_name)
78
+ method_name[4..]
79
+ else
80
+ method_name
81
+ end.to_sym
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiPngfy
4
+ module Settings
5
+ # Reponsibilities
6
+ # - Keeps track of the setting values at the point of creation
7
+ # of this snapshot
8
+ # - Dynamic getter for each setting name which simply returns
9
+ # the value of the respective setting
10
+ class SettingsSnapshot
11
+ def initialize(setting_names_and_values)
12
+ self.setting_names_and_values = setting_names_and_values
13
+ end
14
+
15
+ def respond_to_missing?(setting_name, _)
16
+ setting_exists?(setting_name) || super
17
+ end
18
+
19
+ def method_missing(setting_name, *_arguments)
20
+ super unless respond_to?(setting_name)
21
+
22
+ # return that settings value
23
+ setting_names_and_values[setting_name]
24
+ end
25
+
26
+ private
27
+
28
+ def setting_exists?(setting_name)
29
+ setting_names_and_values.key?(setting_name)
30
+ end
31
+
32
+ attr_accessor(:setting_names_and_values)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: false
2
+
3
+ module AsciiPngfy
4
+ module Settings
5
+ # Reponsibilities
6
+ # - Keeps track of the text and replacement_text setting
7
+ # - Replaces unsupported text characters with replacement text
8
+ # - Validates text and replacement_text
9
+ # rubocop: disable Metrics/ClassLength
10
+ class TextSetting
11
+ include SetableGetable
12
+
13
+ def initialize(settings)
14
+ self.settings = settings
15
+ self.text = ''
16
+
17
+ set('<3 Ascii-Pngfy <3')
18
+ end
19
+
20
+ def get
21
+ text.dup
22
+ end
23
+
24
+ # The philosophy behind this method is as follows:
25
+ # - The desired_text cannot be empty pre replacement. If it is an error is raised
26
+ #
27
+ # - When no replacement text is passed, the desired_text is considered as is by
28
+ # skipping the replacement procedure
29
+ #
30
+ # - When a replacement text is passed though, the replacement text is validated and
31
+ # all unsupported text characters are replaced with the replacement text
32
+ #
33
+ # - The text is then validated post replacement to make sure it is not empty,
34
+ # the same as pre replacement
35
+ #
36
+ # - At this point the text is validated to only contain supported ASCII characters
37
+ # and has its dimensions checked in terms of the needed png texture size to fit
38
+ # the resulting text along with the character spacing previously set.
39
+ #
40
+ # - Finally the text setting is updated to the resulting, optionally replaced text
41
+ def set(desired_text, desired_replacement_text = nil)
42
+ pre_replacement_text_validation(desired_text, desired_replacement_text)
43
+
44
+ if replacement_desired?(desired_replacement_text)
45
+ desired_replacement_text = validate_replacement_text(desired_replacement_text)
46
+
47
+ desired_text = replace_unsupported_characters(from: desired_text, with: desired_replacement_text)
48
+ end
49
+
50
+ post_replacement_text_validation(desired_text)
51
+ validate_text_contents(desired_text)
52
+ validate_text_image_dimensions(desired_text)
53
+
54
+ # set the text to a duplicate of the original text to avoid string injection
55
+ self.text = desired_text.dup
56
+
57
+ desired_text
58
+ end
59
+
60
+ def initialize_copy(original_text_setting)
61
+ self.text = original_text_setting.text.dup
62
+ end
63
+
64
+ protected
65
+
66
+ attr_accessor(:text)
67
+
68
+ private
69
+
70
+ attr_accessor(:settings)
71
+
72
+ def character_supported?(some_character)
73
+ SUPPORTED_ASCII_CHARACTERS.include?(some_character)
74
+ end
75
+
76
+ def extract_unsupported_characters(some_string)
77
+ unsupported_characters = []
78
+
79
+ some_string.each_char do |some_char|
80
+ unsupported_characters << some_char unless character_supported?(some_char)
81
+ end
82
+
83
+ unsupported_characters
84
+ end
85
+
86
+ def string_supported?(some_string)
87
+ # also returns true when the string is empty, so an undesired empty string must
88
+ # be handled separately
89
+ extract_unsupported_characters(some_string).empty?
90
+ end
91
+
92
+ def validate_replacement_text(some_text)
93
+ return some_text if string_supported?(some_text)
94
+
95
+ error_message = "#{some_text.inspect} is not a valid replacement string. "\
96
+ "Must contain only characters with ASCII code #{SUPPORTED_ASCII_CODES.min} "\
97
+ "or in the range (#{SUPPORTED_ASCII_CODES_WITHOUT_NEWLINE_RANGE})."
98
+
99
+ raise AsciiPngfy::Exceptions::InvalidReplacementTextError, error_message
100
+ end
101
+
102
+ def replace_unsupported_characters(from:, with:)
103
+ text_with_replacements = ''
104
+
105
+ from.each_char do |text_character|
106
+ replacement_text = character_supported?(text_character) ? text_character : with
107
+ text_with_replacements << replacement_text
108
+ end
109
+
110
+ text_with_replacements
111
+ end
112
+
113
+ def replacement_desired?(replacement_text)
114
+ !!replacement_text
115
+ end
116
+
117
+ def pre_replacement_text_validation(desired_text, desired_replacement_text)
118
+ return desired_text unless desired_text.empty?
119
+
120
+ error_message = 'Text cannot be empty because that would result in a PNG with a width or height of zero. '\
121
+ "Must contain at least one character with ASCII code #{SUPPORTED_ASCII_CODES.min} "\
122
+ "or in the range (#{SUPPORTED_ASCII_CODES_WITHOUT_NEWLINE_RANGE})."
123
+
124
+ # hint the user that the desired replacement text is also empty
125
+ if replacement_desired?(desired_replacement_text) && desired_replacement_text.empty?
126
+ error_message << ' Hint: Both the text and the replacement text are empty.'
127
+ end
128
+
129
+ raise AsciiPngfy::Exceptions::EmptyTextError, error_message
130
+ end
131
+
132
+ def post_replacement_text_validation(desired_text)
133
+ return desired_text unless desired_text.empty?
134
+
135
+ error_message = 'Text cannot be empty because that would result in a PNG with a width or height of zero. '\
136
+ "Must contain at least one character with ASCII code #{SUPPORTED_ASCII_CODES.min} "\
137
+ "or in the range (#{SUPPORTED_ASCII_CODES_WITHOUT_NEWLINE_RANGE}). "\
138
+ 'Hint: An empty replacement text causes text with only unsupported characters to end up as '\
139
+ 'empty string.'
140
+
141
+ raise AsciiPngfy::Exceptions::EmptyTextError, error_message
142
+ end
143
+
144
+ def validate_text_contents(some_text)
145
+ # this method only accounts for non-empty strings that contains unsupported characters
146
+ # empty strings are handled separately to separate different types of errors more clearly
147
+ return some_text if string_supported?(some_text)
148
+
149
+ un_supported_characters = extract_unsupported_characters(some_text)
150
+ un_supported_inspected_characters = un_supported_characters.map(&:inspect)
151
+ un_supported_characters_list = "#{un_supported_inspected_characters[0..-2].join(', ')} and "\
152
+ "#{un_supported_inspected_characters.last}"
153
+
154
+ error_message = "#{un_supported_characters_list} are all invalid text characters. "\
155
+ "Must contain only characters with ASCII code #{SUPPORTED_ASCII_CODES.min} "\
156
+ "or in the range (#{SUPPORTED_ASCII_CODES_WITHOUT_NEWLINE_RANGE})."
157
+
158
+ raise AsciiPngfy::Exceptions::InvalidCharacterError, error_message
159
+ end
160
+
161
+ def validate_text_image_width(desired_text, image_width)
162
+ return desired_text unless image_width > AsciiPngfy::MAX_RESULT_PNG_IMAGE_WIDTH
163
+
164
+ longest_text_line = RenderingRules.longest_text_line(desired_text)
165
+
166
+ capped_text = cap_string(longest_text_line, '..', 60)
167
+
168
+ error_message = "The text line #{capped_text.inspect} is too long to be represented in a "\
169
+ "#{AsciiPngfy::MAX_RESULT_PNG_IMAGE_WIDTH} pixel wide png. Hint: Use shorter "\
170
+ 'text lines and/or reduce the horizontal character spacing.'
171
+
172
+ raise AsciiPngfy::Exceptions::TextLineTooLongError, error_message
173
+ end
174
+
175
+ def validate_text_image_height(desired_text, image_height)
176
+ return desired_text unless image_height > AsciiPngfy::MAX_RESULT_PNG_IMAGE_HEIGHT
177
+
178
+ capped_text = cap_string(desired_text, '..', 60)
179
+
180
+ error_message = "The text #{capped_text.inspect} contains too many lines to be represented in a "\
181
+ "#{MAX_RESULT_PNG_IMAGE_HEIGHT} pixel high png. Hint: Use less text lines and/or "\
182
+ 'reduce the vertical character spacing.'
183
+
184
+ raise AsciiPngfy::Exceptions::TooManyTextLinesError, error_message
185
+ end
186
+
187
+ def validate_text_image_dimensions(desired_text)
188
+ image_width = AsciiPngfy::RenderingRules.png_width(settings, desired_text)
189
+ image_height = AsciiPngfy::RenderingRules.png_height(settings, desired_text)
190
+
191
+ validate_text_image_width(desired_text, image_width)
192
+ validate_text_image_height(desired_text, image_height)
193
+ end
194
+
195
+ def cap_string(some_string, desired_separator, desired_cap_length)
196
+ if some_string.length <= desired_cap_length
197
+ some_string
198
+ else
199
+ half_cap_length = (desired_cap_length - desired_separator.length) / 2
200
+
201
+ string_beginning_portion = some_string[0, half_cap_length]
202
+ string_end_portion = some_string[-half_cap_length..]
203
+
204
+ "#{string_beginning_portion}#{desired_separator}#{string_end_portion}"
205
+ end
206
+ end
207
+ end
208
+ end
209
+ # rubocop: enable Metrics/ClassLength
210
+ end