defmodule NativeElixirPdfUtilities.HtmlToPdf.Style do
@moduledoc """
Style computation for the native HTML-to-PDF renderer.
This module applies defaults, inheritance, font resolution, and the CSS
cascade for the supported text, box, list, link, table, page-break, flexbox,
grid, image, and embedded-font styling subset. Unsupported properties or
invalid values fail the render instead of being ignored.
"""
alias NativeElixirPdfUtilities.HtmlToPdf.CssParser
alias NativeElixirPdfUtilities.HtmlToPdf.Font
@type text_node :: %{type: :text, text: String.t(), style: map()}
@type styled_element :: %{
type: :element,
tag: String.t(),
style: map(),
children: [text_node() | styled_element()]
}
@type styled_tree :: %{type: :document, children: [styled_element()]}
@type render_option :: NativeElixirPdfUtilities.HtmlToPdf.render_option()
@doc """
Computes styles for a parsed HTML document tree.
"""
@spec compute(term(), [render_option()]) :: {:ok, styled_tree()} | {:error, :invalid_document}
def compute(dom, opts \\ []) do
case compute_detailed(dom, opts) do
{:ok, styled_tree} -> {:ok, styled_tree}
{:error, {_reason, _detail}} -> {:error, :invalid_document}
end
end
@doc """
Computes styles and returns detailed CSS/document diagnostics when validation fails.
"""
@spec compute_detailed(term(), [render_option()]) ::
{:ok, styled_tree()} | {:error, {:invalid_css | :invalid_document, map()}}
def compute_detailed(dom, opts \\ []) do
case {dom, opts} do
{%{type: :document, children: children}, opts} when is_list(opts) and is_list(children) ->
with {:ok, font_registry} <- font_registry(opts),
{:ok, font_families, font_face} <-
resolve_font(
Keyword.get(opts, :default_font, "Helvetica"),
400,
:normal,
font_registry
),
{:ok, rules} <- stylesheet_rules(children, opts) do
base_style = %{
_custom_properties: %{},
_font_registry: font_registry,
color: {0, 0, 0},
font_face: font_face,
font_families: font_families,
font_family: font_face.family,
font_size: 12.0,
font_style: :normal,
font_weight: 400,
letter_spacing: 0.0,
line_height: 14.4,
text_align: :left,
text_transform: :none
}
result =
case fragment_root_style(children, base_style, rules, opts) do
{:ok, nil} ->
{:ok, %{type: :document, children: []}}
{:ok, root_style} ->
with {:ok, styled_children} <-
style_children(children, root_style, rules, [], opts) do
{:ok, %{type: :document, children: styled_children}}
end
{:error, reason} ->
{:error, reason}
end
case result do
{:ok, styled_tree} ->
{:ok, styled_tree}
{:error, :invalid_document} ->
{:error, style_error_detail(dom, opts)}
end
else
{:error, :invalid_document} ->
{:error, style_error_detail(dom, opts)}
end
_ ->
{:error,
{:invalid_document,
%{
stage: :style,
reason: :invalid_document,
message: "document tree must be a parsed HTML document"
}}}
end
end
defp style_children(children, inherited_style, rules, ancestors, opts) do
Enum.reduce_while(children, {:ok, []}, fn child, {:ok, acc} ->
case style_node(child, inherited_style, rules, ancestors, opts) do
{:ok, styled_children} -> {:cont, {:ok, acc ++ styled_children}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp style_node(node, inherited_style, rules, ancestors, opts) do
case node do
%{type: :text, text: text} when is_binary(text) ->
{:ok,
[
%{
type: :text,
text: transform_text(text, Map.get(inherited_style, :text_transform, :none)),
style: text_style(inherited_style)
}
]}
%{type: :element, tag: tag, attributes: attributes, children: children}
when is_binary(tag) and is_map(attributes) and is_list(children) ->
case tag do
tag when tag in ["style", "meta", "title"] ->
{:ok, []}
"head" ->
{:ok, []}
tag when tag in ["html", "body"] ->
with {:ok, element_style} <-
element_style(node, inherited_style, rules, ancestors, opts) do
case Map.get(element_style, :display) do
:none ->
{:ok, []}
_ ->
style_children(
children,
text_style(element_style),
rules,
[node | ancestors],
opts
)
end
end
_ ->
with {:ok, element_style} <-
element_style(node, inherited_style, rules, ancestors, opts) do
case Map.get(element_style, :display) do
:none ->
{:ok, [%{type: :element, tag: tag, style: element_style, children: []}]}
_ ->
with {:ok, styled_children} <-
style_children(
children,
text_style(element_style),
rules,
[node | ancestors],
opts
) do
{:ok,
[
%{
type: :element,
tag: tag,
style: element_style,
children: styled_children
}
]}
end
end
end
end
_ ->
{:error, :invalid_document}
end
end
defp fragment_root_style(children, base_style, rules, opts) do
case Enum.any?(children, &document_container?/1) do
true ->
{:ok, base_style}
false ->
body_node = %{type: :element, tag: "body", attributes: %{}, children: []}
with {:ok, body_style} <- element_style(body_node, base_style, rules, [], opts) do
case Map.get(body_style, :display) do
:none -> {:ok, nil}
_ -> {:ok, text_style(body_style)}
end
end
end
end
defp document_container?(node) do
case node do
%{type: :element, tag: tag} when tag in ["html", "body"] -> true
_ -> false
end
end
defp element_style(
%{tag: tag, attributes: attributes} = node,
inherited_style,
rules,
ancestors,
opts
) do
inherited_font_size = Map.fetch!(inherited_style, :font_size)
defaults =
case tag do
"html" ->
%{display: :block}
"body" ->
block_defaults(inherited_font_size, 400, 0.0)
tag
when tag in ["article", "aside", "div", "footer", "header", "main", "nav", "section"] ->
block_defaults(inherited_font_size, 400, 0.0)
"p" ->
block_defaults(inherited_font_size, 400, inherited_font_size)
"h1" ->
block_defaults(inherited_font_size * 2.0, 700, inherited_font_size * 1.3333333333)
"h2" ->
block_defaults(inherited_font_size * 1.5, 700, inherited_font_size * 1.1666666667)
"h3" ->
block_defaults(inherited_font_size * 1.17, 700, inherited_font_size)
"h4" ->
block_defaults(inherited_font_size, 700, inherited_font_size * 0.8333333333)
"h5" ->
block_defaults(inherited_font_size * 0.83, 700, inherited_font_size * 0.6666666667)
"h6" ->
block_defaults(inherited_font_size * 0.67, 700, inherited_font_size * 0.6666666667)
"ul" ->
list_defaults(:disc, inherited_font_size)
"ol" ->
list_defaults(:decimal, inherited_font_size)
"li" ->
list_item_defaults(inherited_font_size)
"table" ->
table_defaults(inherited_font_size)
"caption" ->
table_caption_defaults(inherited_font_size)
"thead" ->
table_row_group_defaults(:head)
"tbody" ->
table_row_group_defaults(:body)
"tfoot" ->
table_row_group_defaults(:foot)
"tr" ->
table_row_defaults()
"th" ->
table_cell_defaults(:header, attributes, inherited_font_size)
"td" ->
table_cell_defaults(:data, attributes, inherited_font_size)
"strong" ->
%{display: :inline, font_weight: 700}
"b" ->
%{display: :inline, font_weight: 700}
"em" ->
%{display: :inline, font_style: :italic}
"i" ->
%{display: :inline, font_style: :italic}
"span" ->
%{display: :inline}
"a" ->
link_defaults(attributes)
"br" ->
%{display: :line_break}
"img" ->
image_defaults(attributes, opts, inherited_font_size)
_ ->
:invalid
end
case defaults do
{:ok, defaults} ->
style =
inherited_style
|> text_style()
|> Map.merge(defaults)
tag
|> finalize_element_style(apply_author_styles(style, node, ancestors, rules))
:invalid ->
{:error, :invalid_document}
defaults ->
style =
inherited_style
|> text_style()
|> Map.merge(defaults)
tag
|> finalize_element_style(apply_author_styles(style, node, ancestors, rules))
end
end
defp finalize_element_style(tag, result) do
case {tag, result} do
{"img", {:ok, style}} ->
finalize_image_style(style)
{_tag, result} ->
result
end
end
defp finalize_image_style(style) do
case {Map.get(style, :display), Map.get(style, :image), Map.get(style, :svg_image)} do
{:image, nil, svg} when is_binary(svg) ->
with {:ok, png} <- rasterize_svg(svg, svg_raster_options(style)),
{:ok, image} <- decode_image(png) do
{:ok,
style
|> Map.put(:image, image)
|> Map.delete(:svg_image)}
else
_ -> {:error, :invalid_document}
end
_ ->
{:ok, Map.delete(style, :svg_image)}
end
end
defp put_line_height(style) do
case Map.get(style, :line_height_multiplier) do
multiplier when is_number(multiplier) ->
style
|> Map.put(:line_height, Map.fetch!(style, :font_size) * multiplier)
|> Map.delete(:line_height_multiplier)
|> Map.delete(:line_height_explicit)
_ ->
case Map.get(style, :line_height_explicit, false) do
true ->
case Map.get(style, :line_height_normal, false) do
true ->
style
|> Map.put(:line_height, normal_line_height(style))
|> Map.delete(:line_height_normal)
|> Map.delete(:line_height_explicit)
false ->
Map.delete(style, :line_height_explicit)
end
false ->
Map.put(style, :line_height, normal_line_height(style))
end
end
end
defp normal_line_height(style) do
font_size = Map.fetch!(style, :font_size)
case Map.get(style, :font_face) do
%{type: :embedded, ascent: ascent, descent: descent, units_per_em: units_per_em}
when is_number(ascent) and is_number(descent) and is_number(units_per_em) and
units_per_em > 0 ->
max((ascent - descent) / units_per_em * font_size, font_size)
_ ->
font_size * 1.2
end
end
defp text_style(style) do
Map.take(style, [
:_custom_properties,
:_font_registry,
:color,
:font_face,
:font_families,
:font_family,
:font_size,
:font_style,
:font_weight,
:letter_spacing,
:line_break,
:line_height,
:link_url,
:text_align,
:text_transform
])
end
defp block_defaults(font_size, font_weight, margin_bottom) do
%{
background_color: nil,
border_color: {0, 0, 0},
border_colors: edges({0, 0, 0}),
border_radius: 0.0,
border_widths: edges(0.0),
box_sizing: :content_box,
display: :block,
font_size: font_size,
font_weight: font_weight,
margin: edges(0.0, 0.0, margin_bottom, 0.0),
margin_after: margin_bottom,
padding: edges(0.0)
}
end
defp list_defaults(marker_type, font_size) do
block_defaults(font_size, 400, font_size)
|> Map.merge(%{
display: :list,
list_marker_type: marker_type,
margin: edges(0.0, 0.0, font_size, 0.0),
padding: edges(0.0, 0.0, 0.0, 24.0)
})
end
defp list_item_defaults(font_size) do
%{
display: :list_item,
font_size: font_size,
font_weight: 400,
margin: edges(0.0, 0.0, 4.0, 0.0),
margin_after: 4.0
}
end
defp table_defaults(font_size) do
block_defaults(font_size, 400, 0.0)
|> Map.merge(%{
display: :table,
border_collapse: :separate,
margin: edges(0.0),
padding: edges(0.0)
})
end
defp table_caption_defaults(font_size) do
block_defaults(font_size, 700, 4.0)
|> Map.merge(%{
display: :table_caption,
text_align: :center
})
end
defp table_row_group_defaults(section) do
%{
background_color: nil,
border_color: {0, 0, 0},
border_colors: edges({0, 0, 0}),
border_radius: 0.0,
border_widths: edges(0.0),
box_sizing: :content_box,
display: :table_row_group,
padding: edges(0.0),
table_section: section
}
end
defp table_row_defaults do
%{
background_color: nil,
border_color: {0, 0, 0},
border_colors: edges({0, 0, 0}),
border_radius: 0.0,
border_widths: edges(0.0),
box_sizing: :content_box,
display: :table_row,
padding: edges(0.0)
}
end
defp table_cell_defaults(kind, attributes, font_size) do
base = %{
background_color: nil,
border_color: {0, 0, 0},
border_colors: edges({0, 0, 0}),
border_radius: 0.0,
border_widths: edges(1.0),
box_sizing: :content_box,
colspan: 1,
display: :table_cell,
font_size: font_size,
font_weight: 400,
margin: edges(0.0),
padding: edges(4.0),
rowspan: 1,
text_align: :left,
vertical_align: :middle
}
with {:ok, colspan} <- positive_attribute(attributes, "colspan"),
{:ok, rowspan} <- positive_attribute(attributes, "rowspan") do
cell =
base
|> Map.put(:colspan, colspan)
|> Map.put(:rowspan, rowspan)
case kind do
:header ->
{:ok,
Map.merge(cell, %{
background_color: {0.9333333333, 0.9333333333, 0.9333333333},
font_weight: 700,
text_align: :center
})}
:data ->
{:ok, cell}
end
else
_ -> :invalid
end
end
defp link_defaults(attributes) do
case Map.get(attributes, "href") do
nil ->
{:ok, %{display: :inline, color: {0, 0, 1}}}
href ->
case valid_link_url?(href) do
true -> {:ok, %{display: :inline, color: {0, 0, 1}, link_url: href}}
false -> :invalid
end
end
end
defp image_defaults(attributes, opts, font_size) do
with src when is_binary(src) <- Map.get(attributes, "src"),
{:ok, image_style} <- load_image_style(src, opts) do
{:ok,
block_defaults(font_size, 400, font_size)
|> Map.merge(%{
display: :image,
margin: edges(0.0, 0.0, font_size, 0.0),
padding: edges(0.0)
})
|> Map.merge(image_style)}
else
_ -> :invalid
end
end
defp positive_attribute(attributes, name) do
case Map.get(attributes, name) do
nil ->
{:ok, 1}
value when is_binary(value) ->
case Integer.parse(String.trim(value)) do
{integer, ""} when integer >= 1 -> {:ok, integer}
_ -> :error
end
_ ->
:error
end
end
defp edges(value) do
edges(value, value, value, value)
end
defp edges(top, right, bottom, left) do
%{top: top, right: right, bottom: bottom, left: left}
end
defp stylesheet_rules(children, opts) do
case Keyword.get(opts, :stylesheets, []) do
stylesheets when is_list(stylesheets) ->
with {:ok, configured_rules} <- parse_stylesheets(stylesheets, 0),
{:ok, embedded_rules} <-
children
|> style_sources()
|> parse_stylesheets(length(configured_rules)) do
{:ok, configured_rules ++ embedded_rules}
end
_ ->
{:error, :invalid_document}
end
end
defp parse_stylesheets(stylesheets, initial_order) do
parsed =
Enum.reduce_while(stylesheets, {:ok, [], initial_order}, fn stylesheet,
{:ok, acc, next_order} ->
with {:ok, stylesheet} <- stylesheet_source(stylesheet),
{:ok, rules} <- CssParser.parse(stylesheet) do
ordered_rules =
rules
|> Enum.with_index(next_order)
|> Enum.map(fn {rule, order} -> %{rule | order: order} end)
{:cont, {:ok, acc ++ ordered_rules, next_order + length(rules)}}
else
_ ->
{:halt, {:error, :invalid_document}}
end
end)
case parsed do
{:ok, rules, _next_order} -> {:ok, rules}
{:error, reason} -> {:error, reason}
end
end
defp stylesheet_source(stylesheet) do
case stylesheet do
stylesheet when is_binary(stylesheet) ->
case String.contains?(stylesheet, "{") do
true ->
{:ok, stylesheet}
false ->
case File.read(stylesheet) do
{:ok, css} -> {:ok, css}
{:error, _reason} -> {:error, :invalid_document}
end
end
_ ->
{:error, :invalid_document}
end
end
defp style_sources(children) do
Enum.flat_map(children, fn child ->
case child do
%{type: :element, tag: "style", children: style_children} ->
[text_content(style_children)]
%{type: :element, children: nested_children} ->
style_sources(nested_children)
_ ->
[]
end
end)
end
defp text_content(children) do
Enum.map_join(children, "", fn child ->
case child do
%{type: :text, text: text} -> text
_ -> ""
end
end)
end
defp font_registry(opts) do
case Font.load_registry(opts) do
{:ok, registry} -> {:ok, registry}
:error -> {:error, :invalid_document}
end
end
defp style_error_detail(dom, opts) do
case first_author_css_error(dom, opts) do
nil ->
{:invalid_document,
%{
stage: :style,
reason: :invalid_document,
message:
"document style validation failed; check fonts, images, attributes, and supported CSS values"
}}
css_error ->
css_error
end
end
defp first_author_css_error(dom, opts) do
stylesheet_diagnostic_error(dom, opts) || inline_style_diagnostic_error(dom, opts)
end
defp stylesheet_diagnostic_error(dom, opts) do
case stylesheet_diagnostic_sources(dom, opts) do
{:ok, sources} ->
Enum.find_value(sources, fn {_label, source} ->
case CssParser.parse_detailed(source) do
{:ok, _rules} ->
invalid_declaration_detail(source, stylesheet_declaration_sources(source))
{:error, error} ->
error
end
end)
{:error, error} ->
error
end
end
defp stylesheet_diagnostic_sources(dom, opts) do
with {:ok, configured} <- configured_stylesheet_sources(Keyword.get(opts, :stylesheets, [])),
{:ok, embedded} <- embedded_stylesheet_sources(dom) do
{:ok, configured ++ embedded}
end
end
defp configured_stylesheet_sources(stylesheets) do
case stylesheets do
stylesheets when is_list(stylesheets) ->
Enum.reduce_while(stylesheets, {:ok, []}, fn stylesheet, {:ok, acc} ->
case stylesheet_source(stylesheet) do
{:ok, source} ->
{:cont, {:ok, acc ++ [{:configured, source}]}}
{:error, :invalid_document} ->
{:halt,
{:error,
{:invalid_document,
%{
stage: :style,
reason: :invalid_document,
message: "configured stylesheet must be inline CSS or a readable file path"
}}}}
end
end)
_ ->
{:error,
{:invalid_document,
%{
stage: :style,
reason: :invalid_document,
message: "stylesheets option must be a list"
}}}
end
end
defp embedded_stylesheet_sources(dom) do
%{type: :document, children: children} = dom
{:ok, Enum.map(style_sources(children), &{:embedded, &1})}
end
defp stylesheet_declaration_sources(css) do
~r/[^{}]+\{(?[^{}]*)\}/u
|> Regex.scan(css, capture: ["declarations"])
|> List.flatten()
|> Enum.flat_map(fn declarations ->
declarations
|> String.split(";")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end)
end
defp inline_style_diagnostic_error(dom, opts) do
Enum.find_value(inline_style_sources(dom), &inline_style_error(&1, opts))
end
defp inline_style_error(style_source, _opts) do
case CssParser.parse_declarations_detailed(style_source) do
{:ok, _declarations} ->
invalid_declaration_detail(style_source, inline_declaration_sources(style_source))
{:error, error} ->
error
end
end
defp inline_style_sources(node) do
case node do
%{type: :document, children: children} ->
Enum.flat_map(children, &inline_style_sources/1)
%{type: :element, attributes: attributes, children: children} ->
own =
case Map.get(attributes, "style") do
style when is_binary(style) -> [style]
_ -> []
end
own ++ Enum.flat_map(children, &inline_style_sources/1)
_ ->
[]
end
end
defp inline_declaration_sources(style_source) do
style_source
|> String.split(";")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp invalid_declaration_detail(css_source, declaration_sources) do
Enum.find_value(declaration_sources, fn declaration_source ->
with {:ok, [declaration]} <- CssParser.parse_declarations(declaration_source),
{:ok, style} <- diagnostic_style(),
{:ok, _style} <- apply_declaration(style, declaration) do
nil
else
_ -> {:invalid_css, declaration_detail(css_source, declaration_source)}
end
end)
end
defp diagnostic_style do
with {:ok, registry} <- font_registry([]),
{:ok, families, font_face} <- resolve_font("Helvetica", 400, :normal, registry) do
{:ok,
12.0
|> block_defaults(400, 0.0)
|> flex_container_defaults()
|> grid_container_defaults()
|> Map.merge(%{
_custom_properties: %{},
_font_registry: registry,
color: {0, 0, 0},
font_face: font_face,
font_families: families,
font_family: font_face.family,
font_size: 12.0,
font_style: :normal,
font_weight: 400,
letter_spacing: 0.0,
line_height: 14.4,
text_align: :left,
text_transform: :none
})}
end
end
defp declaration_detail(css_source, declaration_source) do
source = String.trim(declaration_source)
{line, column} = source_location(css_source, 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
{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
defp resolve_font(family_value, weight, style, registry) do
case Font.resolve(family_value, weight, style, registry) do
{:ok, families, font_face} -> {:ok, families, font_face}
:error -> {:error, :invalid_document}
end
end
defp apply_author_styles(style, node, ancestors, rules) do
with {:ok, style} <- apply_stylesheet_rules(style, node, ancestors, rules),
{:ok, style} <- apply_inline_style(style, Map.get(node.attributes, "style", "")),
{:ok, style} <- put_font_face(style) do
{:ok, put_line_height(style)}
end
end
defp apply_stylesheet_rules(style, node, ancestors, rules) do
rules
|> matching_declarations(node, ancestors)
|> Enum.map(& &1.declaration)
|> then(&apply_declarations(style, &1))
end
defp matching_declarations(rules, node, ancestors) do
rules
|> Enum.flat_map(fn rule ->
case matching_specificity(rule.selectors, node, ancestors) do
nil ->
[]
specificity ->
rule.declarations
|> Enum.with_index()
|> Enum.map(fn {declaration, declaration_index} ->
%{
declaration: declaration,
important: important_declaration?(declaration),
specificity: specificity,
order: rule.order,
declaration_index: declaration_index
}
end)
end
end)
|> Enum.sort_by(fn matched ->
{matched.important, matched.specificity, matched.order, matched.declaration_index}
end)
end
defp matching_specificity(selectors, node, ancestors) do
selectors
|> Enum.filter(&matches_selector?(&1, node, ancestors))
|> Enum.map(& &1.specificity)
|> Enum.max(fn -> nil end)
end
defp matches_selector?(selector, node, ancestors) do
selector.parts
|> Enum.reverse()
|> match_selector_parts(node, ancestors)
end
defp match_selector_parts(parts, node, ancestors) do
case parts do
[part] ->
matches_simple_selector?(part, node, ancestors)
[part, left_part | remaining] ->
case matches_simple_selector?(part, node, ancestors) do
true -> matches_ancestor_selector?(part.combinator, left_part, remaining, ancestors)
false -> false
end
end
end
defp matches_ancestor_selector?(combinator, left_part, remaining, ancestors) do
case {combinator, ancestors} do
{:child, [parent | rest]} ->
match_selector_parts([left_part | remaining], parent, rest)
{:descendant, ancestors} ->
ancestors
|> Enum.with_index()
|> Enum.any?(fn {ancestor, index} ->
rest = Enum.drop(ancestors, index + 1)
match_selector_parts([left_part | remaining], ancestor, rest)
end)
_ ->
false
end
end
defp matches_simple_selector?(part, node, ancestors) do
attributes = Map.get(node, :attributes, %{})
classes = attributes |> Map.get("class", "") |> String.split(~r/\s+/u, trim: true)
tag_matches? = is_nil(part.tag) or Map.get(node, :tag) == part.tag
id_matches? = is_nil(part.id) or Map.get(attributes, "id") == part.id
classes_match? = Enum.all?(part.classes, &(&1 in classes))
pseudo_classes_match? =
Enum.all?(part.pseudo_classes, &pseudo_class_matches?(&1, node, ancestors))
tag_matches? and id_matches? and classes_match? and pseudo_classes_match?
end
defp pseudo_class_matches?(pseudo_class, node, ancestors) do
case pseudo_class do
:root ->
Map.get(node, :tag) == "html" and ancestors == []
:first_child ->
case ancestors do
[%{children: children} | _rest] when is_list(children) ->
children
|> Enum.filter(&match?(%{type: :element}, &1))
|> List.first()
|> Kernel.==(node)
_ ->
false
end
:last_child ->
case ancestors do
[%{children: children} | _rest] when is_list(children) ->
children
|> Enum.filter(&match?(%{type: :element}, &1))
|> List.last()
|> Kernel.==(node)
_ ->
false
end
{:nth_child, index} ->
case ancestors do
[%{children: children} | _rest] when is_list(children) ->
children
|> Enum.filter(&match?(%{type: :element}, &1))
|> Enum.at(index - 1)
|> Kernel.==(node)
_ ->
false
end
end
end
defp apply_inline_style(style, inline_style) do
case CssParser.parse_declarations(inline_style) do
{:ok, declarations} -> apply_declarations(style, declarations)
{:error, _reason} -> {:error, :invalid_document}
end
end
defp apply_declarations(style, declarations) do
Enum.reduce_while(declarations, {:ok, style}, fn declaration, {:ok, acc} ->
case apply_declaration(acc, declaration) do
{:ok, style} -> {:cont, {:ok, style}}
{:error, reason} -> {:halt, {:error, reason}}
_ -> {:halt, {:error, :invalid_document}}
end
end)
end
defp important_declaration?(declaration) do
case declaration do
{_property, _value, :important} -> true
_ -> false
end
end
defp apply_declaration(style, declaration) do
case declaration do
{property, value} -> apply_declaration_value(style, property, value)
{property, value, :important} -> apply_declaration_value(style, property, value)
end
end
defp apply_declaration_value(style, property, value) do
case property do
"--" <> _custom_property when is_binary(value) ->
custom_properties =
style
|> Map.get(:_custom_properties, %{})
|> Map.put(property, String.trim(value))
{:ok, Map.put(style, :_custom_properties, custom_properties)}
_ ->
with {:ok, value} <- resolved_css_value(style, value) do
apply_resolved_declaration_value(style, property, value)
else
:error -> {:error, :invalid_document}
end
end
end
defp apply_resolved_declaration_value(style, property, value) do
case property do
"color" ->
with {:ok, color} <- parse_color(value, Map.fetch!(style, :color)) do
case color do
:transparent -> {:ok, style}
color -> {:ok, Map.put(style, :color, color)}
end
end
"background-color" ->
with {:ok, color} <- parse_color(value, Map.fetch!(style, :color)) do
{:ok, Map.put(style, :background_color, transparent_color_to_nil(color))}
end
"background" ->
put_background(style, value)
"box-sizing" ->
put_box_sizing(style, value)
"margin" ->
with {:ok, lengths} <- parse_box_lengths(value, :margin),
do: {:ok, put_box_lengths(style, "margin", lengths)}
property when property in ["padding", "border-width"] ->
with {:ok, lengths} <- parse_box_lengths(value),
do: {:ok, put_box_lengths(style, property, lengths)}
property when property in ["margin-top", "margin-right", "margin-bottom", "margin-left"] ->
with {:ok, length} <- parse_length(value, :margin),
do: {:ok, put_edge_length(style, property, length)}
property
when property in ["padding-top", "padding-right", "padding-bottom", "padding-left"] ->
with {:ok, length} <- parse_length(value),
do: {:ok, put_edge_length(style, property, length)}
"border-color" ->
with {:ok, color} <- parse_color(value, Map.fetch!(style, :color)) do
case color do
:transparent ->
{:ok, style}
color ->
{:ok,
style |> Map.put(:border_color, color) |> Map.put(:border_colors, edges(color))}
end
end
property when property in ["border-top", "border-right", "border-bottom", "border-left"] ->
with {:ok, width, color} <- parse_border_edge(value, Map.fetch!(style, :color)) do
style = put_border_edge_width(style, property, width)
case color do
nil -> {:ok, style}
color -> {:ok, put_border_edge_color(style, property, color)}
end
end
property
when property in [
"border-top-width",
"border-right-width",
"border-bottom-width",
"border-left-width"
] ->
with {:ok, width} <- parse_length(value) do
{:ok, put_border_edge_width(style, border_edge_property(property), width)}
end
property
when property in [
"border-top-color",
"border-right-color",
"border-bottom-color",
"border-left-color"
] ->
with {:ok, color} <- parse_color(value, Map.fetch!(style, :color)) do
case color do
:transparent -> {:ok, style}
color -> {:ok, put_border_edge_color(style, border_edge_property(property), color)}
end
end
property
when property in [
"border-style",
"border-top-style",
"border-right-style",
"border-bottom-style",
"border-left-style"
] ->
put_border_style(style, property, value)
"border-radius" ->
with {:ok, length} <- parse_length(value),
do: {:ok, Map.put(style, :border_radius, length)}
"text-align" ->
put_text_align(style, value)
"text-transform" ->
put_text_transform(style, value)
"letter-spacing" ->
put_letter_spacing(style, value)
property when property in ["white-space", "word-break", "word-wrap", "overflow-wrap"] ->
put_text_wrapping(style, property, value)
property when property in ["break-before", "page-break-before"] ->
with {:ok, break_value} <- page_break_value(value),
do: {:ok, Map.put(style, :break_before, break_value)}
property when property in ["break-after", "page-break-after"] ->
with {:ok, break_value} <- page_break_value(value),
do: {:ok, Map.put(style, :break_after, break_value)}
"page-break-inside" ->
put_break_inside(style, value)
"line-break" ->
put_line_break(style, value)
"vertical-align" ->
put_vertical_align(style, value)
"min-height" ->
put_size(style, :min_height, value)
property when property in ["min-width", "max-width", "max-height"] ->
put_size(style, length_property(property), value)
"border" ->
with {:ok, border_widths, border_color} <- parse_border(value, Map.fetch!(style, :color)) do
style =
style
|> Map.put(:border_widths, border_widths)
|> Map.put(:border_color, border_color)
|> Map.put(:border_colors, edges(border_color))
{:ok, style}
end
"font-size" ->
with {:ok, length} <- parse_length(value), do: {:ok, Map.put(style, :font_size, length)}
"line-height" ->
case parse_line_height(value) do
{:ok, {:multiplier, multiplier}} ->
{:ok,
style
|> Map.put(:line_height_multiplier, multiplier)
|> Map.put(:line_height_explicit, true)}
{:ok, :normal} ->
{:ok,
style
|> Map.put(:line_height_normal, true)
|> Map.delete(:line_height_multiplier)
|> Map.put(:line_height_explicit, true)}
{:ok, line_height} ->
{:ok,
style
|> Map.put(:line_height, line_height)
|> Map.delete(:line_height_multiplier)
|> Map.put(:line_height_explicit, true)}
:error ->
:error
end
"font-weight" ->
put_font_weight(style, value)
"font-style" ->
put_font_style(style, value)
"font-family" ->
put_font_family(style, value)
"display" ->
put_display(style, value)
property when property in ["width", "height"] ->
put_size(style, length_property(property), value)
"position" ->
put_position(style, value)
"overflow" ->
put_overflow(style, value)
"aspect-ratio" ->
with {:ok, aspect_ratio} <- parse_aspect_ratio(value),
do: {:ok, Map.put(style, :aspect_ratio, aspect_ratio)}
"border-collapse" ->
put_border_collapse(style, value)
"flex-direction" ->
put_flex_direction(style, value)
"flex-wrap" ->
put_flex_wrap(style, value)
"gap" ->
with {:ok, row_gap, column_gap} <- parse_gap(value) do
{:ok, style |> Map.put(:row_gap, row_gap) |> Map.put(:column_gap, column_gap)}
end
property when property in ["row-gap", "column-gap"] ->
with {:ok, length} <- parse_length(value),
do: {:ok, Map.put(style, gap_property(property), length)}
"justify-content" ->
put_justify_content(style, value)
"align-items" ->
put_align_items(style, value)
"align-self" ->
put_align_self(style, value)
"justify-self" ->
put_grid_alignment(style, :justify_self, value)
"order" ->
put_order(style, value)
"flex-grow" ->
put_nonnegative_number(style, :flex_grow, value)
"flex-shrink" ->
put_nonnegative_number(style, :flex_shrink, value)
"flex-basis" ->
put_flex_basis(style, value)
"flex" ->
put_flex(style, value)
property when property in ["grid-template-columns", "grid-template-rows"] ->
with {:ok, tracks} <- parse_grid_tracks(value) do
{:ok, Map.put(style, grid_template_property(property), tracks)}
end
property when property in ["grid-auto-columns", "grid-auto-rows"] ->
with {:ok, track} <- parse_grid_track(value) do
{:ok, Map.put(style, grid_auto_property(property), track)}
end
property
when property in [
"grid-column",
"grid-row",
"grid-column-start",
"grid-column-end",
"grid-row-start",
"grid-row-end",
"grid-area"
] ->
put_grid_placement(style, property, value)
"justify-items" ->
put_grid_alignment(style, :justify_items, value)
"align-content" ->
put_justify_content(style, value, :align_content)
_ ->
{:error, :invalid_document}
end
end
defp put_display(style, value) do
case value |> String.trim() |> String.downcase() do
"block" ->
{:ok, style |> ensure_box_style() |> Map.put(:display, :block)}
"inline" ->
{:ok, Map.put(style, :display, :inline)}
"inline-block" ->
{:ok, style |> ensure_box_style() |> Map.put(:display, :block)}
"none" ->
{:ok, Map.put(style, :display, :none)}
"flex" ->
{:ok,
style |> ensure_box_style() |> flex_container_defaults() |> Map.put(:display, :flex)}
"inline-flex" ->
{:ok,
style
|> ensure_box_style()
|> flex_container_defaults()
|> Map.put(:display, :inline_flex)}
"grid" ->
{:ok,
style |> ensure_box_style() |> grid_container_defaults() |> Map.put(:display, :grid)}
"inline-grid" ->
{:ok,
style
|> ensure_box_style()
|> grid_container_defaults()
|> Map.put(:display, :inline_grid)}
_ ->
{:error, :invalid_document}
end
end
defp ensure_box_style(style) do
style
|> Map.put_new(:background_color, nil)
|> Map.put_new(:border_color, {0, 0, 0})
|> Map.put_new(:border_colors, edges(Map.get(style, :border_color, {0, 0, 0})))
|> Map.put_new(:border_radius, 0.0)
|> Map.put_new(:border_widths, edges(0.0))
|> Map.put_new(:box_sizing, :content_box)
|> Map.put_new(:margin, edges(0.0, 0.0, Map.get(style, :margin_after, 0.0), 0.0))
|> Map.put_new(:margin_after, 0.0)
|> Map.put_new(:padding, edges(0.0))
end
defp put_size(style, property, value) do
with {:ok, size} <- parse_size(value), do: {:ok, Map.put(style, property, size)}
end
defp put_vertical_align(style, value) do
case value |> String.trim() |> String.downcase() do
"baseline" -> {:ok, Map.put(style, :vertical_align, :baseline)}
"top" -> {:ok, Map.put(style, :vertical_align, :top)}
"middle" -> {:ok, Map.put(style, :vertical_align, :middle)}
"bottom" -> {:ok, Map.put(style, :vertical_align, :bottom)}
_ -> {:error, :invalid_document}
end
end
defp put_line_break(style, value) do
case value |> String.trim() |> String.downcase() do
value when value in ["auto", "normal"] -> {:ok, Map.put(style, :line_break, :normal)}
"anywhere" -> {:ok, Map.put(style, :line_break, :anywhere)}
_ -> {:error, :invalid_document}
end
end
defp put_background(style, value) do
case value |> String.trim() |> String.downcase() do
"none" ->
{:ok, Map.put(style, :background_color, nil)}
value ->
with {:ok, color} <- parse_color(value, Map.fetch!(style, :color)) do
{:ok, Map.put(style, :background_color, transparent_color_to_nil(color))}
end
end
end
defp put_position(style, value) do
case value |> String.trim() |> String.downcase() do
value when value in ["static", "relative"] -> {:ok, style}
_ -> {:error, :invalid_document}
end
end
defp put_overflow(style, value) do
case value |> String.trim() |> String.downcase() do
"visible" -> {:ok, Map.put(style, :overflow, :visible)}
"hidden" -> {:ok, Map.put(style, :overflow, :hidden)}
_ -> {:error, :invalid_document}
end
end
defp put_border_style(style, property, value) do
normalized = value |> String.trim() |> String.downcase()
cond do
normalized in ["solid", "dashed"] ->
{:ok, style}
normalized == "none" and property == "border-style" ->
{:ok, Map.put(style, :border_widths, edges(0.0))}
normalized == "none" ->
{:ok, put_border_edge_width(style, border_edge_property(property), 0.0)}
true ->
{:error, :invalid_document}
end
end
defp put_box_sizing(style, value) do
case value |> String.trim() |> String.downcase() do
"border-box" -> {:ok, Map.put(style, :box_sizing, :border_box)}
"content-box" -> {:ok, Map.put(style, :box_sizing, :content_box)}
_ -> {:error, :invalid_document}
end
end
defp put_text_transform(style, value) do
case value |> String.trim() |> String.downcase() do
"none" -> {:ok, Map.put(style, :text_transform, :none)}
"uppercase" -> {:ok, Map.put(style, :text_transform, :uppercase)}
"lowercase" -> {:ok, Map.put(style, :text_transform, :lowercase)}
"capitalize" -> {:ok, Map.put(style, :text_transform, :capitalize)}
_ -> {:error, :invalid_document}
end
end
defp put_letter_spacing(style, value) do
case value |> String.trim() |> String.downcase() do
"normal" ->
{:ok, Map.put(style, :letter_spacing, 0.0)}
value ->
with {:ok, length} <- parse_letter_spacing(value, Map.fetch!(style, :font_size)) do
{:ok, Map.put(style, :letter_spacing, length)}
else
:error -> {:error, :invalid_document}
end
end
end
defp put_text_wrapping(style, property, value) do
normalized = value |> String.trim() |> String.downcase()
case {property, normalized} do
{"white-space", value} when value in ["normal", "pre-line"] ->
{:ok, style}
{"white-space", "nowrap"} ->
{:ok, Map.put(style, :line_break, :normal)}
{property, "break-word"} when property in ["word-break", "word-wrap", "overflow-wrap"] ->
{:ok, Map.put(style, :line_break, :break_word)}
{property, "anywhere"} when property in ["word-break", "word-wrap", "overflow-wrap"] ->
{:ok, Map.put(style, :line_break, :anywhere)}
{property, "normal"} when property in ["word-break", "word-wrap", "overflow-wrap"] ->
{:ok, Map.put(style, :line_break, :normal)}
_ ->
{:error, :invalid_document}
end
end
defp put_break_inside(style, value) do
case value |> String.trim() |> String.downcase() do
value when value in ["auto", "avoid"] -> {:ok, style}
_ -> {:error, :invalid_document}
end
end
defp transform_text(text, transform) do
case transform do
:uppercase -> String.upcase(text)
:lowercase -> String.downcase(text)
:capitalize -> Regex.replace(~r/\b\p{L}/u, text, &String.upcase/1)
_ -> text
end
end
defp resolved_css_value(style, value) do
normalized = String.trim(value)
variable_references =
~r/var\(\s*(--[a-zA-Z_][a-zA-Z0-9_-]*)\s*\)/u
|> Regex.scan(normalized, capture: :all_but_first)
|> List.flatten()
case variable_references do
[] ->
{:ok, normalized}
references ->
custom_properties = Map.get(style, :_custom_properties, %{})
Enum.reduce_while(references, normalized, fn name, acc ->
case Map.get(custom_properties, name) do
value when is_binary(value) ->
{:cont, String.replace(acc, ~r/var\(\s*#{Regex.escape(name)}\s*\)/u, value)}
_ ->
{:halt, :error}
end
end)
|> case do
value when is_binary(value) -> {:ok, value}
:error -> :error
end
end
end
defp flex_container_defaults(style) do
style
|> Map.put_new(:flex_direction, :row)
|> Map.put_new(:flex_wrap, :nowrap)
|> Map.put_new(:row_gap, 0.0)
|> Map.put_new(:column_gap, 0.0)
|> Map.put_new(:justify_content, :flex_start)
|> Map.put_new(:align_items, :stretch)
end
defp grid_container_defaults(style) do
style
|> Map.put_new(:grid_template_columns, [])
|> Map.put_new(:grid_template_rows, [])
|> Map.put_new(:grid_auto_columns, :auto)
|> Map.put_new(:grid_auto_rows, :auto)
|> Map.put_new(:row_gap, 0.0)
|> Map.put_new(:column_gap, 0.0)
|> Map.put_new(:justify_items, :stretch)
|> Map.put_new(:align_items, :stretch)
|> Map.put_new(:justify_content, :flex_start)
|> Map.put_new(:align_content, :stretch)
end
defp put_flex_direction(style, value) do
case value |> String.trim() |> String.downcase() do
"row" -> {:ok, Map.put(style, :flex_direction, :row)}
"row-reverse" -> {:ok, Map.put(style, :flex_direction, :row_reverse)}
"column" -> {:ok, Map.put(style, :flex_direction, :column)}
"column-reverse" -> {:ok, Map.put(style, :flex_direction, :column_reverse)}
_ -> {:error, :invalid_document}
end
end
defp put_flex_wrap(style, value) do
case value |> String.trim() |> String.downcase() do
"nowrap" -> {:ok, Map.put(style, :flex_wrap, :nowrap)}
"wrap" -> {:ok, Map.put(style, :flex_wrap, :wrap)}
_ -> {:error, :invalid_document}
end
end
defp put_justify_content(style, value) do
put_justify_content(style, value, :justify_content)
end
defp put_justify_content(style, value, property) do
case value |> String.trim() |> String.downcase() do
value when value in ["start", "flex-start"] -> {:ok, Map.put(style, property, :flex_start)}
value when value in ["end", "flex-end"] -> {:ok, Map.put(style, property, :flex_end)}
"center" -> {:ok, Map.put(style, property, :center)}
"stretch" -> {:ok, Map.put(style, property, :stretch)}
"space-between" -> {:ok, Map.put(style, property, :space_between)}
"space-around" -> {:ok, Map.put(style, property, :space_around)}
"space-evenly" -> {:ok, Map.put(style, property, :space_evenly)}
_ -> {:error, :invalid_document}
end
end
defp put_align_items(style, value) do
case value |> String.trim() |> String.downcase() do
"stretch" ->
{:ok, Map.put(style, :align_items, :stretch)}
value when value in ["start", "flex-start"] ->
{:ok, Map.put(style, :align_items, :flex_start)}
value when value in ["end", "flex-end"] ->
{:ok, Map.put(style, :align_items, :flex_end)}
"center" ->
{:ok, Map.put(style, :align_items, :center)}
_ ->
{:error, :invalid_document}
end
end
defp put_align_self(style, value) do
case value |> String.trim() |> String.downcase() do
"auto" ->
{:ok, Map.put(style, :align_self, :auto)}
"stretch" ->
{:ok, Map.put(style, :align_self, :stretch)}
value when value in ["start", "flex-start"] ->
{:ok, Map.put(style, :align_self, :flex_start)}
value when value in ["end", "flex-end"] ->
{:ok, Map.put(style, :align_self, :flex_end)}
"center" ->
{:ok, Map.put(style, :align_self, :center)}
_ ->
{:error, :invalid_document}
end
end
defp put_grid_alignment(style, property, value) do
case value |> String.trim() |> String.downcase() do
"stretch" -> {:ok, Map.put(style, property, :stretch)}
value when value in ["start", "flex-start"] -> {:ok, Map.put(style, property, :flex_start)}
value when value in ["end", "flex-end"] -> {:ok, Map.put(style, property, :flex_end)}
"center" -> {:ok, Map.put(style, property, :center)}
_ -> {:error, :invalid_document}
end
end
defp put_border_collapse(style, value) do
case value |> String.trim() |> String.downcase() do
"collapse" -> {:ok, Map.put(style, :border_collapse, :collapse)}
"separate" -> {:ok, Map.put(style, :border_collapse, :separate)}
_ -> {:error, :invalid_document}
end
end
defp put_order(style, value) do
case Integer.parse(String.trim(value)) do
{order, ""} -> {:ok, Map.put(style, :order, order)}
_ -> {:error, :invalid_document}
end
end
defp put_nonnegative_number(style, key, value) do
case parse_nonnegative_number(value) do
{:ok, number} -> {:ok, Map.put(style, key, number)}
:error -> {:error, :invalid_document}
end
end
defp put_flex_basis(style, value) do
case value |> String.trim() |> String.downcase() do
"auto" ->
{:ok, Map.put(style, :flex_basis, :auto)}
_ ->
with {:ok, length} <- parse_length(value), do: {:ok, Map.put(style, :flex_basis, length)}
end
end
defp put_flex(style, value) do
tokens = value |> String.trim() |> String.downcase() |> String.split(~r/\s+/u, trim: true)
case tokens do
["none"] ->
{:ok,
style
|> Map.put(:flex_grow, 0.0)
|> Map.put(:flex_shrink, 0.0)
|> Map.put(:flex_basis, :auto)}
["auto"] ->
{:ok,
style
|> Map.put(:flex_grow, 1.0)
|> Map.put(:flex_shrink, 1.0)
|> Map.put(:flex_basis, :auto)}
["initial"] ->
{:ok,
style
|> Map.put(:flex_grow, 0.0)
|> Map.put(:flex_shrink, 1.0)
|> Map.put(:flex_basis, :auto)}
[grow] ->
with {:ok, grow} <- parse_nonnegative_number(grow) do
{:ok,
style
|> Map.put(:flex_grow, grow)
|> Map.put_new(:flex_shrink, 1.0)
|> Map.put_new(:flex_basis, 0.0)}
end
[grow, shrink] ->
with {:ok, grow} <- parse_nonnegative_number(grow),
{:ok, shrink} <- parse_nonnegative_number(shrink) do
{:ok,
style
|> Map.put(:flex_grow, grow)
|> Map.put(:flex_shrink, shrink)
|> Map.put_new(:flex_basis, 0.0)}
end
[grow, shrink, basis] ->
with {:ok, grow} <- parse_nonnegative_number(grow),
{:ok, shrink} <- parse_nonnegative_number(shrink),
{:ok, basis} <- flex_basis_value(basis) do
{:ok,
style
|> Map.put(:flex_grow, grow)
|> Map.put(:flex_shrink, shrink)
|> Map.put(:flex_basis, basis)}
end
_ ->
{:error, :invalid_document}
end
end
defp length_property(property) do
case property do
"width" -> :width
"height" -> :height
"min-width" -> :min_width
"max-width" -> :max_width
"max-height" -> :max_height
end
end
defp gap_property(property) do
case property do
"row-gap" -> :row_gap
"column-gap" -> :column_gap
end
end
defp flex_basis_value(value) do
case value do
"auto" -> {:ok, :auto}
value -> parse_length(value)
end
end
defp parse_grid_tracks(value) do
parsed_tracks =
value
|> String.trim()
|> String.downcase()
|> split_css_tokens()
|> Enum.map(&parse_grid_track_source/1)
case Enum.all?(parsed_tracks, &match?({:ok, _track}, &1)) do
true -> {:ok, Enum.flat_map(parsed_tracks, fn {:ok, tracks} -> tracks end)}
false -> :error
end
end
defp split_css_tokens(value) do
value
|> String.graphemes()
|> Enum.reduce({[], "", 0}, fn grapheme, {tokens, current, depth} ->
cond do
grapheme == "(" ->
{tokens, current <> grapheme, depth + 1}
grapheme == ")" ->
{tokens, current <> grapheme, max(depth - 1, 0)}
String.match?(grapheme, ~r/\s/u) and depth == 0 ->
case current do
"" -> {tokens, "", depth}
current -> {tokens ++ [current], "", depth}
end
true ->
{tokens, current <> grapheme, depth}
end
end)
|> then(fn {tokens, current, _depth} -> tokens ++ [current] end)
end
defp split_css_comma(value) do
value
|> String.graphemes()
|> Enum.reduce({[], "", 0}, fn grapheme, {tokens, current, depth} ->
cond do
grapheme == "(" ->
{tokens, current <> grapheme, depth + 1}
grapheme == ")" ->
{tokens, current <> grapheme, max(depth - 1, 0)}
grapheme == "," and depth == 0 ->
{tokens ++ [String.trim(current)], "", depth}
true ->
{tokens, current <> grapheme, depth}
end
end)
|> then(fn {tokens, current, _depth} -> tokens ++ [String.trim(current)] end)
|> Enum.reject(&(&1 == ""))
end
defp parse_grid_track_source(value) do
normalized = value |> String.trim() |> String.downcase()
case Regex.named_captures(~r/^repeat\((?[1-9]\d*)\s*,\s*(?