feat: add transparency range slider to opacity fields

This commit is contained in:
2026-10-05 15:39:47 +05:00
parent a0bc72ef5d
commit ba533e67fd
10 changed files with 92 additions and 65 deletions
-2
View File
@@ -81,8 +81,6 @@
- [ ] сделать несколько проходное сжатие png для получения необходимого размера - [ ] сделать несколько проходное сжатие png для получения необходимого размера
файлов, посмотреть реализацию в файлов, посмотреть реализацию в
[twitch-emote-resizer](https://github.com/tma02/twitch-emote-resizer) [twitch-emote-resizer](https://github.com/tma02/twitch-emote-resizer)
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
должны быть настраиваемой 0-100%, а не переключателем
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать - [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
+4 -2
View File
@@ -127,7 +127,7 @@ export interface TextToImageOptions {
bold: boolean; bold: boolean;
color: string; color: string;
backgroundColor: string; backgroundColor: string;
transparentBg: boolean; bgOpacity: number;
padding: number; padding: number;
maxTextWidth?: number; maxTextWidth?: number;
} }
@@ -148,9 +148,11 @@ export function renderTextToImage(o: TextToImageOptions): PixelImage {
const h = Math.max(1, Math.ceil(lineH + o.padding * 2)); const h = Math.max(1, Math.ceil(lineH + o.padding * 2));
const { canvas, ctx } = ctx2d(w, h); const { canvas, ctx } = ctx2d(w, h);
if (!o.transparentBg) { if (o.bgOpacity > 0) {
ctx.globalAlpha = Math.min(100, Math.max(0, o.bgOpacity)) / 100;
ctx.fillStyle = o.backgroundColor; ctx.fillStyle = o.backgroundColor;
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
ctx.globalAlpha = 1;
} }
ctx.font = fontString(o.fontSize, o.font, o.bold); ctx.font = fontString(o.fontSize, o.font, o.bold);
ctx.fillStyle = o.color; ctx.fillStyle = o.color;
+9 -3
View File
@@ -29,16 +29,22 @@ describe("randomColorBlocks", () => {
describe("drawGrid", () => { describe("drawGrid", () => {
it("линии на пересечениях непрозрачны, фон прозрачен", () => { it("линии на пересечениях непрозрачны, фон прозрачен", () => {
const out = drawGrid(100, 100, 4, 4, 2, "#000000", true); const out = drawGrid(100, 100, 4, 4, 2, "#000000", 0);
expect(out.data[0]).toBe(0); expect(out.data[0]).toBe(0);
expect(out.data[3]).toBe(255); // (0,0) на линии expect(out.data[3]).toBe(255); // (0,0) на линии
const mid = (53 * 100 + 53) * 4; const mid = (53 * 100 + 53) * 4;
expect(out.data[mid + 3]).toBe(0); // между линиями прозрачн expect(out.data[mid + 3]).toBe(0); // между линиями прозрачн
}); });
it("белый непрозрачный фон при transparentBg=false", () => { it("белый непрозрачный фон при bgOpacity=100", () => {
const out = drawGrid(20, 20, 2, 2, 1, "#000000", false); const out = drawGrid(20, 20, 2, 2, 1, "#000000", 100);
const mid = (5 * 20 + 5) * 4; const mid = (5 * 20 + 5) * 4;
expect(out.data[mid]).toBe(255); expect(out.data[mid]).toBe(255);
}); });
it("полупрозрачный фон при bgOpacity=50", () => {
const out = drawGrid(20, 20, 2, 2, 1, "#000000", 50);
const mid = (5 * 20 + 5) * 4;
expect(out.data[mid + 3]).toBe(128);
});
}); });
+13 -3
View File
@@ -79,7 +79,7 @@ function lineMask(
return mask; return mask;
} }
/** Сетка линий на прозрачном или белом фоне. */ /** Сетка линий на белом фоне заданной непрозрачности (0 — прозрачный). */
export function drawGrid( export function drawGrid(
width: number, width: number,
height: number, height: number,
@@ -87,10 +87,20 @@ export function drawGrid(
rows: number, rows: number,
lineWidth: number, lineWidth: number,
colorHex: string, colorHex: string,
transparentBg: boolean, bgOpacity: number,
): PixelImage { ): PixelImage {
const out = createPixelImage(width, height); const out = createPixelImage(width, height);
if (!transparentBg) out.data.fill(255); const bgAlpha = Math.round(
(Math.max(0, Math.min(100, bgOpacity)) / 100) * 255,
);
if (bgAlpha > 0) {
for (let i = 0; i < out.data.length; i += 4) {
out.data[i] = 255;
out.data[i + 1] = 255;
out.data[i + 2] = 255;
out.data[i + 3] = bgAlpha;
}
}
const m = /^#([0-9a-f]{6})$/i.exec(colorHex.trim()); const m = /^#([0-9a-f]{6})$/i.exec(colorHex.trim());
const r = m ? parseInt(m[1].slice(0, 2), 16) : 0; const r = m ? parseInt(m[1].slice(0, 2), 16) : 0;
+3 -2
View File
@@ -9,19 +9,20 @@ export function expandCanvas(
right: number, right: number,
bottom: number, bottom: number,
backgroundHex?: string, backgroundHex?: string,
backgroundAlpha = 255,
): PixelImage { ): PixelImage {
const l = Math.max(0, Math.trunc(left)); const l = Math.max(0, Math.trunc(left));
const t = Math.max(0, Math.trunc(top)); const t = Math.max(0, Math.trunc(top));
const r = Math.max(0, Math.trunc(right)); const r = Math.max(0, Math.trunc(right));
const b = Math.max(0, Math.trunc(bottom)); const b = Math.max(0, Math.trunc(bottom));
const out = createPixelImage(img.width + l + r, img.height + t + b); const out = createPixelImage(img.width + l + r, img.height + t + b);
if (backgroundHex !== undefined) { if (backgroundHex !== undefined && backgroundAlpha > 0) {
const [cr, cg, cb] = parseHex(backgroundHex); const [cr, cg, cb] = parseHex(backgroundHex);
for (let i = 0; i < out.data.length; i += 4) { for (let i = 0; i < out.data.length; i += 4) {
out.data[i] = cr; out.data[i] = cr;
out.data[i + 1] = cg; out.data[i + 1] = cg;
out.data[i + 2] = cb; out.data[i + 2] = cb;
out.data[i + 3] = 255; out.data[i + 3] = backgroundAlpha;
} }
} }
for (let y = 0; y < img.height; y++) { for (let y = 0; y < img.height; y++) {
-2
View File
@@ -298,8 +298,6 @@ export const en: Dict = {
textStyle: "Text style", textStyle: "Text style",
thickness: "Thickness", thickness: "Thickness",
threshold: "Threshold", threshold: "Threshold",
transparent: "Transparent",
transparentBg: "Transparent bg",
waves: "Waves", waves: "Waves",
width: "Width", width: "Width",
x: "X", x: "X",
+5 -7
View File
@@ -299,8 +299,6 @@ export const ru: Dict = {
textStyle: "Стиль текста", textStyle: "Стиль текста",
thickness: "Толщина", thickness: "Толщина",
threshold: "Порог", threshold: "Порог",
transparent: "Прозрачный",
transparentBg: "Прозрачный фон",
waves: "Волны", waves: "Волны",
width: "Ширина", width: "Ширина",
x: "X", x: "X",
@@ -972,7 +970,7 @@ export const ru: Dict = {
"add-padding": { "add-padding": {
params: { params: {
padding: "Поля, px", padding: "Поля, px",
transparent: "Прозрачные поля", opacity: "Непрозрачность полей, %",
color: "Цвет полей", color: "Цвет полей",
}, },
}, },
@@ -983,7 +981,7 @@ export const ru: Dict = {
params: { params: {
width: "Ширина полотна", width: "Ширина полотна",
height: "Высота полотна", height: "Высота полотна",
transparent: "Прозрачный фон", opacity: "Непрозрачность фона, %",
color: "Цвет фона", color: "Цвет фона",
}, },
}, },
@@ -1113,7 +1111,7 @@ export const ru: Dict = {
params: { params: {
width: "Ширина", width: "Ширина",
height: "Высота", height: "Высота",
transparent: "Прозрачный", opacity: "Непрозрачность",
color: "Цвет", color: "Цвет",
}, },
}, },
@@ -1352,7 +1350,7 @@ export const ru: Dict = {
font: "Шрифт", font: "Шрифт",
bold: "Жирный", bold: "Жирный",
color: "Цвет текста", color: "Цвет текста",
transparentBg: "Прозрачный фон", bgOpacity: "Непрозрачность фона, %",
backgroundColor: "Цвет фона", backgroundColor: "Цвет фона",
padding: "Отступ, px", padding: "Отступ, px",
}, },
@@ -1397,7 +1395,7 @@ export const ru: Dict = {
rows: "Строки", rows: "Строки",
lineWidth: "Толщина линий, px", lineWidth: "Толщина линий, px",
color: "Цвет линий", color: "Цвет линий",
transparentBg: "Прозрачный фон", bgOpacity: "Непрозрачность фона, %",
}, },
}, },
quantize: { quantize: {
+6 -6
View File
@@ -199,14 +199,14 @@ describe("registry-new (переведённые инструменты)", () =>
const tool = TOOLS.find((t) => t.id === "create-empty")!; const tool = TOOLS.find((t) => t.id === "create-empty")!;
const d = defaultSchemaParams(tool.schema); const d = defaultSchemaParams(tool.schema);
expect(d.size).toEqual({ width: 800, height: 600 }); expect(d.size).toEqual({ width: 800, height: 600 });
expect(d.transparent).toBe(true); expect(d.opacity).toBe(0);
}); });
it("sanitize клампит dimension к min/max и чинит мусор", () => { it("sanitize клампит dimension к min/max и чинит мусор", () => {
const tool = TOOLS.find((t) => t.id === "create-empty")!; const tool = TOOLS.find((t) => t.id === "create-empty")!;
const s = sanitizeSchemaParams(tool.schema, { const s = sanitizeSchemaParams(tool.schema, {
size: { width: 999999, height: -5 }, size: { width: 999999, height: -5 },
transparent: false, opacity: 50,
color: "#ff0000", color: "#ff0000",
}); });
expect(s.size).toEqual({ width: 20000, height: 1 }); expect(s.size).toEqual({ width: 20000, height: 1 });
@@ -218,7 +218,7 @@ describe("registry-new (переведённые инструменты)", () =>
const tool = TOOLS.find((t) => t.id === "create-empty")!; const tool = TOOLS.find((t) => t.id === "create-empty")!;
const params = sanitizeSchemaParams(tool.schema, { const params = sanitizeSchemaParams(tool.schema, {
size: { width: 320, height: 200 }, size: { width: 320, height: 200 },
transparent: true, opacity: 0,
color: "#ff0000", color: "#ff0000",
}); });
const img = asImage(await tool.run({ params })); const img = asImage(await tool.run({ params }));
@@ -262,7 +262,7 @@ describe("registry-new (переведённые инструменты)", () =>
rows: 3, rows: 3,
lineWidth: 2, lineWidth: 2,
color: "#000000", color: "#000000",
transparentBg: true, bgOpacity: 0,
}, },
], ],
])("генератор %s даёт картинку по dimension", async (id, params) => { ])("генератор %s даёт картинку по dimension", async (id, params) => {
@@ -336,7 +336,7 @@ describe("registry-new (переведённые инструменты)", () =>
source: img, source: img,
params: sanitizeSchemaParams(tool.schema, { params: sanitizeSchemaParams(tool.schema, {
size: { width: 60, height: 30 }, size: { width: 60, height: 30 },
transparent: true, opacity: 0,
color: "#ffffff", color: "#ffffff",
}), }),
}), }),
@@ -633,7 +633,7 @@ describe("registry-new (переведённые инструменты)", () =>
bold: true, bold: true,
color: "#111318", color: "#111318",
}); });
expect(d.transparentBg).toBe(false); expect(d.bgOpacity).toBe(100);
const s = sanitizeSchemaParams(tool.schema, { const s = sanitizeSchemaParams(tool.schema, {
style: { font: "sans", size: 9999, bold: false, color: "#123abc" }, style: { font: "sans", size: 9999, bold: false, color: "#123abc" },
padding: 200, padding: 200,
+31 -28
View File
@@ -78,7 +78,7 @@ function angleGradient(
interface CreateEmptyParams { interface CreateEmptyParams {
size: Dimension; size: Dimension;
transparent: boolean; opacity: number;
color: string; color: string;
} }
@@ -92,14 +92,20 @@ export const createEmptySchema = toolSchema<CreateEmptyParams>(
height: 600, height: 600,
presets: true, presets: true,
}), }),
transparent: field.checkbox({ label: "fields.transparent", default: true }), opacity: field.slider({
label: "fields.opacity",
min: 0,
max: 100,
step: 1,
default: 0,
}),
color: field.color({ label: "fields.fillColor", default: "#ffffff" }), color: field.color({ label: "fields.fillColor", default: "#ffffff" }),
}, },
{ {
layout: { layout: {
groups: [ groups: [
{ title: "groups.canvas", fields: ["size"] }, { title: "groups.canvas", fields: ["size"] },
{ title: "groups.fill", fields: ["transparent", "color"] }, { title: "groups.fill", fields: ["opacity", "color"] },
], ],
}, },
}, },
@@ -112,10 +118,9 @@ const createEmpty: Tool<CreateEmptyParams> = {
run: genTool((p) => { run: genTool((p) => {
const w = Math.trunc(p.size.width); const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height); const h = Math.trunc(p.size.height);
if (p.transparent) { const [r, g, b] = rgba(p.color).slice(0, 3);
return solidImage(w, h, [0, 0, 0, 0]); const alpha = Math.round((Math.trunc(p.opacity) / 100) * 255);
} return solidImage(w, h, [r, g, b, alpha]);
return solidImage(w, h, rgba(p.color));
}), }),
}; };
@@ -352,7 +357,7 @@ interface DrawGridParams {
rows: number; rows: number;
lineWidth: number; lineWidth: number;
color: string; color: string;
transparentBg: boolean; bgOpacity: number;
} }
export const drawGridSchema = toolSchema<DrawGridParams>( export const drawGridSchema = toolSchema<DrawGridParams>(
@@ -387,9 +392,12 @@ export const drawGridSchema = toolSchema<DrawGridParams>(
default: 2, default: 2,
}), }),
color: field.color({ label: "fields.lineColor", default: "#111318" }), color: field.color({ label: "fields.lineColor", default: "#111318" }),
transparentBg: field.checkbox({ bgOpacity: field.slider({
label: "fields.transparentBg", label: "fields.opacity",
default: true, min: 0,
max: 100,
step: 1,
default: 0,
}), }),
}, },
{ {
@@ -399,7 +407,7 @@ export const drawGridSchema = toolSchema<DrawGridParams>(
{ {
title: "groups.grid", title: "groups.grid",
cols: 2, cols: 2,
fields: ["cols", "rows", "lineWidth", "color", "transparentBg"], fields: ["cols", "rows", "lineWidth", "color", "bgOpacity"],
}, },
], ],
}, },
@@ -413,15 +421,7 @@ const drawGridTool: Tool<DrawGridParams> = {
run: genTool((p) => { run: genTool((p) => {
const w = Math.trunc(p.size.width); const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height); const h = Math.trunc(p.size.height);
return drawGrid( return drawGrid(w, h, p.cols, p.rows, p.lineWidth, p.color, p.bgOpacity);
w,
h,
p.cols,
p.rows,
p.lineWidth,
p.color,
p.transparentBg,
);
}), }),
}; };
@@ -481,7 +481,7 @@ const placeholder: Tool<PlaceholderParams> = {
bold: true, bold: true,
color: p.color, color: p.color,
backgroundColor: p.backgroundColor, backgroundColor: p.backgroundColor,
transparentBg: false, bgOpacity: 100,
padding: 0, padding: 0,
}); });
out = changeCanvasSize(label, w, h, "center"); out = changeCanvasSize(label, w, h, "center");
@@ -931,7 +931,7 @@ const sortColorsTool: Tool<SortColorsParams> = {
interface TextToPngParams { interface TextToPngParams {
text: string; text: string;
style: FontStyle; style: FontStyle;
transparentBg: boolean; bgOpacity: number;
backgroundColor: string; backgroundColor: string;
padding: number; padding: number;
} }
@@ -952,9 +952,12 @@ export const textToPngSchema = toolSchema<TextToPngParams>(
bold: true, bold: true,
color: "#111318", color: "#111318",
}), }),
transparentBg: field.checkbox({ bgOpacity: field.slider({
label: "fields.transparentBg", label: "fields.opacity",
default: false, min: 0,
max: 100,
step: 1,
default: 100,
}), }),
backgroundColor: field.color({ backgroundColor: field.color({
label: "fields.backgroundColor", label: "fields.backgroundColor",
@@ -974,7 +977,7 @@ export const textToPngSchema = toolSchema<TextToPngParams>(
{ title: "groups.text", fields: ["text", "style"] }, { title: "groups.text", fields: ["text", "style"] },
{ {
title: "groups.background", title: "groups.background",
fields: ["transparentBg", "backgroundColor"], fields: ["bgOpacity", "backgroundColor"],
}, },
{ title: "groups.padding", fields: ["padding"] }, { title: "groups.padding", fields: ["padding"] },
], ],
@@ -995,7 +998,7 @@ const textToPng: Tool<TextToPngParams> = {
bold: p.style.bold, bold: p.style.bold,
color: p.style.color, color: p.style.color,
backgroundColor: p.backgroundColor, backgroundColor: p.backgroundColor,
transparentBg: p.transparentBg, bgOpacity: p.bgOpacity,
padding: p.padding, padding: p.padding,
}), }),
), ),
+21 -10
View File
@@ -65,7 +65,7 @@ const addBorder: Tool<AddBorderParams> = {
interface FitOnBackgroundParams { interface FitOnBackgroundParams {
size: Dimension; size: Dimension;
transparent: boolean; opacity: number;
color: string; color: string;
} }
@@ -79,9 +79,12 @@ export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>(
height: 600, height: 600,
presets: true, presets: true,
}), }),
transparent: field.checkbox({ opacity: field.slider({
label: "fields.transparent", label: "fields.opacity",
default: false, min: 0,
max: 100,
step: 1,
default: 100,
}), }),
color: field.color({ label: "fields.background", default: "#ffffff" }), color: field.color({ label: "fields.background", default: "#ffffff" }),
}, },
@@ -89,7 +92,7 @@ export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>(
layout: { layout: {
groups: [ groups: [
{ title: "groups.canvas", fields: ["size"] }, { title: "groups.canvas", fields: ["size"] },
{ title: "groups.background", fields: ["transparent", "color"] }, { title: "groups.background", fields: ["opacity", "color"] },
], ],
}, },
}, },
@@ -113,7 +116,8 @@ const fitOnBackground: Tool<FitOnBackgroundParams> = {
top, top,
Math.max(0, width - img.width - left), Math.max(0, width - img.width - left),
Math.max(0, height - img.height - top), Math.max(0, height - img.height - top),
p.transparent ? undefined : p.color, p.color,
Math.round((Math.trunc(p.opacity) / 100) * 255),
); );
}), }),
}; };
@@ -331,7 +335,7 @@ const flipTool: Tool<FlipParams> = {
interface AddPaddingParams { interface AddPaddingParams {
padding: number; padding: number;
transparent: boolean; opacity: number;
color: string; color: string;
} }
@@ -344,14 +348,20 @@ export const addPaddingSchema = toolSchema<AddPaddingParams>(
step: 1, step: 1,
default: 10, default: 10,
}), }),
transparent: field.checkbox({ label: "fields.transparent", default: true }), opacity: field.slider({
label: "fields.opacity",
min: 0,
max: 100,
step: 1,
default: 0,
}),
color: field.color({ label: "fields.fillColor", default: "#ffffff" }), color: field.color({ label: "fields.fillColor", default: "#ffffff" }),
}, },
{ {
layout: { layout: {
groups: [ groups: [
{ title: "groups.padding", fields: ["padding"] }, { title: "groups.padding", fields: ["padding"] },
{ title: "groups.fill", fields: ["transparent", "color"] }, { title: "groups.fill", fields: ["opacity", "color"] },
], ],
}, },
}, },
@@ -368,7 +378,8 @@ const addPaddingTool: Tool<AddPaddingParams> = {
p.padding, p.padding,
p.padding, p.padding,
p.padding, p.padding,
p.transparent ? undefined : p.color, p.color,
Math.round((Math.trunc(p.opacity) / 100) * 255),
), ),
), ),
}; };