boring_avatars 0.1.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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "identifier"
4
+ require_relative "name_hash"
5
+ require_relative "svg/serializer"
6
+ require_relative "variants/bauhaus"
7
+ require_relative "variants/beam"
8
+ require_relative "variants/marble"
9
+ require_relative "variants/pixel"
10
+ require_relative "variants/ring"
11
+ require_relative "variants/sunset"
12
+
13
+ module BoringAvatars
14
+ module Renderer
15
+ VARIANTS = {
16
+ bauhaus: Variants::Bauhaus,
17
+ beam: Variants::Beam,
18
+ marble: Variants::Marble,
19
+ pixel: Variants::Pixel,
20
+ ring: Variants::Ring,
21
+ sunset: Variants::Sunset
22
+ }.freeze
23
+
24
+ module_function
25
+
26
+ def call(input)
27
+ prefix = Identifier.call(input)
28
+ ids = {
29
+ mask: "#{prefix}-mask",
30
+ filter: "#{prefix}-filter",
31
+ gradient_0: "#{prefix}-gradient-0",
32
+ gradient_1: "#{prefix}-gradient-1"
33
+ }.freeze
34
+ hash = NameHash.call(input.name)
35
+ result = VARIANTS.fetch(input.variant).render(input: input, hash: hash, ids: ids)
36
+
37
+ Svg::Serializer.call(root_element(input, result, ids))
38
+ end
39
+
40
+ def root_element(input, result, ids)
41
+ root_attributes = {
42
+ "viewBox" => "0 0 #{result.size} #{result.size}",
43
+ "fill" => "none",
44
+ "role" => "img",
45
+ "xmlns" => "http://www.w3.org/2000/svg",
46
+ "width" => input.size,
47
+ "height" => input.size
48
+ }.merge(input.attributes)
49
+
50
+ children = []
51
+ children << Svg::Element.new(name: "title", children: [input.name]) if input.title
52
+ children << mask_element(input, result, ids[:mask])
53
+ children << Svg::Element.new(
54
+ name: "g",
55
+ attributes: { "mask" => "url(##{ids[:mask]})" },
56
+ children: result.content
57
+ )
58
+ unless result.defs.empty?
59
+ children << Svg::Element.new(name: "defs", children: result.defs)
60
+ end
61
+
62
+ Svg::Element.new(name: "svg", attributes: root_attributes, children: children)
63
+ end
64
+ private_class_method :root_element
65
+
66
+ def mask_element(input, result, mask_id)
67
+ attributes = {
68
+ "id" => mask_id,
69
+ "maskUnits" => "userSpaceOnUse",
70
+ "x" => 0,
71
+ "y" => 0,
72
+ "width" => result.size,
73
+ "height" => result.size
74
+ }.merge(result.mask_attributes)
75
+ rectangle = Svg::Element.new(
76
+ name: "rect",
77
+ attributes: {
78
+ "width" => result.size,
79
+ "height" => result.size,
80
+ "rx" => input.square ? nil : result.size * 2,
81
+ "fill" => "#FFFFFF"
82
+ }
83
+ )
84
+
85
+ Svg::Element.new(name: "mask", attributes: attributes, children: [rectangle])
86
+ end
87
+ private_class_method :mask_element
88
+ end
89
+ private_constant :Renderer
90
+ end
91
+
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringAvatars
4
+ module Svg
5
+ Element = Data.define(:name, :attributes, :children) do
6
+ def initialize(name:, attributes: {}, children: [])
7
+ super(
8
+ name: name.to_s.freeze,
9
+ attributes: attributes.reject { |_key, value| value.nil? }.freeze,
10
+ children: children.freeze
11
+ )
12
+ end
13
+ end
14
+ end
15
+ end
16
+
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "element"
4
+ require_relative "value"
5
+
6
+ module BoringAvatars
7
+ module Svg
8
+ module Serializer
9
+ module_function
10
+
11
+ def call(element)
12
+ serialize_element(element).force_encoding(Encoding::UTF_8)
13
+ end
14
+
15
+ def valid_xml_string?(value)
16
+ value.valid_encoding? && value.codepoints.all? do |codepoint|
17
+ codepoint == 0x09 || codepoint == 0x0A || codepoint == 0x0D ||
18
+ codepoint.between?(0x20, 0xD7FF) ||
19
+ codepoint.between?(0xE000, 0xFFFD) ||
20
+ codepoint.between?(0x10000, 0x10FFFF)
21
+ end
22
+ end
23
+
24
+ def escape(value)
25
+ string = value.to_s
26
+ raise ArgumentError, "value contains characters forbidden by XML 1.0" unless valid_xml_string?(string)
27
+
28
+ string
29
+ .gsub("&", "&amp;")
30
+ .gsub("<", "&lt;")
31
+ .gsub(">", "&gt;")
32
+ .gsub('"', "&quot;")
33
+ .gsub("'", "&apos;")
34
+ end
35
+
36
+ def serialize_element(element)
37
+ attributes = element.attributes.map do |name, value|
38
+ %(#{name}="#{escape(attribute_value(value))}")
39
+ end
40
+ opening = attributes.empty? ? "<#{element.name}" : "<#{element.name} #{attributes.join(' ')}"
41
+
42
+ return "#{opening}/>" if element.children.empty?
43
+
44
+ content = element.children.map do |child|
45
+ child.is_a?(Element) ? serialize_element(child) : escape(child)
46
+ end.join
47
+ "#{opening}>#{content}</#{element.name}>"
48
+ end
49
+ private_class_method :serialize_element
50
+
51
+ def attribute_value(value)
52
+ case value
53
+ when String
54
+ value
55
+ when Numeric
56
+ Value.number(value)
57
+ when true, false
58
+ value.to_s
59
+ else
60
+ raise ArgumentError, "unsupported SVG attribute value: #{value.class}"
61
+ end
62
+ end
63
+ private_class_method :attribute_value
64
+ end
65
+ end
66
+ end
67
+
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringAvatars
4
+ module Svg
5
+ module Value
6
+ module_function
7
+
8
+ def number(value)
9
+ return "0" if value.zero?
10
+ return value.to_i.to_s if value.is_a?(Float) && value.finite? && value == value.to_i
11
+
12
+ value.to_s
13
+ end
14
+ end
15
+ end
16
+ end
17
+
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringAvatars
4
+ module Utilities
5
+ module_function
6
+
7
+ def digit(number, position)
8
+ (number / (10**position)).floor % 10
9
+ end
10
+
11
+ def boolean(number, position)
12
+ digit(number, position).even?
13
+ end
14
+
15
+ def unit(number, range, position = nil)
16
+ value = number % range
17
+ position && !position.zero? && digit(number, position).even? ? -value : value
18
+ end
19
+
20
+ def random_color(number, colors)
21
+ colors[number % colors.length]
22
+ end
23
+
24
+ def contrast(color)
25
+ red = color[1, 2].to_i(16)
26
+ green = color[3, 2].to_i(16)
27
+ blue = color[5, 2].to_i(16)
28
+ yiq = ((red * 299) + (green * 587) + (blue * 114)) / 1000.0
29
+
30
+ yiq >= 128 ? "#000000" : "#FFFFFF"
31
+ end
32
+ end
33
+ end
34
+
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../utilities"
4
+ require_relative "../variants"
5
+
6
+ module BoringAvatars
7
+ module Variants
8
+ module Bauhaus
9
+ extend VariantHelpers
10
+
11
+ SIZE = 80
12
+ ELEMENTS = 4
13
+
14
+ module_function
15
+
16
+ def render(input:, hash:, ids:)
17
+ properties = Array.new(ELEMENTS) do |index|
18
+ multiplier = index + 1
19
+ {
20
+ color: Utilities.random_color(hash + index, input.colors),
21
+ translate_x: Utilities.unit(hash * multiplier, (SIZE / 2) - (index + 17), 1),
22
+ translate_y: Utilities.unit(hash * multiplier, (SIZE / 2) - (index + 17), 2),
23
+ rotate: Utilities.unit(hash * multiplier, 360),
24
+ square: Utilities.boolean(hash, 2)
25
+ }
26
+ end
27
+
28
+ content = [
29
+ element("rect", { "width" => SIZE, "height" => SIZE, "fill" => properties[0][:color] }),
30
+ element(
31
+ "rect",
32
+ {
33
+ "x" => (SIZE - 60) / 2,
34
+ "y" => (SIZE - 20) / 2,
35
+ "width" => SIZE,
36
+ "height" => properties[1][:square] ? SIZE : SIZE / 8,
37
+ "fill" => properties[1][:color],
38
+ "transform" => transform(properties[1])
39
+ }
40
+ ),
41
+ element(
42
+ "circle",
43
+ {
44
+ "cx" => SIZE / 2,
45
+ "cy" => SIZE / 2,
46
+ "fill" => properties[2][:color],
47
+ "r" => SIZE / 5,
48
+ "transform" => "translate(#{number(properties[2][:translate_x])} #{number(properties[2][:translate_y])})"
49
+ }
50
+ ),
51
+ element(
52
+ "line",
53
+ {
54
+ "x1" => 0,
55
+ "y1" => SIZE / 2,
56
+ "x2" => SIZE,
57
+ "y2" => SIZE / 2,
58
+ "stroke-width" => 2,
59
+ "stroke" => properties[3][:color],
60
+ "transform" => transform(properties[3])
61
+ }
62
+ )
63
+ ]
64
+
65
+ VariantResult.new(size: SIZE, content: content)
66
+ end
67
+
68
+ def transform(property)
69
+ "translate(#{number(property[:translate_x])} #{number(property[:translate_y])}) " \
70
+ "rotate(#{number(property[:rotate])} #{SIZE / 2} #{SIZE / 2})"
71
+ end
72
+ private_class_method :transform
73
+ end
74
+ end
75
+ end
76
+
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../utilities"
4
+ require_relative "../variants"
5
+
6
+ module BoringAvatars
7
+ module Variants
8
+ module Beam
9
+ extend VariantHelpers
10
+
11
+ SIZE = 36
12
+
13
+ module_function
14
+
15
+ def render(input:, hash:, ids:)
16
+ data = generate_data(hash, input.colors)
17
+ content = [
18
+ element("rect", { "width" => SIZE, "height" => SIZE, "fill" => data[:background_color] }),
19
+ element(
20
+ "rect",
21
+ {
22
+ "x" => 0,
23
+ "y" => 0,
24
+ "width" => SIZE,
25
+ "height" => SIZE,
26
+ "transform" => wrapper_transform(data),
27
+ "fill" => data[:wrapper_color],
28
+ "rx" => data[:is_circle] ? SIZE : SIZE / 6
29
+ }
30
+ ),
31
+ element("g", { "transform" => face_transform(data) }, face_elements(data))
32
+ ]
33
+
34
+ VariantResult.new(size: SIZE, content: content)
35
+ end
36
+
37
+ def generate_data(hash, colors)
38
+ wrapper_color = Utilities.random_color(hash, colors)
39
+ pre_translate_x = Utilities.unit(hash, 10, 1)
40
+ wrapper_translate_x = pre_translate_x < 5 ? pre_translate_x + (SIZE / 9) : pre_translate_x
41
+ pre_translate_y = Utilities.unit(hash, 10, 2)
42
+ wrapper_translate_y = pre_translate_y < 5 ? pre_translate_y + (SIZE / 9) : pre_translate_y
43
+
44
+ {
45
+ wrapper_color: wrapper_color,
46
+ face_color: Utilities.contrast(wrapper_color),
47
+ background_color: Utilities.random_color(hash + 13, colors),
48
+ wrapper_translate_x: wrapper_translate_x,
49
+ wrapper_translate_y: wrapper_translate_y,
50
+ wrapper_rotate: Utilities.unit(hash, 360),
51
+ wrapper_scale: 1 + (Utilities.unit(hash, SIZE / 12) / 10.0),
52
+ is_mouth_open: Utilities.boolean(hash, 2),
53
+ is_circle: Utilities.boolean(hash, 1),
54
+ eye_spread: Utilities.unit(hash, 5),
55
+ mouth_spread: Utilities.unit(hash, 3),
56
+ face_rotate: Utilities.unit(hash, 10, 3),
57
+ face_translate_x: wrapper_translate_x > SIZE / 6 ? wrapper_translate_x / 2.0 : Utilities.unit(hash, 8, 1),
58
+ face_translate_y: wrapper_translate_y > SIZE / 6 ? wrapper_translate_y / 2.0 : Utilities.unit(hash, 7, 2)
59
+ }
60
+ end
61
+ private_class_method :generate_data
62
+
63
+ def wrapper_transform(data)
64
+ "translate(#{number(data[:wrapper_translate_x])} #{number(data[:wrapper_translate_y])}) " \
65
+ "rotate(#{number(data[:wrapper_rotate])} #{SIZE / 2} #{SIZE / 2}) " \
66
+ "scale(#{number(data[:wrapper_scale])})"
67
+ end
68
+ private_class_method :wrapper_transform
69
+
70
+ def face_transform(data)
71
+ "translate(#{number(data[:face_translate_x])} #{number(data[:face_translate_y])}) " \
72
+ "rotate(#{number(data[:face_rotate])} #{SIZE / 2} #{SIZE / 2})"
73
+ end
74
+ private_class_method :face_transform
75
+
76
+ def face_elements(data)
77
+ mouth_y = 19 + data[:mouth_spread]
78
+ mouth = if data[:is_mouth_open]
79
+ element(
80
+ "path",
81
+ {
82
+ "d" => "M15 #{number(mouth_y)}c2 1 4 1 6 0",
83
+ "stroke" => data[:face_color],
84
+ "fill" => "none",
85
+ "stroke-linecap" => "round"
86
+ }
87
+ )
88
+ else
89
+ element(
90
+ "path",
91
+ {
92
+ "d" => "M13,#{number(mouth_y)} a1,0.75 0 0,0 10,0",
93
+ "fill" => data[:face_color]
94
+ }
95
+ )
96
+ end
97
+
98
+ [
99
+ mouth,
100
+ element(
101
+ "rect",
102
+ {
103
+ "x" => 14 - data[:eye_spread],
104
+ "y" => 14,
105
+ "width" => 1.5,
106
+ "height" => 2,
107
+ "rx" => 1,
108
+ "stroke" => "none",
109
+ "fill" => data[:face_color]
110
+ }
111
+ ),
112
+ element(
113
+ "rect",
114
+ {
115
+ "x" => 20 + data[:eye_spread],
116
+ "y" => 14,
117
+ "width" => 1.5,
118
+ "height" => 2,
119
+ "rx" => 1,
120
+ "stroke" => "none",
121
+ "fill" => data[:face_color]
122
+ }
123
+ )
124
+ ]
125
+ end
126
+ private_class_method :face_elements
127
+ end
128
+ end
129
+ end
130
+
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../utilities"
4
+ require_relative "../variants"
5
+
6
+ module BoringAvatars
7
+ module Variants
8
+ module Marble
9
+ extend VariantHelpers
10
+
11
+ SIZE = 80
12
+ ELEMENTS = 3
13
+ FIRST_PATH = "M32.414 59.35L50.376 70.5H72.5v-71H33.728L26.5 13.381l19.057 27.08L32.414 59.35z"
14
+ SECOND_PATH = "M22.216 24L0 46.75l14.108 38.129L78 86l-3.081-59.276-22.378 4.005 12.972 20.186-23.35 27.395L22.215 24z"
15
+
16
+ module_function
17
+
18
+ def render(input:, hash:, ids:)
19
+ properties = Array.new(ELEMENTS) do |index|
20
+ multiplier = index + 1
21
+ {
22
+ color: Utilities.random_color(hash + index, input.colors),
23
+ translate_x: Utilities.unit(hash * multiplier, SIZE / 10, 1),
24
+ translate_y: Utilities.unit(hash * multiplier, SIZE / 10, 2),
25
+ scale: 1.2 + (Utilities.unit(hash * multiplier, SIZE / 20) / 10.0),
26
+ rotate: Utilities.unit(hash * multiplier, 360, 1)
27
+ }
28
+ end
29
+
30
+ content = [
31
+ element("rect", { "width" => SIZE, "height" => SIZE, "fill" => properties[0][:color] }),
32
+ element(
33
+ "path",
34
+ {
35
+ "filter" => "url(##{ids[:filter]})",
36
+ "d" => FIRST_PATH,
37
+ "fill" => properties[1][:color],
38
+ "transform" => transform(properties[1], properties[2][:scale])
39
+ }
40
+ ),
41
+ element(
42
+ "path",
43
+ {
44
+ "filter" => "url(##{ids[:filter]})",
45
+ "style" => "mix-blend-mode:overlay",
46
+ "d" => SECOND_PATH,
47
+ "fill" => properties[2][:color],
48
+ "transform" => transform(properties[2], properties[2][:scale])
49
+ }
50
+ )
51
+ ]
52
+
53
+ filter = element(
54
+ "filter",
55
+ {
56
+ "id" => ids[:filter],
57
+ "filterUnits" => "userSpaceOnUse",
58
+ "color-interpolation-filters" => "sRGB"
59
+ },
60
+ [
61
+ element("feFlood", { "flood-opacity" => 0, "result" => "BackgroundImageFix" }),
62
+ element(
63
+ "feBlend",
64
+ { "in" => "SourceGraphic", "in2" => "BackgroundImageFix", "result" => "shape" }
65
+ ),
66
+ element(
67
+ "feGaussianBlur",
68
+ { "stdDeviation" => 7, "result" => "effect1_foregroundBlur" }
69
+ )
70
+ ]
71
+ )
72
+
73
+ VariantResult.new(size: SIZE, content: content, defs: [filter])
74
+ end
75
+
76
+ def transform(property, scale)
77
+ "translate(#{number(property[:translate_x])} #{number(property[:translate_y])}) " \
78
+ "rotate(#{number(property[:rotate])} #{SIZE / 2} #{SIZE / 2}) " \
79
+ "scale(#{number(scale)})"
80
+ end
81
+ private_class_method :transform
82
+ end
83
+ end
84
+ end
85
+
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../utilities"
4
+ require_relative "../variants"
5
+
6
+ module BoringAvatars
7
+ module Variants
8
+ module Pixel
9
+ extend VariantHelpers
10
+
11
+ SIZE = 80
12
+ ELEMENTS = 64
13
+ TOP_ROW_X = [0, 20, 40, 60, 10, 30, 50, 70].freeze
14
+ COLUMN_X = [0, 20, 40, 60, 10, 30, 50, 70].freeze
15
+ Y_COORDINATES = (10..70).step(10).to_a.freeze
16
+ COORDINATES = (
17
+ TOP_ROW_X.map { |x| [x, 0] } +
18
+ COLUMN_X.flat_map { |x| Y_COORDINATES.map { |y| [x, y] } }
19
+ ).freeze
20
+
21
+ module_function
22
+
23
+ def render(input:, hash:, ids:)
24
+ colors = Array.new(ELEMENTS) do |index|
25
+ Utilities.random_color(hash % (index + 1), input.colors)
26
+ end
27
+ content = COORDINATES.each_with_index.map do |(x, y), index|
28
+ element(
29
+ "rect",
30
+ {
31
+ "x" => x.zero? ? nil : x,
32
+ "y" => y.zero? ? nil : y,
33
+ "width" => 10,
34
+ "height" => 10,
35
+ "fill" => colors[index]
36
+ }
37
+ )
38
+ end
39
+
40
+ VariantResult.new(
41
+ size: SIZE,
42
+ content: content,
43
+ mask_attributes: { "mask-type" => "alpha" }
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
49
+
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../utilities"
4
+ require_relative "../variants"
5
+
6
+ module BoringAvatars
7
+ module Variants
8
+ module Ring
9
+ extend VariantHelpers
10
+
11
+ SIZE = 90
12
+ COLORS = 5
13
+ PATHS = [
14
+ "M0 0h90v45H0z",
15
+ "M0 45h90v45H0z",
16
+ "M83 45a38 38 0 00-76 0h76z",
17
+ "M83 45a38 38 0 01-76 0h76z",
18
+ "M77 45a32 32 0 10-64 0h64z",
19
+ "M77 45a32 32 0 11-64 0h64z",
20
+ "M71 45a26 26 0 00-52 0h52z",
21
+ "M71 45a26 26 0 01-52 0h52z"
22
+ ].freeze
23
+
24
+ module_function
25
+
26
+ def render(input:, hash:, ids:)
27
+ shuffled = Array.new(COLORS) { |index| Utilities.random_color(hash + index, input.colors) }
28
+ colors = [
29
+ shuffled[0], shuffled[1], shuffled[1], shuffled[2], shuffled[2],
30
+ shuffled[3], shuffled[3], shuffled[0], shuffled[4]
31
+ ]
32
+ content = PATHS.each_with_index.map do |path, index|
33
+ element("path", { "d" => path, "fill" => colors[index] })
34
+ end
35
+ content << element("circle", { "cx" => 45, "cy" => 45, "r" => 23, "fill" => colors[8] })
36
+
37
+ VariantResult.new(size: SIZE, content: content)
38
+ end
39
+ end
40
+ end
41
+ end
42
+
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../utilities"
4
+ require_relative "../variants"
5
+
6
+ module BoringAvatars
7
+ module Variants
8
+ module Sunset
9
+ extend VariantHelpers
10
+
11
+ SIZE = 80
12
+ ELEMENTS = 4
13
+
14
+ module_function
15
+
16
+ def render(input:, hash:, ids:)
17
+ colors = Array.new(ELEMENTS) { |index| Utilities.random_color(hash + index, input.colors) }
18
+ content = [
19
+ element(
20
+ "path",
21
+ { "fill" => "url(##{ids[:gradient_0]})", "d" => "M0 0h80v40H0z" }
22
+ ),
23
+ element(
24
+ "path",
25
+ { "fill" => "url(##{ids[:gradient_1]})", "d" => "M0 40h80v40H0z" }
26
+ )
27
+ ]
28
+ defs = [
29
+ gradient(ids[:gradient_0], 0, SIZE / 2, colors[0], colors[1]),
30
+ gradient(ids[:gradient_1], SIZE / 2, SIZE, colors[2], colors[3])
31
+ ]
32
+
33
+ VariantResult.new(size: SIZE, content: content, defs: defs)
34
+ end
35
+
36
+ def gradient(id, y1, y2, start_color, end_color)
37
+ element(
38
+ "linearGradient",
39
+ {
40
+ "id" => id,
41
+ "x1" => SIZE / 2,
42
+ "y1" => y1,
43
+ "x2" => SIZE / 2,
44
+ "y2" => y2,
45
+ "gradientUnits" => "userSpaceOnUse"
46
+ },
47
+ [
48
+ element("stop", { "stop-color" => start_color }),
49
+ element("stop", { "offset" => 1, "stop-color" => end_color })
50
+ ]
51
+ )
52
+ end
53
+ private_class_method :gradient
54
+ end
55
+ end
56
+ end
57
+