mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
202 lines
7.1 KiB
JavaScript
202 lines
7.1 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as zlib from 'node:zlib';
|
|
import {app, BrowserWindow} from 'electron';
|
|
|
|
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
|
|
const BUILD_RESOURCES_DIR = path.join(ROOT_DIR, 'build_resources');
|
|
const FONT_FILE = path.join(ROOT_DIR, '..', 'packages', 'fonts', 'files', 'FluxerSans', 'FluxerSans-SemiBold.woff2');
|
|
const ICON_DIRECTORIES = Object.freeze(['icons-stable', 'icons-canary', 'icons-development']);
|
|
const FRAME_SIZES = Object.freeze([16, 20, 24, 32, 40, 48]);
|
|
const SUPERSAMPLE = 8;
|
|
const BADGE_COUNT = 11;
|
|
const DOT_INDEX = 11;
|
|
const CIRCLE_DIAMETER_RATIO = 0.875;
|
|
const DIGIT_HEIGHT_RATIO = 0.49;
|
|
const WIDE_LABEL_MAX_WIDTH_RATIO = 0.7;
|
|
const DOT_DIAMETER_RATIO = 0.375;
|
|
const GLYPH_COLOR = '#ffffff';
|
|
const VARIANTS = Object.freeze([
|
|
{prefix: 'badge', fill: '#f35a5a'},
|
|
{prefix: 'badge-dark', fill: '#26252d'},
|
|
]);
|
|
|
|
function labelFor(index) {
|
|
if (index === DOT_INDEX) return null;
|
|
if (index === BADGE_COUNT - 1) return '9+';
|
|
return String(index);
|
|
}
|
|
|
|
function renderInPage(options) {
|
|
return document.fonts.load(`600 100px BadgeFont`).then(() => {
|
|
const results = [];
|
|
for (const job of options.jobs) {
|
|
const size = job.size * options.supersample;
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
const context = canvas.getContext('2d', {willReadFrequently: true});
|
|
context.fillStyle = job.fill;
|
|
context.beginPath();
|
|
context.arc(size / 2, size / 2, (size * options.circleDiameterRatio) / 2, 0, Math.PI * 2);
|
|
context.fill();
|
|
context.fillStyle = options.glyphColor;
|
|
if (job.label === null) {
|
|
context.beginPath();
|
|
context.arc(size / 2, size / 2, (size * options.dotDiameterRatio) / 2, 0, Math.PI * 2);
|
|
context.fill();
|
|
} else {
|
|
context.font = '600 100px BadgeFont';
|
|
const reference = context.measureText('8');
|
|
const referenceHeight = reference.actualBoundingBoxAscent + reference.actualBoundingBoxDescent;
|
|
let fontSize = (100 * size * options.digitHeightRatio) / referenceHeight;
|
|
context.font = `600 ${fontSize}px BadgeFont`;
|
|
let metrics = context.measureText(job.label);
|
|
let width = metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight;
|
|
const maxWidth = size * options.wideLabelMaxWidthRatio;
|
|
if (width > maxWidth) {
|
|
fontSize *= maxWidth / width;
|
|
context.font = `600 ${fontSize}px BadgeFont`;
|
|
metrics = context.measureText(job.label);
|
|
width = metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight;
|
|
}
|
|
const height = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
|
|
const x = (size - width) / 2 + metrics.actualBoundingBoxLeft;
|
|
const y = (size - height) / 2 + metrics.actualBoundingBoxAscent;
|
|
context.fillText(job.label, x, y);
|
|
}
|
|
results.push(Array.from(context.getImageData(0, 0, size, size).data));
|
|
}
|
|
return results;
|
|
});
|
|
}
|
|
|
|
function downsample(rgba, size, factor) {
|
|
const source = size * factor;
|
|
const output = Buffer.alloc(size * size * 4);
|
|
const samples = factor * factor;
|
|
for (let y = 0; y < size; y++) {
|
|
for (let x = 0; x < size; x++) {
|
|
let red = 0;
|
|
let green = 0;
|
|
let blue = 0;
|
|
let alpha = 0;
|
|
for (let sy = 0; sy < factor; sy++) {
|
|
for (let sx = 0; sx < factor; sx++) {
|
|
const offset = ((y * factor + sy) * source + (x * factor + sx)) * 4;
|
|
const weight = rgba[offset + 3];
|
|
red += rgba[offset] * weight;
|
|
green += rgba[offset + 1] * weight;
|
|
blue += rgba[offset + 2] * weight;
|
|
alpha += weight;
|
|
}
|
|
}
|
|
const target = (y * size + x) * 4;
|
|
if (alpha === 0) continue;
|
|
output[target] = Math.round(red / alpha);
|
|
output[target + 1] = Math.round(green / alpha);
|
|
output[target + 2] = Math.round(blue / alpha);
|
|
output[target + 3] = Math.round(alpha / samples);
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function pngChunk(type, data) {
|
|
const chunk = Buffer.alloc(12 + data.length);
|
|
chunk.writeUInt32BE(data.length, 0);
|
|
chunk.write(type, 4, 'latin1');
|
|
data.copy(chunk, 8);
|
|
chunk.writeUInt32BE(zlib.crc32(chunk.subarray(4, 8 + data.length)), 8 + data.length);
|
|
return chunk;
|
|
}
|
|
|
|
function encodePng(rgba, size) {
|
|
const header = Buffer.alloc(13);
|
|
header.writeUInt32BE(size, 0);
|
|
header.writeUInt32BE(size, 4);
|
|
header.writeUInt8(8, 8);
|
|
header.writeUInt8(6, 9);
|
|
const rows = Buffer.alloc(size * (size * 4 + 1));
|
|
for (let y = 0; y < size; y++) {
|
|
rgba.copy(rows, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
|
|
}
|
|
return Buffer.concat([
|
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
pngChunk('IHDR', header),
|
|
pngChunk('IDAT', zlib.deflateSync(rows, {level: 9})),
|
|
pngChunk('IEND', Buffer.alloc(0)),
|
|
]);
|
|
}
|
|
|
|
function encodeIcon(frames) {
|
|
const header = Buffer.alloc(6 + frames.length * 16);
|
|
header.writeUInt16LE(0, 0);
|
|
header.writeUInt16LE(1, 2);
|
|
header.writeUInt16LE(frames.length, 4);
|
|
let offset = header.length;
|
|
frames.forEach(({size, data}, index) => {
|
|
const entry = 6 + index * 16;
|
|
header.writeUInt8(size, entry);
|
|
header.writeUInt8(size, entry + 1);
|
|
header.writeUInt16LE(1, entry + 4);
|
|
header.writeUInt16LE(32, entry + 6);
|
|
header.writeUInt32LE(data.length, entry + 8);
|
|
header.writeUInt32LE(offset, entry + 12);
|
|
offset += data.length;
|
|
});
|
|
return Buffer.concat([header, ...frames.map(({data}) => data)]);
|
|
}
|
|
|
|
async function generate() {
|
|
const font = fs.readFileSync(FONT_FILE).toString('base64');
|
|
const window = new BrowserWindow({show: false, webPreferences: {offscreen: true}});
|
|
const html = `<style>@font-face{font-family:BadgeFont;font-weight:600;src:url(data:font/woff2;base64,${font}) format('woff2')}</style>`;
|
|
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
|
const jobs = VARIANTS.flatMap((variant) =>
|
|
Array.from({length: BADGE_COUNT}, (_, offset) => offset + 1).flatMap((index) =>
|
|
FRAME_SIZES.map((size) => ({variant: variant.prefix, index, size, fill: variant.fill, label: labelFor(index)})),
|
|
),
|
|
);
|
|
const options = {
|
|
jobs: jobs.map(({size, fill, label}) => ({size, fill, label})),
|
|
supersample: SUPERSAMPLE,
|
|
circleDiameterRatio: CIRCLE_DIAMETER_RATIO,
|
|
digitHeightRatio: DIGIT_HEIGHT_RATIO,
|
|
wideLabelMaxWidthRatio: WIDE_LABEL_MAX_WIDTH_RATIO,
|
|
dotDiameterRatio: DOT_DIAMETER_RATIO,
|
|
glyphColor: GLYPH_COLOR,
|
|
};
|
|
const rendered = await window.webContents.executeJavaScript(
|
|
`(${renderInPage.toString()})(${JSON.stringify(options)})`,
|
|
);
|
|
window.destroy();
|
|
const icons = new Map();
|
|
jobs.forEach((job, position) => {
|
|
const name = `${job.variant}-${job.index}.ico`;
|
|
const frames = icons.get(name) ?? [];
|
|
const pixels = downsample(rendered[position], job.size, SUPERSAMPLE);
|
|
frames.push({size: job.size, data: encodePng(pixels, job.size)});
|
|
icons.set(name, frames);
|
|
});
|
|
for (const directory of ICON_DIRECTORIES) {
|
|
const target = path.join(BUILD_RESOURCES_DIR, directory, 'badges');
|
|
fs.mkdirSync(target, {recursive: true});
|
|
for (const [name, frames] of icons) {
|
|
fs.writeFileSync(path.join(target, name), encodeIcon(frames));
|
|
}
|
|
}
|
|
console.log(`Wrote ${icons.size} badge icons to ${ICON_DIRECTORIES.length} channel directories`);
|
|
}
|
|
|
|
app
|
|
.whenReady()
|
|
.then(generate)
|
|
.then(() => app.exit(0))
|
|
.catch((error) => {
|
|
console.error(error);
|
|
app.exit(1);
|
|
});
|