defmodule NativeElixirPdfUtilities.HtmlToPdf.CssParser do @moduledoc """ Strict CSS parser for the native HTML-to-PDF renderer. The parser accepts the document-oriented selector subset used by the style cascade: element, class, id, element.class, descendant, child, and comma groups. Simple `@page` rules are accepted outside the style cascade so the renderer can use page size and margin defaults. Declarations are kept as normalized property/value pairs so the style layer can validate values against the renderer's supported property set. """ @type declaration :: {String.t(), String.t()} | {String.t(), String.t(), :important} @type selector_part :: %{ tag: String.t() | nil, id: String.t() | nil, classes: [String.t()], pseudo_classes: [:first_child | :last_child | :root | {:nth_child, pos_integer()}], combinator: nil | :descendant | :child } @type selector :: %{ parts: [selector_part()], specificity: {non_neg_integer(), non_neg_integer(), non_neg_integer()} } @type rule :: %{ selectors: [selector()], declarations: [declaration()], order: non_neg_integer() } @type stylesheet :: [rule()] @type page_option :: {:page_size, :a4 | :letter | {number(), number()}} | {:margin, String.t() | number()} @doc """ Parses a CSS stylesheet into strict renderer rules. """ @spec parse(String.t()) :: {:ok, stylesheet()} | {:error, :invalid_css} def parse(css) do case parse_detailed(css) do {:ok, stylesheet} -> {:ok, stylesheet} {:error, {:invalid_css, _detail}} -> {:error, :invalid_css} end end @doc """ Parses a CSS stylesheet and returns source-location details when parsing fails. """ @spec parse_detailed(String.t()) :: {:ok, stylesheet()} | {:error, {:invalid_css, map()}} def parse_detailed(css) do case css do css when is_binary(css) -> parsed_css = css |> strip_comments() |> strip_page_rules() case parse_rules(parsed_css) do {:ok, stylesheet} -> {:ok, stylesheet} {:error, :invalid_css} -> {:error, {:invalid_css, css_error_detail(css, parsed_css)}} end _ -> {:error, {:invalid_css, %{ stage: :css, reason: :invalid_css, message: "CSS input must be a string" }}} end end @doc """ Extracts renderer page defaults from simple `@page` rules. Supported declarations are single-value `margin` lengths and common `size` values such as `A4`, `A4 landscape`, `letter`, and `letter landscape`. Unsupported page declarations are ignored so normal CSS parsing remains strict for the supported style cascade. """ @spec page_options(String.t()) :: {:ok, [page_option()]} | {:error, :invalid_css} def page_options(css) do case css do css when is_binary(css) -> css |> strip_comments() |> page_rule_blocks() |> Enum.reduce({:ok, []}, fn block, {:ok, acc} -> case parse_declarations(block) do {:ok, declarations} -> {:ok, Keyword.merge(acc, page_options_from(declarations))} {:error, _reason} -> {:ok, acc} end end) _ -> {:error, :invalid_css} end end @doc """ Parses a CSS declaration block into normalized property/value pairs. This is used for both stylesheet blocks and inline `style` attributes. """ @spec parse_declarations(String.t()) :: {:ok, [declaration()]} | {:error, :invalid_css} def parse_declarations(css) do case parse_declarations_detailed(css) do {:ok, declarations} -> {:ok, declarations} {:error, {:invalid_css, _detail}} -> {:error, :invalid_css} end end @doc """ Parses a CSS declaration block and returns source-location details on failure. """ @spec parse_declarations_detailed(String.t()) :: {:ok, [declaration()]} | {:error, {:invalid_css, map()}} def parse_declarations_detailed(css) do case css do css when is_binary(css) -> declarations = css |> String.split(";") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) Enum.reduce_while(declarations, {:ok, []}, fn declaration, {:ok, acc} -> case parse_declaration(declaration) do {:ok, parsed} -> {:cont, {:ok, acc ++ [parsed]}} {:error, :invalid_css} -> {:halt, {:error, {:invalid_css, declaration_error_detail(css, declaration)}}} end end) _ -> {:error, {:invalid_css, %{ stage: :css, reason: :invalid_css, message: "CSS declaration input must be a string" }}} end end defp strip_comments(css) do Regex.replace(~r/\/\*.*?\*\//us, css, "") end defp strip_page_rules(css) do Regex.replace(~r/@page\s*(?:[^{]*)\{[^{}]*\}/ui, css, "") end defp page_rule_blocks(css) do ~r/@page\s*(?:[^{]*)\{(?[^{}]*)\}/ui |> Regex.scan(css, capture: ["declarations"]) |> List.flatten() end defp page_options_from(declarations) do Enum.reduce(declarations, [], fn declaration, acc -> case declaration do {"size", value} -> case page_size_option(value) do nil -> acc page_size -> Keyword.put(acc, :page_size, page_size) end {"margin", value} -> case page_margin_option(value) do nil -> acc margin -> Keyword.put(acc, :margin, margin) end _ -> acc end end) end defp page_size_option(value) do tokens = value |> String.trim() |> String.downcase() |> String.split(~r/\s+/u, trim: true) case tokens do ["a4"] -> :a4 ["a4", "portrait"] -> :a4 ["portrait", "a4"] -> :a4 ["a4", "landscape"] -> {841.89, 595.28} ["landscape", "a4"] -> {841.89, 595.28} ["letter"] -> :letter ["letter", "portrait"] -> :letter ["portrait", "letter"] -> :letter ["letter", "landscape"] -> {792.0, 612.0} ["landscape", "letter"] -> {792.0, 612.0} _ -> nil end end defp page_margin_option(value) do normalized = String.trim(value) cond do normalized == "0" -> 0.0 String.match?(normalized, ~r/^\d+(?:\.\d+)?(?:pt|px|mm|cm|in)$/u) -> normalized true -> nil end end defp parse_rules(css) do case String.trim(css) do "" -> {:ok, []} css -> rule_sources = Regex.scan(~r/[^{}]+\{[^{}]*\}/u, css) |> Enum.map(&List.first/1) unparsed = Regex.replace(~r/[^{}]+\{[^{}]*\}/u, css, "") case String.trim(unparsed) do "" -> sources_to_rules(rule_sources) _ -> {:error, :invalid_css} end end end defp sources_to_rules(rule_sources) do rule_sources |> Enum.with_index() |> Enum.reduce_while({:ok, []}, fn {source, order}, {:ok, acc} -> case parse_rule(source, order) do {:ok, rule} -> {:cont, {:ok, acc ++ [rule]}} {:error, reason} -> {:halt, {:error, reason}} end end) end defp parse_rule(source, order) do captures = Regex.named_captures( ~r/^\s*(?[^{}]+)\{(?[^{}]*)\}\s*$/u, source ) %{"selectors" => selector_source, "declarations" => declaration_source} = captures with {:ok, selectors} <- parse_selectors(selector_source), true <- selectors != [], {:ok, declarations} <- parse_declarations(declaration_source), true <- declarations != [] do {:ok, %{selectors: selectors, declarations: declarations, order: order}} else _ -> {:error, :invalid_css} end end defp parse_selectors(selector_source) do selector_source |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reduce_while({:ok, []}, fn selector, {:ok, acc} -> case parse_selector(selector) do {:ok, parsed} -> {:cont, {:ok, acc ++ [parsed]}} {:error, reason} -> {:halt, {:error, reason}} end end) end defp parse_selector(selector) do tokens = selector |> String.replace(~r/\s*>\s*/u, " > ") |> String.split(~r/\s+/u, trim: true) case tokens do [] -> {:error, :invalid_css} tokens -> selector_tokens_to_parts(tokens) end end defp selector_tokens_to_parts(tokens) do parsed = Enum.reduce_while(tokens, {:ok, [], nil}, fn token, {:ok, parts, pending_combinator} -> cond do token == ">" and (parts == [] or not is_nil(pending_combinator)) -> {:halt, {:error, :invalid_css}} token == ">" -> {:cont, {:ok, parts, :child}} true -> case parse_simple_selector(token) do {:ok, part} -> combinator = case parts do [] -> nil _ -> pending_combinator || :descendant end {:cont, {:ok, parts ++ [Map.put(part, :combinator, combinator)], nil}} {:error, reason} -> {:halt, {:error, reason}} end end end) case parsed do {:ok, _parts, pending_combinator} when not is_nil(pending_combinator) -> {:error, :invalid_css} {:ok, parts, nil} -> {:ok, %{parts: parts, specificity: specificity(parts)}} {:error, reason} -> {:error, reason} end end defp parse_simple_selector(selector) do captures = Regex.named_captures( ~r/^(?\*|[a-zA-Z][a-zA-Z0-9]*)?(?(?:[#.][a-zA-Z_-][a-zA-Z0-9_-]*)*)(?:(?:first-child|last-child|root|nth-child\([1-9]\d*\)))?$/u, selector ) case captures do %{"tag" => tag, "modifiers" => modifiers, "pseudo" => pseudo} -> part = %{ tag: tag_name(tag), id: nil, classes: [], pseudo_classes: pseudo_classes(pseudo), combinator: nil } parse_selector_modifiers(modifiers, part) _ -> {:error, :invalid_css} end end defp parse_selector_modifiers("", part), do: {:ok, part} defp parse_selector_modifiers(modifiers, part) do captures = Regex.scan(~r/([#.])([a-zA-Z_-][a-zA-Z0-9_-]*)/u, modifiers) modifier_captures_to_part(captures, part) end defp modifier_captures_to_part(captures, part) do Enum.reduce_while(captures, {:ok, part}, fn [_, prefix, name], {:ok, acc} -> case {prefix, acc.id} do {"#", nil} -> {:cont, {:ok, %{acc | id: name}}} {"#", _id} -> {:halt, {:error, :invalid_css}} {".", _id} -> {:cont, {:ok, %{acc | classes: acc.classes ++ [name]}}} end end) end defp specificity(parts) do Enum.reduce(parts, {0, 0, 0}, fn part, {ids, classes, elements} -> id_count = if is_nil(part.id), do: 0, else: 1 element_count = if is_nil(part.tag), do: 0, else: 1 class_count = length(part.classes) + length(part.pseudo_classes) {ids + id_count, classes + class_count, elements + element_count} end) end defp pseudo_classes(pseudo) do case pseudo do ":first-child" -> [:first_child] ":last-child" -> [:last_child] ":root" -> [:root] pseudo -> case Regex.named_captures(~r/^:nth-child\((?[1-9]\d*)\)$/u, pseudo || "") do %{"index" => index} -> {index, ""} = Integer.parse(index) [{:nth_child, index}] _ -> [] end end end defp tag_name("") do nil end defp tag_name("*") do nil end defp tag_name(tag) do String.downcase(tag) end defp parse_declaration(declaration) do case String.split(declaration, ":", parts: 2) do [property, value] -> property = property |> String.trim() |> String.downcase() value = String.trim(value) important? = String.match?(value, ~r/\s*!important\s*$/iu) value = value |> String.replace(~r/\s*!important\s*$/iu, "") |> String.trim() case valid_property?(property) and value != "" do true -> case important? do true -> {:ok, {property, value, :important}} false -> {:ok, {property, value}} end false -> {:error, :invalid_css} end _ -> {:error, :invalid_css} end end defp valid_property?(property) do Regex.match?(~r/^[a-z][a-z-]*$/u, property) or Regex.match?(~r/^--[a-zA-Z_][a-zA-Z0-9_-]*$/u, property) end defp css_error_detail(original_css, parsed_css) do parsed_css |> first_css_issue() |> css_issue_to_detail(original_css) end defp first_css_issue(css) do rule_sources = Regex.scan(~r/[^{}]+\{[^{}]*\}/u, css) |> Enum.map(&List.first/1) unparsed = Regex.replace(~r/[^{}]+\{[^{}]*\}/u, css, "") case String.trim(unparsed) do "" -> Enum.find_value(rule_sources, {:stylesheet, String.trim(css)}, &rule_issue/1) unparsed -> {:stylesheet, String.trim(unparsed)} end end defp rule_issue(rule_source) do %{"selectors" => selectors, "declarations" => declarations} = Regex.named_captures( ~r/^\s*(?[^{}]+)\{(?[^{}]*)\}\s*$/u, rule_source ) cond do invalid_selector(selectors) -> {:selector, invalid_selector(selectors)} invalid_declaration(declarations) -> {:declaration, invalid_declaration(declarations)} true -> nil end end defp invalid_selector(selectors) do selectors |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.find(fn selector -> selector == "" or match?({:error, :invalid_css}, parse_selector(selector)) end) end defp invalid_declaration(declarations) do declarations |> String.split(";") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) |> Enum.find(fn declaration -> match?({:error, :invalid_css}, parse_declaration(declaration)) end) |> case do nil -> case String.trim(declarations) do "" -> declarations _ -> nil end declaration -> declaration end end defp css_issue_to_detail({kind, source}, css) do source = String.trim(source) {line, column} = source_location(css, source) %{ stage: :css, reason: :invalid_css, message: css_issue_message(kind, line, source), line: line, column: column, source: source } end defp css_issue_message(kind, line, source) do case kind do :selector -> ~s(line #{line}: selector "#{source}" is invalid or unsupported) :declaration -> ~s(line #{line}: declaration "#{source}" is invalid or unsupported) :stylesheet -> ~s(line #{line}: CSS source "#{source}" is invalid) end end defp declaration_error_detail(css, declaration) do source = String.trim(declaration) {line, column} = source_location(css, source) %{ stage: :css, reason: :invalid_css, message: ~s(line #{line}: declaration "#{source}" is invalid or unsupported), line: line, column: column, source: source } end defp source_location(source, snippet) do case snippet == "" do true -> {1, 1} false -> {index, _length} = :binary.match(source, snippet) prefix = binary_part(source, 0, index) lines = String.split(prefix, "\n", trim: false) line = length(lines) column = String.length(List.last(lines) || "") + 1 {line, column} end end end