Current section
Files
Jump to
Current section
Files
lib/mead/node.ex
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 `<text>`
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