defmodule NativeElixirPdfUtilities.HtmlToPdf.Layout do @moduledoc """ Layout engine for the native HTML-to-PDF renderer. Lays out block text elements, inline text runs, box styling, lists, link annotation bounds, deterministic tables, a documented flexbox/grid subset, image boxes, embedded-font text metrics, and pagination metadata. """ alias NativeElixirPdfUtilities.HtmlToPdf.Font @type box :: map() @type layout_tree :: %{ type: :layout, page_size: term(), margin: term(), boxes: [box()] } @type render_option :: NativeElixirPdfUtilities.HtmlToPdf.render_option() @doc """ Converts a styled document tree into a layout tree. """ @spec layout(term(), term()) :: {:ok, layout_tree()} | {:error, :invalid_layout | :invalid_margin | :invalid_page_size} def layout(styled_tree, opts \\ []) do case {styled_tree, opts} do {%{type: :document, children: children}, opts} when is_list(children) and is_list(opts) -> with {:ok, page_size} <- page_size(Keyword.get(opts, :page_size, :a4)), {:ok, margin} <- margin(Keyword.get(opts, :margin, 0)), {:ok, boxes} <- layout_blocks(children, page_size, margin) do {page_width, page_height} = page_size {:ok, %{ type: :layout, page_size: page_size, margin: margin, boxes: boxes, content_width: page_width - margin * 2, content_height: page_height - margin * 2 }} end _ -> {:error, :invalid_layout} end end @spec layout_blocks([term()], {number(), number()}, number()) :: {:ok, [box()]} | {:error, :invalid_layout} defp layout_blocks(children, page_size, margin) do {page_width, page_height} = page_size result = Enum.reduce(children, {:ok, [], page_height - margin}, fn child, acc -> case acc do {:ok, boxes, y} -> case layout_block(child, margin, y, page_width - margin * 2) do {:ok, block_boxes, next_y} -> {:ok, boxes ++ block_boxes, next_y} {:error, reason} -> {:error, reason} end {:error, reason} -> {:error, reason} end end) case result do {:ok, boxes, _y} -> {:ok, boxes} {:error, reason} -> {:error, reason} end end @spec layout_block(term(), number(), number(), number()) :: {:ok, [box()], number()} | {:error, :invalid_layout} defp layout_block(block, x, y, width) do case block do %{type: :element, style: %{display: :none}} -> {:ok, [], y} %{type: :element, style: %{display: :block} = style, children: children} when is_list(children) -> with line_height when is_number(line_height) <- Map.get(style, :line_height), font_size when is_number(font_size) <- Map.get(style, :font_size) do margin = Map.get(style, :margin, edges(0.0, 0.0, Map.get(style, :margin_after, 0.0), 0.0)) padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) box_x = x + margin.left box_top = y - margin.top available_box_width = width - margin.left - margin.right content_width = resolved_size( style, :width, available_box_width - horizontal_box_size(style), available_box_width - horizontal_box_size(style) ) box_width = content_width + border_widths.left + padding.left + padding.right + border_widths.right content_x = box_x + border_widths.left + padding.left content_top = box_top - border_widths.top - padding.top flow_metadata = style |> break_metadata() |> Map.put(:flow_id, {:block, box_x, box_top}) case layout_block_content( children, style, content_x, content_top, content_width, flow_metadata ) do {:ok, content_boxes, content_height} -> content_box_height = resolved_size(style, :height, nil, content_height) box_height = border_widths.top + padding.top + content_box_height + padding.bottom + border_widths.bottom background_box = style |> background_box(box_x, box_top - box_height, box_width, box_height) |> tag_boxes(flow_metadata) next_y = box_top - box_height - margin.bottom page_break_box = %{ type: :page_break, x: box_x, y: box_top, width: box_width, height: 0.0 } |> Map.merge(Map.put(flow_metadata, :flow_id, {:page_break, box_x, box_top})) boxes = case {background_box ++ content_boxes, flow_metadata} do {[], %{break_before: :page}} -> [page_break_box] {[], %{break_after: :page}} -> [page_break_box] {boxes, _metadata} -> boxes end {:ok, boxes, next_y} {:error, reason} -> {:error, reason} end else _ -> {:error, :invalid_layout} end %{type: :element, style: %{display: :image} = style} -> layout_image(style, x, y, width) %{type: :element, style: %{display: :list} = style, children: children} when is_list(children) -> layout_list(style, children, x, y, width) %{type: :element, style: %{display: :table} = style, children: children} when is_list(children) -> layout_table(style, children, x, y, width) %{type: :element, style: %{display: display} = style, children: children} when display in [:flex, :inline_flex] and is_list(children) -> layout_flex(style, children, x, y, width) %{type: :element, style: %{display: display} = style, children: children} when display in [:grid, :inline_grid] and is_list(children) -> layout_grid(style, children, x, y, width) _ -> {:error, :invalid_layout} end end defp layout_block_content(children, style, x, y, width, metadata) do case inline_runs(children) do {:ok, runs} -> {boxes, content_height} = inline_text_boxes(runs, style, x, y, width, metadata) {:ok, boxes, content_height} {:error, _reason} -> with {:ok, boxes, next_y} <- layout_block_flow(children, style, x, y, width) do {:ok, boxes, y - next_y} end end end defp layout_block_flow(children, style, x, y, width) do result = Enum.reduce_while(children, {:ok, [], y}, fn child, {:ok, boxes, current_y} -> case layout_flow_child(child, style, x, current_y, width) do {:ok, child_boxes, next_y} -> {:cont, {:ok, boxes ++ child_boxes, next_y}} {:error, reason} -> {:halt, {:error, reason}} end end) case result do {:ok, boxes, next_y} -> {:ok, boxes, next_y} {:error, reason} -> {:error, reason} end end defp layout_flow_child(child, style, x, y, width) do case child do %{type: :text, text: text} when is_binary(text) -> case String.trim(text) do "" -> {:ok, [], y} _ -> with {:ok, runs} <- inline_runs([child]) do {boxes, content_height} = inline_text_boxes(runs, style, x, y, width, %{}) {:ok, boxes, y - content_height} end end %{type: :element, style: %{display: display}} when display in [:inline, :line_break] -> with {:ok, runs} <- inline_runs([child]) do {boxes, content_height} = inline_text_boxes(runs, style, x, y, width, %{}) {:ok, boxes, y - content_height} end _ -> layout_block(child, x, y, width) end end defp layout_image(style, x, y, width) do margin = Map.get(style, :margin, edges(0.0, 0.0, Map.get(style, :margin_after, 0.0), 0.0)) padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) available_content_width = width - margin.left - margin.right - horizontal_box_size(style) {content_width, content_height} = image_content_size(style, available_content_width, nil) box_x = x + margin.left box_top = y - margin.top box_width = content_width + border_widths.left + padding.left + padding.right + border_widths.right box_height = border_widths.top + padding.top + content_height + padding.bottom + border_widths.bottom flow_metadata = style |> break_metadata() |> Map.put(:flow_id, {:image, box_x, box_top}) background_box = style |> background_box(box_x, box_top - box_height, box_width, box_height) |> tag_boxes(flow_metadata) image_box = %{ type: :image, x: box_x + border_widths.left + padding.left, y: box_top - border_widths.top - padding.top - content_height, width: content_width, height: content_height, image: Map.fetch!(style, :image) } |> Map.merge(flow_metadata) next_y = box_top - box_height - margin.bottom {:ok, background_box ++ [image_box], next_y} end defp image_content_size(style) do image_content_size(style, nil, nil) end defp image_content_size(style, available_width, available_height) do image = Map.fetch!(style, :image) natural_width = Map.fetch!(image, :width) natural_height = Map.fetch!(image, :height) ratio = Map.get(style, :aspect_ratio, natural_width / natural_height) width = resolved_size(style, :width, available_width, nil) height = resolved_size(style, :height, available_height, nil) {content_width, content_height} = case {width, height} do {width, height} when is_number(width) and is_number(height) -> {width, height} {width, _height} when is_number(width) -> {width, width / ratio} {_width, height} when is_number(height) -> {height * ratio, height} _ -> {natural_width, natural_height} end apply_image_size_constraints( style, content_width, content_height, ratio, available_width, available_height, is_number(width) and is_number(height) ) end defp layout_grid(style, children, x, y, width) do margin = Map.get(style, :margin, edges(0.0, 0.0, Map.get(style, :margin_after, 0.0), 0.0)) padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) available_box_width = width - margin.left - margin.right content_width = resolved_size( style, :width, available_box_width - horizontal_box_size(style), available_box_width - horizontal_box_size(style) ) box_width = content_width + border_widths.left + padding.left + padding.right + border_widths.right box_x = x + margin.left box_top = y - margin.top content_x = box_x + border_widths.left + padding.left content_top = box_top - border_widths.top - padding.top with {:ok, items} <- grid_items(children) do placed_items = place_grid_items(items, style) column_count = grid_axis_count(placed_items, style, :column) row_count = grid_axis_count(placed_items, style, :row) column_tracks = grid_tracks(style, :column, column_count) row_tracks = grid_tracks(style, :row, row_count) column_intrinsics = grid_column_intrinsics(placed_items, column_count) column_sizes = resolve_grid_columns( column_tracks, column_intrinsics, content_width, grid_column_gap(style) ) placed_items = grid_items_with_resolved_heights( placed_items, style, column_sizes, grid_column_gap(style) ) row_intrinsics = grid_row_intrinsics(placed_items, row_count) available_height = resolved_size(style, :height, nil, nil) row_sizes = resolve_grid_rows( row_tracks, row_intrinsics, grid_row_gap(style), available_height, Map.get(style, :align_content, :stretch) ) content_height = available_height || grid_tracks_size(row_sizes, grid_row_gap(style)) flow_metadata = style |> break_metadata() |> Map.put(:flow_id, {:grid, box_x, box_top}) box_height = border_widths.top + padding.top + content_height + padding.bottom + border_widths.bottom background_box = style |> background_box(box_x, box_top - box_height, box_width, box_height) |> tag_boxes(flow_metadata) with {:ok, item_boxes} <- grid_item_boxes( placed_items, style, content_x, content_top, content_width, content_height, column_sizes, row_sizes, flow_metadata.flow_id ) do next_y = box_top - box_height - margin.bottom {:ok, background_box ++ item_boxes, next_y} end end end defp grid_items(children) do children |> Enum.with_index() |> Enum.reduce_while({:ok, []}, fn {child, index}, {:ok, acc} -> case grid_item(child, index) do {:ok, nil} -> {:cont, {:ok, acc}} {:ok, item} -> {:cont, {:ok, acc ++ [item]}} {:error, reason} -> {:halt, {:error, reason}} end end) end defp grid_item(child, index) do case child do %{type: :text, text: text} when is_binary(text) -> case String.trim(text) do "" -> {:ok, nil} _ -> style = child.style |> text_style() |> Map.put(:display, :inline) {:ok, build_grid_item(style, [%{text: text, style: child.style}], index)} end %{type: :element, style: %{display: :none}} -> {:ok, nil} %{type: :element, style: %{display: :image} = style} -> {:ok, build_grid_image_item(style, index)} %{type: :element, style: style, children: children} when is_list(children) -> case inline_runs(children) do {:ok, runs} -> {:ok, build_grid_item(style, runs, index)} {:error, _reason} -> build_grid_block_item(style, children, index) end _ -> {:error, :invalid_layout} end end defp build_grid_item(style, runs, index) do margin = Map.get(style, :margin, edges(0.0)) text_width = Enum.reduce(runs, 0.0, fn run, acc -> acc + text_width(run.text, run.style) end) line_height = Map.get(style, :line_height, 14.4) content_width = resolved_size(style, :width, nil, text_width) content_height = resolved_size(style, :height, nil, line_height) %{ index: index, style: style, runs: runs, intrinsic_width: content_width + horizontal_box_size(style), intrinsic_height: content_height + vertical_box_size(style), margin: margin, row_start: Map.get(style, :grid_row_start, :auto), row_end: Map.get(style, :grid_row_end, :auto), column_start: Map.get(style, :grid_column_start, :auto), column_end: Map.get(style, :grid_column_end, :auto) } end defp build_grid_block_item(style, children, index) do margin = Map.get(style, :margin, edges(0.0)) intrinsic_width = flex_block_intrinsic_width(children) content_width = resolved_size(style, :width, nil, intrinsic_width) case layout_container_content_height(style, children, max(content_width, 0.0)) do {:ok, content_height} -> content_height = resolved_size(style, :height, nil, content_height) {:ok, %{ index: index, style: style, children: children, intrinsic_width: content_width + horizontal_box_size(style), intrinsic_height: content_height + vertical_box_size(style), margin: margin, row_start: Map.get(style, :grid_row_start, :auto), row_end: Map.get(style, :grid_row_end, :auto), column_start: Map.get(style, :grid_column_start, :auto), column_end: Map.get(style, :grid_column_end, :auto) }} {:error, reason} -> {:error, reason} end end defp build_grid_image_item(style, index) do margin = Map.get(style, :margin, edges(0.0)) {content_width, content_height} = image_content_size(style) %{ index: index, style: style, image: Map.fetch!(style, :image), intrinsic_width: content_width + horizontal_box_size(style), intrinsic_height: content_height + vertical_box_size(style), margin: margin, row_start: Map.get(style, :grid_row_start, :auto), row_end: Map.get(style, :grid_row_end, :auto), column_start: Map.get(style, :grid_column_start, :auto), column_end: Map.get(style, :grid_column_end, :auto) } end defp place_grid_items(items, style) do column_count = style |> Map.get(:grid_template_columns, []) |> length() |> max(1) {_occupied, placed} = Enum.reduce(items, {MapSet.new(), []}, fn item, {occupied, acc} -> placement = grid_item_placement(item, column_count, occupied) occupied = placement.row_start..(placement.row_end - 1) |> Enum.reduce(occupied, fn row, row_acc -> placement.column_start..(placement.column_end - 1) |> Enum.reduce(row_acc, &MapSet.put(&2, {row, &1})) end) {occupied, acc ++ [Map.merge(item, placement)]} end) placed end defp grid_item_placement(item, column_count, occupied) do column_start = grid_line_start(item.column_start) row_start = grid_line_start(item.row_start) column_span = grid_axis_span(item.column_start, item.column_end) row_span = grid_axis_span(item.row_start, item.row_end) case {row_start, column_start} do {row_start, column_start} when is_integer(row_start) and is_integer(column_start) -> grid_placement(row_start, column_start, row_span, column_span) {row_start, :auto} when is_integer(row_start) -> column_start = first_free_grid_column(occupied, row_start, column_count, column_span) grid_placement(row_start, column_start, row_span, column_span) {:auto, column_start} when is_integer(column_start) -> row_start = first_free_grid_row(occupied, column_start, row_span, column_span) grid_placement(row_start, column_start, row_span, column_span) _ -> {row_start, column_start} = first_free_grid_cell(occupied, column_count, row_span, column_span) grid_placement(row_start, column_start, row_span, column_span) end end defp grid_placement(row_start, column_start, row_span, column_span) do %{ row_start: row_start, row_end: row_start + row_span, column_start: column_start, column_end: column_start + column_span } end defp grid_line_start(line) do case line do line when is_integer(line) -> line _ -> :auto end end defp grid_axis_span(start_line, end_line) do case {start_line, end_line} do {_start_line, {:span, span}} -> span {start_line, end_line} when is_integer(start_line) and is_integer(end_line) -> max(end_line - start_line, 1) {{:span, span}, _end_line} -> span _ -> 1 end end defp first_free_grid_column(occupied, row_start, column_count, column_span) do 1..column_count |> Enum.find(&grid_cells_free?(occupied, row_start, &1, 1, column_span)) |> case do nil -> column_count + 1 column -> column end end defp first_free_grid_row(occupied, column_start, row_span, column_span) do 1 |> Stream.iterate(&(&1 + 1)) |> Enum.find(&grid_cells_free?(occupied, &1, column_start, row_span, column_span)) end defp first_free_grid_cell(occupied, column_count, row_span, column_span) do 1 |> Stream.iterate(&(&1 + 1)) |> Enum.reduce_while(nil, fn row, _acc -> column = 1..column_count |> Enum.find(&grid_cells_free?(occupied, row, &1, row_span, column_span)) case column do nil -> {:cont, nil} column -> {:halt, {row, column}} end end) end defp grid_cells_free?(occupied, row_start, column_start, row_span, column_span) do row_start..(row_start + row_span - 1) |> Enum.all?(fn row -> column_start..(column_start + column_span - 1) |> Enum.all?(&(!MapSet.member?(occupied, {row, &1}))) end) end defp grid_axis_count(items, style, axis) do explicit_count = case axis do :column -> style |> Map.get(:grid_template_columns, []) |> length() :row -> style |> Map.get(:grid_template_rows, []) |> length() end item_count = items |> Enum.map(fn item -> case axis do :column -> item.column_end - 1 :row -> item.row_end - 1 end end) |> Enum.max(fn -> 0 end) max(max(explicit_count, item_count), 1) end defp grid_tracks(style, axis, count) do {template, auto_track} = case axis do :column -> {Map.get(style, :grid_template_columns, []), Map.get(style, :grid_auto_columns, :auto)} :row -> {Map.get(style, :grid_template_rows, []), Map.get(style, :grid_auto_rows, :auto)} end missing = max(count - length(template), 0) template ++ List.duplicate(auto_track, missing) end defp grid_column_intrinsics(items, column_count) do 1..column_count |> Enum.map(fn column -> items |> Enum.filter(&(&1.column_start == column and &1.column_end == column + 1)) |> Enum.map(&(&1.intrinsic_width + &1.margin.left + &1.margin.right)) |> Enum.max(fn -> 0.0 end) end) end defp resolve_grid_columns(tracks, column_intrinsics, available_size, gap) do gap_total = gap * max(length(tracks) - 1, 0) fixed_size = tracks |> Enum.with_index() |> Enum.reduce(0.0, fn {track, index}, acc -> case track do {:length, length} -> acc + length :auto -> acc + Enum.at(column_intrinsics, index, 0.0) {:fr, _fraction} -> acc end end) total_fraction = Enum.reduce(tracks, 0.0, fn track, acc -> case track do {:fr, fraction} -> acc + fraction _ -> acc end end) remaining = max(available_size - fixed_size - gap_total, 0.0) tracks |> Enum.with_index() |> Enum.map(fn {track, index} -> case track do {:length, length} -> length {:fr, fraction} when total_fraction > 0 -> remaining * fraction / total_fraction {:fr, _fraction} -> 0.0 :auto -> Enum.at(column_intrinsics, index, 0.0) end end) end defp grid_row_intrinsics(items, row_count) do 1..row_count |> Enum.map(fn row -> items |> Enum.filter(&(&1.row_start == row and &1.row_end == row + 1)) |> Enum.map(&(&1.intrinsic_height + &1.margin.top + &1.margin.bottom)) |> Enum.max(fn -> 0.0 end) end) end defp grid_items_with_resolved_heights(items, container_style, column_sizes, column_gap) do Enum.map( items, &grid_item_with_resolved_height(&1, container_style, column_sizes, column_gap) ) end defp grid_item_with_resolved_height(item, container_style, column_sizes, column_gap) do area_width = grid_track_span(column_sizes, column_gap, item.column_start, item.column_end) justify = grid_item_justify(item, container_style) box_width = grid_aligned_box_size(item, :width, justify, area_width) content_width = max(box_width - horizontal_box_size(item.style), 0.0) content_height = grid_item_content_height(item, content_width) Map.put(item, :intrinsic_height, content_height + vertical_box_size(item.style)) end defp grid_item_content_height(item, content_width) do cond do Map.has_key?(item, :runs) -> line_height = Map.get(item.style, :line_height, 14.4) resolved_size( item.style, :height, nil, inline_content_height(item.runs, content_width, line_height) ) Map.has_key?(item, :children) -> {:ok, content_height} = layout_container_content_height(item.style, item.children, content_width) resolved_size(item.style, :height, nil, content_height) Map.has_key?(item, :image) -> {_content_width, content_height} = image_content_size(item.style, content_width, nil) content_height end end defp resolve_grid_rows(tracks, row_intrinsics, gap, available_height, align_content) do fraction_total = Enum.reduce(tracks, 0.0, fn track, acc -> case track do {:fr, fraction} -> acc + fraction _ -> acc end end) fixed_size = tracks |> Enum.with_index() |> Enum.reduce(0.0, fn {track, index}, acc -> intrinsic = Enum.at(row_intrinsics, index, 0.0) case track do {:length, length} -> acc + length :auto -> acc + intrinsic {:fr, _fraction} -> acc end end) gap_total = gap * max(length(tracks) - 1, 0) remaining = max((available_height || fixed_size) - fixed_size - gap_total, 0.0) row_sizes = tracks |> Enum.with_index() |> Enum.map(fn {track, index} -> intrinsic = Enum.at(row_intrinsics, index, 0.0) case track do {:length, length} -> length :auto -> intrinsic {:fr, fraction} when fraction_total > 0 -> max(intrinsic, remaining * fraction / fraction_total) {:fr, _fraction} -> intrinsic end end) stretch_grid_rows(row_sizes, tracks, gap, available_height, align_content) end defp stretch_grid_rows(row_sizes, tracks, gap, available_height, align_content) do case {align_content, available_height} do {:stretch, available_height} when is_number(available_height) -> stretchable_indexes = tracks |> Enum.with_index() |> Enum.flat_map(fn {:auto, index} -> [index] {_track, _index} -> [] end) free_space = max(available_height - grid_tracks_size(row_sizes, gap), 0.0) cond do stretchable_indexes == [] -> row_sizes free_space == 0.0 -> row_sizes true -> extra = free_space / length(stretchable_indexes) Enum.with_index(row_sizes, fn size, index -> case index in stretchable_indexes do true -> size + extra false -> size end end) end _ -> row_sizes end end defp grid_tracks_size(sizes, gap) do Enum.sum(sizes) + gap * max(length(sizes) - 1, 0) end defp grid_item_boxes( items, style, x, y, content_width, content_height, column_sizes, row_sizes, container_flow_id ) do column_gap = grid_column_gap(style) row_gap = grid_row_gap(style) grid_width = grid_tracks_size(column_sizes, column_gap) grid_height = grid_tracks_size(row_sizes, row_gap) {content_x, content_gap} = grid_content_distribution( style, :justify_content, content_width, grid_width, column_gap, length(column_sizes) ) {content_y, row_gap} = grid_content_distribution( style, :align_content, content_height, grid_height, row_gap, length(row_sizes) ) Enum.reduce_while(items, {:ok, []}, fn item, {:ok, acc} -> item = position_grid_item( item, style, x + content_x, y - content_y, column_sizes, row_sizes, content_gap, row_gap ) {:ok, boxes} = flex_item_boxes(item, :row) tagged_boxes = tag_boxes(boxes, %{flow_id: container_flow_id}) {:cont, {:ok, acc ++ tagged_boxes}} end) end defp position_grid_item( item, container_style, x, y, column_sizes, row_sizes, column_gap, row_gap ) do area_x = x + grid_track_offset(column_sizes, column_gap, item.column_start) area_y = y - grid_track_offset(row_sizes, row_gap, item.row_start) area_width = grid_track_span(column_sizes, column_gap, item.column_start, item.column_end) area_height = grid_track_span(row_sizes, row_gap, item.row_start, item.row_end) justify = grid_item_justify(item, container_style) align = grid_item_align(item, container_style) box_width = grid_aligned_box_size(item, :width, justify, area_width) box_height = grid_aligned_box_size(item, :height, align, area_height) x_offset = grid_axis_position(justify, box_width, area_width) y_offset = grid_axis_position(align, box_height, area_height) Map.merge(item, %{ x: area_x + x_offset + item.margin.left, y: area_y - y_offset - item.margin.top, box_width: box_width, box_height: box_height }) end defp grid_content_distribution(style, property, container_size, grid_size, gap, count) do free_space = max(container_size - grid_size, 0.0) case Map.get(style, property, :flex_start) do :flex_end -> {free_space, gap} :center -> {free_space / 2, gap} :space_between when count > 1 -> {0.0, gap + free_space / (count - 1)} :space_around when count > 0 -> distributed_gap = gap + free_space / count {distributed_gap / 2, distributed_gap} :space_evenly when count > 0 -> distributed_gap = gap + free_space / (count + 1) {distributed_gap, distributed_gap} _ -> {0.0, gap} end end defp grid_track_offset(sizes, gap, start_line) do sizes |> Enum.take(start_line - 1) |> Enum.sum() |> Kernel.+(gap * max(start_line - 1, 0)) end defp grid_track_span(sizes, gap, start_line, end_line) do span_count = max(end_line - start_line, 1) sizes |> Enum.drop(start_line - 1) |> Enum.take(span_count) |> Enum.sum() |> Kernel.+(gap * max(span_count - 1, 0)) end defp grid_aligned_box_size(item, axis, align, area_size) do margin = case axis do :width -> item.margin.left + item.margin.right :height -> item.margin.top + item.margin.bottom end intrinsic = case axis do :width -> item.intrinsic_width :height -> item.intrinsic_height end case align do :stretch -> max(area_size - margin, 0.0) _ -> min(intrinsic, max(area_size - margin, 0.0)) end end defp grid_axis_position(align, box_size, area_size) do case align do :flex_end -> max(area_size - box_size, 0.0) :center -> max((area_size - box_size) / 2, 0.0) _ -> 0.0 end end defp grid_item_align(item, container_style) do case Map.get(item.style, :align_self, :auto) do :auto -> Map.get(container_style, :align_items, :stretch) align -> align end end defp grid_item_justify(item, container_style) do case Map.get(item.style, :justify_self, :auto) do :auto -> Map.get(container_style, :justify_items, :stretch) justify -> justify end end defp grid_column_gap(style) do Map.get(style, :column_gap, 0.0) end defp grid_row_gap(style) do Map.get(style, :row_gap, 0.0) end defp layout_flex(style, children, x, y, width) do margin = Map.get(style, :margin, edges(0.0, 0.0, Map.get(style, :margin_after, 0.0), 0.0)) padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) available_box_width = width - margin.left - margin.right content_width = resolved_size( style, :width, available_box_width - horizontal_box_size(style), available_box_width - horizontal_box_size(style) ) box_width = content_width + border_widths.left + padding.left + padding.right + border_widths.right box_x = x + margin.left box_top = y - margin.top content_x = box_x + border_widths.left + padding.left content_top = box_top - border_widths.top - padding.top main_axis = flex_main_axis(style) with {:ok, items} <- flex_items( children, main_axis, content_width, flex_available_cross(main_axis, content_width) ) do case items do [] -> box_height = border_widths.top + padding.top + padding.bottom + border_widths.bottom background_box = background_box(style, box_x, box_top - box_height, box_width, box_height) {:ok, tag_boxes(background_box, break_metadata(style)), box_top - box_height - margin.bottom} items -> lines = flex_lines(items, style, content_width) content_height = flex_content_height(lines, style) box_height = border_widths.top + padding.top + content_height + padding.bottom + border_widths.bottom flow_metadata = style |> break_metadata() |> Map.put(:flow_id, {:flex, box_x, box_top}) background_box = style |> background_box(box_x, box_top - box_height, box_width, box_height) |> tag_boxes(flow_metadata) with {:ok, item_boxes} <- flex_line_boxes( lines, style, content_x, content_top, content_width, content_height, flow_metadata.flow_id ) do next_y = box_top - box_height - margin.bottom {:ok, background_box ++ item_boxes, next_y} end end end end defp flex_items(children, main_axis, available_main, available_cross) do children |> Enum.with_index() |> Enum.reduce_while({:ok, []}, fn {child, index}, {:ok, acc} -> case flex_item(child, index, main_axis, available_main, available_cross) do {:ok, nil} -> {:cont, {:ok, acc}} {:ok, item} -> {:cont, {:ok, acc ++ [item]}} {:error, reason} -> {:halt, {:error, reason}} end end) |> case do {:ok, items} -> {:ok, Enum.sort_by(items, &{&1.order, &1.index})} {:error, reason} -> {:error, reason} end end defp flex_item(child, index, main_axis, available_main, available_cross) do case child do %{type: :text, text: text} when is_binary(text) -> case text |> collapse_inline_whitespace() |> String.trim() do "" -> {:ok, nil} text -> style = child.style |> text_style() |> Map.put(:display, :inline) runs = [%{text: text, style: child.style}] {:ok, build_flex_item(style, runs, index, main_axis, available_main, available_cross)} end %{type: :element, style: %{display: :none}} -> {:ok, nil} %{type: :element, style: %{display: :line_break}} -> {:ok, nil} %{type: :element, style: %{display: :image} = style} -> {:ok, build_flex_image_item(style, index, main_axis, available_main, available_cross)} %{type: :element, style: style, children: children} when is_list(children) -> case inline_runs(children) do {:ok, runs} -> {:ok, build_flex_item(style, runs, index, main_axis, available_main, available_cross)} {:error, _reason} -> build_flex_block_item( style, children, index, main_axis, available_main, available_cross ) end _ -> {:error, :invalid_layout} end end defp build_flex_item(style, runs, index, main_axis, available_main, available_cross) do margin = Map.get(style, :margin, edges(0.0)) text_width = Enum.reduce(runs, 0.0, fn run, acc -> acc + text_width(run.text, run.style) end) line_height = Map.get(style, :line_height, 14.4) {content_main, content_cross} = case main_axis do :row -> content_main = flex_basis(style, :width, available_main, text_width) content_height = inline_content_height(runs, content_main, line_height) {content_main, resolved_size(style, :height, nil, content_height)} :column -> {flex_basis(style, :height, nil, line_height), resolved_size(style, :width, available_cross, text_width)} end main_box = content_main + flex_main_box_size(style, main_axis) cross_box = content_cross + flex_cross_box_size(style, main_axis) %{ index: index, style: style, runs: runs, order: Map.get(style, :order, 0), flex_grow: Map.get(style, :flex_grow, 0.0), flex_shrink: Map.get(style, :flex_shrink, 1.0), main_axis: main_axis, main_box: main_box, cross_box: cross_box, outer_main: main_box + flex_main_margin_size(margin, main_axis), outer_cross: cross_box + flex_cross_margin_size(margin, main_axis), margin: margin } end defp build_flex_block_item(style, children, index, main_axis, available_main, available_cross) do margin = Map.get(style, :margin, edges(0.0)) intrinsic_width = flex_block_intrinsic_width(children) content_width = case main_axis do :row -> flex_basis(style, :width, available_main, min(intrinsic_width, available_main)) :column -> default_width = max(available_cross - flex_cross_box_size(style, :column), 0.0) resolved_size(style, :width, available_cross, default_width) end case layout_container_content_height(style, children, max(content_width, 0.0)) do {:ok, content_height} -> {content_main, content_cross} = case main_axis do :row -> {content_width, resolved_size(style, :height, nil, content_height)} :column -> {flex_basis(style, :height, nil, content_height), content_width} end main_box = content_main + flex_main_box_size(style, main_axis) cross_box = content_cross + flex_cross_box_size(style, main_axis) {:ok, %{ index: index, style: style, children: children, order: Map.get(style, :order, 0), flex_grow: Map.get(style, :flex_grow, 0.0), flex_shrink: Map.get(style, :flex_shrink, 1.0), main_axis: main_axis, main_box: main_box, cross_box: cross_box, outer_main: main_box + flex_main_margin_size(margin, main_axis), outer_cross: cross_box + flex_cross_margin_size(margin, main_axis), margin: margin }} {:error, reason} -> {:error, reason} end end defp layout_container_content_height(style, children, content_width) do case Map.get(style, :display) do display when display in [:grid, :inline_grid] -> nested_style = Map.put(style, :margin, edges(0.0)) available_width = content_width + horizontal_box_size(style) with {:ok, _boxes, next_y} <- layout_grid(nested_style, children, 0.0, 0.0, available_width) do {:ok, max(0.0 - next_y - vertical_box_size(style), 0.0)} end display when display in [:flex, :inline_flex] -> nested_style = Map.put(style, :margin, edges(0.0)) available_width = content_width + horizontal_box_size(style) with {:ok, _boxes, next_y} <- layout_flex(nested_style, children, 0.0, 0.0, available_width) do {:ok, max(0.0 - next_y - vertical_box_size(style), 0.0)} end _ -> with {:ok, _boxes, content_height} <- layout_block_content(children, style, 0.0, 0.0, content_width, %{}) do {:ok, content_height} end end end defp build_flex_image_item(style, index, main_axis, available_main, available_cross) do margin = Map.get(style, :margin, edges(0.0)) {available_width, available_height} = case main_axis do :row -> {available_main, available_cross} :column -> {available_cross, available_main} end {content_width, content_height} = image_content_size(style, available_width, available_height) {content_main, content_cross} = case main_axis do :row -> {content_width, content_height} :column -> {content_height, content_width} end main_box = content_main + flex_main_box_size(style, main_axis) cross_box = content_cross + flex_cross_box_size(style, main_axis) %{ index: index, style: style, image: Map.fetch!(style, :image), order: Map.get(style, :order, 0), flex_grow: Map.get(style, :flex_grow, 0.0), flex_shrink: Map.get(style, :flex_shrink, 1.0), main_axis: main_axis, main_box: main_box, cross_box: cross_box, outer_main: main_box + flex_main_margin_size(margin, main_axis), outer_cross: cross_box + flex_cross_margin_size(margin, main_axis), margin: margin } end defp flex_basis(style, size_property, available_size, intrinsic_size) do case Map.get(style, :flex_basis, :auto) do :auto -> resolved_size(style, size_property, available_size, intrinsic_size) basis when is_number(basis) -> basis end end defp flex_lines(items, style, content_width) do main_axis = flex_main_axis(style) gap = flex_main_gap(style) available_main = flex_available_main(style, main_axis, content_width, items, gap) wrap = Map.get(style, :flex_wrap, :nowrap) lines = Enum.reduce(items, [], fn item, lines -> append_flex_item_to_lines(lines, item, wrap, available_main, gap) end) lines |> Enum.map(&resolve_flex_line(&1, available_main, gap)) end defp append_flex_item_to_lines([], item, _wrap, _available_main, _gap) do [%{items: [item], base_main: item.outer_main}] end defp append_flex_item_to_lines(lines, item, wrap, available_main, gap) do [line | previous] = Enum.reverse(lines) next_base = line.base_main + gap + item.outer_main case wrap == :wrap and line.items != [] and next_base > available_main do true -> Enum.reverse(previous) ++ [line, %{items: [item], base_main: item.outer_main}] false -> Enum.reverse(previous) ++ [%{line | items: line.items ++ [item], base_main: next_base}] end end defp resolve_flex_line(line, available_main, gap) do item_gap_total = gap * max(length(line.items) - 1, 0) base_without_gap = Enum.reduce(line.items, 0.0, &(&1.outer_main + &2)) free_space = available_main - base_without_gap - item_gap_total items = resolve_flex_item_sizes(line.items, free_space) outer_main = Enum.reduce(items, 0.0, &(&1.outer_main + &2)) + item_gap_total cross = items |> Enum.map(& &1.outer_cross) |> Enum.max(fn -> 0.0 end) %{items: items, main: outer_main, cross: cross} end defp resolve_flex_item_sizes(items, free_space) do cond do free_space > 0 -> total_grow = Enum.reduce(items, 0.0, &(&1.flex_grow + &2)) case total_grow > 0 do true -> Enum.map(items, fn item -> extra = free_space * item.flex_grow / total_grow resize_flex_item(item, item.main_box + extra) end) false -> items end free_space < 0 -> scaled_shrink = Enum.reduce(items, 0.0, &(&1.flex_shrink * &1.main_box + &2)) case scaled_shrink > 0 do true -> Enum.map(items, fn item -> reduction = abs(free_space) * item.flex_shrink * item.main_box / scaled_shrink resize_flex_item(item, max(item.main_box - reduction, 0.0)) end) false -> items end true -> items end end defp resize_flex_item(item, main_box) do outer_main = main_box + flex_main_margin_size(item.margin, item.main_axis) %{item | main_box: main_box, outer_main: outer_main} end defp flex_line_boxes(lines, style, x, y, content_width, content_height, container_flow_id) do main_axis = flex_main_axis(style) cross_gap = flex_cross_gap(style) {lines, _cross_offset} = Enum.reduce(lines, {[], 0.0}, fn line, {acc, cross_offset} -> line_cross = flex_line_cross(line, style, length(lines)) positioned = flex_position_line( line, style, x, y, content_width, content_height, cross_offset, line_cross ) {acc ++ positioned, cross_offset + line_cross + cross_gap} end) Enum.reduce_while(lines, {:ok, []}, fn item, {:ok, acc} -> {:ok, boxes} = flex_item_boxes(item, main_axis) tagged_boxes = tag_boxes(boxes, %{flow_id: container_flow_id}) {:cont, {:ok, acc ++ tagged_boxes}} end) end defp flex_position_line( line, style, x, y, content_width, content_height, cross_offset, line_cross ) do main_axis = flex_main_axis(style) available_main = flex_position_available_main(main_axis, content_width, content_height) gap = flex_main_gap(style) items = flex_direction_items(line.items, style) {main_offset, item_gap} = flex_main_distribution(style, line, available_main, gap) items |> Enum.reduce({[], main_offset}, fn item, {acc, main_offset} -> item = position_flex_item( item, style, x, y, content_width, cross_offset, line_cross, main_offset ) {acc ++ [item], main_offset + item.outer_main + item_gap} end) |> elem(0) end defp position_flex_item(item, style, x, y, content_width, cross_offset, line_cross, main_offset) do main_axis = flex_main_axis(style) align = flex_item_align(item, style) cross_position = flex_cross_position(align, item.outer_cross, line_cross) case main_axis do :row -> box_width = item.main_box box_height = flex_aligned_cross_box(item, align, line_cross, :row) Map.merge(item, %{ x: x + main_offset + item.margin.left, y: y - cross_offset - cross_position - item.margin.top, box_width: box_width, box_height: box_height }) :column -> box_width = flex_aligned_cross_box(item, align, content_width, :column) box_height = item.main_box Map.merge(item, %{ x: x + cross_position + item.margin.left, y: y - main_offset - item.margin.top, box_width: box_width, box_height: box_height }) end end defp flex_item_boxes(item, main_axis) do style = item.style padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) case Map.get(item, :image) do nil -> case Map.get(item, :children) do nil -> flex_text_item_boxes(item, main_axis, style, padding, border_widths) children -> flex_block_item_boxes(item, style, padding, border_widths, children) end image -> flex_image_item_boxes(item, main_axis, style, padding, border_widths, image) end end defp flex_text_item_boxes(item, main_axis, style, padding, border_widths) do content_x = item.x + border_widths.left + padding.left content_width = item.box_width - border_widths.left - padding.left - padding.right - border_widths.right content_top = item.y - border_widths.top - padding.top background_boxes = background_box(style, item.x, item.y - item.box_height, item.box_width, item.box_height) {text_boxes, _content_height} = inline_text_boxes(item.runs, style, content_x, content_top, max(content_width, 0.0), %{}) {:ok, case main_axis do :row -> background_boxes ++ text_boxes :column -> background_boxes ++ text_boxes end} end defp flex_block_item_boxes(item, style, padding, border_widths, children) do case Map.get(style, :display) do display when display in [:grid, :inline_grid] -> nested_style = nested_flex_item_style(style, item) with {:ok, boxes, _next_y} <- layout_grid(nested_style, children, item.x, item.y, item.box_width) do {:ok, boxes} end display when display in [:flex, :inline_flex] -> nested_style = nested_flex_item_style(style, item) with {:ok, boxes, _next_y} <- layout_flex(nested_style, children, item.x, item.y, item.box_width) do {:ok, boxes} end _ -> flex_plain_block_item_boxes(item, style, padding, border_widths, children) end end defp nested_flex_item_style(style, item) do style |> Map.put(:margin, edges(0.0)) |> Map.put(:height, max(item.box_height - vertical_box_size(style), 0.0)) end defp flex_plain_block_item_boxes(item, style, padding, border_widths, children) do content_x = item.x + border_widths.left + padding.left content_width = item.box_width - border_widths.left - padding.left - padding.right - border_widths.right content_top = item.y - border_widths.top - padding.top background_boxes = background_box(style, item.x, item.y - item.box_height, item.box_width, item.box_height) {:ok, content_boxes, _content_height} = layout_block_content(children, style, content_x, content_top, max(content_width, 0.0), %{}) {:ok, background_boxes ++ content_boxes} end defp flex_image_item_boxes(item, main_axis, style, padding, border_widths, image) do content_x = item.x + border_widths.left + padding.left content_width = item.box_width - border_widths.left - padding.left - padding.right - border_widths.right content_height = item.box_height - border_widths.top - padding.top - padding.bottom - border_widths.bottom image_box = %{ type: :image, x: content_x, y: item.y - border_widths.top - padding.top - max(content_height, 0.0), width: max(content_width, 0.0), height: max(content_height, 0.0), image: image } background_boxes = background_box(style, item.x, item.y - item.box_height, item.box_width, item.box_height) {:ok, case main_axis do :row -> background_boxes ++ [image_box] :column -> background_boxes ++ [image_box] end} end defp flex_content_height(lines, style) do main_axis = flex_main_axis(style) height = resolved_size(style, :height, nil, nil) case {main_axis, height} do {:row, height} when is_number(height) -> height {:row, _height} -> cross_gap = flex_cross_gap(style) Enum.reduce(lines, 0.0, &(&1.cross + &2)) + cross_gap * max(length(lines) - 1, 0) {:column, height} when is_number(height) -> height {:column, _height} -> [line] = lines line.main end end defp flex_line_cross(line, style, line_count) do height = resolved_size(style, :height, nil, nil) case {flex_main_axis(style), height, line_count} do {:row, height, 1} when is_number(height) -> height {:row, _height, _line_count} -> line.cross {:column, _height, _line_count} -> resolved_size(style, :width, nil, line.cross) end end defp flex_position_available_main(main_axis, content_width, content_height) do case main_axis do :row -> content_width :column -> content_height end end defp flex_main_distribution(style, line, available_main, gap) do free_space = max(available_main - line.main, 0.0) count = length(line.items) case Map.get(style, :justify_content, :flex_start) do :flex_end -> {free_space, gap} :center -> {free_space / 2, gap} :space_between when count > 1 -> {0.0, gap + free_space / (count - 1)} :space_around when count > 0 -> item_gap = gap + free_space / count {item_gap / 2, item_gap} :space_evenly when count > 0 -> item_gap = gap + free_space / (count + 1) {item_gap, item_gap} _ -> {0.0, gap} end end defp flex_cross_position(align, item_outer_cross, line_cross) do case align do :flex_end -> max(line_cross - item_outer_cross, 0.0) :center -> max((line_cross - item_outer_cross) / 2, 0.0) _ -> 0.0 end end defp flex_aligned_cross_box(item, align, line_cross, main_axis) do case {align, main_axis} do {:stretch, :row} -> max(line_cross - item.margin.top - item.margin.bottom, 0.0) {:stretch, :column} -> max(line_cross - item.margin.left - item.margin.right, 0.0) {_align, :row} -> item.cross_box {_align, :column} -> item.cross_box end end defp flex_item_align(item, container_style) do case Map.get(item.style, :align_self, :auto) do :auto -> Map.get(container_style, :align_items, :stretch) align -> align end end defp flex_direction_items(items, style) do case Map.get(style, :flex_direction, :row) do direction when direction in [:row_reverse, :column_reverse] -> Enum.reverse(items) _ -> items end end defp flex_available_main(style, main_axis, content_width, items, gap) do case main_axis do :row -> content_width :column -> case resolved_size(style, :height, nil, nil) do height when is_number(height) -> height _ -> Enum.reduce(items, 0.0, &(&1.outer_main + &2)) + gap * max(length(items) - 1, 0) end end end defp flex_available_cross(main_axis, content_width) do case main_axis do :column -> content_width :row -> nil end end defp flex_main_axis(style) do case Map.get(style, :flex_direction, :row) do direction when direction in [:column, :column_reverse] -> :column _ -> :row end end defp flex_main_gap(style) do case flex_main_axis(style) do :row -> Map.get(style, :column_gap, 0.0) :column -> Map.get(style, :row_gap, 0.0) end end defp flex_cross_gap(style) do case flex_main_axis(style) do :row -> Map.get(style, :row_gap, 0.0) :column -> Map.get(style, :column_gap, 0.0) end end defp flex_main_box_size(style, main_axis) do padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) case main_axis do :row -> padding.left + padding.right + border_widths.left + border_widths.right :column -> padding.top + padding.bottom + border_widths.top + border_widths.bottom end end defp flex_cross_box_size(style, main_axis) do padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) case main_axis do :row -> padding.top + padding.bottom + border_widths.top + border_widths.bottom :column -> padding.left + padding.right + border_widths.left + border_widths.right end end defp flex_main_margin_size(margin, main_axis) do case main_axis do :row -> margin.left + margin.right :column -> margin.top + margin.bottom end end defp flex_cross_margin_size(margin, main_axis) do case main_axis do :row -> margin.top + margin.bottom :column -> margin.left + margin.right end end defp flex_block_intrinsic_width(children) do Enum.reduce(children, 0.0, fn child, width -> max(width, flex_child_intrinsic_width(child)) end) end defp flex_child_intrinsic_width(child) do case child do %{type: :text, text: text, style: style} when is_binary(text) -> text |> String.trim() |> text_width(style) %{type: :element, style: %{display: :none}} -> 0.0 %{type: :element, style: %{display: :image} = style} -> {width, _height} = image_content_size(style) width + horizontal_box_size(style) %{type: :element, style: style, children: children} when is_list(children) -> case inline_runs(children) do {:ok, runs} -> runs |> Enum.reduce(0.0, fn run, width -> width + text_width(run.text, run.style) end) |> Kernel.+(horizontal_box_size(style)) {:error, _reason} -> flex_block_intrinsic_width(children) + horizontal_box_size(style) end _ -> 0.0 end end defp resolved_size(style, property, available_size, default) do size = case Map.get(style, property) do {:min, sizes} when is_list(sizes) -> sizes |> Enum.map(&resolve_size_value(&1, available_size)) |> Enum.reject(&is_nil/1) |> Enum.min(fn -> default end) {:percent, ratio} when is_number(available_size) -> max(available_size * ratio, 0.0) value when is_number(value) -> value _ -> default end apply_size_constraints(style, property, size, available_size) end defp resolve_size_value(value, available_size) do case value do {:percent, ratio} when is_number(available_size) -> max(available_size * ratio, 0.0) value when is_number(value) -> value _ -> nil end end defp apply_size_constraints(style, property, size, available_size) do case {property, size} do {:width, size} when is_number(size) -> size |> apply_min_size(style, :min_width, available_size) |> apply_max_size(style, :max_width, available_size) {:height, size} when is_number(size) -> size |> apply_min_size(style, :min_height, available_size) |> apply_max_size(style, :max_height, available_size) _ -> size end end defp apply_min_size(size, style, property, available_size) do case resolved_constraint_size(style, property, available_size) do constraint when is_number(constraint) -> max(size, constraint) _ -> size end end defp apply_max_size(size, style, property, available_size) do case resolved_constraint_size(style, property, available_size) do constraint when is_number(constraint) -> min(size, constraint) _ -> size end end defp resolved_constraint_size(style, property, available_size) do style |> Map.get(property) |> resolve_size_value(available_size) end defp apply_image_size_constraints( style, width, height, ratio, available_width, available_height, explicit_dimensions? ) do case explicit_dimensions? do true -> { apply_size_constraints(style, :width, width, available_width), apply_size_constraints(style, :height, height, available_height) } false -> {width, height} |> scale_image_down_to_max(style, ratio, available_width, available_height) |> scale_image_up_to_min(style, ratio, available_width, available_height) end end defp scale_image_down_to_max({width, height}, style, ratio, available_width, available_height) do max_width = resolved_constraint_size(style, :max_width, available_width) max_height = resolved_constraint_size(style, :max_height, available_height) scale = [max_image_scale(width, max_width), max_image_scale(height, max_height)] |> Enum.reject(&is_nil/1) |> Enum.min(fn -> 1.0 end) |> min(1.0) {width * scale, width * scale / ratio} end defp scale_image_up_to_min({width, height}, style, ratio, available_width, available_height) do min_width = resolved_constraint_size(style, :min_width, available_width) min_height = resolved_constraint_size(style, :min_height, available_height) scale = [min_image_scale(width, min_width), min_image_scale(height, min_height)] |> Enum.reject(&is_nil/1) |> Enum.max(fn -> 1.0 end) |> max(1.0) {width * scale, width * scale / ratio} end defp max_image_scale(size, constraint) do case {size, constraint} do {size, constraint} when is_number(size) and size > 0 and is_number(constraint) -> constraint / size _ -> nil end end defp min_image_scale(size, constraint) do case {size, constraint} do {size, constraint} when is_number(size) and size > 0 and is_number(constraint) -> constraint / size _ -> nil end end defp horizontal_box_size(style) do padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) padding.left + padding.right + border_widths.left + border_widths.right end defp vertical_box_size(style) do padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) padding.top + padding.bottom + border_widths.top + border_widths.bottom end defp layout_table(style, children, x, y, width) do margin = Map.get(style, :margin, edges(0.0, 0.0, Map.get(style, :margin_after, 0.0), 0.0)) padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) box_x = x + margin.left box_top = y - margin.top available_box_width = width - margin.left - margin.right content_width = resolved_size( style, :width, available_box_width - horizontal_box_size(style), available_box_width - horizontal_box_size(style) ) box_width = content_width + border_widths.left + padding.left + padding.right + border_widths.right content_x = box_x + border_widths.left + padding.left content_top = box_top - border_widths.top - padding.top table_id = {:table, content_x, content_top} table_metadata = break_metadata(style) with {:ok, caption_boxes, rows_top} <- layout_table_caption(children, content_x, content_top, content_width, table_id), {:ok, rows} <- table_rows(children), {:ok, row_boxes, content_bottom} <- layout_table_rows( rows, content_x, rows_top, content_width, table_id, Map.get(style, :border_collapse, :separate) ) do content_height = content_top - content_bottom box_height = border_widths.top + padding.top + content_height + padding.bottom + border_widths.bottom table_box = style |> background_box(box_x, box_top - box_height, box_width, box_height) |> tag_boxes(Map.merge(table_metadata, %{flow_id: table_id, table_id: table_id})) next_y = box_top - box_height - margin.bottom {:ok, table_box ++ caption_boxes ++ row_boxes, next_y} end end defp layout_table_caption(children, x, y, width, table_id) do case Enum.find(children, &match?(%{style: %{display: :table_caption}}, &1)) do nil -> {:ok, [], y} %{style: style, children: caption_children} when is_list(caption_children) -> with {:ok, runs} <- inline_runs(caption_children) do margin = Map.get(style, :margin, edges(0.0, 0.0, Map.get(style, :margin_after, 0.0), 0.0)) padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) box_x = x + margin.left box_top = y - margin.top box_width = width - margin.left - margin.right content_x = box_x + border_widths.left + padding.left content_width = box_width - border_widths.left - padding.left - padding.right - border_widths.right content_top = box_top - border_widths.top - padding.top flow_metadata = %{flow_id: {:table_caption, table_id}, table_id: table_id} {text_boxes, content_height} = inline_text_boxes(runs, style, content_x, content_top, content_width, flow_metadata) box_height = border_widths.top + padding.top + content_height + padding.bottom + border_widths.bottom background_box = style |> background_box(box_x, box_top - box_height, box_width, box_height) |> tag_boxes(flow_metadata) {:ok, background_box ++ text_boxes, box_top - box_height - margin.bottom} end _ -> {:error, :invalid_layout} end end defp table_rows(children) do result = Enum.reduce_while(children, {:ok, []}, fn child, {:ok, rows} -> case table_child_rows(child) do {:ok, table_rows} -> {:cont, {:ok, rows ++ table_rows}} {:error, reason} -> {:halt, {:error, reason}} end end) case result do {:ok, rows} when rows != [] -> {:ok, rows} _ -> {:error, :invalid_layout} end end defp table_child_rows(child) do case child do %{style: %{display: display}} when display in [:none, :table_caption] -> {:ok, []} %{style: %{display: :table_row}, children: cells} when is_list(cells) -> case visible_table_cells(cells) do [] -> {:ok, []} visible_cells -> {:ok, [%{row: Map.put(child, :children, visible_cells), section: :body}]} end %{style: %{display: :table_row_group}, children: group_rows} when is_list(group_rows) -> section = child.style |> Map.get(:table_section, :body) Enum.reduce_while(group_rows, {:ok, []}, fn group_row, {:ok, rows} -> case table_group_row(group_row, section) do {:ok, table_rows} -> {:cont, {:ok, rows ++ table_rows}} {:error, reason} -> {:halt, {:error, reason}} end end) _ -> {:error, :invalid_layout} end end defp table_group_row(row, section) do case row do %{style: %{display: :none}} -> {:ok, []} %{style: %{display: :table_row}, children: cells} when is_list(cells) -> case visible_table_cells(cells) do [] -> {:ok, []} visible_cells -> {:ok, [%{row: Map.put(row, :children, visible_cells), section: section}]} end _ -> {:error, :invalid_layout} end end defp visible_table_cells(cells) do Enum.reject(cells, &match?(%{style: %{display: :none}}, &1)) end defp layout_table_rows(rows, x, y, width, table_id, border_collapse) do column_count = rows |> Enum.map(fn %{row: %{children: cells}} -> table_column_count(cells) end) |> Enum.max() column_widths = table_column_widths(rows, column_count, width) result = rows |> Enum.with_index() |> Enum.reduce_while({:ok, [], y}, fn {%{row: row, section: section}, index}, {:ok, boxes, current_y} -> case layout_table_row( row, section, table_id, index, x, current_y, column_widths, width, border_collapse, index == length(rows) - 1 ) do {:ok, row_boxes, next_y} -> {:cont, {:ok, boxes ++ row_boxes, next_y}} {:error, reason} -> {:halt, {:error, reason}} end end) case result do {:ok, boxes, next_y} -> {:ok, boxes, next_y} {:error, reason} -> {:error, reason} end end defp layout_table_row( %{style: %{display: :table_row}, children: cells}, section, table_id, index, x, y, column_widths, table_width, border_collapse, last_row? ) when is_list(cells) do case Enum.all?(cells, &match?(%{style: %{display: :table_cell}}, &1)) do true -> with {:ok, row_height} <- table_row_height(cells, column_widths, table_width) do row_metadata = table_row_metadata(table_id, section, index) {boxes, consumed_columns} = Enum.reduce(cells, {[], 0}, fn cell, {acc, index} -> colspan = table_cell_colspan(cell) cell_x = x + (column_widths |> Enum.take(index) |> Enum.sum()) cell_width = table_cell_width(cells, column_widths, index, colspan, table_width) last_cell? = index + colspan >= length(column_widths) {:ok, cell_boxes} = layout_table_cell( cell, cell_x, y, cell_width, row_height, row_metadata, border_collapse, last_cell?, last_row? ) {acc ++ cell_boxes, index + colspan} end) boxes = boxes ++ trailing_collapsed_table_border( cells, x, y, row_height, column_widths, consumed_columns, border_collapse, last_row?, row_metadata ) {background_boxes, content_boxes} = Enum.split_with(boxes, &(Map.get(&1, :role) == :table_cell_background)) {border_boxes, content_boxes} = Enum.split_with(content_boxes, &(Map.get(&1, :role) == :table_border)) {:ok, background_boxes ++ border_boxes ++ content_boxes, y - row_height} else {:error, reason} -> {:error, reason} end false -> {:error, :invalid_layout} end end defp layout_table_cell( %{style: %{display: :table_cell} = style, children: children}, x, y, width, height, row_metadata, border_collapse, last_cell?, last_row? ) when is_list(children) do padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) content_x = x + border_widths.left + padding.left content_width = width - border_widths.left - padding.left - padding.right - border_widths.right content_top = y - border_widths.top - padding.top with {:ok, content_boxes, content_bottom} <- layout_table_cell_content(children, style, content_x, content_top, content_width) do content_height = content_top - content_bottom content_area_height = height - border_widths.top - padding.top - padding.bottom - border_widths.bottom vertical_offset = table_cell_vertical_offset(style, content_area_height, content_height) content_boxes = Enum.map(content_boxes, fn box -> Map.update!(box, :y, &(&1 - vertical_offset)) end) cell_box = table_cell_background_box(style, x, y - height, width, height, border_collapse) |> tag_boxes(row_metadata) |> Enum.map(&Map.put(&1, :role, :table_cell_background)) border_box = table_cell_border_box( style, x, y - height, width, height, border_collapse, last_cell?, last_row? ) |> tag_boxes(row_metadata) |> Enum.map(&Map.put(&1, :role, :table_border)) {:ok, cell_box ++ border_box ++ tag_boxes(content_boxes, row_metadata)} end end defp table_cell_background_box(style, x, y, width, height, border_collapse) do case border_collapse do :collapse -> style |> Map.put(:border_widths, edges(0.0)) |> background_box(x, y, width, height) |> Enum.map(fn box -> box |> Map.put(:stroke_width, 0.0) |> Map.put(:border_widths, edges(0.0)) end) _ -> background_box(style, x, y, width, height) end end defp table_cell_border_box( style, x, y, width, height, border_collapse, last_cell?, last_row? ) do case border_collapse do :collapse -> border_widths = Map.get(style, :border_widths, edges(0.0)) collapsed_widths = %{ top: border_widths.top, right: if(last_cell?, do: border_widths.right, else: 0.0), bottom: if(last_row?, do: border_widths.bottom, else: 0.0), left: border_widths.left } stroke_width = collapsed_widths |> Map.values() |> Enum.max() case stroke_width > 0 do true -> [ %{ type: :rect, x: x, y: y, width: width, height: height, fill_color: nil, stroke_color: Map.get(style, :border_color, {0, 0, 0}), stroke_width: stroke_width, border_widths: collapsed_widths, border_radius: 0.0 } ] false -> [] end _ -> [] end end defp trailing_collapsed_table_border( cells, x, y, row_height, column_widths, consumed_columns, border_collapse, last_row?, row_metadata ) do case {border_collapse, length(cells) > 1 and consumed_columns < length(column_widths)} do {:collapse, true} -> style = cells |> List.last() |> Map.fetch!(:style) cell_x = x + (column_widths |> Enum.take(consumed_columns) |> Enum.sum()) cell_width = column_widths |> Enum.drop(consumed_columns) |> Enum.sum() style |> table_cell_border_box( cell_x, y - row_height, cell_width, row_height, :collapse, true, last_row? ) |> tag_boxes(row_metadata) |> Enum.map(&Map.put(&1, :role, :table_border)) _ -> [] end end defp table_cell_vertical_offset(style, content_area_height, content_height) do extra_space = max(content_area_height - content_height, 0.0) case Map.get(style, :vertical_align, :middle) do :bottom -> extra_space :middle -> extra_space / 2 _ -> 0.0 end end defp table_column_count(cells) do Enum.reduce(cells, 0, &(&2 + table_cell_colspan(&1))) end defp table_column_widths(rows, column_count, table_width) do preferred = Enum.reduce(rows, List.duplicate(nil, column_count), fn %{row: %{children: cells}}, widths -> {next_widths, _index} = Enum.reduce(cells, {widths, 0}, fn cell, {acc, index} -> colspan = table_cell_colspan(cell) acc = case table_cell_preferred_width(cell, table_width) do nil -> acc preferred_width -> share = preferred_width / colspan Enum.reduce(index..(index + colspan - 1), acc, fn column, column_acc -> List.update_at(column_acc, column, fn nil -> share width -> max(width, share) end) end) end {acc, index + colspan} end) next_widths end) minimum = table_minimum_column_widths(rows, column_count) fixed_total = preferred |> Enum.reject(&is_nil/1) |> Enum.sum() flexible_count = Enum.count(preferred, &is_nil/1) cond do fixed_total > table_width and fixed_total > 0 and flexible_count > 0 -> Enum.map(preferred, fn nil -> 0.0 width -> width / fixed_total * table_width end) fixed_total > table_width and fixed_total > 0 -> preferred |> Enum.with_index() |> Enum.map(fn {width, index} -> max(width, Enum.at(minimum, index)) end) |> shrink_columns_to_width(minimum, table_width) flexible_count > 0 -> fixed_widths = preferred |> Enum.with_index() |> Enum.map(fn {nil, _index} -> nil {width, index} -> max(width, Enum.at(minimum, index)) end) fixed_total = fixed_widths |> Enum.reject(&is_nil/1) |> Enum.sum() flexible_width = max((table_width - fixed_total) / flexible_count, 0.0) fixed_widths |> Enum.with_index() |> Enum.map(fn {nil, index} -> max(flexible_width, Enum.at(minimum, index)) {width, _index} -> width end) |> shrink_columns_to_width(minimum, table_width) true -> Enum.map(preferred, &(&1 / fixed_total * table_width)) end end defp table_minimum_column_widths(rows, column_count) do Enum.reduce(rows, List.duplicate(0.0, column_count), fn %{row: %{children: cells}}, widths -> {next_widths, _index} = Enum.reduce(cells, {widths, 0}, fn cell, {acc, index} -> colspan = table_cell_colspan(cell) acc = case {colspan, table_cell_minimum_width(cell)} do {1, min_width} when min_width > 0 -> List.update_at(acc, index, &max(&1, min_width)) _ -> acc end {acc, index + colspan} end) next_widths end) end defp table_cell_minimum_width(cell) do case cell do %{style: %{display: :table_cell} = style, children: children} when is_list(children) -> case inline_runs(children) do {:ok, runs} -> padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) content_width = runs |> Enum.flat_map(fn run -> run.text |> inline_wrap_tokens(run.style) |> Enum.map(&%{text: String.trim(&1), style: run.style}) end) |> Enum.reject(&(&1.text == "")) |> Enum.map(&text_width(&1.text, &1.style)) |> Enum.max(fn -> 0.0 end) content_width + padding.left + padding.right + border_widths.left + border_widths.right {:error, _reason} -> 0.0 end _ -> 0.0 end end defp shrink_columns_to_width(widths, minimum, table_width) do total_width = Enum.sum(widths) case total_width > table_width and total_width > 0 do true -> shrinkable = widths |> Enum.zip(minimum) |> Enum.map(fn {width, min_width} -> max(width - min_width, 0.0) end) shrinkable_total = Enum.sum(shrinkable) overflow = total_width - table_width case {shrinkable_total > 0, overflow <= shrinkable_total} do {true, true} -> shrink_largest_columns(widths, minimum, overflow) _ -> Enum.map(widths, &(&1 / total_width * table_width)) end false -> widths end end defp shrink_largest_columns(widths, minimum, overflow) do shrink_order = widths |> Enum.zip(minimum) |> Enum.with_index() |> Enum.map(fn {{width, min_width}, index} -> {index, max(width - min_width, 0.0)} end) |> Enum.sort_by(fn {_index, shrinkable_width} -> shrinkable_width end, :desc) {shrunk_widths, _remaining_overflow} = Enum.reduce(shrink_order, {widths, overflow}, fn {index, shrinkable_width}, {acc, remaining_overflow} -> reduction = min(shrinkable_width, remaining_overflow) acc = List.update_at(acc, index, fn width -> width - reduction end) {acc, remaining_overflow - reduction} end) shrunk_widths end defp table_cell_preferred_width(cell, table_width) do case cell do %{style: %{width: {:percent, ratio}}} when is_number(ratio) -> table_width * ratio %{style: %{width: width}} when is_number(width) and width > 0 -> min(width, table_width) _ -> nil end end defp table_cell_width(cells, column_widths, index, colspan, table_width) do case cells do [_single_cell] -> table_width _ -> column_widths |> Enum.slice(index, colspan) |> Enum.sum() end end defp table_row_height(cells, column_widths, table_width) do cells |> Enum.reduce_while({:ok, [], 0}, fn cell, {:ok, heights, index} -> colspan = table_cell_colspan(cell) cell_width = table_cell_width(cells, column_widths, index, colspan, table_width) case table_cell_height(cell, cell_width) do {:ok, height} -> {:cont, {:ok, heights ++ [height], index + colspan}} {:error, reason} -> {:halt, {:error, reason}} end end) |> case do {:ok, heights, _index} when heights != [] -> {:ok, Enum.max(heights)} _ -> {:error, :invalid_layout} end end defp table_cell_height(cell, width) do case cell do %{style: %{display: :table_cell} = style, children: children} when is_list(children) -> padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) content_width = width - border_widths.left - padding.left - padding.right - border_widths.right with {:ok, _boxes, content_bottom} <- layout_table_cell_content(children, style, 0.0, 0.0, content_width) do content_height = 0.0 - content_bottom content_box_height = border_widths.top + padding.top + content_height + padding.bottom + border_widths.bottom {:ok, max(content_box_height, table_cell_declared_height(style))} end _ -> {:error, :invalid_layout} end end defp table_cell_colspan(cell) do case cell do %{style: %{colspan: colspan}} when is_integer(colspan) and colspan >= 1 -> colspan _ -> 1 end end defp table_cell_declared_height(style) do [ resolved_size(style, :height, nil, nil), resolved_size(style, :min_height, nil, nil) ] |> Enum.filter(&is_number/1) |> Enum.max(fn -> 0.0 end) end defp layout_table_cell_content(children, style, x, y, width) do case inline_runs(children) do {:ok, runs} -> {boxes, content_height} = inline_text_boxes(runs, style, x, y, width, %{}) {:ok, boxes, y - content_height} {:error, _reason} -> layout_table_cell_blocks(children, style, x, y, width) end end defp layout_table_cell_blocks(children, style, x, y, width) do result = Enum.reduce_while(children, {:ok, [], y}, fn child, {:ok, boxes, current_y} -> case layout_table_cell_block(child, style, x, current_y, width) do {:ok, child_boxes, next_y} -> {:cont, {:ok, boxes ++ child_boxes, next_y}} {:error, reason} -> {:halt, {:error, reason}} end end) case result do {:ok, boxes, next_y} -> {:ok, boxes, next_y} {:error, reason} -> {:error, reason} end end defp layout_table_cell_block(child, style, x, y, width) do case child do %{type: :text, text: text} when is_binary(text) -> case String.trim(text) do "" -> {:ok, [], y} _ -> with {:ok, runs} <- inline_runs([child]) do {boxes, content_height} = inline_text_boxes(runs, style, x, y, width, %{}) {:ok, boxes, y - content_height} end end %{type: :element, style: %{display: display}} when display in [:inline, :line_break] -> with {:ok, runs} <- inline_runs([child]) do {boxes, content_height} = inline_text_boxes(runs, style, x, y, width, %{}) {:ok, boxes, y - content_height} end _ -> layout_block(child, x, y, width) end end defp table_row_metadata(table_id, section, index) do metadata = %{ flow_id: {:table_row, table_id, index}, table_id: table_id, table_section: section } case section do :head -> Map.put(metadata, :repeat_table_header, true) _ -> metadata end end defp aligned_text_x(runs, style, x, width) do text_width = Enum.reduce(runs, 0.0, fn run, acc -> acc + text_width(run.text, run.style) end) case Map.get(style, :text_align, :left) do :center -> x + max((width - text_width) / 2, 0.0) :right -> x + max(width - text_width, 0.0) _ -> x end end defp layout_list(style, children, x, y, width) do margin = Map.get(style, :margin, edges(0.0, 0.0, Map.get(style, :margin_after, 0.0), 0.0)) padding = Map.get(style, :padding, edges(0.0)) border_widths = Map.get(style, :border_widths, edges(0.0)) box_x = x + margin.left box_top = y - margin.top box_width = width - margin.left - margin.right content_x = box_x + border_widths.left + padding.left content_top = box_top - border_widths.top - padding.top content_width = box_width - border_widths.left - padding.left - padding.right - border_widths.right case layout_list_items( children, Map.fetch!(style, :list_marker_type), content_x, content_top, content_width ) do {:ok, item_boxes, content_bottom} -> content_height = content_top - content_bottom box_height = border_widths.top + padding.top + content_height + padding.bottom + border_widths.bottom background_box = background_box(style, box_x, box_top - box_height, box_width, box_height) next_y = box_top - box_height - margin.bottom {:ok, background_box ++ item_boxes, next_y} {:error, reason} -> {:error, reason} end end defp layout_list_items(children, marker_type, x, y, width) do result = children |> Enum.with_index(1) |> Enum.reduce_while({:ok, [], y}, fn {child, index}, {:ok, boxes, current_y} -> case layout_list_item(child, marker_type, index, x, current_y, width) do {:ok, item_boxes, next_y} -> {:cont, {:ok, boxes ++ item_boxes, next_y}} {:error, reason} -> {:halt, {:error, reason}} end end) case result do {:ok, boxes, next_y} -> {:ok, boxes, next_y} {:error, reason} -> {:error, reason} end end defp layout_list_item(item, marker_type, index, x, y, width) do case item do %{type: :element, style: %{display: :list_item} = style, children: children} when is_list(children) -> with {:ok, runs} <- inline_runs(children) do marker_gap = 18.0 marker = list_marker(marker_type, index) marker_style = text_style(style) baseline_y = y - Map.fetch!(style, :font_size) text_x = x + marker_gap text_width = width - marker_gap flow_metadata = %{flow_id: {:list_item, x, y, index}} marker_box = %{text: marker, style: marker_style} |> text_box(x, baseline_y, marker_gap) |> Map.merge(flow_metadata) {text_boxes, content_height} = inline_text_boxes(runs, style, text_x, y, text_width, flow_metadata) margin = Map.get(style, :margin, edges(0.0, 0.0, Map.get(style, :margin_after, 0.0), 0.0)) next_y = y - content_height - margin.bottom {:ok, [marker_box] ++ text_boxes, next_y} end _ -> {:error, :invalid_layout} end end defp inline_text_boxes(runs, style, x, y, width, metadata) do line_height = Map.fetch!(style, :line_height) font_size = Map.fetch!(style, :font_size) lines = inline_lines(runs, width) boxes = lines |> Enum.with_index() |> Enum.flat_map(fn {line_runs, index} -> baseline_y = y - line_height * index - font_size start_x = aligned_text_x(line_runs, style, x, width) {line_boxes, _next_x} = Enum.reduce(line_runs, {[], start_x}, fn run, {acc, current_x} -> box = run |> text_box(current_x, baseline_y, width) |> Map.merge(metadata) {acc ++ [box], current_x + text_width(run.text, run.style)} end) line_boxes end) {boxes, inline_content_height(runs, lines, line_height)} end defp inline_content_height(runs, width, line_height) when is_number(width) do runs |> inline_lines(width) |> then(&inline_content_height(runs, &1, line_height)) end defp inline_content_height(runs, lines, line_height) when is_list(lines) do case runs do [] -> 0.0 _ -> line_height * max(length(lines), 1) end end defp inline_lines(runs, width) do Enum.reduce(runs, [[]], fn run, lines -> run.text |> String.split("\n", trim: false) |> Enum.with_index() |> Enum.reduce(lines, fn {part, index}, acc -> acc = case part do "" -> acc _ -> append_wrapped_inline_text(acc, %{run | text: part}, width) end case index < length(String.split(run.text, "\n", trim: false)) - 1 do true -> acc ++ [[]] false -> acc end end) end) end defp append_wrapped_inline_text(lines, run, width) do case width do width when is_number(width) and width > 0 -> merge_key = System.unique_integer([:positive]) run.text |> inline_wrap_tokens(run.style) |> Enum.reduce(lines, fn token, acc -> append_inline_token( acc, run |> Map.put(:text, token) |> Map.put(:merge_key, merge_key), width ) end) _ -> List.update_at(lines, length(lines) - 1, &(&1 ++ [run])) end end defp inline_wrap_tokens(text, style) do case Map.get(style, :line_break, :normal) do :anywhere -> String.graphemes(text) _ -> ~r/\S+\s*|\s+/u |> Regex.scan(text) |> Enum.map(&List.first/1) end end defp append_inline_token(lines, run, width) do current_line = List.last(lines) || [] token_width = text_width(run.text, run.style) cond do String.trim(run.text) == "" and current_line == [] -> lines token_width > width and Map.get(run.style, :line_break) == :break_word -> append_break_word_token(lines, run, width) current_line != [] and inline_line_width(current_line) + token_width > width -> lines ++ [[%{run | text: String.trim_leading(run.text)}]] true -> List.update_at(lines, length(lines) - 1, &append_inline_line_run(&1, run)) end end defp append_break_word_token(lines, run, width) do run.text |> String.graphemes() |> Enum.reduce(lines, fn grapheme, acc -> append_inline_token(acc, %{run | text: grapheme}, width) end) end defp append_inline_line_run(line, run) do case List.last(line) do %{merge_key: merge_key, text: text} -> case merge_key == Map.get(run, :merge_key) do true -> List.update_at(line, length(line) - 1, &%{&1 | text: text <> run.text}) false -> line ++ [run] end _ -> line ++ [run] end end defp inline_line_width(line_runs) do Enum.reduce(line_runs, 0.0, fn run, width -> width + text_width(run.text, run.style) end) end @spec inline_runs([term()]) :: {:ok, [%{text: String.t(), style: map()}]} | {:error, :invalid_layout} defp inline_runs(children) do Enum.reduce_while(children, {:ok, []}, fn child, {:ok, runs} -> case append_inline_run(child, runs) do {:ok, runs} -> {:cont, {:ok, runs}} {:error, reason} -> {:halt, {:error, reason}} end end) end defp append_inline_run(child, runs) do case child do %{type: :text, text: text, style: style} when is_binary(text) and is_map(style) -> {:ok, runs ++ [%{text: collapse_inline_whitespace(text), style: style}]} %{type: :element, style: %{display: :inline}, children: children} when is_list(children) -> case inline_runs(children) do {:ok, child_runs} -> {:ok, runs ++ child_runs} {:error, reason} -> {:error, reason} end %{type: :element, style: %{display: :line_break} = style, children: []} -> {:ok, runs ++ [%{text: "\n", style: text_style(style), hard_break: true}]} _ -> {:error, :invalid_layout} end end defp collapse_inline_whitespace(text) do String.replace(text, ~r/\s+/u, " ") end @spec text_box(%{text: String.t(), style: map()}, number(), number(), number()) :: box() defp text_box(run, x, y, width) do style = run.style font_face = text_font_face(style) box = %{ type: :text, text: run.text, x: x, y: y, width: width, annotation_width: text_width(run.text, style), font: Font.pdf_name(font_face), font_face: font_face, font_size: Map.fetch!(style, :font_size), letter_spacing: Map.get(style, :letter_spacing, 0.0), line_height: Map.fetch!(style, :line_height), color: Map.fetch!(style, :color) } case Map.get(style, :link_url) do link_url when is_binary(link_url) -> Map.put(box, :link_url, link_url) _ -> box end end @spec background_box(map(), number(), number(), number(), number()) :: [box()] defp background_box(style, x, y, width, height) do border_widths = Map.get(style, :border_widths, edges(0.0)) stroke_width = Enum.max(Map.values(border_widths)) fill_color = Map.get(style, :background_color) case {fill_color, stroke_width > 0} do {nil, false} -> [] _ -> [ %{ type: :rect, x: x, y: y, width: width, height: height, fill_color: fill_color, stroke_color: Map.get(style, :border_color, {0, 0, 0}), stroke_width: stroke_width, border_widths: border_widths, border_colors: Map.get(style, :border_colors, edges(Map.get(style, :border_color, {0, 0, 0}))), border_radius: Map.get(style, :border_radius, 0.0) } ] end end defp tag_boxes(boxes, metadata) do Enum.map(boxes, &Map.merge(&1, metadata)) end defp break_metadata(style) do %{ break_before: Map.get(style, :break_before, :auto), break_after: Map.get(style, :break_after, :auto) } end @spec text_width(String.t(), map()) :: number() defp text_width(text, style) do text |> Font.text_width(text_font_face(style), Map.fetch!(style, :font_size)) |> Kernel.+(letter_spacing_width(text, style)) end defp letter_spacing_width(text, style) do letter_spacing = Map.get(style, :letter_spacing, 0.0) case letter_spacing == 0.0 do true -> 0.0 false -> max(String.length(text) - 1, 0) * letter_spacing end end defp text_font_face(style) do case Map.get(style, :font_face) do nil -> {:ok, _families, font_face} = Font.resolve( Map.fetch!(style, :font_family), Map.fetch!(style, :font_weight), Map.fetch!(style, :font_style), %{embedded: []} ) font_face font_face -> font_face end end defp text_style(style) do Map.take(style, [ :color, :font_face, :font_families, :font_family, :font_size, :font_style, :font_weight, :letter_spacing, :line_height ]) end defp list_marker(marker_type, index) do case marker_type do :decimal -> "#{index}." :disc -> "*" 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 page_size(page_size) do case page_size do :a4 -> {:ok, {595.28, 841.89}} :letter -> {:ok, {612.0, 792.0}} {width, height} when is_number(width) and is_number(height) and width > 0 and height > 0 -> {:ok, normalize_custom_page_size(width, height)} _ -> {:error, :invalid_page_size} end end defp normalize_custom_page_size(width, height) do case width <= 20 and height <= 20 do true -> {width * 72.0, height * 72.0} false -> {width * 1.0, height * 1.0} end end defp margin(margin) do case margin do margin when is_number(margin) and margin >= 0 -> {:ok, margin * 1.0} margin when is_binary(margin) -> case Regex.run(~r/^\s*(\d+(?:\.\d+)?)(pt|px|mm|cm|in)\s*$/u, margin) do [_, value, unit] -> {number, ""} = Float.parse(value) {:ok, number * points_per_unit(unit)} _ -> {:error, :invalid_margin} end _ -> {:error, :invalid_margin} end 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 end end end