pictify 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 +59 -0
- data/LICENSE +21 -0
- data/README.md +284 -0
- data/lib/pictify/client.rb +367 -0
- data/lib/pictify/errors.rb +170 -0
- data/lib/pictify/types.rb +256 -0
- data/lib/pictify/version.rb +5 -0
- data/lib/pictify.rb +26 -0
- metadata +147 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 61ccf483d399d895aed8467dd41ba36464a70b237c370cd03eb4a14911602a17
|
|
4
|
+
data.tar.gz: 168e24019d0d4e03c56866316a1557e397ed3c643d570ba6a9a0df49ddd2f601
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 128158b9198f1a6388efad4a18152d0d563d9865b1ac306da1a280e8ad4e89882191cf912eb9a59d3ce4e1485e838861146de50a28ee55b2a52258dc1feb2c6e
|
|
7
|
+
data.tar.gz: f50e95a9dbb6e177021d512696be627a986f40e7b519712e2b994f2a920da6dae30832ccd6b8274dbb159e91308503c2de6d21ac4f94fd29a34535bfeb027d64
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.0.0] - 2026-06-08
|
|
9
|
+
|
|
10
|
+
### Changed (BREAKING)
|
|
11
|
+
|
|
12
|
+
- Re-pointed the entire SDK to the real Pictify API. The previous `/v1/render*`
|
|
13
|
+
endpoints did not exist.
|
|
14
|
+
- `render_html(html:, css:, width:, height:, selector:, format:)` now posts to
|
|
15
|
+
`POST /image` and returns an `ImageResult` (`url`, `id`, `created_at`). `css`
|
|
16
|
+
is injected as a `<style>` tag; `format` maps to `fileExtension`.
|
|
17
|
+
- `render_url(url:, ...)` added — screenshots a live URL via `POST /image`.
|
|
18
|
+
- `render(template_id:, variables:, format:, quality:, width:, height:, layout:, layouts:)`
|
|
19
|
+
posts to `POST /templates/:uid/render` and returns a `RenderResult` with a
|
|
20
|
+
`results` envelope; `result.url` returns the first result's URL.
|
|
21
|
+
- `render_layouts(template_id:, layouts:, ...)` now takes explicit layout names
|
|
22
|
+
(max 20); failed layouts are returned in `errors`.
|
|
23
|
+
- `render_gif(html:|url:|template_id:, variables:, width:, height:, quality:)`
|
|
24
|
+
posts to `POST /gif`, uses the `template` body key, accepts `quality`
|
|
25
|
+
(`:low`/`:medium`/`:high`), and flattens the `{ gif: {...} }` envelope into a
|
|
26
|
+
`GifRenderResult`.
|
|
27
|
+
- `render_batch(template_id:, variable_sets:, format:, quality:, concurrency:, layout:, layouts:)`
|
|
28
|
+
is now asynchronous — posts to `POST /templates/:uid/batch-render` and returns
|
|
29
|
+
`BatchRenderResult` (`batch_id`, `status`, `total_items`).
|
|
30
|
+
- `get_batch_results(batch_id)` added — polls `GET /templates/batch/:id/results`.
|
|
31
|
+
Per-item URLs are delivered via the `render.completed` webhook, not the poll.
|
|
32
|
+
- `get_template(template_id)` unwraps the `{ template }` envelope; templates are
|
|
33
|
+
keyed by `uid` with `variable_definitions`.
|
|
34
|
+
- `list_templates(page:, limit:, sort:)` returns `ListTemplatesResult`
|
|
35
|
+
(`templates`, `pagination`).
|
|
36
|
+
- `create_template(html:, name:, width:, height:, variable_definitions:, output_format:)`
|
|
37
|
+
added — `POST /templates`.
|
|
38
|
+
- Error mapping updated: 422 → `RenderError` (carries `errors`), 5xx →
|
|
39
|
+
`ServerError`, 429 with `quota_exceeded` → `QuotaExceededError`. Only 5xx and
|
|
40
|
+
network failures are retried; 4xx (including 429) are never retried.
|
|
41
|
+
|
|
42
|
+
### Removed (BREAKING)
|
|
43
|
+
|
|
44
|
+
- `render_stream` — no endpoint streams raw image bytes.
|
|
45
|
+
- Legacy image fields (`image_url`, `render_id`, `device_scale_factor`,
|
|
46
|
+
`transparent`, `download`) and the frame-based GIF API.
|
|
47
|
+
|
|
48
|
+
## [1.0.0] - 2026-01-23
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- Initial release
|
|
53
|
+
- `Pictify::Client` with render, render_html, render_gif, stream, batch, and template methods
|
|
54
|
+
- Comprehensive error handling with specific error types
|
|
55
|
+
- Automatic retry with exponential backoff
|
|
56
|
+
- Support for all image formats (PNG, JPG, JPEG, WebP, GIF, PDF)
|
|
57
|
+
- HTML render endpoint for rendering raw HTML directly
|
|
58
|
+
- GIF render endpoint for creating animated GIFs
|
|
59
|
+
- Rails and Sinatra integration examples
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pictify
|
|
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,284 @@
|
|
|
1
|
+
# Pictify Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby SDK for [Pictify](https://pictify.io) — generate images, PDFs, and GIFs from raw HTML, live URLs, and reusable templates.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "pictify"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or install directly:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
gem install pictify
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "pictify"
|
|
29
|
+
|
|
30
|
+
client = Pictify::Client.new(api_key: "your-api-key")
|
|
31
|
+
|
|
32
|
+
# Render raw HTML to a PNG
|
|
33
|
+
image = client.render_html(
|
|
34
|
+
html: "<div style='font-size:48px;padding:40px'>Hello World</div>",
|
|
35
|
+
width: 1200,
|
|
36
|
+
height: 630
|
|
37
|
+
)
|
|
38
|
+
puts image.url
|
|
39
|
+
|
|
40
|
+
# Render a template
|
|
41
|
+
result = client.render(
|
|
42
|
+
template_id: "your-template-uid",
|
|
43
|
+
variables: { name: "Ada", company: "Pictify" }
|
|
44
|
+
)
|
|
45
|
+
puts result.url
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
client = Pictify::Client.new(
|
|
52
|
+
api_key: "your-api-key",
|
|
53
|
+
base_url: "https://api.pictify.io", # Custom API URL
|
|
54
|
+
timeout: 30, # Request timeout in seconds
|
|
55
|
+
max_retries: 3 # Max retry attempts (5xx / network only)
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Rendering Images
|
|
60
|
+
|
|
61
|
+
### From HTML — `POST /image`
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
image = client.render_html(
|
|
65
|
+
html: "<div>Hello</div>",
|
|
66
|
+
css: "div { color: blue; }", # injected as a <style> tag before rendering
|
|
67
|
+
width: 1200, # default 1280
|
|
68
|
+
height: 630, # default 720
|
|
69
|
+
selector: "#card", # crop to a specific element (optional)
|
|
70
|
+
format: :png # :png, :jpg, :jpeg, :webp, :pdf — mapped to fileExtension
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
puts image.url # CDN URL
|
|
74
|
+
puts image.id # image id
|
|
75
|
+
puts image.created_at # ISO timestamp
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### From a live URL (screenshot) — `POST /image`
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
image = client.render_url(
|
|
82
|
+
url: "https://example.com",
|
|
83
|
+
width: 1280,
|
|
84
|
+
height: 720,
|
|
85
|
+
selector: "#main", # optional
|
|
86
|
+
format: :png # optional
|
|
87
|
+
)
|
|
88
|
+
puts image.url
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Rendering Templates
|
|
92
|
+
|
|
93
|
+
### Single render — `POST /templates/:uid/render`
|
|
94
|
+
|
|
95
|
+
Returns a `results` array (one item per rendered layout). `result.url` is a
|
|
96
|
+
convenience accessor for the first result's URL.
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
result = client.render(
|
|
100
|
+
template_id: "your-template-uid",
|
|
101
|
+
variables: { title: "My Post", author: "Ada" },
|
|
102
|
+
format: :png, # :png, :jpg, :jpeg, :webp, :pdf
|
|
103
|
+
quality: 0.9, # raster quality 0.1–1.0
|
|
104
|
+
width: 1200, # optional override
|
|
105
|
+
height: 630 # optional override
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
puts result.url # results.first.url
|
|
109
|
+
puts result.template_uid
|
|
110
|
+
result.results.each do |item|
|
|
111
|
+
puts "#{item.layout}: #{item.url} (#{item.width}x#{item.height})"
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Multiple layout variants — `POST /templates/:uid/render`
|
|
116
|
+
|
|
117
|
+
Templates can have multiple layout variants (e.g. landscape, square, story)
|
|
118
|
+
created via AI Resize in the Pictify editor. Render several in one call (max 20);
|
|
119
|
+
layouts that fail land in `errors`.
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
result = client.render_layouts(
|
|
123
|
+
template_id: "your-template-uid",
|
|
124
|
+
variables: { title: "Hello World" },
|
|
125
|
+
layouts: ["default", "twitter-post", "instagram-story"]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
result.results.each do |item|
|
|
129
|
+
puts "#{item.layout}: #{item.url} (#{item.width}x#{item.height})"
|
|
130
|
+
end
|
|
131
|
+
result.errors.each do |err|
|
|
132
|
+
puts "#{err.layout} failed: #{err.error}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
puts "rendered #{result.total_rendered} of #{result.total_layouts}"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
You can also render a single named variant via `render(..., layout: "square")`.
|
|
139
|
+
|
|
140
|
+
## Rendering GIFs — `POST /gif`
|
|
141
|
+
|
|
142
|
+
Provide exactly one source: `html`, `url`, or `template_id` (+ optional
|
|
143
|
+
`variables`). The source must contain motion (CSS animation, etc.).
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
gif = client.render_gif(
|
|
147
|
+
html: "<style>@keyframes p{0%{opacity:.2}50%{opacity:1}100%{opacity:.2}}" \
|
|
148
|
+
"div{animation:p 2s infinite}</style><div>Hi</div>",
|
|
149
|
+
width: 400, # default 800
|
|
150
|
+
height: 200, # default 600
|
|
151
|
+
quality: :medium # :low, :medium, :high
|
|
152
|
+
)
|
|
153
|
+
puts gif.url
|
|
154
|
+
puts gif.uid
|
|
155
|
+
puts gif.animation_length
|
|
156
|
+
|
|
157
|
+
# From a template
|
|
158
|
+
gif = client.render_gif(template_id: "your-template-uid", variables: { name: "Ada" })
|
|
159
|
+
|
|
160
|
+
# From a live URL
|
|
161
|
+
gif = client.render_gif(url: "https://example.com/animated-page")
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Batch Rendering (async) — `POST /templates/:uid/batch-render`
|
|
165
|
+
|
|
166
|
+
Batch rendering is asynchronous. Submitting returns a `batch_id` immediately;
|
|
167
|
+
poll `get_batch_results` to track progress.
|
|
168
|
+
|
|
169
|
+
> Note: the poll endpoint reports per-item `index`, `success`, and `variables`
|
|
170
|
+
> but **not** rendered URLs — URLs are delivered via the `render.completed`
|
|
171
|
+
> webhook.
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
job = client.render_batch(
|
|
175
|
+
template_id: "your-template-uid",
|
|
176
|
+
variable_sets: [
|
|
177
|
+
{ name: "Card 1", company: "X" },
|
|
178
|
+
{ name: "Card 2", company: "Y" }
|
|
179
|
+
],
|
|
180
|
+
format: :png,
|
|
181
|
+
quality: 0.9, # optional
|
|
182
|
+
concurrency: 5, # optional, 1–10
|
|
183
|
+
layouts: ["default", "twitter-post"] # optional
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
puts job.batch_id
|
|
187
|
+
puts job.status # "pending"
|
|
188
|
+
puts job.total_items
|
|
189
|
+
|
|
190
|
+
# Poll for progress
|
|
191
|
+
results = client.get_batch_results(job.batch_id)
|
|
192
|
+
puts "status: #{results.status} (#{results.progress}%)"
|
|
193
|
+
puts "completed: #{results.completed_items} / #{results.total_items}"
|
|
194
|
+
results.results.each do |item|
|
|
195
|
+
puts "Item #{item.index}: success=#{item.success?} vars=#{item.variables}"
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Template Management
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
# Get a template by UID — GET /templates/:uid
|
|
203
|
+
template = client.get_template("your-template-uid")
|
|
204
|
+
puts "Template: #{template.name} (#{template.uid})"
|
|
205
|
+
template.variable_definitions.each do |var|
|
|
206
|
+
puts " - #{var.name} (#{var.type})"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# List templates — GET /templates
|
|
210
|
+
result = client.list_templates(page: 1, limit: 20, sort: :newest)
|
|
211
|
+
result.templates.each { |t| puts "#{t.uid}: #{t.name}" }
|
|
212
|
+
puts "total: #{result.pagination.total}"
|
|
213
|
+
|
|
214
|
+
# Create a template from HTML — POST /templates
|
|
215
|
+
# Variables are auto-discovered from {{variableName}} tokens.
|
|
216
|
+
template = client.create_template(
|
|
217
|
+
html: "<div>Hi {{firstName}}</div>",
|
|
218
|
+
name: "Welcome Card",
|
|
219
|
+
width: 600,
|
|
220
|
+
height: 200,
|
|
221
|
+
output_format: "image" # "image" | "pdf"
|
|
222
|
+
)
|
|
223
|
+
puts template.uid
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Error Handling
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
begin
|
|
230
|
+
result = client.render(template_id: "your-template-uid", variables: { name: "Ada" })
|
|
231
|
+
rescue Pictify::AuthenticationError
|
|
232
|
+
puts "Invalid API key"
|
|
233
|
+
rescue Pictify::TemplateNotFoundError => e
|
|
234
|
+
puts "Template not found: #{e.message}"
|
|
235
|
+
rescue Pictify::QuotaExceededError
|
|
236
|
+
puts "Render quota exceeded"
|
|
237
|
+
rescue Pictify::RateLimitError => e
|
|
238
|
+
puts "Rate limited. Retry after: #{e.retry_after}s"
|
|
239
|
+
rescue Pictify::RenderError => e
|
|
240
|
+
puts "Render/validation failed: #{e.message}"
|
|
241
|
+
puts e.errors # field-level validation errors when present (422)
|
|
242
|
+
rescue Pictify::ServerError => e
|
|
243
|
+
puts "Server error: #{e.message}"
|
|
244
|
+
rescue Pictify::NetworkError => e
|
|
245
|
+
puts "Network error: #{e.message}"
|
|
246
|
+
rescue Pictify::TimeoutError
|
|
247
|
+
puts "Request timed out"
|
|
248
|
+
rescue Pictify::Error => e
|
|
249
|
+
puts "Error: #{e.message}"
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
| Status | Error class |
|
|
254
|
+
|--------|-------------|
|
|
255
|
+
| 401 | `Pictify::AuthenticationError` |
|
|
256
|
+
| 402 | `Pictify::QuotaExceededError` |
|
|
257
|
+
| 404 | `Pictify::TemplateNotFoundError` |
|
|
258
|
+
| 422 | `Pictify::RenderError` (carries `errors`) |
|
|
259
|
+
| 429 (`quota_exceeded`) | `Pictify::QuotaExceededError` |
|
|
260
|
+
| 429 (other) | `Pictify::RateLimitError` |
|
|
261
|
+
| other 4xx | `Pictify::RenderError` |
|
|
262
|
+
| 5xx | `Pictify::ServerError` |
|
|
263
|
+
|
|
264
|
+
Only 5xx responses and network failures are retried (with exponential backoff);
|
|
265
|
+
4xx responses (including 429) are never retried.
|
|
266
|
+
|
|
267
|
+
## Framework Example — Rails
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
class OgImagesController < ApplicationController
|
|
271
|
+
def show
|
|
272
|
+
client = Pictify::Client.new(api_key: ENV["PICTIFY_API_KEY"])
|
|
273
|
+
image = client.render(
|
|
274
|
+
template_id: "og-image-template",
|
|
275
|
+
variables: { title: params[:title] }
|
|
276
|
+
)
|
|
277
|
+
redirect_to image.url, allow_other_host: true
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## License
|
|
283
|
+
|
|
284
|
+
MIT
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
require "json"
|
|
6
|
+
require "cgi"
|
|
7
|
+
|
|
8
|
+
module Pictify
|
|
9
|
+
# Client for the Pictify API.
|
|
10
|
+
#
|
|
11
|
+
# Generate images, PDFs, and GIFs from raw HTML, live URLs, and reusable
|
|
12
|
+
# templates.
|
|
13
|
+
#
|
|
14
|
+
# @example Render raw HTML to a PNG
|
|
15
|
+
# client = Pictify::Client.new(api_key: "your-api-key")
|
|
16
|
+
# image = client.render_html(html: "<div>Hello World</div>", width: 1200, height: 630)
|
|
17
|
+
# puts image.url
|
|
18
|
+
#
|
|
19
|
+
# @example Render a template
|
|
20
|
+
# result = client.render(template_id: "your-template-uid", variables: { name: "Ada" })
|
|
21
|
+
# puts result.url # results.first.url
|
|
22
|
+
#
|
|
23
|
+
class Client
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.pictify.io"
|
|
25
|
+
DEFAULT_TIMEOUT = 30
|
|
26
|
+
DEFAULT_MAX_RETRIES = 3
|
|
27
|
+
|
|
28
|
+
# @param api_key [String] Your Pictify API key
|
|
29
|
+
# @param base_url [String] Custom API base URL
|
|
30
|
+
# @param timeout [Integer] Request timeout in seconds
|
|
31
|
+
# @param max_retries [Integer] Maximum retry attempts (5xx / network only)
|
|
32
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES)
|
|
33
|
+
raise AuthenticationError, "API key is required" if api_key.nil? || api_key.empty?
|
|
34
|
+
|
|
35
|
+
@api_key = api_key
|
|
36
|
+
@base_url = base_url.chomp("/")
|
|
37
|
+
@timeout = timeout
|
|
38
|
+
@max_retries = max_retries
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------------
|
|
42
|
+
# Image rendering (POST /image)
|
|
43
|
+
# ------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
# Render an image (or PDF) directly from HTML.
|
|
46
|
+
#
|
|
47
|
+
# +POST /image+ — returns an {ImageResult} with +url+, +id+, +created_at+.
|
|
48
|
+
#
|
|
49
|
+
# @param html [String] Raw HTML content to render
|
|
50
|
+
# @param css [String, nil] Optional CSS, injected into the HTML inside a
|
|
51
|
+
# <style> tag before rendering (the +/image+ endpoint takes a single +html+
|
|
52
|
+
# field)
|
|
53
|
+
# @param width [Integer, nil] Output width in pixels (default 1280)
|
|
54
|
+
# @param height [Integer, nil] Output height in pixels (default 720)
|
|
55
|
+
# @param selector [String, nil] CSS selector to crop the screenshot to
|
|
56
|
+
# @param format [Symbol, String, nil] Output format, mapped to +fileExtension+
|
|
57
|
+
# (default png)
|
|
58
|
+
# @return [ImageResult]
|
|
59
|
+
def render_html(html:, css: nil, width: nil, height: nil, selector: nil, format: nil)
|
|
60
|
+
body = css ? "<style>#{css}</style>#{html}" : html
|
|
61
|
+
|
|
62
|
+
response = request(:post, "image", {
|
|
63
|
+
html: body,
|
|
64
|
+
width: width,
|
|
65
|
+
height: height,
|
|
66
|
+
selector: selector,
|
|
67
|
+
fileExtension: (format || :png).to_s
|
|
68
|
+
})
|
|
69
|
+
ImageResult.new(response)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Screenshot a live URL.
|
|
73
|
+
#
|
|
74
|
+
# +POST /image+ with +url+ — returns an {ImageResult}.
|
|
75
|
+
#
|
|
76
|
+
# @param url [String] The URL to screenshot
|
|
77
|
+
# @param width [Integer, nil] Output width in pixels (default 1280)
|
|
78
|
+
# @param height [Integer, nil] Output height in pixels (default 720)
|
|
79
|
+
# @param selector [String, nil] CSS selector to crop the screenshot to
|
|
80
|
+
# @param format [Symbol, String, nil] Output format, mapped to +fileExtension+
|
|
81
|
+
# (default png)
|
|
82
|
+
# @return [ImageResult]
|
|
83
|
+
def render_url(url:, width: nil, height: nil, selector: nil, format: nil)
|
|
84
|
+
response = request(:post, "image", {
|
|
85
|
+
url: url,
|
|
86
|
+
width: width,
|
|
87
|
+
height: height,
|
|
88
|
+
selector: selector,
|
|
89
|
+
fileExtension: (format || :png).to_s
|
|
90
|
+
})
|
|
91
|
+
ImageResult.new(response)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------------
|
|
95
|
+
# Template rendering (POST /templates/:uid/render)
|
|
96
|
+
# ------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
# Render a single image (or PDF) from a template.
|
|
99
|
+
#
|
|
100
|
+
# +POST /templates/:uid/render+ — returns the {RenderResult} +results+
|
|
101
|
+
# envelope, with a convenience +url+ accessor (+results.first.url+).
|
|
102
|
+
#
|
|
103
|
+
# @param template_id [String] The UID of the template to render
|
|
104
|
+
# @param variables [Hash] Variables to inject into the template
|
|
105
|
+
# @param format [Symbol, String, nil] Output format (default png; +pdf+ supported)
|
|
106
|
+
# @param quality [Float, nil] Render quality for raster output (0.1–1.0, default 0.9)
|
|
107
|
+
# @param width [Integer, nil] Output width in pixels
|
|
108
|
+
# @param height [Integer, nil] Output height in pixels
|
|
109
|
+
# @param layout [String, nil] Render a single named layout variant
|
|
110
|
+
# @param layouts [Array<String>, nil] Render multiple named layout variants (max 20)
|
|
111
|
+
# @return [RenderResult]
|
|
112
|
+
def render(template_id:, variables: {}, format: nil, quality: nil, width: nil, height: nil,
|
|
113
|
+
layout: nil, layouts: nil)
|
|
114
|
+
response = request(:post, "templates/#{encode(template_id)}/render", {
|
|
115
|
+
variables: variables || {},
|
|
116
|
+
format: (format || :png).to_s,
|
|
117
|
+
quality: quality,
|
|
118
|
+
width: width,
|
|
119
|
+
height: height,
|
|
120
|
+
layout: layout,
|
|
121
|
+
layouts: layouts
|
|
122
|
+
})
|
|
123
|
+
RenderResult.new(response)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Render multiple layout variants of a template in a single call.
|
|
127
|
+
#
|
|
128
|
+
# +POST /templates/:uid/render+ with +layouts+ — returns one +results+ item
|
|
129
|
+
# per successful layout; missing/invalid layouts appear in +errors+.
|
|
130
|
+
#
|
|
131
|
+
# @param template_id [String] The UID of the template to render
|
|
132
|
+
# @param layouts [Array<String>] Layout variant names to render (max 20).
|
|
133
|
+
# Use +"default"+ for the base layout.
|
|
134
|
+
# @param variables [Hash] Variables to inject into the template
|
|
135
|
+
# @param format [Symbol, String, nil] Output format (default png)
|
|
136
|
+
# @param quality [Float, nil] Render quality for raster output (0.1–1.0)
|
|
137
|
+
# @param width [Integer, nil] Output width in pixels
|
|
138
|
+
# @param height [Integer, nil] Output height in pixels
|
|
139
|
+
# @return [RenderResult]
|
|
140
|
+
def render_layouts(template_id:, layouts:, variables: {}, format: nil, quality: nil,
|
|
141
|
+
width: nil, height: nil)
|
|
142
|
+
render(
|
|
143
|
+
template_id: template_id,
|
|
144
|
+
variables: variables,
|
|
145
|
+
format: format,
|
|
146
|
+
quality: quality,
|
|
147
|
+
width: width,
|
|
148
|
+
height: height,
|
|
149
|
+
layouts: layouts
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------------
|
|
154
|
+
# GIF rendering (POST /gif)
|
|
155
|
+
# ------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
# Render an animated GIF from raw HTML, a live URL, or a template.
|
|
158
|
+
#
|
|
159
|
+
# +POST /gif+ — the +{ gif: {...} }+ envelope is flattened to a
|
|
160
|
+
# {GifRenderResult} (+url+, +uid+, +width+, +height+, +animation_length+).
|
|
161
|
+
# Provide exactly one source: +html+, +url+, or +template_id+.
|
|
162
|
+
#
|
|
163
|
+
# @param html [String, nil] Raw HTML to render into a GIF (must contain motion)
|
|
164
|
+
# @param url [String, nil] A live URL to capture motion from
|
|
165
|
+
# @param template_id [String, nil] A template UID to render into a GIF
|
|
166
|
+
# @param variables [Hash, nil] Variables to inject when using +template_id+
|
|
167
|
+
# @param width [Integer, nil] Output width in pixels (default 800)
|
|
168
|
+
# @param height [Integer, nil] Output height in pixels (default 600)
|
|
169
|
+
# @param quality [Symbol, String, nil] Quality preset (:low, :medium, :high; default medium)
|
|
170
|
+
# @return [GifRenderResult]
|
|
171
|
+
def render_gif(html: nil, url: nil, template_id: nil, variables: nil, width: nil, height: nil,
|
|
172
|
+
quality: nil)
|
|
173
|
+
response = request(:post, "gif", {
|
|
174
|
+
html: html,
|
|
175
|
+
url: url,
|
|
176
|
+
template: template_id,
|
|
177
|
+
variables: variables,
|
|
178
|
+
width: width,
|
|
179
|
+
height: height,
|
|
180
|
+
quality: (quality || :medium).to_s
|
|
181
|
+
})
|
|
182
|
+
GifRenderResult.new(response["gif"] || {})
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------------
|
|
186
|
+
# Batch rendering (async)
|
|
187
|
+
# ------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
# Submit an async batch render of a template across many variable sets.
|
|
190
|
+
#
|
|
191
|
+
# +POST /templates/:uid/batch-render+ — returns a {BatchRenderResult} with
|
|
192
|
+
# +batch_id+, +status+, +total_items+ immediately (HTTP 202). Poll
|
|
193
|
+
# {#get_batch_results} to track progress.
|
|
194
|
+
#
|
|
195
|
+
# Rendered URLs are NOT returned by the poll endpoint — they are delivered
|
|
196
|
+
# via the +render.completed+ webhook.
|
|
197
|
+
#
|
|
198
|
+
# @param template_id [String] The UID of the template to render
|
|
199
|
+
# @param variable_sets [Array<Hash>] Variable sets — one render per set (max 100)
|
|
200
|
+
# @param format [Symbol, String, nil] Output format (default png)
|
|
201
|
+
# @param quality [Float, nil] Render quality for raster output (0.1–1.0, default 0.9)
|
|
202
|
+
# @param concurrency [Integer, nil] Maximum parallel renders (1–10, default 5)
|
|
203
|
+
# @param layout [String, nil] Render a single named layout variant for every item
|
|
204
|
+
# @param layouts [Array<String>, nil] Render multiple named layout variants for every item
|
|
205
|
+
# @return [BatchRenderResult]
|
|
206
|
+
def render_batch(template_id:, variable_sets:, format: nil, quality: nil, concurrency: nil,
|
|
207
|
+
layout: nil, layouts: nil)
|
|
208
|
+
response = request(:post, "templates/#{encode(template_id)}/batch-render", {
|
|
209
|
+
variableSets: variable_sets,
|
|
210
|
+
format: (format || :png).to_s,
|
|
211
|
+
quality: quality,
|
|
212
|
+
concurrency: concurrency,
|
|
213
|
+
layout: layout,
|
|
214
|
+
layouts: layouts
|
|
215
|
+
})
|
|
216
|
+
BatchRenderResult.new(response)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Get the status, progress, and per-item results of a batch job.
|
|
220
|
+
#
|
|
221
|
+
# +GET /templates/batch/:batch_id/results+. Results carry +index+, +success+,
|
|
222
|
+
# and +variables+ (plus +error+ on failures) but NOT rendered URLs.
|
|
223
|
+
#
|
|
224
|
+
# @param batch_id [String] The batch job ID returned by {#render_batch}
|
|
225
|
+
# @return [BatchResults]
|
|
226
|
+
def get_batch_results(batch_id)
|
|
227
|
+
response = request(:get, "templates/batch/#{encode(batch_id)}/results")
|
|
228
|
+
BatchResults.new(response)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------------
|
|
232
|
+
# Template CRUD
|
|
233
|
+
# ------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
# Get a single template by its UID.
|
|
236
|
+
#
|
|
237
|
+
# +GET /templates/:uid+ — unwraps the +{ template }+ envelope.
|
|
238
|
+
#
|
|
239
|
+
# @param template_id [String] The template UID
|
|
240
|
+
# @return [Template]
|
|
241
|
+
def get_template(template_id)
|
|
242
|
+
response = request(:get, "templates/#{encode(template_id)}")
|
|
243
|
+
Template.new(response["template"] || {})
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# List templates in your account.
|
|
247
|
+
#
|
|
248
|
+
# +GET /templates+ — returns a {ListTemplatesResult} with +templates+ and
|
|
249
|
+
# +pagination+.
|
|
250
|
+
#
|
|
251
|
+
# @param page [Integer, nil] Page number (1-based, default 1)
|
|
252
|
+
# @param limit [Integer, nil] Items per page (max 100, default 12)
|
|
253
|
+
# @param sort [Symbol, String, nil] Sort order (:newest, :oldest, :name)
|
|
254
|
+
# @return [ListTemplatesResult]
|
|
255
|
+
def list_templates(page: nil, limit: nil, sort: nil)
|
|
256
|
+
params = {}
|
|
257
|
+
params[:page] = page unless page.nil?
|
|
258
|
+
params[:limit] = limit unless limit.nil?
|
|
259
|
+
params[:sort] = sort.to_s unless sort.nil?
|
|
260
|
+
|
|
261
|
+
response = request(:get, "templates", nil, params)
|
|
262
|
+
ListTemplatesResult.new(response)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Create a template from HTML.
|
|
266
|
+
#
|
|
267
|
+
# +POST /templates+ — unwraps the +{ template }+ envelope. Variables are
|
|
268
|
+
# auto-discovered from +{{variableName}}+ tokens in the HTML body.
|
|
269
|
+
#
|
|
270
|
+
# @param html [String] Raw HTML body
|
|
271
|
+
# @param name [String, nil] Template name
|
|
272
|
+
# @param width [Integer, nil] Default width in pixels
|
|
273
|
+
# @param height [Integer, nil] Default height in pixels
|
|
274
|
+
# @param variable_definitions [Array<Hash>, nil] Explicit variable definitions
|
|
275
|
+
# (auto-extracted from HTML when omitted)
|
|
276
|
+
# @param output_format [Symbol, String, nil] Output format ("image" | "pdf", default image)
|
|
277
|
+
# @return [Template]
|
|
278
|
+
def create_template(html:, name: nil, width: nil, height: nil, variable_definitions: nil,
|
|
279
|
+
output_format: nil)
|
|
280
|
+
response = request(:post, "templates", {
|
|
281
|
+
html: html,
|
|
282
|
+
name: name,
|
|
283
|
+
width: width,
|
|
284
|
+
height: height,
|
|
285
|
+
variableDefinitions: variable_definitions,
|
|
286
|
+
outputFormat: output_format.nil? ? nil : output_format.to_s
|
|
287
|
+
})
|
|
288
|
+
Template.new(response["template"] || {})
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
private
|
|
292
|
+
|
|
293
|
+
def encode(value)
|
|
294
|
+
CGI.escape(value.to_s)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def connection
|
|
298
|
+
@connection ||= Faraday.new(url: @base_url) do |f|
|
|
299
|
+
f.request :retry,
|
|
300
|
+
max: @max_retries,
|
|
301
|
+
interval: 0.05,
|
|
302
|
+
backoff_factor: 2,
|
|
303
|
+
retry_statuses: [500, 502, 503, 504],
|
|
304
|
+
# Keep faraday-retry's defaults (which include RetriableResponse
|
|
305
|
+
# — required for retry_statuses to engage) and add network
|
|
306
|
+
# failures so 5xx and connection drops retry, but 4xx never do.
|
|
307
|
+
exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ConnectionFailed],
|
|
308
|
+
methods: %i[get post put delete patch]
|
|
309
|
+
f.options.timeout = @timeout
|
|
310
|
+
f.options.open_timeout = 10
|
|
311
|
+
f.headers["Authorization"] = "Bearer #{@api_key}"
|
|
312
|
+
f.headers["Content-Type"] = "application/json"
|
|
313
|
+
f.headers["User-Agent"] = "pictify-ruby/#{VERSION}"
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Make an authenticated JSON request. Nil body fields are stripped so the
|
|
318
|
+
# backend applies its own defaults. HTTP errors are mapped to typed errors.
|
|
319
|
+
def request(method, path, body = nil, params = nil)
|
|
320
|
+
response = connection.send(method) do |req|
|
|
321
|
+
req.url(path, params)
|
|
322
|
+
req.body = JSON.generate(strip_nils(body)) if body
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
handle_response(response)
|
|
326
|
+
rescue Faraday::RetriableResponse => e
|
|
327
|
+
# faraday-retry re-raises this once retries on a retriable (5xx) status are
|
|
328
|
+
# exhausted; map the carried response through the typed-error logic.
|
|
329
|
+
env = e.response
|
|
330
|
+
raise Pictify.error_from_response(env.status, error_body(env.body))
|
|
331
|
+
rescue Faraday::TimeoutError
|
|
332
|
+
raise TimeoutError.new("Request timed out", timeout: @timeout)
|
|
333
|
+
rescue Faraday::ConnectionFailed => e
|
|
334
|
+
if e.message.include?("timeout") || e.message.include?("timed out") || e.message.include?("execution expired")
|
|
335
|
+
raise TimeoutError.new("Request timed out", timeout: @timeout)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
raise NetworkError.new(nil, original_error: e)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def strip_nils(body)
|
|
342
|
+
return body unless body.is_a?(Hash)
|
|
343
|
+
|
|
344
|
+
body.reject { |_k, v| v.nil? }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def handle_response(response)
|
|
348
|
+
return parse_body(response.body) if response.success?
|
|
349
|
+
|
|
350
|
+
raise Pictify.error_from_response(response.status, error_body(response.body))
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def parse_body(raw)
|
|
354
|
+
return {} if raw.nil? || raw.to_s.empty?
|
|
355
|
+
|
|
356
|
+
JSON.parse(raw)
|
|
357
|
+
rescue JSON::ParserError
|
|
358
|
+
{}
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def error_body(raw)
|
|
362
|
+
JSON.parse(raw)
|
|
363
|
+
rescue StandardError
|
|
364
|
+
{ "message" => raw.to_s }
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pictify
|
|
4
|
+
# Base error class for all Pictify errors.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :status_code, :response_body
|
|
7
|
+
|
|
8
|
+
def initialize(message = nil, status_code: nil, response_body: nil)
|
|
9
|
+
@status_code = status_code
|
|
10
|
+
@response_body = response_body
|
|
11
|
+
@raw_message = message || default_message
|
|
12
|
+
super(@raw_message)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def message
|
|
16
|
+
@raw_message
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_s
|
|
20
|
+
return "[#{status_code}] #{@raw_message}" if status_code
|
|
21
|
+
|
|
22
|
+
@raw_message
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def default_message
|
|
28
|
+
"An error occurred"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Raised when the API key is invalid or missing (HTTP 401).
|
|
33
|
+
class AuthenticationError < Error
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def default_message
|
|
37
|
+
"Invalid or missing API key"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Raised when the specified template does not exist (HTTP 404).
|
|
42
|
+
class TemplateNotFoundError < Error
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def default_message
|
|
46
|
+
"Template not found"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Raised when the rate limit is exceeded (HTTP 429 without a quota code).
|
|
51
|
+
class RateLimitError < Error
|
|
52
|
+
attr_reader :retry_after
|
|
53
|
+
|
|
54
|
+
def initialize(message = nil, retry_after: nil, **kwargs)
|
|
55
|
+
@retry_after = retry_after
|
|
56
|
+
super(message, **kwargs)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_s
|
|
60
|
+
base_str = super
|
|
61
|
+
return "#{base_str} (retry after #{retry_after}s)" if retry_after
|
|
62
|
+
|
|
63
|
+
base_str
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def default_message
|
|
69
|
+
"Rate limit exceeded"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Raised when the render quota is exceeded (HTTP 402, or 429 with a quota code).
|
|
74
|
+
class QuotaExceededError < Error
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def default_message
|
|
78
|
+
"Render quota exceeded"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Raised when a render fails or input validation fails (HTTP 422, and other
|
|
83
|
+
# non-quota 4xx). Carries field-level +errors+ from the API when present.
|
|
84
|
+
class RenderError < Error
|
|
85
|
+
attr_reader :errors
|
|
86
|
+
|
|
87
|
+
def initialize(message = nil, errors: nil, **kwargs)
|
|
88
|
+
@errors = errors
|
|
89
|
+
super(message, **kwargs)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def default_message
|
|
95
|
+
"Render failed"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Raised when the server fails (HTTP 5xx).
|
|
100
|
+
class ServerError < Error
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def default_message
|
|
104
|
+
"Server error"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Raised when a network error occurs.
|
|
109
|
+
class NetworkError < Error
|
|
110
|
+
attr_reader :original_error
|
|
111
|
+
|
|
112
|
+
def initialize(message = nil, original_error: nil)
|
|
113
|
+
@original_error = original_error
|
|
114
|
+
super(message || original_error&.message || "Network error occurred")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Raised when a request times out.
|
|
119
|
+
class TimeoutError < Error
|
|
120
|
+
attr_reader :timeout
|
|
121
|
+
|
|
122
|
+
def initialize(message = nil, timeout: nil)
|
|
123
|
+
@timeout = timeout
|
|
124
|
+
super(message || "Request timed out")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Map an HTTP error response to a typed Pictify error.
|
|
129
|
+
#
|
|
130
|
+
# The Pictify API has no unified error envelope: image/GIF endpoints return
|
|
131
|
+
# +{ error, code }+ while template/CRUD endpoints return +{ message }+ or
|
|
132
|
+
# +{ message, errors }+. Message precedence is +error+ then +message+ then a
|
|
133
|
+
# fallback.
|
|
134
|
+
#
|
|
135
|
+
# Status mapping:
|
|
136
|
+
# - 401 -> AuthenticationError
|
|
137
|
+
# - 402 -> QuotaExceededError
|
|
138
|
+
# - 404 -> TemplateNotFoundError
|
|
139
|
+
# - 422 -> RenderError (validation; includes +errors+)
|
|
140
|
+
# - 429 -> QuotaExceededError when code == "quota_exceeded", else RateLimitError
|
|
141
|
+
# - other 4xx -> RenderError
|
|
142
|
+
# - 5xx -> ServerError
|
|
143
|
+
def self.error_from_response(status_code, body)
|
|
144
|
+
body ||= {}
|
|
145
|
+
message = body["error"] || body["message"] || "An unexpected error occurred"
|
|
146
|
+
|
|
147
|
+
case status_code
|
|
148
|
+
when 401
|
|
149
|
+
AuthenticationError.new(message, status_code: status_code, response_body: body)
|
|
150
|
+
when 402
|
|
151
|
+
QuotaExceededError.new(message, status_code: status_code, response_body: body)
|
|
152
|
+
when 404
|
|
153
|
+
TemplateNotFoundError.new(message, status_code: status_code, response_body: body)
|
|
154
|
+
when 422
|
|
155
|
+
RenderError.new(message, status_code: status_code, response_body: body, errors: body["errors"])
|
|
156
|
+
when 429
|
|
157
|
+
if body["code"] == "quota_exceeded"
|
|
158
|
+
QuotaExceededError.new(message, status_code: status_code, response_body: body)
|
|
159
|
+
else
|
|
160
|
+
RateLimitError.new(message, status_code: status_code, response_body: body, retry_after: body["retry_after"])
|
|
161
|
+
end
|
|
162
|
+
else
|
|
163
|
+
if status_code >= 500
|
|
164
|
+
ServerError.new(message, status_code: status_code, response_body: body)
|
|
165
|
+
else
|
|
166
|
+
RenderError.new(message, status_code: status_code, response_body: body, errors: body["errors"])
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Pictify
|
|
6
|
+
# Image output formats supported by Pictify renders.
|
|
7
|
+
#
|
|
8
|
+
# The +/image+ endpoint accepts these via +format+ (mapped to +fileExtension+);
|
|
9
|
+
# template renders accept them via +format+ (including +pdf+).
|
|
10
|
+
FORMATS = %i[png jpg jpeg webp pdf].freeze
|
|
11
|
+
|
|
12
|
+
# GIF quality presets accepted by the +/gif+ endpoint.
|
|
13
|
+
GIF_QUALITIES = %i[low medium high].freeze
|
|
14
|
+
|
|
15
|
+
# Result of an +/image+ render (+render_html+ / +render_url+).
|
|
16
|
+
#
|
|
17
|
+
# Maps the API response +{ url, id, createdAt }+.
|
|
18
|
+
class ImageResult
|
|
19
|
+
attr_reader :url, :id, :created_at, :raw
|
|
20
|
+
|
|
21
|
+
def initialize(data)
|
|
22
|
+
@raw = data
|
|
23
|
+
@url = data["url"]
|
|
24
|
+
@id = data["id"]
|
|
25
|
+
@created_at = data["createdAt"]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A single rendered layout item within a template render response.
|
|
30
|
+
#
|
|
31
|
+
# Maps +{ layout, url, width, height, format, name, id, createdAt }+.
|
|
32
|
+
class RenderResultItem
|
|
33
|
+
attr_reader :layout, :url, :width, :height, :format, :name, :id, :created_at, :raw
|
|
34
|
+
|
|
35
|
+
def initialize(data)
|
|
36
|
+
@raw = data
|
|
37
|
+
@layout = data["layout"]
|
|
38
|
+
@url = data["url"]
|
|
39
|
+
@width = data["width"]
|
|
40
|
+
@height = data["height"]
|
|
41
|
+
@format = data["format"]
|
|
42
|
+
@name = data["name"]
|
|
43
|
+
@id = data["id"]
|
|
44
|
+
@created_at = data["createdAt"]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Error entry returned when a specific layout fails to render.
|
|
49
|
+
#
|
|
50
|
+
# Maps +{ layout, error }+.
|
|
51
|
+
class RenderErrorEntry
|
|
52
|
+
attr_reader :layout, :error
|
|
53
|
+
|
|
54
|
+
def initialize(data)
|
|
55
|
+
@layout = data["layout"]
|
|
56
|
+
@error = data["error"]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Result of a template render (+render+ / +render_layouts+).
|
|
61
|
+
#
|
|
62
|
+
# The API returns a +results[]+ envelope. The convenience method +url+ returns
|
|
63
|
+
# the URL of the first rendered item, or +nil+ if nothing rendered.
|
|
64
|
+
class RenderResult
|
|
65
|
+
attr_reader :results, :errors, :total_layouts, :total_rendered, :total_errors,
|
|
66
|
+
:template_uid, :raw
|
|
67
|
+
|
|
68
|
+
def initialize(data)
|
|
69
|
+
@raw = data
|
|
70
|
+
@results = (data["results"] || []).map { |r| RenderResultItem.new(r) }
|
|
71
|
+
@errors = (data["errors"] || []).map { |e| RenderErrorEntry.new(e) }
|
|
72
|
+
@total_layouts = data["totalLayouts"]
|
|
73
|
+
@total_rendered = data["totalRendered"]
|
|
74
|
+
@total_errors = data["totalErrors"]
|
|
75
|
+
@template_uid = data["templateUid"]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Convenience accessor: URL of the first rendered item (+results[0].url+).
|
|
79
|
+
def url
|
|
80
|
+
results.first&.url
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Result of a GIF render (+render_gif+). Flattened from the API's
|
|
85
|
+
# +{ gif: {...} }+ envelope.
|
|
86
|
+
#
|
|
87
|
+
# Maps +{ url, uid, width, height, animationLength }+.
|
|
88
|
+
class GifRenderResult
|
|
89
|
+
attr_reader :url, :uid, :width, :height, :animation_length, :raw
|
|
90
|
+
|
|
91
|
+
def initialize(data)
|
|
92
|
+
@raw = data
|
|
93
|
+
@url = data["url"]
|
|
94
|
+
@uid = data["uid"]
|
|
95
|
+
@width = data["width"]
|
|
96
|
+
@height = data["height"]
|
|
97
|
+
@animation_length = data["animationLength"]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Result of submitting an async batch render (+render_batch+).
|
|
102
|
+
#
|
|
103
|
+
# The job runs asynchronously: poll {Client#get_batch_results} with the
|
|
104
|
+
# returned +batch_id+ to track progress. Maps +{ batchId, status, totalItems }+.
|
|
105
|
+
class BatchRenderResult
|
|
106
|
+
attr_reader :batch_id, :status, :total_items, :message, :raw
|
|
107
|
+
|
|
108
|
+
def initialize(data)
|
|
109
|
+
@raw = data
|
|
110
|
+
@batch_id = data["batchId"]
|
|
111
|
+
@status = data["status"]
|
|
112
|
+
@total_items = data["totalItems"]
|
|
113
|
+
@message = data["message"]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Status of a single item within a batch job.
|
|
118
|
+
#
|
|
119
|
+
# NOTE: batch results do NOT include rendered URLs — those are delivered via
|
|
120
|
+
# the +render.completed+ webhook. Each item reports only its index, success,
|
|
121
|
+
# the variable names it used, and (on failure) an error message.
|
|
122
|
+
class BatchItemStatus
|
|
123
|
+
attr_reader :index, :success, :variables, :error, :raw
|
|
124
|
+
|
|
125
|
+
def initialize(data)
|
|
126
|
+
@raw = data
|
|
127
|
+
@index = data["index"]
|
|
128
|
+
@success = data["success"]
|
|
129
|
+
@variables = data["variables"]
|
|
130
|
+
@error = data["error"]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def success?
|
|
134
|
+
!!@success
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Full status and progress of a batch job (+get_batch_results+).
|
|
139
|
+
#
|
|
140
|
+
# Maps +{ batchId, status, progress, totalItems, completedItems, failedItems,
|
|
141
|
+
# results[], errors[], createdAt, startedAt, completedAt }+.
|
|
142
|
+
class BatchResults
|
|
143
|
+
attr_reader :batch_id, :status, :progress, :total_items, :completed_items,
|
|
144
|
+
:failed_items, :results, :errors, :created_at, :started_at,
|
|
145
|
+
:completed_at, :raw
|
|
146
|
+
|
|
147
|
+
def initialize(data)
|
|
148
|
+
@raw = data
|
|
149
|
+
@batch_id = data["batchId"]
|
|
150
|
+
@status = data["status"]
|
|
151
|
+
@progress = data["progress"]
|
|
152
|
+
@total_items = data["totalItems"]
|
|
153
|
+
@completed_items = data["completedItems"]
|
|
154
|
+
@failed_items = data["failedItems"]
|
|
155
|
+
@results = (data["results"] || []).map { |r| BatchItemStatus.new(r) }
|
|
156
|
+
@errors = (data["errors"] || []).map { |e| BatchItemStatus.new(e) }
|
|
157
|
+
@created_at = data["createdAt"]
|
|
158
|
+
@started_at = data["startedAt"]
|
|
159
|
+
@completed_at = data["completedAt"]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# A variable definition declared on a template.
|
|
164
|
+
#
|
|
165
|
+
# The API keys variables by +name+ and may include +type+, +defaultValue+,
|
|
166
|
+
# +description+, and +validation+. Unknown engine-specific fields are kept in
|
|
167
|
+
# +raw+.
|
|
168
|
+
class TemplateVariableDefinition
|
|
169
|
+
attr_reader :name, :type, :default_value, :description, :validation, :raw
|
|
170
|
+
|
|
171
|
+
def initialize(data)
|
|
172
|
+
@raw = data
|
|
173
|
+
@name = data["name"]
|
|
174
|
+
@type = data["type"]
|
|
175
|
+
@default_value = data["defaultValue"]
|
|
176
|
+
@description = data["description"]
|
|
177
|
+
@validation = data["validation"]
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Template information returned by +get_template+ / +create_template+ /
|
|
182
|
+
# +list_templates+.
|
|
183
|
+
#
|
|
184
|
+
# The API keys templates by +uid+ (not +id+) and declares variables in
|
|
185
|
+
# +variableDefinitions+ (with a legacy flat +variables+ list of names).
|
|
186
|
+
# Unknown fields are passed through via +raw+.
|
|
187
|
+
class Template
|
|
188
|
+
attr_reader :uid, :name, :html, :width, :height, :engine, :output_format,
|
|
189
|
+
:variables, :variable_definitions, :thumbnail, :created_at,
|
|
190
|
+
:updated_at, :raw
|
|
191
|
+
|
|
192
|
+
def initialize(data)
|
|
193
|
+
@raw = data
|
|
194
|
+
@uid = data["uid"]
|
|
195
|
+
@name = data["name"]
|
|
196
|
+
@html = data["html"]
|
|
197
|
+
@width = data["width"]
|
|
198
|
+
@height = data["height"]
|
|
199
|
+
@engine = data["engine"]
|
|
200
|
+
@output_format = data["outputFormat"]
|
|
201
|
+
@variables = data["variables"] || []
|
|
202
|
+
@variable_definitions = (data["variableDefinitions"] || []).map do |v|
|
|
203
|
+
TemplateVariableDefinition.new(v)
|
|
204
|
+
end
|
|
205
|
+
@thumbnail = data["thumbnail"]
|
|
206
|
+
@created_at = parse_time(data["createdAt"])
|
|
207
|
+
@updated_at = parse_time(data["updatedAt"])
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def parse_time(value)
|
|
213
|
+
return nil unless value
|
|
214
|
+
|
|
215
|
+
Time.parse(value)
|
|
216
|
+
rescue ArgumentError, TypeError
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Pagination metadata returned by +list_templates+.
|
|
222
|
+
class Pagination
|
|
223
|
+
attr_reader :page, :limit, :total, :total_pages, :has_next, :has_prev, :raw
|
|
224
|
+
|
|
225
|
+
def initialize(data)
|
|
226
|
+
@raw = data
|
|
227
|
+
@page = data["page"]
|
|
228
|
+
@limit = data["limit"]
|
|
229
|
+
@total = data["total"]
|
|
230
|
+
@total_pages = data["totalPages"]
|
|
231
|
+
@has_next = data["hasNext"]
|
|
232
|
+
@has_prev = data["hasPrev"]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def has_next?
|
|
236
|
+
!!@has_next
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def has_prev?
|
|
240
|
+
!!@has_prev
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Result of listing templates (+list_templates+).
|
|
245
|
+
#
|
|
246
|
+
# Maps +{ templates: [...], pagination: {...} }+.
|
|
247
|
+
class ListTemplatesResult
|
|
248
|
+
attr_reader :templates, :pagination, :raw
|
|
249
|
+
|
|
250
|
+
def initialize(data)
|
|
251
|
+
@raw = data
|
|
252
|
+
@templates = (data["templates"] || []).map { |t| Template.new(t) }
|
|
253
|
+
@pagination = data["pagination"] ? Pagination.new(data["pagination"]) : nil
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
data/lib/pictify.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pictify/version"
|
|
4
|
+
require_relative "pictify/errors"
|
|
5
|
+
require_relative "pictify/types"
|
|
6
|
+
require_relative "pictify/client"
|
|
7
|
+
|
|
8
|
+
# Pictify Ruby SDK
|
|
9
|
+
#
|
|
10
|
+
# Official SDK for generating images, PDFs, and GIFs from raw HTML, live URLs,
|
|
11
|
+
# and reusable templates using the Pictify API.
|
|
12
|
+
#
|
|
13
|
+
# @example Render raw HTML to a PNG
|
|
14
|
+
# client = Pictify::Client.new(api_key: "your-api-key")
|
|
15
|
+
# image = client.render_html(html: "<div>Hello World</div>", width: 1200, height: 630)
|
|
16
|
+
# puts image.url
|
|
17
|
+
#
|
|
18
|
+
# @example Render a template
|
|
19
|
+
# result = client.render(
|
|
20
|
+
# template_id: "your-template-uid",
|
|
21
|
+
# variables: { name: "Ada", company: "Pictify" }
|
|
22
|
+
# )
|
|
23
|
+
# puts result.url
|
|
24
|
+
#
|
|
25
|
+
module Pictify
|
|
26
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pictify
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Pictify
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-08 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: faraday
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.0'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '3.0'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '2.0'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.0'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: faraday-retry
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
type: :runtime
|
|
41
|
+
prerelease: false
|
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.0'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: minitest
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '5.0'
|
|
54
|
+
type: :development
|
|
55
|
+
prerelease: false
|
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '5.0'
|
|
61
|
+
- !ruby/object:Gem::Dependency
|
|
62
|
+
name: rake
|
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '13.0'
|
|
68
|
+
type: :development
|
|
69
|
+
prerelease: false
|
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '13.0'
|
|
75
|
+
- !ruby/object:Gem::Dependency
|
|
76
|
+
name: rubocop
|
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.0'
|
|
82
|
+
type: :development
|
|
83
|
+
prerelease: false
|
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '1.0'
|
|
89
|
+
- !ruby/object:Gem::Dependency
|
|
90
|
+
name: webmock
|
|
91
|
+
requirement: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '3.0'
|
|
96
|
+
type: :development
|
|
97
|
+
prerelease: false
|
|
98
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '3.0'
|
|
103
|
+
description: Ruby client library for the Pictify API. Generate OG images, social cards,
|
|
104
|
+
and dynamic images from HTML templates.
|
|
105
|
+
email:
|
|
106
|
+
- support@pictify.io
|
|
107
|
+
executables: []
|
|
108
|
+
extensions: []
|
|
109
|
+
extra_rdoc_files: []
|
|
110
|
+
files:
|
|
111
|
+
- CHANGELOG.md
|
|
112
|
+
- LICENSE
|
|
113
|
+
- README.md
|
|
114
|
+
- lib/pictify.rb
|
|
115
|
+
- lib/pictify/client.rb
|
|
116
|
+
- lib/pictify/errors.rb
|
|
117
|
+
- lib/pictify/types.rb
|
|
118
|
+
- lib/pictify/version.rb
|
|
119
|
+
homepage: https://pictify.io
|
|
120
|
+
licenses:
|
|
121
|
+
- MIT
|
|
122
|
+
metadata:
|
|
123
|
+
homepage_uri: https://pictify.io
|
|
124
|
+
source_code_uri: https://github.com/pictify-io/pictify-ruby
|
|
125
|
+
changelog_uri: https://github.com/pictify-io/pictify-ruby/blob/main/CHANGELOG.md
|
|
126
|
+
documentation_uri: https://docs.pictify.io/sdks/ruby
|
|
127
|
+
rubygems_mfa_required: 'true'
|
|
128
|
+
post_install_message:
|
|
129
|
+
rdoc_options: []
|
|
130
|
+
require_paths:
|
|
131
|
+
- lib
|
|
132
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - ">="
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: 3.0.0
|
|
137
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
138
|
+
requirements:
|
|
139
|
+
- - ">="
|
|
140
|
+
- !ruby/object:Gem::Version
|
|
141
|
+
version: '0'
|
|
142
|
+
requirements: []
|
|
143
|
+
rubygems_version: 3.4.6
|
|
144
|
+
signing_key:
|
|
145
|
+
specification_version: 4
|
|
146
|
+
summary: Official Ruby SDK for Pictify - Generate images from HTML templates
|
|
147
|
+
test_files: []
|