Skip to content

Commit 16af0cc

Browse files
matmenclaude
andcommitted
README image: a weather card instead of the profile card
The README example (tests/readme.js) draws a frosted glass weather card over an SVG landscape: renderSVG with gradients, region and blur for the glass, a blurred shadow, composite with an opacity, roundCorners, text in Open Sans and Source Sans 3 with Arabic, Devanagari and color emoji fallbacks, and a chart of gradient bars. It replaces the profile card with the photo and the badge SVGs only it used. The old image stays as tests/targets/profile-card.png: the lossless JPEG XL test compares it with cjxl's output and the fixture scripts crop it. averageColor checks the new image. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsU2GDnECphmYTjLB5UeZv
1 parent 86410a7 commit 16af0cc

14 files changed

Lines changed: 83 additions & 177 deletions

‎scripts/avif-fixtures.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ def write(name, data, reference=avifdec):
265265
write('grain', avif([image(encode([yuv(photo(48, 40), '420', 8)], '420', 8, ['--cq-level=30', '--film-grain-test=1']), '420', 8, 48, 40)]))
266266
write('superres', avif([image(encode([yuv(photo(128, 96), '420', 8)], '420', 8, ['--cq-level=35', '--cpu-used=0', '--superres-mode=1',
267267
'--superres-kf-denominator=13', '--enable-restoration=1']), '420', 8, 128, 96)]))
268-
write('screen', avif([image(encode([yuv(source('readme.png', 200, 150), '420', 8)], '420', 8, ['--cq-level=30', '--tune-content=screen']), '420', 8, 200, 150)]))
268+
write('screen', avif([image(encode([yuv(source('profile-card.png', 200, 150), '420', 8)], '420', 8, ['--cq-level=30', '--tune-content=screen']), '420', 8, 200, 150)]))
269269

270270
# alpha (an auxiliary image), premultiplied too
271271
color, alpha = photo(48, 40), alpha_ramp(48, 40)

‎scripts/jxl-fixtures.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def write_pfm(path, im):
4141

4242

4343
photo = lambda w, h: source('external.png', w, h, (200, 120, 520, 440))
44-
text = lambda w, h: source('readme.png', w, h, (40, 40, 40 + w, 40 + h))
44+
text = lambda w, h: source('profile-card.png', w, h, (40, 40, 40 + w, 40 + h))
4545

4646
# lossless (Modular): the fast efforts (gradient, RLE), MA trees and the weighted predictor, palettes, alpha, 16 bits,
4747
# grey, floats

‎scripts/jxl-lossless-references.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515

1616
def images():
17-
for name in ['external.png', 'circle.png', 'readme.png', 'potato.png']:
17+
for name in ['external.png', 'circle.png', 'profile-card.png', 'potato.png']:
1818
yield name, np.asarray(Image.open(root / 'tests' / 'targets' / name).convert('RGBA'))
1919
# (grey: the red channel)
2020
red = np.asarray(Image.open(root / 'tests' / 'targets' / 'external.png').convert('RGBA'))[..., 0]

‎tests/averageColor.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ const {Image} = require('..');
44
const binary = await fs.readFile('./tests/targets/readme.png');
55
const image = await Image.decode(binary);
66
const avgColor = image.averageColor();
7-
if (avgColor !== 0x343d3eff) process.exit(1);
7+
if (avgColor !== 0x715876ff) process.exit(1);
88
})();

‎tests/jxl-encode.js‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ const references = {
1515
16: {
1616
'external.png': '58a26107a26d63d1a4e38aae9143c23072d1ccb627035c6a306fcf38334d4b47',
1717
'circle.png': 'cef97c2ac1002fb7303dd133b77013bf38d9df094fb68cf5377f9820d165ff45',
18-
'readme.png': 'bf6d939ac373e8c57d86429e9935ceeac55b5bf9619782fd1e2a1111eaa0b328',
18+
'profile-card.png': 'bf6d939ac373e8c57d86429e9935ceeac55b5bf9619782fd1e2a1111eaa0b328',
1919
'potato.png': 'e5f9166c9468f4477c175238aa0b422db0c785fc23349441d0bb73e0327b7fc3',
2020
'external.png grey': '6d127dcd6055a4c753ef4b9c0d17680efc5009c0276c2482e46e4c3622e123f3'
2121
},
2222
8: {
2323
'external.png': '4bf1c60af3ec0166962d34e843bed45ad93dccadd8911167a10970d530c18270',
2424
'circle.png': '9e50886f2f1cca31d509fc305ffcf1fa90fe80e2c0c0e53ee61f800e319cfe05',
25-
'readme.png': 'baa03b62a243e1c5cf8e54e8fb1a903b424893b0d3f992f8dc91017af821055f',
25+
'profile-card.png': 'baa03b62a243e1c5cf8e54e8fb1a903b424893b0d3f992f8dc91017af821055f',
2626
'potato.png': 'd0a2806f68b4ee0cc933a1221d46855b15ca23af8a970a227a6c7a00978a8ba1',
2727
'external.png grey': 'e88fd972b6d54ba5a5cc9b42a1da9082ed15375e4a9076688820ee56d92d7f10'
2828
}
@@ -50,7 +50,7 @@ const codestream = jxl => {
5050

5151
(async () => {
5252
const {encode} = await import('../dist/codecs/jxl/fast-lossless.js');
53-
for (const [name, grey] of [['external.png'], ['circle.png'], ['readme.png'], ['potato.png'], ['external.png', true]]) {
53+
for (const [name, grey] of [['external.png'], ['circle.png'], ['profile-card.png'], ['potato.png'], ['external.png', true]]) {
5454
const key = name + (grey ? ' grey' : '');
5555
const image = await Image.decode(await fs.readFile(`./tests/targets/${name}`));
5656
// (grey: the red channel)
@@ -103,7 +103,7 @@ const codestream = jxl => {
103103
for (let i = 3; i < image.bitmap.length; i += 4) if (back.bitmap[i] !== image.bitmap[i]) panic(`${w}x${h}: lossy alpha`);
104104
}
105105
// efforts: the varblock search (every transform at effort 2) for no worse files; a screenshot's large flat areas
106-
const shot = (await Image.decode(await fs.readFile('./tests/targets/readme.png'))).crop(0, 0, 256, 128);
106+
const shot = (await Image.decode(await fs.readFile('./tests/targets/profile-card.png'))).crop(0, 0, 256, 128);
107107
let effort0 = 0;
108108
for (const effort of [0, 1, 2]) {
109109
const jxl = await shot.encodeJXL({quality: 90, effort});

‎tests/readme.js‎

Lines changed: 75 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,88 @@
1+
// the image in the README: a weather card of frosted glass over an SVG landscape
12
const {Image} = require('..');
2-
const fs = require('fs').promises;
3+
const fs = require('fs');
34
const samePixels = require('./util/pixels');
45

5-
(async () => {
6-
const [backgroundSVG, avatarBinary, badges, font] = await Promise.all([
7-
fs.readFile('./tests/svgs/background.svg').then(b => b.toString()),
8-
fs.readFile('./tests/targets/external.png'),
9-
Promise.all(
10-
[
11-
'crown', 'potato', 'mask', 'microbe', 'petri_dish', 'thermometer', 'cigarette'
12-
].map(
13-
x => fs.readFile(`./tests/svgs/${x}.svg`)
14-
.then(b => b.toString()))
15-
),
16-
fs.readFile('./tests/fonts/carbon phyber.ttf')
17-
]);
18-
19-
const image = new Image(1000, 700);
20-
21-
const backgroundPattern = await Image.renderSVG(backgroundSVG);
22-
for (let xOffset = 0; xOffset < image.width; xOffset += backgroundPattern.width) {
23-
for (let yOffset = 0; yOffset < image.height; yOffset += backgroundPattern.height) {
24-
image.composite(backgroundPattern, {x: xOffset, y: yOffset});
25-
}
26-
}
27-
28-
const avatarBG = new Image(200 + 16, 200 + 16);
29-
const avatarGradient = Image.gradient({0: 0x00ffffff, 1: 0x0080ffff});
30-
avatarBG.fill((x, y) => avatarGradient((x + y) / (avatarBG.width + avatarBG.height)));
31-
image.composite(avatarBG.cropCircle(), {x: 0.05 * image.width, y: 0.05 * image.height});
32-
33-
const avatar = await Image.decode(avatarBinary);
34-
image.composite(avatar.resize(200, 200).cropCircle(), {x: 0.05 * image.width + 8, y: 0.05 * image.height + 8});
35-
36-
const username = await Image.renderText(font, 64, 'matmen', {color: 0xffffffff});
37-
image.composite(username, {x: 0.3 * image.width, y: 0.05 * image.height});
38-
39-
image.drawBox(0.3 * image.width, 0.05 * image.height + username.height, username.width, 3, 0x808080ff);
40-
41-
const discriminator = await Image.renderText(font, 48, '#9984', {color: 0xd0d0d0ff});
42-
image.composite(discriminator, {x: 0.3 * image.width, y: 0.05 * image.height + username.height});
43-
44-
// Global XP
45-
{
46-
const xpBarBackground = new Image(0.9 * image.width, 0.05 * image.height);
47-
xpBarBackground.fill(0xa0a0a0ff);
48-
image.composite(xpBarBackground.roundCorners(16), {x: 0.05 * image.width, y: 0.5 * image.height});
49-
50-
const xpBar = new Image(69 / 420 * xpBarBackground.width, 0.05 * image.height);
51-
const xpGradient = Image.gradient({0: 0x0080ffff, .8: 0x00ffffff, 1: 0x00ff80ff});
52-
xpBar.fill(x => xpGradient(x / xpBarBackground.width));
53-
image.composite(xpBar.roundCorners(16), {x: 0.05 * image.width, y: 0.5 * image.height});
54-
55-
const xpText = await Image.renderText(font, 32, '69/420', {color: 0xff});
56-
image.composite(xpText, {x: 0.05 * image.width + xpBarBackground.width / 2 - xpText.width / 2, y: 0.5 * image.height + xpBarBackground.height / 2 - xpText.height / 2});
6+
const font = name => fs.readFileSync('./tests/fonts/' + name);
577

58-
const levelText = await Image.renderText(font, 32, 'Level 3', {color: 0xffffffff});
59-
image.composite(levelText, {x: 0.05 * image.width + xpBarBackground.width - levelText.width, y: 0.5 * image.height - levelText.height});
60-
61-
const xpKindText = await Image.renderText(font, 32, 'Global XP', {color: 0xffffffff});
62-
image.composite(xpKindText, {x: 0.05 * image.width, y: 0.5 * image.height - levelText.height});
63-
}
64-
65-
// Server XP
66-
{
67-
const xpBarBackground = new Image(0.9 * image.width, 0.05 * image.height);
68-
xpBarBackground.fill(0xa0a0a0ff);
69-
image.composite(xpBarBackground.roundCorners(16), {x: 0.05 * image.width, y: 0.65 * image.height});
70-
71-
const xpBar = new Image(75 / 100 * xpBarBackground.width, 0.05 * image.height);
72-
const xpGradient = Image.gradient({0: 0x0080ffff, .8: 0x00ffffff, 1: 0x00ff80ff});
73-
xpBar.fill(x => xpGradient(x / xpBarBackground.width));
74-
image.composite(xpBar.roundCorners(16), {x: 0.05 * image.width, y: 0.65 * image.height});
75-
76-
const xpText = await Image.renderText(font, 32, '75/100', {color: 0xff});
77-
image.composite(xpText, {x: 0.05 * image.width + xpBarBackground.width / 2 - xpText.width / 2, y: 0.65 * image.height + xpBarBackground.height / 2 - xpText.height / 2});
78-
79-
const levelText = await Image.renderText(font, 32, 'Level 1', {color: 0xffffffff});
80-
image.composite(levelText, {x: 0.05 * image.width + xpBarBackground.width - levelText.width, y: 0.65 * image.height - levelText.height});
81-
82-
const xpKindText = await Image.renderText(font, 32, 'Server XP', {color: 0xffffffff});
83-
image.composite(xpKindText, {x: 0.05 * image.width, y: 0.65 * image.height - levelText.height});
84-
}
85-
86-
const badgeBackground = new Image(...Array(2).fill(Math.sqrt(64 ** 2 * 2)));
87-
badgeBackground.fill(0xa0a0a080);
88-
badgeBackground.cropCircle();
89-
for (let i = 0; i < badges.length; i++) {
90-
image.composite(badgeBackground, {x: 0.05 * image.width + 0.9 * image.width / badges.length * i, y: 0.8 * image.height});
91-
92-
const badge = await Image.renderSVG(badges[i], {size: 64, mode: Image.SVG_MODE_WIDTH});
93-
if (i >= 3)
94-
badge.saturation(0);
95-
96-
image.composite(badge, {
97-
x: 0.05 * image.width + 0.9 * image.width / badges.length * i + (badgeBackground.width - badge.width) / 2,
98-
y: 0.8 * image.height + (badgeBackground.height - badge.height) / 2
99-
});
8+
(async () => {
9+
const W = 1000, H = 600;
10+
const bold = font('opensans bold.ttf'), regular = font('sourcesans3 regular.otf');
11+
const scripts = [regular, font('noto naskh arabic subset.ttf'), font('noto sans devanagari subset.ttf'), font('noto colrv1 subset.ttf')];
12+
13+
// background: an SVG landscape at sunrise
14+
const image = await Image.renderSVG(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
15+
<defs>
16+
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
17+
<stop offset="0" stop-color="#2b3a8c"/><stop offset="0.55" stop-color="#e2729a"/><stop offset="1" stop-color="#ffc58a"/>
18+
</linearGradient>
19+
<radialGradient id="sun"><stop offset="0" stop-color="#fff6d0"/><stop offset="0.5" stop-color="#ffd27a"/><stop offset="1" stop-color="#ffd27a" stop-opacity="0"/></radialGradient>
20+
<linearGradient id="far" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7a4d8c"/><stop offset="1" stop-color="#4a3366"/></linearGradient>
21+
<linearGradient id="near" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2c2347"/><stop offset="1" stop-color="#16122a"/></linearGradient>
22+
</defs>
23+
<rect width="${W}" height="${H}" fill="url(#sky)"/>
24+
<circle cx="760" cy="330" r="190" fill="url(#sun)"/>
25+
<circle cx="760" cy="330" r="62" fill="#fff3c4"/>
26+
<path d="M0 420 L120 330 L210 380 L330 280 L470 390 L560 340 L700 410 L820 320 L1000 400 L1000 600 L0 600 Z" fill="url(#far)"/>
27+
<path d="M0 500 C150 430 260 470 380 440 S620 380 760 460 S920 470 1000 440 L1000 600 L0 600 Z" fill="url(#near)"/>
28+
</svg>`);
29+
30+
// a frosted glass card: the background behind it, blurred and lightened, with a soft shadow
31+
const card = {x: 40, y: 40, width: 520, height: 520, radius: 28};
32+
const shadow = new Image(card.width + 80, card.height + 80).drawBox(40, 48, card.width, card.height, 0x00000060).blur(16);
33+
image.composite(shadow, card.x - 40, card.y - 40);
34+
const glass = image.region(card.x, card.y, card.width, card.height).blur(24)
35+
.composite(new Image(card.width, card.height).fill(0xffffffff), 0, 0, {opacity: 0.18})
36+
.roundCorners(card.radius);
37+
image.composite(glass, card.x, card.y);
38+
39+
const text = async (fonts, size, string, color, x, y) => {
40+
const rendered = await Image.renderText(fonts, size, string, {color});
41+
image.composite(rendered, x, y);
42+
return rendered;
43+
};
44+
const left = card.x + 40;
45+
await text(bold, 44, 'Lisbon', 0xffffffff, left, card.y + 32);
46+
await text(regular, 26, 'Sunrise, clearing up later', 0xffffffd0, left, card.y + 92);
47+
const temperature = await text(bold, 132, '21°', 0xffffffff, left - 6, card.y + 128);
48+
49+
// a sun behind a cloud
50+
const icon = await Image.renderSVG(`<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">
51+
<defs><linearGradient id="s" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ffe27a"/><stop offset="1" stop-color="#ff9a3c"/></linearGradient>
52+
<linearGradient id="c" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#dfe6f5"/></linearGradient></defs>
53+
<circle cx="62" cy="48" r="34" fill="url(#s)"/>
54+
<path d="M44 108 a26 26 0 0 1 4 -52 a34 34 0 0 1 64 8 a22 22 0 0 1 6 44 Z" fill="url(#c)"/>
55+
</svg>`);
56+
image.composite(icon, card.x + card.width - 40 - icon.width, card.y + 150);
57+
58+
// the week: bars from the lowest to the highest temperature, in a gradient from cool to warm
59+
const days = [['Mon', 14, 21], ['Tue', 13, 19], ['Wed', 15, 23], ['Thu', 16, 25], ['Fri', 14, 22], ['Sat', 12, 18], ['Sun', 13, 20]];
60+
const gradient = Image.gradient({0: 0x5ac8faff, 0.5: 0xffd166ff, 1: 0xff7a59ff});
61+
const chart = {top: card.y + 300, height: 100, min: 10, max: 26};
62+
const step = (card.width - 80) / days.length;
63+
for (const [i, [day, low, high]] of days.entries()) {
64+
const cx = left + step * i + step / 2;
65+
const y0 = chart.top + chart.height * (1 - (high - chart.min) / (chart.max - chart.min));
66+
const y1 = chart.top + chart.height * (1 - (low - chart.min) / (chart.max - chart.min));
67+
const track = new Image(14, chart.height).fill(0xffffff30).roundCorners(7);
68+
image.composite(track, cx - 7, chart.top);
69+
const bar = new Image(14, y1 - y0).fill((x, y) => gradient((chart.max - (y0 + y - chart.top) / chart.height * (chart.max - chart.min) - chart.min) / (chart.max - chart.min)));
70+
image.composite(bar.roundCorners(7), cx - 7, y0);
71+
const label = await Image.renderText(regular, 20, day, {color: 0xffffffc0});
72+
image.composite(label, cx - label.width / 2, chart.top + chart.height + 10);
10073
}
10174

102-
image.roundCorners(32);
75+
// a divider, then greetings in a few scripts
76+
image.drawBox(left, card.y + 446, card.width - 80, 1, 0xffffff40);
77+
const greeting = await Image.renderText(scripts, 26, 'Bom dia · مرحبا · नमस्ते 😀', {color: 0xffffffe0});
78+
image.composite(greeting, left, card.y + 446 + (card.height - 446 - greeting.height) / 2);
10379

80+
image.roundCorners(24);
10481
const encoded = await image.encode({metadata: {creationTime: 0, software: ''}});
10582

10683
if (process.env.OVERWRITE_TEST)
107-
await fs.writeFile('./tests/targets/readme.png', encoded);
84+
fs.writeFileSync('./tests/targets/readme.png', encoded);
10885

109-
if (!await samePixels(await fs.readFile('./tests/targets/readme.png'), encoded))
86+
if (!await samePixels(fs.readFileSync('./tests/targets/readme.png'), encoded))
11087
process.exit(1);
11188
})();

‎tests/svgs/background.svg‎

Lines changed: 0 additions & 7 deletions
This file was deleted.

‎tests/svgs/cigarette.svg‎

Lines changed: 0 additions & 7 deletions
This file was deleted.

‎tests/svgs/mask.svg‎

Lines changed: 0 additions & 8 deletions
This file was deleted.

0 commit comments

Comments
 (0)