Module: Dommy::Internal::CSS::Counters
- Defined in:
- lib/dommy/internal/css/counters.rb
Overview
CSS counters (CSS Lists 3 §4): computes the in-scope counter values at
every element from counter-reset / counter-increment / counter-set,
honoring nesting and scope, so counter() / counters() in generated
content can be resolved to text. Dommy renders no boxes, so the consumer
is the accessible name (generated-content text) rather than display.
The computed value of content keeps counter(...) literal (per CSSOM),
so getComputedStyle is unaffected; only the rendered text equivalent
resolves counters. Counter properties on pseudo-elements themselves are
not honored (the common li { counter-increment } + li::before { content: counter() } works because the value is computed at the element).
Constant Summary collapse
- ROMAN =
Format a counter value per a
<counter-style>name (a curated subset: decimal, decimal-leading-zero, lower,upper-alpha,latin,roman). An unsupported style or an out-of-range value falls back to decimal. [[1000, "m"], [900, "cm"], [500, "d"], [400, "cd"], [100, "c"], [90, "xc"], [50, "l"], [40, "xl"], [10, "x"], [9, "ix"], [5, "v"], [4, "iv"], [1, "i"]].freeze
Class Method Summary collapse
-
.alpha(number) ⇒ Object
Bijective base-26 (1 -> "a", 26 -> "z", 27 -> "aa"); nil for n < 1.
-
.apply_counter_ops(element, depth, state) ⇒ Object
Apply the element's counter properties (reset, then increment, then set) to
state, returning the names for which it pushed a new instance. -
.build(document) ⇒ Object
element => { counter_name => [outermost..innermost values] } for the whole document, computed in tree order.
- .computed_style_for(element) ⇒ Object
-
.counter_list(value, default) ⇒ Object
Parse a
counter-reset/counter-increment/counter-setvalue into [name, integer] pairs. - .element_children(element) ⇒ Object
- .ensure_counter(state, name, depth) ⇒ Object
- .format(value, style) ⇒ Object
- .quote(text) ⇒ Object
-
.reserved_counter_name?(name) ⇒ Boolean
CSS-wide keywords /
noneare not counter names. -
.reset_counter(state, name, value, depth) ⇒ Object
Reset
nametovalue. - .resolve_function(function, values) ⇒ Object
-
.roman(number) ⇒ Object
Lowercase Roman numerals for 1..3999; nil outside that range.
- .snapshot(state) ⇒ Object
-
.substitute(content, values) ⇒ Object
Replace each
counter()/counters()in acontentvalue with a double-quoted CSS string of its resolved text, so the caller's normal string handling (unescape) yields the value. - .unquote(token) ⇒ Object
-
.walk(element, depth, state, result) ⇒ Object
Depth-first in document order.
Class Method Details
.alpha(number) ⇒ Object
Bijective base-26 (1 -> "a", 26 -> "z", 27 -> "aa"); nil for n < 1.
190 191 192 193 194 195 196 197 198 199 200 |
# File 'lib/dommy/internal/css/counters.rb', line 190 def alpha(number) return nil if number < 1 letters = +"" while number.positive? number -= 1 letters.prepend((("a".ord) + (number % 26)).chr) number /= 26 end letters end |
.apply_counter_ops(element, depth, state) ⇒ Object
Apply the element's counter properties (reset, then increment, then
set) to state, returning the names for which it pushed a new instance.
A counter-reset whose innermost in-scope counter was created at the same
depth (a preceding sibling at the same nesting level) resets that one in
place rather than nesting. counter-increment / counter-set on a name
with no in-scope counter implicitly create one (value 0) here.
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/dommy/internal/css/counters.rb', line 58 def apply_counter_ops(element, depth, state) style = computed_style_for(element) return [] unless style created = [] counter_list(style["counter-reset"], 0).each do |name, value| created << name if reset_counter(state, name, value, depth) end counter_list(style["counter-increment"], 1).each do |name, value| created << name if ensure_counter(state, name, depth) state[name].last[:value] += value end counter_list(style["counter-set"], 0).each do |name, value| created << name if ensure_counter(state, name, depth) state[name].last[:value] = value end created end |
.build(document) ⇒ Object
element => { counter_name => [outermost..innermost values] } for the
whole document, computed in tree order. counter() reads the innermost
(last) value; counters() joins the whole stack.
23 24 25 26 27 28 29 30 |
# File 'lib/dommy/internal/css/counters.rb', line 23 def build(document) result = {}.compare_by_identity root = document.respond_to?(:document_element) ? document.document_element : nil return result unless root walk(root, 0, Hash.new { |hash, key| hash[key] = [] }, result) result end |
.computed_style_for(element) ⇒ Object
142 143 144 145 146 |
# File 'lib/dommy/internal/css/counters.rb', line 142 def computed_style_for(element) Cascade.computed_style(element) rescue StandardError nil end |
.counter_list(value, default) ⇒ Object
Parse a counter-reset / counter-increment / counter-set value
into [name, integer] pairs. Each name may be followed by an integer
(else default). none / empty yield none.
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/dommy/internal/css/counters.rb', line 108 def counter_list(value, default) text = value.to_s.strip return [] if text.empty? || text.casecmp("none").zero? tokens = text.split(/\s+/) pairs = [] index = 0 while index < tokens.length name = tokens[index] index += 1 next if reserved_counter_name?(name) if index < tokens.length && tokens[index].match?(/\A-?\d+\z/) amount = tokens[index].to_i index += 1 else amount = default end pairs << [name, amount] end pairs end |
.element_children(element) ⇒ Object
136 137 138 139 140 |
# File 'lib/dommy/internal/css/counters.rb', line 136 def element_children(element) return [] unless element.respond_to?(:children) element.children.to_a end |
.ensure_counter(state, name, depth) ⇒ Object
92 93 94 95 96 97 |
# File 'lib/dommy/internal/css/counters.rb', line 92 def ensure_counter(state, name, depth) return false unless state[name].empty? state[name].push({value: 0, depth: depth}) true end |
.format(value, style) ⇒ Object
177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/dommy/internal/css/counters.rb', line 177 def format(value, style) case style.to_s.downcase when "", "decimal" then value.to_s when "decimal-leading-zero" then value.between?(0, 9) ? "0#{value}" : value.to_s when "lower-alpha", "lower-latin" then alpha(value)&.downcase || value.to_s when "upper-alpha", "upper-latin" then alpha(value)&.upcase || value.to_s when "lower-roman" then roman(value)&.downcase || value.to_s when "upper-roman" then roman(value)&.upcase || value.to_s else value.to_s end end |
.quote(text) ⇒ Object
216 217 218 |
# File 'lib/dommy/internal/css/counters.rb', line 216 def quote(text) %("#{text.gsub(/(["\\])/) { "\\#{Regexp.last_match(1)}" }}") end |
.reserved_counter_name?(name) ⇒ Boolean
CSS-wide keywords / none are not counter names.
132 133 134 |
# File 'lib/dommy/internal/css/counters.rb', line 132 def reserved_counter_name?(name) %w[none inherit initial unset revert].include?(name.downcase) end |
.reset_counter(state, name, value, depth) ⇒ Object
Reset name to value. If the innermost in-scope counter was created
at this same depth (a sibling at the same level), reset it in place
(returns false — no new instance). Otherwise nest a new instance
(returns true).
81 82 83 84 85 86 87 88 89 90 |
# File 'lib/dommy/internal/css/counters.rb', line 81 def reset_counter(state, name, value, depth) stack = state[name] if !stack.empty? && stack.last[:depth] == depth stack.last[:value] = value false else stack.push({value: value, depth: depth}) true end end |
.resolve_function(function, values) ⇒ Object
159 160 161 162 163 164 165 166 167 168 169 |
# File 'lib/dommy/internal/css/counters.rb', line 159 def resolve_function(function, values) if (match = function.match(/\Acounter\(\s*([\w-]+)\s*(?:,\s*([\w-]+)\s*)?\)\z/i)) stack = values[match[1]] format(stack ? stack.last : 0, match[2]) elsif (match = function.match(/\Acounters\(\s*([\w-]+)\s*,\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*(?:,\s*([\w-]+)\s*)?\)\z/i)) separator = unquote(match[2]) (values[match[1]] || []).map { |value| format(value, match[3]) }.join(separator) else "" end end |
.roman(number) ⇒ Object
Lowercase Roman numerals for 1..3999; nil outside that range.
203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/dommy/internal/css/counters.rb', line 203 def roman(number) return nil if number < 1 || number > 3999 out = +"" ROMAN.each do |value, symbol| while number >= value out << symbol number -= value end end out end |
.snapshot(state) ⇒ Object
99 100 101 102 103 |
# File 'lib/dommy/internal/css/counters.rb', line 99 def snapshot(state) state.each_with_object({}) do |(name, stack), snap| snap[name] = stack.map { |instance| instance[:value] } unless stack.empty? end end |
.substitute(content, values) ⇒ Object
Replace each counter() / counters() in a content value with a
double-quoted CSS string of its resolved text, so the caller's normal
string handling (unescape) yields the value. values is the element's
{ name => stack } from #build. (Separators containing ) are a
documented limitation of the [^)] scan.)
155 156 157 |
# File 'lib/dommy/internal/css/counters.rb', line 155 def substitute(content, values) content.gsub(/\bcounters?\([^)]*\)/i) { |function| quote(resolve_function(function, values)) } end |
.unquote(token) ⇒ Object
220 221 222 223 |
# File 'lib/dommy/internal/css/counters.rb', line 220 def unquote(token) inner = token[1...-1].to_s inner.gsub(/\\(.)/) { Regexp.last_match(1) } end |
.walk(element, depth, state, result) ⇒ Object
Depth-first in document order. Each counter instance records the depth of its creating element. Returns the names this element instantiated (pushed a new instance), so the parent can pop them once it finishes all of its children — a counter's scope extends to its originating element's following siblings (and their descendants), i.e. to the end of the parent's child list.
38 39 40 41 42 43 44 45 46 47 48 49 50 |
# File 'lib/dommy/internal/css/counters.rb', line 38 def walk(element, depth, state, result) created = apply_counter_ops(element, depth, state) result[element] = snapshot(state) children_created = [] element_children(element).each { |child| children_created.concat(walk(child, depth + 1, state, result)) } children_created.reverse_each do |name| state[name].pop state.delete(name) if state[name].empty? end created end |