mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
feat: base english language, also fallback to english
This commit is contained in:
+2
-2
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
- Без сегментов языка в URL. Статика пререндерится на русском; переключение на клиенте, выбор живёт в localStorage. hreflang/canonical и вторая копия страниц для поисковиков — не в этой волне.
|
- Без сегментов языка в URL. Статика пререндерится на русском; переключение на клиенте, выбор живёт в localStorage. hreflang/canonical и вторая копия страниц для поисковиков — не в этой волне.
|
||||||
- Ядро не знает о локалях. Ошибки в core выбрасываются стабильными кодами (например ERR_BAD_HEX); перевод кода в человекочитаемый текст происходит в слое исполнителя/UI.
|
- Ядро не знает о локалях. Ошибки в core выбрасываются стабильными кодами (например ERR_BAD_HEX); перевод кода в человекочитаемый текст происходит в слое исполнителя/UI.
|
||||||
- Строки реестра живут в словарях, а не инлайн: словарь содержит секцию tools, ключ — id инструмента, внутри title, description, labels параметров и label'ы опций select. Реестр хранит только схему параметров и логику run.
|
- Английский — базовая локаль и фолбэк: строки инструментов в реестре записаны по-английски. Переводы живут в секции tools словарей по ключу id инструмента (title, description, подписи параметров, подписи опций select, тексты-результаты анализаторов); сейчас перевод — русский в ru.ts. Доступ из компонентов только через хелперы toolTitle/toolDescription/paramLabel/optionLabel. Так каждая строка имеет единственный источник: en — реестр, переводы — словари.
|
||||||
|
|
||||||
## 3. Структура i18n-модуля
|
## 3. Структура i18n-модуля
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
- A. Инфраструктура — модуль состояния, функция перевода с фолбэком и интерполяцией, словари-скелеты со всеми секциями (заполнены header и categories как пилот), переключатель RU/EN в шапке, реактивный lang. Тесты: фолбэк на ru при отсутствии ключа в en, интерполяция, персист локали.
|
- A. Инфраструктура — модуль состояния, функция перевода с фолбэком и интерполяцией, словари-скелеты со всеми секциями (заполнены header и categories как пилот), переключатель RU/EN в шапке, реактивный lang. Тесты: фолбэк на ru при отсутствии ключа в en, интерполяция, персист локали.
|
||||||
- B. Каркас интерфейса — перенос всех хардкод-строк компонентов и маршрутов в словари: layout (шапка, футер), главная (герой, «вернуть последний», title), каталог, карточки источника/результата/параметров, текстовый вход и результат, «Показать маску», DropZone/DropOverlay, кнопка скачивания (busyText), InfoPanel плюс форматирование чисел через тег локали вместо жёсткого ru-RU, ToolPage (легенды, «Шаг n», ошибки шага), ChainToolBlock, placeholder и пустой результат поиска, страницы инструмента (title, 404). Обёрточные сообщения исполнителя («Ошибка исполнения в воркере») — тоже здесь.
|
- B. Каркас интерфейса — перенос всех хардкод-строк компонентов и маршрутов в словари: layout (шапка, футер), главная (герой, «вернуть последний», title), каталог, карточки источника/результата/параметров, текстовый вход и результат, «Показать маску», DropZone/DropOverlay, кнопка скачивания (busyText), InfoPanel плюс форматирование чисел через тег локали вместо жёсткого ru-RU, ToolPage (легенды, «Шаг n», ошибки шага), ChainToolBlock, placeholder и пустой результат поиска, страницы инструмента (title, 404). Обёрточные сообщения исполнителя («Ошибка исполнения в воркере») — тоже здесь.
|
||||||
- C. Контент реестра — 69 инструментов: title/description/labels/options переезжают в tools-секцию обоих словарей; компоненты получают строки через хелперы toolTitle/paramLabel. Тест целостности: у каждого инструмента в каждом словаре непустые title/description, полный набор param-ключей и отсутствие лишних ключей.
|
- C. Контент реестра — переводы 69 инструментов (title/description/labels/options) в tools-секции словаря en; компоненты получают строки через хелперы toolTitle/toolDescription/paramLabel/optionLabel с фолбэком на реестр. Тест целостности: у каждого инструмента есть непустые перевод title/description, полный набор param-ключей и подписи всех опций select; лишние ключи словарей отсутствуют.
|
||||||
- D. Поиск по любому языку — нормализация запроса и полей: нижний регистр, ё→е, снятие диакритики через NFD; скоринг против активной и базовой локали сразу, лучший балл побеждает; сортировка совпадений через Intl.Collator активной локали. Тесты нормализации: ё/е, диакритика, английский запрос при русской локали и наоборот.
|
- D. Поиск по любому языку — нормализация запроса и полей: нижний регистр, ё→е, снятие диакритики через NFD; скоринг против активной и базовой локали сразу, лучший балл побеждает; сортировка совпадений через Intl.Collator активной локали. Тесты нормализации: ё/е, диакритика, английский запрос при русской локали и наоборот.
|
||||||
- E. Ошибки на кодах — около 40 мест throw в core, registry, pipeline заменяются на коды; исполнитель переводит код в текст активной локали, неизвестные сообщения показываются как есть. Пять тестов, матчащих русские подстроки ошибок (executor, pipeline, registry, io, alpha), переходят на коды — это упрощает ассерты. Русские describe/it в тестах не трогаем.
|
- E. Ошибки на кодах — около 40 мест throw в core, registry, pipeline заменяются на коды; исполнитель переводит код в текст активной локали, неизвестные сообщения показываются как есть. Пять тестов, матчащих русские подстроки ошибок (executor, pipeline, registry, io, alpha), переходят на коды — это упрощает ассерты. Русские describe/it в тестах не трогаем.
|
||||||
- F. Итог — полный прогон, смоук чеклист §6, архивация плана.
|
- F. Итог — полный прогон, смоук чеклист §6, архивация плана.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { CategoryId } from '../categories';
|
|||||||
export const LOCALES = ['ru', 'en'] as const;
|
export const LOCALES = ['ru', 'en'] as const;
|
||||||
export type Locale = (typeof LOCALES)[number];
|
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> = {
|
export const LOCALE_TAGS: Record<Locale, string> = {
|
||||||
ru: 'ru-RU',
|
ru: 'ru-RU',
|
||||||
@@ -15,12 +15,14 @@ export function isLocale(value: unknown): value is Locale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ToolStrings = {
|
export type ToolStrings = {
|
||||||
title: string;
|
title?: string;
|
||||||
description: string;
|
description?: string;
|
||||||
/** Подписи параметров по их id. */
|
/** Подписи параметров по их id. */
|
||||||
params?: Record<string, string>;
|
params?: Record<string, string>;
|
||||||
/** Подписи опций select: paramId -> value -> label. */
|
/** Подписи опций select: paramId -> value -> label. */
|
||||||
options?: Record<string, Record<string, string>>;
|
options?: Record<string, Record<string, string>>;
|
||||||
|
/** Тексты-результаты текстовых инструментов (analyze): ключ -> строка. */
|
||||||
|
results?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HeaderStrings = {
|
export type HeaderStrings = {
|
||||||
|
|||||||
+38
-18
@@ -1,4 +1,4 @@
|
|||||||
import type { Dict } from './dict';
|
import type { Dict } from './dict';
|
||||||
|
|
||||||
export const en: Dict = {
|
export const en: Dict = {
|
||||||
header: {
|
header: {
|
||||||
@@ -6,7 +6,7 @@ export const en: Dict = {
|
|||||||
catalog: 'Catalog',
|
catalog: 'Catalog',
|
||||||
sectionsAria: 'Sections',
|
sectionsAria: 'Sections',
|
||||||
footerNote:
|
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: {
|
categories: {
|
||||||
convert: 'Convert',
|
convert: 'Convert',
|
||||||
@@ -18,14 +18,14 @@ export const en: Dict = {
|
|||||||
generate: 'Generate'
|
generate: 'Generate'
|
||||||
},
|
},
|
||||||
home: {
|
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?',
|
heroTitle: 'What do you want to do with the image?',
|
||||||
heroLead: 'Find a tool — everything runs locally in your browser.',
|
heroLead: 'Find a tool — everything runs locally in your browser.',
|
||||||
restoreLast: '↩ Restore last: {title}',
|
restoreLast: '↩ Restore last: {title}',
|
||||||
changeTool: '← Change tool'
|
changeTool: 'в†ђ Change tool'
|
||||||
},
|
},
|
||||||
catalog: {
|
catalog: {
|
||||||
pageTitle: 'All tools — easy-png-tools',
|
pageTitle: 'All tools — easy-png-tools',
|
||||||
metaDescription:
|
metaDescription:
|
||||||
'Full catalog of PNG utilities: convert, transparency, color, geometry, analysis and image generation.',
|
'Full catalog of PNG utilities: convert, transparency, color, geometry, analysis and image generation.',
|
||||||
heading: 'Tool catalog',
|
heading: 'Tool catalog',
|
||||||
@@ -46,7 +46,7 @@ export const en: Dict = {
|
|||||||
inputLegend: 'Input',
|
inputLegend: 'Input',
|
||||||
resultLegend: 'Result',
|
resultLegend: 'Result',
|
||||||
paramsLegend: 'Parameters',
|
paramsLegend: 'Parameters',
|
||||||
busyTitle: 'Processing…',
|
busyTitle: 'Processing…',
|
||||||
busyHint: 'Running chain step',
|
busyHint: 'Running chain step',
|
||||||
removeStepAria: 'Remove step'
|
removeStepAria: 'Remove step'
|
||||||
},
|
},
|
||||||
@@ -56,14 +56,14 @@ export const en: Dict = {
|
|||||||
resultCard: {
|
resultCard: {
|
||||||
emptyTitle: 'The result will appear here',
|
emptyTitle: 'The result will appear here',
|
||||||
emptyHint: 'First upload a source image on the left',
|
emptyHint: 'First upload a source image on the left',
|
||||||
processingTitle: 'Processing…',
|
processingTitle: 'Processing…',
|
||||||
processingHint: 'The image is being processed, this will take a moment',
|
processingHint: 'The image is being processed, this will take a moment',
|
||||||
recalc: 'Recalculating…',
|
recalc: 'Recalculating…',
|
||||||
nextTool: '⛓ Next tool',
|
nextTool: 'в›“ Next tool',
|
||||||
breakChain: '✂ Break the chain'
|
breakChain: 'вњ‚ Break the chain'
|
||||||
},
|
},
|
||||||
paramsCard: {
|
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: {
|
textInput: {
|
||||||
heading: 'Text',
|
heading: 'Text',
|
||||||
@@ -78,13 +78,13 @@ export const en: Dict = {
|
|||||||
downloadTxt: 'Download .txt'
|
downloadTxt: 'Download .txt'
|
||||||
},
|
},
|
||||||
download: {
|
download: {
|
||||||
busy: 'Preparing file…',
|
busy: 'Preparing file…',
|
||||||
file: 'Download .{ext}'
|
file: 'Download .{ext}'
|
||||||
},
|
},
|
||||||
infoPanel: {
|
infoPanel: {
|
||||||
dimensions: 'Dimensions',
|
dimensions: 'Dimensions',
|
||||||
alpha: 'Alpha channel',
|
alpha: 'Alpha channel',
|
||||||
alphaYes: 'yes — there are semi-transparent pixels',
|
alphaYes: 'yes — there are semi-transparent pixels',
|
||||||
alphaNo: 'no',
|
alphaNo: 'no',
|
||||||
colorCount: 'Unique colors (RGBA)'
|
colorCount: 'Unique colors (RGBA)'
|
||||||
},
|
},
|
||||||
@@ -93,9 +93,9 @@ export const en: Dict = {
|
|||||||
overlayDefault: 'Release the file to replace the image'
|
overlayDefault: 'Release the file to replace the image'
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
placeholder: 'Find a tool…',
|
placeholder: 'Find a tool…',
|
||||||
aria: 'Search tools',
|
aria: 'Search tools',
|
||||||
nothingFound: 'Nothing found — try another word.'
|
nothingFound: 'Nothing found — try another word.'
|
||||||
},
|
},
|
||||||
ui: {
|
ui: {
|
||||||
showMask: 'Show mask',
|
showMask: 'Show mask',
|
||||||
@@ -110,5 +110,25 @@ export const en: Dict = {
|
|||||||
workerUnavailable: 'Worker is unavailable',
|
workerUnavailable: 'Worker is unavailable',
|
||||||
notFound: 'Tool not found'
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { getLocale, initLocale, setLocale } from './locale.svelte';
|
import { getLocale, initLocale, setLocale } from './locale.svelte';
|
||||||
import { interpolate, t } from './t';
|
import { interpolate, t } from './t';
|
||||||
import { ru } from './ru';
|
import { en } from './en';
|
||||||
|
|
||||||
type Store = Record<string, string>;
|
type Store = Record<string, string>;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ function stubStorage(): { store: Store } {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setLocale('ru');
|
setLocale('ru');
|
||||||
delete (ru.home as Record<string, string>).onlyRuKey;
|
delete (en.home as Record<string, string>).onlyEnKey;
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,10 +30,10 @@ describe('t', () => {
|
|||||||
expect(t('header.catalog')).toBe('Catalog');
|
expect(t('header.catalog')).toBe('Catalog');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('фолбэк на базовую локаль, если в активной нет ключа', () => {
|
it('фолбэк на английскую базу, если в активной локали нет ключа', () => {
|
||||||
(ru.home as Record<string, string>).onlyRuKey = 'Только русская строка';
|
(en.home as Record<string, string>).onlyEnKey = 'Only English string';
|
||||||
setLocale('en');
|
setLocale('ru');
|
||||||
expect(t('home.onlyRuKey')).toBe('Только русская строка');
|
expect(t('home.onlyEnKey')).toBe('Only English string');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('неизвестный путь возвращает сам путь', () => {
|
it('неизвестный путь возвращает сам путь', () => {
|
||||||
|
|||||||
+370
-2
@@ -1,4 +1,4 @@
|
|||||||
import type { Dict } from './dict';
|
import type { Dict } from './dict';
|
||||||
|
|
||||||
export const ru: Dict = {
|
export const ru: Dict = {
|
||||||
header: {
|
header: {
|
||||||
@@ -110,5 +110,373 @@ export const ru: Dict = {
|
|||||||
workerUnavailable: 'Воркер недоступен',
|
workerUnavailable: 'Воркер недоступен',
|
||||||
notFound: 'Инструмент не найден'
|
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