ascii_pngfy 0.2.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.
@@ -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