From de7f149689b292aa995694b622c4ab9dead77b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Wed, 11 Dec 2024 23:19:18 +0100 Subject: [PATCH 01/13] add contain & cover --- lib/image_plug.ex | 2 +- lib/image_plug/param_parser/twicpics.ex | 40 ++++- .../twicpics/transform/contain_max_parser.ex | 28 ++++ .../twicpics/transform/contain_min_parser.ex | 28 ++++ .../twicpics/transform/contain_parser.ex | 7 +- .../twicpics/transform/cover_parser.ex | 48 ++++++ .../twicpics/transform/scale_parser.ex | 18 +- lib/image_plug/transform/contain.ex | 35 +++- lib/image_plug/transform/cover.ex | 157 ++++++++++++++++++ lib/image_plug/transform/scale.ex | 54 +++--- 10 files changed, 364 insertions(+), 53 deletions(-) create mode 100644 lib/image_plug/param_parser/twicpics/transform/contain_max_parser.ex create mode 100644 lib/image_plug/param_parser/twicpics/transform/contain_min_parser.ex create mode 100644 lib/image_plug/param_parser/twicpics/transform/cover_parser.ex create mode 100644 lib/image_plug/transform/cover.ex 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..6175159 100644 --- a/lib/image_plug/param_parser/twicpics.ex +++ b/lib/image_plug/param_parser/twicpics.ex @@ -12,9 +12,34 @@ 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}, "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 +78,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 +94,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..3287bb1 --- /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.ContainParser.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..5a9ad4a --- /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.ContainParser.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..49fd989 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: {:percent, 250}, height: {:pixels, 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_parser.ex b/lib/image_plug/param_parser/twicpics/transform/cover_parser.ex new file mode 100644 index 0000000..c446ee9 --- /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}, constraint: :none}} + """ + + 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}}, constraint: :none} + + {: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/contain.ex b/lib/image_plug/transform/contain.ex index 81e30f3..2f87f28 100644 --- a/lib/image_plug/transform/contain.ex +++ b/lib/image_plug/transform/contain.ex @@ -5,21 +5,28 @@ defmodule ImagePlug.Transform.Contain do 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(), + constraint: :regular | :min | :max + } end @impl ImagePlug.Transform - def execute(%TransformState{} = state, %ContainParams{width: width, height: height}) do + def execute(%TransformState{} = state, %ContainParams{ + width: width, + height: height, + constraint: constraint + }) 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 + {:ok, scaled_image} <- + maybe_scale(state.image, Map.merge(width_and_height, %{constraint: constraint})) do %TransformState{state | image: scaled_image} |> TransformState.reset_focus() end end @@ -35,6 +42,20 @@ defmodule ImagePlug.Transform.Contain do end end + def maybe_scale(image, %{width: width, height: height, constraint: :min} = params) do + if width > Image.width(image) or height > Image.height(image), + do: do_scale(image, params), + else: {:ok, image} + end + + def maybe_scale(image, %{width: width, height: height, constraint: :max} = params) do + if width < Image.width(image) or height < Image.height(image), + do: do_scale(image, params), + else: {:ok, image} + end + + def maybe_scale(image, params), do: do_scale(image, params) + def do_scale(image, %{width: width, height: height}) do width_scale = width / Image.width(image) height_scale = height / Image.height(image) diff --git a/lib/image_plug/transform/cover.ex b/lib/image_plug/transform/cover.ex new file mode 100644 index 0000000..0f5d14d --- /dev/null +++ b/lib/image_plug/transform/cover.ex @@ -0,0 +1,157 @@ +defmodule ImagePlug.Transform.Cover do + @behaviour ImagePlug.Transform + + 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(), ImagePlug.imgp_ratio()}, + constraint: :regular | :min | :max + } + | %__MODULE__{ + type: :dimensions, + width: ImagePlug.imgp_length(), + height: ImagePlug.imgp_length() | :auto, + constraint: :regular | :min | :max + } + | %__MODULE__{ + type: :dimensions, + width: ImagePlug.imgp_length() | :auto, + height: ImagePlug.imgp_length(), + constraint: :regular | :min | :max + } + end + + @impl ImagePlug.Transform + def execute(%TransformState{} = state, %CoverParams{ + width: width, + height: height, + constraint: constraint + }) do + original_width = Image.width(state.image) + original_height = Image.height(state.image) + + with {:ok, target_width} <- + Transform.to_pixels(state, :width, width), + {:ok, target_height} <- + Transform.to_pixels(state, :height, height), + {:ok, width_and_height} <- + fit_cover(state, %{width: target_width, height: target_height}), + {:ok, state} <- + maybe_scale(state, Map.merge(width_and_height, %{constraint: constraint})), + anchored_crop_params <- + anchor_crop(state, %{ + width: target_width, + height: target_height, + original_width: original_width, + original_height: original_height + }), + clamped_crop_params <- clamp(state, anchored_crop_params), + {:ok, state} <- do_crop(state, clamped_crop_params) do + state |> TransformState.reset_focus() + end + end + + def fit_cover(%TransformState{image: image}, target) do + original_ar = Image.width(image) / Image.height(image) + target_ar = target.width / target.height + + if original_ar > target_ar do + scaled_width = round(target.height * original_ar) + {:ok, %{width: scaled_width, height: target.height}} + else + scaled_height = round(target.width / original_ar) + {:ok, %{width: target.width, height: scaled_height}} + end + end + + defp anchor_crop( + %TransformState{} = state, + %{ + width: width, + height: height, + original_width: original_width, + original_height: original_height + } + ) 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} -> width / original_width * left + end + + 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} -> height / original_height * top + end + + left = center_x - width / 2 + top = center_y - 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 maybe_scale( + %TransformState{image: image} = state, + %{width: width, height: height, constraint: :min} = params + ) do + if width > Image.width(image) or height > Image.height(image), + do: do_scale(state, params), + else: {:ok, state} + end + + def maybe_scale( + %TransformState{image: image} = state, + %{width: width, height: height, constraint: :max} = params + ) do + if width < Image.width(image) or height < Image.height(image), + do: do_scale(state, params), + else: {:ok, state} + end + + def maybe_scale(image, params), do: do_scale(image, params) + + def do_scale(%TransformState{image: image} = state, %{width: width, height: height}) do + width_scale = width / Image.width(image) + height_scale = height / Image.height(image) + + case Image.resize(image, width_scale, vertical_scale: height_scale) do + {:ok, resized_image} -> {:ok, %TransformState{state | image: resized_image}} + {:error, _reason} = error -> error + end + end + + def do_crop(%TransformState{image: image} = state, %{ + width: width, + height: height, + top: top, + left: left + }) do + case Image.crop(image, left, top, width, height) do + {:ok, cropped_image} -> {:ok, %TransformState{state | image: cropped_image}} + {:error, _reason} = error -> error + end + end +end diff --git a/lib/image_plug/transform/scale.ex b/lib/image_plug/transform/scale.ex index 8e64c28..4c3a521 100644 --- a/lib/image_plug/transform/scale.ex +++ b/lib/image_plug/transform/scale.ex @@ -5,41 +5,37 @@ defmodule ImagePlug.Transform.Scale do 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()} + 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.Dimensions{width: width, height: height}) do + defp dimensions_for_scale_type(state, %ScaleParams{ + type: :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 end - defp dimensions_for_scale_method(state, %ScaleParams.AspectRatio{ - aspect_ratio: {:ratio, ar_w, ar_h} - }) do + defp dimensions_for_scale_type(state, %ScaleParams{type: :ratio, ratio: {ar_w, ar_h}}) 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) @@ -53,8 +49,8 @@ defmodule ImagePlug.Transform.Scale do 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), + def execute(%TransformState{} = state, %ScaleParams{} = params) do + with {:ok, width_and_height} <- dimensions_for_scale_type(state, params), {:ok, scaled_image} <- do_scale(state.image, width_and_height) do %TransformState{state | image: scaled_image} |> TransformState.reset_focus() end From aa6f730fe90ad03613211321d6c48fe624bbbe1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Fri, 13 Dec 2024 11:20:42 +0100 Subject: [PATCH 02/13] heavy refactoring --- .../twicpics/transform/contain_parser.ex | 2 +- lib/image_plug/transform.ex | 19 --- lib/image_plug/transform/contain.ex | 54 +++---- lib/image_plug/transform/cover.ex | 136 ++++++++---------- lib/image_plug/transform/crop.ex | 98 ++++--------- lib/image_plug/transform/focus.ex | 29 ++-- lib/image_plug/transform/scale.ex | 61 ++++---- lib/image_plug/transform_state.ex | 16 ++- lib/image_plug/utils.ex | 48 +++++++ test/param_parser/twicpics_parser_test.exs | 28 ++-- test/param_parser/twicpics_test.exs | 7 +- 11 files changed, 240 insertions(+), 258 deletions(-) create mode 100644 lib/image_plug/utils.ex 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 49fd989..0c23935 100644 --- a/lib/image_plug/param_parser/twicpics/transform/contain_parser.ex +++ b/lib/image_plug/param_parser/twicpics/transform/contain_parser.ex @@ -13,7 +13,7 @@ defmodule ImagePlug.ParamParser.Twicpics.Transform.ContainParser do ## Examples iex> ImagePlug.ParamParser.Twicpics.Transform.ContainParser.parse("250x25.5p") - {:ok, %ImagePlug.Transform.Contain.ContainParams{width: {:percent, 250}, height: {:pixels, 25.5}, constraint: :none}} + {:ok, %ImagePlug.Transform.Contain.ContainParams{width: {:pixels, 250}, height: {:percent, 25.5}, constraint: :none}} """ def parse(input, pos_offset \\ 0) do 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 2f87f28..f0fe88c 100644 --- a/lib/image_plug/transform/contain.ex +++ b/lib/image_plug/transform/contain.ex @@ -1,6 +1,9 @@ defmodule ImagePlug.Transform.Contain do @behaviour ImagePlug.Transform + import ImagePlug.TransformState + import ImagePlug.Utils + alias ImagePlug.Transform alias ImagePlug.TransformState @@ -21,44 +24,45 @@ defmodule ImagePlug.Transform.Contain do height: height, constraint: constraint }) 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} <- - maybe_scale(state.image, Map.merge(width_and_height, %{constraint: constraint})) do - %TransformState{state | image: scaled_image} |> TransformState.reset_focus() + target_width = to_pixels(state, :x, width) + target_height = to_pixels(state, :y, 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 maybe_scale(image, %{width: width, height: height, constraint: :min} = params) do - if width > Image.width(image) or height > Image.height(image), - do: do_scale(image, params), - else: {:ok, image} + 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(image, %{width: width, height: height, constraint: :max} = params) do - if width < Image.width(image) or height < Image.height(image), - do: do_scale(image, params), - else: {:ok, image} + 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(image, params), do: do_scale(image, params) + def maybe_scale(%TransformState{} = state, width, height, _constraint), + do: do_scale(state, width, height) - 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 end diff --git a/lib/image_plug/transform/cover.ex b/lib/image_plug/transform/cover.ex index 0f5d14d..ffb7dad 100644 --- a/lib/image_plug/transform/cover.ex +++ b/lib/image_plug/transform/cover.ex @@ -1,6 +1,9 @@ defmodule ImagePlug.Transform.Cover do @behaviour ImagePlug.Transform + import ImagePlug.TransformState + import ImagePlug.Utils + alias ImagePlug.Transform alias ImagePlug.TransformState @@ -36,109 +39,86 @@ defmodule ImagePlug.Transform.Cover do height: height, constraint: constraint }) do - original_width = Image.width(state.image) - original_height = Image.height(state.image) - - with {:ok, target_width} <- - Transform.to_pixels(state, :width, width), - {:ok, target_height} <- - Transform.to_pixels(state, :height, height), - {:ok, width_and_height} <- - fit_cover(state, %{width: target_width, height: target_height}), - {:ok, state} <- - maybe_scale(state, Map.merge(width_and_height, %{constraint: constraint})), - anchored_crop_params <- - anchor_crop(state, %{ - width: target_width, - height: target_height, - original_width: original_width, - original_height: original_height + # convert units to pixels + target_width = to_pixels(state, :x, width) + target_height = to_pixels(state, :y, height) + + # figure out width/height + {resize_width, resize_height} = fit_cover(state, target_width, target_height) + + # calculate focus point based on the resized image size, because we'll be resizing before the crop action + {focus_left, focus_top} = + anchor_to_coord(state.focus, %{ + image_width: resize_width, + image_height: resize_height, + target_width: target_width, + target_height: target_height + }) + + # ensure focus_left/focus_top are within bounds + left = max(0, min(resize_width - target_width, focus_left)) + top = max(0, min(resize_height - target_height, focus_top)) + + with {:ok, scaled_state} <- + maybe_scale(state, %{ + width: resize_width, + height: resize_height, + constraint: constraint }), - clamped_crop_params <- clamp(state, anchored_crop_params), - {:ok, state} <- do_crop(state, clamped_crop_params) do - state |> TransformState.reset_focus() + {:ok, cropped_state} <- + do_crop(scaled_state, %{ + left: left, + top: top, + width: target_width, + height: target_height + }) do + reset_focus(cropped_state) + else + {:error, error} -> add_error(state, {__MODULE__, error}) end end - def fit_cover(%TransformState{image: image}, target) do - original_ar = Image.width(image) / Image.height(image) - target_ar = target.width / target.height + 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) - if original_ar > target_ar do - scaled_width = round(target.height * original_ar) - {:ok, %{width: scaled_width, height: target.height}} + # determine resize dimensions + if original_ratio > target_ratio do + # wider image: scale based on height + {round(target_height * original_ratio), target_height} else - scaled_height = round(target.width / original_ar) - {:ok, %{width: target.width, height: scaled_height}} + # taller image: scale based on width + {target_width, round(target_width / original_ratio)} end end - defp anchor_crop( - %TransformState{} = state, - %{ - width: width, - height: height, - original_width: original_width, - original_height: original_height - } - ) 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} -> width / original_width * left - end - - 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} -> height / original_height * top - end - - left = center_x - width / 2 - top = center_y - 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 maybe_scale( - %TransformState{image: image} = state, + %TransformState{} = state, %{width: width, height: height, constraint: :min} = params ) do - if width > Image.width(image) or height > Image.height(image), + if width > image_width(state) or height > image_height(state), do: do_scale(state, params), else: {:ok, state} end def maybe_scale( - %TransformState{image: image} = state, + %TransformState{} = state, %{width: width, height: height, constraint: :max} = params ) do - if width < Image.width(image) or height < Image.height(image), + if width < image_width(state) or height < image_height(state), do: do_scale(state, params), else: {:ok, state} end def maybe_scale(image, params), do: do_scale(image, params) - def do_scale(%TransformState{image: image} = state, %{width: width, height: height}) do - width_scale = width / Image.width(image) - height_scale = height / Image.height(image) + def do_scale(%TransformState{} = state, %{width: width, height: height}) do + width_scale = width / image_width(state) + height_scale = height / image_height(state) - case Image.resize(image, width_scale, vertical_scale: height_scale) do - {:ok, resized_image} -> {:ok, %TransformState{state | image: resized_image}} + 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 @@ -150,7 +130,7 @@ defmodule ImagePlug.Transform.Cover do left: left }) do case Image.crop(image, left, top, width, height) do - {:ok, cropped_image} -> {:ok, %TransformState{state | image: cropped_image}} + {:ok, cropped_image} -> {:ok, set_image(state, cropped_image)} {:error, _reason} = error -> error end end diff --git a/lib/image_plug/transform/crop.ex b/lib/image_plug/transform/crop.ex index 13fc51a..5767c72 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 @@ -18,79 +21,34 @@ defmodule ImagePlug.Transform.Crop do 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 - - 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 - - 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 - - left = center_x - width / 2 - top = center_y - 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}} + def execute(%TransformState{} = state, %CropParams{} = params) do + # make sure crop is within image bounds + crop_width = max(1, min(image_width(state), to_pixels(state, :x, params.width))) + crop_height = max(1, min(image_height(state), to_pixels(state, :y, params.height))) + + # figure out the crop anchor + {focus_left, focus_top} = anchor_crop(state, params.crop_from, crop_width, crop_height) + + # ...and make sure crop still stays within bounds + left = max(0, min(image_width(state) - crop_width, focus_left)) + top = max(0, min(image_height(state) - crop_height, focus_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(%TransformState{} = state, %{left: left, top: top}, _crop_width, _crop_height), + do: {to_pixels(state, :x, left), to_pixels(state, :y, top)} - 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(%TransformState{} = state, :focus, crop_width, crop_height) do + anchor_to_coord(state.focus, %{ + image_width: image_width(state), + image_height: image_height(state), + target_width: crop_width, + target_height: crop_height + }) end end diff --git a/lib/image_plug/transform/focus.ex b/lib/image_plug/transform/focus.ex index eaae24f..7fee2bc 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,20 @@ 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(state, :x, left) + top = to_pixels(state, :y, top) + + focus = + {:coordinate, + max(min(image_width(state), left), 0), + max(min(image_height(state), top), 0)} + + set_focus(state, focus) 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}} + set_focus(state, {:anchor, x, y}) end end diff --git a/lib/image_plug/transform/scale.ex b/lib/image_plug/transform/scale.ex index 4c3a521..c47daa2 100644 --- a/lib/image_plug/transform/scale.ex +++ b/lib/image_plug/transform/scale.ex @@ -1,6 +1,9 @@ defmodule ImagePlug.Transform.Scale do @behaviour ImagePlug.Transform + import ImagePlug.TransformState + import ImagePlug.Utils + alias ImagePlug.Transform alias ImagePlug.TransformState @@ -29,53 +32,51 @@ defmodule ImagePlug.Transform.Scale do 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 + width = to_pixels_or_auto(state, :x, width) + height = to_pixels_or_auto(state, :y, height) + %{width: width, height: height} end - defp dimensions_for_scale_type(state, %ScaleParams{type: :ratio, ratio: {ar_w, ar_h}}) 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) - - {: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{} = params) do - with {:ok, width_and_height} <- dimensions_for_scale_type(state, params), - {:ok, scaled_image} <- do_scale(state.image, width_and_height) do - %TransformState{state | image: scaled_image} |> TransformState.reset_focus() + %{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(_state, _dimension, :auto), do: :auto + defp to_pixels_or_auto(state, dimension, length), do: to_pixels(state, dimension, length) end diff --git a/lib/image_plug/transform_state.ex b/lib/image_plug/transform_state.ex index 6fcf4fb..5d7da9d 100644 --- a/lib/image_plug/transform_state.ex +++ b/lib/image_plug/transform_state.ex @@ -27,9 +27,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..ebc1d00 --- /dev/null +++ b/lib/image_plug/utils.ex @@ -0,0 +1,48 @@ +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) + + def dim_length(%TransformState{} = state, :x), do: image_width(state) + def dim_length(%TransformState{} = state, :y), do: image_height(state) + + @spec to_pixels(TransformState.t(), :x | :y, ImagePlug.imgp_length()) :: integer() + def to_pixels(state, dimension, length) + + def to_pixels(_state, _dimension, {:pixels, num}), do: round(num) + + def to_pixels(state, dimension, {:scale, numerator, denominator}), + do: round(dim_length(state, dimension) * numerator / denominator) + + def to_pixels(state, dimension, {:percent, num}), + do: round(num / 100 * dim_length(state, dimension)) + + def anchor_to_coord(focus, %{ + image_width: image_width, + image_height: image_height, + target_width: target_width, + target_height: target_height + }) do + center_x = + case focus do + {:anchor, :left, _} -> 0 + {:anchor, :center, _} -> image_width / 2 + {:anchor, :right, _} -> image_width + {:coordinate, left, _top} -> left + end + + center_y = + case focus do + {:anchor, _, :top} -> 0 + {:anchor, _, :center} -> image_height / 2 + {:anchor, _, :bottom} -> image_height + {:coordinate, _left, top} -> top + end + + { + round(center_x - target_width / 2), + round(center_y - target_height / 2) + } + end +end diff --git a/test/param_parser/twicpics_parser_test.exs b/test/param_parser/twicpics_parser_test.exs index a354ba6..83f64b6 100644 --- a/test/param_parser/twicpics_parser_test.exs +++ b/test/param_parser/twicpics_parser_test.exs @@ -103,37 +103,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 +148,7 @@ 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{ From 27c35692d8192cdb629d33f73fa88640cfaabd1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Fri, 13 Dec 2024 14:01:21 +0100 Subject: [PATCH 03/13] more refactoring + fixes for focus --- lib/image_plug/transform/contain.ex | 4 +- lib/image_plug/transform/cover.ex | 81 ++++++++-------------- lib/image_plug/transform/crop.ex | 55 +++++++++++---- lib/image_plug/transform/focus.ex | 23 ++++-- lib/image_plug/transform/scale.ex | 8 +-- lib/image_plug/transform_state.ex | 3 +- lib/image_plug/utils.ex | 62 ++++++++++++++--- test/param_parser/twicpics_parser_test.exs | 7 +- 8 files changed, 154 insertions(+), 89 deletions(-) diff --git a/lib/image_plug/transform/contain.ex b/lib/image_plug/transform/contain.ex index f0fe88c..359a6fa 100644 --- a/lib/image_plug/transform/contain.ex +++ b/lib/image_plug/transform/contain.ex @@ -24,8 +24,8 @@ defmodule ImagePlug.Transform.Contain do height: height, constraint: constraint }) do - target_width = to_pixels(state, :x, width) - target_height = to_pixels(state, :y, height) + target_width = to_pixels(image_width(state), width) + target_height = to_pixels(image_height(state), height) {resize_width, resize_height} = fit_inside(state, target_width, target_height) case maybe_scale(state, resize_width, resize_height, constraint) do diff --git a/lib/image_plug/transform/cover.ex b/lib/image_plug/transform/cover.ex index ffb7dad..d307a85 100644 --- a/lib/image_plug/transform/cover.ex +++ b/lib/image_plug/transform/cover.ex @@ -40,38 +40,27 @@ defmodule ImagePlug.Transform.Cover do constraint: constraint }) do # convert units to pixels - target_width = to_pixels(state, :x, width) - target_height = to_pixels(state, :y, height) - - # figure out width/height - {resize_width, resize_height} = fit_cover(state, target_width, target_height) - - # calculate focus point based on the resized image size, because we'll be resizing before the crop action - {focus_left, focus_top} = - anchor_to_coord(state.focus, %{ - image_width: resize_width, - image_height: resize_height, - target_width: target_width, - target_height: target_height - }) - - # ensure focus_left/focus_top are within bounds - left = max(0, min(resize_width - target_width, focus_left)) - top = max(0, min(resize_height - target_height, focus_top)) - - with {:ok, scaled_state} <- - maybe_scale(state, %{ - width: resize_width, - height: resize_height, - constraint: constraint - }), - {:ok, cropped_state} <- - do_crop(scaled_state, %{ - left: left, - top: top, - width: target_width, - height: target_height - }) do + crop_width = to_pixels(image_width(state), width) + crop_height = to_pixels(image_height(state), height) + + # figure out width/height and get scaled size back + {resize_width, resize_height} = + fit_cover(state, crop_width, crop_height) |> IO.inspect(label: :resize) + + # 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}) @@ -93,27 +82,22 @@ defmodule ImagePlug.Transform.Cover do end end - def maybe_scale( - %TransformState{} = state, - %{width: width, height: height, constraint: :min} = params - ) do + def maybe_scale(%TransformState{} = state, width, height, :min) do if width > image_width(state) or height > image_height(state), - do: do_scale(state, params), + do: do_scale(state, width, height), else: {:ok, state} end - def maybe_scale( - %TransformState{} = state, - %{width: width, height: height, constraint: :max} = params - ) do + def maybe_scale(%TransformState{} = state, width, height, :max) do if width < image_width(state) or height < image_height(state), - do: do_scale(state, params), + do: do_scale(state, width, height), else: {:ok, state} end - def maybe_scale(image, params), do: do_scale(image, params) + def maybe_scale(image, width, height, _constraint), + do: do_scale(image, width, height) - def do_scale(%TransformState{} = state, %{width: width, height: height}) do + def do_scale(%TransformState{} = state, width, height) do width_scale = width / image_width(state) height_scale = height / image_height(state) @@ -123,13 +107,8 @@ defmodule ImagePlug.Transform.Cover do end end - def do_crop(%TransformState{image: image} = state, %{ - width: width, - height: height, - top: top, - left: left - }) do - case Image.crop(image, left, top, width, height) do + 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 diff --git a/lib/image_plug/transform/crop.ex b/lib/image_plug/transform/crop.ex index 5767c72..d21507c 100644 --- a/lib/image_plug/transform/crop.ex +++ b/lib/image_plug/transform/crop.ex @@ -16,22 +16,34 @@ 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{} = params) do + image_width = image_width(state) + image_height = image_height(state) + # make sure crop is within image bounds - crop_width = max(1, min(image_width(state), to_pixels(state, :x, params.width))) - crop_height = max(1, min(image_height(state), to_pixels(state, :y, params.height))) + 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))) # figure out the crop anchor - {focus_left, focus_top} = anchor_crop(state, params.crop_from, crop_width, crop_height) + {center_x, center_y} = + anchor_crop_to_pixels( + state, + params.crop_from, + image_width, + image_height, + crop_width, + crop_height + ) # ...and make sure crop still stays within bounds - left = max(0, min(image_width(state) - crop_width, focus_left)) - top = max(0, min(image_height(state) - crop_height, focus_top)) + 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))) # execute crop case Image.crop(state.image, left, top, crop_width, crop_height) do @@ -40,15 +52,30 @@ defmodule ImagePlug.Transform.Crop do end end - defp anchor_crop(%TransformState{} = state, %{left: left, top: top}, _crop_width, _crop_height), - do: {to_pixels(state, :x, left), to_pixels(state, :y, top)} + 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 - defp anchor_crop(%TransformState{} = state, :focus, crop_width, crop_height) do - anchor_to_coord(state.focus, %{ - image_width: image_width(state), - image_height: image_height(state), - target_width: crop_width, - target_height: crop_height - }) + 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 7fee2bc..028ed71 100644 --- a/lib/image_plug/transform/focus.ex +++ b/lib/image_plug/transform/focus.ex @@ -20,19 +20,28 @@ defmodule ImagePlug.Transform.Focus do @impl ImagePlug.Transform def execute(%TransformState{} = state, %FocusParams{type: {:coordinate, left, top}}) do - left = to_pixels(state, :x, left) - top = to_pixels(state, :y, top) + 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)} + {:coordinate, max(min(image_width(state), left), 0), max(min(image_height(state), top), 0)} - set_focus(state, focus) + state + |> set_focus(focus) + |> maybe_draw_debug_dot() end @impl ImagePlug.Transform def execute(%TransformState{image: image} = state, %FocusParams{type: {:anchor, x, y}}) do - set_focus(state, {: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 c47daa2..1232c88 100644 --- a/lib/image_plug/transform/scale.ex +++ b/lib/image_plug/transform/scale.ex @@ -32,8 +32,8 @@ defmodule ImagePlug.Transform.Scale do width: width, height: height }) do - width = to_pixels_or_auto(state, :x, width) - height = to_pixels_or_auto(state, :y, height) + width = to_pixels_or_auto(image_width(state), width) + height = to_pixels_or_auto(image_height(state), height) %{width: width, height: height} end @@ -77,6 +77,6 @@ defmodule ImagePlug.Transform.Scale do {:error, {:unhandled_scale_parameters, parameters}} end - defp to_pixels_or_auto(_state, _dimension, :auto), do: :auto - defp to_pixels_or_auto(state, dimension, length), do: 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 5d7da9d..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 diff --git a/lib/image_plug/utils.ex b/lib/image_plug/utils.ex index ebc1d00..75ef71f 100644 --- a/lib/image_plug/utils.ex +++ b/lib/image_plug/utils.ex @@ -4,19 +4,45 @@ defmodule ImagePlug.Utils do def image_height(%TransformState{image: image}), do: Image.height(image) def image_width(%TransformState{image: image}), do: Image.width(image) - def dim_length(%TransformState{} = state, :x), do: image_width(state) - def dim_length(%TransformState{} = state, :y), do: image_height(state) + @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) - @spec to_pixels(TransformState.t(), :x | :y, ImagePlug.imgp_length()) :: integer() - def to_pixels(state, dimension, length) + def to_pixels(length, {:scale, numerator, denominator}), + do: round(length * numerator / denominator) - def to_pixels(_state, _dimension, {:pixels, num}), do: round(num) + def to_pixels(length, {:percent, percent}), do: round(percent / 100 * length) - def to_pixels(state, dimension, {:scale, numerator, denominator}), - do: round(dim_length(state, dimension) * numerator / denominator) + def anchor_to_scale_units(focus, width, height) do + center_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 + + center_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 + + {center_x_scale, center_y_scale} |> IO.inspect(label: :center_scales) + end - def to_pixels(state, dimension, {:percent, num}), - do: round(num / 100 * dim_length(state, dimension)) + def anchor_to_pixels(focus, width, height) do + {center_x_scale, center_y_scale} = anchor_to_scale_units(focus, width, height) + + { + to_pixels(width, center_x_scale), + to_pixels(height, center_y_scale) + } + end def anchor_to_coord(focus, %{ image_width: image_width, @@ -45,4 +71,22 @@ defmodule ImagePlug.Utils do round(center_y - target_height / 2) } 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 83f64b6..2999cd9 100644 --- a/test/param_parser/twicpics_parser_test.exs +++ b/test/param_parser/twicpics_parser_test.exs @@ -148,7 +148,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), constraint: :none}} == + assert {:ok, + %Contain.ContainParams{ + width: to_result(width), + height: to_result(height), + constraint: :none + }} == parsed end end From ff9886e877873cd12863e83ff2f233b58b09995a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Fri, 13 Dec 2024 14:18:33 +0100 Subject: [PATCH 04/13] remove unused --- lib/image_plug/utils.ex | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/lib/image_plug/utils.ex b/lib/image_plug/utils.ex index 75ef71f..eda993c 100644 --- a/lib/image_plug/utils.ex +++ b/lib/image_plug/utils.ex @@ -44,34 +44,6 @@ defmodule ImagePlug.Utils do } end - def anchor_to_coord(focus, %{ - image_width: image_width, - image_height: image_height, - target_width: target_width, - target_height: target_height - }) do - center_x = - case focus do - {:anchor, :left, _} -> 0 - {:anchor, :center, _} -> image_width / 2 - {:anchor, :right, _} -> image_width - {:coordinate, left, _top} -> left - end - - center_y = - case focus do - {:anchor, _, :top} -> 0 - {:anchor, _, :center} -> image_height / 2 - {:anchor, _, :bottom} -> image_height - {:coordinate, _left, top} -> top - end - - { - round(center_x - target_width / 2), - round(center_y - target_height / 2) - } - end - def draw_debug_dot( %TransformState{} = state, left, From f4eea87b641d6237475335e0c8b9819fd3c24cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Fri, 13 Dec 2024 14:19:23 +0100 Subject: [PATCH 05/13] style --- lib/image_plug/utils.ex | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/image_plug/utils.ex b/lib/image_plug/utils.ex index eda993c..f71201e 100644 --- a/lib/image_plug/utils.ex +++ b/lib/image_plug/utils.ex @@ -36,12 +36,10 @@ defmodule ImagePlug.Utils do end def anchor_to_pixels(focus, width, height) do - {center_x_scale, center_y_scale} = anchor_to_scale_units(focus, width, height) - - { - to_pixels(width, center_x_scale), - to_pixels(height, center_y_scale) - } + case anchor_to_scale_units(focus, width, height) do + {center_x_scale, center_y_scale} -> + {to_pixels(width, center_x_scale), to_pixels(height, center_y_scale)} + end end def draw_debug_dot( From 677cbd2990f104222cd6934f9ad8d4e670eb4bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Fri, 13 Dec 2024 14:30:13 +0100 Subject: [PATCH 06/13] handle ratio in cover --- .../twicpics/transform/cover_parser.ex | 2 +- lib/image_plug/transform/cover.ex | 36 ++++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/lib/image_plug/param_parser/twicpics/transform/cover_parser.ex b/lib/image_plug/param_parser/twicpics/transform/cover_parser.ex index c446ee9..3388c83 100644 --- a/lib/image_plug/param_parser/twicpics/transform/cover_parser.ex +++ b/lib/image_plug/param_parser/twicpics/transform/cover_parser.ex @@ -29,7 +29,7 @@ defmodule ImagePlug.ParamParser.Twicpics.Transform.CoverParser do 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}}, constraint: :none} + {:ok, %CoverParams{type: :ratio, ratio: {width, height}}} {:error, _reason} = error -> Utils.update_error_input(error, input) diff --git a/lib/image_plug/transform/cover.ex b/lib/image_plug/transform/cover.ex index d307a85..7c0bdde 100644 --- a/lib/image_plug/transform/cover.ex +++ b/lib/image_plug/transform/cover.ex @@ -16,23 +16,51 @@ defmodule ImagePlug.Transform.Cover do @type t :: %__MODULE__{ type: :ratio, - ratio: {ImagePlug.imgp_ratio(), ImagePlug.imgp_ratio()}, - constraint: :regular | :min | :max + ratio: {ImagePlug.imgp_ratio(), ImagePlug.imgp_ratio()} } | %__MODULE__{ type: :dimensions, width: ImagePlug.imgp_length(), height: ImagePlug.imgp_length() | :auto, - constraint: :regular | :min | :max + constraint: :none | :min | :max } | %__MODULE__{ type: :dimensions, width: ImagePlug.imgp_length() | :auto, height: ImagePlug.imgp_length(), - constraint: :regular | :min | :max + 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, From 71a77e9566428f673b3d17e66ba0924f54e1b8b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Sat, 14 Dec 2024 11:22:30 +0100 Subject: [PATCH 07/13] fix type --- lib/image_plug/transform/cover.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/image_plug/transform/cover.ex b/lib/image_plug/transform/cover.ex index 7c0bdde..f082d24 100644 --- a/lib/image_plug/transform/cover.ex +++ b/lib/image_plug/transform/cover.ex @@ -16,7 +16,7 @@ defmodule ImagePlug.Transform.Cover do @type t :: %__MODULE__{ type: :ratio, - ratio: {ImagePlug.imgp_ratio(), ImagePlug.imgp_ratio()} + ratio: ImagePlug.imgp_ratio() } | %__MODULE__{ type: :dimensions, From 51b753270724b9f4992b68ae062d40b74a5f325b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Sat, 14 Dec 2024 11:43:27 +0100 Subject: [PATCH 08/13] handle auto size in cover/contain --- lib/image_plug/transform/contain.ex | 3 +-- lib/image_plug/transform/cover.ex | 18 ++++++++++-------- lib/image_plug/utils.ex | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/lib/image_plug/transform/contain.ex b/lib/image_plug/transform/contain.ex index 359a6fa..21f97c8 100644 --- a/lib/image_plug/transform/contain.ex +++ b/lib/image_plug/transform/contain.ex @@ -24,8 +24,7 @@ defmodule ImagePlug.Transform.Contain do height: height, constraint: constraint }) do - target_width = to_pixels(image_width(state), width) - target_height = to_pixels(image_height(state), height) + {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 diff --git a/lib/image_plug/transform/cover.ex b/lib/image_plug/transform/cover.ex index f082d24..d0d20cf 100644 --- a/lib/image_plug/transform/cover.ex +++ b/lib/image_plug/transform/cover.ex @@ -62,18 +62,20 @@ defmodule ImagePlug.Transform.Cover do end @impl ImagePlug.Transform - def execute(%TransformState{} = state, %CoverParams{ - width: width, - height: height, - constraint: constraint - }) do + def execute( + %TransformState{} = state, + %CoverParams{ + width: width, + height: height, + constraint: constraint + } = params + ) do # convert units to pixels - crop_width = to_pixels(image_width(state), width) - crop_height = to_pixels(image_height(state), height) + {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) |> IO.inspect(label: :resize) + fit_cover(state, crop_width, crop_height) # calculate focus scale based on original image and adjust to scaled size original_width = image_width(state) diff --git a/lib/image_plug/utils.ex b/lib/image_plug/utils.ex index f71201e..182af25 100644 --- a/lib/image_plug/utils.ex +++ b/lib/image_plug/utils.ex @@ -42,6 +42,22 @@ defmodule ImagePlug.Utils do 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, From 445ea08d5f7b437fa141549b42cb75c76e0cf34d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Sat, 14 Dec 2024 12:05:20 +0100 Subject: [PATCH 09/13] contain/cover doctests --- .../param_parser/twicpics/transform/contain_max_parser.ex | 2 +- .../param_parser/twicpics/transform/contain_min_parser.ex | 2 +- lib/image_plug/param_parser/twicpics/transform/cover_parser.ex | 2 +- test/param_parser/twicpics_parser_test.exs | 3 +++ 4 files changed, 6 insertions(+), 3 deletions(-) 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 index 3287bb1..964bc8a 100644 --- a/lib/image_plug/param_parser/twicpics/transform/contain_max_parser.ex +++ b/lib/image_plug/param_parser/twicpics/transform/contain_max_parser.ex @@ -12,7 +12,7 @@ defmodule ImagePlug.ParamParser.Twicpics.Transform.ContainMaxParser do ## Examples - iex> ImagePlug.ParamParser.Twicpics.Transform.ContainParser.parse("250x25.5") + iex> ImagePlug.ParamParser.Twicpics.Transform.ContainMaxParser.parse("250x25.5") {:ok, %ImagePlug.Transform.Contain.ContainParams{width: {:pixels, 250}, height: {:pixels, 25.5}, constraint: :max}} """ 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 index 5a9ad4a..7ace043 100644 --- a/lib/image_plug/param_parser/twicpics/transform/contain_min_parser.ex +++ b/lib/image_plug/param_parser/twicpics/transform/contain_min_parser.ex @@ -12,7 +12,7 @@ defmodule ImagePlug.ParamParser.Twicpics.Transform.ContainMinParser do ## Examples - iex> ImagePlug.ParamParser.Twicpics.Transform.ContainParser.parse("250x25.5") + iex> ImagePlug.ParamParser.Twicpics.Transform.ContainMinParser.parse("250x25.5") {:ok, %ImagePlug.Transform.Contain.ContainParams{width: {:pixels, 250}, height: {:pixels, 25.5}, constraint: :min}} """ diff --git a/lib/image_plug/param_parser/twicpics/transform/cover_parser.ex b/lib/image_plug/param_parser/twicpics/transform/cover_parser.ex index 3388c83..c3c45c3 100644 --- a/lib/image_plug/param_parser/twicpics/transform/cover_parser.ex +++ b/lib/image_plug/param_parser/twicpics/transform/cover_parser.ex @@ -17,7 +17,7 @@ defmodule ImagePlug.ParamParser.Twicpics.Transform.CoverParser do {: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}, constraint: :none}} + {:ok, %ImagePlug.Transform.Cover.CoverParams{type: :ratio, ratio: {16, 9}}} """ def parse(input, pos_offset \\ 0) do diff --git a/test/param_parser/twicpics_parser_test.exs b/test/param_parser/twicpics_parser_test.exs index 2999cd9..bd44da2 100644 --- a/test/param_parser/twicpics_parser_test.exs +++ b/test/param_parser/twicpics_parser_test.exs @@ -14,6 +14,9 @@ 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.OutputParser defp length_str({:pixels, unit}), do: "#{unit}" From 877c7b6168ad002d5b70d1293d9cc4799b5fd536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Sat, 14 Dec 2024 12:08:58 +0100 Subject: [PATCH 10/13] add cover min/max parsers --- lib/image_plug/param_parser/twicpics.ex | 2 ++ .../twicpics/transform/cover_max_parser.ex | 28 +++++++++++++++++++ .../twicpics/transform/cover_min_parser.ex | 28 +++++++++++++++++++ test/param_parser/twicpics_parser_test.exs | 2 ++ 4 files changed, 60 insertions(+) create mode 100644 lib/image_plug/param_parser/twicpics/transform/cover_max_parser.ex create mode 100644 lib/image_plug/param_parser/twicpics/transform/cover_min_parser.ex diff --git a/lib/image_plug/param_parser/twicpics.ex b/lib/image_plug/param_parser/twicpics.ex index 6175159..1116ef8 100644 --- a/lib/image_plug/param_parser/twicpics.ex +++ b/lib/image_plug/param_parser/twicpics.ex @@ -15,6 +15,8 @@ defmodule ImagePlug.ParamParser.Twicpics do "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} } 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/test/param_parser/twicpics_parser_test.exs b/test/param_parser/twicpics_parser_test.exs index bd44da2..8367d7a 100644 --- a/test/param_parser/twicpics_parser_test.exs +++ b/test/param_parser/twicpics_parser_test.exs @@ -17,6 +17,8 @@ defmodule ImagePlug.ParamParser.TwicpicsParserTest do 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}" From 99636324c990b917e67e36993dfbd42ebd32d0df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Sat, 14 Dec 2024 12:25:05 +0100 Subject: [PATCH 11/13] update type --- lib/image_plug/transform/contain.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/image_plug/transform/contain.ex b/lib/image_plug/transform/contain.ex index 21f97c8..10a8792 100644 --- a/lib/image_plug/transform/contain.ex +++ b/lib/image_plug/transform/contain.ex @@ -13,9 +13,14 @@ defmodule ImagePlug.Transform.Contain do @type t :: %__MODULE__{ width: ImagePlug.imgp_length(), - height: 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 From 85f7cefdaae9d924ea021fd63bc09845fc181305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Sat, 14 Dec 2024 19:38:07 +0100 Subject: [PATCH 12/13] naming --- lib/image_plug/utils.ex | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/image_plug/utils.ex b/lib/image_plug/utils.ex index 182af25..99136cf 100644 --- a/lib/image_plug/utils.ex +++ b/lib/image_plug/utils.ex @@ -16,7 +16,7 @@ defmodule ImagePlug.Utils do def to_pixels(length, {:percent, percent}), do: round(percent / 100 * length) def anchor_to_scale_units(focus, width, height) do - center_x_scale = + x_scale = case focus do {:anchor, :left, _} -> {:scale, 0, 2} {:anchor, :center, _} -> {:scale, 1, 2} @@ -24,7 +24,7 @@ defmodule ImagePlug.Utils do {:coordinate, left, _top} -> {:scale, to_pixels(width, left), width} end - center_y_scale = + y_scale = case focus do {:anchor, _, :top} -> {:scale, 0, 1} {:anchor, _, :center} -> {:scale, 1, 2} @@ -32,13 +32,13 @@ defmodule ImagePlug.Utils do {:coordinate, _left, top} -> {:scale, to_pixels(height, top), height} end - {center_x_scale, center_y_scale} |> IO.inspect(label: :center_scales) + {x_scale, y_scale} |> IO.inspect(label: :center_scales) end def anchor_to_pixels(focus, width, height) do case anchor_to_scale_units(focus, width, height) do - {center_x_scale, center_y_scale} -> - {to_pixels(width, center_x_scale), to_pixels(height, center_y_scale)} + {x_scale, y_scale} -> + {to_pixels(width, x_scale), to_pixels(height, y_scale)} end end From d0aaea5e9ed8ecf5f086fc0e5ed9fbf3af663f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Sat, 14 Dec 2024 19:38:25 +0100 Subject: [PATCH 13/13] remove IO.inspect --- lib/image_plug/utils.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/image_plug/utils.ex b/lib/image_plug/utils.ex index 99136cf..04e5d82 100644 --- a/lib/image_plug/utils.ex +++ b/lib/image_plug/utils.ex @@ -32,7 +32,7 @@ defmodule ImagePlug.Utils do {:coordinate, _left, top} -> {:scale, to_pixels(height, top), height} end - {x_scale, y_scale} |> IO.inspect(label: :center_scales) + {x_scale, y_scale} end def anchor_to_pixels(focus, width, height) do