bandoola 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ module Bandoola
2
+ class View
3
+ class Style
4
+ # Background and border colors, named with Tailwind classes:
5
+ # bg-<color>-<shade> fills a div; border-<color>-<shade> colors its
6
+ # border (which still needs a border-N width to be drawn). Mixed into
7
+ # Style.
8
+ #
9
+ # PALETTE is Tailwind CSS v3.4 default colors (MIT-licensed): each
10
+ # family mapped to its 50..950 shades as hex.
11
+ module Colors # rubocop:disable Metrics/ModuleLength -- mostly the palette data table
12
+ PALETTE = {
13
+ "red" => {
14
+ 50 => "#fef2f2", 100 => "#fee2e2", 200 => "#fecaca", 300 => "#fca5a5",
15
+ 400 => "#f87171", 500 => "#ef4444", 600 => "#dc2626", 700 => "#b91c1c",
16
+ 800 => "#991b1b", 900 => "#7f1d1d", 950 => "#450a0a"
17
+ },
18
+ "orange" => {
19
+ 50 => "#fff7ed", 100 => "#ffedd5", 200 => "#fed7aa", 300 => "#fdba74",
20
+ 400 => "#fb923c", 500 => "#f97316", 600 => "#ea580c", 700 => "#c2410c",
21
+ 800 => "#9a3412", 900 => "#7c2d12", 950 => "#431407"
22
+ },
23
+ "amber" => {
24
+ 50 => "#fffbeb", 100 => "#fef3c7", 200 => "#fde68a", 300 => "#fcd34d",
25
+ 400 => "#fbbf24", 500 => "#f59e0b", 600 => "#d97706", 700 => "#b45309",
26
+ 800 => "#92400e", 900 => "#78350f", 950 => "#451a03"
27
+ },
28
+ "yellow" => {
29
+ 50 => "#fefce8", 100 => "#fef9c3", 200 => "#fef08a", 300 => "#fde047",
30
+ 400 => "#facc15", 500 => "#eab308", 600 => "#ca8a04", 700 => "#a16207",
31
+ 800 => "#854d0e", 900 => "#713f12", 950 => "#422006"
32
+ },
33
+ "lime" => {
34
+ 50 => "#f7fee7", 100 => "#ecfccb", 200 => "#d9f99d", 300 => "#bef264",
35
+ 400 => "#a3e635", 500 => "#84cc16", 600 => "#65a30d", 700 => "#4d7c0f",
36
+ 800 => "#3f6212", 900 => "#365314", 950 => "#1a2e05"
37
+ },
38
+ "green" => {
39
+ 50 => "#f0fdf4", 100 => "#dcfce7", 200 => "#bbf7d0", 300 => "#86efac",
40
+ 400 => "#4ade80", 500 => "#22c55e", 600 => "#16a34a", 700 => "#15803d",
41
+ 800 => "#166534", 900 => "#14532d", 950 => "#052e16"
42
+ },
43
+ "emerald" => {
44
+ 50 => "#ecfdf5", 100 => "#d1fae5", 200 => "#a7f3d0", 300 => "#6ee7b7",
45
+ 400 => "#34d399", 500 => "#10b981", 600 => "#059669", 700 => "#047857",
46
+ 800 => "#065f46", 900 => "#064e3b", 950 => "#022c22"
47
+ },
48
+ "teal" => {
49
+ 50 => "#f0fdfa", 100 => "#ccfbf1", 200 => "#99f6e4", 300 => "#5eead4",
50
+ 400 => "#2dd4bf", 500 => "#14b8a6", 600 => "#0d9488", 700 => "#0f766e",
51
+ 800 => "#115e59", 900 => "#134e4a", 950 => "#042f2e"
52
+ },
53
+ "cyan" => {
54
+ 50 => "#ecfeff", 100 => "#cffafe", 200 => "#a5f3fc", 300 => "#67e8f9",
55
+ 400 => "#22d3ee", 500 => "#06b6d4", 600 => "#0891b2", 700 => "#0e7490",
56
+ 800 => "#155e75", 900 => "#164e63", 950 => "#083344"
57
+ },
58
+ "sky" => {
59
+ 50 => "#f0f9ff", 100 => "#e0f2fe", 200 => "#bae6fd", 300 => "#7dd3fc",
60
+ 400 => "#38bdf8", 500 => "#0ea5e9", 600 => "#0284c7", 700 => "#0369a1",
61
+ 800 => "#075985", 900 => "#0c4a6e", 950 => "#082f49"
62
+ },
63
+ "blue" => {
64
+ 50 => "#eff6ff", 100 => "#dbeafe", 200 => "#bfdbfe", 300 => "#93c5fd",
65
+ 400 => "#60a5fa", 500 => "#3b82f6", 600 => "#2563eb", 700 => "#1d4ed8",
66
+ 800 => "#1e40af", 900 => "#1e3a8a", 950 => "#172554"
67
+ },
68
+ "indigo" => {
69
+ 50 => "#eef2ff", 100 => "#e0e7ff", 200 => "#c7d2fe", 300 => "#a5b4fc",
70
+ 400 => "#818cf8", 500 => "#6366f1", 600 => "#4f46e5", 700 => "#4338ca",
71
+ 800 => "#3730a3", 900 => "#312e81", 950 => "#1e1b4b"
72
+ },
73
+ "violet" => {
74
+ 50 => "#f5f3ff", 100 => "#ede9fe", 200 => "#ddd6fe", 300 => "#c4b5fd",
75
+ 400 => "#a78bfa", 500 => "#8b5cf6", 600 => "#7c3aed", 700 => "#6d28d9",
76
+ 800 => "#5b21b6", 900 => "#4c1d95", 950 => "#2e1065"
77
+ },
78
+ "purple" => {
79
+ 50 => "#faf5ff", 100 => "#f3e8ff", 200 => "#e9d5ff", 300 => "#d8b4fe",
80
+ 400 => "#c084fc", 500 => "#a855f7", 600 => "#9333ea", 700 => "#7e22ce",
81
+ 800 => "#6b21a8", 900 => "#581c87", 950 => "#3b0764"
82
+ },
83
+ "fuchsia" => {
84
+ 50 => "#fdf4ff", 100 => "#fae8ff", 200 => "#f5d0fe", 300 => "#f0abfc",
85
+ 400 => "#e879f9", 500 => "#d946ef", 600 => "#c026d3", 700 => "#a21caf",
86
+ 800 => "#86198f", 900 => "#701a75", 950 => "#4a044e"
87
+ },
88
+ "pink" => {
89
+ 50 => "#fdf2f8", 100 => "#fce7f3", 200 => "#fbcfe8", 300 => "#f9a8d4",
90
+ 400 => "#f472b6", 500 => "#ec4899", 600 => "#db2777", 700 => "#be185d",
91
+ 800 => "#9d174d", 900 => "#831843", 950 => "#500724"
92
+ },
93
+ "rose" => {
94
+ 50 => "#fff1f2", 100 => "#ffe4e6", 200 => "#fecdd3", 300 => "#fda4af",
95
+ 400 => "#fb7185", 500 => "#f43f5e", 600 => "#e11d48", 700 => "#be123c",
96
+ 800 => "#9f1239", 900 => "#881337", 950 => "#4c0519"
97
+ },
98
+ "slate" => {
99
+ 50 => "#f8fafc", 100 => "#f1f5f9", 200 => "#e2e8f0", 300 => "#cbd5e1",
100
+ 400 => "#94a3b8", 500 => "#64748b", 600 => "#475569", 700 => "#334155",
101
+ 800 => "#1e293b", 900 => "#0f172a", 950 => "#020617"
102
+ },
103
+ "gray" => {
104
+ 50 => "#f9fafb", 100 => "#f3f4f6", 200 => "#e5e7eb", 300 => "#d1d5db",
105
+ 400 => "#9ca3af", 500 => "#6b7280", 600 => "#4b5563", 700 => "#374151",
106
+ 800 => "#1f2937", 900 => "#111827", 950 => "#030712"
107
+ },
108
+ "zinc" => {
109
+ 50 => "#fafafa", 100 => "#f4f4f5", 200 => "#e4e4e7", 300 => "#d4d4d8",
110
+ 400 => "#a1a1aa", 500 => "#71717a", 600 => "#52525b", 700 => "#3f3f46",
111
+ 800 => "#27272a", 900 => "#18181b", 950 => "#09090b"
112
+ },
113
+ "neutral" => {
114
+ 50 => "#fafafa", 100 => "#f5f5f5", 200 => "#e5e5e5", 300 => "#d4d4d4",
115
+ 400 => "#a3a3a3", 500 => "#737373", 600 => "#525252", 700 => "#404040",
116
+ 800 => "#262626", 900 => "#171717", 950 => "#0a0a0a"
117
+ },
118
+ "stone" => {
119
+ 50 => "#fafaf9", 100 => "#f5f5f4", 200 => "#e7e5e4", 300 => "#d6d3d1",
120
+ 400 => "#a8a29e", 500 => "#78716c", 600 => "#57534e", 700 => "#44403c",
121
+ 800 => "#292524", 900 => "#1c1917", 950 => "#0c0a09"
122
+ }
123
+ }.freeze
124
+
125
+ # Colors without a shade: black/white. A shade is optional, so
126
+ # bg-black works, and a bare family (bg-cyan) falls back to 500.
127
+ NAMED = { "black" => "#000000", "white" => "#ffffff" }.freeze
128
+ DEFAULT_SHADE = 500
129
+
130
+ BACKGROUND = /\Abg-([a-z]+)(?:-(\d+))?\z/
131
+ BORDER_COLOR = /\Aborder-([a-z]+)(?:-(\d+))?\z/
132
+ TEXT_COLOR = /\Atext-([a-z]+)(?:-(\d+))?\z/
133
+
134
+ attr_reader :background, :border_color, :text_color
135
+
136
+ # [r, g, b] floats in 0..1 for a color name and optional shade, or nil
137
+ # if unknown. Named colors (black/white) ignore the shade.
138
+ def self.rgb(family, shade)
139
+ hex = NAMED[family] || PALETTE.dig(family, (shade || DEFAULT_SHADE).to_i)
140
+ return nil unless hex
141
+
142
+ digits = hex.delete("#")
143
+ [0, 2, 4].map { |offset| digits[offset, 2].to_i(16) / 255.0 }
144
+ end
145
+
146
+ def init_colors
147
+ @background = nil
148
+ @border_color = nil
149
+ @text_color = nil
150
+ end
151
+
152
+ # Only assigns when the token resolves to a real color, so an unknown
153
+ # color (or a text-<size> token that matches the text- shape but isn't
154
+ # a color) doesn't clobber a color set by an earlier class.
155
+ def apply_color_token(token)
156
+ if (match = token.match(BACKGROUND))
157
+ @background = Colors.rgb(match[1], match[2]) || @background
158
+ elsif (match = token.match(BORDER_COLOR))
159
+ @border_color = Colors.rgb(match[1], match[2]) || @border_color
160
+ elsif (match = token.match(TEXT_COLOR))
161
+ @text_color = Colors.rgb(match[1], match[2]) || @text_color
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,42 @@
1
+ module Bandoola
2
+ class View
3
+ class Style
4
+ # Padding and margin: the p-* and m-* classes (p-4, px-2, mt-1, …). Both
5
+ # use the shared spacing SCALE and the same side letters, so they live
6
+ # together. Mixed into Style.
7
+ module Spacing
8
+ SPACING = /\A([mp])([trblxy]?)-(\d+)\z/
9
+
10
+ # Which edges each side letter touches (empty = all four, Tailwind's
11
+ # x/y axes, or a single t/r/b/l edge).
12
+ SIDES = {
13
+ "" => %i[top right bottom left],
14
+ "x" => %i[left right],
15
+ "y" => %i[top bottom],
16
+ "t" => %i[top],
17
+ "r" => %i[right],
18
+ "b" => %i[bottom],
19
+ "l" => %i[left]
20
+ }.freeze
21
+
22
+ attr_reader :margin, :padding
23
+
24
+ def init_spacing
25
+ @margin = Inset.new(0, 0, 0, 0)
26
+ @padding = Inset.new(0, 0, 0, 0)
27
+ end
28
+
29
+ def apply_spacing_token(token)
30
+ match = token.match(SPACING)
31
+ return unless match
32
+
33
+ points = SCALE[match[3].to_i]
34
+ return unless points # steps outside Tailwind's scale have no effect
35
+
36
+ inset = match[1] == "m" ? @margin : @padding
37
+ SIDES.fetch(match[2]).each { |edge| inset[edge] = points }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,72 @@
1
+ module Bandoola
2
+ class View
3
+ class Style
4
+ # Typography classes:
5
+ # font-sans / font-serif / font-mono / font-<name> -> font family
6
+ # font-light/normal/medium/semibold/bold/... -> font weight
7
+ # text-xs … text-9xl -> font size (pt)
8
+ #
9
+ # The family is recorded by name (the View resolves it to an actual font
10
+ # at render time). The weight is a number; since we don't carry separate
11
+ # weight files, the renderer synthesises heavier weights with a stroke
12
+ # ("faux bold"), so anything below normal renders like normal. A name
13
+ # that is a known weight keyword can't double as a family name.
14
+ #
15
+ # text- also covers font size (text-xs … text-9xl) and alignment
16
+ # (text-left/center/right). These share the prefix with colors
17
+ # (text-cyan-500); each handler recognises only its own keywords, so they
18
+ # never claim each other's tokens.
19
+ # Mixed into Style.
20
+ module Typography
21
+ FONT = /\Afont-([a-z0-9-]+)\z/
22
+
23
+ WEIGHTS = {
24
+ "thin" => 100, "extralight" => 200, "light" => 300, "normal" => 400,
25
+ "medium" => 500, "semibold" => 600, "bold" => 700, "extrabold" => 800,
26
+ "black" => 900
27
+ }.freeze
28
+
29
+ # Tailwind's font-size scale, in points (1px ≈ 1pt).
30
+ SIZES = {
31
+ "xs" => 12, "sm" => 14, "base" => 16, "lg" => 18, "xl" => 20,
32
+ "2xl" => 24, "3xl" => 30, "4xl" => 36, "5xl" => 48, "6xl" => 60,
33
+ "7xl" => 72, "8xl" => 96, "9xl" => 128
34
+ }.freeze
35
+
36
+ ALIGNMENTS = %w[left center right].freeze
37
+
38
+ attr_reader :font, :font_weight, :text_size, :text_align
39
+
40
+ def init_typography
41
+ @font = nil
42
+ @font_weight = nil
43
+ @text_size = nil
44
+ @text_align = nil
45
+ end
46
+
47
+ def apply_font_token(token)
48
+ match = token.match(FONT)
49
+ return unless match
50
+
51
+ name = match[1]
52
+ if WEIGHTS.key?(name)
53
+ @font_weight = WEIGHTS[name]
54
+ else
55
+ @font = name
56
+ end
57
+ end
58
+
59
+ def apply_text_token(token)
60
+ return unless token.start_with?("text-")
61
+
62
+ key = token.delete_prefix("text-")
63
+ if (size = SIZES[key])
64
+ @text_size = size
65
+ elsif ALIGNMENTS.include?(key)
66
+ @text_align = key.to_sym
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,139 @@
1
+ require_relative "style/spacing"
2
+ require_relative "style/borders"
3
+ require_relative "style/colors"
4
+ require_relative "style/typography"
5
+
6
+ module Bandoola
7
+ class View
8
+ # A box's spacing on each edge, in PDF points.
9
+ Inset = Struct.new(:top, :right, :bottom, :left)
10
+
11
+ # The visual style parsed from an element's `class` keyword. The vocabulary
12
+ # borrows from Tailwind. This file keeps the shared scale and the sizing
13
+ # classes; the other concerns live in mixins beside it:
14
+ #
15
+ # Spacing p-4 px-2 mt-1 padding / margin (style/spacing.rb)
16
+ # Borders border-2 border width (style/borders.rb)
17
+ # Colors bg-cyan-100 border-... colors (style/colors.rb)
18
+ #
19
+ # Sizing (kept here):
20
+ # w-2 w-64 h-8 fixed width / height (the shared SCALE)
21
+ # w-1/2 w-6/7 h-3/4 fractional width / height
22
+ # w-full h-full fill the container
23
+ #
24
+ # Spacing and fixed sizes share one scale: `n` maps to `n * SPACING_UNIT`
25
+ # points. The allowed steps are Tailwind's — close together at the low end
26
+ # and increasingly sparse higher up (…12, 14, 16, 20, 24…), so `p-6` and
27
+ # `w-64` are valid but `w-31` is not. Unknown classes are ignored, so
28
+ # decorative names can sit alongside layout ones with no effect yet.
29
+ class Style
30
+ SPACING_UNIT = 4
31
+
32
+ # Tailwind's spacing scale (the integer steps): dense low, sparse high.
33
+ SCALE = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 20, 24, 28,
34
+ 32, 36, 40, 44, 48, 52, 56, 60, 64].to_h { |step| [step, step * SPACING_UNIT] }.freeze
35
+
36
+ SIZE_FRACTION = %r{\A([wh])-(\d+)/(\d+)\z}
37
+ SIZE_FIXED = /\A([wh])-(\d+)\z/
38
+
39
+ include Spacing
40
+ include Borders
41
+ include Colors
42
+ include Typography
43
+
44
+ attr_reader :width, :width_fraction, :height, :height_fraction
45
+
46
+ def self.none
47
+ new
48
+ end
49
+
50
+ # Parse a `class` value into a Style. Accepts a String (split on
51
+ # whitespace), an Array of tokens, or nil.
52
+ def self.parse(value)
53
+ new.apply(tokens(value))
54
+ end
55
+
56
+ # Normalize a `class` value to an array of tokens: a String is split on
57
+ # whitespace, an Array is flattened (its entries normalized in turn),
58
+ # and nil yields none.
59
+ def self.tokens(value)
60
+ case value
61
+ when Array then value.flat_map { |item| tokens(item) }
62
+ when String then value.split(/\s+/).reject(&:empty?)
63
+ when nil then []
64
+ else [value.to_s]
65
+ end
66
+ end
67
+
68
+ def initialize
69
+ init_spacing
70
+ init_sizing
71
+ init_borders
72
+ init_colors
73
+ init_typography
74
+ end
75
+
76
+ def apply(tokens)
77
+ tokens.each { |token| apply_token(token) }
78
+ self
79
+ end
80
+
81
+ private
82
+
83
+ # Try each concern in turn. A token matches at most one, so the others
84
+ # simply do nothing — no branching, no precedence to reason about.
85
+ def apply_token(token)
86
+ apply_spacing_token(token)
87
+ apply_sizing_token(token)
88
+ apply_border_token(token)
89
+ apply_color_token(token)
90
+ apply_font_token(token)
91
+ apply_text_token(token)
92
+ end
93
+
94
+ def init_sizing
95
+ @width = nil # fixed width in points, or nil
96
+ @width_fraction = nil # [numerator, denominator], or nil
97
+ @height = nil
98
+ @height_fraction = nil
99
+ end
100
+
101
+ def apply_sizing_token(token)
102
+ if (match = token.match(SIZE_FRACTION))
103
+ apply_fraction(match[1], [match[2].to_i, match[3].to_i])
104
+ elsif (match = token.match(SIZE_FIXED))
105
+ apply_fixed(match[1], SCALE[match[2].to_i])
106
+ elsif token == "w-full"
107
+ # Width already defaults to full, so "full" just clears any size.
108
+ @width = @width_fraction = nil
109
+ elsif token == "h-full"
110
+ # Height defaults to auto, so "full" means fill the available height.
111
+ @height = nil
112
+ @height_fraction = [1, 1]
113
+ end
114
+ end
115
+
116
+ def apply_fraction(axis, fraction)
117
+ if axis == "w"
118
+ @width = nil
119
+ @width_fraction = fraction
120
+ else
121
+ @height = nil
122
+ @height_fraction = fraction
123
+ end
124
+ end
125
+
126
+ def apply_fixed(axis, points)
127
+ return unless points # steps outside Tailwind's scale have no effect
128
+
129
+ if axis == "w"
130
+ @width = points
131
+ @width_fraction = nil
132
+ else
133
+ @height = points
134
+ @height_fraction = nil
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end