mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
feat: add font style field component
This commit is contained in:
@@ -11,11 +11,12 @@
|
||||
> полностью переведён** (4 инструмента: circle-mask, square-mask,
|
||||
> star-mask, wavy-mask); **составной тип `position9` — переведены
|
||||
> add-text-png и date-stamp-png** (водяной знак-картинка — отдельный шаг:
|
||||
> требует overlay-механику в новом превью); preview
|
||||
> требует overlay-механику в новом превью); **составной тип `font-style`
|
||||
> полностью переведён** (text-to-png, add-text, date-stamp); preview
|
||||
> научен применять **генераторы** (`executeGenerate`, кнопка Generate) и
|
||||
> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/
|
||||
> color-pair/offset/position9).
|
||||
> Следующее: `font-style` (text-to-png → add-text → date-stamp).
|
||||
> color-pair/offset/position9/font-style).
|
||||
> Следующее: `plate` (add-text → date-stamp).
|
||||
>
|
||||
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
|
||||
> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
|
||||
@@ -373,8 +374,13 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
|
||||
ему нужен overlay-source (`getOverlay`/store), которого в новом превью пока
|
||||
нет. Тесты: +2 (602 passed). `font-style` и `plate` на этих инструментах
|
||||
сводятся в шаги 28-29.
|
||||
28. `font-style` — перевести text-to-png → add-text (если ещё не) →
|
||||
date-stamp
|
||||
28. `font-style` — **все 3 инструмента переведены** ✔ (`text-to-png` —
|
||||
генератор в `registry-new/generate.ts` (domOnly), `add-text-png`/
|
||||
`date-stamp-png` — рефакторинг в `registry-new/text.ts`). Составной тип
|
||||
во всех видах: вложенный объект `style: { font, size, bold, color }` +
|
||||
`field.fontStyle`, виджет `kit/fields/schema/FontStyleControl.svelte`,
|
||||
kind `font-style` в схеме (default/sanitize, clamp размера к min/max).
|
||||
Тесты: +1 (603 passed).
|
||||
29. `plate` — перевести add-text → date-stamp
|
||||
30. `gradient` — собрать из dimension + color-pair + direction на
|
||||
linear-gradient (зависит от решения по gradient, см. план)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import TextControl from "./fields/schema/TextControl.svelte";
|
||||
import OffsetControl from "./fields/schema/OffsetControl.svelte";
|
||||
import PositionControl from "./fields/schema/PositionControl.svelte";
|
||||
import FontStyleControl from "./fields/schema/FontStyleControl.svelte";
|
||||
|
||||
interface FieldControlProps {
|
||||
label: string;
|
||||
@@ -34,6 +35,7 @@
|
||||
dimension: DimensionField,
|
||||
offset: OffsetControl,
|
||||
position9: PositionControl,
|
||||
"font-style": FontStyleControl,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import type { TextFont } from "$lib/core/domText";
|
||||
import type {
|
||||
FieldSpec,
|
||||
FontStyle,
|
||||
FontStyleSpec,
|
||||
} from "$lib/registry-schema";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: unknown;
|
||||
spec: FieldSpec;
|
||||
onchange?: (value: FontStyle) => void;
|
||||
}
|
||||
let { label, value, spec, onchange }: Props = $props();
|
||||
|
||||
const sp = $derived(spec as FontStyleSpec);
|
||||
const current = $derived.by(() => {
|
||||
const v = value as Partial<FontStyle> | undefined;
|
||||
return {
|
||||
font:
|
||||
v?.font === "sans" || v?.font === "serif" || v?.font === "mono"
|
||||
? v.font
|
||||
: sp.font,
|
||||
size:
|
||||
typeof v?.size === "number" && Number.isFinite(v.size)
|
||||
? v.size
|
||||
: sp.size,
|
||||
bold: typeof v?.bold === "boolean" ? v.bold : sp.bold,
|
||||
color: typeof v?.color === "string" ? v.color : sp.color,
|
||||
};
|
||||
});
|
||||
|
||||
function set<K extends keyof FontStyle>(key: K, val: FontStyle[K]) {
|
||||
onchange?.({ ...current, [key]: val });
|
||||
}
|
||||
|
||||
const FONTS: { value: TextFont; label: string }[] = [
|
||||
{ value: "sans", label: "Sans" },
|
||||
{ value: "serif", label: "Serif" },
|
||||
{ value: "mono", label: "Mono" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="control font-style-control">
|
||||
<span class="fs-label">{label}</span>
|
||||
<div class="fs-grid">
|
||||
<label class="fs-cell">
|
||||
<span class="fs-sub">Font</span>
|
||||
<select
|
||||
class="fs-select"
|
||||
value={current.font}
|
||||
oninput={(e) =>
|
||||
set("font", (e.target as HTMLSelectElement).value as TextFont)}
|
||||
>
|
||||
{#each FONTS as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="fs-cell">
|
||||
<span class="fs-sub">Size</span>
|
||||
<input
|
||||
class="fs-input"
|
||||
type="number"
|
||||
min={sp.min}
|
||||
max={sp.max}
|
||||
value={current.size}
|
||||
oninput={(e) =>
|
||||
set("size", Number((e.target as HTMLInputElement).value))}
|
||||
/>
|
||||
</label>
|
||||
<label class="fs-cell">
|
||||
<span class="fs-sub">Color</span>
|
||||
<span class="fs-color">
|
||||
<span class="swatch" style="background:{current.color}"></span>
|
||||
<input
|
||||
class="fs-color-input"
|
||||
type="color"
|
||||
value={current.color}
|
||||
oninput={(e) => set("color", (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<label class="fs-cell fs-bold">
|
||||
<span class="fs-sub">Bold</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={current.bold}
|
||||
onchange={(e) => set("bold", (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.control {
|
||||
display: grid;
|
||||
gap: var(--space-m);
|
||||
margin-bottom: var(--space-xl);
|
||||
color: var(--color-text-muted);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
letter-spacing: var(--space-text-m);
|
||||
}
|
||||
.fs-label {
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-s);
|
||||
letter-spacing: var(--space-text-l);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.fs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-m);
|
||||
}
|
||||
.fs-cell {
|
||||
display: grid;
|
||||
gap: var(--space-m);
|
||||
}
|
||||
.fs-sub {
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
.fs-select,
|
||||
.fs-input,
|
||||
.fs-color-input {
|
||||
width: 100%;
|
||||
padding: var(--space-m) var(--space-l);
|
||||
background: var(--color-background);
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
border-radius: var(--radius-s);
|
||||
color: var(--color-text);
|
||||
font: var(--font-size-s) var(--font-mono);
|
||||
}
|
||||
.fs-color {
|
||||
display: grid;
|
||||
grid-template-columns: var(--space-xxl) 1fr;
|
||||
align-items: center;
|
||||
gap: var(--space-m);
|
||||
}
|
||||
.swatch {
|
||||
width: var(--space-xxl);
|
||||
height: var(--space-xxl);
|
||||
border-radius: var(--radius-s);
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
}
|
||||
.fs-color-input {
|
||||
height: var(--space-xxl);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fs-bold {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
.fs-bold input[type="checkbox"] {
|
||||
width: var(--space-xl);
|
||||
height: var(--space-xl);
|
||||
accent-color: var(--color-main);
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
toolSchema,
|
||||
type ColorPair,
|
||||
type Dimension,
|
||||
type FontStyle,
|
||||
} from "../registry-schema";
|
||||
import type { ToolEntry } from "./types";
|
||||
|
||||
@@ -324,6 +325,50 @@ const stepColorsTool: ToolEntry<StepColorsParams> = {
|
||||
),
|
||||
};
|
||||
|
||||
interface TextToPngParams {
|
||||
text: string;
|
||||
style: FontStyle;
|
||||
transparentBg: boolean;
|
||||
backgroundColor: string;
|
||||
padding: number;
|
||||
}
|
||||
|
||||
export const textToPngSchema = toolSchema<TextToPngParams>({
|
||||
text: field.text({ default: "Hello!", placeholder: "Your text" }),
|
||||
style: field.fontStyle({
|
||||
min: 8,
|
||||
max: 300,
|
||||
size: 96,
|
||||
font: "sans",
|
||||
bold: true,
|
||||
color: "#111318",
|
||||
}),
|
||||
transparentBg: field.checkbox({ default: false }),
|
||||
backgroundColor: field.color({ default: "#ffffff" }),
|
||||
padding: field.slider({ min: 0, max: 200, step: 2, default: 24 }),
|
||||
});
|
||||
|
||||
const textToPng: ToolEntry<TextToPngParams> = {
|
||||
id: "text-to-png",
|
||||
title: "Text to PNG",
|
||||
description:
|
||||
"Creates a PNG image from text: the canvas is sized to fit the label plus padding.",
|
||||
category: "generate",
|
||||
domOnly: true,
|
||||
schema: textToPngSchema,
|
||||
generate: (p) =>
|
||||
renderTextToImage({
|
||||
text: p.text,
|
||||
fontSize: p.style.size,
|
||||
font: p.style.font,
|
||||
bold: p.style.bold,
|
||||
color: p.style.color,
|
||||
backgroundColor: p.backgroundColor,
|
||||
transparentBg: p.transparentBg,
|
||||
padding: p.padding,
|
||||
}),
|
||||
};
|
||||
|
||||
export const generateEntries = [
|
||||
createEmpty,
|
||||
singleColor,
|
||||
@@ -335,4 +380,5 @@ export const generateEntries = [
|
||||
placeholder,
|
||||
blendTwo,
|
||||
stepColorsTool,
|
||||
textToPng,
|
||||
];
|
||||
|
||||
@@ -305,22 +305,30 @@ describe("registry-new (переведённые инструменты)", () =>
|
||||
expect(s.offset).toEqual({ x: 50, y: 0 });
|
||||
});
|
||||
|
||||
it("add-text: дефолты position9 и текстовых полей", () => {
|
||||
it("add-text: дефолты position9, font-style и текстовых полей", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "add-text-png")!;
|
||||
const d = defaultSchemaParams(tool.schema);
|
||||
expect(d.position).toBe("bottom-right");
|
||||
expect(d.text).toBe("Hello!");
|
||||
expect(d.plate).toBe(false);
|
||||
expect(d.style).toEqual({
|
||||
font: "sans",
|
||||
size: 48,
|
||||
bold: true,
|
||||
color: "#ffffff",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitize чинит мусор в position9 и оставляет валидные значения", () => {
|
||||
it("sanitize чинит мусор в position9, font-style и оставляет валидные значения", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "date-stamp-png")!;
|
||||
const s = sanitizeSchemaParams(tool.schema, {
|
||||
format: "YYYY-MM-DD",
|
||||
fontSize: 32,
|
||||
color: "#ffffff",
|
||||
font: "mono",
|
||||
bold: "no" as unknown as boolean,
|
||||
style: {
|
||||
font: "comic-sans",
|
||||
size: -500,
|
||||
bold: "no",
|
||||
color: "red",
|
||||
},
|
||||
position: "somewhere-outside",
|
||||
margin: 20,
|
||||
plate: true,
|
||||
@@ -328,12 +336,46 @@ describe("registry-new (переведённые инструменты)", () =>
|
||||
plateOpacity: 55,
|
||||
});
|
||||
expect(s.position).toBe("bottom-right");
|
||||
expect(s.bold).toBe(false);
|
||||
expect(s.style).toEqual({
|
||||
font: "mono",
|
||||
size: 8,
|
||||
bold: false,
|
||||
color: "#ffffff",
|
||||
});
|
||||
const valid = sanitizeSchemaParams(tool.schema, {
|
||||
...defaultSchemaParams(tool.schema),
|
||||
position: "top-center",
|
||||
style: { font: "serif", size: 120, bold: true, color: "#00ff00" },
|
||||
});
|
||||
expect(valid.position).toBe("top-center");
|
||||
expect(valid.style).toEqual({
|
||||
font: "serif",
|
||||
size: 120,
|
||||
bold: true,
|
||||
color: "#00ff00",
|
||||
});
|
||||
});
|
||||
|
||||
it("text-to-png: дефолты font-style и sanitize", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "text-to-png")!;
|
||||
const d = defaultSchemaParams(tool.schema);
|
||||
expect(d.style).toEqual({
|
||||
font: "sans",
|
||||
size: 96,
|
||||
bold: true,
|
||||
color: "#111318",
|
||||
});
|
||||
expect(d.transparentBg).toBe(false);
|
||||
const s = sanitizeSchemaParams(tool.schema, {
|
||||
style: { font: "sans", size: 9999, bold: false, color: "#123abc" },
|
||||
padding: 200,
|
||||
});
|
||||
expect(s.style).toEqual({
|
||||
font: "sans",
|
||||
size: 300,
|
||||
bold: false,
|
||||
color: "#123abc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import type { ToolEntry } from "./types";
|
||||
import type { Position9 } from "../core/textdraw";
|
||||
import { drawTextBlock, type TextFont } from "../core/domText";
|
||||
import { formatStamp } from "../core/datefmt";
|
||||
import { drawTextBlock } from "../core/domText";
|
||||
import type { Position9 } from "../core/textdraw";
|
||||
import type { FontStyle } from "../registry-schema";
|
||||
import { field, toolSchema } from "../registry-schema";
|
||||
|
||||
const FONT_OPTIONS = [
|
||||
{ value: "sans", label: "Sans-serif" },
|
||||
{ value: "serif", label: "Serif" },
|
||||
{ value: "mono", label: "Monospace" },
|
||||
] satisfies { value: TextFont; label: string }[];
|
||||
import type { ToolEntry } from "./types";
|
||||
|
||||
interface AddTextParams {
|
||||
text: string;
|
||||
fontSize: number;
|
||||
color: string;
|
||||
font: TextFont;
|
||||
bold: boolean;
|
||||
style: FontStyle;
|
||||
position: Position9;
|
||||
margin: number;
|
||||
plate: boolean;
|
||||
@@ -25,10 +17,14 @@ interface AddTextParams {
|
||||
|
||||
const addTextSchema = toolSchema<AddTextParams>({
|
||||
text: field.text({ default: "Hello!", placeholder: "Your text" }),
|
||||
fontSize: field.slider({ min: 8, max: 200, step: 1, default: 48 }),
|
||||
color: field.color({ default: "#ffffff" }),
|
||||
font: field.select<TextFont>({ default: "sans", options: FONT_OPTIONS }),
|
||||
bold: field.checkbox({ default: true }),
|
||||
style: field.fontStyle({
|
||||
min: 8,
|
||||
max: 200,
|
||||
size: 48,
|
||||
font: "sans",
|
||||
bold: true,
|
||||
color: "#ffffff",
|
||||
}),
|
||||
position: field.position9({ default: "bottom-right" }),
|
||||
margin: field.slider({ min: 0, max: 200, step: 1, default: 24 }),
|
||||
plate: field.checkbox({ default: false }),
|
||||
@@ -47,10 +43,10 @@ const addText: ToolEntry<AddTextParams> = {
|
||||
run: (img, p) =>
|
||||
drawTextBlock(img, {
|
||||
text: p.text,
|
||||
fontSize: p.fontSize,
|
||||
font: p.font,
|
||||
bold: p.bold,
|
||||
color: p.color,
|
||||
fontSize: p.style.size,
|
||||
font: p.style.font,
|
||||
bold: p.style.bold,
|
||||
color: p.style.color,
|
||||
opacityPercent: 100,
|
||||
position: p.position,
|
||||
margin: p.margin,
|
||||
@@ -61,10 +57,7 @@ const addText: ToolEntry<AddTextParams> = {
|
||||
|
||||
interface DateStampParams {
|
||||
format: string;
|
||||
fontSize: number;
|
||||
color: string;
|
||||
font: TextFont;
|
||||
bold: boolean;
|
||||
style: FontStyle;
|
||||
position: Position9;
|
||||
margin: number;
|
||||
plate: boolean;
|
||||
@@ -77,10 +70,14 @@ const dateStampSchema = toolSchema<DateStampParams>({
|
||||
default: "YYYY-MM-DD",
|
||||
placeholder: "YYYY-MM-DD hh:mm",
|
||||
}),
|
||||
fontSize: field.slider({ min: 8, max: 200, step: 1, default: 32 }),
|
||||
color: field.color({ default: "#ffffff" }),
|
||||
font: field.select<TextFont>({ default: "mono", options: FONT_OPTIONS }),
|
||||
bold: field.checkbox({ default: false }),
|
||||
style: field.fontStyle({
|
||||
min: 8,
|
||||
max: 200,
|
||||
size: 32,
|
||||
font: "mono",
|
||||
bold: false,
|
||||
color: "#ffffff",
|
||||
}),
|
||||
position: field.position9({ default: "bottom-right" }),
|
||||
margin: field.slider({ min: 0, max: 200, step: 1, default: 20 }),
|
||||
plate: field.checkbox({ default: true }),
|
||||
@@ -99,10 +96,10 @@ const dateStamp: ToolEntry<DateStampParams> = {
|
||||
run: (img, p) =>
|
||||
drawTextBlock(img, {
|
||||
text: formatStamp(new Date(), p.format),
|
||||
fontSize: p.fontSize,
|
||||
font: p.font,
|
||||
bold: p.bold,
|
||||
color: p.color,
|
||||
fontSize: p.style.size,
|
||||
font: p.style.font,
|
||||
bold: p.style.bold,
|
||||
color: p.style.color,
|
||||
opacityPercent: 100,
|
||||
position: p.position,
|
||||
margin: p.margin,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// Ошибки компилятора ловят расхождения между интерфейсом Params и схемой.
|
||||
|
||||
import type { Position9 } from "./core/textdraw";
|
||||
import type { TextFont } from "./core/domText";
|
||||
|
||||
export interface NumberSpec {
|
||||
kind: "number";
|
||||
@@ -114,6 +115,28 @@ export interface Position9Spec {
|
||||
default: Position9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Стиль текстовой надписи: шрифт, размер, жирность и цвет как один объект
|
||||
* (переиспользуется text-to-png, add-text, date-stamp).
|
||||
*/
|
||||
export interface FontStyle {
|
||||
font: TextFont;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface FontStyleSpec {
|
||||
kind: "font-style";
|
||||
/** Диапазон размера шрифта. */
|
||||
min: number;
|
||||
max: number;
|
||||
size: number;
|
||||
font: TextFont;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* «Объект as const» kind → спека поля. Единственный источник правды для
|
||||
* перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому
|
||||
@@ -131,6 +154,7 @@ export const fieldSpecs = {
|
||||
"color-pair": {} as ColorPairSpec,
|
||||
offset: {} as OffsetSpec,
|
||||
position9: {} as Position9Spec,
|
||||
"font-style": {} as FontStyleSpec,
|
||||
} as const;
|
||||
|
||||
export type FieldSpecKind = keyof typeof fieldSpecs;
|
||||
@@ -198,6 +222,16 @@ export const field = {
|
||||
position9: (s: { default: Position9 }): Field<Position9> => ({
|
||||
spec: { kind: "position9", ...s },
|
||||
}),
|
||||
fontStyle: (s: {
|
||||
min: number;
|
||||
max: number;
|
||||
size: number;
|
||||
font: TextFont;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
}): Field<FontStyle> => ({
|
||||
spec: { kind: "font-style", ...s },
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -230,6 +264,13 @@ export function defaultSchemaParams<P>(
|
||||
out[key] = { from: spec.from, to: spec.to };
|
||||
} else if (spec.kind === "offset") {
|
||||
out[key] = { x: spec.x, y: spec.y };
|
||||
} else if (spec.kind === "font-style") {
|
||||
out[key] = {
|
||||
font: spec.font,
|
||||
size: spec.size,
|
||||
bold: spec.bold,
|
||||
color: spec.color,
|
||||
};
|
||||
} else {
|
||||
out[key] = spec.default;
|
||||
}
|
||||
@@ -320,6 +361,33 @@ export function sanitizeSchemaParams<P>(
|
||||
? (raw as Position9)
|
||||
: spec.default;
|
||||
break;
|
||||
case "font-style": {
|
||||
const r =
|
||||
typeof raw === "object" &&
|
||||
raw !== null &&
|
||||
"font" in raw &&
|
||||
"size" in raw &&
|
||||
"bold" in raw &&
|
||||
"color" in raw
|
||||
? (raw as Record<string, unknown>)
|
||||
: undefined;
|
||||
out[key] = {
|
||||
font:
|
||||
r?.font === "sans" || r?.font === "serif" || r?.font === "mono"
|
||||
? r.font
|
||||
: spec.font,
|
||||
size:
|
||||
typeof r?.size === "number" && Number.isFinite(r.size)
|
||||
? clamp(r.size, spec.min, spec.max)
|
||||
: spec.size,
|
||||
bold: typeof r?.bold === "boolean" ? r.bold : spec.bold,
|
||||
color:
|
||||
typeof r?.color === "string" && /^#[0-9a-f]{6}$/i.test(r.color)
|
||||
? r.color
|
||||
: spec.color,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "offset": {
|
||||
const r =
|
||||
typeof raw === "object" && raw !== null && "x" in raw && "y" in raw
|
||||
|
||||
Reference in New Issue
Block a user