From 3a66ba5787c80fd643649db3bce616d0662092d6 Mon Sep 17 00:00:00 2001 From: Benjamin Milde Date: Mon, 15 Sep 2025 18:42:18 +0200 Subject: [PATCH 1/6] WIP --- lib/encode.ex | 53 ++++++++++++++++++++++++++ lib/engine.ex | 50 ++++++++++++++++++++++++ lib/format.ex | 53 +------------------------- native/typst_nif/Cargo.lock | 2 +- test/encode_test.exs | 67 ++++++++++++++++++++++++++++++++ test/engine_test.exs | 76 +++++++++++++++++++++++++++++++++++++ 6 files changed, 249 insertions(+), 52 deletions(-) create mode 100644 lib/encode.ex create mode 100644 lib/engine.ex create mode 100644 test/encode_test.exs create mode 100644 test/engine_test.exs diff --git a/lib/encode.ex b/lib/encode.ex new file mode 100644 index 0000000..5e5377c --- /dev/null +++ b/lib/encode.ex @@ -0,0 +1,53 @@ +defprotocol Typst.Encode do + def to_string(t) +end + +defimpl Typst.Encode, for: Map do + def to_string(map) when map_size(map) == 0 do + "(:)" + end + + def to_string(map) do + "(#{Enum.map_join(map, ", ", fn {k, v} -> "#{String.Chars.BitString.to_string(k)}: #{Typst.Encode.to_string(v)}" end)})" + end +end + +defimpl Typst.Encode, for: List do + def to_string(list) do + "(#{Enum.map_join(list, ", ", &Typst.Encode.to_string/1)})" + end +end + +defimpl Typst.Encode, for: Integer do + def to_string(int) do + String.Chars.Integer.to_string(int) + end +end + +defimpl Typst.Encode, for: BitString do + def to_string(str) do + replacements = %{ + "\\" => "\\\\", + "\"" => "\\\"", + "\n" => "\\n", + "\t" => "\\t", + "\r" => "\\r" + } + + escaped = String.replace(str, Map.keys(replacements), &Map.fetch!(replacements, &1)) + + "\"#{escaped}\"" + end +end + +defimpl Typst.Encode, for: Atom do + def to_string(atom) do + Atom.to_string(atom) + end +end + +defimpl Typst.Encode, for: Tuple do + def to_string({:label, label}) when is_atom(label) do + "<#{Atom.to_string(label)}>" + end +end diff --git a/lib/engine.ex b/lib/engine.ex new file mode 100644 index 0000000..c7fe160 --- /dev/null +++ b/lib/engine.ex @@ -0,0 +1,50 @@ +defmodule Typst.Engine do + @behaviour EEx.Engine + + @impl EEx.Engine + def init(opts) do + EEx.Engine.init(opts) + end + + @impl EEx.Engine + def handle_body(state) do + EEx.Engine.handle_body(state) + end + + @impl EEx.Engine + def handle_begin(state) do + EEx.Engine.handle_begin(state) + end + + @impl EEx.Engine + def handle_end(state) do + EEx.Engine.handle_end(state) + end + + @impl EEx.Engine + def handle_text(state, meta, text) do + EEx.Engine.handle_text(state, meta, text) + end + + @impl EEx.Engine + def handle_expr(state, "=", ast) do + %{binary: binary, dynamic: dynamic, vars_count: vars_count} = state + var = Macro.var(:"arg#{vars_count}", __MODULE__) + + ast = + quote do + unquote(var) = Typst.Encode.to_string(unquote(ast)) + end + + segment = + quote do + unquote(var) :: binary + end + + %{state | dynamic: [ast | dynamic], binary: [segment | binary], vars_count: vars_count + 1} + end + + def handle_expr(state, marker, expr) do + EEx.Engine.handle_expr(state, marker, expr) + end +end diff --git a/lib/format.ex b/lib/format.ex index aadcfdc..ee34f0c 100644 --- a/lib/format.ex +++ b/lib/format.ex @@ -1,64 +1,15 @@ defmodule Typst.Format do @moduledoc """ - Contains helper functions for converting elixir datatypes into + Contains helper functions for converting elixir datatypes into the format that Typst expects """ @type column_data :: String.t() | integer - @spec table_content(list(list(column_data))) :: String.t() - @doc """ - Converts a series of columns mapped as a nested list to a format that can be - plugged in an existing table. - - ## Examples - - iex> columns = [["John", 10, 20], ["Alice", 20, 30]] - iex> Typst.Format.table_content(columns) - ~s/"John", "10", "20",\\n "Alice", "20", "30"/ - """ - @deprecated "use %Typst.Format.Table{}" - def table_content(columns) when is_list(columns) do - Enum.map_join(columns, ",\n ", fn row -> - Enum.map_join(row, ", ", &format_column_element/1) - end) - end - - defp format_column_element(e) when is_integer(e) or is_binary(e), do: add_quotes(e) - defp format_column_element(unknown), do: unknown |> inspect() |> add_quotes() - - defp add_quotes(s), do: "\"#{s}\"" - @spec bold(String.Chars.t()) :: String.t() - def bold(el), do: ["*", to_string(el), "*"] |> IO.iodata_to_binary() + def bold(el), do: ["*", el, "*"] |> IO.iodata_to_binary() @spec content(String.Chars.t()) :: String.t() def content(nil), do: "[]" def content(el), do: ["[", to_string(el), "]"] |> IO.iodata_to_binary() - - @spec array(list()) :: String.t() - def array(list) when is_list(list), - do: (["("] ++ Enum.intersperse(list, ", ") ++ [")"]) |> IO.iodata_to_binary() - - @doc false - def if_set(nil, _), do: [] - def if_set(_, content_fn) when is_function(content_fn), do: content_fn.() - def if_set(_, content), do: content - - @doc false - def recurse(content) when is_list(content) do - content - |> List.flatten() - |> Enum.map(&process/1) - |> Enum.intersperse(", ") - end - - def recurse(content), do: process(content) - - defp process(element) when is_struct(element), do: to_string(element) - defp process(element), do: content(element) - - @doc false - def maybe_append_separator([]), do: [] - def maybe_append_separator(list), do: [list | ", "] end diff --git a/native/typst_nif/Cargo.lock b/native/typst_nif/Cargo.lock index 671273b..0a1ffbd 100644 --- a/native/typst_nif/Cargo.lock +++ b/native/typst_nif/Cargo.lock @@ -2242,7 +2242,7 @@ dependencies = [ [[package]] name = "typst_nif" -version = "0.1.1" +version = "0.1.5" dependencies = [ "comemo", "rustler", diff --git a/test/encode_test.exs b/test/encode_test.exs new file mode 100644 index 0000000..fc4d8ae --- /dev/null +++ b/test/encode_test.exs @@ -0,0 +1,67 @@ +defmodule Typst.EncodeTest do + use ExUnit.Case, async: true + + describe "Typst.Encode protocol" do + test "list of integers" do + assert "(1, 2, 3)" = Typst.Encode.to_string([1, 2, 3]) + end + + test "empty map" do + assert "(:)" = Typst.Encode.to_string(%{}) + end + + test "map of strings" do + assert "(a: \"b\", c: \"d\")" = Typst.Encode.to_string(%{"a" => "b", "c" => "d"}) + end + + test "list of strings" do + assert "(\"a\", \"b\", \"c\")" = Typst.Encode.to_string(["a", "b", "c"]) + end + + test "strings with special characters are properly escaped" do + result = Typst.Encode.to_string(["hello \"world\"", "back\\slash", "new\nline"]) + assert "(\"hello \\\"world\\\"\", \"back\\\\slash\", \"new\\nline\")" = result + end + + test "strings with tabs and carriage returns are properly escaped" do + result = Typst.Encode.to_string(["tab\there", "carriage\rreturn", "both\t\r\n"]) + assert "(\"tab\\there\", \"carriage\\rreturn\", \"both\\t\\r\\n\")" = result + end + + test "empty string and strings with only special characters" do + result = Typst.Encode.to_string(["", "\n", "\t", "\r\n", "\\"]) + assert "(\"\", \"\\n\", \"\\t\", \"\\r\\n\", \"\\\\\")" = result + end + + test "unicode characters do not need escaping" do + result = Typst.Encode.to_string(["Hello 🌍", "café", "naïve", "résumé", "тест", "🚀✨"]) + assert "(\"Hello 🌍\", \"café\", \"naïve\", \"résumé\", \"тест\", \"🚀✨\")" = result + end + + test "integers are encoded as strings" do + assert "42" = Typst.Encode.to_string(42) + end + + test "single string is properly quoted and escaped" do + assert "\"hello world\"" = Typst.Encode.to_string("hello world") + end + + test "atoms are encoded as strings" do + assert "test" = Typst.Encode.to_string(:test) + end + + test "labeled tuples are encoded with angle brackets" do + assert "" = Typst.Encode.to_string({:label, :test}) + end + + test "nested structures work correctly" do + data = %{"users" => [%{"name" => "Alice", "age" => 30}, %{"name" => "Bob", "age" => 25}]} + result = Typst.Encode.to_string(data) + + expected = + "(users: ((age: 30, name: \"Alice\"), (age: 25, name: \"Bob\")))" + + assert expected == result + end + end +end diff --git a/test/engine_test.exs b/test/engine_test.exs new file mode 100644 index 0000000..9487d78 --- /dev/null +++ b/test/engine_test.exs @@ -0,0 +1,76 @@ +defmodule Typst.EngineTest do + use ExUnit.Case, async: true + + describe "Typst.Engine" do + test "renders basic templates with interpolated values" do + template = """ + Hello <%= "world" %>! + """ + + assert ~s|Hello "world"!\n| = EEx.eval_string(template, [], engine: Typst.Engine) + end + + test "handles multiple interpolations in a single template" do + template = """ + Name: <%= "Alice" %> + Age: <%= 30 %> + Items: <%= ["apple", "banana"] %> + """ + + expected = """ + Name: "Alice" + Age: 30 + Items: ("apple", "banana") + """ + + assert expected == EEx.eval_string(template, [], engine: Typst.Engine) + end + + test "works with variable bindings" do + template = """ + User: <%= name %> + Score: <%= score %> + """ + + result = EEx.eval_string(template, [name: "Bob", score: 100], engine: Typst.Engine) + assert ~s|User: "Bob"\nScore: 100\n| = result + end + + test "handles complex nested data structures" do + data = %{ + "title" => "My Document", + "sections" => [ + %{"name" => "Introduction", "pages" => 5}, + %{"name" => "Content", "pages" => 20} + ] + } + + template = """ + Document: <%= data %> + """ + + result = EEx.eval_string(template, [data: data], engine: Typst.Engine) + assert String.starts_with?(result, "Document: (") + assert String.contains?(result, "title:") + assert String.contains?(result, "sections:") + end + + test "preserves whitespace and formatting outside interpolations" do + template = """ + = Title + + This is a paragraph with <%= "interpolated" %> content. + + - Item 1: <%= 42 %> + - Item 2: <%= "test" %> + """ + + result = EEx.eval_string(template, [], engine: Typst.Engine) + + assert String.contains?(result, "= Title") + assert String.contains?(result, "This is a paragraph with \"interpolated\" content.") + assert String.contains?(result, "- Item 1: 42") + assert String.contains?(result, "- Item 2: \"test\"") + end + end +end From 2ffcf13a88cf92644b93eab9b352b07c317b9b22 Mon Sep 17 00:00:00 2001 From: Benjamin Milde Date: Sat, 20 Sep 2025 11:12:38 +0200 Subject: [PATCH 2/6] Cleanup --- lib/encode.ex | 14 ++++++ mix.exs | 1 + mix.lock | 1 + test/encode_test.exs | 100 +++++++++++++++++++++++++++++-------------- 4 files changed, 85 insertions(+), 31 deletions(-) diff --git a/lib/encode.ex b/lib/encode.ex index 5e5377c..431f4ca 100644 --- a/lib/encode.ex +++ b/lib/encode.ex @@ -24,6 +24,20 @@ defimpl Typst.Encode, for: Integer do end end +defimpl Typst.Encode, for: Float do + def to_string(float) do + String.Chars.Float.to_string(float) + end +end + +if Code.ensure_loaded?(Decimal) do + defimpl Typst.Encode, for: Decimal do + def to_string(decimal) do + "decimal(\"#{Decimal.to_string(decimal)}\")" + end + end +end + defimpl Typst.Encode, for: BitString do def to_string(str) do replacements = %{ diff --git a/mix.exs b/mix.exs index 55f4f49..2ba3b2b 100644 --- a/mix.exs +++ b/mix.exs @@ -33,6 +33,7 @@ defmodule Typst.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ + {:decimal, "~> 2.0", optional: true}, {:rustler, ">= 0.0.0", optional: true}, {:rustler_precompiled, "~> 0.8"}, {:ex_doc, "~> 0.34", only: :dev, runtime: false} diff --git a/mix.lock b/mix.lock index 861fca9..e8657ef 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,6 @@ %{ "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, diff --git a/test/encode_test.exs b/test/encode_test.exs index fc4d8ae..aaef1cc 100644 --- a/test/encode_test.exs +++ b/test/encode_test.exs @@ -1,67 +1,105 @@ defmodule Typst.EncodeTest do use ExUnit.Case, async: true + alias Typst.Encode + + describe "to_string/1 for List" do + test "empty list" do + assert "()" = Encode.to_string([]) + end - describe "Typst.Encode protocol" do test "list of integers" do - assert "(1, 2, 3)" = Typst.Encode.to_string([1, 2, 3]) + assert "(1, 2, 3)" = Encode.to_string([1, 2, 3]) + end + + test "list of strings" do + assert ~S|("a", "b", "c")| = Encode.to_string(["a", "b", "c"]) end + test "list of mixed subtypes" do + assert ~S|("a", "b", 3)| = Encode.to_string(["a", "b", 3]) + end + end + + describe "to_string/1 for Map" do test "empty map" do - assert "(:)" = Typst.Encode.to_string(%{}) + assert "(:)" = Encode.to_string(%{}) end test "map of strings" do - assert "(a: \"b\", c: \"d\")" = Typst.Encode.to_string(%{"a" => "b", "c" => "d"}) + assert ~S|(a: "b", c: "d")| = Encode.to_string(%{"a" => "b", "c" => "d"}) end - test "list of strings" do - assert "(\"a\", \"b\", \"c\")" = Typst.Encode.to_string(["a", "b", "c"]) + test "map of integers" do + assert ~S|(a: 1, b: 2)| = Encode.to_string(%{"a" => 1, "b" => 2}) end - test "strings with special characters are properly escaped" do - result = Typst.Encode.to_string(["hello \"world\"", "back\\slash", "new\nline"]) - assert "(\"hello \\\"world\\\"\", \"back\\\\slash\", \"new\\nline\")" = result + test "map of mixed types" do + assert ~s|(a: "x", b: 2)| = Encode.to_string(%{"a" => "x", "b" => 2}) + end + end + + describe "to_string/1 for BitString" do + test "simple string is properly quoted and escaped" do + assert ~S|"hello world"| = Encode.to_string("hello world") end - test "strings with tabs and carriage returns are properly escaped" do - result = Typst.Encode.to_string(["tab\there", "carriage\rreturn", "both\t\r\n"]) - assert "(\"tab\\there\", \"carriage\\rreturn\", \"both\\t\\r\\n\")" = result + test "strings with special characters are properly escaped" do + result = + Encode.to_string([ + "hello \"world\"", + "back\\slash", + "new\nline", + "tab\there", + "carriage\rreturn", + "both\t\r\n" + ]) + + assert ~S|("hello \"world\"", "back\\slash", "new\nline", "tab\there", "carriage\rreturn", "both\t\r\n")| = + result end - test "empty string and strings with only special characters" do - result = Typst.Encode.to_string(["", "\n", "\t", "\r\n", "\\"]) - assert "(\"\", \"\\n\", \"\\t\", \"\\r\\n\", \"\\\\\")" = result + test "unicode characters" do + result = Encode.to_string(["Hello 🌍", "café", "naïve", "résumé", "тест", "🚀✨"]) + assert ~S|("Hello 🌍", "café", "naïve", "résumé", "тест", "🚀✨")| = result end + end - test "unicode characters do not need escaping" do - result = Typst.Encode.to_string(["Hello 🌍", "café", "naïve", "résumé", "тест", "🚀✨"]) - assert "(\"Hello 🌍\", \"café\", \"naïve\", \"résumé\", \"тест\", \"🚀✨\")" = result + describe "to_string/1 for Atom" do + test "atoms can be encoded" do + assert "test" = Encode.to_string(:test) end + end - test "integers are encoded as strings" do - assert "42" = Typst.Encode.to_string(42) + describe "to_string/1 for Tuple" do + test "labeled tuples are encoded as typst labels" do + assert "" = Encode.to_string({:label, :test}) end + end - test "single string is properly quoted and escaped" do - assert "\"hello world\"" = Typst.Encode.to_string("hello world") + describe "to_string/1 for Integer" do + test "integers can be encoded" do + assert "42" = Encode.to_string(42) end + end - test "atoms are encoded as strings" do - assert "test" = Typst.Encode.to_string(:test) + describe "to_string/1 for Float" do + test "floats can be encoded" do + assert "0.1" = Encode.to_string(0.1) end + end - test "labeled tuples are encoded with angle brackets" do - assert "" = Typst.Encode.to_string({:label, :test}) + describe "to_string/1 for Decimal" do + test "decimals can be encoded" do + assert ~S|decimal("0.1")| = Encode.to_string(Decimal.new("0.1")) end + end + describe "to_string/1 nested" do test "nested structures work correctly" do data = %{"users" => [%{"name" => "Alice", "age" => 30}, %{"name" => "Bob", "age" => 25}]} - result = Typst.Encode.to_string(data) - - expected = - "(users: ((age: 30, name: \"Alice\"), (age: 25, name: \"Bob\")))" - assert expected == result + assert ~S|(users: ((age: 30, name: "Alice"), (age: 25, name: "Bob")))| == + Encode.to_string(data) end end end From f0b8a44d50a80609fcd50726c140b00ee8975af9 Mon Sep 17 00:00:00 2001 From: Benjamin Milde Date: Sat, 20 Sep 2025 11:16:20 +0200 Subject: [PATCH 3/6] Support bytes --- lib/encode.ex | 17 +++++++++++++++++ test/encode_test.exs | 8 ++++++++ 2 files changed, 25 insertions(+) diff --git a/lib/encode.ex b/lib/encode.ex index 431f4ca..0b8c8ea 100644 --- a/lib/encode.ex +++ b/lib/encode.ex @@ -40,6 +40,14 @@ end defimpl Typst.Encode, for: BitString do def to_string(str) do + if String.printable?(str) do + to_string_printable(str) + else + to_bytes(str) + end + end + + defp to_string_printable(str) do replacements = %{ "\\" => "\\\\", "\"" => "\\\"", @@ -52,6 +60,15 @@ defimpl Typst.Encode, for: BitString do "\"#{escaped}\"" end + + defp to_bytes(bytes) do + bytes = + bytes + |> :binary.bin_to_list() + |> Enum.join(", ") + + "bytes(#{bytes})" + end end defimpl Typst.Encode, for: Atom do diff --git a/test/encode_test.exs b/test/encode_test.exs index aaef1cc..ab73cd8 100644 --- a/test/encode_test.exs +++ b/test/encode_test.exs @@ -39,6 +39,10 @@ defmodule Typst.EncodeTest do end describe "to_string/1 for BitString" do + test "empty string" do + assert ~S|""| = Encode.to_string("") + end + test "simple string is properly quoted and escaped" do assert ~S|"hello world"| = Encode.to_string("hello world") end @@ -62,6 +66,10 @@ defmodule Typst.EncodeTest do result = Encode.to_string(["Hello 🌍", "café", "naïve", "résumé", "тест", "🚀✨"]) assert ~S|("Hello 🌍", "café", "naïve", "résumé", "тест", "🚀✨")| = result end + + test "encode as bytes" do + assert ~S|bytes(0, 1, 2, 3)| = Encode.to_string(<<0, 1, 2, 3>>) + end end describe "to_string/1 for Atom" do From 216315f541cf7a980981a36e058af53ac1a6610b Mon Sep 17 00:00:00 2001 From: Benjamin Milde Date: Sat, 20 Sep 2025 11:36:37 +0200 Subject: [PATCH 4/6] datetime support --- lib/encode.ex | 68 ++++++++++++++++++++++++++++++++++++++++++-- test/encode_test.exs | 38 +++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/lib/encode.ex b/lib/encode.ex index 0b8c8ea..81a7dcb 100644 --- a/lib/encode.ex +++ b/lib/encode.ex @@ -8,13 +8,27 @@ defimpl Typst.Encode, for: Map do end def to_string(map) do - "(#{Enum.map_join(map, ", ", fn {k, v} -> "#{String.Chars.BitString.to_string(k)}: #{Typst.Encode.to_string(v)}" end)})" + "(#{encode_kv(map)})" + end + + def encode_kv(map) do + Enum.map_join(map, ", ", fn + {k, v} when is_binary(k) -> + "#{String.Chars.BitString.to_string(k)}: #{Typst.Encode.to_string(v)}" + + {k, v} when is_atom(k) -> + "#{Typst.Encode.Atom.to_string(k)}: #{Typst.Encode.to_string(v)}" + end) end end defimpl Typst.Encode, for: List do def to_string(list) do - "(#{Enum.map_join(list, ", ", &Typst.Encode.to_string/1)})" + if Keyword.keyword?(list) do + "(#{Typst.Encode.Map.encode_kv(list)})" + else + "(#{Enum.map_join(list, ", ", &Typst.Encode.to_string/1)})" + end end end @@ -82,3 +96,53 @@ defimpl Typst.Encode, for: Tuple do "<#{Atom.to_string(label)}>" end end + +defimpl Typst.Encode, for: Date do + def to_string(date) do + kv = + Typst.Encode.Map.encode_kv( + year: date.year, + month: date.month, + day: date.day + ) + + "datetime(#{kv})" + end +end + +defimpl Typst.Encode, for: Time do + def to_string(time) do + kv = + Typst.Encode.Map.encode_kv( + hour: time.hour, + minute: time.minute, + second: time.second + ) + + "datetime(#{kv})" + end +end + +defimpl Typst.Encode, for: NaiveDateTime do + def to_string(naive) do + kv = + Typst.Encode.Map.encode_kv( + year: naive.year, + month: naive.month, + day: naive.day, + hour: naive.hour, + minute: naive.minute, + second: naive.second + ) + + "datetime(#{kv})" + end +end + +defimpl Typst.Encode, for: DateTime do + def to_string(datetime) do + datetime + |> DateTime.to_naive() + |> Typst.Encode.NaiveDateTime.to_string() + end +end diff --git a/test/encode_test.exs b/test/encode_test.exs index ab73cd8..7ae3837 100644 --- a/test/encode_test.exs +++ b/test/encode_test.exs @@ -18,6 +18,10 @@ defmodule Typst.EncodeTest do test "list of mixed subtypes" do assert ~S|("a", "b", 3)| = Encode.to_string(["a", "b", 3]) end + + test "keyword list" do + assert ~S|(a: "b", c: "d")| = Encode.to_string(a: "b", c: "d") + end end describe "to_string/1 for Map" do @@ -36,6 +40,10 @@ defmodule Typst.EncodeTest do test "map of mixed types" do assert ~s|(a: "x", b: 2)| = Encode.to_string(%{"a" => "x", "b" => 2}) end + + test "atom keys" do + assert ~S|(c: "d", a: "b")| = Encode.to_string(%{a: "b", c: "d"}) + end end describe "to_string/1 for BitString" do @@ -84,6 +92,36 @@ defmodule Typst.EncodeTest do end end + describe "to_string/1 for Date" do + test "dates can be encoded" do + assert "datetime(year: 2025, month: 9, day: 20)" = Encode.to_string(~D[2025-09-20]) + end + end + + describe "to_string/1 for Time" do + test "times can be encoded" do + assert "datetime(hour: 10, minute: 11, second: 12)" = Encode.to_string(~T[10:11:12]) + end + + test "does not support subsecond values" do + assert "datetime(hour: 10, minute: 11, second: 12)" = Encode.to_string(~T[10:11:12.013]) + end + end + + describe "to_string/1 for NaiveDateTime" do + test "naive datetimes can be encoded" do + assert "datetime(year: 2025, month: 9, day: 20, hour: 10, minute: 11, second: 12)" = + Encode.to_string(~N[2025-09-20 10:11:12]) + end + end + + describe "to_string/1 for DateTime" do + test "datetimes drop any timezone related information" do + assert "datetime(year: 2025, month: 9, day: 20, hour: 10, minute: 11, second: 12)" = + Encode.to_string(~U[2025-09-20 10:11:12Z]) + end + end + describe "to_string/1 for Integer" do test "integers can be encoded" do assert "42" = Encode.to_string(42) From e49465f1de4ce09598ddcbd7aff8214142090d9f Mon Sep 17 00:00:00 2001 From: Benjamin Milde Date: Sat, 20 Sep 2025 11:43:44 +0200 Subject: [PATCH 5/6] Support regex --- lib/encode.ex | 6 ++++++ test/encode_test.exs | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/lib/encode.ex b/lib/encode.ex index 81a7dcb..7366d1f 100644 --- a/lib/encode.ex +++ b/lib/encode.ex @@ -146,3 +146,9 @@ defimpl Typst.Encode, for: DateTime do |> Typst.Encode.NaiveDateTime.to_string() end end + +defimpl Typst.Encode, for: Regex do + def to_string(regex) do + "regex(`#{regex.source}`.text)" + end +end diff --git a/test/encode_test.exs b/test/encode_test.exs index 7ae3837..3077d35 100644 --- a/test/encode_test.exs +++ b/test/encode_test.exs @@ -122,6 +122,12 @@ defmodule Typst.EncodeTest do end end + describe "to_string/1 for Regex" do + test "regexes can be encoded" do + assert ~S|regex(`\d+\.\d+\.\d+`.text)| = Encode.to_string(~r/\d+\.\d+\.\d+/) + end + end + describe "to_string/1 for Integer" do test "integers can be encoded" do assert "42" = Encode.to_string(42) From db8573f4b94cbaadcd4aefd0d8870c74ad39e74b Mon Sep 17 00:00:00 2001 From: Benjamin Milde Date: Sun, 21 Sep 2025 16:25:16 +0200 Subject: [PATCH 6/6] WIP --- lib/{encode.ex => code.ex} | 82 +++++++-------- lib/engine.ex | 56 ++++++++++- lib/format.ex | 15 --- lib/format/table.ex | 49 ++++----- lib/markup.ex | 20 ++++ lib/typst.ex | 42 ++++---- test/code_test.exs | 157 +++++++++++++++++++++++++++++ test/encode_test.exs | 157 ----------------------------- test/engine_test.exs | 199 ++++++++++++++++++++++++------------- test/format/table_test.exs | 26 ++--- test/format_test.exs | 19 ---- test/typst_test.exs | 20 +++- 12 files changed, 480 insertions(+), 362 deletions(-) rename lib/{encode.ex => code.ex} (51%) delete mode 100644 lib/format.ex create mode 100644 lib/markup.ex create mode 100644 test/code_test.exs delete mode 100644 test/encode_test.exs delete mode 100644 test/format_test.exs diff --git a/lib/encode.ex b/lib/code.ex similarity index 51% rename from lib/encode.ex rename to lib/code.ex index 7366d1f..d21889a 100644 --- a/lib/encode.ex +++ b/lib/code.ex @@ -1,67 +1,67 @@ -defprotocol Typst.Encode do - def to_string(t) +defprotocol Typst.Code do + def encode(t) end -defimpl Typst.Encode, for: Map do - def to_string(map) when map_size(map) == 0 do +defimpl Typst.Code, for: Map do + def encode(map) when map_size(map) == 0 do "(:)" end - def to_string(map) do + def encode(map) do "(#{encode_kv(map)})" end def encode_kv(map) do Enum.map_join(map, ", ", fn {k, v} when is_binary(k) -> - "#{String.Chars.BitString.to_string(k)}: #{Typst.Encode.to_string(v)}" + "#{String.Chars.BitString.to_string(k)}: #{Typst.Code.encode(v)}" {k, v} when is_atom(k) -> - "#{Typst.Encode.Atom.to_string(k)}: #{Typst.Encode.to_string(v)}" + "#{Typst.Code.Atom.encode(k)}: #{Typst.Code.encode(v)}" end) end end -defimpl Typst.Encode, for: List do - def to_string(list) do +defimpl Typst.Code, for: List do + def encode(list) do if Keyword.keyword?(list) do - "(#{Typst.Encode.Map.encode_kv(list)})" + "(#{Typst.Code.Map.encode_kv(list)})" else - "(#{Enum.map_join(list, ", ", &Typst.Encode.to_string/1)})" + "(#{Enum.map_join(list, ", ", &Typst.Code.encode/1)})" end end end -defimpl Typst.Encode, for: Integer do - def to_string(int) do +defimpl Typst.Code, for: Integer do + def encode(int) do String.Chars.Integer.to_string(int) end end -defimpl Typst.Encode, for: Float do - def to_string(float) do +defimpl Typst.Code, for: Float do + def encode(float) do String.Chars.Float.to_string(float) end end if Code.ensure_loaded?(Decimal) do - defimpl Typst.Encode, for: Decimal do - def to_string(decimal) do + defimpl Typst.Code, for: Decimal do + def encode(decimal) do "decimal(\"#{Decimal.to_string(decimal)}\")" end end end -defimpl Typst.Encode, for: BitString do - def to_string(str) do +defimpl Typst.Code, for: BitString do + def encode(str) do if String.printable?(str) do - to_string_printable(str) + encode_printable(str) else - to_bytes(str) + encode_bytes(str) end end - defp to_string_printable(str) do + defp encode_printable(str) do replacements = %{ "\\" => "\\\\", "\"" => "\\\"", @@ -75,7 +75,7 @@ defimpl Typst.Encode, for: BitString do "\"#{escaped}\"" end - defp to_bytes(bytes) do + defp encode_bytes(bytes) do bytes = bytes |> :binary.bin_to_list() @@ -85,22 +85,22 @@ defimpl Typst.Encode, for: BitString do end end -defimpl Typst.Encode, for: Atom do - def to_string(atom) do +defimpl Typst.Code, for: Atom do + def encode(atom) do Atom.to_string(atom) end end -defimpl Typst.Encode, for: Tuple do - def to_string({:label, label}) when is_atom(label) do +defimpl Typst.Code, for: Tuple do + def encode({:label, label}) when is_atom(label) do "<#{Atom.to_string(label)}>" end end -defimpl Typst.Encode, for: Date do - def to_string(date) do +defimpl Typst.Code, for: Date do + def encode(date) do kv = - Typst.Encode.Map.encode_kv( + Typst.Code.Map.encode_kv( year: date.year, month: date.month, day: date.day @@ -110,10 +110,10 @@ defimpl Typst.Encode, for: Date do end end -defimpl Typst.Encode, for: Time do - def to_string(time) do +defimpl Typst.Code, for: Time do + def encode(time) do kv = - Typst.Encode.Map.encode_kv( + Typst.Code.Map.encode_kv( hour: time.hour, minute: time.minute, second: time.second @@ -123,10 +123,10 @@ defimpl Typst.Encode, for: Time do end end -defimpl Typst.Encode, for: NaiveDateTime do - def to_string(naive) do +defimpl Typst.Code, for: NaiveDateTime do + def encode(naive) do kv = - Typst.Encode.Map.encode_kv( + Typst.Code.Map.encode_kv( year: naive.year, month: naive.month, day: naive.day, @@ -139,16 +139,16 @@ defimpl Typst.Encode, for: NaiveDateTime do end end -defimpl Typst.Encode, for: DateTime do - def to_string(datetime) do +defimpl Typst.Code, for: DateTime do + def encode(datetime) do datetime |> DateTime.to_naive() - |> Typst.Encode.NaiveDateTime.to_string() + |> Typst.Code.NaiveDateTime.encode() end end -defimpl Typst.Encode, for: Regex do - def to_string(regex) do +defimpl Typst.Code, for: Regex do + def encode(regex) do "regex(`#{regex.source}`.text)" end end diff --git a/lib/engine.ex b/lib/engine.ex index c7fe160..16fa973 100644 --- a/lib/engine.ex +++ b/lib/engine.ex @@ -28,12 +28,31 @@ defmodule Typst.Engine do @impl EEx.Engine def handle_expr(state, "=", ast) do + ast = traverse(ast) %{binary: binary, dynamic: dynamic, vars_count: vars_count} = state var = Macro.var(:"arg#{vars_count}", __MODULE__) ast = quote do - unquote(var) = Typst.Encode.to_string(unquote(ast)) + unquote(var) = Typst.Code.encode(unquote(ast)) + end + + segment = + quote do + unquote(var) :: binary + end + + %{state | dynamic: [ast | dynamic], binary: [segment | binary], vars_count: vars_count + 1} + end + + def handle_expr(state, "|", ast) do + ast = traverse(ast) + %{binary: binary, dynamic: dynamic, vars_count: vars_count} = state + var = Macro.var(:"arg#{vars_count}", __MODULE__) + + ast = + quote do + unquote(var) = Typst.Markup.encode(unquote(ast)) end segment = @@ -45,6 +64,41 @@ defmodule Typst.Engine do end def handle_expr(state, marker, expr) do + expr = traverse(expr) EEx.Engine.handle_expr(state, marker, expr) end + + # Assigns Traversal + # There is `EEx.Engine.handle_assign/1`, but it doesn't raise, but only warn. + # + defp traverse(expr) do + Macro.prewalk(expr, &handle_assign/1) + end + + defp handle_assign({:@, meta, [{name, _, atom}]}) when is_atom(name) and is_atom(atom) do + quote line: meta[:line] || 0 do + Typst.Engine.fetch_assign!(var!(assigns), unquote(name)) + end + end + + defp handle_assign(arg), do: arg + + @doc false + def fetch_assign!(assigns, key) do + case Access.fetch(assigns, key) do + {:ok, val} -> + val + + :error -> + raise ArgumentError, """ + assign @#{key} not available in template. + + Please make sure all proper assigns have been set. If this + is a child template, ensure assigns are given explicitly by + the parent template as they are not automatically forwarded. + + Available assigns: #{inspect(Enum.map(assigns, &elem(&1, 0)))} + """ + end + end end diff --git a/lib/format.ex b/lib/format.ex deleted file mode 100644 index ee34f0c..0000000 --- a/lib/format.ex +++ /dev/null @@ -1,15 +0,0 @@ -defmodule Typst.Format do - @moduledoc """ - Contains helper functions for converting elixir datatypes into - the format that Typst expects - """ - - @type column_data :: String.t() | integer - - @spec bold(String.Chars.t()) :: String.t() - def bold(el), do: ["*", el, "*"] |> IO.iodata_to_binary() - - @spec content(String.Chars.t()) :: String.t() - def content(nil), do: "[]" - def content(el), do: ["[", to_string(el), "]"] |> IO.iodata_to_binary() -end diff --git a/lib/format/table.ex b/lib/format/table.ex index b142ce8..6cfb6e5 100644 --- a/lib/format/table.ex +++ b/lib/format/table.ex @@ -1,6 +1,4 @@ defmodule Typst.Format.Table do - import Typst.Format - @moduledoc """ Creates a typst [`#table()`](https://typst.app/docs/reference/model/table) and implements the `String.Chars` protocol for easy EEx interpolation. To build more complex tables you can use the structs under this module like `Typst.Format.Table.Hline` @@ -45,28 +43,33 @@ defmodule Typst.Format.Table do :rows ] - defimpl String.Chars do - def to_string(%Typst.Format.Table{} = table) do - [ - "#table(", - [ - if_set(table.columns, "columns: #{table.columns}"), - if_set(table.rows, "rows: #{table.rows}"), - if_set(table.gutter, "gutter: #{table.gutter}"), - if_set(table.column_gutter, "column-gutter: #{table.column_gutter}"), - if_set(table.row_gutter, "row-gutter: #{table.row_gutter}"), - if_set(table.fill, "fill: #{table.fill}"), - if_set(table.align, "align: #{table.align}"), - if_set(table.stroke, "stroke: #{table.stroke}"), - if_set(table.inset, "inset: #{table.inset}") - ] - |> Enum.reject(fn item -> item == [] end) - |> Enum.intersperse(", ") - |> maybe_append_separator(), - Typst.Format.recurse(table.content), - ")" + defimpl Typst.Markup do + def encode(%Typst.Format.Table{} = table) do + option_fields = [ + :columns, + :gutter, + :row_gutter, + :column_gutter, + :fill, + :align, + :stroke, + :inset, + :rows ] - |> IO.iodata_to_binary() + + kv = + for k <- option_fields, + v <- Map.fetch!(table, k), + v do + {k, v} + end + |> Typst.Code.Map.encode_kv() + |> then(fn + "" -> "" + str -> str <> ", " + end) + + "#table(#{kv}..#{Typst.Code.encode(table.content)})" end end diff --git a/lib/markup.ex b/lib/markup.ex new file mode 100644 index 0000000..d840e68 --- /dev/null +++ b/lib/markup.ex @@ -0,0 +1,20 @@ +defprotocol Typst.Markup do + @fallback_to_any true + def encode(t) +end + +defimpl Typst.Markup, for: BitString do + def encode(str) do + if String.printable?(str) do + str + else + raise ArgumentError, "Cannot print non-printable string to typst markup" + end + end +end + +defimpl Typst.Markup, for: Any do + def encode(data) do + String.Chars.to_string(data) + end +end diff --git a/lib/typst.ex b/lib/typst.ex index 5a2e866..bb456a4 100644 --- a/lib/typst.ex +++ b/lib/typst.ex @@ -13,51 +13,51 @@ defmodule Typst do @type formattable :: {atom, any} - @spec render_to_string(String.t(), list(formattable)) :: String.t() @doc """ - Formats the given markup template with the given bindings, mostly - useful for inspecting and debugging. - ## Examples + """ + defmacro sigil_TYPST({:<<>>, meta, [expr]}, []) do + if not Macro.Env.has_var?(__CALLER__, {:assigns, nil}) do + raise "~TYPST requires a variable named \"assigns\" to exist and be set to a map" + end - iex> Typst.render_to_string("= Hey <%= name %>!", name: "Jude") - "= Hey Jude!" + options = [ + engine: Typst.Engine, + file: __CALLER__.file, + line: __CALLER__.line + 1, + caller: __CALLER__, + indentation: meta[:indentation] || 0, + source: expr + ] - """ - def render_to_string(typst_markup, bindings \\ []) do - EEx.eval_string(typst_markup, bindings) + EEx.compile_string(expr, options) end @type pdf_opt :: {:extra_fonts, list(String.t())} - @spec render_to_pdf(String.t(), list(formattable), list(pdf_opt)) :: - {:ok, binary()} | {:error, String.t()} + @spec render_to_pdf(String.t(), list(pdf_opt)) :: {:ok, binary()} | {:error, String.t()} @doc """ Converts a given piece of typst markup to a PDF binary. ## Examples - iex> {:ok, pdf} = Typst.render_to_pdf("= test\\n<%= name %>", name: "John") + iex> {:ok, pdf} = Typst.render_to_pdf("= test\\ncontent") iex> is_binary(pdf) true """ - def render_to_pdf(typst_markup, bindings \\ [], opts \\ []) do + def render_to_pdf(typst_markup, opts \\ []) do extra_fonts = Keyword.get(opts, :extra_fonts, []) ++ @embedded_fonts root_dir = Keyword.get(opts, :root_dir, ".") - - markup = - render_to_string(typst_markup, bindings) - - Typst.NIF.compile(markup, root_dir, extra_fonts) + Typst.NIF.compile(typst_markup, root_dir, extra_fonts) end @spec render_to_pdf!(String.t(), list(formattable)) :: binary() @doc """ - Same as `render_to_pdf/3`, but raises if the rendering fails. + Same as `render_to_pdf/2`, but raises if the rendering fails. """ - def render_to_pdf!(typst_markup, bindings \\ [], opts \\ []) do - case render_to_pdf(typst_markup, bindings, opts) do + def render_to_pdf!(typst_markup, opts \\ []) do + case render_to_pdf(typst_markup, opts) do {:ok, pdf} -> pdf {:error, reason} -> raise "could not build pdf: #{reason}" end diff --git a/test/code_test.exs b/test/code_test.exs new file mode 100644 index 0000000..5e50e19 --- /dev/null +++ b/test/code_test.exs @@ -0,0 +1,157 @@ +defmodule Typst.CodeTest do + use ExUnit.Case, async: true + alias Typst.Code + + describe "encode/1 for List" do + test "empty list" do + assert "()" = Code.encode([]) + end + + test "list of integers" do + assert "(1, 2, 3)" = Code.encode([1, 2, 3]) + end + + test "list of strings" do + assert ~S|("a", "b", "c")| = Code.encode(["a", "b", "c"]) + end + + test "list of mixed subtypes" do + assert ~S|("a", "b", 3)| = Code.encode(["a", "b", 3]) + end + + test "keyword list" do + assert ~S|(a: "b", c: "d")| = Code.encode(a: "b", c: "d") + end + end + + describe "encode/1 for Map" do + test "empty map" do + assert "(:)" = Code.encode(%{}) + end + + test "map of strings" do + assert ~S|(a: "b", c: "d")| = Code.encode(%{"a" => "b", "c" => "d"}) + end + + test "map of integers" do + assert ~S|(a: 1, b: 2)| = Code.encode(%{"a" => 1, "b" => 2}) + end + + test "map of mixed types" do + assert ~s|(a: "x", b: 2)| = Code.encode(%{"a" => "x", "b" => 2}) + end + + test "atom keys" do + assert ~S|(c: "d", a: "b")| = Code.encode(%{a: "b", c: "d"}) + end + end + + describe "encode/1 for BitString" do + test "empty string" do + assert ~S|""| = Code.encode("") + end + + test "simple string is properly quoted and escaped" do + assert ~S|"hello world"| = Code.encode("hello world") + end + + test "strings with special characters are properly escaped" do + result = + Code.encode([ + "hello \"world\"", + "back\\slash", + "new\nline", + "tab\there", + "carriage\rreturn", + "both\t\r\n" + ]) + + assert ~S|("hello \"world\"", "back\\slash", "new\nline", "tab\there", "carriage\rreturn", "both\t\r\n")| = + result + end + + test "unicode characters" do + result = Code.encode(["Hello 🌍", "café", "naïve", "résumé", "тест", "🚀✨"]) + assert ~S|("Hello 🌍", "café", "naïve", "résumé", "тест", "🚀✨")| = result + end + + test "encode as bytes" do + assert ~S|bytes(0, 1, 2, 3)| = Code.encode(<<0, 1, 2, 3>>) + end + end + + describe "encode/1 for Atom" do + test "atoms can be encoded" do + assert "test" = Code.encode(:test) + end + end + + describe "encode/1 for Tuple" do + test "labeled tuples are encoded as typst labels" do + assert "" = Code.encode({:label, :test}) + end + end + + describe "encode/1 for Date" do + test "dates can be encoded" do + assert "datetime(year: 2025, month: 9, day: 20)" = Code.encode(~D[2025-09-20]) + end + end + + describe "encode/1 for Time" do + test "times can be encoded" do + assert "datetime(hour: 10, minute: 11, second: 12)" = Code.encode(~T[10:11:12]) + end + + test "does not support subsecond values" do + assert "datetime(hour: 10, minute: 11, second: 12)" = Code.encode(~T[10:11:12.013]) + end + end + + describe "encode/1 for NaiveDateTime" do + test "naive datetimes can be encoded" do + assert "datetime(year: 2025, month: 9, day: 20, hour: 10, minute: 11, second: 12)" = + Code.encode(~N[2025-09-20 10:11:12]) + end + end + + describe "encode/1 for DateTime" do + test "datetimes drop any timezone related information" do + assert "datetime(year: 2025, month: 9, day: 20, hour: 10, minute: 11, second: 12)" = + Code.encode(~U[2025-09-20 10:11:12Z]) + end + end + + describe "encode/1 for Regex" do + test "regexes can be encoded" do + assert ~S|regex(`\d+\.\d+\.\d+`.text)| = Code.encode(~r/\d+\.\d+\.\d+/) + end + end + + describe "encode/1 for Integer" do + test "integers can be encoded" do + assert "42" = Code.encode(42) + end + end + + describe "encode/1 for Float" do + test "floats can be encoded" do + assert "0.1" = Code.encode(0.1) + end + end + + describe "encode/1 for Decimal" do + test "decimals can be encoded" do + assert ~S|decimal("0.1")| = Code.encode(Decimal.new("0.1")) + end + end + + describe "encode/1 nested" do + test "nested structures work correctly" do + data = %{"users" => [%{"name" => "Alice", "age" => 30}, %{"name" => "Bob", "age" => 25}]} + + assert ~S|(users: ((age: 30, name: "Alice"), (age: 25, name: "Bob")))| == + Code.encode(data) + end + end +end diff --git a/test/encode_test.exs b/test/encode_test.exs deleted file mode 100644 index 3077d35..0000000 --- a/test/encode_test.exs +++ /dev/null @@ -1,157 +0,0 @@ -defmodule Typst.EncodeTest do - use ExUnit.Case, async: true - alias Typst.Encode - - describe "to_string/1 for List" do - test "empty list" do - assert "()" = Encode.to_string([]) - end - - test "list of integers" do - assert "(1, 2, 3)" = Encode.to_string([1, 2, 3]) - end - - test "list of strings" do - assert ~S|("a", "b", "c")| = Encode.to_string(["a", "b", "c"]) - end - - test "list of mixed subtypes" do - assert ~S|("a", "b", 3)| = Encode.to_string(["a", "b", 3]) - end - - test "keyword list" do - assert ~S|(a: "b", c: "d")| = Encode.to_string(a: "b", c: "d") - end - end - - describe "to_string/1 for Map" do - test "empty map" do - assert "(:)" = Encode.to_string(%{}) - end - - test "map of strings" do - assert ~S|(a: "b", c: "d")| = Encode.to_string(%{"a" => "b", "c" => "d"}) - end - - test "map of integers" do - assert ~S|(a: 1, b: 2)| = Encode.to_string(%{"a" => 1, "b" => 2}) - end - - test "map of mixed types" do - assert ~s|(a: "x", b: 2)| = Encode.to_string(%{"a" => "x", "b" => 2}) - end - - test "atom keys" do - assert ~S|(c: "d", a: "b")| = Encode.to_string(%{a: "b", c: "d"}) - end - end - - describe "to_string/1 for BitString" do - test "empty string" do - assert ~S|""| = Encode.to_string("") - end - - test "simple string is properly quoted and escaped" do - assert ~S|"hello world"| = Encode.to_string("hello world") - end - - test "strings with special characters are properly escaped" do - result = - Encode.to_string([ - "hello \"world\"", - "back\\slash", - "new\nline", - "tab\there", - "carriage\rreturn", - "both\t\r\n" - ]) - - assert ~S|("hello \"world\"", "back\\slash", "new\nline", "tab\there", "carriage\rreturn", "both\t\r\n")| = - result - end - - test "unicode characters" do - result = Encode.to_string(["Hello 🌍", "café", "naïve", "résumé", "тест", "🚀✨"]) - assert ~S|("Hello 🌍", "café", "naïve", "résumé", "тест", "🚀✨")| = result - end - - test "encode as bytes" do - assert ~S|bytes(0, 1, 2, 3)| = Encode.to_string(<<0, 1, 2, 3>>) - end - end - - describe "to_string/1 for Atom" do - test "atoms can be encoded" do - assert "test" = Encode.to_string(:test) - end - end - - describe "to_string/1 for Tuple" do - test "labeled tuples are encoded as typst labels" do - assert "" = Encode.to_string({:label, :test}) - end - end - - describe "to_string/1 for Date" do - test "dates can be encoded" do - assert "datetime(year: 2025, month: 9, day: 20)" = Encode.to_string(~D[2025-09-20]) - end - end - - describe "to_string/1 for Time" do - test "times can be encoded" do - assert "datetime(hour: 10, minute: 11, second: 12)" = Encode.to_string(~T[10:11:12]) - end - - test "does not support subsecond values" do - assert "datetime(hour: 10, minute: 11, second: 12)" = Encode.to_string(~T[10:11:12.013]) - end - end - - describe "to_string/1 for NaiveDateTime" do - test "naive datetimes can be encoded" do - assert "datetime(year: 2025, month: 9, day: 20, hour: 10, minute: 11, second: 12)" = - Encode.to_string(~N[2025-09-20 10:11:12]) - end - end - - describe "to_string/1 for DateTime" do - test "datetimes drop any timezone related information" do - assert "datetime(year: 2025, month: 9, day: 20, hour: 10, minute: 11, second: 12)" = - Encode.to_string(~U[2025-09-20 10:11:12Z]) - end - end - - describe "to_string/1 for Regex" do - test "regexes can be encoded" do - assert ~S|regex(`\d+\.\d+\.\d+`.text)| = Encode.to_string(~r/\d+\.\d+\.\d+/) - end - end - - describe "to_string/1 for Integer" do - test "integers can be encoded" do - assert "42" = Encode.to_string(42) - end - end - - describe "to_string/1 for Float" do - test "floats can be encoded" do - assert "0.1" = Encode.to_string(0.1) - end - end - - describe "to_string/1 for Decimal" do - test "decimals can be encoded" do - assert ~S|decimal("0.1")| = Encode.to_string(Decimal.new("0.1")) - end - end - - describe "to_string/1 nested" do - test "nested structures work correctly" do - data = %{"users" => [%{"name" => "Alice", "age" => 30}, %{"name" => "Bob", "age" => 25}]} - - assert ~S|(users: ((age: 30, name: "Alice"), (age: 25, name: "Bob")))| == - Encode.to_string(data) - end - end -end diff --git a/test/engine_test.exs b/test/engine_test.exs index 9487d78..829729a 100644 --- a/test/engine_test.exs +++ b/test/engine_test.exs @@ -1,76 +1,133 @@ defmodule Typst.EngineTest do use ExUnit.Case, async: true - describe "Typst.Engine" do - test "renders basic templates with interpolated values" do - template = """ - Hello <%= "world" %>! - """ - - assert ~s|Hello "world"!\n| = EEx.eval_string(template, [], engine: Typst.Engine) - end - - test "handles multiple interpolations in a single template" do - template = """ - Name: <%= "Alice" %> - Age: <%= 30 %> - Items: <%= ["apple", "banana"] %> - """ - - expected = """ - Name: "Alice" - Age: 30 - Items: ("apple", "banana") - """ - - assert expected == EEx.eval_string(template, [], engine: Typst.Engine) - end - - test "works with variable bindings" do - template = """ - User: <%= name %> - Score: <%= score %> - """ - - result = EEx.eval_string(template, [name: "Bob", score: 100], engine: Typst.Engine) - assert ~s|User: "Bob"\nScore: 100\n| = result - end - - test "handles complex nested data structures" do - data = %{ - "title" => "My Document", - "sections" => [ - %{"name" => "Introduction", "pages" => 5}, - %{"name" => "Content", "pages" => 20} - ] - } - - template = """ - Document: <%= data %> - """ - - result = EEx.eval_string(template, [data: data], engine: Typst.Engine) - assert String.starts_with?(result, "Document: (") - assert String.contains?(result, "title:") - assert String.contains?(result, "sections:") - end - - test "preserves whitespace and formatting outside interpolations" do - template = """ - = Title - - This is a paragraph with <%= "interpolated" %> content. - - - Item 1: <%= 42 %> - - Item 2: <%= "test" %> - """ - - result = EEx.eval_string(template, [], engine: Typst.Engine) - - assert String.contains?(result, "= Title") - assert String.contains?(result, "This is a paragraph with \"interpolated\" content.") - assert String.contains?(result, "- Item 1: 42") - assert String.contains?(result, "- Item 2: \"test\"") - end + test "renders basic templates with interpolated values" do + template = """ + Hello <%| "world" %>! + """ + + assert ~s|Hello world!\n| = EEx.eval_string(template, [], engine: Typst.Engine) + end + + test "code and markup context supported" do + template = """ + #text(font: <%= "Helvetica Neue" %>)[ + = Background + In the case of <%| "glaciers" %>, fluid + dynamics principles can be used + to understand how the movement + and behaviour of the ice is + influenced by factors such as + temperature, pressure, and the + presence of other fluids (such as + water). + ] + """ + + assert """ + #text(font: "Helvetica Neue")[ + = Background + In the case of glaciers, fluid + dynamics principles can be used + to understand how the movement + and behaviour of the ice is + influenced by factors such as + temperature, pressure, and the + presence of other fluids (such as + water). + ] + """ = EEx.eval_string(template, [], engine: Typst.Engine) + end + + test "works with assigns" do + template = """ + #text(font: <%= @font %>)[ + = Introduction + In this report, we will explore the + various factors that influence _<%| @topic %>_ + in glaciers and how they + contribute to the formation and + behaviour of these natural structures. + ] + """ + + assigns = [font: "Helvetica Neue", topic: "fluid dynamics"] + + assert """ + #text(font: "Helvetica Neue")[ + = Introduction + In this report, we will explore the + various factors that influence _fluid dynamics_ + in glaciers and how they + contribute to the formation and + behaviour of these natural structures. + ] + """ = EEx.eval_string(template, [assigns: assigns], engine: Typst.Engine) + end + + test "raises on missing assign" do + template = """ + #text(font: <%= @font %>)[ + = Introduction + In this report, we will explore the + various factors that influence _<%| @topic %>_ + in glaciers and how they + contribute to the formation and + behaviour of these natural structures. + ] + """ + + assigns = [topic: "fluid dynamics"] + + assert_raise ArgumentError, + """ + assign @font not available in template. + + Please make sure all proper assigns have been set. If this + is a child template, ensure assigns are given explicitly by + the parent template as they are not automatically forwarded. + + Available assigns: [:topic] + """, + fn -> + EEx.eval_string(template, [assigns: assigns], engine: Typst.Engine) + end + end + + test "handles complex nested data structures" do + data = %{ + "title" => "My Document", + "sections" => [ + %{"name" => "Introduction", "pages" => 5}, + %{"name" => "Content", "pages" => 20} + ] + } + + template = """ + Document: <%= @data %> + """ + + result = EEx.eval_string(template, [assigns: [data: data]], engine: Typst.Engine) + assert String.starts_with?(result, "Document: (") + assert String.contains?(result, "title:") + assert String.contains?(result, "sections:") + end + + test "preserves whitespace and formatting outside interpolations" do + template = """ + = Title + + This is a paragraph with <%= "interpolated" %> content. + + - Item 1: <%= 42 %> + - Item 2: <%= "test" %> + """ + + result = EEx.eval_string(template, [], engine: Typst.Engine) + + assert String.contains?(result, "= Title") + assert String.contains?(result, "This is a paragraph with \"interpolated\" content.") + assert String.contains?(result, "- Item 1: 42") + assert String.contains?(result, "- Item 2: \"test\"") end end diff --git a/test/format/table_test.exs b/test/format/table_test.exs index 7813722..bbfacef 100644 --- a/test/format/table_test.exs +++ b/test/format/table_test.exs @@ -1,29 +1,31 @@ defmodule Typst.Format.TableTest do use ExUnit.Case, async: true + import Typst, only: :sigils + import Typst.Format + alias Typst.Format.Table + alias Typst.Format.Table.{Hline, Header} doctest Typst.Format.Table test "table" do - import Typst.Format - alias Typst.Format.Table - alias Typst.Format.Table.{Hline, Header} - - table = - %Table{ + assigns = %{ + table: %Table{ columns: 2, content: [ %Header{content: ["col1", "col2"], repeat: false}, - [bold("hello"), "world"], + ["*hello*", "world"], %Hline{start: 1}, - [bold("foo"), "bar"] + ["*foo*", "bar"] ] } + } - expected = - "#table(columns: 2, table.header(repeat: false, [col1], [col2]), [*hello*], [world], table.hline(start: 1), [*foo*], [bar])" + template = ~TYPST""" + <%| table %> + """ - assert expected == Typst.render_to_string("<%= table %>", table: table) - {:ok, _pdf} = Typst.render_to_pdf("<%= table %>", table: table) + assert "#table(columns: 2, table.header(repeat: false, [col1], [col2]), [*hello*], [world], table.hline(start: 1), [*foo*], [bar])" == + template end test "cell" do diff --git a/test/format_test.exs b/test/format_test.exs deleted file mode 100644 index 2efff94..0000000 --- a/test/format_test.exs +++ /dev/null @@ -1,19 +0,0 @@ -defmodule Typst.FormatTest do - use ExUnit.Case, async: true - - import Typst.Format - doctest Typst.Format, except: [format_column_element: 1] - - test "bold" do - assert "*hello*" == Typst.render_to_string("<%= hello %>", hello: bold("hello")) - end - - test "content" do - assert "[hello]" == Typst.render_to_string("<%= hello %>", hello: content("hello")) - end - - test "array" do - assert "(hello, world)" == - Typst.render_to_string("<%= hello %>", hello: array(["hello", "world"])) - end -end diff --git a/test/typst_test.exs b/test/typst_test.exs index 055814a..7107fae 100644 --- a/test/typst_test.exs +++ b/test/typst_test.exs @@ -1,12 +1,28 @@ defmodule TypstTest do use ExUnit.Case, async: true + import Typst, only: :sigils doctest Typst test "smoke test" do - assert "= Hello world" == Typst.render_to_string("= Hello <%= name %>", name: "world") + assigns = %{ + font: "Roboto", + name: "world" + } - {:ok, pdf} = Typst.render_to_pdf("= Hello <%= name %>", name: "world") + template = ~TYPST""" + #text(font: <%= @font %>)[ + = Hello <%| @name %> + ] + """ + + assert """ + #text(font: "Roboto")[ + = Hello world + ] + """ == template + + {:ok, pdf} = Typst.render_to_pdf(template) assert is_binary(pdf) end end