I’m currently working on a project that involves uploading .docx files and generating fillable PDFs. Initially, I tried converting the .docx files into HTML or .heex files to create PDFs, but that approach proved cumbersome. Instead, I decided to use the .docx files directly as templates and then convert them into PDFs.
For the templating part using .docx files, here’s my solution: I unzip the Word file, replace the placeholders in the word/document.xml file, and then generate a new .docx file. It’s a straightforward method, and it works well enough. One key detail to note is that placeholders must be written as placeholder or PLACEHOLDER. If they’re formatted differently, they might get split across multiple OOXML runs, making them impossible to replace consistently.
defmodule WordPlaceholderUpdater do
@moduledoc """
A module to update placeholders in Word (.docx) files.
Placeholders should be in the format PLACEHOLDERNAME
outerwise the placeholder text can be in diffrent runs.
"""
@doc """
Updates placeholders in a Word document with provided values.
## Parameters
- input_path: Path to the input .docx file
- output_path: Path where the modified .docx will be saved
- replacements: Map of placeholder names to their replacement values
"""
def update_placeholders(input_path, output_path, replacements) do
temp_dir = "/tmp/word_update_#{:erlang.unique_integer()}"
case File.mkdir(temp_dir) do
{:error, reason} ->
{:error, "Failed to create temp directory: #{reason}"}
:ok ->
result =
with :ok <- extract_docx(input_path, temp_dir),
:ok <- update_document_xml(temp_dir, replacements),
:ok <- create_new_docx(temp_dir, output_path) do
:ok
end
# Cleanup happens regardless of success or failure
cleanup(temp_dir)
case result do
:ok -> :ok
{:error, reason} -> {:error, reason}
end
end
end
defp extract_docx(input_path, temp_dir) do
case :zip.extract(String.to_charlist(input_path), [{:cwd, String.to_charlist(temp_dir)}]) do
{:ok, _} -> :ok
{:error, reason} -> {:error, "Failed to extract docx: #{reason}"}
end
end
defp update_document_xml(temp_dir, replacements) do
doc_path = Path.join(temp_dir, "word/document.xml")
case File.read(doc_path) do
{:ok, content} ->
updated_content = replace_placeholders(content, replacements)
File.write("test_out.xml", updated_content)
File.write(doc_path, updated_content)
{:error, reason} ->
{:error, "Failed to read document.xml: #{reason}"}
end
end
defp replace_placeholders(content, replacements) do
Enum.reduce(replacements, content, fn {key, value}, acc ->
Regex.replace(~r/#{key}/, acc, value)
end)
end
defp create_new_docx(temp_dir, output_path) do
file_list =
temp_dir
|> File.ls!()
|> Enum.map(&String.to_charlist/1)
case :zip.create(String.to_charlist(output_path), file_list, [
{:cwd, String.to_charlist(temp_dir)}
]) do
{:ok, _} -> :ok
{:error, reason} -> {:error, "Failed to create new docx: #{reason}"}
end
end
defp cleanup(temp_dir) do
File.rm_rf(temp_dir)
end
end