Rendering Pipeline
This guide explains how AshReports renders processed data to different output formats through the rendering pipeline.
Table of Contents
- Pipeline Overview
- RenderContext
- RenderConfig
- RenderPipeline
- Renderer Behaviour
- Format-Specific Renderers
- RendererIntegration
Pipeline Overview
The rendering system transforms processed data into format-specific output:
graph TB
subgraph "Input"
Data[DataLoader Result]
Report[Report Definition]
Config[RenderConfig]
end
subgraph "Context Creation"
RC[RenderContext<br/>Immutable State Container]
end
subgraph "RenderPipeline (6 Stages)"
S1[1. Initialize]
S2[2. Layout]
S3[3. Data Process]
S4[4. Element Render]
S5[5. Assembly]
S6[6. Finalize]
end
subgraph "Format Renderers"
HTML[IrHtmlRenderer]
JSON[JsonRenderer]
HEEX[HeexRenderer]
PDF[PdfRenderer]
end
subgraph "Output"
OH[HTML Document]
OJ[JSON Data]
OX[HEEX Template]
OP[PDF File]
end
Data --> RC
Report --> RC
Config --> RC
RC --> S1
S1 --> S2
S2 --> S3
S3 --> S4
S4 --> S5
S5 --> S6
S6 --> HTML
S6 --> JSON
S6 --> HEEX
S6 --> PDF
HTML --> OH
JSON --> OJ
HEEX --> OX
PDF --> OPRenderContext
Location: lib/ash_reports/renderers/render_context.ex
RenderContext is the central immutable state container that carries all information through the rendering pipeline.
Structure
defmodule AshReports.RenderContext do
defstruct [
# Report definition
:report,
# Data from DataLoader
:data_result,
:records,
:variables,
:groups,
# Configuration
:config,
# Current state during rendering
:current_record,
:current_record_index,
:current_band,
:current_group,
:current_group_level,
# Layout state
:layout_state,
:current_position,
:page_dimensions,
# Rendered content
:rendered_elements,
:pending_elements,
# Localization
:locale,
:text_direction,
:locale_metadata,
# Error tracking
:errors,
:warnings
]
endCreating Context
def new(report, data_result, config) do
%__MODULE__{
report: report,
data_result: data_result,
records: data_result.records,
variables: data_result.variables,
groups: data_result.groups,
config: config,
current_record: nil,
current_record_index: 0,
current_band: nil,
layout_state: %{},
current_position: %{x: 0, y: 0},
page_dimensions: page_dimensions(config),
rendered_elements: [],
pending_elements: [],
locale: config.locale || "en",
text_direction: text_direction(config.locale),
errors: [],
warnings: []
}
endContext Updates
Context is immutable - updates return new structs:
def set_current_record(context, record, index) do
%{context | current_record: record, current_record_index: index}
end
def set_current_band(context, band) do
%{context | current_band: band}
end
def add_rendered_element(context, element) do
%{context | rendered_elements: [element | context.rendered_elements]}
end
def add_error(context, error) do
%{context | errors: [error | context.errors]}
endAccessing Data
def get_field_value(context, field_name) do
case context.current_record do
nil -> nil
record -> Map.get(record, field_name)
end
end
def get_variable_value(context, variable_name) do
Map.get(context.variables, variable_name)
end
def get_group_value(context, group_name) do
Map.get(context.groups, group_name)
endRenderConfig
Location: lib/ash_reports/renderers/render_config.ex
RenderConfig holds all configuration for rendering.
Structure
defmodule AshReports.RenderConfig do
defstruct [
# Output format
format: :html,
# Page layout
page_size: :a4,
orientation: :portrait,
margins: %{top: 20, right: 20, bottom: 20, left: 20},
# Typography
font_family: "Arial",
font_size: 12,
line_height: 1.5,
# Colors
primary_color: "#333333",
background_color: "#ffffff",
# Performance
streaming: false,
batch_size: 100,
memory_limit: nil,
# Localization
locale: "en",
timezone: "UTC",
# Debug
debug: false,
include_metadata: false
]
endPreset Configurations
def for_large_dataset do
%__MODULE__{
streaming: true,
batch_size: 500,
memory_limit: 100_000_000 # 100MB
}
end
def for_debugging do
%__MODULE__{
debug: true,
include_metadata: true
}
end
def for_production do
%__MODULE__{
streaming: true,
debug: false,
include_metadata: false
}
endRenderPipeline
Location: lib/ash_reports/renderers/render_pipeline.ex
RenderPipeline orchestrates the 6-stage rendering process.
Pipeline Stages
graph TD
subgraph "Stage 1: Initialize"
I1[Validate Context]
I2[Reset State]
I3[Setup Renderer]
end
subgraph "Stage 2: Layout"
L1[Calculate Band Positions]
L2[Element Positioning]
L3[Detect Overflows]
end
subgraph "Stage 3: Data Process"
D1[Iterate Records]
D2[Set Current Record]
D3[Resolve Variables]
end
subgraph "Stage 4: Element Render"
E1[Render Individual Elements]
E2[Format Values]
E3[Apply Styles]
end
subgraph "Stage 5: Assembly"
A1[Call Renderer]
A2[Format-Specific Logic]
A3[Generate Output]
end
subgraph "Stage 6: Finalize"
F1[Generate Metadata]
F2[Cleanup Resources]
F3[Return Result]
end
I1 --> I2 --> I3
I3 --> L1 --> L2 --> L3
L3 --> D1 --> D2 --> D3
D3 --> E1 --> E2 --> E3
E3 --> A1 --> A2 --> A3
A3 --> F1 --> F2 --> F3Implementation
defmodule AshReports.RenderPipeline do
def execute(context, renderer) do
context
|> stage_initialize(renderer)
|> stage_layout()
|> stage_data_process()
|> stage_element_render()
|> stage_assembly(renderer)
|> stage_finalize()
end
defp stage_initialize(context, renderer) do
with :ok <- validate_context(context),
:ok <- renderer.validate_context(context),
{:ok, context} <- renderer.prepare(context) do
{:ok, context}
end
end
defp stage_layout({:ok, context}) do
layout_state = AshReports.LayoutEngine.calculate(context)
{:ok, %{context | layout_state: layout_state}}
end
defp stage_data_process({:ok, context}) do
processed =
Enum.reduce(context.records, context, fn record, ctx ->
ctx
|> set_current_record(record, ctx.current_record_index + 1)
|> resolve_variables_for_record()
end)
{:ok, processed}
end
defp stage_element_render({:ok, context}) do
rendered =
Enum.reduce(context.report.bands, context, fn band, ctx ->
render_band(ctx, band)
end)
{:ok, rendered}
end
defp stage_assembly({:ok, context}, renderer) do
case renderer.render_with_context(context) do
{:ok, content} -> {:ok, context, content}
{:error, error} -> {:error, error}
end
end
defp stage_finalize({:ok, context, content}) do
{:ok, %{
content: content,
metadata: build_metadata(context),
context: context
}}
end
endError Handling Strategies
defmodule AshReports.RenderPipeline do
@strategies [:fail_fast, :continue_on_error, :collect_errors]
def execute(context, renderer, opts \\ []) do
strategy = Keyword.get(opts, :error_strategy, :fail_fast)
case strategy do
:fail_fast -> execute_fail_fast(context, renderer)
:continue_on_error -> execute_continue(context, renderer)
:collect_errors -> execute_collect(context, renderer)
end
end
endRenderer Behaviour
Location: lib/ash_reports/renderers/renderer.ex
All renderers implement this behaviour:
defmodule AshReports.Renderer do
@callback render_with_context(RenderContext.t()) ::
{:ok, String.t()} | {:error, term()}
@callback supports_streaming?() :: boolean()
@callback file_extension() :: String.t()
@callback content_type() :: String.t()
# Optional callbacks
@optional_callbacks validate_context: 1, prepare: 1, cleanup: 2
@callback validate_context(RenderContext.t()) :: :ok | {:error, term()}
@callback prepare(RenderContext.t()) :: {:ok, RenderContext.t()} | {:error, term()}
@callback cleanup(RenderContext.t(), term()) :: :ok
endImplementing a Renderer
defmodule MyApp.CustomRenderer do
@behaviour AshReports.Renderer
@impl true
def render_with_context(context) do
content = build_output(context)
{:ok, content}
end
@impl true
def supports_streaming?, do: false
@impl true
def file_extension, do: ".custom"
@impl true
def content_type, do: "application/x-custom"
@impl true
def validate_context(context) do
# Custom validation
:ok
end
endFormat-Specific Renderers
IrHtmlRenderer
Location: lib/ash_reports/renderers/ir_html_renderer.ex
Renders Layout IR to HTML with CSS Grid:
defmodule AshReports.Renderers.IrHtmlRenderer do
@behaviour AshReports.Renderer
@impl true
def render_with_context(context) do
ir = AshReports.Layout.Transformer.to_ir(context)
html =
ir
|> render_document()
|> wrap_with_styles(context.config)
{:ok, html}
end
defp render_document(ir) do
"""
<!DOCTYPE html>
<html>
<head>#{render_head(ir)}</head>
<body>#{render_body(ir)}</body>
</html>
"""
end
defp render_body(ir) do
ir.bands
|> Enum.map(&render_band/1)
|> Enum.join("\n")
end
defp render_band(%{type: type, elements: elements}) do
"""
<div class="band band-#{type}">
#{render_elements(elements)}
</div>
"""
end
endJsonRenderer
Location: lib/ash_reports/renderers/json_renderer/json_renderer.ex
Serializes Layout IR to JSON:
defmodule AshReports.Renderers.JsonRenderer do
@behaviour AshReports.Renderer
@impl true
def render_with_context(context) do
ir = AshReports.Layout.Transformer.to_ir(context)
json_data = %{
report: serialize_report(context.report),
bands: Enum.map(ir.bands, &serialize_band/1),
records: context.records,
variables: context.variables,
groups: serialize_groups(context.groups),
metadata: build_metadata(context)
}
{:ok, Jason.encode!(json_data)}
end
defp serialize_band(band) do
%{
name: band.name,
type: band.type,
elements: Enum.map(band.elements, &serialize_element/1)
}
end
endHeexRenderer
Location: lib/ash_reports/renderers/heex_renderer/heex_renderer.ex
Generates Phoenix HEEX templates:
defmodule AshReports.Renderers.HeexRenderer do
@behaviour AshReports.Renderer
@impl true
def render_with_context(context) do
template = generate_heex_template(context)
{:ok, template}
end
defp generate_heex_template(context) do
"""
<div class="report" id="report-#{context.report.name}">
<%= for band <- @bands do %>
<.band band={band} records={@records} />
<% end %>
</div>
"""
end
endPdfRenderer / Typst
Location: lib/ash_reports/renderers/pdf_renderer.ex
Generates PDF via Typst:
defmodule AshReports.Renderers.PdfRenderer do
@behaviour AshReports.Renderer
@impl true
def render_with_context(context) do
typst_content = AshReports.Typst.DSLGenerator.generate(context)
AshReports.Typst.BinaryWrapper.compile(typst_content)
end
endRendererIntegration
Location: lib/ash_reports/renderers/renderer_integration.ex
Bridges DataLoader and Rendering:
defmodule AshReports.RendererIntegration do
def render_report(domain, report_name, params, opts \\ []) do
format = Keyword.get(opts, :format, :html)
renderer = get_renderer(format)
with {:ok, data_result} <- DataLoader.load_report(domain, report_name, params, opts),
{:ok, report} <- get_report(domain, report_name),
config <- build_config(opts),
context <- RenderContext.new(report, data_result, config),
{:ok, result} <- RenderPipeline.execute(context, renderer) do
{:ok, result}
end
end
defp get_renderer(:html), do: AshReports.Renderers.IrHtmlRenderer
defp get_renderer(:json), do: AshReports.Renderers.JsonRenderer
defp get_renderer(:heex), do: AshReports.Renderers.HeexRenderer
defp get_renderer(:pdf), do: AshReports.Renderers.PdfRenderer
endUsage
# Render to HTML
{:ok, result} = AshReports.RendererIntegration.render_report(
MyApp.Reporting,
:sales_report,
%{year: 2024},
format: :html
)
# Access output
result.content # HTML string
result.metadata # %{render_time: 150, format: :html, ...}
result.context # Final RenderContext
# Render to PDF
{:ok, pdf_result} = AshReports.RendererIntegration.render_report(
MyApp.Reporting,
:sales_report,
%{year: 2024},
format: :pdf
)
File.write!("report.pdf", pdf_result.content)Next Steps
- Layout System - Layout computation and IR
- Chart System - Chart rendering
- PDF Generation - Typst integration