Current section

Files

Jump to
iiif_image_plug lib iiif_image_plug v3.ex
Raw

lib/iiif_image_plug/v3.ex

defmodule IIIFImagePlug.V3 do
@behaviour Plug
import Plug.Conn
alias IIIFImagePlug.V3.Size.Scaling
alias IIIFImagePlug.V3.{
Data,
Information,
Quality
}
alias Vix.Vips.Image
require Logger
@moduledoc """
This plug implements the IIIF Image API version 3 (see also https://iiif.io/api/image/3.0/).
# Options
## `:scheme` (default: `:http`)
The scheme used to create the `id` image [information](https://iiif.io/api/image/3.0/#51-image-information-request) requests.
## `:host` (default: `"localhost"`)
The host used to create the `id` image [information](https://iiif.io/api/image/3.0/#51-image-information-request) requests.
## `:port` (default: `nil`)
The port used to create the `id` image [information](https://iiif.io/api/image/3.0/#51-image-information-request) requests.
## `:prefix` (default: `""`)
The path prefix used to create the `id` image [information](https://iiif.io/api/image/3.0/#51-image-information-request) requests.
## `:max_width` (default: `10000`)
The maximum image width the plug will serve.
## `:max_height` (default: `10000`)
The maximum image height the plug will serve.
## `:max_area` (default: `100000000`)
The maximum amount of image pixels the plug will serve (does not necessarily have to be `max_width` * `max_height`).
## `:preferred_formats` (default: `[:jpg]`)
The [preferred formats](https://iiif.io/api/image/3.0/#55-preferred-formats) to be used for your service.
## `:extra_formats` (default: `[:png, :webp, :tif]`)
The [extra formats](https://iiif.io/api/image/3.0/#57-extra-functionality) your service can deliver. Note that TIF files
have to be buffered before they are sent, so large images might cause issues.
## `:identifier_to_path_callback` required
An arity 1 callback function that resolves a given IIIF identifier to a file path (string).
## `:identifier_to_rights_callback` (optional)
An arity 1 callback function that returns a [rights](https://iiif.io/api/image/3.0/#56-rights) statement for a given identifier.
## `:identifier_to_part_of_callback` (optional)
An arity 1 callback function that returns a list of [part of](https://iiif.io/api/image/3.0/#58-linking-properties) properties for a given identifier.
## `:identifier_to_see_also_callback` (optional)
An arity 1 callback function that returns a list of [see also](https://iiif.io/api/image/3.0/#58-linking-properties) properties for a given identifier.
## `:identifier_to_service_callback` (optional)
An arity 1 callback function that returns a list of [service](https://iiif.io/api/image/3.0/#58-linking-properties) properties for a given identifier.
## `:status_callbacks` (optional)
A map where each key is a HTTP status code (integer), and each value an arity 2 callback that can be used to replace the plug's default response. Each
callback should accept a plug as its first parameter and a Map (containing the error message) as its second parameter.
"""
defmodule Settings do
@enforce_keys [
:scheme,
:host,
:port,
:prefix,
:max_width,
:max_height,
:max_area,
:preferred_formats,
:extra_formats,
:identifier_to_path_callback,
:identifier_to_rights_callback,
:identifier_to_part_of_callback,
:identifier_to_see_also_callback,
:identifier_to_service_callback,
:status_callbacks
]
defstruct [
:scheme,
:host,
:port,
:prefix,
:max_width,
:max_height,
:max_area,
:preferred_formats,
:extra_formats,
:identifier_to_path_callback,
:identifier_to_rights_callback,
:identifier_to_part_of_callback,
:identifier_to_see_also_callback,
:identifier_to_service_callback,
:status_callbacks
]
end
@default_preferred_format [:jpg]
@default_extra_formats [:webp, :png, :tif]
@default_max_width Application.compile_env(:iiif_image_plug, :max_width, 10000)
@default_max_height Application.compile_env(:iiif_image_plug, :max_height, 10000)
@default_max_area Application.compile_env(:iiif_image_plug, :max_area, 10000 * 10000)
def init(opts) when is_map(opts) do
%Settings{
scheme: opts[:scheme] || :http,
host: opts[:host] || "localhost",
port: opts[:port],
prefix:
if opts[:prefix] do
String.trim_trailing(opts[:prefix], "/")
else
""
end,
max_width: opts[:max_width] || @default_max_width,
max_height: opts[:max_height] || @default_max_height,
max_area: opts[:max_area] || @default_max_area,
preferred_formats: opts[:preferred_formats] || @default_preferred_format,
extra_formats: opts[:extra_formats] || @default_extra_formats,
identifier_to_path_callback:
opts[:identifier_to_path_callback] ||
raise("Missing callback used to construct file path from identifier."),
identifier_to_rights_callback: opts[:identifier_to_rights_callback],
identifier_to_part_of_callback: opts[:identifier_to_part_of_callback],
identifier_to_see_also_callback: opts[:identifier_to_see_also_callback],
identifier_to_service_callback: opts[:identifier_to_service_callback],
status_callbacks: opts[:status_callbacks] || %{}
}
end
def call(
%Plug.Conn{path_info: [identifier, "info.json"]} = conn,
%Settings{status_callbacks: status_callbacks} = settings
) do
case Information.evaluate(identifier, settings) do
{:ok, info} ->
conn
|> put_resp_content_type("application/ld+json")
|> send_resp(200, Jason.encode!(info))
{:file_exists, false} ->
send_error(
conn,
404,
%{
reason: "No file with identifier '#{identifier}'."
},
status_callbacks
)
{:file_opened, _} ->
Logger.error("File matching identifier '#{identifier}' could not be opened as an image.")
send_error(
conn,
500,
%{},
status_callbacks
)
end
end
def call(
%Plug.Conn{
path_info: [identifier, region, size, rotation, quality_and_format]
} = conn,
%Settings{
identifier_to_path_callback: path_callback,
status_callbacks: status_callbacks
} =
settings
) do
path = path_callback.(identifier)
with {:file_exists, true} <- {:file_exists, File.exists?(path)},
{:file_opened, {:ok, file}} <- {:file_opened, Image.new_from_file(path)},
{:quality_and_format_parsed, %{quality: quality, format: format}} <-
{:quality_and_format_parsed, Quality.parse(quality_and_format, settings)} do
page_count =
try do
Image.n_pages(file)
rescue
_ -> 1
end
if page_count > 1 do
last_page = page_count - 1
width = Image.width(file)
pages =
0..last_page
|> Enum.map(fn page ->
{:ok, page_image} = Image.new_from_file(path, page: page)
page_width = Image.width(page_image)
{page_image, %Scaling{scale: page_width / width}}
end)
Data.process_page_optimized(
file,
URI.decode(region),
URI.decode(size),
URI.decode(rotation),
quality,
settings,
pages
)
else
Data.process_basic(
file,
URI.decode(region),
URI.decode(size),
URI.decode(rotation),
quality,
settings
)
end
|> case do
%Image{} = transformed ->
if format == "tif" do
send_buffered(conn, transformed, format)
else
send_stream(conn, transformed, format)
end
{:error, msg} ->
send_error(
conn,
400,
%{error: msg},
status_callbacks
)
end
else
{:file_exists, false} ->
send_error(
conn,
404,
%{
reason: "No file with identifier '#{identifier}'."
},
status_callbacks
)
{:file_opened, _} ->
Logger.error("File matching identifier '#{identifier}' could not be opened as an image.")
send_error(
conn,
500,
%{},
status_callbacks
)
{:quality_and_format_parsed, _} ->
send_error(
conn,
400,
%{
reason: "Could not find parse valid quality and format from '#{quality_and_format}'."
},
status_callbacks
)
end
end
def call(
conn,
%Settings{status_callbacks: callbacks}
) do
send_error(
conn,
400,
%{reason: "Invalid request scheme.", path_info: conn.path_info},
callbacks
)
end
defp send_buffered(conn, %Image{} = image, format) do
{:ok, buffer} = Image.write_to_buffer(image, ".#{format}")
send_resp(conn, 200, buffer)
end
defp send_stream(conn, %Image{} = image, format) do
stream = Image.write_to_stream(image, ".#{format}")
conn = send_chunked(conn, 200)
Enum.reduce_while(stream, conn, fn data, conn ->
case chunk(conn, data) do
{:ok, conn} -> {:cont, conn}
{:error, :closed} -> {:halt, conn}
end
end)
end
defp send_error(conn, code, info, status_callbacks)
when is_map(info) and is_map(status_callbacks) do
if status_callbacks[code] do
status_callbacks[code].(conn, info)
else
conn
|> put_resp_content_type("application/json")
|> send_resp(
code,
Jason.encode!(info)
)
end
end
end