defmodule Pdf.Component.QrCode do @moduledoc """ QR Code PDF component — renders a QR code at a given position. ## Usage via Builder %{type: :qr, props: %{ data: "https://example.com", style: %{ position: {50, 700}, size: 120, ec_level: :h, logo: "https://example.com/icon.png", logo_size: 30, label: "Scan me" } }} ## Style options ### Core - `:data` — the string to encode (required) - `:size` — QR code size in points, square (default `100`) - `:ec_level` — error correction: `:l`, `:m` (default), `:q`, `:h` - `:color` — module (dark) color (default `{0, 0, 0}`) - `:background` — light module color (default `{1, 1, 1}`) - `:quiet_zone` — number of quiet zone modules (default `2`) ### Border & padding - `:border` — border width (default `0`) - `:border_color` — border stroke color (default `{0.8, 0.8, 0.8}`) - `:border_radius` — corner radius (default `0`) - `:border_fill` — fill color for border area / card background (default `nil`) - `:padding` — extra padding between border and QR (default `0`) ### Logo / icon overlay (center of QR) - `:logo` — image path, URL, or `{:binary, data}` (default `nil`) - `:logo_size` — logo width & height in points (default 20% of `:size`) - `:logo_background` — background behind logo (default `:background`) - `:logo_padding` — padding around logo image (default `3`) - `:logo_border_radius` — rounded clip for logo area (default `4`) ### Label - `:label` — text rendered below the QR code (default `nil`) - `:label_font` — font name (default `"Helvetica"`) - `:label_font_size` — font size (default `8`) - `:label_color` — text color (default same as `:color`) - `:label_bold` — bold label (default `false`) """ @default_size 100 @default_color {0, 0, 0} @default_background {1, 1, 1} @default_quiet_zone 2 @default_font "Helvetica" @doc """ Render a QR code onto the PDF document. `{x, y}` is the top-left corner. The QR code grows downward. """ def render(doc, {x, y}, style \\ %{}) do data = Map.get(style, :data, "") size = Map.get(style, :size, @default_size) ec_level = Map.get(style, :ec_level, :m) color = Map.get(style, :color, @default_color) bg = Map.get(style, :background, @default_background) quiet_zone = Map.get(style, :quiet_zone, @default_quiet_zone) padding = Map.get(style, :padding, 0) # Border border_w = Map.get(style, :border, 0) border_color = Map.get(style, :border_color, {0.8, 0.8, 0.8}) border_radius = Map.get(style, :border_radius, 0) border_fill = Map.get(style, :border_fill) # Logo logo = Map.get(style, :logo) logo_size = Map.get(style, :logo_size, round(size * 0.2)) logo_bg = Map.get(style, :logo_background, bg) logo_pad = Map.get(style, :logo_padding, 3) logo_radius = Map.get(style, :logo_border_radius, 4) # Label label = Map.get(style, :label) label_font = Map.get(style, :label_font, @default_font) label_fs = Map.get(style, :label_font_size, 8) label_color = Map.get(style, :label_color, color) label_bold = Map.get(style, :label_bold, false) # Total outer size includes padding outer_size = size + padding * 2 case ExQR.encode(data, ec_level) do {:ok, matrix, qr_size} -> total_modules = qr_size + quiet_zone * 2 module_size = size / total_modules # Outer box origin (bottom-left) ox = x oy = y - outer_size # Border fill (card background) doc = if border_fill do doc |> Pdf.save_state() |> Pdf.set_fill_color(border_fill) |> draw_rect({ox, oy}, {outer_size, outer_size}, border_radius) |> Pdf.fill() |> Pdf.restore_state() else doc end # Border stroke doc = if border_w > 0 do doc |> Pdf.save_state() |> Pdf.set_stroke_color(border_color) |> Pdf.set_line_width(border_w) |> draw_rect({ox, oy}, {outer_size, outer_size}, border_radius) |> Pdf.stroke() |> Pdf.restore_state() else doc end # QR area origin (inside padding) qx = x + padding qy = y - padding # QR background doc = doc |> Pdf.save_state() |> Pdf.set_fill_color(bg) |> Pdf.rectangle({qx, qy - size}, {size, size}) |> Pdf.fill() |> Pdf.restore_state() # Draw dark modules doc = Pdf.save_state(doc) |> Pdf.set_fill_color(color) doc = Enum.reduce(0..(qr_size - 1), doc, fn row, d -> Enum.reduce(0..(qr_size - 1), d, fn col, d -> if Map.get(matrix, {row, col}, 0) == 1 do mx = qx + (quiet_zone + col) * module_size my = qy - (quiet_zone + row + 1) * module_size d |> Pdf.rectangle({mx, my}, {module_size, module_size}) |> Pdf.fill() else d end end) end) doc = Pdf.restore_state(doc) # Logo overlay in center doc = if logo do draw_logo(doc, logo, {qx, qy}, size, logo_size, logo_bg, logo_pad, logo_radius) else doc end # Label below QR if label do label_y = oy - label_fs - 2 text_w = String.length(label) * label_fs * 0.55 label_x = x + (outer_size - text_w) / 2 font_opts = if label_bold, do: [bold: true], else: [] doc |> Pdf.set_font(label_font, label_fs, font_opts) |> Pdf.set_fill_color(label_color) |> Pdf.text_at({label_x, label_y}, label) else doc end {:error, _reason} -> doc end end # ── Drawing helpers ──────────────────────────────────────────── defp draw_rect(doc, {x, y}, {w, h}, radius) when radius > 0 do Pdf.rounded_rectangle(doc, {x, y}, {w, h}, radius) end defp draw_rect(doc, {x, y}, {w, h}, _radius) do Pdf.rectangle(doc, {x, y}, {w, h}) end # ── Logo overlay ─────────────────────────────────────────────── defp draw_logo(doc, logo, {qx, qy}, size, logo_size, logo_bg, logo_pad, logo_radius) do # Center of QR cx = qx + size / 2 cy = qy - size / 2 # Logo background area (larger, includes padding) bg_size = logo_size + logo_pad * 2 bg_x = cx - bg_size / 2 bg_y = cy - bg_size / 2 # Draw white/colored background behind logo doc = doc |> Pdf.save_state() |> Pdf.set_fill_color(logo_bg) |> draw_rect({bg_x, bg_y}, {bg_size, bg_size}, logo_radius) |> Pdf.fill() |> Pdf.restore_state() # Draw logo image clipped to rounded rect img_x = cx - logo_size / 2 img_y = cy - logo_size / 2 if logo_radius > 0 do doc |> Pdf.save_state() |> Pdf.rounded_rectangle({img_x, img_y}, {logo_size, logo_size}, max(logo_radius - logo_pad, 2)) |> Pdf.clip() |> Pdf.add_image({img_x, img_y}, logo, width: logo_size, height: logo_size) |> Pdf.restore_state() else Pdf.add_image(doc, {img_x, img_y}, logo, width: logo_size, height: logo_size) end end end