# frozen_string_literal: true
require 'ostruct'
require 'set'
module PrawnHtml
class Attributes < OpenStruct
attr_reader :initial, :styles
STYLES_APPLY = {
block: %i[align bottom leading left margin_left padding_left position right top],
tag_close: %i[margin_bottom padding_bottom break_after],
tag_open: %i[margin_top padding_top break_before],
text_node: %i[callback character_spacing color font link list_style_type size styles white_space]
}.freeze
STYLES_LIST = {
# text node styles
'background' => { key: :callback, set: :callback_background },
'color' => { key: :color, set: :convert_color },
'font-family' => { key: :font, set: :filter_font_family },
'font-size' => { key: :size, set: :convert_size },
'font-style' => { key: :styles, set: :append_styles, values: %i[italic] },
'font-weight' => { key: :styles, set: :append_styles, values: %i[bold] },
'href' => { key: :link, set: :copy_value },
'letter-spacing' => { key: :character_spacing, set: :convert_float },
'list-style-type' => { key: :list_style_type, set: :unquote },
'text-decoration' => { key: :styles, set: :append_styles, values: %i[underline] },
'vertical-align' => { key: :styles, set: :append_styles, values: %i[subscript superscript] },
'white-space' => { key: :white_space, set: :convert_symbol },
# tag opening styles
'break-before' => { key: :break_before, set: :convert_symbol },
'margin-top' => { key: :margin_top, set: :convert_size },
'padding-top' => { key: :padding_top, set: :convert_size },
# tag closing styles
'break-after' => { key: :break_after, set: :convert_symbol },
'margin-bottom' => { key: :margin_bottom, set: :convert_size },
'padding-bottom' => { key: :padding_bottom, set: :convert_size },
# block styles
'bottom' => { key: :bottom, set: :convert_size, options: :height },
'left' => { key: :left, set: :convert_size, options: :width },
'line-height' => { key: :leading, set: :convert_size },
'margin-left' => { key: :margin_left, set: :convert_size },
'padding-left' => { key: :padding_left, set: :convert_size },
'position' => { key: :position, set: :convert_symbol },
'right' => { key: :right, set: :convert_size, options: :width },
'text-align' => { key: :align, set: :convert_symbol },
'top' => { key: :top, set: :convert_size, options: :height },
# special styles
'text-decoration-line-through' => { key: :callback, set: :callback_strike_through }
}.freeze
STYLES_MERGE = %i[margin_left padding_left].freeze
# Init the Attributes
def initialize(attributes = {})
super
@styles = {} # result styles
@initial = Set.new
end
# Processes the data attributes
#
# @return [Hash] hash of data attributes with 'data-' prefix removed and stripped values
def data
to_h.each_with_object({}) do |(key, value), res|
data_key = key.match /\Adata-(.+)/
res[data_key[1]] = value.strip if data_key
end
end
# Merge text styles
#
# @param text_styles [String] styles to parse and process
# @param options [Hash] options (container width/height/etc.)
def merge_text_styles!(text_styles, options: {})
hash_styles = Attributes.parse_styles(text_styles)
process_styles(hash_styles, options: options) unless hash_styles.empty?
end
# Remove an attribute value from the context styles
#
# @param context_styles [Hash] hash of the context styles that will be updated
# @param rule [Hash] rule from the STYLES_LIST to lookup in the context style for value removal
def remove_value(context_styles, rule)
if rule[:set] == :append_styles
context_styles[rule[:key]] -= rule[:values] if context_styles[:styles]
else
default = Context::DEFAULT_STYLES[rule[:key]]
default ? (context_styles[rule[:key]] = default) : context_styles.delete(rule[:key])
end
end
# Update context styles applying the initial rules (if set)
#
# @param context_styles [Hash] hash of the context styles that will be updated
#
# @return [Hash] the update context styles
def update_styles(context_styles)
initial.each do |rule|
next unless rule
remove_value(context_styles, rule)
end
context_styles
end
class << self
# Merges attributes
#
# @param attributes [Hash] target attributes hash
# @param key [Symbol] key
# @param value
#
# @return [Hash] the updated hash of attributes
def merge_attr!(attributes, key, value)
return unless key
return (attributes[key] = value) unless Attributes::STYLES_MERGE.include?(key)
attributes[key] ||= 0
attributes[key] += value
end
# Parses a string of styles
#
# @param styles [String] styles to parse
#
# @return [Hash] hash of styles
def parse_styles(styles)
(styles || '').scan(/\s*([^:;]+)\s*:\s*([^;]+)\s*/).to_h
end
end
private
def process_styles(hash_styles, options:)
hash_styles.each do |key, value|
rule = evaluate_rule(key, value)
next unless rule
apply_rule!(merged_styles: @styles, rule: rule, value: value, options: options)
end
@styles
end
def evaluate_rule(rule_key, attr_value)
key = nil
key = 'text-decoration-line-through' if rule_key == 'text-decoration' && attr_value == 'line-through'
key ||= rule_key
STYLES_LIST[key]
end
def apply_rule!(merged_styles:, rule:, value:, options:)
return (@initial << rule) if value == 'initial'
if rule[:set] == :append_styles
val = Utils.normalize_style(value, rule[:values])
(merged_styles[rule[:key]] ||= []) << val if val
else
opts = rule[:options] ? options[rule[:options]] : nil
val = Utils.send(rule[:set], value, options: opts)
merged_styles[rule[:key]] = val if val
end
end
end
end