diff --git a/lib/image_plug.ex b/lib/image_plug.ex index 375629f..c6d9859 100644 --- a/lib/image_plug.ex +++ b/lib/image_plug.ex @@ -12,7 +12,7 @@ defmodule ImagePlug do @type imgp_pixels() :: {:pixels, imgp_number()} @type imgp_pct() :: {:percent, imgp_number()} @type imgp_scale() :: {:scale, imgp_number(), imgp_number()} - @type imgp_ratio() :: {:ratio, imgp_number(), imgp_number()} + @type imgp_ratio() :: {imgp_number(), imgp_number()} @type imgp_length() :: imgp_pixels() | imgp_pct() | imgp_scale() @alpha_format_priority ~w(image/avif image/webp image/png) diff --git a/lib/image_plug/param_parser/twicpics.ex b/lib/image_plug/param_parser/twicpics.ex index 668198f..1116ef8 100644 --- a/lib/image_plug/param_parser/twicpics.ex +++ b/lib/image_plug/param_parser/twicpics.ex @@ -12,9 +12,36 @@ defmodule ImagePlug.ParamParser.Twicpics do "resize" => {ImagePlug.Transform.Scale, Twicpics.Transform.ScaleParser}, "focus" => {ImagePlug.Transform.Focus, Twicpics.Transform.FocusParser}, "contain" => {ImagePlug.Transform.Contain, Twicpics.Transform.ContainParser}, + "contain-min" => {ImagePlug.Transform.Contain, Twicpics.Transform.ContainMinParser}, + "contain-max" => {ImagePlug.Transform.Contain, Twicpics.Transform.ContainMaxParser}, + "cover" => {ImagePlug.Transform.Cover, Twicpics.Transform.CoverParser}, + "cover-min" => {ImagePlug.Transform.Cover, Twicpics.Transform.CoverMinParser}, + "cover-max" => {ImagePlug.Transform.Cover, Twicpics.Transform.CoverMaxParser}, "output" => {ImagePlug.Transform.Output, Twicpics.Transform.OutputParser} } + @shadowable_transforms ~w(resize cover focus output) + + # consecutive transforms that can safely be shadowed + # e.g. two consecutive scale operations will only keep the last one + defp shadow_transforms(transform_kvs) do + Enum.reduce(transform_kvs, [], fn + transform, [] -> + [transform] + + {key, _, _} = new, [{prev_key, _, _} | tail] = acc when key == prev_key -> + if Enum.member?(@shadowable_transforms, key) do + [new | tail] + else + [new | acc] + end + + elem, acc -> + [elem | acc] + end) + |> Enum.reverse() + end + @transform_keys Map.keys(@transforms) @query_param "twic" @query_param_prefix "v1/" @@ -53,12 +80,12 @@ defmodule ImagePlug.ParamParser.Twicpics do {transform_name, params_str, key_start_pos}, {:ok, transforms_acc} -> {transform_mod, parser_mod} = Map.get(@transforms, transform_name) - # key start pos + key length + 1 (=-sign) + # key start pos + key length + 1 (the = char) value_pos = key_start_pos + String.length(transform_name) + 1 case parser_mod.parse(params_str, value_pos) do {:ok, parsed_params} -> - {:cont, {:ok, [{transform_mod, parsed_params} | transforms_acc]}} + {:cont, {:ok, [{transform_name, transform_mod, parsed_params} | transforms_acc]}} {:error, _reason} = error -> {:halt, error} @@ -69,8 +96,15 @@ defmodule ImagePlug.ParamParser.Twicpics do error end |> case do - {:ok, transforms} -> {:ok, Enum.reverse(transforms)} - other -> other + {:ok, transforms} -> + {:ok, + transforms + |> Enum.reverse() + |> shadow_transforms() + |> Enum.map(fn {_name, mod, params} -> {mod, params} end)} + + other -> + other end end diff --git a/lib/image_plug/param_parser/twicpics/transform/contain_max_parser.ex b/lib/image_plug/param_parser/twicpics/transform/contain_max_parser.ex new file mode 100644 index 0000000..964bc8a --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/transform/contain_max_parser.ex @@ -0,0 +1,28 @@ +defmodule ImagePlug.ParamParser.Twicpics.Transform.ContainMaxParser do + alias ImagePlug.ParamParser.Twicpics.SizeParser + alias ImagePlug.ParamParser.Twicpics.RatioParser + alias ImagePlug.ParamParser.Twicpics.Utils + alias ImagePlug.Transform.Contain.ContainParams + + @doc """ + Parses a string into a `ImagePlug.Transform.Contain.ContainParams` struct. + + Syntax: + * `contain-max=` + + ## Examples + + iex> ImagePlug.ParamParser.Twicpics.Transform.ContainMaxParser.parse("250x25.5") + {:ok, %ImagePlug.Transform.Contain.ContainParams{width: {:pixels, 250}, height: {:pixels, 25.5}, constraint: :max}} + """ + + def parse(input, pos_offset \\ 0) do + case SizeParser.parse(input, pos_offset) do + {:ok, %{width: width, height: height}} -> + {:ok, %ContainParams{width: width, height: height, constraint: :max}} + + {:error, _reason} = error -> + Utils.update_error_input(error, input) + end + end +end diff --git a/lib/image_plug/param_parser/twicpics/transform/contain_min_parser.ex b/lib/image_plug/param_parser/twicpics/transform/contain_min_parser.ex new file mode 100644 index 0000000..7ace043 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/transform/contain_min_parser.ex @@ -0,0 +1,28 @@ +defmodule ImagePlug.ParamParser.Twicpics.Transform.ContainMinParser do + alias ImagePlug.ParamParser.Twicpics.SizeParser + alias ImagePlug.ParamParser.Twicpics.RatioParser + alias ImagePlug.ParamParser.Twicpics.Utils + alias ImagePlug.Transform.Contain.ContainParams + + @doc """ + Parses a string into a `ImagePlug.Transform.Contain.ContainParams` struct. + + Syntax: + * `contain-min=` + + ## Examples + + iex> ImagePlug.ParamParser.Twicpics.Transform.ContainMinParser.parse("250x25.5") + {:ok, %ImagePlug.Transform.Contain.ContainParams{width: {:pixels, 250}, height: {:pixels, 25.5}, constraint: :min}} + """ + + def parse(input, pos_offset \\ 0) do + case SizeParser.parse(input, pos_offset) do + {:ok, %{width: width, height: height}} -> + {:ok, %ContainParams{width: width, height: height, constraint: :min}} + + {:error, _reason} = error -> + Utils.update_error_input(error, input) + end + end +end diff --git a/lib/image_plug/param_parser/twicpics/transform/contain_parser.ex b/lib/image_plug/param_parser/twicpics/transform/contain_parser.ex index a29bac0..0c23935 100644 --- a/lib/image_plug/param_parser/twicpics/transform/contain_parser.ex +++ b/lib/image_plug/param_parser/twicpics/transform/contain_parser.ex @@ -11,14 +11,15 @@ defmodule ImagePlug.ParamParser.Twicpics.Transform.ContainParser do * `contain=` ## Examples - iex> ImagePlug.ParamParser.Twicpics.Transform.ContainParser.parse("250x25.5") - {:ok, %ImagePlug.Transform.Contain.ContainParams{width: {:pixels, 250}, height: {:pixels, 25.5}}} + + iex> ImagePlug.ParamParser.Twicpics.Transform.ContainParser.parse("250x25.5p") + {:ok, %ImagePlug.Transform.Contain.ContainParams{width: {:pixels, 250}, height: {:percent, 25.5}, constraint: :none}} """ def parse(input, pos_offset \\ 0) do case SizeParser.parse(input, pos_offset) do {:ok, %{width: width, height: height}} -> - {:ok, %ContainParams{width: width, height: height}} + {:ok, %ContainParams{width: width, height: height, constraint: :none}} {:error, _reason} = error -> Utils.update_error_input(error, input) diff --git a/lib/image_plug/param_parser/twicpics/transform/cover_max_parser.ex b/lib/image_plug/param_parser/twicpics/transform/cover_max_parser.ex new file mode 100644 index 0000000..1f0a303 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/transform/cover_max_parser.ex @@ -0,0 +1,28 @@ +defmodule ImagePlug.ParamParser.Twicpics.Transform.CoverMaxParser do + alias ImagePlug.ParamParser.Twicpics.SizeParser + alias ImagePlug.ParamParser.Twicpics.RatioParser + alias ImagePlug.ParamParser.Twicpics.Utils + alias ImagePlug.Transform.Cover.CoverParams + + @doc """ + Parses a string into a `ImagePlug.Transform.Cover.CoverParams` struct. + + Syntax: + * `cover-max=` + + ## Examples + + iex> ImagePlug.ParamParser.Twicpics.Transform.CoverMaxParser.parse("250x25.5") + {:ok, %ImagePlug.Transform.Cover.CoverParams{width: {:pixels, 250}, height: {:pixels, 25.5}, constraint: :max}} + """ + + def parse(input, pos_offset \\ 0) do + case SizeParser.parse(input, pos_offset) do + {:ok, %{width: width, height: height}} -> + {:ok, %CoverParams{width: width, height: height, constraint: :max}} + + {:error, _reason} = error -> + Utils.update_error_input(error, input) + end + end +end diff --git a/lib/image_plug/param_parser/twicpics/transform/cover_min_parser.ex b/lib/image_plug/param_parser/twicpics/transform/cover_min_parser.ex new file mode 100644 index 0000000..821b4d9 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/transform/cover_min_parser.ex @@ -0,0 +1,28 @@ +defmodule ImagePlug.ParamParser.Twicpics.Transform.CoverMinParser do + alias ImagePlug.ParamParser.Twicpics.SizeParser + alias ImagePlug.ParamParser.Twicpics.RatioParser + alias ImagePlug.ParamParser.Twicpics.Utils + alias ImagePlug.Transform.Cover.CoverParams + + @doc """ + Parses a string into a `ImagePlug.Transform.Cover.CoverParams` struct. + + Syntax: + * `cover-min=` + + ## Examples + + iex> ImagePlug.ParamParser.Twicpics.Transform.CoverMinParser.parse("250x25.5") + {:ok, %ImagePlug.Transform.Cover.CoverParams{width: {:pixels, 250}, height: {:pixels, 25.5}, constraint: :min}} + """ + + def parse(input, pos_offset \\ 0) do + case SizeParser.parse(input, pos_offset) do + {:ok, %{width: width, height: height}} -> + {:ok, %CoverParams{width: width, height: height, constraint: :min}} + + {:error, _reason} = error -> + Utils.update_error_input(error, input) + end + end +end diff --git a/lib/image_plug/param_parser/twicpics/transform/cover_parser.ex b/lib/image_plug/param_parser/twicpics/transform/cover_parser.ex new file mode 100644 index 0000000..c3c45c3 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/transform/cover_parser.ex @@ -0,0 +1,48 @@ +defmodule ImagePlug.ParamParser.Twicpics.Transform.CoverParser do + alias ImagePlug.ParamParser.Twicpics.SizeParser + alias ImagePlug.ParamParser.Twicpics.RatioParser + alias ImagePlug.ParamParser.Twicpics.Utils + alias ImagePlug.Transform.Cover.CoverParams + + @doc """ + Parses a string into a `ImagePlug.Transform.Cover.CoverParams` struct. + + Syntax + * `cover=` + * `cover=` + + ## Examples + + iex> ImagePlug.ParamParser.Twicpics.Transform.CoverParser.parse("250x25.5") + {:ok, %ImagePlug.Transform.Cover.CoverParams{type: :dimensions, width: {:pixels, 250}, height: {:pixels, 25.5}, constraint: :none}} + + iex> ImagePlug.ParamParser.Twicpics.Transform.CoverParser.parse("16:9") + {:ok, %ImagePlug.Transform.Cover.CoverParams{type: :ratio, ratio: {16, 9}}} + """ + + def parse(input, pos_offset \\ 0) do + if String.contains?(input, ":"), + do: parse_ratio(input, pos_offset), + else: parse_size(input, pos_offset) + end + + defp parse_ratio(input, pos_offset) do + case RatioParser.parse(input, pos_offset) do + {:ok, %{width: width, height: height}} -> + {:ok, %CoverParams{type: :ratio, ratio: {width, height}}} + + {:error, _reason} = error -> + Utils.update_error_input(error, input) + end + end + + defp parse_size(input, pos_offset) do + case SizeParser.parse(input, pos_offset) do + {:ok, %{width: width, height: height}} -> + {:ok, %CoverParams{type: :dimensions, width: width, height: height, constraint: :none}} + + {:error, _reason} = error -> + Utils.update_error_input(error, input) + end + end +end diff --git a/lib/image_plug/param_parser/twicpics/transform/scale_parser.ex b/lib/image_plug/param_parser/twicpics/transform/scale_parser.ex index 6dde875..6b753a5 100644 --- a/lib/image_plug/param_parser/twicpics/transform/scale_parser.ex +++ b/lib/image_plug/param_parser/twicpics/transform/scale_parser.ex @@ -15,25 +15,25 @@ defmodule ImagePlug.ParamParser.Twicpics.Transform.ScaleParser do ## Examples iex> ImagePlug.ParamParser.Twicpics.Transform.ScaleParser.parse("250x25p") - {:ok, %ImagePlug.Transform.Scale.ScaleParams{method: %ImagePlug.Transform.Scale.ScaleParams.Dimensions{width: {:pixels, 250}, height: {:percent, 25}}}} + {:ok, %ImagePlug.Transform.Scale.ScaleParams{type: :dimensions, width: {:pixels, 250}, height: {:percent, 25}}} iex> ImagePlug.ParamParser.Twicpics.Transform.ScaleParser.parse("-x25p") - {:ok, %ImagePlug.Transform.Scale.ScaleParams{method: %ImagePlug.Transform.Scale.ScaleParams.Dimensions{width: :auto, height: {:percent, 25}}}} + {:ok, %ImagePlug.Transform.Scale.ScaleParams{type: :dimensions, width: :auto, height: {:percent, 25}}} iex> ImagePlug.ParamParser.Twicpics.Transform.ScaleParser.parse("50.5px-") - {:ok, %ImagePlug.Transform.Scale.ScaleParams{method: %ImagePlug.Transform.Scale.ScaleParams.Dimensions{width: {:percent, 50.5}, height: :auto}}} + {:ok, %ImagePlug.Transform.Scale.ScaleParams{type: :dimensions, width: {:percent, 50.5}, height: :auto}} iex> ImagePlug.ParamParser.Twicpics.Transform.ScaleParser.parse("50.5") - {:ok, %ImagePlug.Transform.Scale.ScaleParams{method: %ImagePlug.Transform.Scale.ScaleParams.Dimensions{width: {:pixels, 50.5}, height: :auto}}} + {:ok, %ImagePlug.Transform.Scale.ScaleParams{type: :dimensions, width: {:pixels, 50.5}, height: :auto}} iex> ImagePlug.ParamParser.Twicpics.Transform.ScaleParser.parse("50p") - {:ok, %ImagePlug.Transform.Scale.ScaleParams{method: %ImagePlug.Transform.Scale.ScaleParams.Dimensions{width: {:percent, 50}, height: :auto}}} + {:ok, %ImagePlug.Transform.Scale.ScaleParams{type: :dimensions, width: {:percent, 50}, height: :auto}} iex> ImagePlug.ParamParser.Twicpics.Transform.ScaleParser.parse("(25*10)x(1/2)s") - {:ok, %ImagePlug.Transform.Scale.ScaleParams{method: %ImagePlug.Transform.Scale.ScaleParams.Dimensions{width: {:pixels, 250}, height: {:scale, 0.5}}}} + {:ok, %ImagePlug.Transform.Scale.ScaleParams{type: :dimensions, width: {:pixels, 250}, height: {:scale, 0.5}}} iex> ImagePlug.ParamParser.Twicpics.Transform.ScaleParser.parse("16:9") - {:ok, %ImagePlug.Transform.Scale.ScaleParams{method: %ImagePlug.Transform.Scale.ScaleParams.AspectRatio{aspect_ratio: {:ratio, 16, 9}}}} + {:ok, %ImagePlug.Transform.Scale.ScaleParams{type: :ratio, ratio: {16, 9}}} """ def parse(input, pos_offset \\ 0) do if String.contains?(input, ":"), @@ -44,7 +44,7 @@ defmodule ImagePlug.ParamParser.Twicpics.Transform.ScaleParser do defp parse_ratio(input, pos_offset) do case RatioParser.parse(input, pos_offset) do {:ok, %{width: width, height: height}} -> - {:ok, %ScaleParams{method: %AspectRatio{aspect_ratio: {:ratio, width, height}}}} + {:ok, %ScaleParams{type: :ratio, ratio: {width, height}}} {:error, _reason} = error -> Utils.update_error_input(error, input) @@ -54,7 +54,7 @@ defmodule ImagePlug.ParamParser.Twicpics.Transform.ScaleParser do defp parse_size(input, pos_offset) do case SizeParser.parse(input, pos_offset) do {:ok, %{width: width, height: height}} -> - {:ok, %ScaleParams{method: %Dimensions{width: width, height: height}}} + {:ok, %ScaleParams{type: :dimensions, width: width, height: height}} {:error, _reason} = error -> Utils.update_error_input(error, input) diff --git a/lib/image_plug/transform.ex b/lib/image_plug/transform.ex index 464b4b0..f435379 100644 --- a/lib/image_plug/transform.ex +++ b/lib/image_plug/transform.ex @@ -3,23 +3,4 @@ defmodule ImagePlug.Transform do alias ImagePlug.ArithmeticParser @callback execute(TransformState.t(), String.t()) :: TransformState.t() - - def image_dim(%TransformState{image: image}, :width), do: Image.width(image) - def image_dim(%TransformState{image: image}, :height), do: Image.height(image) - - @spec to_pixels(TransformState.t(), :width | :height, ImagePlug.imgp_length()) :: - {:ok, integer()} | {:error, atom()} - def to_pixels(state, dimension, length) - - def to_pixels(_state, _dimension, {:pixels, num}) do - {:ok, round(num)} - end - - def to_pixels(state, dimension, {:scale, numerator, denominator}) do - {:ok, round(image_dim(state, dimension) * numerator / denominator)} - end - - def to_pixels(state, dimension, {:percent, num}) do - {:ok, round(num / 100 * image_dim(state, dimension))} - end end diff --git a/lib/image_plug/transform/contain.ex b/lib/image_plug/transform/contain.ex index 81e30f3..10a8792 100644 --- a/lib/image_plug/transform/contain.ex +++ b/lib/image_plug/transform/contain.ex @@ -1,43 +1,72 @@ defmodule ImagePlug.Transform.Contain do @behaviour ImagePlug.Transform + import ImagePlug.TransformState + import ImagePlug.Utils + alias ImagePlug.Transform alias ImagePlug.TransformState defmodule ContainParams do - @doc """ - The parsed parameters used by `ImagePlug.Transform.Contain`. - """ - defstruct [:width, :height] + defstruct [:width, :height, :constraint] - @type t :: %__MODULE__{width: ImagePlug.imgp_length(), height: ImagePlug.imgp_length()} + @type t :: + %__MODULE__{ + width: ImagePlug.imgp_length(), + height: ImagePlug.imgp_length() | :auto, + constraint: :regular | :min | :max + } + | %__MODULE__{ + width: ImagePlug.imgp_length() | :auto, + height: ImagePlug.imgp_length(), + constraint: :regular | :min | :max + } end @impl ImagePlug.Transform - def execute(%TransformState{} = state, %ContainParams{width: width, height: height}) do - with {:ok, target_width} <- Transform.to_pixels(state, :width, width), - {:ok, target_height} <- Transform.to_pixels(state, :height, height), - {:ok, width_and_height} <- - fit_inside(state, %{width: target_width, height: target_height}), - {:ok, scaled_image} <- do_scale(state.image, width_and_height) do - %TransformState{state | image: scaled_image} |> TransformState.reset_focus() + def execute(%TransformState{} = state, %ContainParams{ + width: width, + height: height, + constraint: constraint + }) do + {target_width, target_height} = resolve_auto_size(state, width, height) + {resize_width, resize_height} = fit_inside(state, target_width, target_height) + + case maybe_scale(state, resize_width, resize_height, constraint) do + {:ok, scaled_image} -> state |> set_image(scaled_image) |> reset_focus() + {:error, error} -> add_error(state, {__MODULE__, error}) end end - def fit_inside(%TransformState{image: image}, target) do - original_ar = Image.width(image) / Image.height(image) - target_ar = target.width / target.height + def fit_inside(%TransformState{} = state, target_width, target_height) do + original_ar = image_width(state) / image_height(state) + target_ar = target_width / target_height if original_ar > target_ar do - {:ok, %{width: target.width, height: round(target.width / original_ar)}} + {target_width, round(target_width / original_ar)} else - {:ok, %{width: round(target.height * original_ar), height: target.height}} + {round(target_height * original_ar), target_height} end end - def do_scale(image, %{width: width, height: height}) do - width_scale = width / Image.width(image) - height_scale = height / Image.height(image) - Image.resize(image, width_scale, vertical_scale: height_scale) + def maybe_scale(%TransformState{} = state, width, height, :min) do + if width > image_width(state) or height > image_height(state), + do: do_scale(state, width, height), + else: {:ok, state.image} + end + + def maybe_scale(%TransformState{} = state, width, height, :max) do + if width < image_width(state) or height < image_height(state), + do: do_scale(state, width, height), + else: {:ok, state.image} + end + + def maybe_scale(%TransformState{} = state, width, height, _constraint), + do: do_scale(state, width, height) + + def do_scale(%TransformState{} = state, width, height) do + width_scale = width / image_width(state) + height_scale = height / image_height(state) + Image.resize(state.image, width_scale, vertical_scale: height_scale) end end diff --git a/lib/image_plug/transform/cover.ex b/lib/image_plug/transform/cover.ex new file mode 100644 index 0000000..d0d20cf --- /dev/null +++ b/lib/image_plug/transform/cover.ex @@ -0,0 +1,146 @@ +defmodule ImagePlug.Transform.Cover do + @behaviour ImagePlug.Transform + + import ImagePlug.TransformState + import ImagePlug.Utils + + alias ImagePlug.Transform + alias ImagePlug.TransformState + + defmodule CoverParams do + @doc """ + The parsed parameters used by `ImagePlug.Transform.Cover`. + """ + defstruct [:type, :ratio, :width, :height, :constraint] + + @type t :: + %__MODULE__{ + type: :ratio, + ratio: ImagePlug.imgp_ratio() + } + | %__MODULE__{ + type: :dimensions, + width: ImagePlug.imgp_length(), + height: ImagePlug.imgp_length() | :auto, + constraint: :none | :min | :max + } + | %__MODULE__{ + type: :dimensions, + width: ImagePlug.imgp_length() | :auto, + height: ImagePlug.imgp_length(), + constraint: :none | :min | :max + } + end + + @impl ImagePlug.Transform + def execute(%TransformState{} = state, %CoverParams{ + type: :ratio, + ratio: {ratio_width, ratio_height} + }) do + # compute target width and height based on the ratio + image_width = image_width(state) + image_height = image_height(state) + + target_ratio = ratio_width / ratio_height + original_ratio = image_width / image_height + + {target_width, target_height} = + if original_ratio > target_ratio do + # wider image: scale height to match ratio + {round(image_height * target_ratio), image_height} + else + # taller image: scale width to match ratio + {image_width, round(image_width / target_ratio)} + end + + execute(state, %CoverParams{ + type: :dimensions, + width: target_width, + height: target_height, + constraint: :none + }) + end + + @impl ImagePlug.Transform + def execute( + %TransformState{} = state, + %CoverParams{ + width: width, + height: height, + constraint: constraint + } = params + ) do + # convert units to pixels + {crop_width, crop_height} = resolve_auto_size(state, width, height) + + # figure out width/height and get scaled size back + {resize_width, resize_height} = + fit_cover(state, crop_width, crop_height) + + # calculate focus scale based on original image and adjust to scaled size + original_width = image_width(state) + original_height = image_height(state) + {center_x, center_y} = anchor_to_scale_units(state.focus, original_width, original_height) + + scaled_center_x = to_pixels(resize_width, center_x) + scaled_center_y = to_pixels(resize_height, center_y) + + # keep in bounds + left = max(0, min(resize_width - crop_width, round(scaled_center_x - crop_width / 2))) + top = max(0, min(resize_height - crop_height, round(scaled_center_y - crop_height / 2))) + + with {:ok, resized_state} <- maybe_scale(state, resize_width, resize_height, constraint), + {:ok, cropped_state} <- do_crop(resized_state, left, top, crop_width, crop_height) do + reset_focus(cropped_state) + else + {:error, error} -> add_error(state, {__MODULE__, error}) + end + end + + def fit_cover(%TransformState{} = state, target_width, target_height) do + # compute aspect ratios + target_ratio = target_width / target_height + original_ratio = image_width(state) / image_height(state) + + # determine resize dimensions + if original_ratio > target_ratio do + # wider image: scale based on height + {round(target_height * original_ratio), target_height} + else + # taller image: scale based on width + {target_width, round(target_width / original_ratio)} + end + end + + def maybe_scale(%TransformState{} = state, width, height, :min) do + if width > image_width(state) or height > image_height(state), + do: do_scale(state, width, height), + else: {:ok, state} + end + + def maybe_scale(%TransformState{} = state, width, height, :max) do + if width < image_width(state) or height < image_height(state), + do: do_scale(state, width, height), + else: {:ok, state} + end + + def maybe_scale(image, width, height, _constraint), + do: do_scale(image, width, height) + + def do_scale(%TransformState{} = state, width, height) do + width_scale = width / image_width(state) + height_scale = height / image_height(state) + + case Image.resize(state.image, width_scale, vertical_scale: height_scale) do + {:ok, resized_image} -> {:ok, set_image(state, resized_image)} + {:error, _reason} = error -> error + end + end + + def do_crop(%TransformState{} = state, left, top, width, height) do + case Image.crop(state.image, left, top, width, height) do + {:ok, cropped_image} -> {:ok, set_image(state, cropped_image)} + {:error, _reason} = error -> error + end + end +end diff --git a/lib/image_plug/transform/crop.ex b/lib/image_plug/transform/crop.ex index 13fc51a..d21507c 100644 --- a/lib/image_plug/transform/crop.ex +++ b/lib/image_plug/transform/crop.ex @@ -1,6 +1,9 @@ defmodule ImagePlug.Transform.Crop do @behaviour ImagePlug.Transform + import ImagePlug.TransformState + import ImagePlug.Utils + alias ImagePlug.Transform alias ImagePlug.TransformState @@ -13,84 +16,66 @@ defmodule ImagePlug.Transform.Crop do @type t :: %__MODULE__{ width: ImagePlug.imgp_length(), height: ImagePlug.imgp_length(), + # todo: make the parser output focus + crop actions instead of handling this special crop_from stuff? crop_from: :focus | %{left: ImagePlug.imgp_length(), top: ImagePlug.imgp_length()} } end @impl ImagePlug.Transform - def execute(%TransformState{} = state, %CropParams{} = parameters) do - with coord_mapped_params <- map_params_to_pixels(state, parameters), - anchored_params <- anchor_crop(state, coord_mapped_params), - clamped_params <- clamp(state, anchored_params), - {:ok, cropped_image} <- do_crop(state.image, clamped_params) do - %ImagePlug.TransformState{state | image: cropped_image} |> TransformState.reset_focus() - else - {:error, error} -> - %ImagePlug.TransformState{state | errors: [{__MODULE__, error} | state.errors]} - end - end - - defp anchor_crop(%TransformState{}, %{ - crop_from: %{left: left, top: top}, - width: width, - height: height - }) do - %{width: width, height: height, left: left, top: top} - end + def execute(%TransformState{} = state, %CropParams{} = params) do + image_width = image_width(state) + image_height = image_height(state) - defp anchor_crop( - %TransformState{} = state, - %{crop_from: :focus, width: width, height: height} = params - ) do - center_x = - case state.focus do - {:anchor, :left, _} -> width / 2 - {:anchor, :center, _} -> Image.width(state.image) / 2 - {:anchor, :right, _} -> Image.width(state.image) - width / 2 - {:coordinate, left, _top} -> left - end + # make sure crop is within image bounds + crop_width = max(1, min(image_width, to_pixels(image_width, params.width))) + crop_height = max(1, min(image_height, to_pixels(image_height, params.height))) - center_y = - case state.focus do - {:anchor, _, :top} -> height / 2 - {:anchor, _, :center} -> Image.height(state.image) / 2 - {:anchor, _, :bottom} -> Image.height(state.image) - height / 2 - {:coordinate, _left, top} -> top - end + # figure out the crop anchor + {center_x, center_y} = + anchor_crop_to_pixels( + state, + params.crop_from, + image_width, + image_height, + crop_width, + crop_height + ) - left = center_x - width / 2 - top = center_y - height / 2 + # ...and make sure crop still stays within bounds + left = max(0, min(image_width - crop_width, round(center_x - crop_width / 2))) + top = max(0, min(image_height - crop_height, round(center_y - crop_height / 2))) - %{width: width, height: height, left: round(left), top: round(top)} - end - - # clamps the crop area to stay withing the image boundaries - def clamp(%TransformState{image: image}, %{width: width, height: height, top: top, left: left}) do - clamped_width = max(min(Image.width(image), width), 1) - clamped_height = max(min(Image.height(image), height), 1) - clamped_left = max(min(Image.width(image) - clamped_width, left), 0) - clamped_top = max(min(Image.height(image) - clamped_height, top), 0) - %{width: clamped_width, height: clamped_height, left: clamped_left, top: clamped_top} - end - - def do_crop(image, %{width: width, height: height, top: top, left: left}) do - Image.crop(image, left, top, width, height) - end - - def map_crop_from_to_pixels(state, %{left: left, top: top}) do - with {:ok, mapped_left} <- Transform.to_pixels(state, :width, left), - {:ok, mapped_top} <- Transform.to_pixels(state, :height, top) do - {:ok, %{left: mapped_left, top: mapped_top}} + # execute crop + case Image.crop(state.image, left, top, crop_width, crop_height) do + {:ok, cropped_image} -> state |> set_image(cropped_image) |> reset_focus() + {:error, error} -> add_error(state, {__MODULE__, error}) end end - def map_crop_from_to_pixels(_state, :focus), do: {:ok, :focus} + defp anchor_crop_to_pixels( + %TransformState{} = state, + %{left: left, top: top}, + image_width, + image_height, + crop_width, + crop_height + ) do + # if explicit coordinates are given, they are to be the top-left corner of the crop, + # so we need to move the center point based on the crop dimensions + {left, top} = anchor_to_pixels({:coordinate, left, top}, image_width, image_height) + center_x = round(left + crop_width / 2) + center_y = round(top + crop_height / 2) + {center_x, center_y} + end - def map_params_to_pixels(state, %CropParams{width: width, height: height, crop_from: crop_from}) do - with {:ok, mapped_width} <- Transform.to_pixels(state, :width, width), - {:ok, mapped_height} <- Transform.to_pixels(state, :height, height), - {:ok, mapped_crop_from} <- map_crop_from_to_pixels(state, crop_from) do - %{width: mapped_width, height: mapped_height, crop_from: mapped_crop_from} - end + defp anchor_crop_to_pixels( + %TransformState{} = state, + :focus, + image_width, + image_height, + _crop_width, + _crop_height + ) do + anchor_to_pixels(state.focus, image_width, image_height) end end diff --git a/lib/image_plug/transform/focus.ex b/lib/image_plug/transform/focus.ex index eaae24f..028ed71 100644 --- a/lib/image_plug/transform/focus.ex +++ b/lib/image_plug/transform/focus.ex @@ -1,6 +1,9 @@ defmodule ImagePlug.Transform.Focus do @behaviour ImagePlug.Transform + import ImagePlug.TransformState + import ImagePlug.Utils + alias ImagePlug.Transform alias ImagePlug.TransformState @@ -16,24 +19,29 @@ defmodule ImagePlug.Transform.Focus do end @impl ImagePlug.Transform - def execute(%TransformState{image: image} = state, %FocusParams{type: {:coordinate, left, top}}) do - with {:ok, left} <- Transform.to_pixels(state, :width, left), - {:ok, top} <- Transform.to_pixels(state, :height, top) do - %ImagePlug.TransformState{ - state - | image: image, - focus: - {:coordinate, max(min(Image.width(image), left), 0), - max(min(Image.height(image), top), 0)} - } - else - {:error, error} -> - %ImagePlug.TransformState{state | errors: [{__MODULE__, error} | state.errors]} - end + def execute(%TransformState{} = state, %FocusParams{type: {:coordinate, left, top}}) do + left = to_pixels(image_width(state), left) + top = to_pixels(image_height(state), top) + + focus = + {:coordinate, max(min(image_width(state), left), 0), max(min(image_height(state), top), 0)} + + state + |> set_focus(focus) + |> maybe_draw_debug_dot() end @impl ImagePlug.Transform def execute(%TransformState{image: image} = state, %FocusParams{type: {:anchor, x, y}}) do - %ImagePlug.TransformState{state | image: image, focus: {:anchor, x, y}} + state + |> set_focus({:anchor, x, y}) + |> maybe_draw_debug_dot() end + + defp maybe_draw_debug_dot(%TransformState{debug: true, focus: focus} = state) do + {left, top} = anchor_to_pixels(focus, image_width(state), image_height(state)) + draw_debug_dot(state, left, top) + end + + defp maybe_draw_debug_dot(state, _focus), do: state end diff --git a/lib/image_plug/transform/scale.ex b/lib/image_plug/transform/scale.ex index 8e64c28..1232c88 100644 --- a/lib/image_plug/transform/scale.ex +++ b/lib/image_plug/transform/scale.ex @@ -1,85 +1,82 @@ defmodule ImagePlug.Transform.Scale do @behaviour ImagePlug.Transform + import ImagePlug.TransformState + import ImagePlug.Utils + alias ImagePlug.Transform alias ImagePlug.TransformState defmodule ScaleParams do - defmodule Dimensions do - defstruct [:width, :height] - - @type t :: - %__MODULE__{width: ImagePlug.imgp_length() | :auto, height: ImagePlug.imgp_length()} - | %__MODULE__{ - width: ImagePlug.imgp_length(), - height: ImagePlug.imgp_length() | :auto - } - end - - defmodule AspectRatio do - defstruct [:aspect_ratio] - - @type t :: %__MODULE__{aspect_ratio: ImagePlug.imgp_ratio()} - end - - @doc """ - The parsed parameters used by `ImagePlug.Transform.Scale`. - """ - defstruct [:method] - - @type t :: %__MODULE__{method: Dimension.t() | AspectRatio.t()} - end - - defp dimensions_for_scale_method(state, %ScaleParams.Dimensions{width: width, height: height}) do - with {:ok, width} <- to_pixels(state, :width, width), - {:ok, height} <- to_pixels(state, :height, height) do - {:ok, %{width: width, height: height}} - end + defstruct [:type, :ratio, :width, :height] + + @type t :: + %__MODULE__{ + type: :ratio, + ratio: {ImagePlug.imgp_ratio(), ImagePlug.imgp_ratio()} + } + | %__MODULE__{ + type: :dimensions, + width: ImagePlug.imgp_length(), + height: ImagePlug.imgp_length() | :auto + } + | %__MODULE__{ + type: :dimensions, + width: ImagePlug.imgp_length() | :auto, + height: ImagePlug.imgp_length() + } end - defp dimensions_for_scale_method(state, %ScaleParams.AspectRatio{ - aspect_ratio: {:ratio, ar_w, ar_h} + defp dimensions_for_scale_type(state, %ScaleParams{ + type: :dimensions, + width: width, + height: height }) do - with {:ok, aspect_width} <- Transform.eval_number(ar_w), - {:ok, aspect_height} <- Transform.eval_number(ar_h) do - current_area = Image.width(state.image) * Image.height(state.image) - target_height = :math.sqrt(current_area * aspect_height / aspect_width) - target_width = target_height * aspect_width / aspect_height - target_width = round(target_width) - target_height = round(target_height) + width = to_pixels_or_auto(image_width(state), width) + height = to_pixels_or_auto(image_height(state), height) + %{width: width, height: height} + end - {:ok, %{width: target_width, height: target_height}} - end + defp dimensions_for_scale_type( + state, + %ScaleParams{type: :ratio, ratio: {ratio_width, ratio_height}} = params + ) do + current_area = image_width(state) * image_height(state) + target_height = :math.sqrt(current_area * ratio_height / ratio_width) + target_width = target_height * ratio_width / ratio_height + %{width: round(target_width), height: round(target_height)} end @impl ImagePlug.Transform - def execute(%TransformState{} = state, %ScaleParams{method: scale_method}) do - with {:ok, width_and_height} <- dimensions_for_scale_method(state, scale_method), - {:ok, scaled_image} <- do_scale(state.image, width_and_height) do - %TransformState{state | image: scaled_image} |> TransformState.reset_focus() + def execute(%TransformState{} = state, %ScaleParams{} = params) do + %{width: width, height: height} = dimensions_for_scale_type(state, params) + + case do_scale(state, width, height) do + {:ok, image} -> state |> set_image(image) |> reset_focus() + {:error, _reason} = error -> add_error(state, {__MODULE__, error}) end end - def do_scale(image, %{width: width, height: :auto}) do - scale = width / Image.width(image) - Image.resize(image, scale) + def do_scale(%TransformState{} = state, width, :auto) do + scale = width / image_width(state) + Image.resize(state.image, scale) end - def do_scale(image, %{width: :auto, height: height}) do - scale = height / Image.height(image) - Image.resize(image, scale) + def do_scale(%TransformState{} = state, :auto, height) do + scale = height / image_height(state) + Image.resize(state.image, scale) end - def do_scale(image, %{width: width, height: height}) do - width_scale = width / Image.width(image) - height_scale = height / Image.height(image) - Image.resize(image, width_scale, vertical_scale: height_scale) + def do_scale(%TransformState{} = state, width, height) do + width_scale = width / image_width(state) + height_scale = height / image_height(state) + Image.resize(state.image, width_scale, vertical_scale: height_scale) end def do_scale(_image, parameters) do {:error, {:unhandled_scale_parameters, parameters}} end - def to_pixels(_state, _dimension, :auto), do: {:ok, :auto} - def to_pixels(state, dimension, length), do: Transform.to_pixels(state, dimension, length) + defp to_pixels_or_auto(_length, :auto), do: :auto + defp to_pixels_or_auto(length, size_unit), do: to_pixels(length, size_unit) end diff --git a/lib/image_plug/transform_state.ex b/lib/image_plug/transform_state.ex index 6fcf4fb..3ae26e0 100644 --- a/lib/image_plug/transform_state.ex +++ b/lib/image_plug/transform_state.ex @@ -4,7 +4,8 @@ defmodule ImagePlug.TransformState do defstruct image: nil, focus: @default_focus, errors: [], - output: :auto + output: :auto, + debug: true @type file_format() :: :avif | :webp | :jpeg | :png @type preview_format() :: :blurhash @@ -27,9 +28,21 @@ defmodule ImagePlug.TransformState do output: output_format() } - def default_focus, do: @default_focus + defp default_focus, do: @default_focus + + def set_focus(%__MODULE__{} = state, focus) do + %__MODULE__{state | focus: focus} + end def reset_focus(%__MODULE__{} = state) do - %__MODULE__{state | focus: default_focus()} + set_focus(state, default_focus()) + end + + def set_image(%__MODULE__{} = state, %Vix.Vips.Image{} = image) do + %__MODULE__{state | image: image} + end + + def add_error(%__MODULE__{} = state, error) do + %__MODULE__{state | errors: [error | state.errors]} end end diff --git a/lib/image_plug/utils.ex b/lib/image_plug/utils.ex new file mode 100644 index 0000000..04e5d82 --- /dev/null +++ b/lib/image_plug/utils.ex @@ -0,0 +1,78 @@ +defmodule ImagePlug.Utils do + alias ImagePlug.TransformState + + def image_height(%TransformState{image: image}), do: Image.height(image) + def image_width(%TransformState{image: image}), do: Image.width(image) + + @spec to_pixels(integer(), ImagePlug.imgp_length()) :: integer() + def to_pixels(length, size_unit) + def to_pixels(_length, num) when is_integer(num), do: num + def to_pixels(_length, num) when is_float(num), do: round(num) + def to_pixels(_length, {:pixels, num}), do: round(num) + + def to_pixels(length, {:scale, numerator, denominator}), + do: round(length * numerator / denominator) + + def to_pixels(length, {:percent, percent}), do: round(percent / 100 * length) + + def anchor_to_scale_units(focus, width, height) do + x_scale = + case focus do + {:anchor, :left, _} -> {:scale, 0, 2} + {:anchor, :center, _} -> {:scale, 1, 2} + {:anchor, :right, _} -> {:scale, 1, 1} + {:coordinate, left, _top} -> {:scale, to_pixels(width, left), width} + end + + y_scale = + case focus do + {:anchor, _, :top} -> {:scale, 0, 1} + {:anchor, _, :center} -> {:scale, 1, 2} + {:anchor, _, :bottom} -> {:scale, 1, 1} + {:coordinate, _left, top} -> {:scale, to_pixels(height, top), height} + end + + {x_scale, y_scale} + end + + def anchor_to_pixels(focus, width, height) do + case anchor_to_scale_units(focus, width, height) do + {x_scale, y_scale} -> + {to_pixels(width, x_scale), to_pixels(height, y_scale)} + end + end + + def resolve_auto_size(%TransformState{image: image} = state, width, :auto) do + aspect_ratio = image_height(state) / image_width(state) + auto_height = round(to_pixels(image_width(state), width) * aspect_ratio) + {to_pixels(image_width(state), width), auto_height} + end + + def resolve_auto_size(%TransformState{image: image} = state, :auto, height) do + aspect_ratio = image_width(state) / image_height(state) + auto_width = round(to_pixels(image_height(state), height) * aspect_ratio) + {auto_width, to_pixels(image_height(state), height)} + end + + def resolve_auto_size(%TransformState{image: image} = state, width, height) do + {to_pixels(image_width(state), width), to_pixels(image_height(state), height)} + end + + def draw_debug_dot( + %TransformState{} = state, + left, + top, + dot_color \\ :red, + border_color \\ :white + ) do + left = to_pixels(image_width(state), left) + top = to_pixels(image_height(state), top) + + image_with_debug_dot = + state.image + |> Image.Draw.circle!(left, top, 9, color: border_color) + |> Image.Draw.circle!(left, top, 5, color: dot_color) + + TransformState.set_image(state, image_with_debug_dot) + end +end diff --git a/test/param_parser/twicpics_parser_test.exs b/test/param_parser/twicpics_parser_test.exs index a354ba6..8367d7a 100644 --- a/test/param_parser/twicpics_parser_test.exs +++ b/test/param_parser/twicpics_parser_test.exs @@ -14,6 +14,11 @@ defmodule ImagePlug.ParamParser.TwicpicsParserTest do doctest ImagePlug.ParamParser.Twicpics.Transform.ScaleParser doctest ImagePlug.ParamParser.Twicpics.Transform.FocusParser doctest ImagePlug.ParamParser.Twicpics.Transform.ContainParser + doctest ImagePlug.ParamParser.Twicpics.Transform.ContainMinParser + doctest ImagePlug.ParamParser.Twicpics.Transform.ContainMaxParser + doctest ImagePlug.ParamParser.Twicpics.Transform.CoverParser + doctest ImagePlug.ParamParser.Twicpics.Transform.CoverMinParser + doctest ImagePlug.ParamParser.Twicpics.Transform.CoverMaxParser doctest ImagePlug.ParamParser.Twicpics.Transform.OutputParser defp length_str({:pixels, unit}), do: "#{unit}" @@ -103,37 +108,37 @@ defmodule ImagePlug.ParamParser.TwicpicsParserTest do {:auto_width, {height}} -> {"-x#{length_str(height)}", %Scale.ScaleParams{ - method: %Scale.ScaleParams.Dimensions{width: :auto, height: to_result(height)} + type: :dimensions, + width: :auto, + height: to_result(height) }} {:auto_height, {width}} -> {"#{length_str(width)}x-", %Scale.ScaleParams{ - method: %Scale.ScaleParams.Dimensions{width: to_result(width), height: :auto} + type: :dimensions, + width: to_result(width), + height: :auto }} {:simple, {width}} -> {"#{length_str(width)}", %Scale.ScaleParams{ - method: %Scale.ScaleParams.Dimensions{width: to_result(width), height: :auto} + type: :dimensions, + width: to_result(width), + height: :auto }} {:width_and_height, {width, height}} -> {"#{length_str(width)}x#{length_str(height)}", %Scale.ScaleParams{ - method: %Scale.ScaleParams.Dimensions{ - width: to_result(width), - height: to_result(height) - } + type: :dimensions, + width: to_result(width), + height: to_result(height) }} {:aspect_ratio, {ar_w, ar_h}} -> - {"#{ar_w}:#{ar_h}", - %Scale.ScaleParams{ - method: %Scale.ScaleParams.AspectRatio{ - aspect_ratio: {:ratio, ar_w, ar_h} - } - }} + {"#{ar_w}:#{ar_h}", %Scale.ScaleParams{type: :ratio, ratio: {ar_w, ar_h}}} end {:ok, parsed} = Twicpics.Transform.ScaleParser.parse(str_params) @@ -148,7 +153,12 @@ defmodule ImagePlug.ParamParser.TwicpicsParserTest do str_params = "#{length_str(width)}x#{length_str(height)}" parsed = Twicpics.Transform.ContainParser.parse(str_params) - assert {:ok, %Contain.ContainParams{width: to_result(width), height: to_result(height)}} == + assert {:ok, + %Contain.ContainParams{ + width: to_result(width), + height: to_result(height), + constraint: :none + }} == parsed end end diff --git a/test/param_parser/twicpics_test.exs b/test/param_parser/twicpics_test.exs index 699cc88..8997444 100644 --- a/test/param_parser/twicpics_test.exs +++ b/test/param_parser/twicpics_test.exs @@ -31,10 +31,9 @@ defmodule ImagePlug.TwicpicsTest do }}, {Transform.Scale, %Transform.Scale.ScaleParams{ - method: %Transform.Scale.ScaleParams.Dimensions{ - width: {:pixels, 200}, - height: :auto - } + type: :dimensions, + width: {:pixels, 200}, + height: :auto }}, {Transform.Output, %Transform.Output.OutputParams{