defmodule Pdf.Component.CodeBlock do
@moduledoc """
Code block component for PDF documents.
Renders monospaced text with a background box, optional line numbers,
and syntax-like styling. Designed for code snippets and terminal output.
## Examples
doc |> Pdf.Component.CodeBlock.render({50, 700}, %{width: 400},
"defmodule Hello do\\n def world, do: :ok\\nend")
doc |> Pdf.Component.CodeBlock.render({50, 700}, %{
width: 400,
line_numbers: true,
background: {0.15, 0.15, 0.18}
color: {0.9, 0.9, 0.9}
}, code)
"""
@default_font "Courier"
@default_font_size 9
@default_color {0.2, 0.2, 0.2}
@default_background {0.95, 0.96, 0.97}
@default_border_color {0.85, 0.85, 0.85}
@default_padding 10
@default_line_height 13
@default_border_radius 4
@doc """
Render a code block at `{x, y}`.
## Style options
- `:width` — block width (required)
- `:font` — monospaced font (default `"Courier"`)
- `:font_size` — text size (default `9`)
- `:color` — text color (default dark)
- `:background` — background color (default light gray)
- `:border_color` — border color (default gray)
- `:border_radius` — corner radius (default `4`)
- `:padding` — inner padding (default `10`)
- `:line_height` — spacing between lines (default `13`)
- `:line_numbers` — show line numbers (default `false`)
- `:line_number_color` — line number color (default muted)
- `:title` — optional title/filename above the block
"""
def render(doc, {x, y}, style \\ %{}, code) do
width = Map.get(style, :width, 400)
font = Map.get(style, :font, @default_font)
font_size = Map.get(style, :font_size, @default_font_size)
color = Map.get(style, :color, @default_color)
bg = Map.get(style, :background, @default_background)
border_color = Map.get(style, :border_color, @default_border_color)
border_radius = Map.get(style, :border_radius, @default_border_radius)
padding = Map.get(style, :padding, @default_padding)
line_height = Map.get(style, :line_height, @default_line_height)
line_numbers = Map.get(style, :line_numbers, false)
ln_color = Map.get(style, :line_number_color, {0.6, 0.6, 0.6})
title = Map.get(style, :title)
lines = String.split(code, "\n")
ln_width = if line_numbers, do: String.length("#{length(lines)}") * font_size * 0.6 + 12, else: 0
title_h = if title, do: font_size + padding, else: 0
total_h = title_h + padding + length(lines) * line_height + padding
# Background
doc =
doc
|> Pdf.save_state()
|> Pdf.set_fill_color(bg)
|> Pdf.rounded_rectangle({x, y - total_h}, {width, total_h}, border_radius)
|> Pdf.fill()
|> Pdf.restore_state()
# Border
doc =
doc
|> Pdf.save_state()
|> Pdf.set_stroke_color(border_color)
|> Pdf.set_line_width(0.5)
|> Pdf.rounded_rectangle({x, y - total_h}, {width, total_h}, border_radius)
|> Pdf.stroke()
|> Pdf.restore_state()
# Title bar
{doc, content_y} =
if title do
title_y = y - font_size - 2
doc =
doc
|> Pdf.save_state()
|> Pdf.set_stroke_color(border_color)
|> Pdf.set_line_width(0.5)
|> Pdf.line({x, y - title_h}, {x + width, y - title_h})
|> Pdf.stroke()
|> Pdf.restore_state()
|> Pdf.set_font("Helvetica", font_size, bold: true)
|> Pdf.set_fill_color(color)
|> Pdf.text_at({x + padding, title_y}, title)
{doc, y - title_h}
else
{doc, y}
end
# Line number separator
doc =
if line_numbers and ln_width > 0 do
sep_x = x + ln_width + 4
doc
|> Pdf.save_state()
|> Pdf.set_stroke_color(border_color)
|> Pdf.set_line_width(0.3)
|> Pdf.line({sep_x, content_y - 4}, {sep_x, y - total_h + 4})
|> Pdf.stroke()
|> Pdf.restore_state()
else
doc
end
# Code lines
text_x = x + padding + ln_width + (if line_numbers, do: 8, else: 0)
start_y = content_y - padding - font_size
lines
|> Enum.with_index(1)
|> Enum.reduce(doc, fn {line, num}, d ->
ly = start_y - (num - 1) * line_height
d =
if line_numbers do
ln_text = String.pad_leading("#{num}", String.length("#{length(lines)}"))
d
|> Pdf.set_font(font, font_size)
|> Pdf.set_fill_color(ln_color)
|> Pdf.text_at({x + padding, ly}, ln_text)
else
d
end
d
|> Pdf.set_font(font, font_size)
|> Pdf.set_fill_color(color)
|> Pdf.text_at({text_x, ly}, line)
end)
end
end