placeholder-image 1.0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +107 -0
- data/lib/placeholder_image/middleware.rb +161 -0
- data/lib/placeholder_image/renderer.rb +146 -0
- data/lib/placeholder_image/version.rb +5 -0
- data/lib/placeholder_image.rb +5 -0
- data/sig/placeholder_image/middleware.rbs +44 -0
- data/sig/placeholder_image/renderer.rbs +53 -0
- data/sig/placeholder_image/version.rbs +3 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1b9fc489b1812d0f1f82cbc9bfa765d6e01cdba612da2697a416bbcbc460ae0e
|
|
4
|
+
data.tar.gz: 1e0d6fe2e8fd81ca37bad24219771fd4125190e92d1f0a9cdc0b216b526ef7d5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 28682fa1cfa37908f42663ba841ff50de20dcb578d7cbeabd8eb09b46e32bd67b92d1d2cabcb8010d1a1cba1469f3de68b43b690b0858fbcb5f65797a749d236
|
|
7
|
+
data.tar.gz: ddd2662d54dbdf2bd81125763a3ccf9cf4c927e57e32a2952cab2e47f76f446f13ffa3c959b9ceab41d04a19443a552f1c6a8a4e38868fc9ec8c4e9d1337a877
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented here. This project follows
|
|
4
|
+
[Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## v1.0.0 - 2026-08-05
|
|
7
|
+
|
|
8
|
+
- Initial release. Includes PNG-generating Rack middleware, stand-alone Docker service, and complete examples of integrating with Sinatra, Rails, and generic Rack applications.
|
|
9
|
+
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rodney Waldhoff and Placeholder Image contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Placeholder Image
|
|
2
|
+
|
|
3
|
+
A dependency-light Rack middleware that generates simple placeholder images in pure Ruby (stdlib zlib only, no image gem dependencies).
|
|
4
|
+
|
|
5
|
+
[](https://github.com/rodw/placeholder-image-rb/actions/workflows/ci.yml)
|
|
6
|
+
[](https://rubygems.org/gems/placeholder-image)
|
|
7
|
+
[](https://www.ruby-lang.org)
|
|
8
|
+
[](LICENSE.txt)
|
|
9
|
+
|
|
10
|
+
<p>
|
|
11
|
+
<img
|
|
12
|
+
src="https://raw.githubusercontent.com/rodw/placeholder-image-rb/main/examples/images/example-100x160.png"
|
|
13
|
+
alt="A 100 by 160 pixel placeholder using the default colors"
|
|
14
|
+
width="100"
|
|
15
|
+
align="middle"
|
|
16
|
+
title="/placeholder/100x160.png">
|
|
17
|
+
<img
|
|
18
|
+
src="https://raw.githubusercontent.com/rodw/placeholder-image-rb/main/examples/images/example-640x360-bg1D3557-fgF1FAEE.png"
|
|
19
|
+
alt="A 640 by 360 pixel placeholder with custom colors"
|
|
20
|
+
width="320"
|
|
21
|
+
align="middle"
|
|
22
|
+
title="/placeholder/640x360.png?bg=1d3557&fg=f1faee">
|
|
23
|
+
<img
|
|
24
|
+
src="https://raw.githubusercontent.com/rodw/placeholder-image-rb/main/examples/images/example-120-bg000-fgF90.png"
|
|
25
|
+
alt="A 120-pixel square placeholder with custom colors"
|
|
26
|
+
width="120"
|
|
27
|
+
align="middle"
|
|
28
|
+
title="/placeholder/120.png?bg=000&fg=f90">
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
Placeholder-Image requires Ruby 3.2+ and Rack 3.
|
|
34
|
+
|
|
35
|
+
Add it to your bundle:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
gem "placeholder-image"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then install:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
bundle install
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Using
|
|
48
|
+
|
|
49
|
+
Placeholder-Image runs as standard Rack middleware:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
require "placeholder_image"
|
|
53
|
+
|
|
54
|
+
use PlaceholderImage::Middleware, path_prefix: "/placeholder"
|
|
55
|
+
run MyApp
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The URL path specifies a square or rectangular image, with optional background and foreground colors as 3- or 6-digit hex codes:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
/placeholder/300.png
|
|
62
|
+
/placeholder/640x480.png
|
|
63
|
+
/placeholder/640x480.png?bg=eee&fg=1d3557
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
See [the examples](https://github.com/rodw/placeholder-image-rb/tree/main/examples) for complete demonstrations of integrating placeholder-image with Sinatra, Rails, and vanilla Rack applications.
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
Pass configuration options as keyword arguments when adding the middleware:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
use PlaceholderImage::Middleware,
|
|
74
|
+
path_prefix: "/placeholder",
|
|
75
|
+
http_header_cache_control: "public, max-age=31536000, immutable",
|
|
76
|
+
image_max_dim_px: 4_000,
|
|
77
|
+
image_max_total_px: 4_000 * 4_000,
|
|
78
|
+
image_default_bg: "#eeeeee",
|
|
79
|
+
image_default_fg: "#909",
|
|
80
|
+
cache_max_entries: 128
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| Option | Default | Description |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| `path_prefix` | `/placeholder` | URL path prefix under which generated images are served. For example, the default serves `/placeholder/300.png`. A trailing slash is optional. |
|
|
86
|
+
| `http_header_cache_control` | `public, max-age=31536000, immutable` | Value of the `Cache-Control` response header. The default allows public caches to retain an image for one year. |
|
|
87
|
+
| `image_max_dim_px` | `4000` | Maximum permitted width or height, in pixels. Requests exceeding this limit return `400 Bad Request`. |
|
|
88
|
+
| `image_max_total_px` | `16000000` | Maximum permitted total pixel count (`width * height`). This limits the CPU time and memory consumed by a single image. Requests exceeding this limit return `400 Bad Request`. |
|
|
89
|
+
| `image_default_bg` | `[0xEE,0xEE,0xEE]` | Default background color as an RGB byte array or a 3- or 6-digit hex string, optionally beginning with `#`. The request's `bg` parameter overrides it. |
|
|
90
|
+
| `image_default_fg` | `[0x99,0x99,0x99]`| Default foreground color used for the border and dimension label. It accepts the same array and hex-string formats as `image_default_bg`; the request's `fg` parameter overrides it. |
|
|
91
|
+
| `cache_max_entries` | `128` | Maximum number of generated images retained in each middleware instance's in-memory FIFO cache. Set to `0` to disable caching. |
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
## Running as a Stand-Alone Container
|
|
95
|
+
|
|
96
|
+
Placeholder-Image can be run as a stand-alone, containerized service.
|
|
97
|
+
|
|
98
|
+
See the [Docker server documentation](https://github.com/rodw/placeholder-image-rb/blob/main/docker/README.md) for details.
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
For development setup and contribution guidelines, see
|
|
103
|
+
[CONTRIBUTING.md](https://github.com/rodw/placeholder-image-rb/blob/main/CONTRIBUTING.md).
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
Placeholder-Image is available under the [MIT License](https://github.com/rodw/placeholder-image-rb/blob/main/LICENSE.txt).
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
# Rack middleware that serves generated placeholder PNGs.
|
|
7
|
+
#
|
|
8
|
+
# use PlaceholderImage::Middleware, path_prefix: "/placeholder"
|
|
9
|
+
#
|
|
10
|
+
# Examples:
|
|
11
|
+
# GET /placeholder/300.png -> 300x300, default colors
|
|
12
|
+
# GET /placeholder/640x480.png -> 640x480, default colors
|
|
13
|
+
# GET /placeholder/300x200.png?bg=eee&fg=999 -> 300x200, specified colors
|
|
14
|
+
#
|
|
15
|
+
# No image gems required: PNGs are encoded with stdlib zlib.
|
|
16
|
+
module PlaceholderImage
|
|
17
|
+
class BadRequest < StandardError; end
|
|
18
|
+
|
|
19
|
+
# ------------------------------------------------------------- Middleware --
|
|
20
|
+
class Middleware
|
|
21
|
+
DEFAULTS = {
|
|
22
|
+
path_prefix: "/placeholder",
|
|
23
|
+
image_max_dim_px: 4_000,
|
|
24
|
+
image_max_total_px: 4_000 * 4_000,
|
|
25
|
+
http_header_cache_control: "public, max-age=31536000, immutable",
|
|
26
|
+
cache_max_entries: 128,
|
|
27
|
+
image_default_bg: [0xEE, 0xEE, 0xEE],
|
|
28
|
+
image_default_fg: [0x99, 0x99, 0x99]
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
DIMENSION = /[1-9]\d*/
|
|
32
|
+
|
|
33
|
+
# @param app [#call] the downstream Rack application.
|
|
34
|
+
# @param options [Hash] configuration overrides merged over {DEFAULTS}.
|
|
35
|
+
# @option options [String] :path_prefix ("/placeholder") URL prefix the middleware serves.
|
|
36
|
+
# @option options [Integer] :image_max_dim_px (4000) maximum width or height, in pixels.
|
|
37
|
+
# @option options [Integer] :image_max_total_px (16_000_000) maximum total pixel count.
|
|
38
|
+
# @option options [String] :http_header_cache_control the +Cache-Control+ response header value.
|
|
39
|
+
# @option options [Integer] :cache_max_entries (128) in-memory FIFO cache size; +0+ disables caching.
|
|
40
|
+
# @option options [Array(Integer, Integer, Integer), String] :image_default_bg default background color.
|
|
41
|
+
# @option options [Array(Integer, Integer, Integer), String] :image_default_fg default foreground color.
|
|
42
|
+
def initialize(app, **options)
|
|
43
|
+
@app = app
|
|
44
|
+
@options = DEFAULTS.merge(options)
|
|
45
|
+
@cache = {}
|
|
46
|
+
@mutex = Mutex.new
|
|
47
|
+
|
|
48
|
+
path_prefix = Regexp.escape(@options[:path_prefix].chomp("/"))
|
|
49
|
+
@owned = %r{\A#{path_prefix}(?:[./]|\z)}
|
|
50
|
+
@route = %r{\A#{path_prefix}/(#{DIMENSION})(?:x(#{DIMENSION}))?\.png\z}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Rack entry point. Serves a PNG for owned paths and delegates everything else downstream.
|
|
54
|
+
#
|
|
55
|
+
# @param env [Hash] the Rack environment.
|
|
56
|
+
# @return [Array(Integer, Hash, Array)] a Rack response tuple.
|
|
57
|
+
def call(env)
|
|
58
|
+
return @app.call(env) unless @owned.match?(env["PATH_INFO"])
|
|
59
|
+
return error(env["REQUEST_METHOD"], 405, "method not allowed") unless %w[GET HEAD].include?(env["REQUEST_METHOD"])
|
|
60
|
+
|
|
61
|
+
spec = parse(env["PATH_INFO"], env["QUERY_STRING"].to_s)
|
|
62
|
+
etag = etag_for(spec)
|
|
63
|
+
return [304, cache_headers(etag), []] if fresh?(env, etag)
|
|
64
|
+
|
|
65
|
+
img = fetch(spec)
|
|
66
|
+
body = env["REQUEST_METHOD"] == "HEAD" ? [] : [img]
|
|
67
|
+
|
|
68
|
+
[200, cache_headers(etag).merge(
|
|
69
|
+
"content-type" => "image/png",
|
|
70
|
+
"content-length" => img.bytesize.to_s
|
|
71
|
+
), body]
|
|
72
|
+
rescue BadRequest => e
|
|
73
|
+
error(env["REQUEST_METHOD"], 400, e.message)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def cache_headers(etag)
|
|
79
|
+
{ "etag" => etag,
|
|
80
|
+
"cache-control" => @options[:http_header_cache_control] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def fresh?(env, etag)
|
|
84
|
+
env["HTTP_IF_NONE_MATCH"].to_s.split(",").map(&:strip).include?(etag)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def etag_for(spec)
|
|
88
|
+
%("#{Digest::SHA256.hexdigest(spec.inspect)[0, 16]}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def error(req_method, status, message)
|
|
92
|
+
body = req_method == "HEAD" ? "" : "#{message}\n"
|
|
93
|
+
|
|
94
|
+
[
|
|
95
|
+
status,
|
|
96
|
+
{ "content-type" => "text/plain; charset=utf-8",
|
|
97
|
+
"content-length" => body.bytesize.to_s },
|
|
98
|
+
[body]
|
|
99
|
+
]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def fetch(spec)
|
|
103
|
+
@mutex.synchronize { return @cache[spec] if @cache.key?(spec) }
|
|
104
|
+
|
|
105
|
+
img = Renderer.call(**spec)
|
|
106
|
+
|
|
107
|
+
@mutex.synchronize do
|
|
108
|
+
@cache[spec] = img if @options[:cache_max_entries].positive?
|
|
109
|
+
@cache.shift while @cache.size > @options[:cache_max_entries] # FIFO eviction
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
img
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse(path, query)
|
|
116
|
+
width, height = parse_dimensions(path)
|
|
117
|
+
params = parse_query_string(query)
|
|
118
|
+
|
|
119
|
+
{ width: width,
|
|
120
|
+
height: height,
|
|
121
|
+
bg: parse_color(params["bg"], @options[:image_default_bg]),
|
|
122
|
+
fg: parse_color(params["fg"], @options[:image_default_fg]),
|
|
123
|
+
text: "#{width}x#{height}" }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def parse_dimensions(path)
|
|
127
|
+
match = @route.match(path) or
|
|
128
|
+
raise BadRequest, "no such image; expected #{@options[:path_prefix]}/<SIZE>.png or " \
|
|
129
|
+
"#{@options[:path_prefix]}/<W>x<H>.png"
|
|
130
|
+
|
|
131
|
+
width = match[1].to_i
|
|
132
|
+
height = (match[2] || match[1]).to_i
|
|
133
|
+
max = @options[:image_max_dim_px]
|
|
134
|
+
|
|
135
|
+
raise BadRequest, "dimensions must be between 1 and #{max}" if width > max || height > max
|
|
136
|
+
raise BadRequest, "image exceeds #{@options[:image_max_total_px]} pixels" if
|
|
137
|
+
width * height > @options[:image_max_total_px]
|
|
138
|
+
|
|
139
|
+
[width, height]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def parse_query_string(query)
|
|
143
|
+
return {} if query.empty?
|
|
144
|
+
|
|
145
|
+
URI.decode_www_form(query).to_h
|
|
146
|
+
rescue ArgumentError
|
|
147
|
+
raise BadRequest, "malformed query string"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def parse_color(value, default)
|
|
151
|
+
value = default if value.nil? || value.empty?
|
|
152
|
+
return value if value.is_a?(Array)
|
|
153
|
+
|
|
154
|
+
hex = value.delete_prefix("#")
|
|
155
|
+
hex = hex.chars.map { |c| c * 2 }.join if hex.length == 3
|
|
156
|
+
raise BadRequest, "invalid color: #{value}" unless hex.match?(/\A\h{6}\z/)
|
|
157
|
+
|
|
158
|
+
[hex[0, 2], hex[2, 2], hex[4, 2]].map { |pair| pair.to_i(16) }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module PlaceholderImage
|
|
6
|
+
# Generates a placeholder PNG from dimensions, colors, and label text.
|
|
7
|
+
module Renderer
|
|
8
|
+
LABEL_WIDTH_PCT = 0.80
|
|
9
|
+
LABEL_HEIGHT_PCT = 0.50
|
|
10
|
+
|
|
11
|
+
# Minimal 8-bit truecolor PNG encoder.
|
|
12
|
+
class Canvas
|
|
13
|
+
SIGNATURE = "\x89PNG\r\n\x1A\n".b
|
|
14
|
+
|
|
15
|
+
attr_reader :width, :height
|
|
16
|
+
|
|
17
|
+
def initialize(width, height, rgb)
|
|
18
|
+
@width = width
|
|
19
|
+
@height = height
|
|
20
|
+
@row_bytes = width * 3
|
|
21
|
+
@data = (rgb.pack("C3") * (width * height)).b
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def []=(x, y, rgb)
|
|
25
|
+
return unless x.between?(0, @width - 1) && y.between?(0, @height - 1)
|
|
26
|
+
|
|
27
|
+
i = ((y * @width) + x) * 3
|
|
28
|
+
@data.setbyte(i, rgb[0])
|
|
29
|
+
@data.setbyte(i + 1, rgb[1])
|
|
30
|
+
@data.setbyte(i + 2, rgb[2])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def fill_rect(x, y, width, height, rgb)
|
|
34
|
+
y.upto(y + height - 1) do |py|
|
|
35
|
+
x.upto(x + width - 1) { |px| self[px, py] = rgb }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_png
|
|
40
|
+
raw = String.new(capacity: (@row_bytes + 1) * @height, encoding: Encoding::BINARY)
|
|
41
|
+
@height.times { |y| raw << 0.chr << @data.byteslice(y * @row_bytes, @row_bytes) }
|
|
42
|
+
ihdr = [@width, @height, 8, 2, 0, 0, 0].pack("NNC5") # 8-bit, color type 2 (RGB)
|
|
43
|
+
|
|
44
|
+
SIGNATURE +
|
|
45
|
+
chunk("IHDR", ihdr) +
|
|
46
|
+
chunk("IDAT", Zlib::Deflate.deflate(raw, Zlib::BEST_SPEED)) +
|
|
47
|
+
chunk("IEND", "".b)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def chunk(type, data)
|
|
53
|
+
type = type.b
|
|
54
|
+
[data.bytesize].pack("N") + type + data + [Zlib.crc32(type + data)].pack("N")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# 5x7 bitmap font, just enough to render dimension labels such as "1024x768".
|
|
59
|
+
module Font
|
|
60
|
+
FONT_WIDTH = 5
|
|
61
|
+
FONT_HEIGHT = 7
|
|
62
|
+
|
|
63
|
+
GLYPHS = {
|
|
64
|
+
"0" => %w[01110 10001 10011 10101 11001 10001 01110],
|
|
65
|
+
"1" => %w[00100 01100 00100 00100 00100 00100 01110],
|
|
66
|
+
"2" => %w[01110 10001 00001 00010 00100 01000 11111],
|
|
67
|
+
"3" => %w[11111 00010 00100 00010 00001 10001 01110],
|
|
68
|
+
"4" => %w[00010 00110 01010 10010 11111 00010 00010],
|
|
69
|
+
"5" => %w[11111 10000 11110 00001 00001 10001 01110],
|
|
70
|
+
"6" => %w[00110 01000 10000 11110 10001 10001 01110],
|
|
71
|
+
"7" => %w[11111 00001 00010 00100 01000 01000 01000],
|
|
72
|
+
"8" => %w[01110 10001 10001 01110 10001 10001 01110],
|
|
73
|
+
"9" => %w[01110 10001 10001 01111 00001 00010 01100],
|
|
74
|
+
"x" => %w[00000 00000 10001 01010 00100 01010 10001]
|
|
75
|
+
}.freeze
|
|
76
|
+
|
|
77
|
+
def self.advance(scale) = (FONT_WIDTH + 1) * scale
|
|
78
|
+
def self.text_width(text, scale) = text.empty? ? 0 : (advance(scale) * text.length) - scale
|
|
79
|
+
|
|
80
|
+
def self.draw(canvas, text, x, y, scale, color)
|
|
81
|
+
text.each_char.with_index do |char, i|
|
|
82
|
+
glyph = GLYPHS[char] or next
|
|
83
|
+
ox = x + (advance(scale) * i)
|
|
84
|
+
glyph.each_with_index do |row, ry|
|
|
85
|
+
row.each_char.with_index do |bit, rx|
|
|
86
|
+
next if bit == "0"
|
|
87
|
+
|
|
88
|
+
canvas.fill_rect(ox + (rx * scale), y + (ry * scale), scale, scale, color)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
module_function
|
|
96
|
+
|
|
97
|
+
# Render a placeholder PNG.
|
|
98
|
+
#
|
|
99
|
+
# @param width [Integer] image width in pixels.
|
|
100
|
+
# @param height [Integer] image height in pixels.
|
|
101
|
+
# @param bg [Array(Integer, Integer, Integer)] background color as RGB bytes.
|
|
102
|
+
# @param fg [Array(Integer, Integer, Integer)] foreground color, used for the border and label.
|
|
103
|
+
# @param text [String] label drawn centered on the image (digits and "x" only).
|
|
104
|
+
# @return [String] binary PNG data.
|
|
105
|
+
# @example
|
|
106
|
+
# Renderer.call(width: 320, height: 240, bg: [238, 238, 238], fg: [153, 153, 153], text: "320x240")
|
|
107
|
+
def call(width:, height:, bg:, fg:, text:)
|
|
108
|
+
canvas = Canvas.new(width, height, bg)
|
|
109
|
+
draw_border(canvas, fg)
|
|
110
|
+
draw_label(canvas, text, fg)
|
|
111
|
+
canvas.to_png
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def draw_border(canvas, color)
|
|
115
|
+
width = canvas.width
|
|
116
|
+
height = canvas.height
|
|
117
|
+
thickness = ([width, height].min / 40).clamp(1, 6)
|
|
118
|
+
return if width < 4 * thickness || height < 4 * thickness
|
|
119
|
+
|
|
120
|
+
canvas.fill_rect(0, 0, width, thickness, color)
|
|
121
|
+
canvas.fill_rect(0, height - thickness, width, thickness, color)
|
|
122
|
+
canvas.fill_rect(0, 0, thickness, height, color)
|
|
123
|
+
canvas.fill_rect(width - thickness, 0, thickness, height, color)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def draw_label(canvas, text, color)
|
|
127
|
+
return if text.empty?
|
|
128
|
+
|
|
129
|
+
natural_width = Font.text_width(text, 1)
|
|
130
|
+
scale = [(canvas.width * LABEL_WIDTH_PCT / natural_width).floor,
|
|
131
|
+
(canvas.height * LABEL_HEIGHT_PCT / Font::FONT_HEIGHT).floor].min
|
|
132
|
+
return if scale < 1
|
|
133
|
+
|
|
134
|
+
Font.draw(canvas,
|
|
135
|
+
text,
|
|
136
|
+
(canvas.width - Font.text_width(text, scale)) / 2,
|
|
137
|
+
(canvas.height - (Font::FONT_HEIGHT * scale)) / 2,
|
|
138
|
+
scale,
|
|
139
|
+
color)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private_class_method :draw_border, :draw_label
|
|
143
|
+
|
|
144
|
+
private_constant :Canvas, :Font
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module PlaceholderImage
|
|
2
|
+
# Raised for any client error; converted to an HTTP 400 by the middleware.
|
|
3
|
+
class BadRequest < StandardError
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
# Rack middleware that serves generated placeholder PNGs.
|
|
7
|
+
class Middleware
|
|
8
|
+
type rack_response = [ Integer, Hash[String, String], Array[String] ]
|
|
9
|
+
|
|
10
|
+
DEFAULTS: Hash[Symbol, untyped]
|
|
11
|
+
DIMENSION: Regexp
|
|
12
|
+
|
|
13
|
+
@app: untyped
|
|
14
|
+
@options: Hash[Symbol, untyped]
|
|
15
|
+
@cache: Hash[untyped, untyped]
|
|
16
|
+
@mutex: Thread::Mutex
|
|
17
|
+
@owned: Regexp
|
|
18
|
+
@route: Regexp
|
|
19
|
+
|
|
20
|
+
def initialize: (untyped app, **untyped options) -> void
|
|
21
|
+
|
|
22
|
+
def call: (Hash[String, untyped] env) -> rack_response
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def cache_headers: (String etag) -> Hash[String, String]
|
|
27
|
+
|
|
28
|
+
def fresh?: (Hash[String, untyped] env, String etag) -> bool
|
|
29
|
+
|
|
30
|
+
def etag_for: (untyped spec) -> String
|
|
31
|
+
|
|
32
|
+
def error: (untyped req_method, Integer status, String message) -> rack_response
|
|
33
|
+
|
|
34
|
+
def fetch: (untyped spec) -> String
|
|
35
|
+
|
|
36
|
+
def parse: (untyped path, untyped query) -> Hash[Symbol, untyped]
|
|
37
|
+
|
|
38
|
+
def parse_dimensions: (untyped path) -> [ Integer, Integer ]
|
|
39
|
+
|
|
40
|
+
def parse_query_string: (untyped query) -> Hash[String, String]
|
|
41
|
+
|
|
42
|
+
def parse_color: (untyped value, untyped default) -> Array[Integer]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module PlaceholderImage
|
|
2
|
+
# Generates a placeholder PNG from dimensions, colors, and label text.
|
|
3
|
+
module Renderer
|
|
4
|
+
type rgb = Array[Integer]
|
|
5
|
+
|
|
6
|
+
LABEL_WIDTH_PCT: Float
|
|
7
|
+
LABEL_HEIGHT_PCT: Float
|
|
8
|
+
|
|
9
|
+
# Minimal 8-bit truecolor PNG encoder.
|
|
10
|
+
class Canvas
|
|
11
|
+
SIGNATURE: String
|
|
12
|
+
|
|
13
|
+
attr_reader width: Integer
|
|
14
|
+
attr_reader height: Integer
|
|
15
|
+
|
|
16
|
+
@width: Integer
|
|
17
|
+
@height: Integer
|
|
18
|
+
@row_bytes: Integer
|
|
19
|
+
@data: String
|
|
20
|
+
|
|
21
|
+
def initialize: (Integer width, Integer height, PlaceholderImage::Renderer::rgb rgb) -> void
|
|
22
|
+
|
|
23
|
+
def []=: (Integer x, Integer y, PlaceholderImage::Renderer::rgb rgb) -> void
|
|
24
|
+
|
|
25
|
+
def fill_rect: (Integer x, Integer y, Integer width, Integer height, PlaceholderImage::Renderer::rgb rgb) -> void
|
|
26
|
+
|
|
27
|
+
def to_png: () -> String
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def chunk: (String type, String data) -> String
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# 5x7 bitmap font, just enough to render dimension labels such as "1024x768".
|
|
35
|
+
module Font
|
|
36
|
+
FONT_WIDTH: Integer
|
|
37
|
+
FONT_HEIGHT: Integer
|
|
38
|
+
GLYPHS: Hash[String, Array[String]]
|
|
39
|
+
|
|
40
|
+
def self.advance: (Integer scale) -> Integer
|
|
41
|
+
|
|
42
|
+
def self.text_width: (String text, Integer scale) -> Integer
|
|
43
|
+
|
|
44
|
+
def self.draw: (PlaceholderImage::Renderer::Canvas canvas, String text, Integer x, Integer y, Integer scale, PlaceholderImage::Renderer::rgb color) -> void
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self?.call: (width: Integer, height: Integer, bg: PlaceholderImage::Renderer::rgb, fg: PlaceholderImage::Renderer::rgb, text: String) -> String
|
|
48
|
+
|
|
49
|
+
def self?.draw_border: (PlaceholderImage::Renderer::Canvas canvas, PlaceholderImage::Renderer::rgb color) -> void
|
|
50
|
+
|
|
51
|
+
def self?.draw_label: (PlaceholderImage::Renderer::Canvas canvas, String text, PlaceholderImage::Renderer::rgb color) -> void
|
|
52
|
+
end
|
|
53
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: placeholder-image
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Rodney Waldhoff
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '4.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '3.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '4.0'
|
|
32
|
+
description: A small, dependency-light Rack middleware that generates simple placeholder
|
|
33
|
+
images.
|
|
34
|
+
executables: []
|
|
35
|
+
extensions: []
|
|
36
|
+
extra_rdoc_files: []
|
|
37
|
+
files:
|
|
38
|
+
- CHANGELOG.md
|
|
39
|
+
- LICENSE.txt
|
|
40
|
+
- README.md
|
|
41
|
+
- lib/placeholder_image.rb
|
|
42
|
+
- lib/placeholder_image/middleware.rb
|
|
43
|
+
- lib/placeholder_image/renderer.rb
|
|
44
|
+
- lib/placeholder_image/version.rb
|
|
45
|
+
- sig/placeholder_image/middleware.rbs
|
|
46
|
+
- sig/placeholder_image/renderer.rbs
|
|
47
|
+
- sig/placeholder_image/version.rbs
|
|
48
|
+
homepage: https://github.com/rodw/placeholder-image-rb
|
|
49
|
+
licenses:
|
|
50
|
+
- MIT
|
|
51
|
+
metadata:
|
|
52
|
+
bug_tracker_uri: https://github.com/rodw/placeholder-image-rb/issues
|
|
53
|
+
changelog_uri: https://github.com/rodw/placeholder-image-rb/blob/main/CHANGELOG.md
|
|
54
|
+
documentation_uri: https://rubydoc.info/gems/placeholder-image
|
|
55
|
+
homepage_uri: https://github.com/rodw/placeholder-image-rb
|
|
56
|
+
source_code_uri: https://github.com/rodw/placeholder-image-rb
|
|
57
|
+
rubygems_mfa_required: 'true'
|
|
58
|
+
rdoc_options: []
|
|
59
|
+
require_paths:
|
|
60
|
+
- lib
|
|
61
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '3.2'
|
|
66
|
+
- - "<"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '5.0'
|
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '0'
|
|
74
|
+
requirements: []
|
|
75
|
+
rubygems_version: 3.6.9
|
|
76
|
+
specification_version: 4
|
|
77
|
+
summary: Rack middleware for generating placeholder images
|
|
78
|
+
test_files: []
|