skrift-color 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 641dc02079c341fcc4818ca69128e8f3aae7bba2d11f25098cec8f0c31c1ae69
4
+ data.tar.gz: 91708a5b2cdaaa52a3731119871df272d1d0e1bec4523633ddea279baf65a057
5
+ SHA512:
6
+ metadata.gz: 81dc7ecaa7a487fcaba8447527291a78172cad96124c0df83e68e7eafa019730245eb81a09785b7201ff84fd670d4dd6b266d00462230ba628ae251ec97d6e67
7
+ data.tar.gz: 076e83a3bfc367be5f0d2a25813be9f2f270f5ae3cb377310e661f1cb292b134a518f34836f8ed763210a8b76cd1390154bcd8dae2afbdbdfe307f929af57f99
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ # skrift is developed alongside this plugin. To work against a local checkout,
8
+ # set a per-machine override (stored in ~/.bundle/config, never committed):
9
+ #
10
+ # bundle config set --local local.skrift /path/to/skrift
11
+ gem "skrift", git: "https://github.com/vidarh/skrift.git", branch: "master"
12
+
13
+ gem "rake", "~> 13.0"
14
+ gem "rspec", "~> 3.0"
data/LICENSE.txt ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ © 2026 Vidar Hokstad
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # skrift-color
2
+
3
+ A [Skrift](https://github.com/vidarh/skrift) plugin that renders **colour
4
+ glyphs** (colour emoji) to RGBA, in the two formats that matter in practice:
5
+
6
+ - **COLR/CPAL (v0)** — layered vector glyphs (Segoe-style emoji, Twemoji-COLR
7
+ builds). Each layer is an ordinary outline drawn in a flat palette colour;
8
+ the plugin rasterises each layer with skrift's monochrome renderer and
9
+ composites them.
10
+ - **CBDT/CBLC** — embedded PNG bitmaps (e.g. **Noto Color Emoji**, the usual
11
+ colour emoji font on Linux). The plugin extracts the glyph's PNG, decodes it
12
+ (pure Ruby, via `zlib`), and scales it to the requested size.
13
+
14
+ `Skrift::Color::Renderer#render` tries COLR first, then CBDT, and returns the
15
+ same RGBA `Skrift::Color::Image` either way (or `nil` to fall back to mono).
16
+ It's pure Ruby with no extra dependencies, and the **core skrift renderer stays
17
+ monochrome** — colour is entirely opt-in.
18
+
19
+ ## Scope
20
+
21
+ - **Supported:** COLR v0 + CPAL (layered vector), and CBDT/CBLC with image
22
+ format 17 + index format 1 (PNG bitmaps, as Noto Color Emoji uses).
23
+ - **Not (yet) supported:** COLR v1 (gradients/transforms), `sbix`, OT-SVG, less
24
+ common CBLC index/image formats, and ZWJ / multi-codepoint emoji sequences.
25
+
26
+ ## Usage
27
+
28
+ ```ruby
29
+ require "skrift"
30
+ require "skrift/color"
31
+
32
+ font = Skrift::Font.load("emoji.ttf")
33
+ colr = Skrift::Color::Renderer.new(font, x_scale: 64, y_scale: 64)
34
+
35
+ if (img = colr.render(0x1F600)) # nil if the codepoint has no COLR glyph
36
+ # img is a Skrift::Color::Image: width, height, and `pixels`, a row-major
37
+ # array of packed 0xRRGGBBAA integers (straight alpha).
38
+ img.pixels.each_slice(img.width) { |row| ... }
39
+ else
40
+ # fall back to skrift's normal monochrome rendering
41
+ end
42
+ ```
43
+
44
+ ## Example
45
+
46
+ `examples/color_preview.rb` renders a colour glyph and previews it as ANSI
47
+ 24-bit-colour terminal output (alpha-composited over a checkerboard) plus a
48
+ saved RGBA PNG. With no arguments it uses the bundled synthetic fixture:
49
+
50
+ ```sh
51
+ ruby examples/color_preview.rb # the fixture glyph
52
+ ruby examples/color_preview.rb Twemoji.ttf 0x1F600 96 grin.png
53
+ ```
54
+
55
+ ## Tests
56
+
57
+ The specs run against a tiny **synthetic** COLR/CPAL font built with
58
+ [fontTools](https://github.com/fonttools/fonttools) (committed under
59
+ `spec/fixtures/`). Rebuild it with `rake fixtures` (needs `python3` +
60
+ `fonttools`).
61
+
62
+ ## License
63
+
64
+ ISC. See `LICENSE.txt`.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ desc "Rebuild the synthetic COLR/CPAL test fixtures (needs fonttools)"
8
+ task :fixtures do
9
+ sh "python3 spec/support/build_fixture.rb"
10
+ end
11
+
12
+ task default: :spec
@@ -0,0 +1,101 @@
1
+ # Renders a colour glyph (COLR/CPAL vector or CBDT PNG emoji) with skrift-color
2
+ # and showcases it two ways:
3
+ # * an ANSI 24-bit-colour terminal preview, alpha-composited over a
4
+ # checkerboard so transparency is visible, and
5
+ # * a saved RGBA PNG (out.png by default).
6
+ #
7
+ # Usage (from the gem root):
8
+ # ruby examples/color_preview.rb [FONT.ttf] [CODEPOINT] [SCALE] [OUT.png]
9
+ #
10
+ # With no arguments it renders a real grinning-face emoji from Noto Color Emoji
11
+ # (CBDT) if installed, else the bundled synthetic COLR fixture. Examples:
12
+ # ruby examples/color_preview.rb # 😀 from Noto
13
+ # ruby examples/color_preview.rb /path/to/Twemoji.ttf 0x1F389 96 # COLR font
14
+
15
+ # Allow running straight from source checkouts (no bundler needed).
16
+ sibling = File.expand_path("../../skrift/lib", __dir__)
17
+ $LOAD_PATH.unshift(sibling) if File.directory?(sibling)
18
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
19
+
20
+ require "skrift"
21
+ require "skrift/color"
22
+ require "zlib"
23
+
24
+ # By default, render a real grinning-face emoji from Noto Color Emoji (CBDT)
25
+ # if it's installed; otherwise fall back to the bundled synthetic COLR fixture.
26
+ NOTO = "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf"
27
+ FIXTURE = File.expand_path("../spec/fixtures/colrtest.ttf", __dir__)
28
+
29
+ font_path = ARGV[0] || (File.exist?(NOTO) ? NOTO : FIXTURE)
30
+ default_cp = font_path == NOTO ? 0x1F600 : 0x41
31
+ codepoint = ARGV[1] ? Integer(ARGV[1]) : default_cp # accepts 0x... or decimal
32
+ scale = (ARGV[2] || 64).to_i
33
+ out_png = ARGV[3] || "out.png"
34
+
35
+ font = Skrift::Font.load(font_path)
36
+ colr = Skrift::Color::Renderer.new(font, x_scale: scale, y_scale: scale)
37
+
38
+ unless colr.color?
39
+ abort "#{File.basename(font_path)} has no colour data (COLR/CPAL or CBDT)."
40
+ end
41
+
42
+ img = colr.render(codepoint)
43
+ unless img
44
+ abort format("U+%04X has no colour glyph in %s.", codepoint, File.basename(font_path))
45
+ end
46
+
47
+ puts format("Rendered U+%04X from %s at %dpx -> %dx%d RGBA",
48
+ codepoint, File.basename(font_path), scale, img.width, img.height)
49
+
50
+ # --- helpers ---------------------------------------------------------------
51
+
52
+ def unpack(px)
53
+ [(px >> 24) & 0xff, (px >> 16) & 0xff, (px >> 8) & 0xff, px & 0xff]
54
+ end
55
+
56
+ # straight-alpha source-over of (r,g,b,a) onto an opaque (br,bg,bb) background
57
+ def over(fg, bg)
58
+ r, g, b, a = fg
59
+ br, bgc, bb = bg
60
+ [(r * a + br * (255 - a)) / 255,
61
+ (g * a + bgc * (255 - a)) / 255,
62
+ (b * a + bb * (255 - a)) / 255]
63
+ end
64
+
65
+ # --- terminal preview ------------------------------------------------------
66
+
67
+ LIGHT = [120, 120, 120]
68
+ DARK = [70, 70, 70]
69
+
70
+ img.height.times do |y|
71
+ line = +""
72
+ img.width.times do |x|
73
+ checker = (x / 2 + y / 2).even? ? LIGHT : DARK
74
+ r, g, b = over(unpack(img.pixels[y * img.width + x]), checker)
75
+ line << "\e[48;2;#{r};#{g};#{b}m "
76
+ end
77
+ puts line + "\e[0m"
78
+ end
79
+
80
+ # --- RGBA PNG --------------------------------------------------------------
81
+
82
+ def png_chunk(type, data)
83
+ body = type.b + data.b
84
+ [data.bytesize].pack("N") + body + [Zlib.crc32(body)].pack("N")
85
+ end
86
+
87
+ raw = +"".b
88
+ img.height.times do |y|
89
+ raw << "\0".b # filter: none
90
+ img.width.times do |x|
91
+ r, g, b, a = unpack(img.pixels[y * img.width + x])
92
+ raw << r.chr << g.chr << b.chr << a.chr
93
+ end
94
+ end
95
+
96
+ png = +"\x89PNG\r\n\x1a\n".b
97
+ png << png_chunk("IHDR", [img.width, img.height, 8, 6, 0, 0, 0].pack("NNC5")) # 8-bit RGBA
98
+ png << png_chunk("IDAT", Zlib::Deflate.deflate(raw))
99
+ png << png_chunk("IEND", "")
100
+ File.binwrite(out_png, png)
101
+ puts "Wrote #{out_png}"
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skrift
4
+ module Color
5
+ # Parses CBLC/CBDT colour-bitmap tables and extracts a glyph's embedded PNG.
6
+ # Handles the common Noto Color Emoji shape: index format 1 (uint32 offset
7
+ # array) and image format 17 (small metrics + PNG). Returns nil for fonts or
8
+ # glyphs it can't handle, so callers can fall back.
9
+ class CBDT
10
+ Strike = Struct.new(:ppem_x, :ppem_y, :start_gid, :end_gid, :subtables)
11
+ SubTable = Struct.new(:first, :last, :index_format, :image_format, :data_offset, :array_offset)
12
+
13
+ def self.parse(font)
14
+ cblc = font.tables["CBLC"]
15
+ cbdt = font.tables["CBDT"]
16
+ return nil unless cblc && cbdt
17
+ new(font, cblc, cbdt)
18
+ end
19
+
20
+ def initialize(font, cblc, cbdt)
21
+ @font = font
22
+ @cbdt = cbdt
23
+ @strikes = parse_strikes(cblc)
24
+ end
25
+
26
+ # [png_bytes, ppem_y] for a glyph id, or nil if it has no colour bitmap.
27
+ def glyph(gid)
28
+ @strikes.each do |st|
29
+ next unless gid.between?(st.start_gid, st.end_gid)
30
+ sub = st.subtables.find { |s| gid.between?(s.first, s.last) }
31
+ next unless sub
32
+ png = extract(sub, gid)
33
+ return [png, st.ppem_y] if png
34
+ end
35
+ nil
36
+ end
37
+
38
+ private
39
+
40
+ def f = @font
41
+
42
+ def parse_strikes(cblc)
43
+ f.getu32(cblc + 4).times.map do |i|
44
+ st = cblc + 8 + i * 48 # bitmapSizeTable is 48 bytes
45
+ isa_off = cblc + f.getu32(st)
46
+ n_sub = f.getu32(st + 8)
47
+ subs = n_sub.times.map do |j|
48
+ e = isa_off + j * 8
49
+ sub_off = isa_off + f.getu32(e + 4)
50
+ SubTable.new(f.getu16(e), f.getu16(e + 2),
51
+ f.getu16(sub_off), f.getu16(sub_off + 2),
52
+ f.getu32(sub_off + 4), sub_off)
53
+ end
54
+ Strike.new(f.getu8(st + 44), f.getu8(st + 45),
55
+ f.getu16(st + 40), f.getu16(st + 42), subs)
56
+ end
57
+ end
58
+
59
+ def extract(sub, gid)
60
+ return nil unless sub.index_format == 1 && sub.image_format == 17
61
+
62
+ arr = sub.array_offset + 8 # uint32 offsetArray follows the 8-byte header
63
+ idx = gid - sub.first
64
+ off = f.getu32(arr + idx * 4)
65
+ nxt = f.getu32(arr + (idx + 1) * 4)
66
+ return nil if nxt <= off # no bitmap for this glyph
67
+
68
+ data = @cbdt + sub.data_offset + off
69
+ # image format 17: smallGlyphMetrics (5 bytes) + dataLen (uint32) + PNG
70
+ png_len = f.getu32(data + 5)
71
+ f.at(data + 9, png_len)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skrift
4
+ module Color
5
+ # Parses a COLR (v0) table into a map of
6
+ # base glyph id => [[layer glyph id, palette index], ...] (bottom to top)
7
+ # Returns nil if the font has no COLR table or a version we don't support.
8
+ module COLR
9
+ module_function
10
+
11
+ def parse(font)
12
+ off = font.tables["COLR"]
13
+ return nil unless off
14
+ return nil unless font.getu16(off).zero? # v0 only
15
+
16
+ num_base = font.getu16(off + 2)
17
+ base_off = off + font.getu32(off + 4)
18
+ layer_off = off + font.getu32(off + 8)
19
+
20
+ layers = {}
21
+ num_base.times do |i|
22
+ rec = base_off + i * 6
23
+ gid = font.getu16(rec)
24
+ first = font.getu16(rec + 2)
25
+ count = font.getu16(rec + 4)
26
+ layers[gid] = count.times.map do |j|
27
+ lr = layer_off + (first + j) * 4
28
+ [font.getu16(lr), font.getu16(lr + 2)]
29
+ end
30
+ end
31
+ layers
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skrift
4
+ module Color
5
+ # Parses a CPAL table's first palette into an array of [r, g, b, a]
6
+ # (0..255). CPAL stores colours as BGRA; we normalise to RGBA. Returns nil
7
+ # if the font has no CPAL table.
8
+ module CPAL
9
+ module_function
10
+
11
+ def parse(font)
12
+ off = font.tables["CPAL"]
13
+ return nil unless off
14
+
15
+ num_entries = font.getu16(off + 2)
16
+ records_off = off + font.getu32(off + 8)
17
+ first_index = font.getu16(off + 12) # colorRecordIndices[0]
18
+
19
+ num_entries.times.map do |i|
20
+ rec = records_off + (first_index + i) * 4
21
+ b = font.getu8(rec)
22
+ g = font.getu8(rec + 1)
23
+ r = font.getu8(rec + 2)
24
+ a = font.getu8(rec + 3)
25
+ [r, g, b, a]
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Skrift
6
+ module Color
7
+ # A small, dependency-free PNG decoder, just enough for the bitmaps found in
8
+ # colour-emoji fonts (CBDT/sbix). Supports 8-bit RGB (2) and RGBA (6), and
9
+ # palette (3, any bit depth) with a tRNS alpha table. Returns
10
+ # [width, height, pixels] where pixels is a row-major array of packed
11
+ # 0xRRGGBBAA integers. Raises on anything it doesn't understand.
12
+ module PNG
13
+ module_function
14
+
15
+ SIGNATURE = "\x89PNG\r\n\x1a\n".b
16
+
17
+ def decode(bytes)
18
+ bytes = bytes.b
19
+ raise "not a PNG" unless bytes[0, 8] == SIGNATURE
20
+
21
+ ihdr = nil
22
+ idat = +"".b
23
+ plte = nil
24
+ trns = nil
25
+ pos = 8
26
+ while pos + 8 <= bytes.bytesize
27
+ len = bytes[pos, 4].unpack1("N")
28
+ type = bytes[pos + 4, 4]
29
+ data = bytes[pos + 8, len]
30
+ case type
31
+ when "IHDR" then ihdr = data
32
+ when "PLTE" then plte = data
33
+ when "tRNS" then trns = data
34
+ when "IDAT" then idat << data
35
+ when "IEND" then break
36
+ end
37
+ pos += 12 + len # length + type + data + CRC
38
+ end
39
+
40
+ width, height, depth, color_type = ihdr.unpack("NNCC")
41
+ channels = CHANNELS.fetch(color_type) { raise "unsupported PNG colour type #{color_type}" }
42
+ rows = unfilter(Zlib::Inflate.inflate(idat), width, height, depth, channels)
43
+ to_rgba(rows, width, height, depth, color_type, plte, trns)
44
+ end
45
+
46
+ CHANNELS = { 0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4 }.freeze
47
+
48
+ # Reverse the per-scanline PNG filters, returning one byte string per row.
49
+ def unfilter(raw, width, height, depth, channels)
50
+ bpp = [(depth * channels) / 8, 1].max # bytes per pixel (>=1)
51
+ stride = (width * channels * depth + 7) / 8 # bytes per row
52
+ rows = []
53
+ prev = ("\0" * stride).b
54
+ pos = 0
55
+ height.times do
56
+ filter = raw.getbyte(pos)
57
+ line = raw.byteslice(pos + 1, stride).dup
58
+ recon = apply_filter(filter, line, prev, bpp)
59
+ rows << recon
60
+ prev = recon
61
+ pos += 1 + stride
62
+ end
63
+ rows
64
+ end
65
+
66
+ def apply_filter(filter, line, prev, bpp)
67
+ bytes = line.bytes
68
+ bytes.each_index do |i|
69
+ a = i >= bpp ? bytes[i - bpp] : 0
70
+ b = prev.getbyte(i)
71
+ c = i >= bpp ? prev.getbyte(i - bpp) : 0
72
+ add =
73
+ case filter
74
+ when 0 then 0
75
+ when 1 then a
76
+ when 2 then b
77
+ when 3 then (a + b) / 2
78
+ when 4 then paeth(a, b, c)
79
+ else raise "bad PNG filter #{filter}"
80
+ end
81
+ bytes[i] = (bytes[i] + add) & 0xff
82
+ end
83
+ bytes.pack("C*")
84
+ end
85
+
86
+ def paeth(a, b, c)
87
+ p = a + b - c
88
+ pa = (p - a).abs; pb = (p - b).abs; pc = (p - c).abs
89
+ pa <= pb && pa <= pc ? a : (pb <= pc ? b : c)
90
+ end
91
+
92
+ def to_rgba(rows, width, height, depth, color_type, plte, trns)
93
+ pixels = Array.new(width * height, 0)
94
+ height.times do |y|
95
+ row = rows[y]
96
+ width.times do |x|
97
+ r, g, b, a = sample(row, x, depth, color_type, plte, trns)
98
+ pixels[y * width + x] = (r << 24) | (g << 16) | (b << 8) | a
99
+ end
100
+ end
101
+ [width, height, pixels]
102
+ end
103
+
104
+ def sample(row, x, depth, color_type, plte, trns)
105
+ case color_type
106
+ when 6 # RGBA, 8-bit
107
+ o = x * 4
108
+ [row.getbyte(o), row.getbyte(o + 1), row.getbyte(o + 2), row.getbyte(o + 3)]
109
+ when 2 # RGB, 8-bit
110
+ o = x * 3
111
+ [row.getbyte(o), row.getbyte(o + 1), row.getbyte(o + 2), 255]
112
+ when 3 # palette
113
+ idx = palette_index(row, x, depth)
114
+ po = idx * 3
115
+ a = trns && idx < trns.bytesize ? trns.getbyte(idx) : 255
116
+ [plte.getbyte(po), plte.getbyte(po + 1), plte.getbyte(po + 2), a]
117
+ else
118
+ raise "unsupported PNG colour type #{color_type}"
119
+ end
120
+ end
121
+
122
+ # Index into a palette scanline, honouring sub-byte bit depths.
123
+ def palette_index(row, x, depth)
124
+ return row.getbyte(x) if depth == 8
125
+ per_byte = 8 / depth
126
+ byte = row.getbyte(x / per_byte)
127
+ shift = (per_byte - 1 - (x % per_byte)) * depth
128
+ (byte >> shift) & ((1 << depth) - 1)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skrift
4
+ module Color
5
+ # An RGBA colour glyph. +pixels+ is a row-major array of packed 0xRRGGBBAA
6
+ # integers (straight, non-premultiplied alpha).
7
+ Image = Struct.new(:width, :height, :pixels)
8
+
9
+ # Renders colour glyphs to an RGBA Skrift::Color::Image. Two formats are
10
+ # supported: COLR/CPAL v0 (layered vector glyphs, composited with skrift's
11
+ # monochrome rasteriser) and CBDT/CBLC (embedded PNG bitmaps, e.g. Noto
12
+ # Color Emoji), scaled to the requested size.
13
+ class Renderer
14
+ def initialize(font, x_scale:, y_scale:)
15
+ @font = font
16
+ @y_scale = y_scale
17
+ @sft = Skrift::Renderer.new(font)
18
+ @sft.x_scale = x_scale
19
+ @sft.y_scale = y_scale
20
+ @colr = COLR.parse(font)
21
+ @cpal = CPAL.parse(font)
22
+ @cbdt = CBDT.parse(font)
23
+ end
24
+
25
+ # True if the font carries any supported colour data (COLR+CPAL or CBDT).
26
+ def color? = (!@colr.nil? && !@cpal.nil?) || !@cbdt.nil?
27
+
28
+ # A Skrift::Color::Image (RGBA) for +codepoint+, or nil if it has no
29
+ # colour glyph — in which case the caller should fall back to ordinary
30
+ # monochrome rendering. Tries vector (COLR) then bitmap (CBDT).
31
+ def render(codepoint)
32
+ render_colr(codepoint) || render_cbdt(codepoint)
33
+ end
34
+
35
+ private
36
+
37
+ def render_colr(codepoint)
38
+ return nil unless @colr && @cpal
39
+ gid = @font.glyph_id(codepoint)
40
+ return nil unless gid
41
+ layers = @colr[gid]
42
+ return nil unless layers && !layers.empty?
43
+ composite(layers)
44
+ end
45
+
46
+ def render_cbdt(codepoint)
47
+ return nil unless @cbdt
48
+ gid = @font.glyph_id(codepoint)
49
+ return nil if gid.nil? || gid.zero?
50
+ found = @cbdt.glyph(gid)
51
+ return nil unless found
52
+ png, ppem = found
53
+ w, h, pixels = PNG.decode(png)
54
+ factor = @y_scale.to_f / ppem
55
+ dw = [(w * factor).round, 1].max
56
+ dh = [(h * factor).round, 1].max
57
+ Image.new(dw, dh, resample(w, h, pixels, dw, dh))
58
+ end
59
+
60
+ # Alpha-weighted area resample (good for the heavy downscale from a
61
+ # ~128px emoji bitmap to a text-sized cell).
62
+ def resample(sw, sh, src, dw, dh)
63
+ out = Array.new(dw * dh, 0)
64
+ fx = sw.to_f / dw
65
+ fy = sh.to_f / dh
66
+ dh.times do |dy|
67
+ y0 = dy * fy; y1 = y0 + fy
68
+ dw.times do |dx|
69
+ x0 = dx * fx; x1 = x0 + fx
70
+ r = g = b = a = wsum = 0.0
71
+ iy = y0.floor
72
+ while iy < y1 && iy < sh
73
+ wy = [y1, iy + 1].min - [y0, iy].max
74
+ ix = x0.floor
75
+ while ix < x1 && ix < sw
76
+ wx = [x1, ix + 1].min - [x0, ix].max
77
+ wt = wx * wy
78
+ p = src[iy * sw + ix]
79
+ pa = (p & 0xff) * wt
80
+ r += ((p >> 24) & 0xff) * pa
81
+ g += ((p >> 16) & 0xff) * pa
82
+ b += ((p >> 8) & 0xff) * pa
83
+ a += (p & 0xff) * wt
84
+ wsum += wt
85
+ ix += 1
86
+ end
87
+ iy += 1
88
+ end
89
+ next if a <= 0.0
90
+ out[dy * dw + dx] =
91
+ ((r / a).round.clamp(0, 255) << 24) |
92
+ ((g / a).round.clamp(0, 255) << 16) |
93
+ ((b / a).round.clamp(0, 255) << 8) |
94
+ (wsum.positive? ? (a / wsum).round.clamp(0, 255) : 0)
95
+ end
96
+ end
97
+ out
98
+ end
99
+
100
+ def composite(layers)
101
+ rendered = layers.filter_map { |gid, pal| render_layer(gid, pal) }
102
+ return nil if rendered.empty?
103
+
104
+ # Align layers by their actual ink bounding box (in the shared em),
105
+ # not the font's left-side-bearing: a layer's outline carries its
106
+ # absolute position, so xmin/ymax place it relative to the others.
107
+ min_x = rendered.map { _1[:xmin] }.min
108
+ max_y = rendered.map { _1[:ymax] }.max
109
+ rendered.each do |r|
110
+ r[:ox] = r[:xmin] - min_x
111
+ r[:oy] = max_y - r[:ymax]
112
+ end
113
+ width = rendered.map { _1[:ox] + _1[:w] }.max
114
+ height = rendered.map { _1[:oy] + _1[:h] }.max
115
+
116
+ acc = Array.new(width * height) { [0.0, 0.0, 0.0, 0.0] } # premultiplied
117
+ rendered.each { |r| blend_layer(acc, width, r) }
118
+ Image.new(width, height, acc.map { |px| pack(px) })
119
+ end
120
+
121
+ def render_layer(gid, pal_index)
122
+ outline = @font.outline_offset(gid)
123
+ return nil unless outline
124
+ bbox = @sft.glyph_bbox(outline) # [xMin, yMin, xMax, yMax] in renderer space
125
+ w = bbox[2] - bbox[0] + 1
126
+ h = bbox[3] - bbox[1] + 1
127
+ img = Skrift::Image.new(w, h)
128
+ return nil unless @sft.render(gid, img)
129
+ { alpha: img.pixels, w: img.width, h: img.height,
130
+ xmin: bbox[0], ymax: bbox[3],
131
+ color: @cpal[pal_index] || [0, 0, 0, 255] }
132
+ end
133
+
134
+ # Source-over composite of one layer into the premultiplied accumulator.
135
+ def blend_layer(acc, width, r)
136
+ pr, pg, pb, pa = r[:color]
137
+ pr /= 255.0; pg /= 255.0; pb /= 255.0; pa /= 255.0
138
+ r[:h].times do |row|
139
+ arow = row * r[:w]
140
+ accrow = (r[:oy] + row) * width + r[:ox]
141
+ r[:w].times do |col|
142
+ cov = r[:alpha][arow + col]
143
+ next if cov.zero?
144
+ sa = (cov / 255.0) * pa
145
+ next if sa <= 0.0
146
+ dst = acc[accrow + col]
147
+ inv = 1.0 - sa
148
+ dst[0] = pr * sa + dst[0] * inv
149
+ dst[1] = pg * sa + dst[1] * inv
150
+ dst[2] = pb * sa + dst[2] * inv
151
+ dst[3] = sa + dst[3] * inv
152
+ end
153
+ end
154
+ end
155
+
156
+ # Premultiplied float RGBA -> packed straight-alpha 0xRRGGBBAA.
157
+ def pack((pr, pg, pb, pa))
158
+ return 0 if pa <= 0.0
159
+ r = (pr / pa * 255).round.clamp(0, 255)
160
+ g = (pg / pa * 255).round.clamp(0, 255)
161
+ b = (pb / pa * 255).round.clamp(0, 255)
162
+ a = (pa * 255).round.clamp(0, 255)
163
+ (r << 24) | (g << 16) | (b << 8) | a
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skrift
4
+ module Color
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "skrift"
4
+ require_relative "color/version"
5
+ require_relative "color/colr"
6
+ require_relative "color/cpal"
7
+ require_relative "color/png"
8
+ require_relative "color/cbdt"
9
+ require_relative "color/renderer"
10
+
11
+ module Skrift
12
+ # COLR/CPAL (v0) colour-glyph support for Skrift, as an opt-in plugin. The
13
+ # core renderer stays monochrome; this composites colour layers into an RGBA
14
+ # Skrift::Color::Image. See Skrift::Color::Renderer.
15
+ module Color
16
+ end
17
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: skrift-color
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Vidar Hokstad
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: skrift
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.3.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 0.3.0
27
+ description: A Skrift plugin that renders COLR/CPAL (v0) colour glyphs - e.g. layered
28
+ colour emoji - by compositing each layer with skrift's monochrome rasteriser into
29
+ an RGBA buffer. The core renderer stays monochrome; colour is opt-in.
30
+ email:
31
+ - vidar@hokstad.com
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - ".rspec"
37
+ - Gemfile
38
+ - LICENSE.txt
39
+ - README.md
40
+ - Rakefile
41
+ - examples/color_preview.rb
42
+ - lib/skrift/color.rb
43
+ - lib/skrift/color/cbdt.rb
44
+ - lib/skrift/color/colr.rb
45
+ - lib/skrift/color/cpal.rb
46
+ - lib/skrift/color/png.rb
47
+ - lib/skrift/color/renderer.rb
48
+ - lib/skrift/color/version.rb
49
+ homepage: https://github.com/vidarh/skrift-color
50
+ licenses:
51
+ - ISC
52
+ metadata:
53
+ homepage_uri: https://github.com/vidarh/skrift-color
54
+ source_code_uri: https://github.com/vidarh/skrift-color
55
+ post_install_message:
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 3.0.0
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubygems_version: 3.4.10
71
+ signing_key:
72
+ specification_version: 4
73
+ summary: COLR/CPAL colour-glyph (emoji) rendering for the Skrift font renderer
74
+ test_files: []