defmodule Ootempl do @moduledoc """ Office Open XML document templating library for Elixir. Ootempl enables programmatic manipulation of Microsoft Word documents (.docx) by replacing placeholders with dynamic content to generate customized documents from templates. ## Features - Load and parse .docx templates - Replace `{{variable}}` placeholders with dynamic content - Support nested data access with dot notation (`{{customer.name}}`) - Conditional sections with `{{if condition}}...{{endif}}` syntax - Dynamic table row generation from list data - Multi-row table templates for complex layouts - Replace placeholder images with dynamic content (PNG, JPEG, GIF) - Automatic image dimension scaling with aspect ratio preservation - Case-insensitive placeholder matching - Process headers, footers, footnotes, and endnotes - Replace placeholders in document properties (title, author, company) - Preserve Word formatting (bold, italic, fonts, table borders, shading) - Generate valid .docx output files - Comprehensive validation and error handling ## Basic Usage ### Simple Variable Replacement ```elixir # Render a template with placeholder replacement data = %{ "name" => "John Doe", "customer" => %{"email" => "john@example.com"}, "total" => 99.99 } Ootempl.render("template.docx", data, "output.docx") #=> :ok ``` ### Struct Support Elixir structs work seamlessly with `render/3`. You can pass structs directly without converting them to maps first. Struct fields (atoms) are matched case-insensitively to placeholders. ```elixir defmodule Customer do defstruct [:name, :email, :address] end defmodule Address do defstruct [:street, :city, :state, :zip] end # Use structs directly in your data customer = %Customer{ name: "John Doe", email: "john@example.com", address: %Address{ city: "Boston", state: "MA", zip: "02101" } } data = %{ "order_id" => "ORD-12345", "customer" => customer } # Template can reference struct fields: # - {{customer.name}} → "John Doe" # - {{customer.email}} → "john@example.com" # - {{customer.address.city}} → "Boston" Ootempl.render("invoice_template.docx", data, "invoice.docx") #=> :ok ``` Struct features: - **Nested structs**: Access fields multiple levels deep - **Case-insensitive**: `{{customer.Name}}` matches `:name` field - **Mixed data**: Combine structs and maps in the same data structure - **Lists of structs**: Use structs in table templates ### Table Templates Table templates automatically duplicate rows based on list data. Template rows are identified by placeholders that reference list items. ```elixir # Simple table template data = %{ "title" => "Claims Report", "claims" => [ %{"id" => 5565, "amount" => 100.50}, %{"id" => 5566, "amount" => 250.00} ], "total" => 350.50 } Ootempl.render("invoice_template.docx", data, "invoice.docx") #=> :ok ``` Template structure in Word document: ``` | Claim ID | Amount | ← Header (no list placeholders) | {{claims.id}} | {{claims.amount}} | ← Template row (references "claims" list) | Total | {{total}} | ← Footer (single value) ``` Generated output: ``` | Claim ID | Amount | | 5565 | 100.50 | | 5566 | 250.00 | | Total | 350.50 | ``` Multi-row templates are supported for complex table layouts: ```elixir data = %{ "orders" => [ %{"id" => 100, "product" => "Widget", "qty" => 5, "price" => 10.00}, %{"id" => 101, "product" => "Gadget", "qty" => 3, "price" => 25.00} ] } ``` Template with two rows per order: ``` | Order {{orders.id}} | ← Row 1 of template | {{orders.qty}}x {{orders.product}} @ ${{orders.price}} each | ← Row 2 of template ``` Generated output duplicates both rows for each order: ``` | Order 100 | | 5x Widget @ $10.00 each | | Order 101 | | 3x Gadget @ $25.00 each | ``` ### Hierarchical Table Templates For nested data structures with parent-child relationships, use block markers (`{{#list}}...{{/list}}`) in dedicated rows to create multi-level layouts: ```elixir data = %{ "categories" => [ %{ "name" => "Electronics", "total" => "$300", "subtotal" => "$300", "items" => [ %{"desc" => "Phone", "price" => "$200"}, %{"desc" => "Charger", "price" => "$100"} ] } ] } ``` Template structure (marker rows are removed from output): ``` | {{#categories}} | | | ← Marker row (removed) | {{name}} | {{total}} | | ← Header row | {{#items}} | | | ← Marker row (removed) | | {{desc}} | {{price}}| ← Body row (repeated per child) | {{/items}} | | | ← Marker row (removed) | | Subtotal: | {{subtotal}} | ← Footer row | {{/categories}} | | | ← Marker row (removed) ``` Generated output: ``` | Electronics | | | | | Phone | $200 | | | Charger| $100 | | | Subtotal: | $300 | ``` Block marker features: - **Dedicated rows**: Block markers must be in their own rows (removed from output) - **Nested blocks**: Support for parent/child iteration with separate data scoping - **Data inheritance**: Child rows access both parent and child data fields - **Empty handling**: Empty parent list produces no rows; empty children skips body rows ### Conditional Sections Control which sections of your document appear based on data conditions using `{{if condition}}...{{endif}}` markers. Sections are shown when the condition is truthy and hidden when falsy. ```elixir # Template structure: # Standard content here. # # {{if show_disclaimer}} # DISCLAIMER: This is a legal disclaimer that appears # only when show_disclaimer is true. # {{endif}} # # {{if include_pricing}} # Pricing: $100/month # {{endif}} data = %{ "show_disclaimer" => true, "include_pricing" => false } Ootempl.render("contract_template.docx", data, "contract.docx") #=> :ok # Generated document includes disclaimer section, excludes pricing section ``` **If/Else Support:** Use `{{else}}` markers to show alternative content when a condition is false: ```elixir # Template structure: # Dear Customer, # # {{if is_premium}} # Thank you for being a premium member! You get 20% off. # {{else}} # Become a premium member today for 20% off all purchases. # {{endif}} # # Thank you! data_premium = %{"is_premium" => true} Ootempl.render("letter.docx", data_premium, "premium_letter.docx") # Output: "Thank you for being a premium member! You get 20% off." data_standard = %{"is_premium" => false} Ootempl.render("letter.docx", data_standard, "standard_letter.docx") # Output: "Become a premium member today for 20% off all purchases." ``` **Truthiness rules:** - **Truthy**: non-nil, non-false, non-empty string, non-zero number - **Falsy**: `nil`, `false`, `""` (empty string), `0`, `0.0` **Conditional features:** - Case-insensitive markers: `{{IF name}}`, `{{if NAME}}`, `{{ELSE}}` all work - Nested data paths: `{{if customer.active}}` - Optional `{{else}}` for alternative content - Multi-paragraph sections supported - Sections can contain tables, images, lists, etc. ### Document Properties Placeholders in document metadata (title, author, company) are automatically replaced. Use this feature to populate document properties from your data. ```elixir # Template has placeholders in File > Properties: # - Title: {{document_title}} # - Author: {{author}} # - Company: {{company_name}} data = %{ "document_title" => "Q4 Financial Report", "author" => "Jane Smith", "company_name" => "Acme Corporation" } Ootempl.render("report_template.docx", data, "Q4_report.docx") #=> :ok # Generated document has Title, Author, and Company fields populated ``` Supported property fields: - **Core properties**: `dc:title`, `dc:subject`, `dc:description`, `dc:creator` - **App properties**: `Company`, `Manager` ### Headers, Footers, Footnotes, and Endnotes Placeholders in headers, footers, footnotes, and endnotes are processed just like the main document body: ```elixir # Template has: # - Header with: {{company_name}} - {{document_title}} # - Footer with: Page {{page}} of {{total_pages}} # - Footnote with: {{footnote_citation}} data = %{ "company_name" => "Acme Corp", "document_title" => "Annual Report", "footnote_citation" => "Source: Annual Review 2025" } Ootempl.render("template.docx", data, "output.docx") ``` ### Image Replacement Replace placeholder images in templates with dynamic images from your data. Use the alt text field in Word to mark placeholder images with `{{image:name}}` markers. **Preparing templates in Word:** 1. Insert a placeholder image (any PNG, JPEG, or GIF) 2. Right-click the image → "View Alt Text" (or "Edit Alt Text") 3. Set the alt text to `{{image:placeholder_name}}` (e.g., `{{image:company_logo}}`) 4. Save the template **Data structure:** Provide image file paths in your data map using the placeholder name as the key: ```elixir data = %{ "company_logo" => "/path/to/logo.png", "employee_photo" => "/path/to/photo.jpg", "signature" => "/path/to/signature.gif" } Ootempl.render("template.docx", data, "output.docx") #=> :ok ``` **Image format support:** - **PNG** - Portable Network Graphics (`.png`) - **JPEG** - Joint Photographic Experts Group (`.jpg`, `.jpeg`) - **GIF** - Graphics Interchange Format (`.gif`) **Automatic dimension scaling:** Images are automatically scaled to fit the placeholder dimensions while preserving aspect ratio. The library calculates the minimum scale factor needed to fit the image within the template bounds: ```elixir # Template has 200x100 EMU placeholder # Image is 800x600 pixels → scaled by 0.25x to fit # Image is 150x75 pixels → scaled by 1.33x to fill space data = %{"logo" => "large_image.png"} Ootempl.render("template.docx", data, "output.docx") # Image automatically scaled to fit placeholder bounds ``` **Multiple images:** Templates can contain multiple placeholder images, each with a unique marker: ```elixir # Template contains three images: # - Header logo with alt text: {{image:company_logo}} # - Employee photo with alt text: {{image:employee_photo}} # - Footer signature with alt text: {{image:signature}} data = %{ "company_logo" => "assets/logo.png", "employee_photo" => "photos/john_doe.jpg", "signature" => "signatures/ceo.gif" } Ootempl.render("contract_template.docx", data, "contract.docx") #=> :ok # All three images replaced with dynamic content ``` **Error handling:** Image replacement returns errors for missing data or invalid files: ```elixir # Missing image key in data data = %{"name" => "John"} Ootempl.render("template.docx", data, "output.docx") #=> {:error, %Ootempl.ImageError{ # message: "Image placeholder '{{image:logo}}' has no corresponding data key 'logo'", # placeholder_name: "logo", # image_path: nil, # reason: :image_not_found_in_data # }} # Image file doesn't exist data = %{"logo" => "nonexistent.png"} Ootempl.render("template.docx", data, "output.docx") #=> {:error, %Ootempl.ImageError{ # message: "Image file not found for placeholder 'logo': nonexistent.png", # placeholder_name: "logo", # image_path: "nonexistent.png", # reason: :file_not_found # }} # Unsupported format data = %{"logo" => "document.pdf"} Ootempl.render("template.docx", data, "output.docx") #=> {:error, %Ootempl.ImageError{ # message: "Unsupported image format for placeholder 'logo': document.pdf (format: .pdf, only PNG, JPEG, GIF supported)", # placeholder_name: "logo", # image_path: "document.pdf", # reason: :unsupported_format # }} ``` ## Architecture The library is organized into several modules: - `Ootempl.Archive` - ZIP archive operations for .docx files - `Ootempl.Xml` - XML parsing and serialization using :xmerl - `Ootempl.Xml.Normalizer` - XML normalization for fragmented placeholders - `Ootempl.Placeholder` - Placeholder detection and parsing - `Ootempl.DataAccess` - Nested data access with case-insensitive matching - `Ootempl.Conditional` - Conditional marker detection, evaluation, and section processing - `Ootempl.Replacement` - Placeholder replacement in XML with formatting preservation - `Ootempl.Table` - Table structure detection, template row identification, and duplication - `Ootempl.Validator` - Document validation and error handling ## Error Handling The main `render/3` function returns: - `:ok` on success (document generated successfully) - `{:error, %PlaceholderError{}}` when placeholders cannot be resolved - `{:error, exception}` on structural failures Specific error types: - `Ootempl.PlaceholderError` - One or more placeholders cannot be resolved - `Ootempl.ValidationError` - File validation failures - `Ootempl.InvalidArchiveError` - Invalid ZIP structure - `Ootempl.MissingFileError` - Required files missing - `Ootempl.MalformedXMLError` - XML parsing failures """ alias Ootempl.Archive alias Ootempl.Block alias Ootempl.Conditional alias Ootempl.Image alias Ootempl.Placeholder alias Ootempl.Relationships alias Ootempl.Replacement alias Ootempl.Table alias Ootempl.Template alias Ootempl.TemplateInfo alias Ootempl.Validator alias Ootempl.Xml alias Ootempl.Xml.Normalizer @doc """ Loads and pre-processes a .docx template for batch rendering. This function reads a .docx template file once, parses all XML structures, normalizes them, and returns a `%Template{}` struct that can be reused for multiple render operations. This provides significant performance benefits when generating multiple documents from the same template. ## Performance Loading a template eliminates ~40% of rendering time for batch operations: - File I/O: ~20% savings - XML parsing: ~18% savings - Normalization: ~0.2% savings For example, generating 100 invoices: - Without pre-loading: ~10ms × 100 = 1000ms - With pre-loading: ~60ms (load) + ~6ms × 100 = 660ms (34% faster) ## Parameters - `template_path` - Path to the .docx template file ## Returns - `{:ok, %Template{}}` on success - `{:error, reason}` on failure (invalid file, corrupt ZIP, etc.) ## Examples # Load template once {:ok, template} = Ootempl.load("invoice_template.docx") # Render multiple documents (reusing parsed template) customers |> Enum.each(fn customer -> data = %{"name" => customer.name, "total" => customer.balance} Ootempl.render(template, data, "invoice_\#{customer.id}.docx") end) ## Error Cases Same validation errors as `render/3`: - Template file does not exist - Template is not a valid .docx file - Template has invalid XML structure """ @spec load(Path.t()) :: {:ok, Template.t()} | {:error, term()} def load(template_path) do with :ok <- Validator.validate_docx(template_path), {:ok, temp_dir} <- Archive.extract(template_path), {:ok, template} <- load_and_parse_template(temp_dir, template_path), :ok <- Archive.cleanup(temp_dir) do {:ok, template} else {:error, _reason} = error -> error end end @doc """ Validates that a template can be successfully rendered with provided data. This function runs the complete rendering pipeline (conditionals, tables, placeholders, images) without creating output files. It returns `:ok` if rendering would succeed, or the same errors that `render/3` would return. This is useful for: - **Pre-flight validation** - Check if data satisfies all placeholders before batch operations - **Testing** - Verify template/data combinations without filesystem I/O - **API endpoints** - Validate user input before expensive operations - **Debugging** - Quickly test if data structure matches template requirements ## Parameters - `template` - Either: - A file path (String) to a .docx template - A `%Template{}` struct from `Ootempl.load/1` (faster) - `data` - Map or struct of data for placeholder replacement ## Returns - `:ok` if rendering would succeed - `{:error, %PlaceholderError{}}` when placeholders cannot be resolved - `{:error, %ImageError{}}` when image processing fails - `{:error, exception}` on structural failures ## Examples # Validate before rendering data = %{"name" => "John", "total" => 99.99} case Ootempl.validate("invoice.docx", data) do :ok -> # Safe to render Ootempl.render("invoice.docx", data, "output.docx") {:error, %Ootempl.PlaceholderError{} = error} -> IO.puts("Missing placeholders: \#{inspect(error.placeholders)}") {:error, reason} -> IO.puts("Validation failed: \#{inspect(reason)}") end # Batch validation templates = ["contract.docx", "invoice.docx", "receipt.docx"] Enum.filter(templates, fn template -> match?(:ok, Ootempl.validate(template, data)) end) # With pre-loaded template (faster for batch validation) {:ok, template} = Ootempl.load("invoice.docx") customers |> Enum.filter(fn customer -> data = %{"name" => customer.name, "total" => customer.balance} match?(:ok, Ootempl.validate(template, data)) end) |> Enum.each(fn customer -> data = %{"name" => customer.name, "total" => customer.balance} Ootempl.render(template, data, "invoice_\#{customer.id}.docx") end) """ @spec validate(Path.t() | Template.t(), map() | struct()) :: :ok | {:error, term()} def validate(template, data) # Pattern 1: Validate with pre-loaded Template struct def validate(%Template{} = template, data) do validate_from_template(template, data) end # Pattern 2: Validate from file path def validate(template_path, data) when is_binary(template_path) do with :ok <- Validator.validate_docx(template_path), {:ok, temp_dir} <- Archive.extract(template_path) do # Process template without creating output file result = validate_template_in_temp_dir(temp_dir, data) cleanup_result = Archive.cleanup(temp_dir) # Return original result or cleanup error case {result, cleanup_result} do {:ok, :ok} -> :ok {:ok, {:error, _} = cleanup_error} -> cleanup_error {error, _} -> error end else {:error, _reason} = error -> error end end @doc """ Inspects a template to discover placeholders, conditionals, and validate syntax. This function analyzes a template without performing any data replacement. It returns detailed information about: - All variable placeholders found in the template - All conditional markers (`{{if}}`, `{{else}}`, `{{endif}}`) - Required top-level data keys - Syntax validation errors (unclosed conditionals, malformed placeholders, etc.) This is useful for: - **Template discovery** - Finding what data fields a template requires - **Validation** - Checking template syntax before rendering - **Documentation** - Generating documentation about template requirements - **Dynamic UIs** - Building forms based on template placeholders - **Debugging** - Understanding template structure and errors ## Parameters - `template` - Either: - A file path (String) to a .docx template - loads and inspects in one call - A `%Template{}` struct from `Ootempl.load/1` - faster for batch operations ## Returns - `{:ok, %TemplateInfo{}}` with inspection results - `{:error, term()}` for invalid files or structural errors ## Examples ### Single Template (Convenience) # Inspect a contract template {:ok, info} = Ootempl.inspect("contract_template.docx") # Check if template is valid if info.valid? do IO.puts("✓ Template is valid") else IO.puts("✗ Found \#{length(info.errors)} errors") Enum.each(info.errors, fn err -> IO.puts(" - [\#{err.type}] \#{err.message}") end) end # Discover required data keys IO.puts("Required data keys: \#{Enum.join(info.required_keys, ", ")}") #=> Required data keys: customer, total, items ### Batch Inspection (Optimized) # Load template once {:ok, template} = Ootempl.load("invoice_template.docx") # Inspect the same template multiple times (fast - no I/O) templates = ["v1.docx", "v2.docx", "v3.docx"] Enum.each(templates, fn path -> {:ok, tmpl} = Ootempl.load(path) {:ok, info} = Ootempl.inspect(tmpl) IO.puts("\#{path}: \#{length(info.placeholders)} placeholders") end) ### Placeholder Details # List all placeholders Enum.each(info.placeholders, fn ph -> IO.puts("Placeholder: \#{ph.original}") IO.puts(" Path: \#{Enum.join(ph.path, ".")}") IO.puts(" Found in: \#{inspect(ph.locations)}") end) #=> Placeholder: {{customer.name}} # Path: customer.name # Found in: [:document_body, :header1] # List conditionals Enum.each(info.conditionals, fn cond -> IO.puts("Conditional: {{if \#{cond.condition}}}") IO.puts(" Path: \#{Enum.join(cond.path, ".")}") end) #=> Conditional: {{if show_disclaimer}} # Path: show_disclaimer ## Detectable Errors - **Unclosed conditionals** - `{{if condition}}` without matching `{{endif}}` - **Orphan markers** - `{{endif}}` or `{{else}}` without matching `{{if}}` - **Nested conditionals** - Conditional blocks inside other blocks (not supported) - **Malformed placeholders** - Invalid placeholder syntax - **Invalid conditional syntax** - Empty conditions, missing closing `}}` ## Template Coverage Scans all parts of the document: - Document body - Headers (header1.xml, header2.xml, header3.xml) - Footers (footer1.xml, footer2.xml, footer3.xml) - Footnotes - Endnotes - Document properties (core.xml, app.xml) ## Limitations - Cannot determine if placeholders are in table template rows (requires data shape) - Does not validate whether data would satisfy placeholders (use `validate/2` for that) - Deduplicates placeholders across locations (same placeholder reported once) """ @spec inspect(Path.t() | Template.t()) :: {:ok, TemplateInfo.t()} | {:error, term()} def inspect(template) # Pattern 1: Inspect from pre-loaded Template struct (optimized) def inspect(%Template{} = template) do inspect_from_template(template) end # Pattern 2: Inspect from file path (convenience API) def inspect(template_path) when is_binary(template_path) do with :ok <- Validator.validate_docx(template_path), {:ok, temp_dir} <- Archive.extract(template_path) do # Inspect template and always cleanup temp directory result = inspect_template_in_temp_dir(temp_dir) cleanup_result = Archive.cleanup(temp_dir) # Return original result or cleanup error case {result, cleanup_result} do {{:ok, info}, :ok} -> {:ok, info} {{:ok, _info}, {:error, _} = cleanup_error} -> cleanup_error {error, _} -> error end else {:error, _reason} = error -> error end end @doc """ Renders a .docx template with data to generate an output document. This function accepts either a template file path (String) or a pre-loaded `%Template{}` struct. Using a pre-loaded template is significantly faster for batch operations. Replaces `{{variable}}` placeholders in the template with values from the data map, supporting nested data access with dot notation (e.g., `{{customer.name}}`). Case-insensitive matching ensures `{{Name}}`, `{{name}}`, and `{{NAME}}` all match the same data key. ## Parameters - `template` - Either: - A file path (String) to a .docx template - loads, parses, and renders in one call - A `%Template{}` struct from `Ootempl.load/1` - skips loading/parsing (fast) - `data` - Map of data for placeholder replacement (string keys) - `output_path` - Path where the generated .docx file should be saved ## Returns - `:ok` on success - `{:error, %PlaceholderError{}}` when placeholders cannot be resolved - `{:error, exception}` on structural failures (invalid file, corrupt ZIP, etc.) ## Examples ### Single Document (Convenience API) data = %{ "name" => "John Doe", "customer" => %{"email" => "john@example.com"}, "total" => 99.99 } Ootempl.render("template.docx", data, "output.docx") #=> :ok ### Batch Processing (Optimized API) # Load template once {:ok, template} = Ootempl.load("invoice_template.docx") # Render many documents (40% faster) Enum.each(customers, fn customer -> data = %{"name" => customer.name, "total" => customer.balance} Ootempl.render(template, data, "invoice_\#{customer.id}.docx") end) ### Error Handling # Missing placeholders (collects all errors) Ootempl.render("template.docx", %{}, "output.docx") #=> {:error, %Ootempl.PlaceholderError{ # message: "2 placeholders could not be resolved (first: {{name}})", # placeholders: [ # %{placeholder: "{{name}}", reason: {:path_not_found, ["name"]}}, # %{placeholder: "{{customer.email}}", reason: {:path_not_found, ["customer", "email"]}} # ] # }} # Structural errors Ootempl.render("missing.docx", %{}, "out.docx") #=> {:error, %Ootempl.ValidationError{reason: :file_not_found}} ## Error Cases ### Structural Errors (fail-fast) - Template file does not exist - Template is not a valid .docx file - Template and output are the same file - Output directory does not exist or is not writable - Insufficient disk space - Template file is locked/in use ### Placeholder Errors (collected and returned together) - Placeholder not found in data map - Invalid nested path - Nil values in data - Unsupported data types (maps, lists as values) """ @spec render(Path.t() | Template.t(), map() | struct(), Path.t()) :: :ok | {:error, term()} def render(template, data, output_path) # Pattern 1: Render from pre-loaded Template struct (optimized for batch processing) def render(%Template{} = template, data, output_path) do with :ok <- validate_output_path(output_path) do render_from_template(template, data, output_path) end end # Pattern 2: Render from file path (convenience API - loads template on each call) def render(template_path, data, output_path) when is_binary(template_path) do with :ok <- validate_paths(template_path, output_path), :ok <- Validator.validate_docx(template_path) do # Extract template and ensure cleanup happens regardless of success or failure case Archive.extract(template_path) do {:ok, temp_dir} -> # Process template and always cleanup temp directory, even on error result = process_template(temp_dir, data, output_path) cleanup_result = Archive.cleanup(temp_dir) # Return original result or cleanup error case {result, cleanup_result} do {:ok, :ok} -> :ok {:ok, {:error, _} = cleanup_error} -> cleanup_error {error, _} -> error end {:error, _reason} = error -> error end else {:error, _reason} = error -> error end end # Private functions # Inspects template from pre-loaded Template struct @spec inspect_from_template(Template.t()) :: {:ok, TemplateInfo.t()} defp inspect_from_template(template) do # Collect XML elements to inspect with their locations xml_elements = [ {template.document, :document_body} ] # Add headers header_elements = template.headers |> Enum.map(fn {filename, xml} -> # Extract number from filename (e.g., "word/header1.xml" -> 1) number = filename |> Path.basename(".xml") |> String.replace(~r/[^\d]/, "") |> String.to_integer() location = String.to_atom("header#{number}") {xml, location} end) # Add footers footer_elements = template.footers |> Enum.map(fn {filename, xml} -> number = filename |> Path.basename(".xml") |> String.replace(~r/[^\d]/, "") |> String.to_integer() location = String.to_atom("footer#{number}") {xml, location} end) # Add optional elements optional_elements = [ {template.footnotes, :footnotes}, {template.endnotes, :endnotes}, {template.core_properties, :properties}, {template.app_properties, :properties} ] |> Enum.reject(fn {xml, _location} -> is_nil(xml) end) all_elements = xml_elements ++ header_elements ++ footer_elements ++ optional_elements # Inspect each XML element file_inspections = Enum.map(all_elements, fn {xml, location} -> text = extract_all_text_from_doc(xml) # Detect placeholders and conditionals placeholders = Placeholder.detect(text) conditionals = Conditional.detect_conditionals(text) # Validate conditionals validation_errors = validate_conditional_syntax(conditionals) %{ location: location, placeholders: placeholders, conditionals: conditionals, errors: validation_errors } end) # Build TemplateInfo from inspections build_template_info(file_inspections) end # Inspects template in temp directory and returns TemplateInfo struct @spec inspect_template_in_temp_dir(Path.t()) :: {:ok, TemplateInfo.t()} | {:error, term()} defp inspect_template_in_temp_dir(temp_dir) do # Define which files to inspect files_to_inspect = [ {"word/document.xml", :document_body}, {"word/footnotes.xml", :footnotes}, {"word/endnotes.xml", :endnotes}, {"docProps/core.xml", :properties}, {"docProps/app.xml", :properties} ] # Find header and footer files header_files = find_xml_files_with_location(temp_dir, "word/header*.xml", :header) footer_files = find_xml_files_with_location(temp_dir, "word/footer*.xml", :footer) all_files = files_to_inspect ++ header_files ++ footer_files # Inspect each file and collect results case inspect_all_files(temp_dir, all_files) do {:ok, file_inspections} -> # Aggregate results into TemplateInfo build_template_info(file_inspections) {:error, _reason} = error -> error end end # Finds XML files matching a pattern and assigns them location identifiers @spec find_xml_files_with_location(Path.t(), String.t(), :header | :footer) :: [{String.t(), TemplateInfo.location()}] defp find_xml_files_with_location(temp_dir, pattern, type) do temp_dir |> Path.join(pattern) |> Path.wildcard() |> Enum.map(fn file_path -> relative_path = Path.relative_to(file_path, temp_dir) # Extract number from filename (e.g., header1.xml -> 1) number = file_path |> Path.basename(".xml") |> String.replace(~r/[^\d]/, "") |> String.to_integer() location = case type do :header -> String.to_atom("header#{number}") :footer -> String.to_atom("footer#{number}") end {relative_path, location} end) end # Inspects all files and returns list of file inspection results @spec inspect_all_files(Path.t(), [{String.t(), TemplateInfo.location()}]) :: {:ok, [map()]} | {:error, term()} defp inspect_all_files(temp_dir, files) do results = Enum.reduce_while(files, {:ok, []}, fn file_info, {:ok, acc} -> inspect_file_if_exists(temp_dir, file_info, acc) end) case results do {:ok, inspections} -> {:ok, Enum.reverse(inspections)} error -> error end end # Helper to inspect a file if it exists @spec inspect_file_if_exists(Path.t(), {String.t(), TemplateInfo.location()}, [map()]) :: {:cont, {:ok, [map()]}} | {:halt, {:error, term()}} defp inspect_file_if_exists(temp_dir, {relative_path, location}, acc) do file_path = Path.join(temp_dir, relative_path) if File.exists?(file_path) do case inspect_single_file(file_path, location) do {:ok, inspection} -> {:cont, {:ok, [inspection | acc]}} {:error, _reason} = error -> {:halt, error} end else # File doesn't exist - skip it {:cont, {:ok, acc}} end end # Inspects a single XML file @spec inspect_single_file(Path.t(), TemplateInfo.location()) :: {:ok, map()} | {:error, term()} defp inspect_single_file(file_path, location) do with {:ok, xml_content} <- File.read(file_path), {:ok, xml_doc} <- Xml.parse(xml_content) do normalized_doc = Normalizer.normalize(xml_doc) text = extract_all_text_from_doc(normalized_doc) # Detect placeholders and conditionals placeholders = Placeholder.detect(text) conditionals = Conditional.detect_conditionals(text) # Validate conditionals validation_errors = validate_conditional_syntax(conditionals) {:ok, %{ location: location, placeholders: placeholders, conditionals: conditionals, errors: validation_errors }} else {:error, reason} -> {:error, {:inspect_file_failed, file_path, reason}} end end # Validates conditional syntax and returns list of errors @spec validate_conditional_syntax([Conditional.conditional()]) :: [TemplateInfo.error_info()] defp validate_conditional_syntax(conditionals) do case Conditional.validate_pairs(conditionals) do :ok -> [] {:error, message} -> # Parse error message to determine error type error_type = cond do String.contains?(message, "Unmatched {{if") -> :unclosed_conditional String.contains?(message, "Orphan") -> :unclosed_conditional String.contains?(message, "Multiple {{else}}") -> :invalid_conditional_syntax true -> :invalid_conditional_syntax end [%{type: error_type, message: message, location: nil}] end end # Builds TemplateInfo struct from file inspection results @spec build_template_info([map()]) :: {:ok, TemplateInfo.t()} defp build_template_info(file_inspections) do # Aggregate placeholders across all files all_placeholders = file_inspections |> Enum.flat_map(fn inspection -> Enum.map(inspection.placeholders, fn ph -> Map.put(ph, :location, inspection.location) end) end) # Deduplicate placeholders and collect locations deduplicated_placeholders = deduplicate_placeholders(all_placeholders) # Aggregate conditionals across all files all_conditionals = file_inspections |> Enum.flat_map(fn inspection -> # Only keep :if conditionals for the summary (not :else or :endif) inspection.conditionals |> Enum.filter(&(&1.type == :if)) |> Enum.map(fn cond -> %{ condition: cond.condition, path: cond.path, location: inspection.location } end) end) # Deduplicate conditionals deduplicated_conditionals = deduplicate_conditionals(all_conditionals) # Extract required keys (first segment of each placeholder path) required_keys = deduplicated_placeholders |> Enum.map(& &1.path) |> Enum.map(&List.first/1) |> Enum.uniq() |> Enum.sort() # Collect all errors all_errors = file_inspections |> Enum.flat_map(& &1.errors) # Determine validity valid? = Enum.empty?(all_errors) info = %TemplateInfo{ valid?: valid?, placeholders: deduplicated_placeholders, conditionals: deduplicated_conditionals, required_keys: required_keys, errors: all_errors } {:ok, info} end # Deduplicates placeholders by combining locations for the same placeholder @spec deduplicate_placeholders([map()]) :: [TemplateInfo.placeholder_info()] defp deduplicate_placeholders(placeholders) do placeholders |> Enum.group_by(& &1.original) |> Enum.map(fn {_original, group} -> # Take the first one as the template first = List.first(group) # Collect all unique locations locations = group |> Enum.map(& &1.location) |> Enum.uniq() |> Enum.sort() %{ original: first.original, path: first.path, locations: locations } end) |> Enum.sort_by(& &1.original) end # Deduplicates conditionals by combining locations for the same condition @spec deduplicate_conditionals([map()]) :: [TemplateInfo.conditional_info()] defp deduplicate_conditionals(conditionals) do conditionals |> Enum.group_by(& &1.condition) |> Enum.map(fn {_condition, group} -> first = List.first(group) locations = group |> Enum.map(& &1.location) |> Enum.uniq() |> Enum.sort() %{ condition: first.condition, path: first.path, locations: locations } end) |> Enum.sort_by(& &1.condition) end # Validates template in temp directory without creating output file @spec validate_template_in_temp_dir(Path.t(), map()) :: :ok | {:error, term()} defp validate_template_in_temp_dir(temp_dir, data) do with :ok <- process_single_xml_file(temp_dir, "word/document.xml", data), :ok <- process_header_footer_files(temp_dir, data), :ok <- process_footnote_endnote_files(temp_dir, data) do process_document_properties(temp_dir, data) # Validation successful - all processing steps completed without errors end end # Validates from a pre-loaded template without creating output file @spec validate_from_template(Template.t(), map()) :: :ok | {:error, term()} defp validate_from_template(template, data) do # Clone the template's XML structures (they'll be modified during processing) document = Template.clone_xml(template.document) headers = Template.clone_xml_map(template.headers) footers = Template.clone_xml_map(template.footers) footnotes = if template.footnotes, do: Template.clone_xml(template.footnotes) endnotes = if template.endnotes, do: Template.clone_xml(template.endnotes) core_props = if template.core_properties, do: Template.clone_xml(template.core_properties) app_props = if template.app_properties, do: Template.clone_xml(template.app_properties) # Process the cloned XML structures (same as render but skip file creation) with {:ok, _processed_doc} <- process_xml_document(document, data), {:ok, _processed_headers} <- process_xml_map(headers, data), {:ok, _processed_footers} <- process_xml_map(footers, data), {:ok, _processed_footnotes} <- process_optional_xml(footnotes, data), {:ok, _processed_endnotes} <- process_optional_xml(endnotes, data), {:ok, _processed_core} <- process_optional_xml_properties(core_props, data), {:ok, _processed_app} <- process_optional_xml_properties(app_props, data) do # Validation successful - all processing steps completed without errors :ok end end @spec process_template(Path.t(), map(), Path.t()) :: :ok | {:error, term()} defp process_template(temp_dir, data, output_path) do with :ok <- process_single_xml_file(temp_dir, "word/document.xml", data), :ok <- process_header_footer_files(temp_dir, data), :ok <- process_footnote_endnote_files(temp_dir, data), :ok <- process_document_properties(temp_dir, data), {:ok, file_map} <- build_file_map(temp_dir) do Archive.create(file_map, output_path) end end # Processes a single XML file through the full replacement pipeline. # # Applies the complete processing pipeline to a single XML file: # - Load XML content # - Parse XML # - Normalize (collapse fragmented placeholders) # - Process conditionals (FIRST - before tables and variables) # - Process tables (if any) # - Replace placeholders # - Serialize back to XML # - Save to disk @spec process_single_xml_file(Path.t(), String.t(), map()) :: :ok | {:error, term()} defp process_single_xml_file(temp_dir, relative_path, data) do file_path = Path.join(temp_dir, relative_path) with {:ok, xml_content} <- File.read(file_path), {:ok, xml_doc} <- Xml.parse(xml_content), normalized_doc = Normalizer.normalize(xml_doc), {:ok, conditional_processed_doc} <- process_conditionals(normalized_doc, data), {:ok, table_processed_doc} <- process_tables(conditional_processed_doc, data), {:ok, replaced_doc} <- Replacement.replace_in_document(table_processed_doc, data), {:ok, image_processed_doc} <- process_images(replaced_doc, data, temp_dir), {:ok, modified_xml} <- Xml.serialize(image_processed_doc), :ok <- File.write(file_path, modified_xml) do :ok else # PlaceholderError and ImageError should be returned directly without wrapping {:error, %Ootempl.PlaceholderError{} = error} -> {:error, error} {:error, %Ootempl.ImageError{} = error} -> {:error, error} # Other errors are wrapped with context {:error, reason} -> {:error, {:file_processing_failed, relative_path, reason}} end end # Discovers and processes all header and footer XML files in the document. # # Finds all `word/header*.xml` and `word/footer*.xml` files using Path.wildcard # and applies the same processing pipeline used for the main document body. # # Missing header/footer files are OK (not all documents have them). @spec process_header_footer_files(Path.t(), map()) :: :ok | {:error, term()} defp process_header_footer_files(temp_dir, data) do header_files = Path.wildcard(Path.join(temp_dir, "word/header*.xml")) footer_files = Path.wildcard(Path.join(temp_dir, "word/footer*.xml")) all_files = header_files ++ footer_files # Convert absolute paths to relative paths relative_files = Enum.map(all_files, fn file_path -> Path.relative_to(file_path, temp_dir) end) # Process each file Enum.reduce_while(relative_files, :ok, fn relative_path, _acc -> case process_single_xml_file(temp_dir, relative_path, data) do :ok -> {:cont, :ok} {:error, _reason} = error -> {:halt, error} end end) end # Processes footnotes and endnotes XML files if they exist. # # Finds `word/footnotes.xml` and `word/endnotes.xml` files and applies # the same processing pipeline used for the main document body. # These files use the same w:p/w:r/w:t XML structure as document.xml. # # Missing files are OK (not all documents have footnotes or endnotes). @spec process_footnote_endnote_files(Path.t(), map()) :: :ok | {:error, term()} defp process_footnote_endnote_files(temp_dir, data) do ["word/footnotes.xml", "word/endnotes.xml"] |> Enum.filter(&File.exists?(Path.join(temp_dir, &1))) |> Enum.reduce_while(:ok, fn relative_path, _acc -> case process_single_xml_file(temp_dir, relative_path, data) do :ok -> {:cont, :ok} {:error, _reason} = error -> {:halt, error} end end) end # Processes document property files for placeholder replacement. # # Handles `docProps/core.xml` (title, subject, description, creator) and # `docProps/app.xml` (company, manager) files. These files use simpler XML # structures with direct text content rather than Word's paragraph/run structure. # # Missing property files are OK (not all documents have all properties set). @spec process_document_properties(Path.t(), map()) :: :ok | {:error, term()} defp process_document_properties(temp_dir, data) do ["docProps/core.xml", "docProps/app.xml"] |> Enum.filter(&File.exists?(Path.join(temp_dir, &1))) |> Enum.reduce_while(:ok, fn relative_path, _acc -> case process_property_file(temp_dir, relative_path, data) do :ok -> {:cont, :ok} {:error, _reason} = error -> {:halt, error} end end) end # Processes a document property XML file using simple text replacement. # # Property files have simpler XML structures with direct text content # (e.g., `{{title}}`) rather than the complex # w:p/w:r/w:t structure used in the main document. This function uses # the full XML processing pipeline to ensure proper handling. @spec process_property_file(Path.t(), String.t(), map()) :: :ok | {:error, term()} defp process_property_file(temp_dir, relative_path, data) do file_path = Path.join(temp_dir, relative_path) with {:ok, xml_content} <- File.read(file_path), {:ok, xml_doc} <- Xml.parse(xml_content), normalized_doc = Normalizer.normalize(xml_doc), {:ok, replaced_doc} <- Replacement.replace_in_document(normalized_doc, data), {:ok, modified_xml} <- Xml.serialize(replaced_doc), :ok <- File.write(file_path, modified_xml) do :ok else {:error, %Ootempl.PlaceholderError{} = error} -> {:error, error} {:error, reason} -> {:error, {:property_file_processing_failed, relative_path, reason}} end end # Processes conditional sections in an XML document. # # Detects all `{{if condition}}...{{endif}}` markers, evaluates conditions, # and either removes sections (when false) or removes markers (when true). # # This must run BEFORE variable replacement to ensure removed sections # don't get processed. @spec process_conditionals(Xml.xml_element(), map()) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_conditionals(xml_doc, data) do # Extract all text to detect conditionals text = extract_all_text_from_doc(xml_doc) # Detect all conditional markers conditionals = Conditional.detect_conditionals(text) # If no conditionals, return document unchanged if Enum.empty?(conditionals) do {:ok, xml_doc} else # Validate marker pairs case Conditional.validate_pairs(conditionals) do :ok -> # Process all conditional pairs process_all_conditionals(xml_doc, conditionals, data) {:error, reason} -> {:error, {:conditional_validation_failed, reason}} end end end # Processes all conditional pairs in the document # Re-detects conditionals after each processing step to avoid stale references @spec process_all_conditionals(Xml.xml_element(), [Conditional.conditional()], map()) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_all_conditionals(xml_doc, _conditionals, data) do # Process conditionals one at a time, re-detecting after each process_conditionals_iteratively(xml_doc, data) end # Iteratively process conditionals, re-detecting after each one @spec process_conditionals_iteratively(Xml.xml_element(), map()) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_conditionals_iteratively(xml_doc, data) do # Extract text and detect conditionals text = extract_all_text_from_doc(xml_doc) conditionals = Conditional.detect_conditionals(text) # If no conditionals remain, we're done if Enum.empty?(conditionals) do {:ok, xml_doc} else process_detected_conditionals(xml_doc, conditionals, data) end end # Processes detected conditionals after validation @spec process_detected_conditionals(Xml.xml_element(), [Conditional.conditional()], map()) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_detected_conditionals(xml_doc, conditionals, data) do # Validate pairs case Conditional.validate_pairs(conditionals) do :ok -> process_first_conditional_pair(xml_doc, conditionals, data) {:error, reason} -> {:error, {:conditional_validation_failed, reason}} end end # Processes the first conditional pair and recurses @spec process_first_conditional_pair(Xml.xml_element(), [Conditional.conditional()], map()) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_first_conditional_pair(xml_doc, conditionals, data) do pairs = group_conditional_pairs(conditionals) case pairs do [] -> {:ok, xml_doc} [first_pair | _rest] -> # Process the first conditional pair case process_single_conditional(xml_doc, first_pair, data) do {:ok, modified_doc} -> # Re-detect and process remaining conditionals process_conditionals_iteratively(modified_doc, data) {:error, _reason} = error -> error end end end # Groups conditional markers into if/else/endif triplets (or if/endif pairs if no else) @spec group_conditional_pairs([Conditional.conditional()]) :: [%{if: Conditional.conditional(), else: Conditional.conditional() | nil, endif: Conditional.conditional()}] defp group_conditional_pairs(conditionals) do do_group_pairs(conditionals, [], []) end @spec do_group_pairs( [Conditional.conditional()], [{Conditional.conditional(), Conditional.conditional() | nil}], [%{if: Conditional.conditional(), else: Conditional.conditional() | nil, endif: Conditional.conditional()}] ) :: [%{if: Conditional.conditional(), else: Conditional.conditional() | nil, endif: Conditional.conditional()}] defp do_group_pairs([], _stack, pairs), do: Enum.reverse(pairs) defp do_group_pairs([%{type: :if} = marker | rest], stack, pairs) do # Push if marker with no else marker yet do_group_pairs(rest, [{marker, nil} | stack], pairs) end defp do_group_pairs([%{type: :else} = marker | rest], [{if_marker, _} | stack_rest], pairs) do # Update the top of stack to include the else marker do_group_pairs(rest, [{if_marker, marker} | stack_rest], pairs) end defp do_group_pairs([%{type: :endif} = marker | rest], [{if_marker, else_marker} | stack], pairs) do pair = %{if: if_marker, else: else_marker, endif: marker} do_group_pairs(rest, stack, [pair | pairs]) end # Processes a single conditional (if/else/endif or if/endif) @spec process_single_conditional( Xml.xml_element(), %{if: Conditional.conditional(), else: Conditional.conditional() | nil, endif: Conditional.conditional()}, map() ) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_single_conditional(xml_doc, %{if: if_marker, else: else_marker, endif: endif_marker} = pair, data) do # Evaluate the condition case Conditional.evaluate_condition(if_marker.path, data) do {:ok, true} -> # Condition is true: keep if section, remove else section (if present) remove_conditional_markers_and_else_section(xml_doc, pair) {:ok, false} -> # Condition is false: remove if section, keep else section (if present) remove_if_section_keep_else(xml_doc, if_marker, else_marker, endif_marker) {:error, reason} -> {:error, {:conditional_evaluation_failed, if_marker.condition, reason}} end end # Removes conditional markers and else section (when condition is true) @spec remove_conditional_markers_and_else_section( Xml.xml_element(), %{if: Conditional.conditional(), else: Conditional.conditional() | nil, endif: Conditional.conditional()} ) :: {:ok, Xml.xml_element()} | {:error, term()} defp remove_conditional_markers_and_else_section(xml_doc, %{if: if_marker, else: nil, endif: _endif_marker}) do # No else section: just remove the if and endif markers if_marker_text = "{{if #{if_marker.condition}}}" endif_marker_text = "{{endif}}" case Conditional.find_section_boundaries(xml_doc, if_marker_text, endif_marker_text) do {:ok, {start_para, end_para}} -> modified_doc = Xml.remove_nodes(xml_doc, [start_para, end_para]) {:ok, modified_doc} {:error, reason} -> {:error, {:marker_removal_failed, reason}} end end defp remove_conditional_markers_and_else_section(xml_doc, %{if: if_marker, else: _else_marker, endif: _endif_marker}) do # Has else section: remove if marker, remove entire else section through endif if_marker_text = "{{if #{if_marker.condition}}}" else_marker_text = "{{else}}" endif_marker_text = "{{endif}}" with {:ok, if_para} <- find_paragraph(xml_doc, if_marker_text), {:ok, {else_para, endif_para}} <- Conditional.find_section_boundaries(xml_doc, else_marker_text, endif_marker_text), {:ok, nodes_to_remove} <- collect_else_section_nodes(xml_doc, else_para, endif_para) do # Remove if marker paragraph and all nodes from else through endif modified_doc = Xml.remove_nodes(xml_doc, [if_para | nodes_to_remove]) {:ok, modified_doc} else {:error, reason} -> {:error, {:marker_removal_failed, reason}} end end # Removes if section and keeps else section (when condition is false) @spec remove_if_section_keep_else( Xml.xml_element(), Conditional.conditional(), Conditional.conditional() | nil, Conditional.conditional() ) :: {:ok, Xml.xml_element()} | {:error, term()} defp remove_if_section_keep_else(xml_doc, if_marker, nil, _endif_marker) do # No else section: remove entire if/endif section if_marker_text = "{{if #{if_marker.condition}}}" endif_marker_text = "{{endif}}" case Conditional.find_section_boundaries(xml_doc, if_marker_text, endif_marker_text) do {:ok, {start_para, end_para}} -> body_element = find_body_element(xml_doc) case Conditional.collect_section_nodes(body_element, start_para, end_para) do {:ok, nodes_to_remove} -> modified_doc = Xml.remove_nodes(xml_doc, nodes_to_remove) {:ok, modified_doc} {:error, reason} -> {:error, {:section_boundary_error, reason}} end {:error, reason} -> {:error, {:section_boundary_not_found, if_marker.condition, reason}} end end defp remove_if_section_keep_else(xml_doc, if_marker, _else_marker, _endif_marker) do # Has else section: remove if section through else, remove else and endif markers if_marker_text = "{{if #{if_marker.condition}}}" else_marker_text = "{{else}}" endif_marker_text = "{{endif}}" with {:ok, {if_para, else_para}} <- Conditional.find_section_boundaries(xml_doc, if_marker_text, else_marker_text), {:ok, endif_para} <- find_paragraph(xml_doc, endif_marker_text), {:ok, if_section_nodes} <- collect_if_section_nodes(xml_doc, if_para, else_para) do # Remove if section (if through else) and endif marker modified_doc = Xml.remove_nodes(xml_doc, if_section_nodes ++ [endif_para]) {:ok, modified_doc} else {:error, reason} -> {:error, {:section_boundary_not_found, if_marker.condition, reason}} end end # Finds a paragraph containing specific text @spec find_paragraph(Xml.xml_element(), String.t()) :: {:ok, Xml.xml_element()} | {:error, :not_found} defp find_paragraph(xml_doc, text) do import Xml case Enum.find(find_all_paragraphs(xml_doc), fn para -> paragraph_contains_text?(para, text) end) do nil -> {:error, :not_found} para -> {:ok, para} end end # Collects nodes from else paragraph through endif paragraph (inclusive) @spec collect_else_section_nodes(Xml.xml_element(), Xml.xml_element(), Xml.xml_element()) :: {:ok, [Xml.xml_node()]} | {:error, term()} defp collect_else_section_nodes(xml_doc, else_para, endif_para) do body_element = find_body_element(xml_doc) Conditional.collect_section_nodes(body_element, else_para, endif_para) end # Collects nodes from if paragraph through else paragraph (inclusive) @spec collect_if_section_nodes(Xml.xml_element(), Xml.xml_element(), Xml.xml_element()) :: {:ok, [Xml.xml_node()]} | {:error, term()} defp collect_if_section_nodes(xml_doc, if_para, else_para) do body_element = find_body_element(xml_doc) Conditional.collect_section_nodes(body_element, if_para, else_para) end # Helper to find all paragraphs in a document @spec find_all_paragraphs(Xml.xml_element()) :: [Xml.xml_element()] defp find_all_paragraphs(xml_element) do import Xml children = xmlElement(xml_element, :content) Enum.flat_map(children, &collect_paragraph_from_node/1) end @spec collect_paragraph_from_node(Xml.xml_node()) :: [Xml.xml_element()] defp collect_paragraph_from_node(node) do import Xml require Record cond do not Record.is_record(node, :xmlElement) -> [] xmlElement(node, :name) == :"w:p" -> [node] true -> find_all_paragraphs(node) end end # Helper to check if paragraph contains text @spec paragraph_contains_text?(Xml.xml_element(), String.t()) :: boolean() defp paragraph_contains_text?(paragraph, text) do import Xml paragraph |> extract_text_from_element() |> String.contains?(text) end # Helper to extract text from an element @spec extract_text_from_element(Xml.xml_element()) :: String.t() defp extract_text_from_element(element) do import Xml require Record children = xmlElement(element, :content) Enum.map_join(children, fn node -> cond do Record.is_record(node, :xmlText) -> node |> xmlText(:value) |> List.to_string() Record.is_record(node, :xmlElement) -> extract_text_from_element(node) true -> "" end end) end # Finds the w:body element in the document @spec find_body_element(Xml.xml_element()) :: Xml.xml_element() defp find_body_element(xml_doc) do import Xml require Record children = xmlElement(xml_doc, :content) Enum.find(children, fn node -> Record.is_record(node, :xmlElement) and xmlElement(node, :name) == :"w:body" end) end # Extracts all text content from the document for conditional detection @spec extract_all_text_from_doc(Xml.xml_element()) :: String.t() defp extract_all_text_from_doc(element) do import Xml require Record children = xmlElement(element, :content) Enum.map_join(children, fn node -> cond do Record.is_record(node, :xmlText) -> node |> xmlText(:value) |> List.to_string() Record.is_record(node, :xmlElement) -> extract_all_text_from_doc(node) true -> "" end end) end @spec process_tables(Xml.xml_element(), map()) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_tables(xml_doc, data) do # Find all tables in document tables = Table.find_tables(xml_doc) # Process each table and collect results case process_all_tables(tables, data, xml_doc) do {:ok, modified_doc} -> {:ok, modified_doc} {:error, _reason} = error -> error end end @spec process_all_tables([Xml.xml_element()], map(), Xml.xml_element()) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_all_tables([], _data, xml_doc), do: {:ok, xml_doc} defp process_all_tables([table | rest_tables], data, xml_doc) do case process_single_table(table, data, xml_doc) do {:ok, modified_doc} -> process_all_tables(rest_tables, data, modified_doc) {:error, _reason} = error -> error end end @spec process_single_table(Xml.xml_element(), map(), Xml.xml_element()) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_single_table(table, data, xml_doc) do rows = Table.extract_rows(table) table_text = extract_table_text(table) # Check if table contains block markers for hierarchical processing if Block.contains_markers?(table_text) do process_block_table(table, rows, data, xml_doc) else # Existing simple table processing case Table.group_template_rows(rows, data) do {:ok, row_analyses} -> # Find template row groups and duplicate process_row_groups(table, row_analyses, data, xml_doc) {:error, {:multiple_lists, _row}} = error -> error end end end # Processes a table with hierarchical block markers @spec process_block_table(Xml.xml_element(), [Xml.xml_element()], map(), Xml.xml_element()) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_block_table(table, rows, data, xml_doc) do with {:ok, structure} <- Block.parse_table_structure(rows, data), expanded <- Block.expand_block(structure, rows, data), {:ok, replaced_rows} <- replace_placeholders_in_expanded_rows(expanded), block_row_range <- {structure.open_row_index, structure.close_row_index}, new_table <- rebuild_table_with_expanded_rows(table, rows, replaced_rows, block_row_range) do replace_table_in_doc(xml_doc, table, new_table) end end # Replaces placeholders in expanded rows @spec replace_placeholders_in_expanded_rows([{Xml.xml_element(), map()}]) :: {:ok, [Xml.xml_element()]} | {:error, term()} defp replace_placeholders_in_expanded_rows(expanded_rows) do {replaced_rows, all_errors} = Enum.reduce(expanded_rows, {[], []}, fn {row, scoped_data}, {rows_acc, errors_acc} -> # Strip block markers from the row before replacement cleaned_row = strip_block_markers_from_row(row) case Replacement.replace_in_document(cleaned_row, scoped_data) do {:ok, replaced_row} -> {[replaced_row | rows_acc], errors_acc} {:error, %Ootempl.PlaceholderError{placeholders: placeholders}} -> {[cleaned_row | rows_acc], errors_acc ++ placeholders} end end) if all_errors == [] do {:ok, Enum.reverse(replaced_rows)} else {:error, Ootempl.PlaceholderError.exception(placeholders: all_errors)} end end # Strips block markers from a row's text content @spec strip_block_markers_from_row(Xml.xml_element()) :: Xml.xml_element() defp strip_block_markers_from_row(row) do import Xml require Record # Pattern to match block markers marker_pattern = ~r/\{\{[#\/][a-zA-Z_][a-zA-Z0-9_]*\}\}/ # Recursively process the row to strip markers strip_markers_recursive(row, marker_pattern) end @spec strip_markers_recursive(Xml.xml_node(), Regex.t()) :: Xml.xml_node() defp strip_markers_recursive(node, pattern) do import Xml require Record cond do Record.is_record(node, :xmlText) -> text = node |> xmlText(:value) |> List.to_string() cleaned = String.replace(text, pattern, "") xmlText(node, value: String.to_charlist(cleaned)) Record.is_record(node, :xmlElement) -> content = xmlElement(node, :content) cleaned_content = Enum.map(content, &strip_markers_recursive(&1, pattern)) xmlElement(node, content: cleaned_content) true -> node end end # Rebuilds the table with expanded rows, removing only block rows @spec rebuild_table_with_expanded_rows( Xml.xml_element(), [Xml.xml_element()], [Xml.xml_element()], {non_neg_integer(), non_neg_integer()} ) :: Xml.xml_element() defp rebuild_table_with_expanded_rows(table, original_rows, expanded_rows, {block_start, block_end}) do import Xml require Record content = xmlElement(table, :content) # Get rows before the block (static header rows) rows_before_block = Enum.slice(original_rows, 0, block_start) # Get rows after the block (static footer rows) rows_after_block = Enum.slice(original_rows, (block_end + 1)..-1//1) # Find the position in content where the first block row is # We need to insert our new rows at this position row_indices_in_content = content |> Enum.with_index() |> Enum.filter(fn {node, _idx} -> Record.is_record(node, :xmlElement) and xmlElement(node, :name) == :"w:tr" end) |> Enum.map(fn {_node, idx} -> idx end) # Non-row content (tblPr, tblGrid, etc.) non_row_content = content |> Enum.with_index() |> Enum.filter(fn {node, _idx} -> not (Record.is_record(node, :xmlElement) and xmlElement(node, :name) == :"w:tr") end) |> Enum.map(fn {node, _idx} -> node end) # Get the position where the first row appears in content first_row_content_idx = List.first(row_indices_in_content) || 0 # Split non-row content - items before any rows and items after {before_non_row, after_non_row} = non_row_content |> Enum.with_index() |> Enum.split_with(fn {_node, orig_idx} -> # Find original index of this non-row item actual_idx = content |> Enum.with_index() |> Enum.filter(fn {node, _} -> not (Record.is_record(node, :xmlElement) and xmlElement(node, :name) == :"w:tr") end) |> Enum.at(orig_idx) |> elem(1) actual_idx < first_row_content_idx end) before_non_row = Enum.map(before_non_row, &elem(&1, 0)) after_non_row = Enum.map(after_non_row, &elem(&1, 0)) # Combine: before non-rows + rows before block + expanded rows + rows after block + after non-rows new_content = before_non_row ++ rows_before_block ++ expanded_rows ++ rows_after_block ++ after_non_row xmlElement(table, content: new_content) end # Extracts all text content from a table for marker detection @spec extract_table_text(Xml.xml_element()) :: String.t() defp extract_table_text(table) do extract_text_from_element(table) end @spec process_row_groups(Xml.xml_element(), [Table.row_analysis()], map(), Xml.xml_element()) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_row_groups(table, row_analyses, data, xml_doc) do # Identify template row groups (consecutive template rows with same list_key) template_groups = identify_template_groups(row_analyses) # Process template groups in reverse order to maintain positions # Collect all errors from all groups before failing result = template_groups |> Enum.reverse() |> Enum.reduce({:ok, table, []}, fn group, {status, acc_table, acc_errors} -> case status do :ok -> case duplicate_and_replace_group(group, data, acc_table) do {:ok, new_table} -> {:ok, new_table, acc_errors} {:error, %Ootempl.PlaceholderError{placeholders: placeholders}} -> {:error, acc_table, acc_errors ++ placeholders} end :error -> # Continue processing to collect all errors case duplicate_and_replace_group(group, data, acc_table) do {:ok, new_table} -> {:error, new_table, acc_errors} {:error, %Ootempl.PlaceholderError{placeholders: placeholders}} -> {:error, acc_table, acc_errors ++ placeholders} end end end) case result do {:ok, modified_table, []} -> # Replace the old table with modified table in the document replace_table_in_doc(xml_doc, table, modified_table) {_, _table, errors} when errors != [] -> {:error, Ootempl.PlaceholderError.exception(placeholders: errors)} end end @spec identify_template_groups([Table.row_analysis()]) :: [%{rows: [Xml.xml_element()], list_key: String.t(), position: non_neg_integer()}] defp identify_template_groups(row_analyses) do row_analyses |> Enum.with_index() |> Enum.reduce([], &add_template_row_to_groups/2) |> Enum.reverse() end @spec add_template_row_to_groups({Table.row_analysis(), non_neg_integer()}, [map()]) :: [map()] defp add_template_row_to_groups({analysis, index}, acc) do if analysis.template? do handle_template_row(analysis, index, acc) else acc end end @spec handle_template_row(Table.row_analysis(), non_neg_integer(), [map()]) :: [map()] defp handle_template_row(analysis, index, []), do: [%{rows: [analysis.row], list_key: analysis.list_key, position: index}] defp handle_template_row(analysis, _index, [current | rest]) when current.list_key == analysis.list_key do updated_current = %{current | rows: current.rows ++ [analysis.row]} [updated_current | rest] end defp handle_template_row(analysis, index, groups) do [%{rows: [analysis.row], list_key: analysis.list_key, position: index} | groups] end @spec duplicate_and_replace_group( %{rows: [Xml.xml_element()], list_key: String.t(), position: non_neg_integer()}, map(), Xml.xml_element() ) :: {:ok, Xml.xml_element()} | {:error, Ootempl.PlaceholderError.t()} defp duplicate_and_replace_group(group, data, table) do # Duplicate rows with scoped data duplicated_with_data = Table.duplicate_rows(group.rows, group.list_key, data) # Replace placeholders in each duplicated row, collecting errors {duplicated_rows, all_errors} = Enum.reduce(duplicated_with_data, {[], []}, fn {row, scoped_data}, {rows_acc, errors_acc} -> case Replacement.replace_in_document(row, scoped_data) do {:ok, replaced_row} -> {[replaced_row | rows_acc], errors_acc} {:error, %Ootempl.PlaceholderError{placeholders: placeholders}} -> # Still add the row (will be filtered out if there are errors) # but collect the errors for reporting {[row | rows_acc], errors_acc ++ placeholders} end end) # Reverse to maintain original order duplicated_rows = Enum.reverse(duplicated_rows) if all_errors == [] do # Insert duplicated rows first, then remove template rows modified_table = table |> Table.insert_rows(duplicated_rows, group.position) |> Table.remove_template_rows(group.rows) {:ok, modified_table} else {:error, Ootempl.PlaceholderError.exception(placeholders: all_errors)} end end @spec replace_table_in_doc(Xml.xml_element(), Xml.xml_element(), Xml.xml_element()) :: {:ok, Xml.xml_element()} defp replace_table_in_doc(xml_doc, old_table, new_table) do # Traverse document and replace old table with new table modified_doc = replace_element_in_tree(xml_doc, old_table, new_table) {:ok, modified_doc} end @spec replace_element_in_tree(Xml.xml_element(), Xml.xml_element(), Xml.xml_element()) :: Xml.xml_element() defp replace_element_in_tree(element, old_element, new_element) do import Xml # If this is the element to replace, return the new one if element == old_element do new_element else # Otherwise, recursively process children content = xmlElement(element, :content) modified_content = Enum.map(content, &replace_node_in_tree(&1, old_element, new_element)) xmlElement(element, content: modified_content) end end @spec replace_node_in_tree(Xml.xml_node(), Xml.xml_element(), Xml.xml_element()) :: Xml.xml_node() defp replace_node_in_tree(node, old_element, new_element) do require Record if Record.is_record(node, :xmlElement) do replace_element_in_tree(node, old_element, new_element) else node end end # Loads and parses all XML files from an extracted .docx template @spec load_and_parse_template(Path.t(), Path.t()) :: {:ok, Template.t()} | {:error, term()} defp load_and_parse_template(temp_dir, source_path) do with {:ok, document_xml} <- load_and_parse_xml(temp_dir, "word/document.xml"), {:ok, headers} <- load_headers(temp_dir), {:ok, footers} <- load_footers(temp_dir), {:ok, footnotes} <- load_optional_xml(temp_dir, "word/footnotes.xml"), {:ok, endnotes} <- load_optional_xml(temp_dir, "word/endnotes.xml"), {:ok, core_props} <- load_optional_xml(temp_dir, "docProps/core.xml"), {:ok, app_props} <- load_optional_xml(temp_dir, "docProps/app.xml"), {:ok, static_files} <- load_static_files(temp_dir) do template = Template.new( document: document_xml, headers: headers, footers: footers, footnotes: footnotes, endnotes: endnotes, core_properties: core_props, app_properties: app_props, static_files: static_files, source_path: source_path ) {:ok, template} end end # Renders a document from a pre-loaded Template struct @spec render_from_template(Template.t(), map(), Path.t()) :: :ok | {:error, term()} defp render_from_template(template, data, output_path) do # Clone the template's XML structures (they'll be modified during processing) document = Template.clone_xml(template.document) headers = Template.clone_xml_map(template.headers) footers = Template.clone_xml_map(template.footers) footnotes = if template.footnotes, do: Template.clone_xml(template.footnotes) endnotes = if template.endnotes, do: Template.clone_xml(template.endnotes) core_props = if template.core_properties, do: Template.clone_xml(template.core_properties) app_props = if template.app_properties, do: Template.clone_xml(template.app_properties) # Process the cloned XML structures with {:ok, processed_doc} <- process_xml_document(document, data), {:ok, processed_headers} <- process_xml_map(headers, data), {:ok, processed_footers} <- process_xml_map(footers, data), {:ok, processed_footnotes} <- process_optional_xml(footnotes, data), {:ok, processed_endnotes} <- process_optional_xml(endnotes, data), {:ok, processed_core} <- process_optional_xml_properties(core_props, data), {:ok, processed_app} <- process_optional_xml_properties(app_props, data), {:ok, file_map} <- build_output_file_map( template.static_files, processed_doc, processed_headers, processed_footers, processed_footnotes, processed_endnotes, processed_core, processed_app ) do Archive.create(file_map, output_path) end end # Loads and parses a single XML file with normalization @spec load_and_parse_xml(Path.t(), String.t()) :: {:ok, Xml.xml_element()} | {:error, term()} defp load_and_parse_xml(temp_dir, relative_path) do file_path = Path.join(temp_dir, relative_path) with {:ok, xml_content} <- File.read(file_path), {:ok, xml_doc} <- Xml.parse(xml_content) do {:ok, Normalizer.normalize(xml_doc)} else {:error, reason} -> {:error, {:load_xml_failed, relative_path, reason}} end end # Loads all header XML files @spec load_headers(Path.t()) :: {:ok, %{String.t() => Xml.xml_element()}} | {:error, term()} defp load_headers(temp_dir) do load_xml_files_by_pattern(temp_dir, "word/header*.xml") end # Loads all footer XML files @spec load_footers(Path.t()) :: {:ok, %{String.t() => Xml.xml_element()}} | {:error, term()} defp load_footers(temp_dir) do load_xml_files_by_pattern(temp_dir, "word/footer*.xml") end # Loads XML files matching a glob pattern @spec load_xml_files_by_pattern(Path.t(), String.t()) :: {:ok, %{String.t() => Xml.xml_element()}} | {:error, term()} defp load_xml_files_by_pattern(temp_dir, pattern) do files = Path.wildcard(Path.join(temp_dir, pattern)) result = Enum.reduce_while(files, {:ok, %{}}, fn file_path, {:ok, acc} -> relative_path = Path.relative_to(file_path, temp_dir) case load_and_parse_xml(temp_dir, relative_path) do {:ok, xml} -> {:cont, {:ok, Map.put(acc, relative_path, xml)}} {:error, _reason} = error -> {:halt, error} end end) result end # Loads an optional XML file (returns nil if missing) @spec load_optional_xml(Path.t(), String.t()) :: {:ok, Xml.xml_element() | nil} | {:error, term()} defp load_optional_xml(temp_dir, relative_path) do file_path = Path.join(temp_dir, relative_path) if File.exists?(file_path) do case load_and_parse_xml(temp_dir, relative_path) do {:ok, xml} -> {:ok, xml} {:error, _reason} = error -> error end else {:ok, nil} end end # Loads static files that don't need processing (relationships, content types, media, etc.) @spec load_static_files(Path.t()) :: {:ok, %{String.t() => binary()}} | {:error, term()} defp load_static_files(temp_dir) do # Static files are everything except the XML files we process processable_patterns = [ "word/document.xml", "word/header*.xml", "word/footer*.xml", "word/footnotes.xml", "word/endnotes.xml", "docProps/core.xml", "docProps/app.xml" ] processable_files = processable_patterns |> Enum.flat_map(&Path.wildcard(Path.join(temp_dir, &1))) |> MapSet.new() # Gather all files case gather_files(temp_dir, temp_dir) do {:ok, all_files} -> # Filter out processable XML files static_files = all_files |> Enum.reject(fn {relative_path, _content} -> full_path = Path.join(temp_dir, relative_path) MapSet.member?(processable_files, full_path) end) |> Map.new() {:ok, static_files} {:error, reason} -> {:error, {:load_static_files_failed, reason}} end end # Processes an XML document through the full pipeline @spec process_xml_document(Xml.xml_element(), map()) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_xml_document(xml_doc, data) do with {:ok, conditional_processed} <- process_conditionals(xml_doc, data), {:ok, table_processed} <- process_tables(conditional_processed, data), {:ok, replaced} <- Replacement.replace_in_document(table_processed, data) do process_images_in_memory(replaced, data) end end # Processes a map of XML documents @spec process_xml_map(%{String.t() => Xml.xml_element()}, map()) :: {:ok, %{String.t() => Xml.xml_element()}} | {:error, term()} defp process_xml_map(xml_map, data) do result = Enum.reduce_while(xml_map, {:ok, %{}}, fn {key, xml}, {:ok, acc} -> case process_xml_document(xml, data) do {:ok, processed_xml} -> {:cont, {:ok, Map.put(acc, key, processed_xml)}} {:error, _reason} = error -> {:halt, error} end end) result end # Processes an optional XML document @spec process_optional_xml(Xml.xml_element() | nil, map()) :: {:ok, Xml.xml_element() | nil} | {:error, term()} defp process_optional_xml(nil, _data), do: {:ok, nil} defp process_optional_xml(xml, data), do: process_xml_document(xml, data) # Processes optional property XML (simpler pipeline, no conditionals/tables) @spec process_optional_xml_properties(Xml.xml_element() | nil, map()) :: {:ok, Xml.xml_element() | nil} | {:error, term()} defp process_optional_xml_properties(nil, _data), do: {:ok, nil} defp process_optional_xml_properties(xml, data) do Replacement.replace_in_document(xml, data) end # Validates images in memory (without temp_dir) # For validation, we don't need to actually replace images, just verify they exist and are valid @spec process_images_in_memory(Xml.xml_element(), map()) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_images_in_memory(xml_doc, data) do # Find all placeholder images in the document placeholders = Image.find_placeholder_images(xml_doc) # If no placeholders, return document unchanged if Enum.empty?(placeholders) do {:ok, xml_doc} else # Validate each placeholder without modifying the document case validate_all_image_placeholders(placeholders, data) do :ok -> {:ok, xml_doc} {:error, _} = error -> error end end end # Validates all image placeholders without modifying anything @spec validate_all_image_placeholders([map()], map()) :: :ok | {:error, term()} defp validate_all_image_placeholders([], _data), do: :ok defp validate_all_image_placeholders([placeholder | rest], data) do case validate_single_image_placeholder(placeholder, data) do :ok -> validate_all_image_placeholders(rest, data) {:error, _} = error -> error end end # Validates a single image placeholder @spec validate_single_image_placeholder(map(), map()) :: :ok | {:error, term()} defp validate_single_image_placeholder(placeholder, data) do # Get image path from data image_path = Map.get(data, placeholder.placeholder_name) if is_nil(image_path) do {:error, Ootempl.ImageError.exception( placeholder_name: placeholder.placeholder_name, image_path: nil, reason: :image_not_found_in_data )} else # Validate the image file exists and is supported case Image.validate_image_file(image_path) do :ok -> :ok {:error, reason} -> {:error, Ootempl.ImageError.exception( placeholder_name: placeholder.placeholder_name, image_path: image_path, reason: reason )} end end end # Builds output file map from processed XML and static files @spec build_output_file_map( %{String.t() => binary()}, Xml.xml_element(), %{String.t() => Xml.xml_element()}, %{String.t() => Xml.xml_element()}, Xml.xml_element() | nil, Xml.xml_element() | nil, Xml.xml_element() | nil, Xml.xml_element() | nil ) :: {:ok, Archive.file_map()} | {:error, term()} defp build_output_file_map(static_files, document, headers, footers, footnotes, endnotes, core_props, app_props) do # Serialize all XML back to strings with {:ok, document_xml} <- Xml.serialize(document), {:ok, headers_map} <- serialize_xml_map(headers), {:ok, footers_map} <- serialize_xml_map(footers), {:ok, footnotes_xml} <- serialize_optional(footnotes), {:ok, endnotes_xml} <- serialize_optional(endnotes), {:ok, core_xml} <- serialize_optional(core_props), {:ok, app_xml} <- serialize_optional(app_props) do file_map = static_files |> Map.put("word/document.xml", document_xml) |> Map.merge(headers_map) |> Map.merge(footers_map) |> maybe_put("word/footnotes.xml", footnotes_xml) |> maybe_put("word/endnotes.xml", endnotes_xml) |> maybe_put("docProps/core.xml", core_xml) |> maybe_put("docProps/app.xml", app_xml) {:ok, file_map} end end # Serializes a map of XML elements @spec serialize_xml_map(%{String.t() => Xml.xml_element()}) :: {:ok, %{String.t() => binary()}} | {:error, term()} defp serialize_xml_map(xml_map) do result = Enum.reduce_while(xml_map, {:ok, %{}}, fn {key, xml}, {:ok, acc} -> case Xml.serialize(xml) do {:ok, xml_string} -> {:cont, {:ok, Map.put(acc, key, xml_string)}} {:error, _reason} = error -> {:halt, error} end end) result end # Serializes optional XML @spec serialize_optional(Xml.xml_element() | nil) :: {:ok, binary() | nil} | {:error, term()} defp serialize_optional(nil), do: {:ok, nil} defp serialize_optional(xml), do: Xml.serialize(xml) # Conditionally puts a value in map if not nil @spec maybe_put(map(), String.t(), any()) :: map() defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) @spec validate_output_path(Path.t()) :: :ok | {:error, term()} defp validate_output_path(output_path) do if File.dir?(Path.dirname(output_path)) do :ok else {:error, {:invalid_output_path, "Output directory does not exist: #{Path.dirname(output_path)}"}} end end @spec validate_paths(Path.t(), Path.t()) :: :ok | {:error, term()} defp validate_paths(template_path, output_path) do cond do Path.expand(template_path) == Path.expand(output_path) -> {:error, {:same_file, "Template and output paths must be different"}} not File.dir?(Path.dirname(output_path)) -> {:error, {:invalid_output_path, "Output directory does not exist: #{Path.dirname(output_path)}"}} true -> :ok end end @spec build_file_map(Path.t()) :: {:ok, Archive.file_map()} | {:error, term()} defp build_file_map(temp_dir) do # Recursively find all files in temp directory case gather_files(temp_dir, temp_dir) do {:ok, file_map} -> {:ok, file_map} {:error, reason} -> {:error, {:build_file_map_failed, reason}} end end @spec gather_files(Path.t(), Path.t()) :: {:ok, Archive.file_map()} | {:error, term()} defp gather_files(base_dir, current_dir) do case File.ls(current_dir) do {:ok, entries} -> file_map = Enum.reduce(entries, %{}, fn entry, acc -> process_entry(base_dir, current_dir, entry, acc) end) {:ok, file_map} {:error, reason} -> {:error, reason} end end @spec process_entry(Path.t(), Path.t(), String.t(), Archive.file_map()) :: Archive.file_map() defp process_entry(base_dir, current_dir, entry, acc) do full_path = Path.join(current_dir, entry) relative_path = Path.relative_to(full_path, base_dir) cond do File.regular?(full_path) -> add_file_to_map(relative_path, full_path, acc) File.dir?(full_path) -> merge_directory_contents(base_dir, full_path, acc) true -> acc end end @spec add_file_to_map(String.t(), Path.t(), Archive.file_map()) :: Archive.file_map() defp add_file_to_map(relative_path, full_path, acc) do case File.read(full_path) do {:ok, content} -> Map.put(acc, relative_path, content) {:error, _} -> acc end end @spec merge_directory_contents(Path.t(), Path.t(), Archive.file_map()) :: Archive.file_map() defp merge_directory_contents(base_dir, full_path, acc) do case gather_files(base_dir, full_path) do {:ok, nested_map} -> Map.merge(acc, nested_map) {:error, _} -> acc end end # Processes images in an XML document. # # Finds placeholder images (using Image.find_placeholder_images/1), validates image files, # embeds them into the archive, updates relationships and content types, and updates # image references in the document. # # This must run AFTER variable replacement to ensure placeholders in data are resolved first. @spec process_images(Xml.xml_element(), map(), Path.t()) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_images(xml_doc, data, temp_dir) do # Find all placeholder images in the document placeholders = Image.find_placeholder_images(xml_doc) # If no placeholders, return document unchanged if Enum.empty?(placeholders) do {:ok, xml_doc} else # Process each placeholder process_all_image_placeholders(xml_doc, placeholders, data, temp_dir) end end # Processes all image placeholders @spec process_all_image_placeholders( Xml.xml_element(), [map()], map(), Path.t() ) :: {:ok, Xml.xml_element()} | {:error, term()} defp process_all_image_placeholders(xml_doc, placeholders, data, temp_dir) do # Load relationships file rels_path = Path.join(temp_dir, "word/_rels/document.xml.rels") with {:ok, rels_xml} <- load_relationships(rels_path), {:ok, content_types_xml} <- load_content_types(temp_dir), {:ok, modified_doc, updated_rels, updated_types} <- process_each_placeholder(xml_doc, placeholders, data, temp_dir, rels_xml, content_types_xml), :ok <- save_relationships(rels_path, updated_rels), :ok <- save_content_types(temp_dir, updated_types) do {:ok, modified_doc} end end # Processes each placeholder sequentially @spec process_each_placeholder( Xml.xml_element(), [map()], map(), Path.t(), Xml.xml_element(), tuple() ) :: {:ok, Xml.xml_element(), Xml.xml_element(), tuple()} | {:error, term()} defp process_each_placeholder(xml_doc, [], _data, _temp_dir, rels_xml, content_types_xml) do {:ok, xml_doc, rels_xml, content_types_xml} end defp process_each_placeholder(xml_doc, [placeholder | rest], data, temp_dir, rels_xml, content_types_xml) do with {:ok, modified_doc, updated_rels, updated_types} <- process_single_image_placeholder( xml_doc, placeholder, data, temp_dir, rels_xml, content_types_xml ) do process_each_placeholder(modified_doc, rest, data, temp_dir, updated_rels, updated_types) end end # Processes a single image placeholder @spec process_single_image_placeholder( Xml.xml_element(), map(), map(), Path.t(), Xml.xml_element(), tuple() ) :: {:ok, Xml.xml_element(), Xml.xml_element(), tuple()} | {:error, term()} defp process_single_image_placeholder(xml_doc, placeholder, data, temp_dir, rels_xml, content_types_xml) do # Get image path from data image_path = Map.get(data, placeholder.placeholder_name) if is_nil(image_path) do {:error, Ootempl.ImageError.exception( placeholder_name: placeholder.placeholder_name, image_path: nil, reason: :image_not_found_in_data )} else with :ok <- Image.validate_image_file(image_path), {:ok, image_dims} <- Image.get_image_dimensions(image_path), {scaled_width, scaled_height} <- Image.calculate_scaled_dimensions(image_dims, placeholder.template_dimensions), extension = Path.extname(image_path), existing_ids = Relationships.extract_relationship_ids(rels_xml), new_rel_id = Relationships.generate_unique_id(existing_ids), existing_media_files = list_existing_media_files(temp_dir), media_filename = Image.generate_media_filename(existing_media_files, extension), :ok <- embed_image_to_media(temp_dir, image_path, media_filename) do relationship = Relationships.create_image_relationship(new_rel_id, "media/#{media_filename}") updated_rels = Relationships.add_relationship(rels_xml, relationship) mime_type = Image.mime_type_for_extension(extension) updated_types = add_image_content_type(content_types_xml, extension, mime_type) modified_doc = update_image_reference( xml_doc, placeholder, new_rel_id, scaled_width, scaled_height ) {:ok, modified_doc, updated_rels, updated_types} else {:error, atom} when is_atom(atom) -> {:error, Ootempl.ImageError.exception( placeholder_name: placeholder.placeholder_name, image_path: image_path, reason: atom )} {:error, reason} -> {:error, reason} end end end # Loads relationships XML from file @spec load_relationships(Path.t()) :: {:ok, Xml.xml_element()} | {:error, term()} defp load_relationships(rels_path) do with {:ok, xml_content} <- File.read(rels_path), {:ok, rels_xml} <- Relationships.parse_relationships(xml_content) do {:ok, rels_xml} else {:error, reason} -> {:error, {:load_relationships_failed, reason}} end end # Loads content types XML from file @spec load_content_types(Path.t()) :: {:ok, tuple()} | {:error, term()} defp load_content_types(temp_dir) do content_types_path = Path.join(temp_dir, "[Content_Types].xml") with {:ok, xml_content} <- File.read(content_types_path), {:ok, types_xml} <- Image.parse_content_types(xml_content) do {:ok, types_xml} else {:error, reason} -> {:error, {:load_content_types_failed, reason}} end end # Saves relationships XML to file @spec save_relationships(Path.t(), Xml.xml_element()) :: :ok | {:error, term()} defp save_relationships(rels_path, rels_xml) do with {:ok, xml_string} <- Relationships.serialize_relationships(rels_xml), :ok <- File.write(rels_path, xml_string) do :ok else {:error, reason} -> {:error, {:save_relationships_failed, reason}} end end # Saves content types XML to file @spec save_content_types(Path.t(), tuple()) :: :ok | {:error, term()} defp save_content_types(temp_dir, types_xml) do content_types_path = Path.join(temp_dir, "[Content_Types].xml") xml_string = Image.serialize_content_types(types_xml) case File.write(content_types_path, xml_string) do :ok -> :ok {:error, reason} -> {:error, {:save_content_types_failed, reason}} end end # Lists existing media files in the word/media directory @spec list_existing_media_files(Path.t()) :: [String.t()] defp list_existing_media_files(temp_dir) do media_dir = Path.join(temp_dir, "word/media") case File.ls(media_dir) do {:ok, files} -> files {:error, _} -> [] end end # Embeds image file into the word/media directory @spec embed_image_to_media(Path.t(), String.t(), String.t()) :: :ok | {:error, term()} defp embed_image_to_media(temp_dir, image_path, media_filename) do media_dir = Path.join(temp_dir, "word/media") File.mkdir_p(media_dir) destination_path = Path.join(media_dir, media_filename) case File.copy(image_path, destination_path) do {:ok, _bytes} -> :ok {:error, reason} -> {:error, {:copy_image_failed, reason}} end end # Adds image content type to content types XML @spec add_image_content_type(tuple(), String.t(), String.t()) :: tuple() defp add_image_content_type(content_types_xml, extension, mime_type) do # Remove leading dot from extension if present normalized_ext = String.trim_leading(extension, ".") Image.add_content_type(content_types_xml, normalized_ext, mime_type) end # Updates image reference in the document with new relationship ID and dimensions @spec update_image_reference( Xml.xml_element(), map(), String.t(), float(), float() ) :: Xml.xml_element() defp update_image_reference(xml_doc, placeholder, new_rel_id, scaled_width, scaled_height) do # Find the blip element within the drawing blip_element = find_blip_in_drawing(placeholder.xml_element) if is_nil(blip_element) do xml_doc else # Update the r:embed attribute to point to new relationship ID updated_blip = update_blip_relationship(blip_element, new_rel_id) # Find extent element and update dimensions extent_element = find_extent_in_drawing(placeholder.xml_element) updated_extent = if is_nil(extent_element) do nil else update_extent_dimensions(extent_element, scaled_width, scaled_height) end # Replace blip and extent in the document xml_doc |> replace_element_in_tree(blip_element, updated_blip) |> maybe_replace_extent(extent_element, updated_extent) end end # Conditionally replaces extent element if both old and new are present @spec maybe_replace_extent(Xml.xml_element(), tuple() | nil, tuple() | nil) :: Xml.xml_element() defp maybe_replace_extent(xml_doc, nil, _new_extent), do: xml_doc defp maybe_replace_extent(xml_doc, _old_extent, nil), do: xml_doc defp maybe_replace_extent(xml_doc, old_extent, new_extent) do replace_element_in_tree(xml_doc, old_extent, new_extent) end # Finds blip element in a drawing element @spec find_blip_in_drawing(tuple()) :: tuple() | nil defp find_blip_in_drawing(drawing_element) do import Xml case drawing_element do xmlElement(name: name, content: content) -> current_name = name |> Atom.to_string() |> String.split(":") |> List.last() if current_name == "blip" do drawing_element else Enum.find_value(content, &find_blip_in_drawing/1) end _ -> nil end end # Finds extent element in a drawing element @spec find_extent_in_drawing(tuple()) :: tuple() | nil defp find_extent_in_drawing(drawing_element) do import Xml case drawing_element do xmlElement(name: name, content: content) -> current_name = name |> Atom.to_string() |> String.split(":") |> List.last() if current_name == "extent" do drawing_element else Enum.find_value(content, &find_extent_in_drawing/1) end _ -> nil end end # Updates blip element's r:embed attribute @spec update_blip_relationship(tuple(), String.t()) :: tuple() defp update_blip_relationship(blip_element, new_rel_id) do import Xml xmlElement(attributes: attrs) = blip_element # Update or add r:embed attribute updated_attrs = Enum.map(attrs, fn attr -> xmlAttribute(name: name) = attr attr_name_str = Atom.to_string(name) if String.ends_with?(attr_name_str, "embed") do xmlAttribute(attr, value: String.to_charlist(new_rel_id)) else attr end end) xmlElement(blip_element, attributes: updated_attrs) end # Updates extent element's cx and cy attributes (dimensions in EMUs) @spec update_extent_dimensions(tuple(), float(), float()) :: tuple() defp update_extent_dimensions(extent_element, width, height) do import Xml xmlElement(attributes: attrs) = extent_element # Convert to EMUs (English Metric Units): 1 pixel ≈ 9525 EMUs at 96 DPI # However, we should preserve the scale of the original template dimensions # The scaled_width and scaled_height are already in the same units as template_dimensions # So we just need to round them to integers width_emus = round(width) height_emus = round(height) # Update cx and cy attributes updated_attrs = Enum.map(attrs, fn attr -> xmlAttribute(name: name) = attr attr_name = Atom.to_string(name) cond do attr_name == "cx" -> xmlAttribute(attr, value: Integer.to_charlist(width_emus)) attr_name == "cy" -> xmlAttribute(attr, value: Integer.to_charlist(height_emus)) true -> attr end end) xmlElement(extent_element, attributes: updated_attrs) end end