feat: base english language, also fallback to english
This commit is contained in:
@@ -3,7 +3,7 @@ import type { CategoryId } from '../categories';
|
||||
export const LOCALES = ['ru', 'en'] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
export const BASE_LOCALE: Locale = 'ru';
|
||||
export const BASE_LOCALE: Locale = 'en';
|
||||
|
||||
export const LOCALE_TAGS: Record<Locale, string> = {
|
||||
ru: 'ru-RU',
|
||||
@@ -15,12 +15,14 @@ export function isLocale(value: unknown): value is Locale {
|
||||
}
|
||||
|
||||
export type ToolStrings = {
|
||||
title: string;
|
||||
description: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** Подписи параметров по их id. */
|
||||
params?: Record<string, string>;
|
||||
/** Подписи опций select: paramId -> value -> label. */
|
||||
options?: Record<string, Record<string, string>>;
|
||||
/** Тексты-результаты текстовых инструментов (analyze): ключ -> строка. */
|
||||
results?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type HeaderStrings = {
|
||||
|
||||
+38
-18
@@ -1,4 +1,4 @@
|
||||
import type { Dict } from './dict';
|
||||
import type { Dict } from './dict';
|
||||
|
||||
export const en: Dict = {
|
||||
header: {
|
||||
@@ -6,7 +6,7 @@ export const en: Dict = {
|
||||
catalog: 'Catalog',
|
||||
sectionsAria: 'Sections',
|
||||
footerNote:
|
||||
'All operations run locally in your browser — your files are never uploaded anywhere.'
|
||||
'All operations run locally in your browser — your files are never uploaded anywhere.'
|
||||
},
|
||||
categories: {
|
||||
convert: 'Convert',
|
||||
@@ -18,14 +18,14 @@ export const en: Dict = {
|
||||
generate: 'Generate'
|
||||
},
|
||||
home: {
|
||||
defaultTitle: 'easy-png-tools — PNG utilities right in your browser',
|
||||
defaultTitle: 'easy-png-tools — PNG utilities right in your browser',
|
||||
heroTitle: 'What do you want to do with the image?',
|
||||
heroLead: 'Find a tool — everything runs locally in your browser.',
|
||||
restoreLast: '↩ Restore last: {title}',
|
||||
changeTool: '← Change tool'
|
||||
heroLead: 'Find a tool — everything runs locally in your browser.',
|
||||
restoreLast: '↩ Restore last: {title}',
|
||||
changeTool: 'в†ђ Change tool'
|
||||
},
|
||||
catalog: {
|
||||
pageTitle: 'All tools — easy-png-tools',
|
||||
pageTitle: 'All tools — easy-png-tools',
|
||||
metaDescription:
|
||||
'Full catalog of PNG utilities: convert, transparency, color, geometry, analysis and image generation.',
|
||||
heading: 'Tool catalog',
|
||||
@@ -46,7 +46,7 @@ export const en: Dict = {
|
||||
inputLegend: 'Input',
|
||||
resultLegend: 'Result',
|
||||
paramsLegend: 'Parameters',
|
||||
busyTitle: 'Processing…',
|
||||
busyTitle: 'Processing…',
|
||||
busyHint: 'Running chain step',
|
||||
removeStepAria: 'Remove step'
|
||||
},
|
||||
@@ -56,14 +56,14 @@ export const en: Dict = {
|
||||
resultCard: {
|
||||
emptyTitle: 'The result will appear here',
|
||||
emptyHint: 'First upload a source image on the left',
|
||||
processingTitle: 'Processing…',
|
||||
processingTitle: 'Processing…',
|
||||
processingHint: 'The image is being processed, this will take a moment',
|
||||
recalc: 'Recalculating…',
|
||||
nextTool: '⛓ Next tool',
|
||||
breakChain: '✂ Break the chain'
|
||||
recalc: 'Recalculating…',
|
||||
nextTool: 'в›“ Next tool',
|
||||
breakChain: 'вњ‚ Break the chain'
|
||||
},
|
||||
paramsCard: {
|
||||
noParams: 'This tool has no parameters — the result is ready as is.'
|
||||
noParams: 'This tool has no parameters — the result is ready as is.'
|
||||
},
|
||||
textInput: {
|
||||
heading: 'Text',
|
||||
@@ -78,13 +78,13 @@ export const en: Dict = {
|
||||
downloadTxt: 'Download .txt'
|
||||
},
|
||||
download: {
|
||||
busy: 'Preparing file…',
|
||||
busy: 'Preparing file…',
|
||||
file: 'Download .{ext}'
|
||||
},
|
||||
infoPanel: {
|
||||
dimensions: 'Dimensions',
|
||||
alpha: 'Alpha channel',
|
||||
alphaYes: 'yes — there are semi-transparent pixels',
|
||||
alphaYes: 'yes — there are semi-transparent pixels',
|
||||
alphaNo: 'no',
|
||||
colorCount: 'Unique colors (RGBA)'
|
||||
},
|
||||
@@ -93,9 +93,9 @@ export const en: Dict = {
|
||||
overlayDefault: 'Release the file to replace the image'
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Find a tool…',
|
||||
placeholder: 'Find a tool…',
|
||||
aria: 'Search tools',
|
||||
nothingFound: 'Nothing found — try another word.'
|
||||
nothingFound: 'Nothing found — try another word.'
|
||||
},
|
||||
ui: {
|
||||
showMask: 'Show mask',
|
||||
@@ -110,5 +110,25 @@ export const en: Dict = {
|
||||
workerUnavailable: 'Worker is unavailable',
|
||||
notFound: 'Tool not found'
|
||||
},
|
||||
tools: {}
|
||||
tools: {
|
||||
'png-is-grayscale': {
|
||||
results: {
|
||||
grayscaleYes: 'Yes — all pixels are shades of gray.',
|
||||
grayscaleNo: 'No — colored pixels were found.'
|
||||
}
|
||||
},
|
||||
'png-is-transparent': {
|
||||
results: {
|
||||
transparentYes: 'Yes — there are transparent or semi-transparent pixels.',
|
||||
transparentNo: 'No — all pixels are fully opaque.'
|
||||
}
|
||||
},
|
||||
'png-orientation': {
|
||||
results: {
|
||||
orientationPortrait: 'Portrait — height is greater than width.',
|
||||
orientationLandscape: 'Landscape — width is greater than height.',
|
||||
orientationSquare: 'Square — the sides are equal.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getLocale, initLocale, setLocale } from './locale.svelte';
|
||||
import { interpolate, t } from './t';
|
||||
import { ru } from './ru';
|
||||
import { en } from './en';
|
||||
|
||||
type Store = Record<string, string>;
|
||||
|
||||
@@ -19,7 +19,7 @@ function stubStorage(): { store: Store } {
|
||||
|
||||
beforeEach(() => {
|
||||
setLocale('ru');
|
||||
delete (ru.home as Record<string, string>).onlyRuKey;
|
||||
delete (en.home as Record<string, string>).onlyEnKey;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -30,10 +30,10 @@ describe('t', () => {
|
||||
expect(t('header.catalog')).toBe('Catalog');
|
||||
});
|
||||
|
||||
it('фолбэк на базовую локаль, если в активной нет ключа', () => {
|
||||
(ru.home as Record<string, string>).onlyRuKey = 'Только русская строка';
|
||||
setLocale('en');
|
||||
expect(t('home.onlyRuKey')).toBe('Только русская строка');
|
||||
it('фолбэк на английскую базу, если в активной локали нет ключа', () => {
|
||||
(en.home as Record<string, string>).onlyEnKey = 'Only English string';
|
||||
setLocale('ru');
|
||||
expect(t('home.onlyEnKey')).toBe('Only English string');
|
||||
});
|
||||
|
||||
it('неизвестный путь возвращает сам путь', () => {
|
||||
|
||||
+370
-2
@@ -1,4 +1,4 @@
|
||||
import type { Dict } from './dict';
|
||||
import type { Dict } from './dict';
|
||||
|
||||
export const ru: Dict = {
|
||||
header: {
|
||||
@@ -110,5 +110,373 @@ export const ru: Dict = {
|
||||
workerUnavailable: 'Воркер недоступен',
|
||||
notFound: 'Инструмент не найден'
|
||||
},
|
||||
tools: {}
|
||||
tools: {
|
||||
'jpg-to-png': {
|
||||
title: 'Конвертировать JPG в PNG',
|
||||
description: 'Открывает JPEG и сохраняет его как PNG без потерь. Прозрачность, если была, сохраняется.'
|
||||
},
|
||||
'webp-to-png': {
|
||||
title: 'Конвертировать WebP в PNG',
|
||||
description: 'Перекодирует WebP-изображение в универсальный PNG.'
|
||||
},
|
||||
'gif-to-png': {
|
||||
title: 'Конвертировать GIF в PNG',
|
||||
description: 'Достаёт первый кадр GIF-анимации и сохраняет его как PNG.'
|
||||
},
|
||||
'bmp-to-png': {
|
||||
title: 'Конвертировать BMP в PNG',
|
||||
description: 'Перекодирует BMP в компактный PNG без потерь.'
|
||||
},
|
||||
'ico-to-png': {
|
||||
title: 'Конвертировать ICO в PNG',
|
||||
description: 'Превращает иконку .ico в обычный PNG нужного размера.'
|
||||
},
|
||||
'png-to-bmp': {
|
||||
title: 'Конвертировать PNG в BMP',
|
||||
description: 'Сохраняет изображение в 24-битный BMP без альфа-канала: прозрачность заменяется чёрным фоном.'
|
||||
},
|
||||
'png-to-base64': {
|
||||
title: 'PNG в Base64',
|
||||
description: 'Кодирует изображение в base64-строку для вставки в код или стили.'
|
||||
},
|
||||
'base64-to-png': {
|
||||
title: 'Base64 в PNG',
|
||||
description: 'Декодирует base64-строку или data-uri обратно в картинку. Вставьте строку слева.'
|
||||
},
|
||||
'png-to-data-uri': {
|
||||
title: 'PNG в Data URI',
|
||||
description: 'Строит полный data-uri (data:image/png;base64,…) для встраивания в HTML/CSS.'
|
||||
},
|
||||
'data-uri-to-png': {
|
||||
title: 'Data URI в PNG',
|
||||
description: 'Декодирует data:image/…;base64,… обратно в файл картинки.'
|
||||
},
|
||||
'png-to-hex': {
|
||||
title: 'PNG в HEX-пиксели',
|
||||
description: 'Показывает все пиксели как hex-значения rrggbbaa — по строкам, через пробел.'
|
||||
},
|
||||
'hex-to-png': {
|
||||
title: 'HEX-пиксели в PNG',
|
||||
description: 'Собирает картинку из hex-значений rrggbbaa (через пробел). Укажите ширину — высота рассчитается сама.',
|
||||
params: { width: 'Ширина изображения' }
|
||||
},
|
||||
'resize-png': {
|
||||
title: 'Изменить размер PNG',
|
||||
description: 'Масштабирование изображения с билинейной интерполяцией. При сохранении пропорций одна сторона задаёт масштаб, а если указаны обе — изображение вписывается в эти размеры.',
|
||||
params: { width: 'Ширина (0 — авто)', height: 'Высота (0 — авто)', keepAspect: 'Сохранять пропорции' }
|
||||
},
|
||||
'crop-png': {
|
||||
title: 'Обрезать PNG',
|
||||
description: 'Вырезает прямоугольную область. Координаты и размеры выходят за границы изображения — область усекается до пересечения с картинкой.',
|
||||
params: { x: 'X (слева)', y: 'Y (сверху)', width: 'Ширина области', height: 'Высота области' }
|
||||
},
|
||||
'rotate-png': {
|
||||
title: 'Повернуть PNG',
|
||||
description: 'Поворот на 90°, 180° или 270° по часовой стрелке без потери качества.',
|
||||
params: { angle: 'Угол поворота' },
|
||||
options: { angle: { '90': '90° по часовой', '180': '180°', '270': '270° по часовой' } }
|
||||
},
|
||||
'flip-png': {
|
||||
title: 'Отразить PNG',
|
||||
description: 'Зеркальное отражение по горизонтали или вертикали без потери качества.',
|
||||
params: { axis: 'Ось отражения' },
|
||||
options: {
|
||||
axis: {
|
||||
horizontal: 'По горизонтали (слева направо)',
|
||||
vertical: 'По вертикали (сверху вниз)'
|
||||
}
|
||||
}
|
||||
},
|
||||
'add-padding-png': {
|
||||
title: 'Добавить поля PNG',
|
||||
description: 'Расширяет холст во все стороны на выбранное число пикселей.',
|
||||
params: { padding: 'Поля, px', transparent: 'Прозрачные поля', color: 'Цвет полей' }
|
||||
},
|
||||
'add-border-png': {
|
||||
title: 'Добавить рамку PNG',
|
||||
description: 'Рисует цветную рамку вокруг изображения выбранной толщины.',
|
||||
params: { thickness: 'Толщина рамки, px', color: 'Цвет рамки' }
|
||||
},
|
||||
'fit-on-background-png': {
|
||||
title: 'Вписать PNG на фон',
|
||||
description: 'Помещает изображение по центру полотна заданного размера с прозрачным или цветным фоном.',
|
||||
params: {
|
||||
width: 'Ширина полотна',
|
||||
height: 'Высота полотна',
|
||||
transparent: 'Прозрачный фон',
|
||||
color: 'Цвет фона'
|
||||
}
|
||||
},
|
||||
'tile-png': {
|
||||
title: 'Замостить PNG',
|
||||
description: 'Повторяет изображение сеткой из выбранного числа столбцов и строк.',
|
||||
params: { columns: 'Столбцов', rows: 'Строк' }
|
||||
},
|
||||
'center-by-alpha-png': {
|
||||
title: 'Центрировать PNG по содержимому',
|
||||
description: 'Находит непрозрачную часть изображения и размещает её по центру прежнего холста.'
|
||||
},
|
||||
'blur-png': {
|
||||
title: 'Размытие PNG',
|
||||
description: 'Гауссово размытие: три прохода разделяемого бокса — быстро при любом радиусе. Прозрачные края не темнеют.',
|
||||
params: { radius: 'Радиус, px' }
|
||||
},
|
||||
'sharpen-png': {
|
||||
title: 'Резкость PNG',
|
||||
description: 'Подчёркивает края ядром резкости; сила задаёт смесь с оригиналом. 0% — без изменений.',
|
||||
params: { strength: 'Сила, %' }
|
||||
},
|
||||
'grayscale-png': {
|
||||
title: 'Чёрно-белый PNG',
|
||||
description: 'Переводит изображение в оттенки серого по яркостной формуле BT.601. Альфа сохраняется.'
|
||||
},
|
||||
'invert-colors-png': {
|
||||
title: 'Инвертировать цвета PNG',
|
||||
description: 'Обращает каждый цветовой канал (255 − значение). Альфа не меняется.'
|
||||
},
|
||||
'adjust-brightness-contrast-png': {
|
||||
title: 'Яркость и контраст PNG',
|
||||
description: 'Изменяет яркость и контраст в диапазоне от −100 до +100. Значение 0 — без изменений.',
|
||||
params: { brightness: 'Яркость', contrast: 'Контраст' }
|
||||
},
|
||||
'change-png-opacity': {
|
||||
title: 'Изменить прозрачность PNG',
|
||||
description: 'Умножает альфа-канал на процент: 0% — полностью прозрачный, 100% — без изменений.',
|
||||
params: { percent: 'Прозрачность, %' }
|
||||
},
|
||||
'sepia-png': {
|
||||
title: 'Эффект сепии',
|
||||
description: 'Тонирует изображение в тёплые коричневые тона классической сепии.'
|
||||
},
|
||||
'change-png-hue': {
|
||||
title: 'Сменить оттенок PNG',
|
||||
description: 'Сдвиг цветового тона по кругу. Насыщенность и яркость сохраняются.',
|
||||
params: { degrees: 'Сдвиг тона, °' }
|
||||
},
|
||||
'extract-channel-png': {
|
||||
title: 'Извлечь канал PNG',
|
||||
description: 'Оставляет выбранный канал — красный, зелёный или синий — в оттенках серого.',
|
||||
params: { channel: 'Канал' },
|
||||
options: { channel: { red: 'Красный', green: 'Зелёный', blue: 'Синий' } }
|
||||
},
|
||||
'swap-channels-png': {
|
||||
title: 'Переставить каналы PNG',
|
||||
description: 'Меняет местами два цветовых канала — быстрый способ получить необычный окрас.',
|
||||
params: { pair: 'Пара каналов' },
|
||||
options: {
|
||||
pair: { 'r-g': 'Красный ↔ Зелёный', 'r-b': 'Красный ↔ Синий', 'g-b': 'Зелёный ↔ Синий' }
|
||||
}
|
||||
},
|
||||
'black-and-white-png': {
|
||||
title: 'Чёрно-белый PNG по порогу',
|
||||
description: 'Жёсткая бинаризация по яркости: каждый пиксель становится чёрным или белым.',
|
||||
params: { threshold: 'Порог яркости, %' }
|
||||
},
|
||||
'posterize-png': {
|
||||
title: 'Постеризация PNG',
|
||||
description: 'Уменьшает число уровней каждого канала — плакатный эффект.',
|
||||
params: { levels: 'Уровней на канал' }
|
||||
},
|
||||
'two-colors-png': {
|
||||
title: 'Два цвета PNG',
|
||||
description: 'Перекрашивает изображение в два выбранных цвета по порогу яркости.',
|
||||
params: {
|
||||
lightColor: 'Цвет светлых участков',
|
||||
darkColor: 'Цвет тёмных участков',
|
||||
threshold: 'Порог яркости, %'
|
||||
}
|
||||
},
|
||||
'convert-png-to-jpg': {
|
||||
title: 'Конвертировать PNG в JPG',
|
||||
description: 'Прозрачность накладывается на выбранный цвет подложки (по умолчанию белый), результат сохраняется в JPEG.',
|
||||
params: { background: 'Цвет подложки', quality: 'Качество JPEG' }
|
||||
},
|
||||
'convert-png-to-webp': {
|
||||
title: 'Конвертировать PNG в WebP',
|
||||
description: 'Перекодирует изображение в WebP с настраиваемым качеством. Прозрачность сохраняется.',
|
||||
params: { quality: 'Качество WebP' }
|
||||
},
|
||||
'remove-alpha-channel-png': {
|
||||
title: 'Убрать альфа-канал PNG',
|
||||
description: 'Накладывает изображение на белый фон и сохраняет без прозрачности.'
|
||||
},
|
||||
'set-alpha-channel-png': {
|
||||
title: 'Задать альфа-канал PNG',
|
||||
description: 'Присваивает всем пикселям одинаковую прозрачность, цвета не меняются.',
|
||||
params: { percent: 'Прозрачность, %' }
|
||||
},
|
||||
'extract-alpha-mask-png': {
|
||||
title: 'Извлечь маску альфы PNG',
|
||||
description: 'Превращает прозрачность в чёрно-белую непрозрачную маску.'
|
||||
},
|
||||
'round-corners-png': {
|
||||
title: 'Скруглить углы PNG',
|
||||
description: 'Обрезает углы по радиусу, заданному в процентах от половины меньшей стороны.',
|
||||
params: { radius: 'Радиус скругления, %' }
|
||||
},
|
||||
'invert-alpha-png': {
|
||||
title: 'Инвертировать альфа-канал PNG',
|
||||
description: 'Непрозрачные области становятся прозрачными и наоборот.'
|
||||
},
|
||||
'remove-background-png': {
|
||||
title: 'Удалить фон PNG (умно)',
|
||||
description: 'Убирает однотонный фон: по цвету с допуском, только внешние области от краёв или весь совпадающий цвет. Умеет сглаживать границу.',
|
||||
params: {
|
||||
color: 'Цвет фона',
|
||||
tolerance: 'Допуск похожести, %',
|
||||
outerOnly: 'Только внешние области',
|
||||
smooth: 'Сглаживание границы, проходы'
|
||||
}
|
||||
},
|
||||
'add-stroke-png': {
|
||||
title: 'Обвести PNG',
|
||||
description: 'Добавляет цветную обводку-кольцо вокруг непрозрачного содержимого заданной толщины.',
|
||||
params: { color: 'Цвет обводки', thickness: 'Толщина, px' }
|
||||
},
|
||||
'find-contour-png': {
|
||||
title: 'Найти контур PNG',
|
||||
description: 'Оставляет только линию по границе непрозрачных областей выбранного цвета и толщины.',
|
||||
params: { color: 'Цвет линии', thickness: 'Толщина линии, px' }
|
||||
},
|
||||
'make-thicker-png': {
|
||||
title: 'Утолщить PNG',
|
||||
description: 'Расширяет непрозрачные области на заданное число пикселей.',
|
||||
params: { radius: 'На сколько px' }
|
||||
},
|
||||
'make-thinner-png': {
|
||||
title: 'Утончить PNG',
|
||||
description: 'Сужает непрозрачные области — утоньшает штрихи надписей и деталей.',
|
||||
params: { radius: 'На сколько px' }
|
||||
},
|
||||
'harden-alpha-png': {
|
||||
title: 'Жёсткие края PNG',
|
||||
description: 'Бинаризует альфа-канал по порогу: полупрозрачные пиксели становятся либо полностью прозрачными, либо непрозрачными.',
|
||||
params: { threshold: 'Порог альфы, %' }
|
||||
},
|
||||
'despeckle-alpha-png': {
|
||||
title: 'Убрать мусор PNG',
|
||||
description: 'Открытие: убирает одиночные полупрозрачные пиксели и мелкие крапинки.',
|
||||
params: { radius: 'Радиус очистки, px' }
|
||||
},
|
||||
'close-holes-png': {
|
||||
title: 'Закрыть дыры PNG',
|
||||
description: 'Закрытие: заполняет одиночные прозрачные точки внутри объекта.',
|
||||
params: { radius: 'Радиус закрытия, px' }
|
||||
},
|
||||
'remove-color-from-png': {
|
||||
title: 'Удалить цвет из PNG (прозрачность)',
|
||||
description: 'Делает прозрачными все пиксели, близкие к выбранному цвету. Порог задаёт допустимое отклонение в процентах от максимального цветового расстояния.',
|
||||
params: { targetColor: 'Цвет для удаления', tolerance: 'Порог похожести, %' }
|
||||
},
|
||||
'png-info': {
|
||||
title: 'Информация о PNG',
|
||||
description: 'Показывает размеры, наличие альфа-канала и количество уникальных цветов загруженного изображения.'
|
||||
},
|
||||
'create-empty-png': {
|
||||
title: 'Создать пустой PNG',
|
||||
description: 'Генерирует холст выбранного размера — прозрачный или залитый цветом.',
|
||||
params: { width: 'Ширина', height: 'Высота', transparent: 'Прозрачный', color: 'Цвет' }
|
||||
},
|
||||
'single-color-png': {
|
||||
title: 'Создать одноцветный PNG',
|
||||
description: 'Генерирует прямоугольник заданного размера и цвета.',
|
||||
params: { width: 'Ширина', height: 'Высота', color: 'Цвет' }
|
||||
},
|
||||
'random-noise-png': {
|
||||
title: 'Создать случайный шум PNG',
|
||||
description: 'Генерирует картинку со случайными пикселями. Зерно фиксирует результат: одно зерно — одна картинка.',
|
||||
params: { width: 'Ширина', height: 'Высота', seed: 'Зерно' }
|
||||
},
|
||||
'linear-gradient-png': {
|
||||
title: 'Создать градиент PNG',
|
||||
description: 'Генерирует плавный переход между двумя цветами по горизонтали или вертикали.',
|
||||
params: {
|
||||
width: 'Ширина',
|
||||
height: 'Высота',
|
||||
fromColor: 'Цвет начала',
|
||||
toColor: 'Цвет конца',
|
||||
direction: 'Направление'
|
||||
},
|
||||
options: { direction: { horizontal: 'По горизонтали', vertical: 'По вертикали' } }
|
||||
},
|
||||
'png-is-grayscale': {
|
||||
title: 'Проверить: PNG монохромный?',
|
||||
description: 'Сообщает, состоит ли изображение только из оттенков серого.',
|
||||
results: {
|
||||
grayscaleYes: 'Да — все пиксели являются оттенками серого.',
|
||||
grayscaleNo: 'Нет — найдены цветные пиксели.'
|
||||
}
|
||||
},
|
||||
'skew-png': {
|
||||
title: 'Наклонить PNG',
|
||||
description: 'Сдвигает содержимое по горизонтали и вертикали — эффект перспективы.',
|
||||
params: { degX: 'Наклон по X, °', degY: 'Наклон по Y, °' }
|
||||
},
|
||||
'rotate-free-png': {
|
||||
title: 'Повернуть на произвольный угол',
|
||||
description: 'Поворот на любой угол. Холст расширяется под новые габариты, углы остаются прозрачными.',
|
||||
params: { angle: 'Угол, °' }
|
||||
},
|
||||
'zoom-png': {
|
||||
title: 'Приблизить PNG',
|
||||
description: 'Увеличивает содержимое к центру. Холст прежнего размера — края обрезаются.',
|
||||
params: { scale: 'Масштаб, %' }
|
||||
},
|
||||
'shift-png': {
|
||||
title: 'Сдвинуть PNG',
|
||||
description: 'Перемещает содержимое на заданное смещение по X и Y.',
|
||||
params: { offsetX: 'Смещение X, px', offsetY: 'Смещение Y, px', color: 'Цвет фона' }
|
||||
},
|
||||
'vignette-png': {
|
||||
title: 'Виньетка PNG',
|
||||
description: 'Плавно затемняет края изображения, центр не затрагивает.',
|
||||
params: { strength: 'Сила затемнения, %' }
|
||||
},
|
||||
'jpeg-artifacts-png': {
|
||||
title: 'Артефакты JPEG',
|
||||
description: 'Имитирует пережатие в JPEG с низким качеством — видимые квадраты и размытие цветов.',
|
||||
params: { quality: 'Качество JPEG' }
|
||||
},
|
||||
'gamma-png': {
|
||||
title: 'Гамма-коррекция PNG',
|
||||
description: 'Корректирует яркость средних тонов. <1 темнее, >1 светлее, 1 — без изменений.',
|
||||
params: { value: 'Гамма' }
|
||||
},
|
||||
'auto-contrast-png': {
|
||||
title: 'Автоконтраст PNG',
|
||||
description: 'Растягивает диапазон каждого канала на весь доступный диапазон яркости.'
|
||||
},
|
||||
'temperature-png': {
|
||||
title: 'Температура PNG',
|
||||
description: 'Положительные значения делают изображение теплее (оранжевее), отрицательные — холоднее (синеватее).',
|
||||
params: { percent: 'Температура' }
|
||||
},
|
||||
'tint-png': {
|
||||
title: 'Тонировать PNG',
|
||||
description: 'Умножает цветовые каналы на выбранный оттенок с заданной силой.',
|
||||
params: { color: 'Цвет тонирования', strength: 'Сила, %' }
|
||||
},
|
||||
'svg-to-png': {
|
||||
title: 'SVG в PNG',
|
||||
description: 'Декодирует SVG-разметку в растровое изображение. Вставьте SVG-код слева.',
|
||||
params: { width: 'Ширина результата, px' }
|
||||
},
|
||||
'png-is-transparent': {
|
||||
title: 'Проверить: PNG прозрачный?',
|
||||
description: 'Сообщает, есть ли в изображении прозрачные или полупрозрачные пиксели.',
|
||||
results: {
|
||||
transparentYes: 'Да — есть прозрачные или полупрозрачные пиксели.',
|
||||
transparentNo: 'Нет — все пиксели полностью непрозрачны.'
|
||||
}
|
||||
},
|
||||
'png-orientation': {
|
||||
title: 'Ориентация PNG',
|
||||
description: 'Сообщает, портрет это, ландшафт или квадрат.',
|
||||
results: {
|
||||
orientationPortrait: 'Портрет — высота больше ширины.',
|
||||
orientationLandscape: 'Ландшафт — ширина больше высоты.',
|
||||
orientationSquare: 'Квадрат — стороны равны.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ParamDef, ToolEntry } from '$lib/registry';
|
||||
import { getMergedDict } from './locale.svelte';
|
||||
|
||||
/**
|
||||
* Строки инструмента на активной локали.
|
||||
* Русский текст живёт в самом реестре (базовая локаль),
|
||||
* переводы — в секции tools словарей; отсутствие ключа даёт фолбэк на реестр.
|
||||
*/
|
||||
export function toolTitle(tool: ToolEntry): string {
|
||||
return getMergedDict().tools[tool.id]?.title ?? tool.title;
|
||||
}
|
||||
|
||||
export function toolDescription(tool: ToolEntry): string {
|
||||
return getMergedDict().tools[tool.id]?.description ?? tool.description;
|
||||
}
|
||||
|
||||
export function paramLabel(tool: ToolEntry, param: ParamDef): string {
|
||||
const viaDict = getMergedDict().tools[tool.id]?.params?.[param.id];
|
||||
if (viaDict) return viaDict;
|
||||
const viaDef = param as { label?: string };
|
||||
return viaDef.label ?? param.id;
|
||||
}
|
||||
|
||||
export function optionLabel(tool: ToolEntry, param: ParamDef, value: string): string {
|
||||
const viaDict = getMergedDict().tools[tool.id]?.options?.[param.id]?.[value];
|
||||
if (viaDict) return viaDict;
|
||||
if (param.type === 'select') {
|
||||
return param.options.find((o) => o.value === value)?.label ?? value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TOOLS } from '../registry';
|
||||
import { en } from './en';
|
||||
import { ru } from './ru';
|
||||
|
||||
describe('секция tools словарей покрывает реестр', () => {
|
||||
it('у каждого инструмента есть перевод ru с непустыми title/description', () => {
|
||||
for (const tool of TOOLS) {
|
||||
const strings = ru.tools[tool.id];
|
||||
expect(strings, `нет перевода для ${tool.id}`).toBeDefined();
|
||||
expect(strings?.title, `${tool.id}: title`).toBeTruthy();
|
||||
expect(strings?.description, `${tool.id}: description`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('переведены подписи всех параметров', () => {
|
||||
for (const tool of TOOLS) {
|
||||
const params = ru.tools[tool.id]?.params ?? {};
|
||||
for (const param of tool.params) {
|
||||
const label = params[param.id];
|
||||
expect(label, `${tool.id}.${param.id}: нет перевода подписи`).toBeDefined();
|
||||
expect(label?.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('переведены подписи всех опций select', () => {
|
||||
for (const tool of TOOLS) {
|
||||
const options = ru.tools[tool.id]?.options ?? {};
|
||||
for (const param of tool.params) {
|
||||
if (param.type !== 'select') continue;
|
||||
const labels = options[param.id] ?? {};
|
||||
for (const option of param.options) {
|
||||
const label = labels[option.value];
|
||||
expect(label, `${tool.id}.${param.id}[${option.value}]: нет перевода`).toBeDefined();
|
||||
expect(label.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('в словарях нет лишних ключей инструментов и параметров', () => {
|
||||
const ids = new Set(TOOLS.map((tool) => tool.id));
|
||||
for (const dict of [ru.tools, en.tools]) {
|
||||
for (const id of Object.keys(dict)) {
|
||||
expect(ids.has(id), `лишний инструмент в словаре: ${id}`).toBe(true);
|
||||
const tool = TOOLS.find((t) => t.id === id)!;
|
||||
const paramIds = new Set(tool.params.map((p) => p.id));
|
||||
for (const paramId of Object.keys(dict[id].params ?? {})) {
|
||||
expect(paramIds.has(paramId), `${id}: лишний параметр ${paramId}`).toBe(true);
|
||||
}
|
||||
const selectIds = new Set(
|
||||
tool.params.filter((p) => p.type === 'select').map((p) => p.id)
|
||||
);
|
||||
for (const paramId of Object.keys(dict[id].options ?? {})) {
|
||||
expect(selectIds.has(paramId), `${id}: options у не-select параметра ${paramId}`).toBe(
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('тексты-результаты анализаторов переведены в обоих словарях', () => {
|
||||
const expectations: Array<[string, string[]]> = [
|
||||
['png-is-grayscale', ['grayscaleYes', 'grayscaleNo']],
|
||||
['png-is-transparent', ['transparentYes', 'transparentNo']],
|
||||
['png-orientation', ['orientationPortrait', 'orientationLandscape', 'orientationSquare']]
|
||||
];
|
||||
for (const [id, keys] of expectations) {
|
||||
for (const key of keys) {
|
||||
expect(en.tools[id]?.results?.[key], `${id}.${key} (en)`).toBeDefined();
|
||||
expect(ru.tools[id]?.results?.[key], `${id}.${key} (ru)`).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
+238
-242
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user