Skip to main content

lib/pdf/component/barcode.ex

defmodule Pdf.Component.Barcode do
  @moduledoc """
  Barcode PDF component — renders Code 128 barcodes onto a PDF.

  This is a **thin renderer**. All encoding logic (standard and shaped)
  lives in `ex_barcode`. This component only scales and draws.

  ## Standard barcode

      Pdf.Component.Barcode.render(doc, {50, 700}, %{data: "ABC-12345", width: 200})

  ## Shape barcode (creative barcode with silhouette + decorations)

      Pdf.Component.Barcode.render(doc, {50, 700}, %{
        data: "DEMOCAMP", width: 250, height: 100, shape: :rv
      })

  ## Style options

  ### Core
    - `:data` — the string to encode (required)
    - `:width` — barcode width in points (default `200`)
    - `:height` — bar height in points (default `50`, or auto from aspect ratio)
    - `:color` — bar color (default `{0, 0, 0}`)
    - `:background` — optional background color (standard barcodes only)
    - `:quiet_zone` — modules of white space (default `10` standard, `2` shaped)

  ### Text
    - `:show_text` — render data below bars (default `true`)
    - `:font` / `:font_size` / `:text_color` — text styling
    - `:label` — optional title above barcode
    - `:label_font_size` / `:label_color` — label styling

  ### Image
    - `:image` — path, URL, or `{:binary, data}`
    - `:image_size` — `{w, h}` (default `{40, 40}`)
    - `:image_position` — `:left`, `:right`, `:top` (default `:left`)
    - `:image_gap` — gap between image and barcode (default `8`)
    - `:image_border_radius` — rounded clip (default `0`)

  ### Shape (delegated to `ExBarcode.Shape`)
    - `:shape` — predefined shape: `:rv`, `:camper`, `:city`, `:wave`, `:diamond`, `:hill`
    - `:contour_top` — custom top contour `[{x_pct, y_pct}, ...]`
    - `:contour_bottom` — custom bottom contour
    - `:bar_min_height` — minimum bar fraction (default `0.0`)
    - `:decoration_color` — color for decorations (default same as `:color`)
    - `:decoration_stroke_color` — stroke color for stroke decorations (default lighter)
  """

  @default_width 200
  @default_height 50
  @default_color {0, 0, 0}
  @default_font "Helvetica"
  @default_font_size 8

  @kappa 0.5522847498

  def render(doc, {x, y}, style \\ %{}) do
    data = Map.get(style, :data, "")
    shape = Map.get(style, :shape)
    contour_top = Map.get(style, :contour_top)

    if shape || contour_top do
      render_shaped(doc, {x, y}, style, data)
    else
      render_standard(doc, {x, y}, style, data)
    end
  end

  # ── Standard barcode ───────────────────────────────────────────

  defp render_standard(doc, {x, y}, style, data) do
    width = Map.get(style, :width, @default_width)
    height = Map.get(style, :height, @default_height)
    color = Map.get(style, :color, @default_color)
    bg = Map.get(style, :background)
    show_text = Map.get(style, :show_text, true)
    font = Map.get(style, :font, @default_font)
    font_size = Map.get(style, :font_size, @default_font_size)
    text_color = Map.get(style, :text_color, color)
    quiet_zone = Map.get(style, :quiet_zone, 10)
    image = Map.get(style, :image)
    {img_w, img_h} = Map.get(style, :image_size, {40, 40})
    img_pos = Map.get(style, :image_position, :left)
    img_gap = Map.get(style, :image_gap, 8)
    img_radius = Map.get(style, :image_border_radius, 0)
    label = Map.get(style, :label)
    label_fs = Map.get(style, :label_font_size, 10)
    label_color = Map.get(style, :label_color, color)

    case ExBarcode.encode(data) do
      {:ok, bars} ->
        {bar_x, bar_y_start, img_x, img_y} =
          compute_layout(x, y, width, image, {img_w, img_h}, img_pos, img_gap, label, label_fs)

        doc = render_label(doc, label, bar_x, y, font, label_fs, label_color)

        baseline = bar_y_start - height
        total_modules = Enum.sum(bars) + quiet_zone * 2
        module_width = width / total_modules

        doc =
          if bg do
            total_h = if show_text, do: height + font_size + 4, else: height
            doc
            |> Pdf.save_state()
            |> Pdf.set_fill_color(bg)
            |> Pdf.rectangle({bar_x, bar_y_start - total_h}, {width, total_h})
            |> Pdf.fill()
            |> Pdf.restore_state()
          else
            doc
          end

        doc = Pdf.save_state(doc) |> Pdf.set_fill_color(color)

        {doc, _} =
          bars
          |> Enum.with_index()
          |> Enum.reduce({doc, bar_x + quiet_zone * module_width}, fn {modules, i}, {d, offset} ->
            bar_w = modules * module_width

            d =
              if rem(i, 2) == 0 do
                d |> Pdf.rectangle({offset, baseline}, {bar_w, height}) |> Pdf.fill()
              else
                d
              end

            {d, offset + bar_w}
          end)

        doc = Pdf.restore_state(doc)

        doc = render_text(doc, show_text, data, bar_x, baseline, width, font, font_size, text_color)

        if image do
          draw_image(doc, image, {img_x, img_y}, {img_w, img_h}, img_radius)
        else
          doc
        end

      {:error, _} ->
        doc
    end
  end

  # ── Shaped barcode ─────────────────────────────────────────────

  defp render_shaped(doc, {x, y}, style, data) do
    width = Map.get(style, :width, @default_width)
    color = Map.get(style, :color, @default_color)
    bg = Map.get(style, :background, {1, 1, 1})
    show_text = Map.get(style, :show_text, true)
    font = Map.get(style, :font, @default_font)
    font_size = Map.get(style, :font_size, @default_font_size)
    text_color = Map.get(style, :text_color, color)
    label = Map.get(style, :label)
    label_fs = Map.get(style, :label_font_size, 10)
    label_color = Map.get(style, :label_color, color)
    deco_color = Map.get(style, :decoration_color, color)
    deco_stroke = Map.get(style, :decoration_stroke_color, lighten(color, 0.3))

    shape_opts = [
      shape: Map.get(style, :shape),
      contour_top: Map.get(style, :contour_top),
      contour_bottom: Map.get(style, :contour_bottom),
      quiet_zone: Map.get(style, :quiet_zone, 2),
      bar_min: Map.get(style, :bar_min_height, 0.0)
    ]

    case ExBarcode.encode_shaped(data, shape_opts) do
      {:ok, result} ->
        height = Map.get(style, :height, width / result.aspect_ratio)
        baseline = y - height

        doc = render_label(doc, label, x, y, font, label_fs, label_color)

        case result.style do
          :silhouette ->
            render_silhouette(doc, result, x, baseline, width, height, color, bg, deco_color, deco_stroke)

          :contour ->
            render_contour(doc, result, x, baseline, width, height, color, deco_color, deco_stroke)
        end
        |> render_text(show_text, data, x, baseline, width, font, font_size, text_color)

      {:error, _} ->
        doc
    end
  end

  # ── Silhouette renderer ──────────────────────────────────────
  # 1. Fill silhouette polygons (barcode color)
  # 2. Fill barcode region (background color = clear)
  # 3. Draw bars inside region (barcode color)
  # 4. Draw cutouts (background color details on solid areas)
  # 5. Draw decorations (barcode color details)

  defp render_silhouette(doc, result, x, baseline, w, h, color, bg, deco_color, deco_stroke) do
    # 1. Draw silhouette polygons
    doc =
      Enum.reduce(result.silhouette, doc, fn polygon, d ->
        scaled = Enum.map(polygon, fn {px, py} -> {x + px * w, baseline + py * h} end)
        render_polygon_fill(d, scaled, color)
      end)

    # 2. Clear barcode region with background
    doc =
      case result.barcode_region do
        {rx, ry, rw, rh} ->
          doc
          |> Pdf.save_state()
          |> Pdf.set_fill_color(bg)
          |> Pdf.rectangle({x + rx * w, baseline + ry * h}, {rw * w, rh * h})
          |> Pdf.fill()
          |> Pdf.restore_state()

        _ ->
          doc
      end

    # 3. Draw bars
    doc = Pdf.save_state(doc) |> Pdf.set_fill_color(color)

    doc =
      Enum.reduce(result.bars, doc, fn bar, d ->
        bx = x + bar.x * w
        by = baseline + bar.y * h
        bw = bar.w * w
        bh = bar.h * h

        if bh > 0.3 do
          d |> Pdf.rectangle({bx, by}, {bw, bh}) |> Pdf.fill()
        else
          d
        end
      end)

    doc = Pdf.restore_state(doc)

    # 4. Draw cutouts (background-colored shapes on solid areas)
    doc = render_cutouts(doc, result.cutouts, x, baseline, w, h, bg)

    # 5. Draw decorations
    render_decorations(doc, result.decorations, x, baseline, w, h, deco_color, deco_stroke)
  end

  # ── Contour renderer (existing behavior) ─────────────────────

  defp render_contour(doc, result, x, baseline, w, h, color, deco_color, deco_stroke) do
    doc = Pdf.save_state(doc) |> Pdf.set_fill_color(color)

    doc =
      Enum.reduce(result.bars, doc, fn bar, d ->
        bx = x + bar.x * w
        by = baseline + bar.y * h
        bw = bar.w * w
        bh = bar.h * h

        if bh > 0.5 do
          d |> Pdf.rectangle({bx, by}, {bw, bh}) |> Pdf.fill()
        else
          d
        end
      end)

    doc = Pdf.restore_state(doc)

    render_decorations(doc, result.decorations, x, baseline, w, h, deco_color, deco_stroke)
  end

  # ── Decoration renderer ────────────────────────────────────────

  defp render_decorations(doc, decorations, x, baseline, w, h, fill_color, stroke_color) do
    Enum.reduce(decorations, doc, fn deco, d ->
      case deco do
        {:circle, {cx, cy, r}} ->
          render_circle(d, x + cx * w, baseline + cy * h, r * min(w, h), fill_color)

        {:circle_stroke, {cx, cy, r}} ->
          render_circle_stroke(d, x + cx * w, baseline + cy * h, r * min(w, h), stroke_color)

        {:polygon, points} ->
          scaled = Enum.map(points, fn {px, py} -> {x + px * w, baseline + py * h} end)
          render_polygon_stroke(d, scaled, stroke_color)

        {:line, {x1, y1, x2, y2}} ->
          d
          |> Pdf.save_state()
          |> Pdf.set_stroke_color(fill_color)
          |> Pdf.set_line_width(0.8)
          |> Pdf.line({x + x1 * w, baseline + y1 * h}, {x + x2 * w, baseline + y2 * h})
          |> Pdf.stroke()
          |> Pdf.restore_state()

        _ ->
          d
      end
    end)
  end

  defp render_circle(doc, cx, cy, r, color) do
    k = r * @kappa

    doc
    |> Pdf.save_state()
    |> Pdf.set_fill_color(color)
    |> Pdf.move_to({cx + r, cy})
    |> Pdf.curve_to({cx + r, cy + k}, {cx + k, cy + r}, {cx, cy + r})
    |> Pdf.curve_to({cx - k, cy + r}, {cx - r, cy + k}, {cx - r, cy})
    |> Pdf.curve_to({cx - r, cy - k}, {cx - k, cy - r}, {cx, cy - r})
    |> Pdf.curve_to({cx + k, cy - r}, {cx + r, cy - k}, {cx + r, cy})
    |> Pdf.close_path()
    |> Pdf.fill()
    |> Pdf.restore_state()
  end

  defp render_circle_stroke(doc, cx, cy, r, color) do
    k = r * @kappa

    doc
    |> Pdf.save_state()
    |> Pdf.set_stroke_color(color)
    |> Pdf.set_line_width(0.5)
    |> Pdf.move_to({cx + r, cy})
    |> Pdf.curve_to({cx + r, cy + k}, {cx + k, cy + r}, {cx, cy + r})
    |> Pdf.curve_to({cx - k, cy + r}, {cx - r, cy + k}, {cx - r, cy})
    |> Pdf.curve_to({cx - r, cy - k}, {cx - k, cy - r}, {cx, cy - r})
    |> Pdf.curve_to({cx + k, cy - r}, {cx + r, cy - k}, {cx + r, cy})
    |> Pdf.close_path()
    |> Pdf.stroke()
    |> Pdf.restore_state()
  end

  defp render_polygon_fill(doc, points, color) do
    case points do
      [first | rest] ->
        doc =
          doc
          |> Pdf.save_state()
          |> Pdf.set_fill_color(color)
          |> Pdf.move_to(first)

        doc = Enum.reduce(rest, doc, fn pt, d -> Pdf.line_append(d, pt) end)

        doc
        |> Pdf.close_path()
        |> Pdf.fill()
        |> Pdf.restore_state()

      _ ->
        doc
    end
  end

  # ── Cutout renderer (background-colored shapes on solid areas) ──

  defp render_cutouts(doc, cutouts, x, baseline, w, h, bg_color) do
    Enum.reduce(cutouts, doc, fn cutout, d ->
      case cutout do
        {:circle_bg, {cx, cy, r}} ->
          render_circle(d, x + cx * w, baseline + cy * h, r * min(w, h), bg_color)

        {:polygon_bg, points} ->
          scaled = Enum.map(points, fn {px, py} -> {x + px * w, baseline + py * h} end)
          render_polygon_fill(d, scaled, bg_color)

        _ ->
          d
      end
    end)
  end

  defp render_polygon_stroke(doc, points, color) do
    case points do
      [first | rest] ->
        doc =
          doc
          |> Pdf.save_state()
          |> Pdf.set_stroke_color(color)
          |> Pdf.set_line_width(0.5)
          |> Pdf.move_to(first)

        doc = Enum.reduce(rest, doc, fn pt, d -> Pdf.line_append(d, pt) end)

        doc
        |> Pdf.close_path()
        |> Pdf.stroke()
        |> Pdf.restore_state()

      _ ->
        doc
    end
  end

  # ── Shared helpers ─────────────────────────────────────────────

  defp render_label(doc, nil, _x, _y, _font, _fs, _color), do: doc

  defp render_label(doc, label, x, y, font, fs, color) do
    doc
    |> Pdf.set_font(font, fs, bold: true)
    |> Pdf.set_fill_color(color)
    |> Pdf.text_at({x, y - fs}, label)
  end

  defp render_text(doc, false, _data, _x, _baseline, _w, _font, _fs, _color), do: doc

  defp render_text(doc, true, data, x, baseline, width, font, font_size, text_color) do
    text_y = baseline - font_size - 2
    text_w = String.length(data) * font_size * 0.6
    text_x = x + (width - text_w) / 2

    doc
    |> Pdf.set_font(font, font_size)
    |> Pdf.set_fill_color(text_color)
    |> Pdf.text_at({text_x, text_y}, data)
  end

  defp compute_layout(x, y, width, image, {img_w, img_h}, img_pos, img_gap, label, label_fs) do
    label_offset = if label, do: label_fs + 4, else: 0

    if image do
      case img_pos do
        :left -> {x + img_w + img_gap, y - label_offset, x, y - label_offset}
        :right -> {x, y - label_offset, x + width + img_gap, y - label_offset}
        :top -> {x, y - img_h - img_gap - label_offset, x + (width - img_w) / 2, y - label_offset}
        _ -> {x + img_w + img_gap, y - label_offset, x, y - label_offset}
      end
    else
      {x, y - label_offset, x, y}
    end
  end

  defp draw_image(doc, image, {ix, iy}, {iw, ih}, radius) do
    img_bottom = iy - ih

    if radius > 0 do
      doc
      |> Pdf.save_state()
      |> Pdf.rounded_rectangle({ix, img_bottom}, {iw, ih}, radius)
      |> Pdf.clip()
      |> Pdf.add_image({ix, img_bottom}, image, width: iw, height: ih)
      |> Pdf.restore_state()
    else
      Pdf.add_image(doc, {ix, img_bottom}, image, width: iw, height: ih)
    end
  end

  defp lighten({r, g, b}, amount) do
    {r + (1 - r) * amount, g + (1 - g) * amount, b + (1 - b) * amount}
  end
end