feat: add watermarks and text tools
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
import ToolSearch from './search/ToolSearch.svelte';
|
import ToolSearch from './search/ToolSearch.svelte';
|
||||||
import { createAutoRunner } from '$lib/tools/auto-run';
|
import { createAutoRunner } from '$lib/tools/auto-run';
|
||||||
import { executeStep } from '$lib/tools/executor';
|
import { executeStep } from '$lib/tools/executor';
|
||||||
|
import { clearOverlay, setOverlay } from '$lib/tools/overlay-store.svelte';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
import { toolDescription, toolTitle } from '$lib/i18n/tool-strings';
|
import { toolDescription, toolTitle } from '$lib/i18n/tool-strings';
|
||||||
import type { StageStatus } from './stage/stage-props';
|
import type { StageStatus } from './stage/stage-props';
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
let showMask = $state(false);
|
let showMask = $state(false);
|
||||||
let info = $state<ImageInfo | null>(null);
|
let info = $state<ImageInfo | null>(null);
|
||||||
let errorText = $state('');
|
let errorText = $state('');
|
||||||
|
let overlayImage = $state<PixelImage | null>(null);
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let values = $state<Record<string, any>>({ ...defaultParams(tool), ...presetBaseValues });
|
let values = $state<Record<string, any>>({ ...defaultParams(tool), ...presetBaseValues });
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
@@ -272,7 +274,16 @@
|
|||||||
info = null;
|
info = null;
|
||||||
errorText = '';
|
errorText = '';
|
||||||
status = 'idle';
|
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) {
|
function handlePaste(event: ClipboardEvent) {
|
||||||
@@ -332,6 +343,10 @@
|
|||||||
handlePickColor={handlePickColor}
|
handlePickColor={handlePickColor}
|
||||||
showError={showError}
|
showError={showError}
|
||||||
errorMessage={errorMessage}
|
errorMessage={errorMessage}
|
||||||
|
overlayImage={overlayImage}
|
||||||
|
onOverlayFile={(f) => void handleOverlayFile(f)}
|
||||||
|
onOverlayError={(e) => (errorText = errorMessage(e))}
|
||||||
|
onOverlayClear={clearOverlay}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="chain-stack">
|
<div class="chain-stack">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import DownloadButton from '../DownloadButton.svelte';
|
import DownloadButton from '../DownloadButton.svelte';
|
||||||
import Button from '../ui/Button.svelte';
|
import Button from '../ui/Button.svelte';
|
||||||
import EmptyState from '../ui/EmptyState.svelte';
|
import EmptyState from '../ui/EmptyState.svelte';
|
||||||
|
import OverlayCard from '../tool/OverlayCard.svelte';
|
||||||
import ParamsCard from '../tool/ParamsCard.svelte';
|
import ParamsCard from '../tool/ParamsCard.svelte';
|
||||||
import ResultCard from '../tool/ResultCard.svelte';
|
import ResultCard from '../tool/ResultCard.svelte';
|
||||||
import SourceCard from '../tool/SourceCard.svelte';
|
import SourceCard from '../tool/SourceCard.svelte';
|
||||||
@@ -43,6 +44,10 @@
|
|||||||
handlePipetteToggle?: (id: string) => void;
|
handlePipetteToggle?: (id: string) => void;
|
||||||
handlePickColor?: (hex: string) => void;
|
handlePickColor?: (hex: string) => void;
|
||||||
showError?: (e: unknown) => void;
|
showError?: (e: unknown) => void;
|
||||||
|
overlayImage?: PixelImage | null;
|
||||||
|
onOverlayFile?: (file: File) => void;
|
||||||
|
onOverlayError?: (e: unknown) => void;
|
||||||
|
onOverlayClear?: () => void;
|
||||||
index?: number;
|
index?: number;
|
||||||
input?: PixelImage | null;
|
input?: PixelImage | null;
|
||||||
result?: PixelImage | null;
|
result?: PixelImage | null;
|
||||||
@@ -81,6 +86,10 @@
|
|||||||
handlePipetteToggle,
|
handlePipetteToggle,
|
||||||
handlePickColor,
|
handlePickColor,
|
||||||
showError,
|
showError,
|
||||||
|
overlayImage = null,
|
||||||
|
onOverlayFile,
|
||||||
|
onOverlayError,
|
||||||
|
onOverlayClear,
|
||||||
index = 0,
|
index = 0,
|
||||||
input = null,
|
input = null,
|
||||||
result = null,
|
result = null,
|
||||||
@@ -138,6 +147,14 @@
|
|||||||
pipetteActive={!!pipetteTargetId}
|
pipetteActive={!!pipetteTargetId}
|
||||||
onPickColor={(hex) => handlePickColor?.(hex)}
|
onPickColor={(hex) => handlePickColor?.(hex)}
|
||||||
/>
|
/>
|
||||||
|
{#if tool.needsOverlaySource && source}
|
||||||
|
<OverlayCard
|
||||||
|
overlay={overlayImage}
|
||||||
|
onFile={(f) => onOverlayFile?.(f)}
|
||||||
|
onError={(e) => onOverlayError?.(e)}
|
||||||
|
onClear={() => onOverlayClear?.()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
@@ -92,6 +92,38 @@ export interface TileTextOptions extends Omit<TextBlockOptions, 'position' | 'ma
|
|||||||
angleDeg: number;
|
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 {
|
export function drawTextTile(img: PixelImage, o: TileTextOptions): PixelImage {
|
||||||
const { canvas, ctx } = ctx2d(img.width, img.height);
|
const { canvas, ctx } = ctx2d(img.width, img.height);
|
||||||
|
|||||||
@@ -105,13 +105,17 @@ export const en: Dict = {
|
|||||||
reset: 'Reset',
|
reset: 'Reset',
|
||||||
pipette: 'Eyedropper',
|
pipette: 'Eyedropper',
|
||||||
themeLight: 'Light theme',
|
themeLight: 'Light theme',
|
||||||
themeDark: 'Dark theme'
|
themeDark: 'Dark theme',
|
||||||
|
overlayTitle: 'Watermark',
|
||||||
|
overlayDrop: 'Drop a watermark PNG or click',
|
||||||
|
overlayRemove: 'Remove watermark'
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
noImageRun: 'This tool does not process images',
|
noImageRun: 'This tool does not process images',
|
||||||
workerFailed: 'Worker execution failed',
|
workerFailed: 'Worker execution failed',
|
||||||
workerUnavailable: 'Worker is unavailable',
|
workerUnavailable: 'Worker is unavailable',
|
||||||
notFound: 'Tool not found',
|
notFound: 'Tool not found',
|
||||||
|
noWatermark: 'Pick a watermark image first',
|
||||||
badTransform: 'Degenerate transformation matrix',
|
badTransform: 'Degenerate transformation matrix',
|
||||||
skewAngle: 'Skew angles cannot be 90° or -90°',
|
skewAngle: 'Skew angles cannot be 90° or -90°',
|
||||||
badHex: 'Invalid HEX color: "{value}"',
|
badHex: 'Invalid HEX color: "{value}"',
|
||||||
|
|||||||
+48
-1
@@ -105,13 +105,17 @@ export const ru: Dict = {
|
|||||||
reset: 'Сбросить',
|
reset: 'Сбросить',
|
||||||
pipette: 'Пипетка',
|
pipette: 'Пипетка',
|
||||||
themeLight: 'Светлая тема',
|
themeLight: 'Светлая тема',
|
||||||
themeDark: 'Тёмная тема'
|
themeDark: 'Тёмная тема',
|
||||||
|
overlayTitle: 'Знак',
|
||||||
|
overlayDrop: 'Перетащите PNG-знак или нажмите',
|
||||||
|
overlayRemove: 'Убрать знак'
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
noImageRun: 'Этот инструмент не обрабатывает изображения',
|
noImageRun: 'Этот инструмент не обрабатывает изображения',
|
||||||
workerFailed: 'Ошибка исполнения в воркере',
|
workerFailed: 'Ошибка исполнения в воркере',
|
||||||
workerUnavailable: 'Воркер недоступен',
|
workerUnavailable: 'Воркер недоступен',
|
||||||
notFound: 'Инструмент не найден',
|
notFound: 'Инструмент не найден',
|
||||||
|
noWatermark: 'Сначала выберите картинку-знак',
|
||||||
badTransform: 'Вырожденная матрица трансформации',
|
badTransform: 'Вырожденная матрица трансформации',
|
||||||
skewAngle: 'Углы наклона не могут быть 90° или -90°',
|
skewAngle: 'Углы наклона не могут быть 90° или -90°',
|
||||||
badHex: 'Некорректный HEX-цвет: "{value}"',
|
badHex: 'Некорректный HEX-цвет: "{value}"',
|
||||||
@@ -440,6 +444,49 @@ export const ru: Dict = {
|
|||||||
grayscaleNo: 'Нет — найдены цветные пиксели.'
|
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': {
|
'add-text-png': {
|
||||||
title: 'Надпись на PNG',
|
title: 'Надпись на PNG',
|
||||||
description:
|
description:
|
||||||
|
|||||||
+92
-1
@@ -52,7 +52,13 @@ import {
|
|||||||
} from './core/color';
|
} from './core/color';
|
||||||
import { centerByAlpha, crop, expandCanvas, flip, resize, rotate90, tile } from './core/geometry';
|
import { centerByAlpha, crop, expandCanvas, flip, resize, rotate90, tile } from './core/geometry';
|
||||||
import { decodeSvgText, decodeTextImage, jpegRoundtrip, toBase64, toDataUrl, type OutputMime } from './core/io';
|
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 { formatStamp } from './core/datefmt';
|
||||||
import type { Position9 } from './core/textdraw';
|
import type { Position9 } from './core/textdraw';
|
||||||
import { hexToPixels, pixelsToHex } from './core/text';
|
import { hexToPixels, pixelsToHex } from './core/text';
|
||||||
@@ -117,6 +123,8 @@ export type ToolEntry = {
|
|||||||
output?: OutputFormat;
|
output?: OutputFormat;
|
||||||
/** Инструменту нужен DOM (canvas): исполняется только напрямую, без воркера. */
|
/** Инструменту нужен DOM (canvas): исполняется только напрямую, без воркера. */
|
||||||
domOnly?: boolean;
|
domOnly?: boolean;
|
||||||
|
/** Инструменту нужна вторая картинка-источник (знак), загружаемая на странице. */
|
||||||
|
needsOverlaySource?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
|
export const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
|
||||||
@@ -1003,6 +1011,89 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
plateOpacityPercent: num(p, 'plateOpacity')
|
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',
|
id: 'png-is-grayscale',
|
||||||
title: 'Check: is PNG grayscale?',
|
title: 'Check: is PNG grayscale?',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
Scissors,
|
Scissors,
|
||||||
SearchCheck,
|
SearchCheck,
|
||||||
Square,
|
Square,
|
||||||
|
Stamp,
|
||||||
Sun,
|
Sun,
|
||||||
Type,
|
Type,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
@@ -98,6 +99,8 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
|
|||||||
'add-stroke-png': Square,
|
'add-stroke-png': Square,
|
||||||
'add-text-png': Type,
|
'add-text-png': Type,
|
||||||
'date-stamp-png': CalendarDays,
|
'date-stamp-png': CalendarDays,
|
||||||
|
'watermark-tile-png': Stamp,
|
||||||
|
'watermark-image-png': FileImage,
|
||||||
'find-contour-png': Scan,
|
'find-contour-png': Scan,
|
||||||
'make-thicker-png': ZoomIn,
|
'make-thicker-png': ZoomIn,
|
||||||
'make-thinner-png': ZoomOut,
|
'make-thinner-png': ZoomOut,
|
||||||
|
|||||||
Reference in New Issue
Block a user