feat: adds pdf export with imprintor
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
496e2e438f
commit
f6b35f03a5
16 changed files with 1962 additions and 70 deletions
270
lib/mv/membership/members_pdf.ex
Normal file
270
lib/mv/membership/members_pdf.ex
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
defmodule Mv.Membership.MembersPDF do
|
||||
@moduledoc """
|
||||
Exports members to PDF using Typst templates and Imprintor.
|
||||
|
||||
Uses the same data structure as `MemberExport.Build` and converts it
|
||||
to the format expected by the Typst template. No translations/Gettext
|
||||
in this module - labels come from the web layer.
|
||||
|
||||
Ensures deterministic output by maintaining column and row order.
|
||||
|
||||
Creates a temporary directory per request and copies the template there
|
||||
to avoid symlink issues and ensure isolation.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.Config
|
||||
|
||||
@template_filename "members_export.typ"
|
||||
@template_path "priv/pdf_templates/members_export.typ"
|
||||
|
||||
@doc """
|
||||
Renders export data to PDF binary.
|
||||
|
||||
- `export_data` - Map from `MemberExport.Build.build/3` with `columns`, `rows`, and `meta`
|
||||
|
||||
Returns `{:ok, binary}` where binary is the PDF content, or `{:error, term}`.
|
||||
|
||||
The PDF binary starts with "%PDF" (PDF magic bytes).
|
||||
|
||||
Validates row count against configured limit before processing.
|
||||
"""
|
||||
@spec render(map()) :: {:ok, binary()} | {:error, term()}
|
||||
def render(export_data) do
|
||||
row_count = length(export_data.rows)
|
||||
max_rows = Config.pdf_export_row_limit()
|
||||
|
||||
if row_count > max_rows do
|
||||
Logger.warning("PDF export rejected: row count exceeds limit",
|
||||
row_count: row_count,
|
||||
max_rows: max_rows,
|
||||
error_type: :row_limit_exceeded
|
||||
)
|
||||
|
||||
{:error, {:row_limit_exceeded, row_count, max_rows}}
|
||||
else
|
||||
Logger.info("Starting PDF export",
|
||||
row_count: row_count,
|
||||
column_count: length(export_data.columns)
|
||||
)
|
||||
|
||||
create_and_use_temp_directory(export_data)
|
||||
end
|
||||
end
|
||||
|
||||
defp create_and_use_temp_directory(export_data) do
|
||||
case create_temp_directory() do
|
||||
{:ok, temp_dir} ->
|
||||
try do
|
||||
with {:ok, template_content} <- load_template(),
|
||||
{:ok, _template_path} <- copy_template_to_temp(temp_dir, template_content),
|
||||
{:ok, template_data} <- convert_to_template_format(export_data),
|
||||
{:ok, config} <-
|
||||
build_imprintor_config(template_content, template_data, temp_dir),
|
||||
{:ok, pdf_binary} <- compile_to_pdf(config) do
|
||||
Logger.info("PDF export completed successfully",
|
||||
row_count: length(export_data.rows)
|
||||
)
|
||||
|
||||
{:ok, pdf_binary}
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.error("PDF export failed",
|
||||
error: reason,
|
||||
error_type: :pdf_export_failed
|
||||
)
|
||||
|
||||
error
|
||||
end
|
||||
after
|
||||
cleanup_temp_directory(temp_dir)
|
||||
end
|
||||
|
||||
{:error, reason} = error ->
|
||||
Logger.error("Failed to create temp directory",
|
||||
error: reason,
|
||||
error_type: :temp_dir_creation_failed
|
||||
)
|
||||
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp create_temp_directory do
|
||||
# Create unique temp directory per request
|
||||
temp_base = System.tmp_dir!()
|
||||
temp_dir = Path.join(temp_base, "mv_pdf_export_#{System.unique_integer([:positive])}")
|
||||
|
||||
case File.mkdir_p(temp_dir) do
|
||||
:ok -> {:ok, temp_dir}
|
||||
{:error, reason} -> {:error, {:temp_dir_creation_failed, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp load_template do
|
||||
# Try multiple paths: compiled app path and source path (for tests/dev)
|
||||
template_paths = [
|
||||
Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/#{@template_filename}"),
|
||||
Path.join([File.cwd!(), "priv", "pdf_templates", @template_filename])
|
||||
]
|
||||
|
||||
Enum.reduce_while(template_paths, nil, fn path, _acc ->
|
||||
case File.read(path) do
|
||||
{:ok, content} -> {:halt, {:ok, content}}
|
||||
{:error, _reason} -> {:cont, nil}
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
{:ok, content} -> {:ok, content}
|
||||
nil -> {:error, {:template_not_found, :enoent}}
|
||||
end
|
||||
end
|
||||
|
||||
defp copy_template_to_temp(temp_dir, template_content) do
|
||||
# Write template to temp directory (no symlinks, actual file copy)
|
||||
template_path = Path.join(temp_dir, @template_filename)
|
||||
|
||||
case File.write(template_path, template_content) do
|
||||
:ok -> {:ok, template_path}
|
||||
{:error, reason} -> {:error, {:template_copy_failed, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp cleanup_temp_directory(temp_dir) do
|
||||
# Clean up temp directory and all contents
|
||||
case File.rm_rf(temp_dir) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
{:error, reason, _} ->
|
||||
Logger.warning("Failed to cleanup temp directory",
|
||||
temp_dir: temp_dir,
|
||||
error: reason
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp convert_to_template_format(export_data) do
|
||||
headers = Enum.map(export_data.columns, & &1.label)
|
||||
column_count = length(export_data.columns)
|
||||
|
||||
meta = Map.get(export_data, :meta) || Map.get(export_data, "meta") || %{}
|
||||
|
||||
generated_at =
|
||||
Map.get(meta, :generated_at) ||
|
||||
Map.get(meta, "generated_at") ||
|
||||
DateTime.utc_now() |> DateTime.to_iso8601()
|
||||
|
||||
member_count =
|
||||
Map.get(meta, :member_count) ||
|
||||
Map.get(meta, "member_count") ||
|
||||
length(export_data.rows)
|
||||
|
||||
template_data = %{
|
||||
"title" => "Mitglieder-Export",
|
||||
"generated_at" => generated_at,
|
||||
"column_count" => column_count,
|
||||
"headers" => headers,
|
||||
"rows" => export_data.rows,
|
||||
"columns" =>
|
||||
Enum.map(export_data.columns, fn col ->
|
||||
%{
|
||||
"key" => to_string(col.key),
|
||||
"kind" => to_string(col.kind),
|
||||
"label" => col.label
|
||||
}
|
||||
end),
|
||||
"meta" => %{
|
||||
"generated_at" => generated_at,
|
||||
"member_count" => member_count
|
||||
}
|
||||
}
|
||||
|
||||
{:ok, template_data}
|
||||
end
|
||||
|
||||
defp build_imprintor_config(template_content, template_data, root_directory) do
|
||||
# Imprintor.Config.new(source_document, inputs, options)
|
||||
# inputs: %{"elixir_data" => template_data} for sys.inputs.elixir_data in template
|
||||
# options: set root_directory to temp dir to ensure no symlink issues
|
||||
# extra_fonts: list of font file paths for Typst to use
|
||||
extra_fonts = get_extra_fonts()
|
||||
options = [root_directory: root_directory, extra_fonts: extra_fonts]
|
||||
|
||||
config = Imprintor.Config.new(template_content, template_data, options)
|
||||
{:ok, config}
|
||||
end
|
||||
|
||||
defp get_extra_fonts do
|
||||
# Try multiple paths: compiled app path and source path (for tests/dev)
|
||||
font_paths = [
|
||||
Path.join(Application.app_dir(:mv, "priv"), "fonts"),
|
||||
Path.join([File.cwd!(), "priv", "fonts"])
|
||||
]
|
||||
|
||||
Enum.reduce_while(font_paths, [], fn base_path, _acc ->
|
||||
case File.ls(base_path) do
|
||||
{:ok, files} ->
|
||||
fonts =
|
||||
files
|
||||
|> Enum.filter(&String.ends_with?(&1, ".ttf"))
|
||||
|> Enum.map(&Path.join(base_path, &1))
|
||||
|> Enum.sort()
|
||||
|
||||
if fonts != [] do
|
||||
{:halt, fonts}
|
||||
else
|
||||
{:cont, []}
|
||||
end
|
||||
|
||||
{:error, _reason} ->
|
||||
{:cont, []}
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
[] -> []
|
||||
fonts -> fonts
|
||||
end
|
||||
end
|
||||
|
||||
defp compile_to_pdf(config) do
|
||||
case Imprintor.compile_to_pdf(config) do
|
||||
{:ok, pdf_binary} when is_binary(pdf_binary) ->
|
||||
# Verify PDF magic bytes
|
||||
if String.starts_with?(pdf_binary, "%PDF") do
|
||||
{:ok, pdf_binary}
|
||||
else
|
||||
Logger.error("PDF compilation returned invalid format",
|
||||
binary_start: String.slice(pdf_binary, 0, 20)
|
||||
)
|
||||
|
||||
{:error, :invalid_pdf_format}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("PDF compilation failed",
|
||||
error: inspect(reason),
|
||||
error_type: :imprintor_compile_error
|
||||
)
|
||||
|
||||
{:error, {:compile_error, reason}}
|
||||
|
||||
other ->
|
||||
Logger.error("PDF compilation returned unexpected result",
|
||||
result: inspect(other),
|
||||
error_type: :unexpected_result
|
||||
)
|
||||
|
||||
{:error, {:unexpected_result, other}}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("PDF compilation raised exception",
|
||||
exception: inspect(e),
|
||||
error_type: :compile_exception
|
||||
)
|
||||
|
||||
{:error, {:compile_exception, e}}
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue