diff --git a/lib/image_plug.ex b/lib/image_plug.ex index b5eb1a9..583dbfc 100644 --- a/lib/image_plug.ex +++ b/lib/image_plug.ex @@ -1,15 +1,6 @@ defmodule ImagePlug do @behaviour Plug - @type imgp_int() :: {:int, integer()} - @type imgp_float() :: {:float, float()} - @type imgp_expr() :: {:expr, float()} - @type imgp_number() :: imgp_int() | imgp_float() | imgp_expr() - @type imgp_pct() :: {:float, imgp_number()} - @type imgp_scale() :: {:float, imgp_number(), imgp_number()} - @type imgp_length() :: imgp_number() | imgp_pct() | imgp_scale() - @type imgp_ratio() :: {:ratio, imgp_number(), imgp_number()} - import Plug.Conn require Logger @@ -17,6 +8,16 @@ defmodule ImagePlug do alias ImagePlug.TransformState alias ImagePlug.TransformChain + @type imgp_number() :: integer() | float() + @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_length() :: imgp_pixels() | imgp_pct() | imgp_scale() + + @alpha_format_priority ~w(image/avif image/webp image/png) + @no_alpha_format_priority ~w(image/avif image/webp image/jpeg) + def init(opts), do: opts def call(%Plug.Conn{} = conn, opts) do @@ -42,9 +43,6 @@ defmodule ImagePlug do end end - @alpha_format_priority ~w(image/avif image/webp image/png) - @no_alpha_format_priority ~w(image/avif image/webp image/jpeg) - defp accepted_formats(%Plug.Conn{} = conn) do from_accept_header = get_req_header(conn, "accept") diff --git a/lib/image_plug/arithmetic_parser.ex b/lib/image_plug/arithmetic_parser.ex deleted file mode 100644 index 9a5db83..0000000 --- a/lib/image_plug/arithmetic_parser.ex +++ /dev/null @@ -1,145 +0,0 @@ -defmodule ImagePlug.ArithmeticParser do - @type token :: {:int, integer} | {:float, float} | {:op, binary} | :left_paren | :right_paren - @type expr :: {:int, integer} | {:float, float} | {:op, binary, expr(), expr()} - - @spec parse(String.t()) :: {:ok, expr} | {:error, atom()} - def parse(input) do - case tokenize(input) do - {:ok, tokens} -> - case parse_expression(tokens, 0) do - {:ok, expr, []} -> {:ok, expr} - {:ok, _, _} -> {:error, :unexpected_token_after_expr} - {:error, _} = error -> error - end - - {:error, _} = error -> - error - end - end - - @spec evaluate(String.t()) :: {:ok, number} | {:error, atom()} - def parse_and_evaluate(input) do - case parse(input) do - {:ok, expr} -> evaluate(expr) - {:error, _} = error -> error - end - end - - defp tokenize(input) do - input - |> String.replace(~r/\s+/, "") - |> String.graphemes() - |> do_tokenize([]) - end - - defp do_tokenize([], acc), do: {:ok, Enum.reverse(acc)} - - defp do_tokenize([h | t], acc) when h in ~w(+ - * /) do - do_tokenize(t, [{:op, h} | acc]) - end - - defp do_tokenize(["(" | t], acc), do: do_tokenize(t, [:left_paren | acc]) - defp do_tokenize([")" | t], acc), do: do_tokenize(t, [:right_paren | acc]) - - defp do_tokenize([h | t], acc) when h in ~w(0 1 2 3 4 5 6 7 8 9 .) do - {number, rest} = consume_number([h | t]) - - token = - if String.contains?(number, "."), - do: {:float, String.to_float(number)}, - else: {:int, String.to_integer(number)} - - do_tokenize(rest, [token | acc]) - end - - defp do_tokenize(_, _), do: {:error, :invalid_character} - - defp consume_number(chars) do - {number, rest} = Enum.split_while(chars, &(&1 in ~w(0 1 2 3 4 5 6 7 8 9 .))) - {Enum.join(number), rest} - end - - defp parse_expression(tokens, min_prec) do - case parse_primary(tokens) do - {:ok, lhs, rest} -> parse_binary_op(lhs, rest, min_prec) - {:error, _} = error -> error - end - end - - defp parse_primary([{:int, n} | rest]), do: {:ok, {:int, n}, rest} - defp parse_primary([{:float, n} | rest]), do: {:ok, {:float, n}, rest} - - defp parse_primary([:left_paren | rest]) do - case parse_expression(rest, 0) do - {:ok, expr, [:right_paren | rest2]} -> {:ok, expr, rest2} - {:ok, _, _} -> {:error, :mismatched_paren} - {:error, _} = error -> error - end - end - - defp parse_primary(_), do: {:error, :expected_primary_expression} - - defp parse_binary_op(lhs, tokens, min_prec) do - case tokens do - [{:op, op} | rest] -> - prec = precedence(op) - - if prec < min_prec do - {:ok, lhs, tokens} - else - case parse_expression(rest, prec + 1) do - {:ok, rhs, rest2} -> - new_lhs = {:op, op, lhs, rhs} - parse_binary_op(new_lhs, rest2, min_prec) - - {:error, _} = error -> - error - end - end - - _ -> - {:ok, lhs, tokens} - end - end - - defp precedence("+"), do: 1 - defp precedence("-"), do: 1 - defp precedence("*"), do: 2 - defp precedence("/"), do: 2 - - @spec evaluate(expr()) :: {:ok, number} | {:error, String.t()} - defp evaluate({:int, n}), do: {:ok, n} - defp evaluate({:float, n}), do: {:ok, n} - - defp evaluate({:op, "+", lhs, rhs}) do - with {:ok, lval} <- evaluate(lhs), - {:ok, rval} <- evaluate(rhs) do - {:ok, lval + rval} - end - end - - defp evaluate({:op, "-", lhs, rhs}) do - with {:ok, lval} <- evaluate(lhs), - {:ok, rval} <- evaluate(rhs) do - {:ok, lval - rval} - end - end - - defp evaluate({:op, "*", lhs, rhs}) do - with {:ok, lval} <- evaluate(lhs), - {:ok, rval} <- evaluate(rhs) do - {:ok, lval * rval} - end - end - - defp evaluate({:op, "/", lhs, rhs}) do - with {:ok, lval} <- evaluate(lhs), - {:ok, rval} <- evaluate(rhs) do - if rval == 0 do - {:error, :division_by_zero} - else - {:ok, lval / rval} - end - end - end -end diff --git a/lib/image_plug/param_parser/twicpics.ex b/lib/image_plug/param_parser/twicpics.ex index 36ce47e..20a339b 100644 --- a/lib/image_plug/param_parser/twicpics.ex +++ b/lib/image_plug/param_parser/twicpics.ex @@ -4,60 +4,63 @@ defmodule ImagePlug.ParamParser.Twicpics do alias ImagePlug.ParamParser.Twicpics @transforms %{ - "crop" => ImagePlug.Transform.Crop, - "resize" => ImagePlug.Transform.Scale, - "focus" => ImagePlug.Transform.Focus, - "contain" => ImagePlug.Transform.Contain, - "output" => ImagePlug.Transform.Output + "crop" => {ImagePlug.Transform.Crop, Twicpics.Transform.CropParser}, + "resize" => {ImagePlug.Transform.Scale, Twicpics.Transform.ScaleParser}, + "focus" => {ImagePlug.Transform.Focus, Twicpics.Transform.FocusParser}, + "contain" => {ImagePlug.Transform.Contain, Twicpics.Transform.ContainParser}, + "output" => {ImagePlug.Transform.Output, Twicpics.Transform.OutputParser} } - @parsers %{ - ImagePlug.Transform.Crop => Twicpics.CropParser, - ImagePlug.Transform.Scale => Twicpics.ScaleParser, - ImagePlug.Transform.Focus => Twicpics.FocusParser, - ImagePlug.Transform.Contain => Twicpics.ContainParser, - ImagePlug.Transform.Output => Twicpics.OutputParser - } + @transform_keys Map.keys(@transforms) + @query_param "twic" + @query_param_prefix "v1/" @impl ImagePlug.ParamParser def parse(%Plug.Conn{} = conn) do conn = Plug.Conn.fetch_query_params(conn) case conn.params do - %{"twic" => input} -> parse_string(input) - _ -> {:ok, []} + %{@query_param => input} -> + # start position count from where the request_path starts. + # used for parser error messages. + pos_offset = String.length(conn.request_path <> "?" <> @query_param <> "=") + parse_string(input, pos_offset) + + _ -> + {:ok, []} end end - def parse_string(input) do + def parse_string(input, pos_offset \\ 0) do case input do - "v1/" <> chain -> parse_chain(chain) - _ -> {:ok, []} + @query_param_prefix <> chain -> + pos_offset = pos_offset + String.length(@query_param_prefix) + parse_chain(chain, pos_offset) + + _ -> + {:ok, []} end end - # a `key=value` string followed by either a slash and a - # new key=value string or the end of the string using lookahead - @params_regex ~r/\/?([a-z]+)=(.+?(?=\/[a-z]+=|$))/ + def parse_chain(chain_str, pos_offset) do + case Twicpics.KVParser.parse(chain_str, @transform_keys, pos_offset) do + {:ok, kv_params} -> + Enum.reduce_while(kv_params, {:ok, []}, fn + {transform_name, params_str, pos}, {:ok, transforms_acc} -> + {transform_mod, parser_mod} = Map.get(@transforms, transform_name) - def parse_chain(chain_str) do - Regex.scan(@params_regex, chain_str, capture: :all_but_first) - |> Enum.reduce_while({:ok, []}, fn - [transform_name, params_str], {:ok, transforms_acc} - when is_map_key(@transforms, transform_name) -> - module = Map.get(@transforms, transform_name) + case parser_mod.parse(params_str, pos) do + {:ok, parsed_params} -> + {:cont, {:ok, [{transform_mod, parsed_params} | transforms_acc]}} - case @parsers[module].parse(params_str) do - {:ok, parsed_params} -> - {:cont, {:ok, [{module, parsed_params} | transforms_acc]}} + {:error, _reason} = error -> + {:halt, error} + end + end) - {:error, {:parameter_parse_error, input}} -> - {:halt, {:error, {:invalid_params, {module, "invalid input: #{input}"}}}} - end - - [transform_name, _params_str], acc -> - {:cont, [{:error, {:invalid_transform, transform_name}} | acc]} - end) + {:error, _reason} = error -> + error + end |> case do {:ok, transforms} -> {:ok, Enum.reverse(transforms)} other -> other diff --git a/lib/image_plug/param_parser/twicpics/arithmetic_parser.ex b/lib/image_plug/param_parser/twicpics/arithmetic_parser.ex new file mode 100644 index 0000000..d621f70 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/arithmetic_parser.ex @@ -0,0 +1,118 @@ +defmodule ImagePlug.ParamParser.Twicpics.ArithmeticParser do + alias ImagePlug.ParamParser.Twicpics.Utils + + @type token :: {:int, integer} | {:float, float} | {:op, binary} | :left_paren | :right_paren + @type expr :: {:int, integer} | {:float, float} | {:op, binary, expr(), expr()} + + @spec evaluate(String.t()) :: {:ok} | {:error, atom()} + def parse_and_evaluate(input) do + case parse(input) do + {:ok, expr} -> evaluate(expr) + {:error, _} = error -> error + end + end + + @spec parse(String.t()) :: {:ok, expr()} | {:error, atom()} + def parse(tokens) do + case parse_expression(tokens, 0) do + {:ok, expr, []} -> + {:ok, expr} + + {:ok, _expr, [token | _]} -> + {start_pos, _end_pos} = Utils.token_pos(token) + {:error, {:unexpected_token, pos: start_pos}} + + {:error, _} = error -> + error + end + end + + def parse_expression(tokens, min_prec) do + case parse_primary(tokens) do + {:ok, lhs, rest} -> parse_binary_op(lhs, rest, min_prec) + {:error, _} = error -> error + end + end + + defp parse_primary([{:int, n, pos_b, pos_e} | rest]), do: {:ok, {:int, n, pos_b, pos_e}, rest} + + defp parse_primary([{:float, n, pos_b, pos_e} | rest]), + do: {:ok, {:float, n, pos_b, pos_e}, rest} + + defp parse_primary([{:left_paren, pos} | rest]) do + case parse_expression(rest, 0) do + {:ok, expr, [{:right_paren, _pos} | rest2]} -> {:ok, expr, rest2} + {:ok, _, _} -> {:error, {:mismatched_paren, pos: pos}} + {:error, _} = error -> error + end + end + + defp parse_primary([token | _]) do + {start_pos, _end_pos} = Utils.token_pos(token) + {:error, {:unexpected_token, pos: start_pos}} + end + + defp parse_binary_op(lhs, tokens, min_prec) do + case tokens do + [{:op, op, _} | rest] -> + prec = precedence(op) + + if prec < min_prec do + {:ok, lhs, tokens} + else + case parse_expression(rest, prec + 1) do + {:ok, rhs, rest2} -> + new_lhs = {:op, op, lhs, rhs} + parse_binary_op(new_lhs, rest2, min_prec) + + {:error, _} = error -> + error + end + end + + _ -> + {:ok, lhs, tokens} + end + end + + defp precedence("+"), do: 1 + defp precedence("-"), do: 1 + defp precedence("*"), do: 2 + defp precedence("/"), do: 2 + + @spec evaluate(expr()) :: {:ok, number} | {:error, String.t()} + def evaluate({:int, n, _pos_b, _pos_e}), do: {:ok, n} + def evaluate({:float, n, _pos_b, _pos_e}), do: {:ok, n} + + def evaluate({:op, "+", lhs, rhs}) do + with {:ok, lval} <- evaluate(lhs), + {:ok, rval} <- evaluate(rhs) do + {:ok, lval + rval} + end + end + + def evaluate({:op, "-", lhs, rhs}) do + with {:ok, lval} <- evaluate(lhs), + {:ok, rval} <- evaluate(rhs) do + {:ok, lval - rval} + end + end + + def evaluate({:op, "*", lhs, rhs}) do + with {:ok, lval} <- evaluate(lhs), + {:ok, rval} <- evaluate(rhs) do + {:ok, lval * rval} + end + end + + def evaluate({:op, "/", lhs, rhs}) do + with {:ok, lval} <- evaluate(lhs), + {:ok, rval} <- evaluate(rhs) do + if rval == 0 do + {:error, :division_by_zero} + else + {:ok, lval / rval} + end + end + end +end diff --git a/lib/image_plug/param_parser/twicpics/common.ex b/lib/image_plug/param_parser/twicpics/common.ex deleted file mode 100644 index bb16786..0000000 --- a/lib/image_plug/param_parser/twicpics/common.ex +++ /dev/null @@ -1,69 +0,0 @@ -defmodule ImagePlug.ParamParser.Twicpics.Common do - def with_parsed_units(units, fun) do - case parse_all_units(units) do - {:error, _} = error -> error - {:ok, units} -> fun.(units) - end - end - - def parse_all_units(units) do - reduced = - Enum.reduce_while(units, [], fn unit, acc -> - case parse_unit(unit) do - {:ok, parsed} -> {:cont, [parsed | acc]} - {:error, _} = error -> {:halt, error} - end - end) - - case reduced do - parsed when is_list(parsed) -> - {:ok, Enum.reverse(parsed)} - - {:error, _} = error -> - error - end - end - - def parse_unit(input) do - cond do - Regex.match?(~r/^\((.+)\/(.+)\)s$/, input) -> - [_, num1, num2] = Regex.run(~r/^\((.+)\/(.+)\)s$/, input) - - with {:ok, parsed_num1} <- parse_number(num1), - {:ok, parsed_num2} <- parse_number(num2) do - {:ok, {:scale, parsed_num1, parsed_num2}} - else - {:error, _} = error -> error - end - - Regex.match?(~r/^(.+)p$/, input) -> - [_, num] = Regex.run(~r/^(.+)p$/, input) - - case parse_number(num) do - {:ok, parsed} -> {:ok, {:pct, parsed}} - {:error, _} = error -> error - end - - true -> - parse_number(input) - end - end - - def parse_number(input) do - cond do - Regex.match?(~r/^\d+(\.(\d+|0e\d+))?$/, input) -> - if String.contains?(input, ".") do - {:ok, {:float, String.to_float(input)}} - else - {:ok, {:int, String.to_integer(input)}} - end - - Regex.match?(~r/^\((.+)\)$/, input) -> - [_, expr] = Regex.run(~r/^\((.+)\)$/, input) - {:ok, {:expr, expr}} - - true -> - {:error, {:invalid_number, input}} - end - end -end diff --git a/lib/image_plug/param_parser/twicpics/contain_parser.ex b/lib/image_plug/param_parser/twicpics/contain_parser.ex deleted file mode 100644 index f4205ce..0000000 --- a/lib/image_plug/param_parser/twicpics/contain_parser.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule ImagePlug.ParamParser.Twicpics.ContainParser do - import ImagePlug.ParamParser.Twicpics.Common - - alias ImagePlug.Transform.Contain.ContainParams - - @doc """ - Parses a string into a `ImagePlug.Transform.Contain.ContainParams` struct. - - Returns a `ImagePlug.Transform.Contain.ContainParams` struct. - - ## Syntax - - ``` - contain= - ``` - - ## Examples - - iex> ImagePlug.ParamParser.Twicpics.ContainParser.parse("250x25.5") - {:ok, %ImagePlug.Transform.Contain.ContainParams{width: {:int, 250}, height: {:float, 25.5}}} - """ - def parse(input) do - cond do - Regex.match?(~r/^(.+)x(.+)$/, input) -> - Regex.run(~r/^(.+)x(.+)$/, input, capture: :all_but_first) - |> with_parsed_units(fn [width, height] -> - {:ok, %ContainParams{width: width, height: height}} - end) - - true -> - {:error, {:parameter_parse_error, input}} - end - end -end diff --git a/lib/image_plug/param_parser/twicpics/coordinates_parser.ex b/lib/image_plug/param_parser/twicpics/coordinates_parser.ex new file mode 100644 index 0000000..a998c1d --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/coordinates_parser.ex @@ -0,0 +1,44 @@ +defmodule ImagePlug.ParamParser.Twicpics.CoordinatesParser do + alias ImagePlug.ParamParser.Twicpics.LengthParser + alias ImagePlug.ParamParser.Twicpics.Utils + + def parse(input, pos_offset \\ 0) do + case String.split(input, "x", parts: 2) do + [left_str, top_str] -> + with {:ok, parsed_left} <- parse_and_validate(left_str, pos_offset), + {:ok, parsed_top} <- + parse_and_validate(top_str, pos_offset + String.length(left_str) + 1) do + {:ok, %{left: parsed_left, top: parsed_top}} + else + {:error, _reason} = error -> Utils.update_error_input(error, input) + end + + [left_str] -> + # this is an invalid coordinate! + # + # attempt to parse string to get error messages for number parsing. + # if it suceeds, complain that the second dimension that's missing + case parse_and_validate(left_str, pos_offset) do + {:ok, _} -> + Utils.unexpected_value_error(pos_offset + String.length(left_str), ["x"], :eoi) + |> Utils.update_error_input(input) + + {:error, _} = error -> + Utils.update_error_input(error, input) + end + end + end + + defp parse_and_validate(length_str, pos_offset) do + case LengthParser.parse(length_str, pos_offset) do + {:ok, {_type, number} = parsed_length} when number >= 0 -> + {:ok, parsed_length} + + {:ok, {_type, number}} -> + {:error, {:positive_number_required, pos: pos_offset, found: number}} + + {:error, _reason} = error -> + error + end + end +end diff --git a/lib/image_plug/param_parser/twicpics/crop_parser.ex b/lib/image_plug/param_parser/twicpics/crop_parser.ex deleted file mode 100644 index 095119f..0000000 --- a/lib/image_plug/param_parser/twicpics/crop_parser.ex +++ /dev/null @@ -1,45 +0,0 @@ -defmodule ImagePlug.ParamParser.Twicpics.CropParser do - import ImagePlug.ParamParser.Twicpics.Common - - alias ImagePlug.Transform.Crop.CropParams - - @doc """ - Parses a string into a `ImagePlug.Transform.Crop.CropParams` struct. - - Returns a `ImagePlug.Transform.Crop.CropParams` struct. - - ## Format - - ``` - [@] - ``` - - ## Examples - - iex> ImagePlug.ParamParser.Twicpics.CropParser.parse("250x25p") - {:ok, %ImagePlug.Transform.Crop.CropParams{width: {:int, 250}, height: {:pct, {:int, 25}}, crop_from: :focus}} - - iex> ImagePlug.ParamParser.Twicpics.CropParser.parse("20px25@10x50.1p") - {:ok, %ImagePlug.Transform.Crop.CropParams{width: {:pct, {:int, 20}}, height: {:int, 25}, crop_from: %{left: {:int, 10}, top: {:pct, {:float, 50.1}}}}} - """ - def parse(input) do - cond do - Regex.match?(~r/^(.+)x(.+)@(.+)x(.+)$/, input) -> - Regex.run(~r/^(.+)x(.+)@(.+)x(.+)$/, input, capture: :all_but_first) - |> with_parsed_units(fn [width, height, left, top] -> - # TODO: Validate that width/height is strictly positive (> 0) - {:ok, %CropParams{width: width, height: height, crop_from: %{left: left, top: top}}} - end) - - Regex.match?(~r/^(.+)x(.+)$/, input) -> - Regex.run(~r/^(.+)x(.+)$/, input, capture: :all_but_first) - |> with_parsed_units(fn [width, height] -> - # TODO: Validate that width/height is strictly positive (> 0) - {:ok, %CropParams{width: width, height: height, crop_from: :focus}} - end) - - true -> - {:error, {:parameter_parse_error, input}} - end - end -end diff --git a/lib/image_plug/param_parser/twicpics/focus_parser.ex b/lib/image_plug/param_parser/twicpics/focus_parser.ex deleted file mode 100644 index 8b106c1..0000000 --- a/lib/image_plug/param_parser/twicpics/focus_parser.ex +++ /dev/null @@ -1,67 +0,0 @@ -defmodule ImagePlug.ParamParser.Twicpics.FocusParser do - import ImagePlug.ParamParser.Twicpics.Common - - alias ImagePlug.Transform.Focus.FocusParams - - @doc """ - Parses a string into a `ImagePlug.Transform.Focus.FocusParams` struct. - - Returns a `ImagePlug.Transform.Focus.FocusParams` struct. - - ## Format - - ``` - focus= - focus= - ``` - - Note: `auto` is not supported at the moment. - - ## Examples - - iex> ImagePlug.ParamParser.Twicpics.FocusParser.parse("250x25.5") - {:ok, %ImagePlug.Transform.Focus.FocusParams{type: {:coordinate, {:int, 250}, {:float, 25.5}}}} - - iex> ImagePlug.ParamParser.Twicpics.FocusParser.parse("bottom-right") - {:ok, %ImagePlug.Transform.Focus.FocusParams{type: {:anchor, :right, :bottom}}} - """ - def parse(input) do - cond do - Regex.match?(~r/^(.+)x(.+)$/, input) -> - Regex.run(~r/^(.+)x(.+)$/, input, capture: :all_but_first) - |> with_parsed_units(fn [left, top] -> - {:ok, %FocusParams{type: {:coordinate, left, top}}} - end) - - input == "center" -> - {:ok, %FocusParams{type: {:anchor, :center, :center}}} - - input == "bottom" -> - {:ok, %FocusParams{type: {:anchor, :center, :bottom}}} - - input == "bottom-left" -> - {:ok, %FocusParams{type: {:anchor, :left, :bottom}}} - - input == "bottom-right" -> - {:ok, %FocusParams{type: {:anchor, :right, :bottom}}} - - input == "left" -> - {:ok, %FocusParams{type: {:anchor, :left, :center}}} - - input == "top" -> - {:ok, %FocusParams{type: {:anchor, :center, :top}}} - - input == "top-left" -> - {:ok, %FocusParams{type: {:anchor, :left, :top}}} - - input == "top-right" -> - {:ok, %FocusParams{type: {:anchor, :right, :top}}} - - input == "right" -> - {:ok, %FocusParams{type: {:anchor, :right, :center}}} - - true -> - {:error, {:parameter_parse_error, input}} - end - end -end diff --git a/lib/image_plug/param_parser/twicpics/formatters.ex b/lib/image_plug/param_parser/twicpics/formatters.ex new file mode 100644 index 0000000..1a8f50d --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/formatters.ex @@ -0,0 +1,46 @@ +defmodule ImagePlug.ParamParser.Twicpics.Formatters do + defp format_char(:eoi), do: "end of input" + defp format_char(other), do: other + + defp join_chars([choice | tail]), do: join_chars(tail, ~s|"#{format_char(choice)}"|) + defp join_chars([], acc), do: acc + defp join_chars([last_choice], acc), do: ~s|#{acc} or "#{format_char(last_choice)}"| + + defp join_chars([choice | tail], acc), + do: join_chars(tail, ~s|#{acc}, "#{format_char(choice)}"|) + + defp format_msg({:unexpected_char, opts}) do + expected_chars = Keyword.get(opts, :expected) + found_char = Keyword.get(opts, :found) + ~s|Expected #{join_chars(expected_chars)} but "#{format_char(found_char)}" found.| + end + + defp format_msg({:strictly_positive_number_required, opts}) do + found_number = Keyword.get(opts, :found) + ~s|Strictly positive number expected, found #{format_char(found_number)} instead.| + end + + defp format_msg({:positive_number_required, opts}) do + found_number = Keyword.get(opts, :found) + ~s|Positive number expected, found #{format_char(found_number)} instead.| + end + + defp format_msg({other, _}), do: to_string(other) + + def format_error({_, opts} = error) do + input = Keyword.get(opts, :input, "") + error_offset = Keyword.get(opts, :pos, 0) + error_padding = String.duplicate(" ", error_offset) + IO.puts(input) + IO.puts("#{error_padding}▲") + IO.puts("#{error_padding}└── #{format_msg(error)}") + end + + def print_result({:ok, result}) do + IO.puts(inspect(result, syntax_colors: IO.ANSI.syntax_colors())) + end + + def print_result({:error, err}) do + format_error(err) + end +end diff --git a/lib/image_plug/param_parser/twicpics/kv_parser.ex b/lib/image_plug/param_parser/twicpics/kv_parser.ex new file mode 100644 index 0000000..4c822c1 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/kv_parser.ex @@ -0,0 +1,60 @@ +defmodule ImagePlug.ParamParser.Twicpics.KVParser do + alias ImagePlug.ParamParser.Twicpics.Utils + + def parse(input, valid_keys, pos_offset \\ 0) do + case parse_pairs(input, [], valid_keys, pos_offset) do + {:ok, result} -> {:ok, Enum.reverse(result)} + {:error, _reason} = error -> error + end + end + + defp parse_pairs("", acc, _valid_keys, _pos), do: {:ok, acc} + + # pos + 1 because key is expected at the next char + defp parse_pairs("/", _acc, _valid_keys, pos), do: {:error, {:expected_key, pos: pos + 1}} + + defp parse_pairs(<<"/"::binary, input::binary>>, acc, valid_keys, pos), + do: parse_pairs(input, acc, valid_keys, pos + 1) + + defp parse_pairs(input, acc, valid_keys, key_pos) do + with {:ok, {key, rest, value_pos}} <- extract_key(input, valid_keys, key_pos), + {:ok, {value, rest, next_pos}} <- extract_value(rest, value_pos) do + parse_pairs(rest, [{key, value, key_pos} | acc], valid_keys, next_pos) + else + {:error, _reason} = error -> error + end + end + + defp extract_key(input, valid_keys, pos) do + case String.split(input, "=", parts: 2) do + [key, rest] -> + if key in valid_keys, + do: {:ok, {key, rest, pos + String.length(key) + 1}}, + else: Utils.unexpected_value_error(pos, valid_keys, key) + + [rest] -> + Utils.unexpected_value_error(pos + String.length(rest), ["="], :eoi) + end + end + + defp extract_value(input, pos) do + case extract_until_slash_or_end(input, "", pos) do + {"", _rest, new_pos} -> {:error, {:expected_value, pos: new_pos}} + {value, rest, new_pos} -> {:ok, {value, rest, new_pos}} + end + end + + defp extract_until_slash_or_end("", acc, pos), do: {acc, "", pos} + + defp extract_until_slash_or_end(<<"/"::binary, rest::binary>>, acc, pos) do + if Utils.balanced_parens?(acc) do + {acc, "/" <> rest, pos} + else + extract_until_slash_or_end(rest, acc <> "/", pos + 1) + end + end + + defp extract_until_slash_or_end(<>, acc, pos) do + extract_until_slash_or_end(rest, acc <> <>, pos + 1) + end +end diff --git a/lib/image_plug/param_parser/twicpics/length_parser.ex b/lib/image_plug/param_parser/twicpics/length_parser.ex new file mode 100644 index 0000000..50701f3 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/length_parser.ex @@ -0,0 +1,21 @@ +defmodule ImagePlug.ParamParser.Twicpics.LengthParser do + alias ImagePlug.ParamParser.Twicpics.NumberParser + alias ImagePlug.ParamParser.Twicpics.ArithmeticParser + alias ImagePlug.ParamParser.Twicpics.Utils + + def parse(input, pos_offset \\ 0) do + {type, num_str} = + case String.reverse(input) do + "p" <> num_str -> {:percent, String.reverse(num_str)} + "s" <> num_str -> {:scale, String.reverse(num_str)} + num_str -> {:pixels, String.reverse(num_str)} + end + + with {:ok, tokens} <- NumberParser.parse(num_str, pos_offset), + {:ok, evaluated} <- ArithmeticParser.parse_and_evaluate(tokens) do + {:ok, {type, evaluated}} + else + {:error, {_reason, _opts}} = error -> Utils.update_error_input(error, input) + end + end +end diff --git a/lib/image_plug/param_parser/twicpics/number_parser.ex b/lib/image_plug/param_parser/twicpics/number_parser.ex new file mode 100644 index 0000000..566cc70 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/number_parser.ex @@ -0,0 +1,417 @@ +defmodule ImagePlug.ParamParser.Twicpics.NumberParser do + alias ImagePlug.ParamParser.Twicpics.Utils + + @op_tokens ~c"+-*/" + + defmodule State do + defstruct input: "", tokens: [], pos: 0, paren_count: 0 + end + + defp consume_char(%State{input: <<_char::utf8, rest::binary>>, pos: pos} = state), + do: %State{state | input: rest, pos: pos + 1} + + defp add_token(%State{tokens: tokens} = state, token), + do: %State{state | tokens: [token | tokens]} |> consume_char() |> do_parse() + + defp replace_token(%State{tokens: [_head | tail]} = state, token), + do: %State{state | tokens: [token | tail]} |> consume_char() |> do_parse() + + defp add_left_paren(%State{} = state) do + %State{state | paren_count: state.paren_count + 1} + |> add_token({:left_paren, state.pos}) + end + + defp add_right_paren(%State{} = state) do + %State{state | paren_count: state.paren_count - 1} + |> add_token({:right_paren, state.pos}) + end + + defp mk_int(value, left_pos, right_pos), + do: {:int, value, left_pos, right_pos} + + defp mk_float_open(value, left_pos, right_pos), + do: {:float_open, value, left_pos, right_pos} + + defp mk_exp_open(value, left_pos, right_pos), + do: {:exp_open, value, left_pos, right_pos} + + defp mk_exp(value, left_pos, right_pos), + do: {:exp, value, left_pos, right_pos} + + defp mk_float(value, left_pos, right_pos), + do: {:float, value, left_pos, right_pos} + + defp mk_op(type, pos), + do: {:op, type, pos} + + defp sci_to_num(sci) do + [base_str, exponent_str] = String.split(sci, ~r/e/i) + {base, ""} = Float.parse(base_str) + {exponent, ""} = Integer.parse(exponent_str) + base * :math.pow(10, exponent) + end + + def parse(input, pos_offset \\ 0) do + case do_parse(%State{input: input, pos: pos_offset}) do + {:ok, tokens} -> + {:ok, + tokens + |> Enum.reverse() + |> Enum.map(fn + {:int, int, pos_b, pos_e} -> mk_int(String.to_integer(int), pos_b, pos_e) + {:float, int, pos_b, pos_e} -> mk_float(String.to_float(int), pos_b, pos_e) + {:exp, exp_num, pos_b, pos_e} -> mk_float(sci_to_num(exp_num), pos_b, pos_e) + other -> other + end)} + + {:error, {_reason, _opts}} = error -> + Utils.update_error_input(error, input) + end + end + + # we hit end of input, but no tokens have been processed + defp do_parse(%State{input: "", tokens: []} = state) when state.paren_count == 0, + do: Utils.unexpected_value_error(state.pos, ["(", "[0-9]"], found: :eoi) + + # just consume space characters + defp do_parse(%State{input: <>} = state) when char in ~c[ ] do + state |> consume_char() |> do_parse() + end + + # + # the following states are legal end of input locations as long as + # we're not inside a parentheses: :int, :float, :exp and :right_paren + # + defp do_parse(%State{input: "", tokens: [{:int, _, _, _} | _] = tokens} = state) + when state.paren_count == 0, + do: {:ok, tokens} + + defp do_parse(%State{input: "", tokens: [{:float, _, _, _} | _] = tokens} = state) + when state.paren_count == 0, + do: {:ok, tokens} + + defp do_parse(%State{input: "", tokens: [{:exp, _, _, _} | _] = tokens} = state) + when state.paren_count == 0, + do: {:ok, tokens} + + defp do_parse(%State{input: "", tokens: [{:right_paren, _} | _] = tokens} = state) + when state.paren_count == 0, + do: {:ok, tokens} + + # first char in string + defp do_parse(%State{input: <>, tokens: []} = state) do + cond do + char in ?0..?9 or char == ?- -> + add_token(state, mk_int(<>, state.pos, state.pos)) + + # the only way to enter paren_count > 0 is through the first char + char == ?( -> + add_left_paren(state) + + true -> + Utils.unexpected_value_error(state.pos, ["(", "[0-9]"], <>) + end + end + + # + # prev token: :left_paren + # + + defp do_parse( + %State{ + input: <>, + tokens: [{:left_paren, _} | _] + } = state + ) do + cond do + char in ?0..?9 or char == ?- -> + add_token(state, mk_int(<>, state.pos, state.pos)) + + char == ?( -> + add_left_paren(state) + + true -> + Utils.unexpected_value_error(state.pos, ["(", "[0-9]", "-"], <>) + end + end + + # we hit end of input while the previous token was a :left_paren + defp do_parse(%State{input: "", tokens: [{:left_paren, _} | _]} = state) do + Utils.unexpected_value_error(state.pos, ["(", "[0-9]", "-"], :eoi) + end + + # + # prev token: :right_paren + # + + # if last :right_paren has been closed, the expression is completed, + # so no more characters are allowed + defp do_parse( + %State{ + input: <>, + tokens: [{:right_paren, _} | _] + } = state + ) + when state.paren_count == 0, + do: Utils.unexpected_value_error(state.pos, [:eoi], <>) + + defp do_parse( + %State{ + input: <>, + tokens: [{:right_paren, _} | _] + } = state + ) + when state.paren_count > 0 do + cond do + char in @op_tokens -> add_token(state, mk_op(<>, state.pos)) + char == ?) -> add_right_paren(state) + true -> Utils.unexpected_value_error(state.pos, ["+", "-", "*", "/", ")"], <>) + end + end + + # we hit end of input while the previous token was a :right_paren, but we're still inside a paren + defp do_parse(%State{input: "", tokens: [{:right_paren, _} | _]} = state) + when state.paren_count > 0 do + Utils.unexpected_value_error(state.pos, ["+", "-", "*", "/", ")"], :eoi) + end + + # + # prev token: integer + # + + defp do_parse( + %State{ + input: <>, + tokens: [{:int, cur_val, t_pos_b, _} | _] + } = state + ) + when state.paren_count == 0 do + # not in parens, so it's only a number literal, and no ops are allowed + cond do + char in ?0..?9 -> + replace_token(state, mk_int(cur_val <> <>, t_pos_b, state.pos)) + + char == ?. -> + replace_token(state, mk_float_open(cur_val <> <>, t_pos_b, state.pos)) + + char == ?e or char == ?E -> + replace_token(state, mk_exp_open(cur_val <> <>, t_pos_b, state.pos)) + + true -> + Utils.unexpected_value_error(state.pos, ["[0-9]", "."], <>) + end + end + + defp do_parse( + %State{ + input: <>, + tokens: [{:int, cur_val, t_pos_b, _} | _] + } = state + ) + when state.paren_count > 0 do + cond do + char in ?0..?9 -> + replace_token(state, mk_int(cur_val <> <>, t_pos_b, state.pos)) + + char == ?. -> + replace_token(state, mk_float_open(cur_val <> <>, t_pos_b, state.pos)) + + char == ?e or char == ?E -> + replace_token(state, mk_exp_open(cur_val <> <>, t_pos_b, state.pos)) + + char in @op_tokens -> + add_token(state, mk_op(<>, state.pos)) + + char == ?) -> + add_right_paren(state) + + true -> + Utils.unexpected_value_error( + state.pos, + ["[0-9]", ".", "+", "-", "*", "/", ")"], + <> + ) + end + end + + # we hit eoi while on an :int token, and we're in a parentheses + defp do_parse(%State{input: "", tokens: [{:int, _, _, _} | _]} = state) + when state.paren_count > 0 do + Utils.unexpected_value_error(state.pos, ["[0-9]", ".", "+", "-", "*", "/", ")"], :eoi) + end + + # + # prev token: :float_open + # - it's not a valid float yet + # + + defp do_parse( + %State{ + input: <>, + tokens: [{:float_open, cur_val, t_pos_b, _} | _] + } = state + ) do + cond do + char in ?0..?9 -> + replace_token(state, mk_float(cur_val <> <>, t_pos_b, state.pos)) + + true -> + Utils.unexpected_value_error(state.pos, ["[0-9]"], <>) + end + end + + # we hit end of input while in a :float_open + defp do_parse(%State{input: "", tokens: [{:float_open, _, _, _} | _]} = state), + do: Utils.unexpected_value_error(state.pos, ["[0-9]"], :eoi) + + # + # prev token: :float + # - at this point it's a valid float + # + + defp do_parse( + %State{ + input: <>, + tokens: [{:float, cur_val, t_pos_b, _} | _] + } = state + ) + when state.paren_count == 0 do + # not in parens, so it's only a number literal, and no ops are allowed + cond do + char in ?0..?9 -> + replace_token(state, mk_float(cur_val <> <>, t_pos_b, state.pos)) + + char == ?e or char == ?E -> + replace_token(state, mk_exp_open(cur_val <> <>, t_pos_b, state.pos)) + + true -> + Utils.unexpected_value_error(state.pos, ["[0-9]"], <>) + end + end + + defp do_parse( + %State{ + input: <>, + tokens: [{:float, cur_val, t_pos_b, _} | _] + } = state + ) + when state.paren_count > 0 do + cond do + char in ?0..?9 -> + replace_token(state, mk_float(cur_val <> <>, t_pos_b, state.pos)) + + char == ?e or char == ?E -> + replace_token(state, mk_exp_open(cur_val <> <>, t_pos_b, state.pos)) + + char in @op_tokens -> + add_token(state, mk_op(<>, state.pos)) + + char == ?) -> + add_right_paren(state) + + true -> + Utils.unexpected_value_error( + state.pos, + ["[0-9]", "+", "-", "*", "/", ")"], + <> + ) + end + end + + # we hit eoi while on an :float token, and we're in a parentheses + defp do_parse(%State{input: "", tokens: [{:float, _, _, _} | _]} = state) + when state.paren_count > 0 do + Utils.unexpected_value_error(state.pos, ["[0-9]", "+", "-", "*", "/", ")"], :eoi) + end + + # + # prev token: :exp_open + # - at this point it's a not a valid exponential number + # + + defp do_parse( + %State{ + input: <>, + tokens: [{:exp_open, cur_val, t_pos_b, _} | _] + } = state + ) do + cond do + char in ?0..?9 or char == ?- -> + replace_token(state, mk_exp(cur_val <> <>, t_pos_b, state.pos)) + + true -> + Utils.unexpected_value_error(state.pos, ["[0-9]", "-"], <>) + end + end + + # + # prev token: :exp + # - we have a valid number in exponential notation + # + + defp do_parse( + %State{ + input: <>, + tokens: [{:exp, cur_val, t_pos_b, _} | _] + } = state + ) + when state.paren_count == 0 do + cond do + char in ?0..?9 -> + replace_token(state, mk_exp(cur_val <> <>, t_pos_b, state.pos)) + + true -> + Utils.unexpected_value_error(state.pos, ["[0-9]"], <>) + end + end + + defp do_parse( + %State{ + input: <>, + tokens: [{:exp, cur_val, t_pos_b, _} | _] + } = state + ) + when state.paren_count > 0 do + cond do + char in ?0..?9 -> + replace_token(state, mk_exp(cur_val <> <>, t_pos_b, state.pos)) + + char in @op_tokens -> + add_token(state, mk_op(<>, state.pos)) + + char == ?) -> + add_right_paren(state) + + true -> + Utils.unexpected_value_error(state.pos, ["[0-9]", "-"], <>) + end + end + + # + # prev token: :op + # + + defp do_parse( + %State{ + input: <>, + tokens: [{:op, _, _} | _] + } = state + ) + when state.paren_count > 0 do + cond do + char in ?0..?9 or char == ?- -> + add_token(state, mk_int(<>, state.pos, state.pos)) + + char == ?( -> + add_left_paren(state) + + true -> + Utils.unexpected_value_error(state.pos, ["[0-9]", "-", "("], <>) + end + end + + # we hit eoi while on an :op token + defp do_parse(%State{input: "", tokens: [{:op, _, _} | _]} = state) do + Utils.unexpected_value_error(state.pos, ["[0-9]", "-", "("], :eoi) + end +end diff --git a/lib/image_plug/param_parser/twicpics/output_parser.ex b/lib/image_plug/param_parser/twicpics/output_parser.ex deleted file mode 100644 index aa185bc..0000000 --- a/lib/image_plug/param_parser/twicpics/output_parser.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule ImagePlug.ParamParser.Twicpics.OutputParser do - alias ImagePlug.Transform.Output.OutputParams - - @doc """ - Parses a string into a `ImagePlug.Transform.Output.OutputParams` struct. - - Returns a `ImagePlug.Transform.Output.OutputParams` struct. - - ## Examples - - iex> ImagePlug.ParamParser.Twicpics.OutputParser.parse("avif") - {:ok, %ImagePlug.Transform.Output.OutputParams{format: :avif}} - """ - def parse(input) do - case input do - "auto" -> {:ok, %OutputParams{format: :auto}} - "avif" -> {:ok, %OutputParams{format: :avif}} - "webp" -> {:ok, %OutputParams{format: :webp}} - "jpeg" -> {:ok, %OutputParams{format: :jpeg}} - "png" -> {:ok, %OutputParams{format: :png}} - "blurhash" -> {:ok, %OutputParams{format: :blurhash}} - _ -> {:error, {:parameter_parse_error, input}} - end - end -end diff --git a/lib/image_plug/param_parser/twicpics/ratio_parser.ex b/lib/image_plug/param_parser/twicpics/ratio_parser.ex new file mode 100644 index 0000000..a61e2a9 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/ratio_parser.ex @@ -0,0 +1,45 @@ +defmodule ImagePlug.ParamParser.Twicpics.RatioParser do + alias ImagePlug.ParamParser.Twicpics.NumberParser + alias ImagePlug.ParamParser.Twicpics.ArithmeticParser + alias ImagePlug.ParamParser.Twicpics.Utils + + def parse(input, pos_offset \\ 0) do + case String.split(input, ":", parts: 2) do + [width_str, height_str] -> + with {:ok, parsed_width} <- parse_and_validate(width_str, pos_offset), + {:ok, parsed_height} <- + parse_and_validate(height_str, pos_offset + String.length(width_str) + 1) do + {:ok, %{width: parsed_width, height: parsed_height}} + else + {:error, _reason} = error -> Utils.update_error_input(error, input) + end + + [width_str] -> + # this is an invalid ratio! + # + # attempt to parse string to get error messages for number parsing. + # if it suceeds, complain that the second component that's missing + case parse_and_validate(width_str, pos_offset) do + {:ok, _} -> + Utils.unexpected_value_error(pos_offset + String.length(width_str), [":"], :eoi) + |> Utils.update_error_input(input) + + {:error, _} = error -> + Utils.update_error_input(error, input) + end + end + end + + defp parse_and_validate(num_str, pos_offset) do + with {:ok, tokens} <- NumberParser.parse(num_str, pos_offset), + {:ok, evaluated} <- ArithmeticParser.parse_and_evaluate(tokens) do + if evaluated > 0 do + {:ok, evaluated} + else + {:error, {:positive_number_required, pos: pos_offset, found: evaluated}} + end + else + {:error, {_reason, _opts}} = error -> error + end + end +end diff --git a/lib/image_plug/param_parser/twicpics/scale_parser.ex b/lib/image_plug/param_parser/twicpics/scale_parser.ex deleted file mode 100644 index 53f8cff..0000000 --- a/lib/image_plug/param_parser/twicpics/scale_parser.ex +++ /dev/null @@ -1,83 +0,0 @@ -defmodule ImagePlug.ParamParser.Twicpics.ScaleParser do - import ImagePlug.ParamParser.Twicpics.Common - - alias ImagePlug.Transform.Scale.ScaleParams - - @doc """ - Parses a string into a `ImagePlug.Transform.Scale.ScaleParams` struct. - - Returns a `ImagePlug.Transform.Scale.ScaleParams` struct. - - ## Format - - ``` - [x] - ``` - - ## Units - - Type | Format - --------- | ------------ - `pixel` | `` - `percent` | `p` - `auto` | `-` - - Only one of the dimensions can be set to `auto`. - - ## Examples - - iex> ImagePlug.ParamParser.Twicpics.ScaleParser.parse("250x25p") - {:ok, %ImagePlug.Transform.Scale.ScaleParams{method: %ImagePlug.Transform.Scale.ScaleParams.Dimensions{width: {:int, 250}, height: {:pct, {:int, 25}}}}} - - iex> ImagePlug.ParamParser.Twicpics.ScaleParser.parse("-x25p") - {:ok, %ImagePlug.Transform.Scale.ScaleParams{method: %ImagePlug.Transform.Scale.ScaleParams.Dimensions{width: :auto, height: {:pct, {:int, 25}}}}} - - iex> ImagePlug.ParamParser.Twicpics.ScaleParser.parse("50.5px-") - {:ok, %ImagePlug.Transform.Scale.ScaleParams{method: %ImagePlug.Transform.Scale.ScaleParams.Dimensions{width: {:pct, {:float, 50.5}}, height: :auto}}} - - iex> ImagePlug.ParamParser.Twicpics.ScaleParser.parse("50.5") - {:ok, %ImagePlug.Transform.Scale.ScaleParams{method: %ImagePlug.Transform.Scale.ScaleParams.Dimensions{width: {:float, 50.5}, height: :auto}}} - - iex> ImagePlug.ParamParser.Twicpics.ScaleParser.parse("50p") - {:ok, %ImagePlug.Transform.Scale.ScaleParams{method: %ImagePlug.Transform.Scale.ScaleParams.Dimensions{width: {:pct, {:int, 50}}, height: :auto}}} - """ - def parse(input) do - cond do - Regex.match?(~r/^(.+)x-$/, input) -> - Regex.run(~r/^(.+)x-$/, input, capture: :all_but_first) - |> with_parsed_units(fn [width] -> - {:ok, %ScaleParams{method: %ScaleParams.Dimensions{width: width, height: :auto}}} - end) - - Regex.match?(~r/^-x(.+)$/, input) -> - Regex.run(~r/^-x(.+)$/, input, capture: :all_but_first) - |> with_parsed_units(fn [height] -> - {:ok, %ScaleParams{method: %ScaleParams.Dimensions{width: :auto, height: height}}} - end) - - Regex.match?(~r/^(.+)x(.+)$/, input) -> - Regex.run(~r/^(.+)x(.+)$/, input, capture: :all_but_first) - |> with_parsed_units(fn [width, height] -> - {:ok, %ScaleParams{method: %ScaleParams.Dimensions{width: width, height: height}}} - end) - - Regex.match?(~r/^(.+):(.+)$/, input) -> - Regex.run(~r/^(.+):(.+)$/, input, capture: :all_but_first) - |> with_parsed_units(fn [ar_width, ar_height] -> - {:ok, - %ScaleParams{ - method: %ScaleParams.AspectRatio{aspect_ratio: {:ratio, ar_width, ar_height}} - }} - end) - - Regex.match?(~r/^(.+)$/, input) -> - Regex.run(~r/^(.+)$/, input, capture: :all_but_first) - |> with_parsed_units(fn [width] -> - {:ok, %ScaleParams{method: %ScaleParams.Dimensions{width: width, height: :auto}}} - end) - - true -> - {:error, {:parameter_parse_error, input}} - end - end -end diff --git a/lib/image_plug/param_parser/twicpics/size_parser.ex b/lib/image_plug/param_parser/twicpics/size_parser.ex new file mode 100644 index 0000000..2a16401 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/size_parser.ex @@ -0,0 +1,51 @@ +defmodule ImagePlug.ParamParser.Twicpics.SizeParser do + alias ImagePlug.ParamParser.Twicpics.LengthParser + alias ImagePlug.ParamParser.Twicpics.Utils + + def parse(input, pos_offset \\ 0) do + case String.split(input, "x", parts: 2) do + ["-", "-"] -> + {:error, {:unexpected_char, pos: pos_offset + 2, expected: ["(", "[0-9]", found: "-"]}} + + ["-", height_str] -> + case parse_and_validate(height_str, pos_offset + 2) do + {:ok, parsed_height} -> {:ok, %{width: :auto, height: parsed_height}} + {:error, _reason} = error -> Utils.update_error_input(error, input) + end + + [width_str, "-"] -> + case parse_and_validate(width_str, pos_offset) do + {:ok, parsed_width} -> {:ok, %{width: parsed_width, height: :auto}} + {:error, _reason} = error -> Utils.update_error_input(error, input) + end + + [width_str, height_str] -> + with {:ok, parsed_width} <- parse_and_validate(width_str, pos_offset), + {:ok, parsed_height} <- + parse_and_validate(height_str, pos_offset + String.length(width_str) + 1) do + {:ok, %{width: parsed_width, height: parsed_height}} + else + {:error, _reason} = error -> Utils.update_error_input(error, input) + end + + [width_str] -> + case parse_and_validate(width_str, pos_offset) do + {:ok, parsed_width} -> {:ok, %{width: parsed_width, height: :auto}} + {:error, _reason} = error -> Utils.update_error_input(error, input) + end + end + end + + defp parse_and_validate(length_str, offset) do + case LengthParser.parse(length_str, offset) do + {:ok, {_type, number} = parsed_length} when number > 0 -> + {:ok, parsed_length} + + {:ok, {_type, number}} -> + {:error, {:strictly_positive_number_required, pos: offset, found: number}} + + {:error, _reason} = error -> + error + 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 new file mode 100644 index 0000000..a29bac0 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/transform/contain_parser.ex @@ -0,0 +1,27 @@ +defmodule ImagePlug.ParamParser.Twicpics.Transform.ContainParser 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=` + + ## Examples + iex> ImagePlug.ParamParser.Twicpics.Transform.ContainParser.parse("250x25.5") + {:ok, %ImagePlug.Transform.Contain.ContainParams{width: {:pixels, 250}, height: {:pixels, 25.5}}} + """ + + 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}} + + {:error, _reason} = error -> + Utils.update_error_input(error, input) + end + end +end diff --git a/lib/image_plug/param_parser/twicpics/transform/crop_parser.ex b/lib/image_plug/param_parser/twicpics/transform/crop_parser.ex new file mode 100644 index 0000000..14ba0c6 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/transform/crop_parser.ex @@ -0,0 +1,55 @@ +defmodule ImagePlug.ParamParser.Twicpics.Transform.CropParser do + alias ImagePlug.ParamParser.Twicpics.CoordinatesParser + alias ImagePlug.ParamParser.Twicpics.SizeParser + alias ImagePlug.ParamParser.Twicpics.Utils + alias ImagePlug.Transform.Crop.CropParams + + @doc """ + Parses a string into a `ImagePlug.Transform.Crop.CropParams` struct. + + Syntax: + * `crop=` + * `crop=@` + + ## Examples + iex> ImagePlug.ParamParser.Twicpics.Transform.CropParser.parse("250x25p") + {:ok, %ImagePlug.Transform.Crop.CropParams{width: {:pixels, 250}, height: {:percent, 25}, crop_from: :focus}} + + iex> ImagePlug.ParamParser.Twicpics.Transform.CropParser.parse("20px25@10x50.1p") + {:ok, %ImagePlug.Transform.Crop.CropParams{width: {:percent, 20}, height: {:pixels, 25}, crop_from: %{left: {:pixels, 10}, top: {:percent, 50.1}}}} + """ + def parse(input, pos_offset \\ 0) do + case String.split(input, "@", parts: 2) do + [size_str, coordinates_str] -> + with {:ok, parsed_size} <- SizeParser.parse(size_str, pos_offset), + {:ok, parsed_coordinates} <- + CoordinatesParser.parse(coordinates_str, pos_offset + String.length(size_str) + 1) do + {:ok, + %CropParams{ + width: parsed_size.width, + height: parsed_size.height, + crop_from: %{ + left: parsed_coordinates.left, + top: parsed_coordinates.top + } + }} + else + {:error, _reason} = error -> Utils.update_error_input(error, input) + end + + [size_str] -> + case SizeParser.parse(size_str, pos_offset) do + {:ok, parsed_size} -> + {:ok, + %CropParams{ + width: parsed_size.width, + height: parsed_size.height, + crop_from: :focus + }} + + {:error, _reason} = error -> + Utils.update_error_input(error, input) + end + end + end +end diff --git a/lib/image_plug/param_parser/twicpics/transform/focus_parser.ex b/lib/image_plug/param_parser/twicpics/transform/focus_parser.ex new file mode 100644 index 0000000..de4df6d --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/transform/focus_parser.ex @@ -0,0 +1,61 @@ +defmodule ImagePlug.ParamParser.Twicpics.Transform.FocusParser do + alias ImagePlug.ParamParser.Twicpics.CoordinatesParser + alias ImagePlug.ParamParser.Twicpics.Utils + + alias ImagePlug.Transform.Focus.FocusParams + + @anchors %{ + "center" => {:anchor, :center, :center}, + "bottom" => {:anchor, :center, :bottom}, + "bottom-left" => {:anchor, :left, :bottom}, + "bottom-right" => {:anchor, :right, :bottom}, + "left" => {:anchor, :left, :center}, + "top" => {:anchor, :center, :top}, + "top-left" => {:anchor, :left, :top}, + "top-right" => {:anchor, :right, :top}, + "right" => {:anchor, :right, :center} + } + + @doc """ + Parses a string into a `ImagePlug.Transform.Focus.FocusParams` struct. + + Syntax: + * `focus=` + * `focus=` + * ~~`focus=auto`~~ + + ## Examples + iex> ImagePlug.ParamParser.Twicpics.Transform.FocusParser.parse("(500/2)x25.5") + {:ok, %ImagePlug.Transform.Focus.FocusParams{type: {:coordinate, {:pixels, 250.0}, {:pixels, 25.5}}}} + + iex> ImagePlug.ParamParser.Twicpics.Transform.FocusParser.parse("bottom-right") + {:ok, %ImagePlug.Transform.Focus.FocusParams{type: {:anchor, :right, :bottom}}} + """ + + def parse(input, pos_offset \\ 0) do + if String.contains?(input, "x"), + do: parse_coordinates(input, pos_offset), + else: parse_anchor_string(input, pos_offset) + end + + defp parse_coordinates(input, pos_offset) do + case CoordinatesParser.parse(input, pos_offset) do + {:ok, %{left: left, top: top}} -> + {:ok, %FocusParams{type: {:coordinate, left, top}}} + + {:error, _reason} = error -> + Utils.update_error_input(error, input) + end + end + + defp parse_anchor_string(input, pos_offset) do + case Map.get(@anchors, input) do + {:anchor, _, _} = anchor -> + {:ok, %FocusParams{type: anchor}} + + _ -> + Utils.unexpected_value_error(pos_offset, Map.keys(@anchors), input) + |> Utils.update_error_input(input) + end + end +end diff --git a/lib/image_plug/param_parser/twicpics/transform/output_parser.ex b/lib/image_plug/param_parser/twicpics/transform/output_parser.ex new file mode 100644 index 0000000..5961a32 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/transform/output_parser.ex @@ -0,0 +1,40 @@ +defmodule ImagePlug.ParamParser.Twicpics.Transform.OutputParser do + alias ImagePlug.ParamParser.Twicpics.CoordinatesParser + alias ImagePlug.ParamParser.Twicpics.Utils + + alias ImagePlug.Transform.Output.OutputParams + + @formats %{ + "auto" => :auto, + "avif" => :avif, + "webp" => :webp, + "jpeg" => :jpeg, + "png" => :png, + "blurhash" => :blurhash + } + + @doc """ + Parses a string into a `ImagePlug.Transform.Output.OutputParams` struct. + + Syntax: + * `output=` + * `output=` + + ## Examples + iex> ImagePlug.ParamParser.Twicpics.Transform.OutputParser.parse("avif") + {:ok, %ImagePlug.Transform.Output.OutputParams{format: :avif}} + + iex> ImagePlug.ParamParser.Twicpics.Transform.OutputParser.parse("blurhash") + {:ok, %ImagePlug.Transform.Output.OutputParams{format: :blurhash}} + """ + def parse(input, pos_offset \\ 0) do + case Map.get(@formats, input) do + format when is_atom(format) -> + {:ok, %OutputParams{format: format}} + + _ -> + Utils.unexpected_value_error(pos_offset, Map.keys(@formats), input) + |> Utils.update_error_input(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 new file mode 100644 index 0000000..6dde875 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/transform/scale_parser.ex @@ -0,0 +1,63 @@ +defmodule ImagePlug.ParamParser.Twicpics.Transform.ScaleParser do + alias ImagePlug.ParamParser.Twicpics.SizeParser + alias ImagePlug.ParamParser.Twicpics.RatioParser + alias ImagePlug.ParamParser.Twicpics.Utils + alias ImagePlug.Transform.Scale.ScaleParams + alias ImagePlug.Transform.Scale.ScaleParams.Dimensions + alias ImagePlug.Transform.Scale.ScaleParams.AspectRatio + + @doc """ + Parses a string into a `ImagePlug.Transform.Scale.ScaleParams` struct. + + Syntax + * `resize=` + * `resize=` + + ## 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}}}} + + iex> ImagePlug.ParamParser.Twicpics.Transform.ScaleParser.parse("-x25p") + {:ok, %ImagePlug.Transform.Scale.ScaleParams{method: %ImagePlug.Transform.Scale.ScaleParams.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}}} + + 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}}} + + iex> ImagePlug.ParamParser.Twicpics.Transform.ScaleParser.parse("50p") + {:ok, %ImagePlug.Transform.Scale.ScaleParams{method: %ImagePlug.Transform.Scale.ScaleParams.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}}}} + + 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}}}} + """ + 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, %ScaleParams{method: %AspectRatio{aspect_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, %ScaleParams{method: %Dimensions{width: width, height: height}}} + + {:error, _reason} = error -> + Utils.update_error_input(error, input) + end + end +end diff --git a/lib/image_plug/param_parser/twicpics/utils.ex b/lib/image_plug/param_parser/twicpics/utils.ex new file mode 100644 index 0000000..8781f45 --- /dev/null +++ b/lib/image_plug/param_parser/twicpics/utils.ex @@ -0,0 +1,40 @@ +defmodule ImagePlug.ParamParser.Twicpics.Utils do + def balanced_parens?(value) when is_binary(value) do + balanced_parens?(value, []) + end + + # both sting and stack exhausted, we're in balance! + defp balanced_parens?("", []), do: true + + # string is empty, but stack is not, so a paren has not been closed + defp balanced_parens?("", _stack), do: false + + # add "(" to stack + defp balanced_parens?(<<"("::binary, rest::binary>>, stack), + do: balanced_parens?(rest, ["(" | stack]) + + # we found a ")", remove "(" from stack and continue + defp balanced_parens?(<<")"::binary, rest::binary>>, ["(" | stack]), + do: balanced_parens?(rest, stack) + + # we found a ")", but head of stack doesn't match + defp balanced_parens?(<<")"::binary, _rest::binary>>, _stack), do: false + + # consume all other chars + defp balanced_parens?(<<_char::utf8, rest::binary>>, stack), do: balanced_parens?(rest, stack) + + def update_error_input({:error, {reason, opts}}, input) do + {:error, {reason, Keyword.put(opts, :input, input)}} + end + + def token_pos({:int, _value, pos_b, pos_e}), do: {pos_b, pos_e} + def token_pos({:float_open, _value, pos_b, pos_e}), do: {pos_b, pos_e} + def token_pos({:float, _value, pos_b, pos_e}), do: {pos_b, pos_e} + def token_pos({:left_paren, pos}), do: {pos, pos} + def token_pos({:right_paren, pos}), do: {pos, pos} + def token_pos({:op, _optype, pos}), do: {pos, pos} + + def unexpected_value_error(pos, expected, found) do + {:error, {:unexpected_char, pos: pos, expected: expected, found: found}} + end +end diff --git a/lib/image_plug/transform.ex b/lib/image_plug/transform.ex index 01bce56..464b4b0 100644 --- a/lib/image_plug/transform.ex +++ b/lib/image_plug/transform.ex @@ -4,37 +4,22 @@ defmodule ImagePlug.Transform do @callback execute(TransformState.t(), String.t()) :: TransformState.t() - def eval_number({:int, int}), do: {:ok, int} - def eval_number({:float, float}), do: {:ok, float} - def eval_number({:expr, expr}), do: ArithmeticParser.parse_and_evaluate(expr) - 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, {:int, int}), do: {:ok, int} - def to_pixels(_state, _dimension, {:float, float}), do: {:ok, round(float)} - def to_pixels(_state, _dimension, {:expr, _} = expr), do: eval_number(expr) - def to_pixels(state, dimension, {:scale, numerator_num, denominator_num}) do - with {:ok, numerator} <- eval_number(numerator_num), - {:ok, denominator} <- eval_number(denominator_num) do - if denominator_num != 0 do - {:ok, round(image_dim(state, dimension) * numerator / denominator)} - else - {:error, :division_by_zero} - end - else - {:error, _} = error -> error - end + 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, {:pct, num}) do - case eval_number(num) do - {:ok, result} -> {:ok, round(result / 100 * image_dim(state, dimension))} - {:error, _} = error -> error - 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_chain.ex b/lib/image_plug/transform_chain.ex index d13b20e..a307289 100644 --- a/lib/image_plug/transform_chain.ex +++ b/lib/image_plug/transform_chain.ex @@ -10,8 +10,8 @@ defmodule ImagePlug.TransformChain do ## Examples iex> chain = [ - ...> {ImagePlug.Transform.Focus, %ImagePlug.Transform.Focus.FocusParams{type: {:coordinate, {:int, 20}, {:int, 30}}}}, - ...> {ImagePlug.Transform.Crop, %ImagePlug.Transform.Crop.CropParams{width: {:int, 100}, height: {:int, 150}, crop_from: :focus}} + ...> {ImagePlug.Transform.Focus, %ImagePlug.Transform.Focus.FocusParams{type: {:coordinate, {:pixels, 20}, {:pixels, 30}}}}, + ...> {ImagePlug.Transform.Crop, %ImagePlug.Transform.Crop.CropParams{width: {:pixels, 100}, height: {:pixels, 150}, crop_from: :focus}} ...> ] ...> {:ok, empty_image} = Image.new(500, 500) ...> initial_state = %ImagePlug.TransformState{image: empty_image} diff --git a/test/param_parser/twicpics_parser_test.exs b/test/param_parser/twicpics_parser_test.exs index 7dbc285..a354ba6 100644 --- a/test/param_parser/twicpics_parser_test.exs +++ b/test/param_parser/twicpics_parser_test.exs @@ -10,39 +10,42 @@ defmodule ImagePlug.ParamParser.TwicpicsParserTest do alias ImagePlug.Transform.Focus alias ImagePlug.Transform.Contain - doctest ImagePlug.ParamParser.Twicpics.CropParser - doctest ImagePlug.ParamParser.Twicpics.ScaleParser - doctest ImagePlug.ParamParser.Twicpics.FocusParser - doctest ImagePlug.ParamParser.Twicpics.ContainParser - doctest ImagePlug.ParamParser.Twicpics.OutputParser + doctest ImagePlug.ParamParser.Twicpics.Transform.CropParser + doctest ImagePlug.ParamParser.Twicpics.Transform.ScaleParser + doctest ImagePlug.ParamParser.Twicpics.Transform.FocusParser + doctest ImagePlug.ParamParser.Twicpics.Transform.ContainParser + doctest ImagePlug.ParamParser.Twicpics.Transform.OutputParser - defp unit_str({:int, v}), do: "#{v}" - defp unit_str({:float, v}), do: "#{v}" - defp unit_str({:scale, unit_a, unit_b}), do: "(#{unit_str(unit_a)}/#{unit_str(unit_b)})s" - defp unit_str({:pct, unit}), do: "#{unit_str(unit)}p" + defp length_str({:pixels, unit}), do: "#{unit}" + defp length_str({:scale, unit_a, unit_b}), do: "(#{unit_a}/#{unit_b})s" + defp length_str({:percent, unit}), do: "#{unit}p" + + defp to_result({:pixels, unit}), do: {:pixels, unit} + defp to_result({:scale, unit_a, unit_b}), do: {:scale, unit_a / unit_b} + defp to_result({:percent, unit}), do: {:percent, unit} test "crop params parser" do - check all width <- random_root_unit(), - height <- random_root_unit(), + check all width <- random_root_unit(min: 1), + height <- random_root_unit(min: 1), crop_from <- crop_from() do - str_params = "#{unit_str(width)}x#{unit_str(height)}" + str_params = "#{length_str(width)}x#{length_str(height)}" str_params = case crop_from do :focus -> str_params - %{left: left, top: top} -> "#{str_params}@#{unit_str(left)}x#{unit_str(top)}" + %{left: left, top: top} -> "#{str_params}@#{length_str(left)}x#{length_str(top)}" end - parsed = Twicpics.CropParser.parse(str_params) + parsed = Twicpics.Transform.CropParser.parse(str_params) assert {:ok, %Crop.CropParams{ - width: width, - height: height, + width: to_result(width), + height: to_result(height), crop_from: case crop_from do :focus -> :focus - %{left: left, top: top} -> %{left: left, top: top} + %{left: left, top: top} -> %{left: to_result(left), top: to_result(top)} end }} == parsed @@ -63,73 +66,90 @@ defmodule ImagePlug.ParamParser.TwicpicsParserTest do check all focus_type <- focus_type() do str_params = case focus_type do - {:coordinate, left, top} -> "#{unit_str(left)}x#{unit_str(top)}" + {:coordinate, left, top} -> "#{length_str(left)}x#{length_str(top)}" {:anchor, _, _} = anchor -> anchor_to_str(anchor) end - {:ok, parsed} = Twicpics.FocusParser.parse(str_params) - assert %Focus.FocusParams{type: focus_type} == parsed + {:ok, parsed} = Twicpics.Transform.FocusParser.parse(str_params) + + case focus_type do + {:coordinate, left, top} -> + assert %Focus.FocusParams{type: {:coordinate, to_result(left), to_result(top)}} == + parsed + + {:anchor, _, _} -> + assert %Focus.FocusParams{type: focus_type} == parsed + end end end test "scale params parser" do check all {type, params} <- one_of([ - tuple({constant(:auto_width), tuple({random_root_unit()})}), - tuple({constant(:auto_height), tuple({random_root_unit()})}), - tuple({constant(:simple), tuple({random_root_unit()})}), + tuple({constant(:auto_width), tuple({random_root_unit(min: 1)})}), + tuple({constant(:auto_height), tuple({random_root_unit(min: 1)})}), + tuple({constant(:simple), tuple({random_root_unit(min: 1)})}), tuple( - {constant(:width_and_height), tuple({random_root_unit(), random_root_unit()})} + {constant(:width_and_height), + tuple({random_root_unit(min: 1), random_root_unit(min: 1)})} ), tuple( - {constant(:aspect_ratio), tuple({random_root_unit(), random_root_unit()})} + {constant(:aspect_ratio), + tuple({random_base_unit(min: 1), random_base_unit(min: 1)})} ) ]) do {str_params, expected} = case {type, params} do {:auto_width, {height}} -> - {"-x#{unit_str(height)}", + {"-x#{length_str(height)}", %Scale.ScaleParams{ - method: %Scale.ScaleParams.Dimensions{width: :auto, height: height} + method: %Scale.ScaleParams.Dimensions{width: :auto, height: to_result(height)} }} {:auto_height, {width}} -> - {"#{unit_str(width)}x-", + {"#{length_str(width)}x-", %Scale.ScaleParams{ - method: %Scale.ScaleParams.Dimensions{width: width, height: :auto} + method: %Scale.ScaleParams.Dimensions{width: to_result(width), height: :auto} }} {:simple, {width}} -> - {"#{unit_str(width)}", + {"#{length_str(width)}", %Scale.ScaleParams{ - method: %Scale.ScaleParams.Dimensions{width: width, height: :auto} + method: %Scale.ScaleParams.Dimensions{width: to_result(width), height: :auto} }} {:width_and_height, {width, height}} -> - {"#{unit_str(width)}x#{unit_str(height)}", + {"#{length_str(width)}x#{length_str(height)}", %Scale.ScaleParams{ - method: %Scale.ScaleParams.Dimensions{width: width, height: height} + method: %Scale.ScaleParams.Dimensions{ + width: to_result(width), + height: to_result(height) + } }} {:aspect_ratio, {ar_w, ar_h}} -> - {"#{unit_str(ar_w)}:#{unit_str(ar_h)}", + {"#{ar_w}:#{ar_h}", %Scale.ScaleParams{ - method: %Scale.ScaleParams.AspectRatio{aspect_ratio: {:ratio, ar_w, ar_h}} + method: %Scale.ScaleParams.AspectRatio{ + aspect_ratio: {:ratio, ar_w, ar_h} + } }} end - {:ok, parsed} = Twicpics.ScaleParser.parse(str_params) + {:ok, parsed} = Twicpics.Transform.ScaleParser.parse(str_params) assert parsed == expected end end test "contain params parser" do - check all width <- random_root_unit(), - height <- random_root_unit() do - str_params = "#{unit_str(width)}x#{unit_str(height)}" - parsed = Twicpics.ContainParser.parse(str_params) - assert {:ok, %Contain.ContainParams{width: width, height: height}} == parsed + check all width <- random_root_unit(min: 1), + height <- random_root_unit(min: 1) 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)}} == + parsed end end end diff --git a/test/twicpics_test.exs b/test/param_parser/twicpics_test.exs similarity index 89% rename from test/twicpics_test.exs rename to test/param_parser/twicpics_test.exs index c6b9904..699cc88 100644 --- a/test/twicpics_test.exs +++ b/test/param_parser/twicpics_test.exs @@ -19,20 +19,20 @@ defmodule ImagePlug.TwicpicsTest do %Transform.Focus.FocusParams{ type: { :coordinate, - {:scale, {:int, 1}, {:int, 2}}, - {:scale, {:int, 2}, {:int, 3}} + {:scale, 1 / 2}, + {:scale, 2 / 3} } }}, {Transform.Crop, %Transform.Crop.CropParams{ - width: {:int, 100}, - height: {:int, 100}, + width: {:pixels, 100}, + height: {:pixels, 100}, crop_from: :focus }}, {Transform.Scale, %Transform.Scale.ScaleParams{ method: %Transform.Scale.ScaleParams.Dimensions{ - width: {:int, 200}, + width: {:pixels, 200}, height: :auto } }}, diff --git a/test/param_parser/twicpics_v2/kv_parser_test.exs b/test/param_parser/twicpics_v2/kv_parser_test.exs new file mode 100644 index 0000000..a0d6ee5 --- /dev/null +++ b/test/param_parser/twicpics_v2/kv_parser_test.exs @@ -0,0 +1,40 @@ +defmodule ImagePlug.ParamParser.Twicpics.KVParserTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias ImagePlug.ParamParser.Twicpics.KVParser + + @keys ~w(k1 k2 k3 k1 k20 k300 k4000) + + test "successful parse output returns correct key positions" do + assert KVParser.parse("k1=v1/k2=v2/k3=v3", @keys) == + {:ok, + [ + {"k1", "v1", 0}, + {"k2", "v2", 6}, + {"k3", "v3", 12} + ]} + + assert KVParser.parse("k1=v1/k20=v20/k300=v300/k4000=v4000", @keys) == + {:ok, + [ + {"k1", "v1", 0}, + {"k20", "v20", 6}, + {"k300", "v300", 14}, + {"k4000", "v4000", 24} + ]} + end + + test "error returns correct position when missing =" do + assert KVParser.parse("k1=v1/k20=v20/k300", @keys) == + {:error, {:unexpected_char, [{:pos, 18}, {:expected, ["="]}, {:found, :eoi}]}} + end + + test "expected key error returns correct position" do + assert KVParser.parse("k1=v1/k20=v20/", @keys) == {:error, {:expected_key, pos: 14}} + end + + test "expected value error returns correct position" do + assert KVParser.parse("k1=v1/k20=", @keys) == {:error, {:expected_value, pos: 10}} + end +end diff --git a/test/param_parser/twicpics_v2/number_parser_test.exs b/test/param_parser/twicpics_v2/number_parser_test.exs new file mode 100644 index 0000000..5ee1c07 --- /dev/null +++ b/test/param_parser/twicpics_v2/number_parser_test.exs @@ -0,0 +1,217 @@ +defmodule ImagePlug.ParamParser.Twicpics.NumberParserTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias ImagePlug.ParamParser.Twicpics.NumberParser + + describe "successful parsing" do + test "parses a single integer" do + input = "123" + {:ok, tokens} = NumberParser.parse(input) + + assert tokens == [{:int, 123, 0, 2}] + end + + test "parses a single negative integer" do + input = "-123" + {:ok, tokens} = NumberParser.parse(input) + + assert tokens == [{:int, -123, 0, 3}] + end + + test "parses a single floating-point number" do + input = "123.45" + {:ok, tokens} = NumberParser.parse(input) + + assert tokens == [{:float, 123.45, 0, 5}] + end + + test "parses a single negative floating-point number" do + input = "-123.45" + {:ok, tokens} = NumberParser.parse(input) + + assert tokens == [{:float, -123.45, 0, 6}] + end + + test "parses a simple addition expression with parentheses" do + input = "(123+456)" + {:ok, tokens} = NumberParser.parse(input) + + assert tokens == [ + {:left_paren, 0}, + {:int, 123, 1, 3}, + {:op, "+", 4}, + {:int, 456, 5, 7}, + {:right_paren, 8} + ] + end + + test "parses a complex nested expression" do + input = "((123+456)*789)" + {:ok, tokens} = NumberParser.parse(input) + + assert tokens == [ + {:left_paren, 0}, + {:left_paren, 1}, + {:int, 123, 2, 4}, + {:op, "+", 5}, + {:int, 456, 6, 8}, + {:right_paren, 9}, + {:op, "*", 10}, + {:int, 789, 11, 13}, + {:right_paren, 14} + ] + end + + test "parses an expression with mixed operators" do + input = "(123+456*789)" + {:ok, tokens} = NumberParser.parse(input) + + assert tokens == [ + {:left_paren, 0}, + {:int, 123, 1, 3}, + {:op, "+", 4}, + {:int, 456, 5, 7}, + {:op, "*", 8}, + {:int, 789, 9, 11}, + {:right_paren, 12} + ] + end + + test "parses an expression with multiple levels of nesting" do + input = "(((123+456)-789)/2)" + {:ok, tokens} = NumberParser.parse(input) + + assert tokens == [ + {:left_paren, 0}, + {:left_paren, 1}, + {:left_paren, 2}, + {:int, 123, 3, 5}, + {:op, "+", 6}, + {:int, 456, 7, 9}, + {:right_paren, 10}, + {:op, "-", 11}, + {:int, 789, 12, 14}, + {:right_paren, 15}, + {:op, "/", 16}, + {:int, 2, 17, 17}, + {:right_paren, 18} + ] + end + + test "parses a complex statement with all operators" do + assert NumberParser.parse("(10/20*(4+5)-5*-1)") == + {:ok, + [ + {:left_paren, 0}, + {:int, 10, 1, 2}, + {:op, "/", 3}, + {:int, 20, 4, 5}, + {:op, "*", 6}, + {:left_paren, 7}, + {:int, 4, 8, 8}, + {:op, "+", 9}, + {:int, 5, 10, 10}, + {:right_paren, 11}, + {:op, "-", 12}, + {:int, 5, 13, 13}, + {:op, "*", 14}, + {:int, -1, 15, 16}, + {:right_paren, 17} + ]} + end + + test "parses an expression with whitespace" do + input = " ( 123 + 456 * ( 789 - 10 ) ) " + {:ok, tokens} = NumberParser.parse(input) + + assert tokens == [ + {:left_paren, 1}, + {:int, 123, 5, 7}, + {:op, "+", 9}, + {:int, 456, 11, 13}, + {:op, "*", 15}, + {:left_paren, 17}, + {:int, 789, 19, 21}, + {:op, "-", 23}, + {:int, 10, 28, 29}, + {:right_paren, 31}, + {:right_paren, 36} + ] + end + end + + describe "unexpected_value_error/3" do + test "invalid character at the start of input" do + input = "x123" + {:error, {:unexpected_char, opts}} = NumberParser.parse(input) + + assert Keyword.get(opts, :pos) == 0 + assert Keyword.get(opts, :expected) == ["(", "[0-9]"] + assert Keyword.get(opts, :found) == "x" + end + + test "invalid character after a valid integer" do + input = "123x" + {:error, {:unexpected_char, opts}} = NumberParser.parse(input) + + assert Keyword.get(opts, :pos) == 3 + assert Keyword.get(opts, :expected) == ["[0-9]", "."] + assert Keyword.get(opts, :found) == "x" + end + + test "invalid character after a valid float" do + input = "12.3x" + {:error, {:unexpected_char, opts}} = NumberParser.parse(input) + + assert Keyword.get(opts, :pos) == 4 + assert Keyword.get(opts, :expected) == ["[0-9]"] + assert Keyword.get(opts, :found) == "x" + end + + test "mismatched parentheses" do + input = "(123" + {:error, {:unexpected_char, opts}} = NumberParser.parse(input) + + assert Keyword.get(opts, :pos) == 4 + assert Keyword.get(opts, :expected) == ["[0-9]", ".", "+", "-", "*", "/", ")"] + assert Keyword.get(opts, :found) == :eoi + end + + test "operators outside parentheses" do + input = "123+456" + {:error, {:unexpected_char, opts}} = NumberParser.parse(input) + + assert Keyword.get(opts, :pos) == 3 + assert Keyword.get(opts, :expected) == ["[0-9]", "."] + assert Keyword.get(opts, :found) == "+" + end + + test "unexpected character after a valid expression" do + input = "(123+456)x" + {:error, {:unexpected_char, opts}} = NumberParser.parse(input) + + assert Keyword.get(opts, :pos) == 9 + assert Keyword.get(opts, :expected) == [:eoi] + assert Keyword.get(opts, :found) == "x" + end + + test "unclosed float at the end of input" do + input = "123." + {:error, {:unexpected_char, opts}} = NumberParser.parse(input) + + assert Keyword.get(opts, :pos) == 4 + assert Keyword.get(opts, :expected) == ["[0-9]"] + assert Keyword.get(opts, :found) == :eoi + end + + test "unexpected end of input after opening parenthesis" do + input = "(" + {:error, {:unexpected_char, opts}} = NumberParser.parse(input) + + assert Keyword.get(opts, :pos) == 1 + assert Keyword.get(opts, :expected) == ["(", "[0-9]", "-"] + assert Keyword.get(opts, :found) == :eoi + end + end +end diff --git a/test/param_parser/twicpics_v2/utils_test.exs b/test/param_parser/twicpics_v2/utils_test.exs new file mode 100644 index 0000000..4abf5a8 --- /dev/null +++ b/test/param_parser/twicpics_v2/utils_test.exs @@ -0,0 +1,20 @@ +defmodule ImagePlug.ParamParser.Twicpics.UtilsTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias ImagePlug.ParamParser.Twicpics.Utils + + test "balanced_parens?/1" do + assert Utils.balanced_parens?("(") == false + assert Utils.balanced_parens?(")") == false + assert Utils.balanced_parens?("(()") == false + assert Utils.balanced_parens?("())") == false + assert Utils.balanced_parens?("(((()))") == false + assert Utils.balanced_parens?("((())))") == false + + assert Utils.balanced_parens?("") == true + assert Utils.balanced_parens?("()") == true + assert Utils.balanced_parens?("(())") == true + assert Utils.balanced_parens?("((()))") == true + end +end diff --git a/test/support/image_plug_test_support.ex b/test/support/image_plug_test_support.ex index 2573813..9cbe975 100644 --- a/test/support/image_plug_test_support.ex +++ b/test/support/image_plug_test_support.ex @@ -6,8 +6,8 @@ defmodule ImagePlug.TestSupport do max = Keyword.get(opts, :max, 9999) one_of([ - tuple({constant(:int), integer(min..max)}), - tuple({constant(:float), float(min: min, max: max)}) + integer(min..max), + float(min: min, max: max) ]) end @@ -24,13 +24,13 @@ defmodule ImagePlug.TestSupport do denominator_max = Keyword.get(opts, :denominator_min, 9999) one_of([ - tuple({constant(:int), integer(int_min..int_max)}), - tuple({constant(:float), float(min: float_min, max: float_max)}), + tuple({constant(:pixels), integer(int_min..int_max)}), + tuple({constant(:pixels), float(min: float_min, max: float_max)}), tuple( {constant(:scale), random_base_unit(min: numerator_min, max: numerator_max), random_base_unit(min: 1, max: denominator_max)} ), - tuple({constant(:pct), random_base_unit(min: pct_min, max: pct_max)}) + tuple({constant(:percent), random_base_unit(min: pct_min, max: pct_max)}) ]) end