defmodule Mead.Node do @moduledoc """ An element in the document tree. Two kinds exist during the spike: `:view` (a flex container) and `:text` (a leaf whose content is measured, wrapped, and drawn). """ alias Mead.Style defstruct kind: :view, style: %Style{}, content: nil, children: [], source: nil, asset: nil, columns: nil, counter: nil, href: nil, bookmark: nil, bookmark_level: 1, break_before: false, keep_with_next: false, wrap: true, repeat: false, repeat_after: false @typedoc """ Document props: * `href` - external link URI; the node's box becomes a clickable link annotation in the PDF. * `bookmark` / `bookmark_level` - adds an outline (bookmark) entry pointing at this node; the tree nests by level like HTML headings. Pagination props: * `break_before: true` - force a page break before this node. * `break_before: :avoid` - avoid a page break directly before this node (CSS `break-before: avoid`): the break relocates to the latest earlier opportunity — inside the previous sibling, between earlier siblings, or before the whole container. Sugar for `keep_with_next: true` on the preceding sibling; ignored on a first child (v1: no cross-container chains). * `keep_with_next: true` - keep this node on the same page as (the start of) its next sibling: a break directly after it relocates earlier, as with `break_before: :avoid`. When no earlier break opportunity exists on the page, the break happens anyway — progress is never sacrificed. * `wrap: false` - atomic: never split across pages; moves whole to the next page when it doesn't fit (unless already at the top of a page). * `repeat: true` - when this node's parent is split across pages, this child is re-emitted at the top of each continuation fragment (table-header semantics). * `repeat_after: true` - re-emitted after the fragment's kept children on every page that shows them, when it fits — rows are never sacrificed for it, and only the presentational clones are omitted: the node itself always renders, flowing onto a continuation page when it can't follow the last child (table-footer semantics). Table kinds (`:table`, `:table_header`, `:table_body`, `:table_footer`, `:table_row`, `:table_cell`) are desugared to views by `Mead.Table` before layout; `columns` holds the table's column track specs. """ @type track :: number() | {:fr, number()} @typedoc "Barcode symbologies supported by `barcode/2`." @type barcode_format :: :qr | :aztec | :data_matrix | :pdf417 | :code128 | :code39 | :code93 | :codabar | :ean8 | :ean13 | :upc_a | :upc_e | :itf | :telepen @typedoc "Normalized barcode spec held in `:source` for `:barcode` nodes." @type barcode_source :: {String.t(), barcode_format(), String.t() | nil, non_neg_integer() | nil} @type t :: %__MODULE__{ kind: :view | :text | :span | :image | :barcode | :table | :table_header | :table_body | :table_footer | :table_row | :table_cell, style: Style.t(), content: String.t() | nil, children: [t()], source: Path.t() | {:binary, binary()} | barcode_source() | nil, asset: non_neg_integer() | nil, columns: [track()] | nil, counter: :page | :pages | nil, href: String.t() | nil, bookmark: String.t() | nil, bookmark_level: pos_integer(), break_before: boolean() | :avoid, keep_with_next: boolean(), wrap: boolean(), repeat: boolean(), repeat_after: boolean() } @node_attrs [ :break_before, :keep_with_next, :wrap, :repeat, :repeat_after, :href, :bookmark, :bookmark_level ] @doc "Builds a `:view` container node." @spec view([t()], keyword()) :: t() def view(children \\ [], attrs \\ []) do {node_attrs, style_attrs} = Keyword.split(attrs, @node_attrs) %__MODULE__{kind: :view, children: children} |> struct!(node_attrs) |> Map.put(:style, struct!(Style, style_attrs)) end @doc """ Builds a `:text` leaf node. `content` is a string, or — for rich text — a list of `span/2` nodes and plain strings (strings become unstyled spans inheriting the text node's properties). """ @spec text(String.t() | [t() | String.t()], keyword()) :: t() def text(content, attrs \\ []) def text(content, attrs) when is_binary(content) do {node_attrs, style_attrs} = Keyword.split(attrs, @node_attrs) %__MODULE__{kind: :text, content: content} |> struct!(node_attrs) |> Map.put(:style, struct!(Style, style_attrs)) end def text(spans, attrs) when is_list(spans) do {node_attrs, style_attrs} = Keyword.split(attrs, @node_attrs) children = Enum.map(spans, fn %__MODULE__{kind: :span} = span -> span content when is_binary(content) -> span(content) other -> raise ArgumentError, "text spans must be span nodes or strings, got: #{inspect(other)}" end) %__MODULE__{kind: :text, children: children} |> struct!(node_attrs) |> Map.put(:style, struct!(Style, style_attrs)) end @doc """ Builds a `:span` — a styled run inside a rich `text/2` node. Spans inherit unset text properties from their text node and may override `font_family`/`font_weight`/`italic`/`font_size`/`color`. """ @spec span(String.t(), keyword()) :: t() def span(content, attrs \\ []) do %__MODULE__{kind: :span, content: content, style: struct!(Style, attrs)} end @doc """ Builds a counter span: renders as the current page number. Only valid inside `text/2` in a page `:header`/`:footer` subtree — the value is substituted after pagination. The `"0"` placeholder content sizes the chrome before substitution. """ @spec page_number(keyword()) :: t() def page_number(attrs \\ []), do: %{span("0", attrs) | counter: :page} @doc "Builds a counter span: renders as the total page count (see `page_number/1`)." @spec page_count(keyword()) :: t() def page_count(attrs \\ []), do: %{span("0", attrs) | counter: :pages} @doc """ Builds an `:image` leaf node. `source` is a file path or `{:binary, data}` (PNG/JPEG/GIF/WebP, or an SVG/SVGZ — embedded as vector content, never rasterized; SVG `` resolves against the document's declared fonts). With no explicit `width`/`height` the image renders at its intrinsic size (1px = 0.75pt; for SVG from its `width`/`height` or `viewBox`); with one dimension it scales preserving aspect ratio. """ @spec image(Path.t() | {:binary, binary()}, keyword()) :: t() def image(source, attrs \\ []) do {node_attrs, style_attrs} = Keyword.split(attrs, @node_attrs) %__MODULE__{kind: :image, source: source} |> struct!(node_attrs) |> Map.put(:style, struct!(Style, style_attrs)) end @barcode_formats [ :qr, :aztec, :data_matrix, :pdf417, :code128, :code39, :code93, :codabar, :ean8, :ean13, :upc_a, :upc_e, :itf, :telepen ] @doc """ Builds a `:barcode` leaf node — the value is encoded and drawn as crisp vector modules (no rasterization). Options (all remaining attrs are node/style attrs, as with `image/2`): * `:format` - the symbology (default `:qr`); one of `t:barcode_format/0`. * `:ec_level` - error-correction level; for QR one of `"L"`, `"M"`, `"Q"`, `"H"` (atoms accepted). * `:quiet_zone` - quiet-zone width in modules, overriding the symbology's default (QR reserves 4). `0` disables — the drawn modules then touch the node box edge exactly. Sizing: with no explicit `width`/`height`, 2D symbols render at 2pt per module and 1D strips at 1pt per module wide, 40pt tall. With one dimension, 2D symbols keep their square grid (like images keep aspect ratio); 1D width and height are independent. The modules draw in the node's (inherited) text `color`. """ @spec barcode(String.t(), keyword()) :: t() def barcode(value, attrs \\ []) when is_binary(value) do {format, attrs} = Keyword.pop(attrs, :format, :qr) {ec_level, attrs} = Keyword.pop(attrs, :ec_level) {quiet_zone, attrs} = Keyword.pop(attrs, :quiet_zone) {node_attrs, style_attrs} = Keyword.split(attrs, @node_attrs) format in @barcode_formats || raise ArgumentError, "unknown barcode format #{inspect(format)}; expected one of #{inspect(@barcode_formats)}" ec_level = ec_level && ec_level |> to_string() |> String.upcase() (is_nil(quiet_zone) or (is_integer(quiet_zone) and quiet_zone >= 0)) || raise ArgumentError, "quiet_zone must be a non-negative integer, got: #{inspect(quiet_zone)}" %__MODULE__{kind: :barcode, source: {value, format, ec_level, quiet_zone}} |> struct!(node_attrs) |> Map.put(:style, struct!(Style, style_attrs)) end @doc """ Builds a `:table` node ("measure globally, split locally"). Children are `table_header/2`, `table_body/2`, `table_footer/2` groups and/or bare `table_row/2`s. `:columns` freezes the column tracks for every row: a number is a fixed width in points, `{:fr, n}` a fraction of the remaining width. Rows with more cells than tracks give the extras `{:fr, 1}`; omitting `:columns` makes all columns `{:fr, 1}`. """ @spec table([t()], keyword()) :: t() def table(children, attrs \\ []) do {columns, attrs} = Keyword.pop(attrs, :columns) {node_attrs, style_attrs} = Keyword.split(attrs, @node_attrs) %__MODULE__{kind: :table, children: children, columns: columns} |> struct!(node_attrs) |> Map.put(:style, struct!(Style, style_attrs)) end @doc "Builds a header group: repeats atop every fragment that shows rows." @spec table_header([t()], keyword()) :: t() def table_header(rows, attrs \\ []), do: group(:table_header, rows, attrs) @doc "Builds a body group (semantic only — dissolved into the table's rows)." @spec table_body([t()], keyword()) :: t() def table_body(rows, attrs \\ []), do: group(:table_body, rows, attrs) @doc "Builds a footer group: repeats after each fragment's rows when it fits." @spec table_footer([t()], keyword()) :: t() def table_footer(rows, attrs \\ []), do: group(:table_footer, rows, attrs) @doc "Builds a table row (atomic across pages by default: `wrap: false`)." @spec table_row([t()], keyword()) :: t() def table_row(cells, attrs \\ []), do: group(:table_row, cells, Keyword.put_new(attrs, :wrap, false)) @doc "Builds a table cell (a column-flex container sized by its column track)." @spec table_cell([t()], keyword()) :: t() def table_cell(children, attrs \\ []), do: group(:table_cell, children, attrs) defp group(kind, children, attrs) do {node_attrs, style_attrs} = Keyword.split(attrs, @node_attrs) %__MODULE__{kind: kind, children: children} |> struct!(node_attrs) |> Map.put(:style, struct!(Style, style_attrs)) end end