feat: add watermarks and text tools

This commit is contained in:
2026-08-26 07:21:43 +05:00
parent b277f3c66d
commit 116d7c72aa
9 changed files with 296 additions and 4 deletions
+16 -1
View File
@@ -12,6 +12,7 @@
import ToolSearch from './search/ToolSearch.svelte';
import { createAutoRunner } from '$lib/tools/auto-run';
import { executeStep } from '$lib/tools/executor';
import { clearOverlay, setOverlay } from '$lib/tools/overlay-store.svelte';
import { t } from '$lib/i18n/t';
import { toolDescription, toolTitle } from '$lib/i18n/tool-strings';
import type { StageStatus } from './stage/stage-props';
@@ -51,6 +52,7 @@
let showMask = $state(false);
let info = $state<ImageInfo | null>(null);
let errorText = $state('');
let overlayImage = $state<PixelImage | null>(null);
// svelte-ignore state_referenced_locally
let values = $state<Record<string, any>>({ ...defaultParams(tool), ...presetBaseValues });
// svelte-ignore state_referenced_locally
@@ -272,7 +274,16 @@
info = null;
errorText = '';
status = 'idle';
values = defaultParams(tool);
clearOverlay();
values = { ...defaultParams(tool), ...presetBaseValues };
}
async function handleOverlayFile(file: File) {
try {
setOverlay(await decodeFile(file));
} catch (e) {
errorText = errorMessage(e);
}
}
function handlePaste(event: ClipboardEvent) {
@@ -332,6 +343,10 @@
handlePickColor={handlePickColor}
showError={showError}
errorMessage={errorMessage}
overlayImage={overlayImage}
onOverlayFile={(f) => void handleOverlayFile(f)}
onOverlayError={(e) => (errorText = errorMessage(e))}
onOverlayClear={clearOverlay}
/>
<div class="chain-stack">
@@ -9,6 +9,7 @@
import DownloadButton from '../DownloadButton.svelte';
import Button from '../ui/Button.svelte';
import EmptyState from '../ui/EmptyState.svelte';
import OverlayCard from '../tool/OverlayCard.svelte';
import ParamsCard from '../tool/ParamsCard.svelte';
import ResultCard from '../tool/ResultCard.svelte';
import SourceCard from '../tool/SourceCard.svelte';
@@ -43,6 +44,10 @@
handlePipetteToggle?: (id: string) => void;
handlePickColor?: (hex: string) => void;
showError?: (e: unknown) => void;
overlayImage?: PixelImage | null;
onOverlayFile?: (file: File) => void;
onOverlayError?: (e: unknown) => void;
onOverlayClear?: () => void;
index?: number;
input?: PixelImage | null;
result?: PixelImage | null;
@@ -81,6 +86,10 @@
handlePipetteToggle,
handlePickColor,
showError,
overlayImage = null,
onOverlayFile,
onOverlayError,
onOverlayClear,
index = 0,
input = null,
result = null,
@@ -138,6 +147,14 @@
pipetteActive={!!pipetteTargetId}
onPickColor={(hex) => handlePickColor?.(hex)}
/>
{#if tool.needsOverlaySource && source}
<OverlayCard
overlay={overlayImage}
onFile={(f) => onOverlayFile?.(f)}
onError={(e) => onOverlayError?.(e)}
onClear={() => onOverlayClear?.()}
/>
{/if}
{/if}
</div>
</section>
@@ -0,0 +1,63 @@
<script lang="ts">
import { t } from '$lib/i18n/t';
import type { PixelImage } from '$lib/core/types';
import DropZone from '../DropZone.svelte';
import Preview from '../Preview.svelte';
import Button from '../ui/Button.svelte';
interface Props {
overlay: PixelImage | null;
onFile: (file: File) => void;
onError: (e: unknown) => void;
onClear: () => void;
}
let { overlay, onFile, onError, onClear }: Props = $props();
</script>
<div class="overlay-card">
<span class="edge-legend overlay-legend" aria-hidden="true">{t('ui.overlayTitle')}</span>
{#if !overlay}
<DropZone {onFile} {onError} label={t('ui.overlayDrop')} />
{:else}
<div class="preview-wrap">
<Preview image={overlay} />
<Button variant="secondary" onclick={onClear}>{t('ui.overlayRemove')}</Button>
</div>
{/if}
</div>
<style>
.overlay-card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px dashed var(--border);
}
.overlay-legend {
top: -0.65em;
left: 50%;
transform: translateX(-50%);
background: var(--surface);
}
.overlay-card :global(.dropzone) {
min-height: 7rem;
font-size: var(--text-s);
}
.preview-wrap {
display: flex;
flex-direction: column;
gap: var(--space-2);
align-items: stretch;
}
.preview-wrap :global(.media) {
max-height: 12rem;
}
</style>
+32
View File
@@ -92,6 +92,38 @@ export interface TileTextOptions extends Omit<TextBlockOptions, 'position' | 'ma
angleDeg: number;
}
export interface ImageWatermarkOptions {
mark: PixelImage;
scalePercent: number;
opacityPercent: number;
position: Position9;
margin: number;
}
/** Картинка-знак поверх изображения: масштаб от ширины холста, позиция 3×3. */
export function drawImageWatermark(img: PixelImage, o: ImageWatermarkOptions): PixelImage {
const { canvas, ctx } = ctx2d(img.width, img.height);
ctx.putImageData(new ImageData(new Uint8ClampedArray(img.data), img.width, img.height), 0, 0);
const w = Math.max(1, Math.round((img.width * o.scalePercent) / 100));
const h = Math.max(1, Math.round((w * o.mark.height) / o.mark.width));
const origin = anchorOrigin(o.position, w, h, img.width, img.height, o.margin);
const markCanvas = ctx2d(o.mark.width, o.mark.height);
markCanvas.ctx.putImageData(
new ImageData(new Uint8ClampedArray(o.mark.data), o.mark.width, o.mark.height),
0,
0
);
ctx.save();
ctx.globalAlpha = o.opacityPercent / 100;
ctx.drawImage(markCanvas.canvas, origin.x, origin.y, w, h);
ctx.restore();
return toPixelImage(canvas);
}
/** Повторяющаяся диагональная плитка текста на весь холст. */
export function drawTextTile(img: PixelImage, o: TileTextOptions): PixelImage {
const { canvas, ctx } = ctx2d(img.width, img.height);
+5 -1
View File
@@ -105,13 +105,17 @@ export const en: Dict = {
reset: 'Reset',
pipette: 'Eyedropper',
themeLight: 'Light theme',
themeDark: 'Dark theme'
themeDark: 'Dark theme',
overlayTitle: 'Watermark',
overlayDrop: 'Drop a watermark PNG or click',
overlayRemove: 'Remove watermark'
},
errors: {
noImageRun: 'This tool does not process images',
workerFailed: 'Worker execution failed',
workerUnavailable: 'Worker is unavailable',
notFound: 'Tool not found',
noWatermark: 'Pick a watermark image first',
badTransform: 'Degenerate transformation matrix',
skewAngle: 'Skew angles cannot be 90° or -90°',
badHex: 'Invalid HEX color: "{value}"',
+48 -1
View File
@@ -105,13 +105,17 @@ export const ru: Dict = {
reset: 'Сбросить',
pipette: 'Пипетка',
themeLight: 'Светлая тема',
themeDark: 'Тёмная тема'
themeDark: 'Тёмная тема',
overlayTitle: 'Знак',
overlayDrop: 'Перетащите PNG-знак или нажмите',
overlayRemove: 'Убрать знак'
},
errors: {
noImageRun: 'Этот инструмент не обрабатывает изображения',
workerFailed: 'Ошибка исполнения в воркере',
workerUnavailable: 'Воркер недоступен',
notFound: 'Инструмент не найден',
noWatermark: 'Сначала выберите картинку-знак',
badTransform: 'Вырожденная матрица трансформации',
skewAngle: 'Углы наклона не могут быть 90° или -90°',
badHex: 'Некорректный HEX-цвет: "{value}"',
@@ -440,6 +444,49 @@ export const ru: Dict = {
grayscaleNo: 'Нет — найдены цветные пиксели.'
}
},
'watermark-tile-png': {
title: 'Плитка-водяной знак PNG',
description:
'Покрывает изображение повторяющейся диагональной полупрозрачной плиткой текста — защитный водяной знак.',
params: {
text: 'Текст',
fontSize: 'Размер шрифта, px',
color: 'Цвет текста',
opacity: 'Непрозрачность, %',
angle: 'Угол, °',
stepX: 'Шаг X, px',
stepY: 'Шаг Y, px',
font: 'Шрифт',
bold: 'Жирный'
},
options: {
font: { sans: 'Без засечек', serif: 'С засечками', mono: 'Моноширинный' }
}
},
'watermark-image-png': {
title: 'Знак картинкой PNG',
description:
'Накладывает другой PNG (логотип, подпись): масштаб от ширины холста, прозрачность, позиция 3×3. Знак живёт, пока открыта страница, — после восстановления цепочки выберите его заново.',
params: {
scale: 'Ширина знака, % от холста',
opacity: 'Непрозрачность, %',
position: 'Позиция',
margin: 'Отступ, px'
},
options: {
position: {
'top-left': 'Сверху слева',
'top-center': 'Сверху по центру',
'top-right': 'Сверху справа',
'middle-left': 'По центру слева',
center: 'По центру',
'middle-right': 'По центру справа',
'bottom-left': 'Снизу слева',
'bottom-center': 'Снизу по центру',
'bottom-right': 'Снизу справа'
}
}
},
'add-text-png': {
title: 'Надпись на PNG',
description:
+92 -1
View File
@@ -52,7 +52,13 @@ import {
} from './core/color';
import { centerByAlpha, crop, expandCanvas, flip, resize, rotate90, tile } from './core/geometry';
import { decodeSvgText, decodeTextImage, jpegRoundtrip, toBase64, toDataUrl, type OutputMime } from './core/io';
import { drawTextBlock, type TextFont } from './core/domText';
import {
drawImageWatermark,
drawTextBlock,
drawTextTile,
type TextFont
} from './core/domText';
import { getOverlay } from './tools/overlay-store.svelte';
import { formatStamp } from './core/datefmt';
import type { Position9 } from './core/textdraw';
import { hexToPixels, pixelsToHex } from './core/text';
@@ -117,6 +123,8 @@ export type ToolEntry = {
output?: OutputFormat;
/** Инструменту нужен DOM (canvas): исполняется только напрямую, без воркера. */
domOnly?: boolean;
/** Инструменту нужна вторая картинка-источник (знак), загружаемая на странице. */
needsOverlaySource?: boolean;
};
export const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
@@ -1003,6 +1011,89 @@ export const TOOLS: ToolEntry[] = [
plateOpacityPercent: num(p, 'plateOpacity')
})
},
{
id: 'watermark-tile-png',
title: 'Watermark Tile PNG',
description:
'Covers the image with a repeating diagonal semi-transparent text tile — a protection watermark.',
category: 'text',
domOnly: true,
params: [
{ id: 'text', label: 'Text', type: 'text', default: 'DRAFT', placeholder: 'Watermark text' },
{ id: 'fontSize', label: 'Font size, px', type: 'slider', min: 12, max: 160, step: 1, default: 56 },
{ id: 'color', label: 'Text color', type: 'color', default: '#ffffff' },
{ id: 'opacity', label: 'Opacity, %', type: 'slider', min: 5, max: 100, step: 5, default: 30 },
{ id: 'angle', label: 'Angle, °', type: 'slider', min: -90, max: 90, step: 1, default: -30 },
{ id: 'stepX', label: 'Step X, px', type: 'slider', min: 40, max: 600, step: 10, default: 220 },
{ id: 'stepY', label: 'Step Y, px', type: 'slider', min: 40, max: 600, step: 10, default: 180 },
{
id: 'font',
label: 'Font',
type: 'select',
default: 'sans',
options: [
{ value: 'sans', label: 'Sans-serif' },
{ value: 'serif', label: 'Serif' },
{ value: 'mono', label: 'Monospace' }
]
},
{ id: 'bold', label: 'Bold', type: 'checkbox', default: true }
],
run: (img, p) =>
drawTextTile(img, {
text: str(p, 'text'),
fontSize: num(p, 'fontSize'),
font: str(p, 'font') as TextFont,
bold: bool(p, 'bold'),
color: str(p, 'color'),
opacityPercent: num(p, 'opacity'),
stepX: num(p, 'stepX'),
stepY: num(p, 'stepY'),
angleDeg: num(p, 'angle')
})
},
{
id: 'watermark-image-png',
title: 'Watermark Image PNG',
description:
'Overlays another PNG (logo/signature) on top: scale from canvas width, opacity, 3×3 position. The mark lives only while the page is open — after restoring a chain, pick it again.',
category: 'text',
domOnly: true,
needsOverlaySource: true,
params: [
{ id: 'scale', label: 'Mark width, % of canvas', type: 'slider', min: 5, max: 100, step: 1, default: 30 },
{ id: 'opacity', label: 'Opacity, %', type: 'slider', min: 5, max: 100, step: 5, default: 60 },
{
id: 'position',
label: 'Position',
type: 'select',
default: 'bottom-right',
options: [
{ value: 'top-left', label: 'Top left' },
{ value: 'top-center', label: 'Top center' },
{ value: 'top-right', label: 'Top right' },
{ value: 'middle-left', label: 'Middle left' },
{ value: 'center', label: 'Center' },
{ value: 'middle-right', label: 'Middle right' },
{ value: 'bottom-left', label: 'Bottom left' },
{ value: 'bottom-center', label: 'Bottom center' },
{ value: 'bottom-right', label: 'Bottom right' }
]
},
{ id: 'margin', label: 'Margin, px', type: 'slider', min: 0, max: 200, step: 1, default: 24 }
],
run: (img, p) => {
const mark = getOverlay();
if (!mark) throw new ToolError('errors.noWatermark');
return drawImageWatermark(img, {
mark,
scalePercent: num(p, 'scale'),
opacityPercent: num(p, 'opacity'),
position: str(p, 'position') as Position9,
margin: num(p, 'margin')
});
}
},
{
id: 'png-is-grayscale',
title: 'Check: is PNG grayscale?',
+20
View File
@@ -0,0 +1,20 @@
import type { PixelImage } from '$lib/core/types';
/**
* Картинка-знак для watermark-image-png. Живёт только в состоянии страницы:
* сознательно не сохраняется в pipeline/localStorage после восстановления
* цепочки знак нужно выбрать заново.
*/
let overlay = $state<PixelImage | null>(null);
export function getOverlay(): PixelImage | null {
return overlay;
}
export function setOverlay(img: PixelImage): void {
overlay = img;
}
export function clearOverlay(): void {
overlay = null;
}
+3
View File
@@ -37,6 +37,7 @@ import {
Scissors,
SearchCheck,
Square,
Stamp,
Sun,
Type,
ZoomIn,
@@ -98,6 +99,8 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
'add-stroke-png': Square,
'add-text-png': Type,
'date-stamp-png': CalendarDays,
'watermark-tile-png': Stamp,
'watermark-image-png': FileImage,
'find-contour-png': Scan,
'make-thicker-png': ZoomIn,
'make-thinner-png': ZoomOut,