Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions packages/icons-scripts/Readme.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# @vkontakte/icons-scripts

Этот пакет содержит скрипты, используемые для сборки SVG-иконок в React-компоненты.
Expand Down Expand Up @@ -156,12 +156,16 @@

Используется lossless WebP с `effort: 6`, `quality: 100` и `exact: true`, без сравнения
размеров файлов. В режиме lossless параметр `quality` управляет затратами на сжатие.
Декодированные RGBA-пиксели проверяются на точное совпадение с оригиналом, включая RGB
полностью прозрачных точек. Преобразования, изменяющие пиксели, отбрасываются;
если WebP не сохраняет пиксели, изображение остаётся в PNG.
Сборка записывает WebP без повторного декодирования для сравнения пикселей; точное
сохранение RGBA проверяется тестами оптимизатора, включая RGB полностью прозрачных точек.
Готовые изображения находятся в `dist/raster`, исходники не меняются.
Анимированные PNG (APNG), 16-битные PNG и PNG с цветовыми метаданными (ICC, `gAMA`,
`cHRM`, `sRGB`, `cICP`, `mDCV`, `cLLI`) или EXIF-ориентацией сохраняются без перекодирования.
Исключение для WebP-конвертации — стандартная метка `sRGB` с perceptual intent (`0`)
и необязательным `gAMA: 45455`: такой файл можно конвертировать, поскольку WebP
без ICC-профиля использует sRGB.
Сам по себе `gAMA: 45455` без `sRGB` не разрешает конвертацию.
При сборке в PNG цветовые метаданные по-прежнему сохраняются без перекодирования.
Для каждой плотности в сборку записывается один файл. Компонент использует `<img>`
с `srcSet`. Системные утилиты для Oxipng не требуются.

Expand Down
55 changes: 36 additions & 19 deletions packages/icons-scripts/scripts/raster/optimize.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,10 @@
import * as fs from 'node:fs/promises';
import optimizePng, { init as initOxipng } from '@jsquash/oxipng/optimise.js';
import sharp from 'sharp';

let oxipngReady;

const protectedPngChunks = new Set([
'acTL',
'iCCP',
'gAMA',
'cHRM',
'sRGB',
'cICP',
'mDCV',
'cLLI',
]);
const protectedPngChunks = new Set(['acTL', 'iCCP', 'cHRM', 'cICP', 'mDCV', 'cLLI']);

/**
* @param {Buffer} input
Expand All @@ -30,22 +21,20 @@
(metadata.pages ?? 1) !== 1 ||
metadata.hasProfile ||
metadata.orientation ||
hasProtectedPngChunks(input)
hasProtectedPngChunks(input, convertToWebp)
) {
return { buffer: input, extension: '.png' };
}

const original = await image.clone().ensureAlpha().raw().toBuffer();
if (convertToWebp) {
const webp = await image
.clone()
.webp({ lossless: true, effort: 6, quality: 100, exact: true })
.toBuffer();
if (await preservesPixels(webp, original)) {
return { buffer: webp, extension: '.webp' };
}
return { buffer: webp, extension: '.webp' };
}

const original = await image.clone().ensureAlpha().raw().toBuffer();
const pngOptions = { compressionLevel: 9, adaptiveFiltering: true };
let png = input;
const compressed = await image.clone().png(pngOptions).toBuffer();
Expand Down Expand Up @@ -103,24 +92,52 @@
return decoded.equals(original);
}

/** @param {Buffer} input */
function hasProtectedPngChunks(input) {
/**
* @param {Buffer} input
* @param {boolean} allowStandardSrgb
*/
function hasProtectedPngChunks(input, allowStandardSrgb) {
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (!input.subarray(0, signature.length).equals(signature)) {
return false;
}

let hasSrgb = false;
let hasGamma = false;
// Inspect chunk headers rather than matching pixel or metadata bytes.
for (let offset = signature.length; offset + 12 <= input.length; ) {
const length = input.readUInt32BE(offset);
const type = input.toString('ascii', offset + 4, offset + 8);
if (length > input.length - offset - 12) {
return true;
}
// Unprofiled WebP uses sRGB. Only discard an explicit standard sRGB
// declaration with perceptual intent and its optional fallback gamma.
if (type === 'sRGB') {
if (!allowStandardSrgb || hasSrgb || length !== 1 || input[offset + 8] !== 0) {
return true;
}
hasSrgb = true;
}
if (type === 'gAMA') {
if (
!allowStandardSrgb ||
hasGamma ||
length !== 4 ||
input.readUInt32BE(offset + 8) !== 45455
) {
return true;
}
hasGamma = true;
}
if (protectedPngChunks.has(type)) {
return true;
}
if (type === 'IEND') {
return false;
break;
}
offset += length + 12;
}
return false;
// A gamma value alone does not specify the sRGB primaries or transfer curve.
return hasGamma && !hasSrgb;
}
56 changes: 55 additions & 1 deletion packages/icons-scripts/test/optimize.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import sharp from 'sharp';
Expand Down Expand Up @@ -147,7 +147,7 @@
for (const [type, data] of [
['gAMA', gamma],
['cHRM', chromaticities],
['sRGB', Buffer.from([0])],
['sRGB', Buffer.from([1])],
]) {
await t.test(type, async () => {
const input = Buffer.concat([base.subarray(0, 33), pngChunk(type, data), base.subarray(33)]);
Expand All @@ -160,3 +160,57 @@
});
}
});

test('standard sRGB PNGs convert to exact WebP while PNG mode preserves colour chunks', async (t) => {
const base = await noisyPng();
const gamma = Buffer.alloc(4);
gamma.writeUInt32BE(45455);
const srgbChunk = pngChunk('sRGB', Buffer.from([0]));
const gammaChunk = pngChunk('gAMA', gamma);
for (const [name, chunks] of [
['sRGB only', [srgbChunk]],
['sRGB followed by gamma', [srgbChunk, gammaChunk]],
['gamma followed by sRGB', [gammaChunk, srgbChunk]],
]) {
await t.test(name, async () => {
const input = Buffer.concat([base.subarray(0, 33), ...chunks, base.subarray(33)]);
const webp = await optimizeRasterIcon(input, { convertToWebp: true });
assert.equal(webp.extension, '.webp');
assert.equal((await sharp(webp.buffer).metadata()).format, 'webp');
assert.deepEqual(await rgba(webp.buffer), await rgba(input));
const png = await optimizeRasterIcon(input, { convertToWebp: false });
assert.equal(png.extension, '.png');
assert.deepEqual(png.buffer, input);
});
}
});

test('sRGB exceptions do not bypass protection for other colour metadata', async (t) => {
const base = await noisyPng();
const gamma = Buffer.alloc(4);
gamma.writeUInt32BE(45455);
const nonstandardGamma = Buffer.alloc(4);
nonstandardGamma.writeUInt32BE(100000);
const chromaticities = Buffer.alloc(32);
[31270, 32900, 64000, 33000, 30000, 60000, 15000, 6000].forEach((value, index) => {
chromaticities.writeUInt32BE(value, index * 4);
});
const srgbChunk = pngChunk('sRGB', Buffer.from([0]));
for (const [name, chunks] of [
['standard gamma without sRGB', [pngChunk('gAMA', gamma)]],
['nonstandard gamma with sRGB', [pngChunk('gAMA', nonstandardGamma), srgbChunk]],
['chromaticities with sRGB', [srgbChunk, pngChunk('cHRM', chromaticities)]],
['relative colorimetric intent', [pngChunk('sRGB', Buffer.from([1]))]],
['saturation intent', [pngChunk('sRGB', Buffer.from([2]))]],
['absolute colorimetric intent', [pngChunk('sRGB', Buffer.from([3]))]],
]) {
await t.test(name, async () => {
const input = Buffer.concat([base.subarray(0, 33), ...chunks, base.subarray(33)]);
for (const convertToWebp of [false, true]) {
const optimized = await optimizeRasterIcon(input, { convertToWebp });
assert.equal(optimized.extension, '.png');
assert.deepEqual(optimized.buffer, input);
}
});
}
});
Loading