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` and `@font-face` rules are accepted outside the style cascade, and `@media print` rules are included in the active print cascade. 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 font_face :: %{ family: String.t(), sources: [String.t()], weight: 100..900, style: :normal | :italic } @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) -> with {:ok, active_css} <- css |> strip_comments() |> active_media_rules(), {:ok, _font_faces} <- parse_font_faces(active_css, css) do parsed_css = active_css |> strip_font_face_rules() |> 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 else {:error, {:invalid_css, detail}} -> {:error, {:invalid_css, detail}} {:error, :invalid_css} -> {:error, {:invalid_css, css_error_detail(css, css)}} end _ -> {:error, {:invalid_css, %{ stage: :css, reason: :invalid_css, message: "CSS input must be a string" }}} end end @doc """ Extracts active local font declarations from `@font-face` rules. Sources must use `url(...)` with a TrueType or OpenType source. Remote URLs, data URIs, WOFF/WOFF2 sources, and unsupported descriptors are rejected. Relative paths are returned unchanged for the style layer to resolve against the stylesheet location or renderer `:base_url`. Supported sources retain their declared order so loading can fall back when an earlier file is unavailable or invalid. """ @spec font_faces(String.t()) :: {:ok, [font_face()]} | {:error, :invalid_css} def font_faces(css) do case css do css when is_binary(css) -> case css |> strip_comments() |> active_media_rules() do {:ok, active_css} -> case parse_font_faces(active_css, active_css) do {:ok, font_faces} -> {:ok, font_faces} {:error, {:invalid_css, _detail}} -> {:error, :invalid_css} end {:error, :invalid_css} -> {:error, :invalid_css} end _ -> {:error, :invalid_css} 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) -> with {:ok, active_css} <- css |> strip_comments() |> active_media_rules() do active_css |> 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) 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 strip_font_face_rules(css) do Regex.replace(~r/@font-face\s*\{[^{}]*\}/ui, css, "") end defp active_media_rules(css) do media_rule = ~r/@media\s+(?[^{}]+)\{(?(?:[^{}]|\{[^{}]*\})*)\}/ui active_css = Regex.replace(media_rule, css, fn _rule, query, body -> query = query |> String.trim() |> String.downcase() case query in ["print", "only print", "all", "only all"] do true -> body false -> "" end end) case Regex.match?(~r/@media\b/ui, active_css) do true -> {:error, :invalid_css} false -> {:ok, active_css} end end defp parse_font_faces(css, diagnostic_css) do ~r/@font-face\s*\{(?[^{}]*)\}/ui |> Regex.scan(css, capture: ["declarations"]) |> List.flatten() |> Enum.reduce_while({:ok, []}, fn block, {:ok, acc} -> case parse_font_face(block) do {:ok, font_face} -> {:cont, {:ok, acc ++ [font_face]}} {:error, {message, source}} -> {:halt, {:error, {:invalid_css, font_face_error_detail(diagnostic_css, message, source)}}} end end) end defp parse_font_face(block) do case parse_declarations_detailed(block) do {:ok, declarations} -> case Enum.find(declarations, &(not supported_font_descriptor?(&1))) do nil -> family = font_family_descriptor(declarations) sources = font_source_descriptor(declarations) weight = font_weight_descriptor(declarations) style = font_style_descriptor(declarations) case {family, sources, weight, style} do {{:ok, family}, {:ok, sources}, {:ok, weight}, {:ok, style}} -> {:ok, %{family: family, sources: sources, weight: weight, style: style}} _ -> error = [ {"font-family", family}, {"src", sources}, {"font-weight", weight}, {"font-style", style} ] |> Enum.find_value(fn {property, result} -> case result do {:error, :invalid_css} -> font_face_descriptor_error(block, property) _ -> nil end end) {:error, error} end declaration -> property = elem(declaration, 0) {:error, font_face_descriptor_error(block, property)} end {:error, {:invalid_css, detail}} -> source = Map.get(detail, :source, "@font-face") {:error, {~s(@font-face declaration "#{source}" is invalid or unsupported), source}} end end defp font_face_descriptor_error(block, property) do source = block |> String.split(";") |> Enum.map(&String.trim/1) |> Enum.find(&Regex.match?(~r/^#{Regex.escape(property)}\s*:/iu, &1)) case source do nil -> {~s(@font-face is missing required "#{property}" descriptor), "@font-face"} source -> {~s(@font-face declaration "#{source}" is invalid or unsupported), source} end end defp font_face_error_detail(css, message, source) do {line, column} = source_location(css, source) %{ stage: :css, reason: :invalid_css, message: "line #{line}: #{message}", line: line, column: column, source: source } end defp supported_font_descriptor?(declaration) do case declaration do {property, _value} when property in ["font-family", "src", "font-weight", "font-style"] -> true {"font-display", value} -> String.downcase(value) in ["auto", "block", "swap", "fallback", "optional"] _ -> false end end defp font_family_descriptor(declarations) do case declarations |> Enum.reverse() |> List.keyfind("font-family", 0) do {"font-family", value} -> family = value |> String.trim() |> String.trim("\"") |> String.trim("'") if family == "", do: {:error, :invalid_css}, else: {:ok, family} _ -> {:error, :invalid_css} end end defp font_source_descriptor(declarations) do case declarations |> Enum.reverse() |> List.keyfind("src", 0) do {"src", value} -> sources = value |> font_source_candidates() |> Enum.flat_map(fn candidate -> captures = Regex.named_captures( ~r/^url\(\s*(?:"(?[^"]+)"|'(?[^']+)'|(?[^)'"\s]+))\s*\)(?:\s+format\(\s*(?:"(?[^"]+)"|'(?[^']+)'|(?[^)'"\s]+))\s*\))?$/ui, candidate ) case captures do captures when is_map(captures) -> source = first_capture(captures, ["double", "single", "bare"]) format = first_capture(captures, ["format_double", "format_single", "format_bare"]) if supported_font_source?(source, format), do: [source], else: [] _ -> [] end end) case sources do [] -> {:error, :invalid_css} sources -> {:ok, sources} end _ -> {:error, :invalid_css} end end defp font_source_candidates(value) do {candidates, current, _quote, _depth} = value |> String.graphemes() |> Enum.reduce({[], [], nil, 0}, fn character, {candidates, current, quote, depth} -> cond do character in ["\"", "'"] and is_nil(quote) -> {candidates, [character | current], character, depth} character == quote -> {candidates, [character | current], nil, depth} is_nil(quote) and character == "(" -> {candidates, [character | current], quote, depth + 1} is_nil(quote) and character == ")" -> {candidates, [character | current], quote, max(depth - 1, 0)} is_nil(quote) and depth == 0 and character == "," -> candidate = current |> Enum.reverse() |> Enum.join() |> String.trim() {[candidate | candidates], [], quote, depth} true -> {candidates, [character | current], quote, depth} end end) final_candidate = current |> Enum.reverse() |> Enum.join() |> String.trim() [final_candidate | candidates] |> Enum.reverse() |> Enum.reject(&(&1 == "")) end defp supported_font_source?(source, format) do normalized_format = String.downcase(format) extension = source |> Path.extname() |> String.downcase() local? = source != "" and not String.contains?(source, ["\0", "://"]) and not String.starts_with?(String.downcase(source), "data:") format_supported? = case normalized_format do "" -> true format -> format in ["truetype", "opentype"] end local? and extension in [".ttf", ".otf"] and format_supported? end defp first_capture(captures, names) do Enum.find_value(names, "", fn name -> case Map.get(captures, name) do value when is_binary(value) and value != "" -> value _ -> nil end end) end defp font_weight_descriptor(declarations) do case declarations |> Enum.reverse() |> List.keyfind("font-weight", 0) do nil -> {:ok, 400} {"font-weight", value} -> case String.downcase(String.trim(value)) do "normal" -> {:ok, 400} "bold" -> {:ok, 700} value -> parsed_font_weight(Integer.parse(value)) end end end defp parsed_font_weight({weight, ""}) when weight >= 100 and weight <= 900, do: {:ok, weight} defp parsed_font_weight(_parsed), do: {:error, :invalid_css} defp font_style_descriptor(declarations) do case declarations |> Enum.reverse() |> List.keyfind("font-style", 0) do nil -> {:ok, :normal} {"font-style", value} -> case String.downcase(String.trim(value)) do "normal" -> {:ok, :normal} "italic" -> {:ok, :italic} _ -> {:error, :invalid_css} end end 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 -> case :binary.match(source, snippet) do {index, _length} -> 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} :nomatch -> {1, 1} end end end end