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 +7 -0
- data/.rspec +3 -0
- data/Gemfile +14 -0
- data/LICENSE.txt +15 -0
- data/README.md +64 -0
- data/Rakefile +12 -0
- data/examples/color_preview.rb +101 -0
- data/lib/skrift/color/cbdt.rb +75 -0
- data/lib/skrift/color/colr.rb +35 -0
- data/lib/skrift/color/cpal.rb +30 -0
- data/lib/skrift/color/png.rb +132 -0
- data/lib/skrift/color/renderer.rb +167 -0
- data/lib/skrift/color/version.rb +7 -0
- data/lib/skrift/color.rb +17 -0
- metadata +74 -0
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
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
|
data/lib/skrift/color.rb
ADDED
|
@@ -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: []
|