defmodule Image.Shape do
@moduledoc """
Functions to render a shape as an image.
The supported shapes match those defined in
[Scalable Vector Graphics](https://developer.mozilla.org/en-US/docs/Web/SVG)
including:
* Rectangle
* Polygon
* Circle
* Ellipse
* Line
"""
alias Vix.Vips.Image, as: Vimage
alias Vix.Vips.Operation
@typedoc """
A point is a list of two integers
representing the `x` and `y` coordinates
"""
@type point :: [integer()]
@typedoc """
A path is a list of points representing
a path, open polygon or closed polygon.
"""
@type path :: String.t() | [point(), ...]
@default_width 500
@default_radius 100
@default_rotation 180
@default_star_points 5
@default_star_inner_radius 60
@default_star_outer_radius 150
@default_star_rotation 0
@doc """
Creates a image of a rectangle.
* `width` is the number of pixels wide.
* `height` is the number of pixels high.
* `options` is a `t:Keyword.t/0` list of options.
### Options
* `:fill_color` is the color used to fill in the
polygon. The default is `:none`.
* `:stroke_width` is the width of the line used
to draw the rectangle. The default is `1px`.
* `:stroke_color` is the color used for the outline
of the polygon. The default is `:black`
* `:opacity` is the opacity as a float between
`0.0` and `1.0` where `0.0` is completely transparent
and `1.0` is completely opaque. The default is `0.7`.
* `:rotation` is the number of degrees to rotate the
axis of a generated rectangle.
### Returns
* `{:ok, rectangle_image}` or
* `{:error, reason}`
### Examples
"""
@doc since: "1.27.0"
@spec rect(width :: pos_integer(), height :: pos_integer(), options :: Keyword.t()) ::
{:ok, Vimage.t()} | {:error, Image.error_message()}
def rect(width, height, options \\ []) do
with {:ok, options} <- Image.Options.Shape.validate_polygon_options(options) do
svg = """
<svg viewBox="0 0 #{width} #{height}">
<style type="text/css">
svg rect {
fill: #{options.fill_color};
stroke: #{options.stroke_color};
stroke-width: #{options.stroke_width};
opacity: #{options.opacity};
}
</style>
<rect width="#{width}" height="#{height}" />
</svg>
"""
case Operation.svgload_buffer(svg) do
{:ok, {polygon, _flags}} -> {:ok, polygon}
{:error, reason} -> {:error, reason}
end
end
end
@doc """
Creates a image of a rectangle or raises
and exception.
* `width` is the number of pixels wide.
* `height` is the number of pixels high.
* `options` is a `t:Keyword.t/0` list of options.
### Options
* `:fill_color` is the color used to fill in the
polygon. The default is `:none`.
* `:stroke_width` is the width of the line used
to draw the rectangle. The default is `1px`.
* `:stroke_color` is the color used for the outline
of the polygon. The default is `:black`.
* `:opacity` is the opacity as a float between
`0.0` and `1.0` where `0.0` is completely transparent
and `1.0` is completely opaque. The default is `0.7`.
* `:rotation` is the number of degrees to rotate the
axis of a generated rectangle.
### Returns
* `rectangle_image` or
* raises an exception.
### Examples
"""
@doc since: "1.27.0"
@spec rect!(width :: pos_integer(), height :: pos_integer(), options :: Keyword.t()) ::
Vimage.t() | no_return()
def rect!(width, height, options \\ []) do
case rect(width, height, options) do
{:ok, rectangle} -> rectangle
{:error, reason} -> raise Image.Error, reason
end
end
@doc """
Creates an image of a polygon.
### Arguments
* `points` defines the points of the polygon. The
origin is the top left of the image with a positive
`x` value moving from right to left and a positive
`y` value moving from top to bottom. The points can
be an [SVG point string](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/points)
or a "list of lists" of the form
`[[x1, y1], [x2, y2], ...]` where `x1` and `y1`
are integers. `points` can also be a positive
integer >= 3 which indicates that an `n` sided
polygon will be generated. In this case the options
`:rotation` and `:radius` are also applicable.
* `options` is a `t:Keyword.t/0` list of options.
### Options
* `:width` is the width of the canvas onto which the
polygon is drawn. The default is `500` pixels.
* `:height` is the height of the canvas onto which the
polygon is drawn. The default is `500` pixels.
* `:fill_color` is the color used to fill in the
polygon. The default is `:none`.
* `:stroke_width` is the width of the line used
to draw the polygon. The default is `1px`.
* `:stroke_color` is the color used for the outline
of the polygon. The default is `:black`
* `:opacity` is the opacity as a float between
`0.0` and `1.0` where `0.0` is completely transparent
and `1.0` is completely opaque. The default is `0.7`.
* `:rotation` is the number of degrees to rotate the
axis of a generated n-sided polygon. This option is
only valid if `points` is an integer >= 3.
The default is `#{@default_rotation}`.
* `:radius` indicates the radius in pixels of a generated
n-sided polygon. The default is `#{@default_radius}`.
### Notes
* The polygon points are scaled to fit the canvas size
defined by `:width` and `:height` This means that the
resulting image will fill the canvas. This is useful
for composing images. Define the canvas to be the size
intended to be composed into a base image and the
polygon will be scaled to fit.
* Colors may be any valid
[CSS color name](https://www.w3.org/wiki/CSS/Properties/color/keywords) or
a six hexadecimal digit string prefixed with `#`. For example
`#FF00FF` for the color "Fuchsia".
### Returns
* `{:ok, image}` or
* `{:error, reason}`
### Examples
"""
@spec polygon(points :: path(), options :: Keyword.t()) ::
{:ok, Vimage.t()} | {:error, Image.error_message()}
def polygon(points, options \\ [])
def polygon(points, options) when is_binary(points) do
points
|> points_to_path()
|> polygon(options)
end
def polygon(points, options) when is_list(points) and is_list(options) do
with {:ok, options} <- Image.Options.Shape.validate_polygon_options(options) do
polygon(points, options)
end
end
def polygon(points, %{} = options) when is_list(points) do
{width, height} = dimensions_from(points, options[:width], options[:height])
points =
points
|> rescale(0, width, 0, height)
|> format_points()
svg = """
<svg width="#{width}" height="#{height}">
<style type="text/css">
svg polygon {
fill: #{options.fill_color};
stroke: #{options.stroke_color};
stroke-width: #{options.stroke_width};
opacity: #{options.opacity};
}
</style>
<polygon points="#{points}" />
</svg>
"""
case Operation.svgload_buffer(svg) do
{:ok, {polygon, _flags}} -> {:ok, polygon}
{:error, reason} -> {:error, reason}
end
end
@spec polygon(sides :: pos_integer(), options :: Keyword.t()) ::
{:ok, Vimage.t()} | {:error, Image.error_message()}
def polygon(sides, options) when is_integer(sides) and sides > 2 do
{radius, options} = Keyword.pop(options, :radius, @default_radius)
{rotation, options} = Keyword.pop(options, :rotation, @default_rotation)
segment = :math.pi() * 2 / sides
rotation = rotation * :math.pi() / 180
for side <- 1..sides do
[
:math.sin(segment * side + rotation) * radius,
:math.cos(segment * side + rotation) * radius
]
end
|> polygon(options)
end
defp dimensions_from(points, nil, nil) do
aspect_ratio = aspect_ratio(points)
{@default_width, round(@default_width / aspect_ratio)}
end
defp dimensions_from(_points, width, height) when is_integer(width) and is_integer(height) do
{width, height}
end
defp dimensions_from(points, width, nil) when is_integer(width) do
aspect_ratio = aspect_ratio(points)
{width, round(width / aspect_ratio)}
end
defp dimensions_from(points, nil, height) when is_integer(height) do
aspect_ratio = aspect_ratio(points)
{round(height * aspect_ratio), height}
end
@doc """
Creates an image of a polygon as a single
band image on a transparent background.
### Arguments
* `points` defines the points of the polygon. The
origin is the top left of the image with a positive
`x` value moving from right to left and a positive
`y` value moving from top to bottom. The points can
be an [SVG point string](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/points)
or a "list of lists" of the form
`[[x1, y1], [x2, y2], ...]` where `x1` and `y1`
are integers.
* `options` is a `t:Keyword.t/0` list of options.
### Options
* `:width` is the width of the canvas onto which the
polygon is drawn. The default is `500` pixels.
* `:height` is the width of the canvas onto which the
polygon is drawn. The default is `500` pixels.
* `:fill_color` is the color used to fill in the
polygon. The default is `:none`.
* `:stroke_width` is the width of the line used
to draw the polygon. The default is `1px`.
* `:stroke_color` is the color used for the outline
of the polygon. The default is `:black`
* `:opacity` is the opacity as a float between
`0.0` and `1.0` where `0.0` is completely transparent
and `1.0` is completely opaque. The default is `0.7`.
### Notes
* The polygon points are scaled to fit the canvas size
defined by `:width` and `:height` This means that the
resulting image will fill the canvas. This is useful
for composing images. Define the canvas to be the size
intended to be composed into a base image and the
polygon will be scaled to fit.
* Colors may be any valid
[CSS color name](https://www.w3.org/wiki/CSS/Properties/color/keywords) or
a six hexadecimal digit string prefixed with `#`. For example
`#FF00FF` for the color "Fuchsia".
### Returns
* `image` or
* raises an exception
### Examples
"""
@spec polygon!(points :: path(), options :: Keyword.t()) ::
Vimage.t() | no_return()
def polygon!(points, options \\ []) do
case polygon(points, options) do
{:ok, image} -> image
{:error, reason} -> raise Image.Error, reason
end
end
@doc """
Returns an image of an n-pointed star that
can be composed over other images.
### Arguments
* `points` is an integer number of points
on the star. `points` must be >= 3. The default
is `#{@default_star_points}`.
* `options` is a `t:Keyword.t/0` list of options.
### Options
* `:inner_radius` is the size of the inner
radius. The default is `#{@default_star_inner_radius}`.
* `:outer_radius` is the size of the outer
radius. The default is `#{@default_star_outer_radius}`.
* `:rotation` is the angle in degrees of rotation
applied to the points. The default is `#{@default_star_rotation}`.
* Any remaining options are passed to `Image.Shape.polygon/2`.
### Returns
* `{:ok, image}` or
* `{:error, reason}`
### Examples
#=> {:ok, star} = Image.Shape.star
#=> {:ok, star} = Image.Shape.star 5, rotation: 90, fill_color: :red, stroke_color: :green
"""
@spec star(points :: pos_integer(), options :: Keyword.t()) ::
{:ok, Vimage.t()} | {:error, Image.error_message()}
def star(points \\ @default_star_points, options \\ []) when points > 3 do
{inner_radius, options} = Keyword.pop(options, :inner_radius, @default_star_inner_radius)
{outer_radius, options} = Keyword.pop(options, :outer_radius, @default_star_outer_radius)
{rotation, options} = Keyword.pop(options, :rotation, @default_star_rotation)
rotation = rotation * :math.pi() / 180
Enum.reduce(1..points, [], fn point, polygon ->
inner_angle = 2 * :math.pi() * point / points
outer_angle = inner_angle + :math.pi() / points
inner_angle = inner_angle + rotation
outer_angle = outer_angle + rotation
inner = [
inner_radius * :math.cos(inner_angle),
inner_radius * :math.sin(inner_angle)
]
outer = [
outer_radius * :math.cos(outer_angle),
outer_radius * :math.sin(outer_angle)
]
[outer, inner | polygon]
end)
|> polygon(options)
end
@doc """
Returns an image of an n-pointed star that
can be composed over other images.
### Arguments
* `points` is an integer number of points
on the star. `points` must be >= 3. The default
is `#{@default_star_points}`.
* `options` is a `t:Keyword.t/0` list of options.
### Options
* `:inner_radius` is the size of the inner
radius. The default is `#{@default_star_inner_radius}`.
* `:outer_radius` is the size of the outer
radius. The default is `#{@default_star_outer_radius}`.
* `:rotation` is the angle in degrees of rotation
applied to the points. The default is `#{@default_star_rotation}`.
* Any remaining options are passed to `Image.Shape.polygon/2`.
### Returns
* `image` or
* raises an exception
### Examples
#=> star = Image.Shape.star!
#=> star = Image.Shape.star! 5, rotation: 90, fill_color: :red, stroke_color: :green
"""
@spec star!(points :: pos_integer(), options :: Keyword.t()) :: Vimage.t() | no_return()
def star!(points \\ @default_star_points, options \\ []) do
case star(points, options) do
{:ok, image} -> image
{:error, reason} -> raise Image.Error, reason
end
end
### Helpers
defp format_points(points) do
points
|> List.flatten()
|> Enum.join(" ")
end
@doc false
def rescale(unscaled, from_min, from_max, to_min, to_max) when is_number(unscaled) do
round((to_max - to_min) * (unscaled - from_min) / (from_max - from_min) + to_min)
end
@doc false
def rescale(polygon, x_min, x_max, y_min, y_max, scale \\ nil) when is_list(polygon) do
{from_x_min, from_x_max, from_y_min, from_y_max} = scale || polygon_scale(polygon)
for [x, y] <- polygon do
[
rescale(x, from_x_min, from_x_max, x_min, x_max),
rescale(y, from_y_min, from_y_max, y_min, y_max)
]
end
end
@doc false
def rescale(polygon, %Vimage{} = image) when is_list(polygon) do
{from_x_min, from_x_max, from_y_min, from_y_max} = polygon_scale(polygon)
aspect_ratio = (from_x_max - from_x_min) / (from_y_max - from_y_min)
width = Image.width(image)
height = round(width * aspect_ratio)
min = 0
rescale(polygon, min, width, min, height, {from_x_min, from_x_max, from_y_min, from_y_max})
end
@doc false
def aspect_ratio(%Vimage{} = image) do
Image.width(image) / Image.height(image)
end
def aspect_ratio(polygon) when is_list(polygon) do
{from_x_min, from_x_max, from_y_min, from_y_max} = polygon_scale(polygon)
(from_x_max - from_x_min) / (from_y_max - from_y_min)
end
defp polygon_scale(polygon) do
Enum.reduce(polygon, {10_000_000, -10_000_000, 10_000_000, -10_000_000}, fn
[x, y], {x_min, x_max, y_min, y_max} ->
x_min = min(x, x_min)
x_max = max(x, x_max)
y_min = min(y, y_min)
y_max = max(y, y_max)
{x_min, x_max, y_min, y_max}
end)
end
defp points_to_path(points) when is_binary(points) do
points
|> String.split([",", " ", "\n"], trim: true)
|> Enum.map(&String.to_integer/1)
|> Enum.chunk_every(2)
end
end