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*(?.+)\)$/u, normalized) do %{"count" => count, "track" => track} -> with {count, ""} <- Integer.parse(count), {:ok, parsed_track} <- parse_grid_track(track) do {:ok, List.duplicate(parsed_track, count)} else _ -> :error end _ -> with {:ok, track} <- parse_grid_track(normalized), do: {:ok, [track]} end end defp parse_grid_track(value) do normalized = value |> String.trim() |> String.downcase() cond do String.starts_with?(normalized, "minmax(") -> parse_minmax_grid_track(normalized) normalized == "auto" -> {:ok, :auto} Regex.match?(~r/^\d+(?:\.\d+)?fr$/u, normalized) -> {number, "fr"} = Float.parse(normalized) {:ok, {:fr, number}} true -> with {:ok, length} <- parse_length(normalized), do: {:ok, {:length, length}} end end defp parse_minmax_grid_track(value) do case Regex.named_captures(~r/^minmax\((?.+)\s*,\s*(?.+)\)$/u, value) do %{"max" => max_track} -> parse_grid_track(max_track) _ -> :error end end defp put_grid_placement(style, property, value) do case property do property when property in ["grid-column-start", "grid-column-end"] -> with {:ok, placement} <- parse_grid_line(value) do {:ok, Map.put(style, grid_line_property(property), placement)} end property when property in ["grid-row-start", "grid-row-end"] -> with {:ok, placement} <- parse_grid_line(value) do {:ok, Map.put(style, grid_line_property(property), placement)} end "grid-column" -> with {:ok, start_line, end_line} <- parse_grid_axis(value) do {:ok, style |> Map.put(:grid_column_start, start_line) |> Map.put(:grid_column_end, end_line)} end "grid-row" -> with {:ok, start_line, end_line} <- parse_grid_axis(value) do {:ok, style |> Map.put(:grid_row_start, start_line) |> Map.put(:grid_row_end, end_line)} end "grid-area" -> with {:ok, row_start, column_start, row_end, column_end} <- parse_grid_area(value) do {:ok, style |> Map.put(:grid_row_start, row_start) |> Map.put(:grid_column_start, column_start) |> Map.put(:grid_row_end, row_end) |> Map.put(:grid_column_end, column_end)} end end end defp parse_grid_axis(value) do case value |> String.trim() |> String.downcase() |> String.split("/", trim: true) do [start_line] -> with {:ok, start_line} <- parse_grid_line(start_line), do: {:ok, start_line, :auto} [start_line, end_line] -> with {:ok, start_line} <- parse_grid_line(start_line), {:ok, end_line} <- parse_grid_line(end_line) do {:ok, start_line, end_line} end _ -> :error end end defp parse_grid_area(value) do case value |> String.trim() |> String.downcase() |> String.split("/", trim: true) do [row_start, column_start, row_end, column_end] -> with {:ok, row_start} <- parse_grid_line(row_start), {:ok, column_start} <- parse_grid_line(column_start), {:ok, row_end} <- parse_grid_line(row_end), {:ok, column_end} <- parse_grid_line(column_end) do {:ok, row_start, column_start, row_end, column_end} end _ -> :error end end defp parse_grid_line(value) do normalized = value |> String.trim() |> String.downcase() case normalized do "auto" -> {:ok, :auto} _ -> parse_grid_number_or_span(normalized) end end defp parse_grid_number_or_span(value) do cond do Regex.match?(~r/^span\s+[1-9]\d*$/u, value) -> ["span", count] = String.split(value, ~r/\s+/u, trim: true) {count, ""} = Integer.parse(count) {:ok, {:span, count}} Regex.match?(~r/^[1-9]\d*$/u, value) -> {line, ""} = Integer.parse(value) {:ok, line} true -> :error end end defp grid_template_property(property) do case property do "grid-template-columns" -> :grid_template_columns "grid-template-rows" -> :grid_template_rows end end defp grid_auto_property(property) do case property do "grid-auto-columns" -> :grid_auto_columns "grid-auto-rows" -> :grid_auto_rows end end defp grid_line_property(property) do case property do "grid-column-start" -> :grid_column_start "grid-column-end" -> :grid_column_end "grid-row-start" -> :grid_row_start "grid-row-end" -> :grid_row_end end end defp put_text_align(style, value) do case String.downcase(String.trim(value)) do "left" -> {:ok, Map.put(style, :text_align, :left)} "center" -> {:ok, Map.put(style, :text_align, :center)} "right" -> {:ok, Map.put(style, :text_align, :right)} _ -> {:error, :invalid_document} end end defp put_font_weight(style, value) do normalized = value |> String.trim() |> String.downcase() cond do normalized == "normal" -> {:ok, Map.put(style, :font_weight, 400)} normalized == "bold" -> {:ok, Map.put(style, :font_weight, 700)} Regex.match?(~r/^[1-9]00$/u, normalized) -> {weight, ""} = Integer.parse(normalized) {:ok, Map.put(style, :font_weight, weight)} true -> {:error, :invalid_document} end end defp put_font_style(style, value) do case String.downcase(String.trim(value)) do "normal" -> {:ok, Map.put(style, :font_style, :normal)} "italic" -> {:ok, Map.put(style, :font_style, :italic)} _ -> {:error, :invalid_document} end end defp put_font_family(style, value) do with {:ok, families, font_face} <- resolve_font( value, Map.fetch!(style, :font_weight), Map.fetch!(style, :font_style), Map.fetch!(style, :_font_registry) ) do {:ok, style |> Map.put(:font_families, families) |> Map.put(:font_family, font_face.family) |> Map.put(:font_face, font_face)} end end defp put_font_face(style) do with {:ok, families, font_face} <- resolve_font( Map.fetch!(style, :font_families), Map.fetch!(style, :font_weight), Map.fetch!(style, :font_style), Map.fetch!(style, :_font_registry) ) do {:ok, style |> Map.put(:font_families, families) |> Map.put(:font_family, font_face.family) |> Map.put(:font_face, font_face)} end end defp parse_color(value, current_color) do normalized = value |> String.trim() |> String.downcase() cond do normalized == "currentcolor" and not is_nil(current_color) -> {:ok, current_color} normalized == "transparent" -> {:ok, :transparent} Map.has_key?(named_colors(), normalized) -> {:ok, Map.fetch!(named_colors(), normalized)} Regex.match?(~r/^#[0-9a-f]{6}$/u, normalized) -> <<"#", red::binary-size(2), green::binary-size(2), blue::binary-size(2)>> = normalized {:ok, {hex_to_pdf_color(red), hex_to_pdf_color(green), hex_to_pdf_color(blue)}} Regex.match?(~r/^#[0-9a-f]{8}$/u, normalized) -> <<"#", red::binary-size(2), green::binary-size(2), blue::binary-size(2), _alpha::binary-size(2)>> = normalized {:ok, {hex_to_pdf_color(red), hex_to_pdf_color(green), hex_to_pdf_color(blue)}} Regex.match?(~r/^#[0-9a-f]{3}$/u, normalized) -> <<"#", red::binary-size(1), green::binary-size(1), blue::binary-size(1)>> = normalized {:ok, {hex_to_pdf_color(red <> red), hex_to_pdf_color(green <> green), hex_to_pdf_color(blue <> blue)}} String.starts_with?(normalized, "rgb(") or String.starts_with?(normalized, "rgba(") -> parse_rgb_color(normalized) true -> :error end end defp parse_rgb_color(value) do case Regex.named_captures(~r/^rgba?\((?.+)\)$/u, value) do %{"channels" => channels} -> channels = split_css_comma(channels) case channels do [red, green, blue] -> parse_rgb_channels(red, green, blue) [red, green, blue, _alpha] -> parse_rgb_channels(red, green, blue) _ -> :error end _ -> :error end end defp parse_rgb_channels(red, green, blue) do with {:ok, red} <- parse_rgb_channel(red), {:ok, green} <- parse_rgb_channel(green), {:ok, blue} <- parse_rgb_channel(blue) do {:ok, {red, green, blue}} end end defp parse_rgb_channel(channel) do normalized = String.trim(channel) cond do Regex.match?(~r/^\d+(?:\.\d+)?%$/u, normalized) -> {number, "%"} = Float.parse(normalized) {:ok, number |> max(0.0) |> min(100.0) |> Kernel./(100.0)} true -> case Float.parse(normalized) do {number, ""} -> {:ok, number |> max(0.0) |> min(255.0) |> Kernel./(255.0)} _ -> :error end end end defp parse_box_lengths(value) do parse_box_lengths(value, :nonnegative) end defp parse_box_lengths(value, length_context) do lengths = value |> String.split(~r/\s+/u, trim: true) |> Enum.map(&parse_length(&1, length_context)) case lengths do [{:ok, all}] -> {:ok, edges(all)} [{:ok, vertical}, {:ok, horizontal}] -> {:ok, edges(vertical, horizontal, vertical, horizontal)} [{:ok, top}, {:ok, horizontal}, {:ok, bottom}] -> {:ok, edges(top, horizontal, bottom, horizontal)} [{:ok, top}, {:ok, right}, {:ok, bottom}, {:ok, left}] -> {:ok, edges(top, right, bottom, left)} _ -> :error end end defp parse_gap(value) do gaps = value |> String.split(~r/\s+/u, trim: true) |> Enum.map(&parse_length/1) case gaps do [{:ok, both}] -> {:ok, both, both} [{:ok, row_gap}, {:ok, column_gap}] -> {:ok, row_gap, column_gap} _ -> :error end end defp parse_length(value) do parse_length(value, :nonnegative) end defp parse_size(value) do normalized = String.trim(value) cond do Regex.match?(~r/^\d+(?:\.\d+)?%$/u, normalized) -> {number, "%"} = Float.parse(normalized) {:ok, {:percent, number / 100}} String.starts_with?(String.downcase(normalized), "min(") -> parse_min_size(normalized) true -> parse_length(value) end end defp parse_min_size(value) do case Regex.named_captures(~r/^min\((?.+)\)$/u, String.downcase(String.trim(value))) do %{"sizes" => sizes} -> parsed_sizes = sizes |> split_css_comma() |> Enum.map(&parse_size/1) case Enum.all?(parsed_sizes, &match?({:ok, _size}, &1)) do true -> {:ok, {:min, Enum.map(parsed_sizes, fn {:ok, size} -> size end)}} false -> :error end _ -> :error end end defp parse_length(value, length_context) do normalized = String.trim(value) case Regex.run(~r/^(?:(0)|(-?\d+(?:\.\d+)?)(pt|px|mm|cm|in|rem))$/u, normalized) do [_, "0"] -> {:ok, 0.0} [_, "", value, unit] -> {number, ""} = Float.parse(value) case length_context == :margin or number >= 0 do true -> {:ok, number * points_per_unit(unit)} false -> :error end _ -> :error end end defp parse_letter_spacing(value, font_size) do normalized = String.trim(value) case Regex.run(~r/^(?:(0)|(-?\d+(?:\.\d+)?)(pt|px|mm|cm|in|rem|em))$/u, normalized) do [_, "0"] -> {:ok, 0.0} [_, "", value, "em"] -> {number, ""} = Float.parse(value) {:ok, number * font_size} [_, "", value, unit] -> {number, ""} = Float.parse(value) {:ok, number * points_per_unit(unit)} _ -> :error end end defp parse_aspect_ratio(value) do tokens = value |> String.trim() |> String.downcase() |> String.split("/", trim: true) |> Enum.map(&String.trim/1) case tokens do [ratio] -> with {:ok, number} <- parse_positive_number(ratio), do: {:ok, number} [width, height] -> with {:ok, width} <- parse_positive_number(width), {:ok, height} <- parse_positive_number(height) do {:ok, width / height} end _ -> :error end end defp parse_line_height(value) do normalized = value |> String.trim() |> String.downcase() cond do normalized == "normal" -> {:ok, :normal} match?({:ok, _length}, parse_length(normalized)) -> parse_length(normalized) true -> with {:ok, multiplier} <- parse_nonnegative_number(normalized) do {:ok, {:multiplier, multiplier}} end end end defp parse_positive_number(value) do case Float.parse(String.trim(value)) do {number, ""} when number > 0 -> {:ok, number} _ -> :error end end defp parse_nonnegative_number(value) do case Float.parse(String.trim(value)) do {number, ""} when number >= 0 -> {:ok, number} _ -> :error end end defp put_box_lengths(style, property, lengths) do case property do "margin" -> style |> Map.put(:margin, lengths) |> Map.put(:margin_after, lengths.bottom) "padding" -> Map.put(style, :padding, lengths) "border-width" -> Map.put(style, :border_widths, lengths) end end defp put_edge_length(style, property, length) do [box_property, edge] = String.split(property, "-", parts: 2) key = String.to_existing_atom(edge) map_key = String.to_existing_atom(box_property) lengths = style |> Map.fetch!(map_key) |> Map.put(key, length) style = Map.put(style, map_key, lengths) case property do "margin-bottom" -> Map.put(style, :margin_after, length) _ -> style end end defp put_border_edge_width(style, property, width) do edge = property |> String.replace_prefix("border-", "") |> String.to_existing_atom() Map.update!(style, :border_widths, &Map.put(&1, edge, width)) end defp put_border_edge_color(style, property, color) do edge = property |> String.replace_prefix("border-", "") |> String.to_existing_atom() style |> Map.update( :border_colors, edges(Map.get(style, :border_color, {0, 0, 0})), &Map.put(&1, edge, color) ) |> Map.put(:border_color, color) end defp border_edge_property(property) do property |> String.replace_suffix("-width", "") |> String.replace_suffix("-color", "") |> String.replace_suffix("-style", "") end defp parse_border(value, current_color) do tokens = String.split(value, ~r/\s+/u, trim: true) case tokens do ["none"] -> {:ok, edges(0.0), current_color} ["0"] -> {:ok, edges(0.0), current_color} tokens -> parsed = Enum.reduce_while(tokens, %{width: nil, style: nil, color: nil}, fn token, acc -> cond do token in ["solid", "dashed"] and is_nil(acc.style) -> {:cont, %{acc | style: String.to_atom(token)}} is_nil(acc.width) and match?({:ok, _}, parse_length(token)) -> {:ok, width} = parse_length(token) {:cont, %{acc | width: width}} is_nil(acc.color) and match?({:ok, _}, parse_color(token, current_color)) -> {:ok, color} = parse_color(token, current_color) {:cont, %{acc | color: color}} true -> {:halt, :error} end end) case parsed do %{width: width, style: style, color: color} when is_number(width) and style in [:solid, :dashed] -> border_color = case color do nil -> current_color :transparent -> current_color color -> color end {:ok, edges(width), border_color} _ -> :error end end end defp parse_border_edge(value, current_color) do case value |> String.trim() |> String.downcase() do "none" -> {:ok, 0.0, nil} "0" -> {:ok, 0.0, nil} _ -> with {:ok, border_widths, border_color} <- parse_border(value, current_color) do {:ok, Enum.max(Map.values(border_widths)), border_color} end end end defp transparent_color_to_nil(color) do case color do :transparent -> nil color -> color end end defp page_break_value(value) do case String.downcase(String.trim(value)) do "auto" -> {:ok, :auto} "always" -> {:ok, :page} "page" -> {:ok, :page} _ -> :error end end defp load_image_style(src, opts) do case image_source(src, Keyword.get(opts, :base_url)) do {:ok, svg, :svg} -> {:ok, %{svg_image: svg}} {:ok, data, expected_format} -> with {:ok, image} <- decode_image(data) do case is_nil(expected_format) or image.format == expected_format do true -> {:ok, %{image: image}} false -> :error end end _ -> :error end end defp data_uri_image_source(src) do case Regex.run(~r/^data:image\/svg\+xml;base64,([A-Za-z0-9+\/=\r\n]+)$/u, src) do [_, encoded] -> with {:ok, svg} <- Base.decode64(String.replace(encoded, ~r/\s/u, "")) do {:ok, svg, :svg} end _ -> case Regex.run(~r/^data:(image\/(?:png|jpeg));base64,([A-Za-z0-9+\/=\r\n]+)$/u, src) do [_, mime_type, encoded] -> with {:ok, data} <- Base.decode64(String.replace(encoded, ~r/\s/u, "")) do {:ok, data, data_uri_format(mime_type)} end _ -> :error end end end defp rasterize_svg(svg, raster_options) do case String.valid?(svg) do true -> sanitized_svg = svg |> String.replace(~r/<\?xml[^>]*>/iu, "") |> String.replace(~r/]*(?:\[[\s\S]*?\]\s*)?>/iu, "") case Resvg.svg_string_to_png_buffer( sanitized_svg, [ resources_dir: System.tmp_dir!(), shape_rendering: :optimize_speed, text_rendering: :optimize_speed, image_rendering: :optimize_speed, skip_system_fonts: true ] ++ raster_options ) do {:ok, png} -> {:ok, IO.iodata_to_binary(png)} {:error, _reason} -> :error end false -> :error end end defp svg_raster_options(style) do dimensions = [ width: style |> target_image_width() |> points_to_pixels(), height: style |> target_image_height() |> points_to_pixels() ] |> Enum.reject(fn {_key, value} -> is_nil(value) end) dimensions end defp target_image_width(style) do case {Map.get(style, :width), Map.get(style, :height), Map.get(style, :aspect_ratio)} do {width, _height, _ratio} when is_number(width) -> width {_width, height, ratio} when is_number(height) and is_number(ratio) -> height * ratio _ -> nil end end defp target_image_height(style) do case {Map.get(style, :height), Map.get(style, :width), Map.get(style, :aspect_ratio)} do {height, _width, _ratio} when is_number(height) -> height {_height, width, ratio} when is_number(width) and is_number(ratio) -> width / ratio _ -> nil end end defp points_to_pixels(points) do case points do points when is_number(points) -> max(round(points / 0.75), 1) _ -> nil end end defp image_source(src, base_url) do cond do String.starts_with?(src, "data:") -> data_uri_image_source(src) Path.type(src) == :absolute -> with {:ok, data} <- File.read(src), do: {:ok, data, nil} is_binary(base_url) -> with {:ok, path} <- relative_image_path(src, base_url), {:ok, data} <- File.read(path) do {:ok, data, nil} end true -> :error end end defp data_uri_format(mime_type) do case mime_type do "image/png" -> :png "image/jpeg" -> :jpeg end end defp relative_image_path(src, base_url) do case String.contains?(src, ["\0", "://"]) do true -> :error false -> base_path = case URI.parse(base_url) do %URI{scheme: nil, path: path} when is_binary(path) -> path %URI{scheme: "file", path: path} when is_binary(path) -> path _ -> nil end case base_path do nil -> :error base_path -> expanded_base = Path.expand(base_path) path = Path.expand(src, expanded_base) case path == expanded_base or String.starts_with?(path, expanded_base <> "/") do true -> {:ok, path} false -> :error end end end end defp decode_image(data) do cond do String.starts_with?(data, <<137, 80, 78, 71, 13, 10, 26, 10>>) -> decode_png(data) String.starts_with?(data, <<255, 216>>) -> decode_jpeg(data) true -> :error end end defp decode_png(<<137, 80, 78, 71, 13, 10, 26, 10, chunks::binary>>) do with {:ok, parsed} <- png_chunks(chunks, %{idat: []}), %{ width_px: width, height_px: height, bit_depth: 8, color_type: color_type, compression: 0, filter: 0, interlace: 0, idat: idat } when width > 0 and height > 0 and color_type in [2, 6] <- parsed, {:ok, inflated} <- png_inflate(Enum.join(idat, "")), {:ok, rgb_data, alpha_data} <- png_image_data(inflated, width, height, color_type) do image = %{ format: :png, data: rgb_data, width_px: width, height_px: height, width: width * 0.75, height: height * 0.75, color_space: :device_rgb, bits_per_component: 8 } {:ok, case alpha_data do nil -> image alpha_data -> Map.put(image, :alpha_data, alpha_data) end} else _ -> :error end end defp png_chunks(chunks, acc) do case chunks do <<0::32, "IEND", _crc::32>> -> {:ok, acc} <> -> png_chunk(type, data, rest, acc) _ -> :error end end defp png_chunk(type, data, rest, acc) do case type do "IHDR" -> case data do <> -> png_chunks( rest, Map.merge(acc, %{ width_px: width, height_px: height, bit_depth: bit_depth, color_type: color_type, compression: compression, filter: filter, interlace: interlace }) ) _ -> :error end "IDAT" -> png_chunks(rest, Map.update!(acc, :idat, &(&1 ++ [data]))) _ -> png_chunks(rest, acc) end end defp png_inflate(data) do try do {:ok, :zlib.uncompress(data)} rescue ErlangError -> :error end end defp png_image_data(data, width, height, color_type) do bytes_per_pixel = case color_type do 2 -> 3 6 -> 4 end row_size = width * bytes_per_pixel case png_rows(data, width, height, bytes_per_pixel, row_size, [], "") do {:ok, rows} -> {rgb, alpha} = split_png_rows(rows, color_type) {:ok, rgb, alpha} :error -> :error end end defp split_png_rows(rows, color_type) do case color_type do 2 -> {Enum.join(rows, ""), nil} 6 -> {rgb_rows, alpha_rows} = Enum.map(rows, &split_png_rgba/1) |> Enum.unzip() alpha = Enum.join(alpha_rows, "") case alpha == :binary.copy(<<255>>, byte_size(alpha)) do true -> {Enum.join(rgb_rows, ""), nil} false -> {Enum.join(rgb_rows, ""), alpha} end end end defp png_rows(data, _width, height, _bytes_per_pixel, _row_size, rows, _previous) when length(rows) == height do case data do "" -> {:ok, Enum.reverse(rows)} _ -> :error end end defp png_rows(data, width, height, bytes_per_pixel, row_size, rows, previous) do case data do <> -> with {:ok, decoded} <- png_unfilter_row(filter, row, previous, bytes_per_pixel) do png_rows(rest, width, height, bytes_per_pixel, row_size, [decoded | rows], decoded) end _ -> :error end end defp png_unfilter_row(filter, row, previous, bytes_per_pixel) do case filter do 0 -> {:ok, row} filter when filter in [1, 2, 3, 4] -> previous = case previous do "" -> :binary.copy(<<0>>, byte_size(row)) previous -> previous end {:ok, png_unfilter_bytes(filter, row, previous, bytes_per_pixel, 0, [], [], [])} _ -> :error end end defp png_unfilter_bytes( _filter, "", "", _bytes_per_pixel, _index, _left_window, _up_left_window, acc ) do acc |> Enum.reverse() |> :binary.list_to_bin() end defp png_unfilter_bytes( filter, <>, <>, bytes_per_pixel, index, left_window, up_left_window, acc ) do left = if index >= bytes_per_pixel, do: hd(left_window), else: 0 up_left = if index >= bytes_per_pixel, do: hd(up_left_window), else: 0 predictor = case filter do 1 -> left 2 -> up 3 -> div(left + up, 2) 4 -> png_paeth(left, up, up_left) end decoded = rem(byte + predictor, 256) png_unfilter_bytes( filter, row_rest, previous_rest, bytes_per_pixel, index + 1, png_window_push(left_window, decoded, bytes_per_pixel), png_window_push(up_left_window, up, bytes_per_pixel), [decoded | acc] ) end defp png_window_push(window, byte, bytes_per_pixel) do case length(window) < bytes_per_pixel do true -> window ++ [byte] false -> tl(window) ++ [byte] end end defp png_paeth(left, up, up_left) do estimate = left + up - up_left left_distance = abs(estimate - left) up_distance = abs(estimate - up) up_left_distance = abs(estimate - up_left) cond do left_distance <= up_distance and left_distance <= up_left_distance -> left up_distance <= up_left_distance -> up true -> up_left end end defp split_png_rgba(row) do row |> :binary.bin_to_list() |> Enum.chunk_every(4) |> Enum.reduce({"", ""}, fn [red, green, blue, alpha], {rgb, mask} -> {rgb <> <>, mask <> <>} end) end defp decode_jpeg(data) do with {:ok, width, height, color_space} <- jpeg_dimensions(data) do {:ok, %{ format: :jpeg, data: data, width_px: width, height_px: height, width: width * 0.75, height: height * 0.75, color_space: color_space, bits_per_component: 8 }} end end defp jpeg_dimensions(<<255, 216, rest::binary>>) do jpeg_marker_dimensions(rest) end defp jpeg_marker_dimensions(data) do case data do <<255, marker, rest::binary>> when marker in [192, 194] -> case rest do <<_length::16, 8, height::16, width::16, components, _component_data::binary>> when width > 0 and height > 0 and components in [1, 3, 4] -> {:ok, width, height, jpeg_color_space(components)} _ -> :error end <<255, marker, rest::binary>> when marker in [216, 217] -> jpeg_marker_dimensions(rest) <<255, marker, rest::binary>> when marker >= 208 and marker <= 215 -> jpeg_marker_dimensions(rest) <<255, _marker, length::16, _segment::binary-size(length - 2), rest::binary>> when length >= 2 -> jpeg_marker_dimensions(rest) _ -> :error end end defp jpeg_color_space(components) do case components do 1 -> :device_gray 3 -> :device_rgb 4 -> :device_cmyk end end defp named_colors do %{ "black" => {0, 0, 0}, "blue" => {0, 0, 1}, "gray" => {0.5019607843, 0.5019607843, 0.5019607843}, "green" => {0, 0.5019607843, 0}, "grey" => {0.5019607843, 0.5019607843, 0.5019607843}, "red" => {1, 0, 0}, "white" => {1, 1, 1} } end defp valid_link_url?(href) do case href do href when is_binary(href) -> Regex.match?(~r/^(https?:\/\/[^\s<>]+|mailto:[^\s<>@]+@[^\s<>@]+)$/iu, href) _ -> false end end defp hex_to_pdf_color(hex) do {integer, ""} = Integer.parse(hex, 16) integer / 255 end defp points_per_unit(unit) do case unit do "pt" -> 1.0 "px" -> 0.75 "mm" -> 72.0 / 25.4 "cm" -> 72.0 / 2.54 "in" -> 72.0 "rem" -> 12.0 end end end