6
${firstOpt.label || ''}▾`;
const dropdown = document.createElement('div');
dropdown.className = 'repeat-style-dropdown';
options.forEach((item, idx) => {
const opt = document.createElement('div');
opt.className = 'rs-option' + (idx === 0 ? ' active' : '');
opt.dataset.value = item.value;
opt.dataset.gap = item.gap || '';
const desc = getDesc(item.value);
opt.innerHTML = `
Protect Your Documents Instantly
Welcome to the ultimate free pdf watermark tool. Whether you need to secure business documents or brand your creative assets, this free pdf watermark tool operates entirely inside your web browser. This means you can upload files with unlimited MB sizes safely without any data leaving your computer.
Advanced PDF Watermark Tool
Preview and exported PDF use one full-page overlay system, so text and logo positions stay aligned.
Upload PDF
Upload the PDF first. This row stays horizontal on desktop for a cleaner layout.
No file selected
or drag & drop PDF here
Uploading PDF… 0%
Supported: PDF
Editable PDF View
Export uses the same preview canvas coordinate system.
Upload a PDF to see the Editable PDF View here.
${item.label}${desc}
`;
dropdown.appendChild(opt);
});
wrapper.appendChild(trigger);
wrapper.appendChild(dropdown);
// hidden select for full compatibility with existing event listeners
const hiddenSelect = document.createElement('select');
hiddenSelect.id = id;
hiddenSelect.style.display = 'none';
options.forEach(item => {
const o = document.createElement('option');
o.value = item.value;
o.textContent = item.label;
if (item.gap) o.dataset.gap = item.gap;
hiddenSelect.appendChild(o);
});
wrapper.appendChild(hiddenSelect);
trigger.addEventListener('click', (e) => {
e.stopPropagation();
// close all other open dropdowns
watermarkToolRoot.querySelectorAll('.repeat-style-dropdown.open').forEach(d => { if (d !== dropdown) d.classList.remove('open'); });
dropdown.classList.toggle('open');
});
dropdown.addEventListener('click', (e) => {
const optEl = e.target.closest('.rs-option');
if (!optEl) return;
dropdown.querySelectorAll('.rs-option').forEach(o => o.classList.remove('active'));
optEl.classList.add('active');
const val = optEl.dataset.value;
const label = optEl.querySelector('.rs-option-name').textContent;
trigger.querySelector('.rs-mini').className = `rs-mini ${val}`;
trigger.querySelector('.rs-label').textContent = label;
hiddenSelect.value = val;
hiddenSelect.dispatchEvent(new Event('change', { bubbles: true }));
dropdown.classList.remove('open');
});
document.addEventListener('click', () => dropdown.classList.remove('open'));
wrapper._hiddenSelect = hiddenSelect;
return wrapper;
}
function createToolbarCheck(id, labelText, title) {
const label = document.createElement('label');
label.className = 'tool-check';
label.title = title;
const input = document.createElement('input');
input.id = id;
input.type = 'checkbox';
const span = document.createElement('span');
span.textContent = labelText;
label.append(input, span);
return { label, input };
}
function getRepeatStyleOptions(group) {
return Array.from(watermarkToolRoot.querySelectorAll(`.repeat-preset[data-group="${group}"]`)).map(btn => ({
value: btn.dataset.pattern,
label: btn.dataset.label || btn.querySelector('strong')?.textContent || btn.textContent.trim(),
gap: btn.dataset.gap
}));
}
const toolbarTextQuickPosition = createToolbarSelect('toolbarTextQuickPosition', 'Text Quick Position', QUICK_POSITION_OPTIONS);
const toolbarLogoQuickPosition = createToolbarSelect('toolbarLogoQuickPosition', 'Logo Quick Position', QUICK_POSITION_OPTIONS);
const toolbarLogoPageRange = createToolbarSelect('toolbarLogoPageRange', 'Logo Pages', PAGE_RANGE_OPTIONS);
const toolbarTextRepeatStylePicker = createRepeatStylePicker('toolbarTextRepeatStyle', 'Text Repeat Styles', getRepeatStyleOptions('text'));
const toolbarLogoRepeatStylePicker = createRepeatStylePicker('toolbarLogoRepeatStyle', 'Logo Repeat Styles', getRepeatStyleOptions('logo'));
const toolbarTextRepeatStyle = toolbarTextRepeatStylePicker.querySelector('#toolbarTextRepeatStyle');
const toolbarLogoRepeatStyle = toolbarLogoRepeatStylePicker.querySelector('#toolbarLogoRepeatStyle');
const toolbarTextRepeatControl = createToolbarCheck('toolbarTextRepeatToggle', 'Repeat', 'Text Repeat Watermark');
const toolbarLogoRepeatControl = createToolbarCheck('toolbarLogoRepeatToggle', 'Repeat', 'Logo Repeat Watermark');
const toolbarTextRepeatToggle = toolbarTextRepeatControl.input;
const toolbarLogoRepeatToggle = toolbarLogoRepeatControl.input;
if (textToolbarMoreBtn && toolbarSize?.parentElement) {
[toolbarTextQuickPosition, toolbarTextRepeatControl.label, toolbarTextRepeatStylePicker].forEach(control => {
toolbarSize.parentElement.insertBefore(control, toolbarSize);
});
}
if (imageToolbarMoreBtn?.parentElement) {
[toolbarLogoQuickPosition, toolbarLogoRepeatControl.label, toolbarLogoRepeatStylePicker].forEach(control => {
imageToolbarMoreBtn.parentElement.insertBefore(control, imageToolbarMoreBtn);
});
}
if (toolbarImageLayerBtn?.parentElement) {
toolbarImageLayerBtn.parentElement.insertBefore(toolbarLogoPageRange, toolbarImageLayerBtn);
}
let pdfBytes = null;
let pdfDoc = null;
let pdfPageIndex = 1;
let previewReady = false;
let currentFileName = 'watermarked-pdf';
let generatedPdfBlob = null;
let generatedPdfUrl = '';
let wmImageBytes = null;
let wmImageMime = null;
let wmImageObjectUrl = null;
const textState = { xRatio: 0.5, yRatio: 0.15, rotation: 0, size: 48, opacity: 0.80, language: 'english', overPdf: true };
const textRepeatState = { xRatio: 0.5, yRatio: 0.5 };
const imageState = { xRatio: 0.5, yRatio: 0.5, rotation: 0, width: 140, overPdf: true, opacity: 0.80 };
const textRepeatSettings = { pattern: 'grid', label: 'Classic Grid' };
const logoRepeatSettings = { pattern: 'grid', label: 'Classic Grid' };
let activeDrag = null, dragOffsetX = 0, dragOffsetY = 0;
let activeResize = null, resizeStartDistance = 0, resizeStartValue = 0;
let activeRotate = null;
textPreview.contentEditable = 'false';
function getFontChoices(language = 'english') {
return FONT_CHOICES[language] || FONT_CHOICES.english;
}
function getLanguageSample(language) {
return LANGUAGE_OPTIONS[language]?.sample || LANGUAGE_OPTIONS.english.sample;
}
function isRtlLanguage(language) {
return Boolean(LANGUAGE_OPTIONS[language]?.rtl);
}
function updateLanguageUi(language) {
languageSwitch.querySelectorAll('[data-language]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.language === language);
});
if (!['hebrew', 'hindi', 'urdu', 'persian', 'russian', 'japanese', 'chinese', 'german', 'spanish'].includes(language)) {
extraLanguageSelect.value = '';
} else {
extraLanguageSelect.value = language;
}
fontPanel.classList.toggle('rtl', isRtlLanguage(language));
fontContext.textContent = `${LANGUAGE_OPTIONS[language]?.label || 'English'} font collection`;
fontSample.textContent = getLanguageSample(language);
fontSample.style.fontFamily = getPreviewFontStack(fontFamily.value);
fontSample.dir = isRtlLanguage(language) ? 'rtl' : 'ltr';
fontContext.dir = 'ltr';
}
function updateSelectPreviewStyle(selectEl, familyValue) {
const source = Object.values(FONT_CHOICES).flat();
const meta = source.find(font => font.value === familyValue);
if (meta) selectEl.style.fontFamily = meta.stack;
selectEl.style.direction = 'ltr';
selectEl.style.textAlign = 'left';
}
function getStyleLabel() {
const bits = [];
bits.push(textBold.checked ? 'Bold' : 'Regular');
if (textItalic.checked) bits.push('Italic');
if (textUnderline.checked) bits.push('Underline');
return bits.join(' / ');
}
function populateFontMenus(language = 'english', preferredValue = '') {
const fonts = getFontChoices(language);
const fallback = fonts[0]?.value || '';
const nextValue = fonts.some(font => font.value === preferredValue) ? preferredValue : fallback;
[fontFamily, toolbarFont].forEach(selectEl => {
selectEl.innerHTML = '';
fonts.forEach(font => {
const option = document.createElement('option');
option.value = font.value;
option.textContent = font.label;
option.style.fontFamily = font.stack;
selectEl.appendChild(option);
});
selectEl.value = nextValue;
updateSelectPreviewStyle(selectEl, nextValue);
});
textState.language = language;
languageSelect.value = language;
toolbarLanguage.value = language;
updateLanguageUi(language);
}
function syncLanguageAndFonts(language = languageSelect.value, preferredFont = fontFamily.value) {
populateFontMenus(language, preferredFont);
applyTextDirection(wmText.value || '');
applyTextOverlay();
}
syncLanguageAndFonts('english', 'Arial');
applyTextDirection(wmText.value || '');
syncHyperlinkUi();
updateLogoThumbUi(null, '');
syncSizeInputs();
function syncSizeInputs() {
fontSizeRange.value = fontSize.value;
fontSizeTag.textContent = fontSize.value;
toolbarSize.value = fontSize.value;
toolbarSizeRange.value = fontSize.value;
imageWidthRange.value = imageWidth.value;
imageWidthTag.textContent = imageWidth.value;
toolbarImageSize.value = imageWidth.value;
toolbarImageSizeRange.value = imageWidth.value;
}
function containsArabic(text) {
return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text || '');
}
function isLikelyRtlText(text) {
return /[\u0590-\u08FF]/.test(text || '');
}
function preparePdfText(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function applyTextDirection(text) {
const forceRtl = isRtlLanguage(languageSelect.value);
const rtl = forceRtl || isLikelyRtlText(text);
textPreview.dir = rtl ? 'rtl' : 'ltr';
textPreview.style.direction = rtl ? 'rtl' : 'ltr';
textPreview.style.unicodeBidi = rtl ? 'plaintext' : 'normal';
textPreview.style.textAlign = rtl ? 'right' : 'left';
fontSample.dir = rtl ? 'rtl' : 'ltr';
}
function normalizeHyperlink(value) {
const raw = String(value || '').trim();
if (!raw) return '';
if (/^www\./i.test(raw)) return `https://${raw}`;
if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) return raw;
return `https://${raw}`;
}
function getValidatedHyperlink(inputValue) {
const normalized = normalizeHyperlink(inputValue);
if (!normalized) return '';
try {
const parsed = new URL(normalized);
if (['http:', 'https:', 'mailto:', 'tel:'].includes(parsed.protocol)) return normalized;
} catch (err) {}
throw new Error('Invalid hyperlink. Use http(s), mailto, tel, or a valid domain.');
}
function getWatermarkHyperlink() {
if (!showTextWatermark.checked || !enableTextLink.checked) return '';
return getValidatedHyperlink(wmTextLink.value);
}
function getImageWatermarkHyperlink() {
if (!showLogoWatermark.checked || !enableImageLink.checked) return '';
return getValidatedHyperlink(wmImageLink.value);
}
function syncHyperlinkUi() {
wmTextLink.disabled = !enableTextLink.checked || !showTextWatermark.checked;
wmImageLink.disabled = !enableImageLink.checked || !showLogoWatermark.checked;
if (!enableTextLink.checked) wmTextLink.blur();
if (!enableImageLink.checked) wmImageLink.blur();
}
function getRotatedBoundingRect(x, y, width, height, rotationDeg) {
const radians = degreesToRadians(rotationDeg);
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const points = [
{ x, y },
{ x: x + width * cos, y: y + width * sin },
{ x: x - height * sin, y: y + height * cos },
{ x: x + width * cos - height * sin, y: y + width * sin + height * cos }
];
const xs = points.map(point => point.x);
const ys = points.map(point => point.y);
return { x1: Math.min(...xs), y1: Math.min(...ys), x2: Math.max(...xs), y2: Math.max(...ys) };
}
function addHyperlinkAnnotation(pdf, page, rect, url) {
const normalized = normalizeHyperlink(url);
if (!normalized) return;
let annots = page.node.lookupMaybe(PDFName.of('Annots'), PDFArray);
if (!annots) {
annots = pdf.context.obj([]);
page.node.set(PDFName.of('Annots'), annots);
}
const linkAnnotation = pdf.context.register(
pdf.context.obj({
Type: PDFName.of('Annot'),
Subtype: PDFName.of('Link'),
Rect: [rect.x1, rect.y1, rect.x2, rect.y2],
Border: [0, 0, 0],
H: PDFName.of('I'),
A: pdf.context.obj({
Type: PDFName.of('Action'),
S: PDFName.of('URI'),
URI: PDFString.of(normalized)
})
})
);
annots.push(linkAnnotation);
}
function freshPdfBytes() {
return new Uint8Array(pdfBytes);
}
function getCanvasRect() {
return pdfCanvas.getBoundingClientRect();
}
function formatFileSize(bytes) {
if (!bytes || bytes < 1) return '0 KB';
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(value >= 10 || i === 0 ? 0 : 1)} ${sizes[i]}`;
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function setProgressUi(kind, percent, statusText = '') {
if (kind !== 'pdf') return;
pdfUploadProgress.classList.add('active');
const safePercent = Math.max(0, Math.min(100, Math.round(percent || 0)));
if (statusText) pdfUploadStatus.textContent = statusText;
pdfUploadPercent.textContent = `${safePercent}%`;
pdfUploadBar.style.width = `${safePercent}%`;
}
function hideProgressUi(kind, delay = 500) {
if (kind !== 'pdf') return;
window.setTimeout(() => pdfUploadProgress.classList.remove('active'), delay);
}
function readFileWithProgress(file, kind, startText, endText) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
if (kind === 'pdf') setProgressUi(kind, 0, startText);
reader.onprogress = (event) => {
if (kind === 'pdf' && event.lengthComputable) {
setProgressUi(kind, (event.loaded / event.total) * 100, startText);
}
};
reader.onloadstart = () => {
if (kind === 'pdf') setProgressUi(kind, 0, startText);
};
reader.onload = () => {
if (kind === 'pdf') {
setProgressUi(kind, 100, endText);
hideProgressUi(kind, 700);
}
resolve(reader.result);
};
reader.onerror = () => {
if (kind === 'pdf') hideProgressUi(kind, 0);
reject(reader.error || new Error('File could not be read.'));
};
reader.readAsArrayBuffer(file);
});
}
function updateLogoThumbUi(file, objectUrl = '') {
const hasFile = !!(file && objectUrl);
logoFileName.textContent = hasFile ? file.name : 'No logo selected';
logoFileMeta.textContent = hasFile ? `${(file.type || 'image').toUpperCase().replace('IMAGE/', '')} • ${formatFileSize(file.size)}` : 'PNG or JPG only';
logoThumbPreview.style.display = hasFile ? 'block' : 'none';
if (hasFile) logoThumbPreview.src = objectUrl;
else logoThumbPreview.removeAttribute('src');
}
function resetRepeatedImagePreview() {
repeatImageLayer.innerHTML = '';
repeatImageLayer.classList.add('hidden');
repeatImageLayer.style.pointerEvents = 'none';
repeatImageLayer.style.cursor = 'default';
}
function resetRepeatedTextPreview() {
repeatTextLayer.innerHTML = '';
repeatTextLayer.classList.add('hidden');
repeatTextLayer.style.pointerEvents = 'none';
}
function renderRepeatedTextPreview() {
resetRepeatedTextPreview();
if (!previewReady || !showTextWatermark.checked || !repeatWatermark.checked || !isCurrentPageSelectedForText()) {
return;
}
const text = preparePdfText(wmText.value);
if (!text) return;
// Use getBoundingClientRect for the actual displayed (DOM) dimensions
// canvas.width may differ from displayed size due to pdf.js scaling
const rect = pdfCanvas.getBoundingClientRect();
const domW = rect.width;
const domH = rect.height;
const gap = parseFloat(repeatGap.value || 120);
const rotation = textState.rotation;
const opacity = textState.opacity;
const size = textState.size;
const color = fontColor.value;
const weight = textBold.checked ? '700' : '400';
const style = textItalic.checked ? 'italic' : 'normal';
const decoration = textUnderline.checked ? 'underline' : 'none';
const fontStack = getPreviewFontStack(fontFamily.value);
repeatTextLayer.classList.remove('hidden');
repeatTextLayer.style.zIndex = textState.overPdf ? '18' : '8';
repeatTextLayer.style.mixBlendMode = textState.overPdf ? 'normal' : 'multiply';
repeatTextLayer.style.pointerEvents = 'auto';
repeatTextLayer.style.cursor = 'move';
// Estimate text dimensions for spacing (approximate rendered width)
const approxCharWidth = size * 0.6;
const estimatedTextW = Math.max(text.length * approxCharWidth, size);
const estimatedTextH = size * 1.2;
// Scale gap to DOM coordinate space (matches how logo repeat works)
const scaleX = domW / (pdfCanvas.width || domW);
const scaleY = domH / (pdfCanvas.height || domH);
const scaledGap = gap * Math.min(scaleX, scaleY);
// Use the shared getRepeatPositions helper (same as logo repeat)
// so both text and logo use identical coordinate logic
const { offsetX, offsetY } = getRepeatTextOffset(domW, domH);
const positions = getRepeatPositions(
domW, domH,
estimatedTextW, estimatedTextH,
scaledGap,
textRepeatSettings,
offsetX, offsetY
);
const frag = document.createDocumentFragment();
const clipPad = size * 0.7;
positions.forEach(({ x, y }) => {
// Skip elements whose center is clearly outside the visible page
if (x < -clipPad || x > domW + clipPad) return;
if (y < -clipPad || y > domH + clipPad) return;
const el = document.createElement('span');
el.style.position = 'absolute';
el.style.left = x + 'px';
el.style.top = y + 'px';
el.style.transform = `translate(-50%, -50%) rotate(${rotation}deg)`;
el.style.transformOrigin = 'center center';
el.style.whiteSpace = 'nowrap';
el.style.pointerEvents = 'none';
el.style.userSelect = 'none';
el.style.fontSize = size + 'px';
el.style.fontFamily = fontStack;
el.style.fontWeight = weight;
el.style.fontStyle = style;
el.style.textDecoration = decoration;
el.style.color = color;
el.style.opacity = opacity;
el.style.lineHeight = '1.1';
el.textContent = text;
frag.appendChild(el);
});
repeatTextLayer.appendChild(frag);
}
function invalidateGeneratedPdf() {
generatedPdfBlob = null;
if (generatedPdfUrl) {
URL.revokeObjectURL(generatedPdfUrl);
generatedPdfUrl = '';
}
if (downloadBtn) downloadBtn.style.display = 'none';
if (generateBtn) {
generateBtn.disabled = false;
generateBtn.innerHTML = 'Add Watermark';
}
if (actionStatus) {
actionStatus.textContent = 'Single click to select or drag to reposition. Double-click text to edit. Repeat watermark is also draggable. Preview updates in real-time.';
}
}
function setGenerateLoading(isLoading) {
generateBtn.disabled = isLoading;
generateBtn.innerHTML = isLoading
? 'Preparing watermark...'
: 'Add Watermark';
}
function removeTextWatermark() {
showTextWatermark.checked = false;
wmText.value = '';
textPreview.textContent = '';
closeToolbars();
applyAllOverlays();
}
function removeLogoWatermark() {
showLogoWatermark.checked = false;
resetLogoUi();
closeToolbars();
applyAllOverlays();
}
function resetLogoUi() {
invalidateGeneratedPdf();
wmImageInput.value = '';
wmImageBytes = null;
wmImageMime = null;
if (wmImageObjectUrl) {
URL.revokeObjectURL(wmImageObjectUrl);
wmImageObjectUrl = null;
}
imagePreview.removeAttribute('src');
imageOverlay.classList.add('hidden');
imageToolbar.classList.remove('open');
repeatLogoWatermark.checked = false;
resetRepeatedImagePreview(); resetRepeatedTextPreview();
removeLogoBtn.style.display = 'none';
enableImageLink.checked = false;
wmImageLink.value = '';
syncHyperlinkUi();
updateLogoThumbUi(null, '');
}
function resetPdfUi() {
invalidateGeneratedPdf();
pdfInput.value = '';
pdfFileName.textContent = 'No file selected';
pdfFileMeta.textContent = 'Size: — | Pages: —';
removePdfBtn.style.display = 'none';
pdfLabel.textContent = 'Select PDF File';
pdfDropText.style.display = 'block';
pdfUploadBar.style.width = '0%';
pdfUploadPercent.textContent = '0%';
pdfUploadStatus.textContent = 'Uploading PDF...';
pdfUploadProgress.classList.remove('active');
pdfBytes = null;
pdfDoc = null;
pdfPageIndex = 1;
previewReady = false;
currentFileName = 'watermarked-pdf';
const ctx = pdfCanvas.getContext('2d');
ctx.clearRect(0, 0, pdfCanvas.width, pdfCanvas.height);
pdfCanvas.width = 0;
pdfCanvas.height = 0;
stage.style.width = 'auto';
stage.style.height = 'auto';
pageControls.style.display = 'none';
previewStatus.style.display = 'block';
previewStatus.textContent = 'Upload a PDF to see the Editable PDF View here.';
closeToolbars();
textOverlay.classList.add('hidden');
imageOverlay.classList.add('hidden');
resetRepeatedImagePreview(); resetRepeatedTextPreview();
}
function getCenterFromState(state) {
const rect = getCanvasRect();
return {
x: rect.left + state.xRatio * rect.width,
y: rect.top + state.yRatio * rect.height
};
}
function getPreviewFontStack(selectedFamily) {
const source = Object.values(FONT_CHOICES).flat();
const meta = source.find(font => font.value === selectedFamily);
return meta?.stack || 'Arial, Helvetica, sans-serif';
}
function placeCaretAtEnd(el) {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
function startInlineTextEdit() {
if (!showTextWatermark.checked || !preparePdfText(wmText.value)) return;
textVisual.classList.add('editing');
textPreview.contentEditable = 'true';
textPreview.focus();
placeCaretAtEnd(textPreview);
openTextToolbar();
}
function stopInlineTextEdit() {
const cleanText = textPreview.textContent.replace(/\s+/g, ' ').trim();
wmText.value = cleanText;
textPreview.textContent = cleanText;
applyTextDirection(cleanText);
textPreview.contentEditable = 'false';
textVisual.classList.remove('editing');
if (!cleanText) showTextWatermark.checked = false;
applyTextOverlay();
}
function syncTextToolbar() {
toolbarColor.value = fontColor.value;
toolbarOpacity.value = textOpacity.value;
toolbarLanguage.value = languageSelect.value;
toolbarFont.value = fontFamily.value;
toolbarSize.value = fontSize.value;
toolbarSizeRange.value = fontSize.value;
textLayerMode.value = textState.overPdf ? 'overPdf' : 'belowPdf';
textLayerText.textContent = textState.overPdf ? 'Over PDF' : 'Below PDF';
toolbarTextLayerBtn.classList.toggle('active', textState.overPdf);
toolbarBoldBtn.classList.toggle('active', textBold.checked);
toolbarItalicBtn.classList.toggle('active', textItalic.checked);
toolbarUnderlineBtn.classList.toggle('active', textUnderline.checked);
toolbarTextQuickPosition.value = getQuickPositionValue(textState);
toolbarTextRepeatToggle.checked = repeatWatermark.checked;
toolbarTextRepeatStyle.value = textRepeatSettings.pattern; syncRepeatPickerTrigger(toolbarTextRepeatStylePicker, textRepeatSettings.pattern, textRepeatSettings.label);
textOpacityLabel.textContent = Number(textOpacity.value).toFixed(2);
fontSizeTag.textContent = fontSize.value;
updateSelectPreviewStyle(fontFamily, fontFamily.value);
updateSelectPreviewStyle(toolbarFont, toolbarFont.value);
if (toolbarFontStylePreview) {
toolbarFontStylePreview.textContent = getStyleLabel();
toolbarFontStylePreview.style.fontFamily = getPreviewFontStack(fontFamily.value);
toolbarFontStylePreview.style.fontWeight = textBold.checked ? '700' : '400';
toolbarFontStylePreview.style.fontStyle = textItalic.checked ? 'italic' : 'normal';
toolbarFontStylePreview.style.textDecoration = textUnderline.checked ? 'underline' : 'none';
}
fontSample.style.fontFamily = getPreviewFontStack(fontFamily.value);
fontSample.style.fontWeight = textBold.checked ? '700' : '400';
fontSample.style.fontStyle = textItalic.checked ? 'italic' : 'normal';
fontSample.style.textDecoration = textUnderline.checked ? 'underline' : 'none';
}
function syncImageToolbar() {
toolbarImageOpacity.value = imageOpacity.value;
toolbarImageSize.value = imageWidth.value;
toolbarImageSizeRange.value = imageWidth.value;
imageLayerMode.value = imageState.overPdf ? 'overPdf' : 'belowPdf';
imageLayerText.textContent = imageState.overPdf ? 'Over PDF' : 'Below PDF';
toolbarImageLayerBtn.classList.toggle('active', imageState.overPdf);
toolbarLogoQuickPosition.value = getQuickPositionValue(imageState);
toolbarLogoPageRange.value = imagePageRangeMode.value || 'all';
toolbarLogoRepeatToggle.checked = repeatLogoWatermark.checked;
toolbarLogoRepeatStyle.value = logoRepeatSettings.pattern; syncRepeatPickerTrigger(toolbarLogoRepeatStylePicker, logoRepeatSettings.pattern, logoRepeatSettings.label);
imageOpacityLabel.textContent = Number(imageOpacity.value).toFixed(2);
imageWidthTag.textContent = imageWidth.value;
}
function positionTextToolbar() {
if (!previewReady) return;
const rect = getCanvasRect();
let x, y;
if (repeatWatermark.checked) {
// In repeat mode, anchor toolbar to top-center of the canvas
x = rect.width / 2;
y = 40;
} else {
if (textOverlay.classList.contains('hidden')) return;
x = textState.xRatio * rect.width;
y = textState.yRatio * rect.height;
}
const gap = 40;
const toolbarHeight = Math.max(40, textToolbarShell.offsetHeight || 40);
const shellWidth = Math.max(260, textToolbarShell.offsetWidth || 260);
const shouldPlaceAbove = rect.height - y < (toolbarHeight + gap + 10) && y > (toolbarHeight + gap + 10);
textToolbarShell.style.left = `${Math.min(Math.max(x - shellWidth / 2, 8), Math.max(rect.width - shellWidth - 8, 8))}px`;
textToolbarShell.style.top = shouldPlaceAbove
? `${Math.max(y - toolbarHeight - gap, 8)}px`
: `${Math.min(y + gap, Math.max(rect.height - toolbarHeight - 8, 8))}px`;
}
function positionImageToolbar() {
if (!previewReady) return;
const rect = getCanvasRect();
const x = imageState.xRatio * rect.width;
const y = imageState.yRatio * rect.height;
const gap = 46;
const toolbarHeight = Math.max(40, imageToolbarShell.offsetHeight || 40);
const shouldPlaceAbove = rect.height - y < (toolbarHeight + gap + 10) && y > (toolbarHeight + gap + 10);
const shellWidth = Math.max(220, imageToolbarShell.offsetWidth || 220);
imageToolbarShell.style.left = `${Math.min(Math.max(x - shellWidth / 2, 8), Math.max(rect.width - shellWidth - 8, 8))}px`;
imageToolbarShell.style.top = shouldPlaceAbove ? `${Math.max(y - toolbarHeight - gap, 8)}px` : `${Math.min(y + gap, Math.max(rect.height - toolbarHeight - 8, 8))}px`;
}
function openTextToolbar() {
const text = preparePdfText(wmText.value);
if (!previewReady || !showTextWatermark.checked || !isCurrentPageSelectedForText() || !text) return;
textToolbarShell.classList.add('open');
positionTextToolbar();
textToolbar.classList.add('open');
}
function openImageToolbar() {
if (!previewReady || !showLogoWatermark.checked || !wmImageBytes || !isCurrentPageSelectedForImage()) return;
imageToolbarShell.classList.add('open');
positionImageToolbar();
imageToolbar.classList.add('open');
}
function closeToolbars() {
textToolbarShell.classList.remove('open');
imageToolbarShell.classList.remove('open');
textToolbar.classList.remove('open');
imageToolbar.classList.remove('open');
}
function toggleToolbarExpanded(target, forceExpanded = null) {
const shell = target === 'text' ? textToolbarShell : imageToolbarShell;
const moreBtn = target === 'text' ? textToolbarMoreBtn : imageToolbarMoreBtn;
const shouldExpand = forceExpanded === null ? !shell.classList.contains('expanded') : forceExpanded;
shell.classList.toggle('expanded', shouldExpand);
const expanded = shell.classList.contains('expanded');
if (moreBtn) moreBtn.classList.toggle('active', expanded);
if (target === 'text' && textToolbar.classList.contains('open')) positionTextToolbar();
if (target === 'image' && imageToolbar.classList.contains('open')) positionImageToolbar();
}
function updatePageRangeUi(modeEl, rangeEl, helperEl, target = 'text') {
const mode = modeEl.value;
const isCustom = mode === 'custom';
rangeEl.disabled = !isCustom;
rangeEl.style.display = isCustom ? 'block' : 'none';
const helperMap = target === 'text'
? {
all: 'Apply watermark to all pages.',
first: 'Apply watermark to page 1 only.',
first2: 'Apply watermark to pages 1 and 2 only.',
first3: 'Apply watermark to the first 3 pages.',
firstLast: 'Apply watermark to the first and last page.',
custom: 'Examples: 1-3, 2,4,7'
}
: {
all: 'Apply logo watermark to all pages.',
first: 'Apply logo watermark to page 1 only.',
first2: 'Apply logo watermark to pages 1 and 2 only.',
first3: 'Apply logo watermark to the first 3 pages.',
firstLast: 'Apply logo watermark to the first and last page.',
custom: 'Examples: 1-3, 2,4,7'
};
helperEl.textContent = helperMap[mode] || helperMap.custom;
}
function parseCustomPageRange(rawValue, totalPages) {
const raw = String(rawValue || '').trim().toLowerCase();
const out = new Set();
const parts = raw.split(',').map(s => s.trim()).filter(Boolean);
for (const part of parts) {
if (part.includes('-')) {
const [a, b] = part.split('-').map(v => parseInt(v.trim(), 10));
if (Number.isFinite(a) && Number.isFinite(b)) {
const start = Math.max(1, Math.min(a, b));
const end = Math.min(totalPages, Math.max(a, b));
for (let p = start; p <= end; p++) out.add(p);
}
} else {
const p = parseInt(part, 10);
if (Number.isFinite(p) && p >= 1 && p <= totalPages) out.add(p);
}
}
if (!out.size) throw new Error('Invalid custom page range. Use values like 1-3,5');
return out;
}
function parsePageRange(modeEl, rangeEl, totalPages) {
const mode = modeEl.value;
if (mode === 'all') return new Set(Array.from({ length: totalPages }, (_, i) => i + 1));
if (mode === 'first') return new Set([1]);
if (mode === 'first2') return new Set(Array.from({ length: Math.min(2, totalPages) }, (_, i) => i + 1));
if (mode === 'first3') return new Set(Array.from({ length: Math.min(3, totalPages) }, (_, i) => i + 1));
if (mode === 'firstLast') return new Set(totalPages > 1 ? [1, totalPages] : [1]);
return parseCustomPageRange(rangeEl.value, totalPages);
}
function getSelectedImagePages(totalPages) {
const pages = parsePageRange(imagePageRangeMode, imagePageRange, totalPages);
const mode = imagePageRangeMode.value || 'all';
if (['all', 'first', 'first2', 'first3', 'firstLast'].includes(mode)) pages.add(1);
return pages;
}
function isCurrentPageSelectedForText() {
if (!pdfDoc) return true;
try {
return parsePageRange(pageRangeMode, pageRange, pdfDoc.numPages).has(pdfPageIndex);
} catch {
return true;
}
}
function isCurrentPageSelectedForImage() {
if (!pdfDoc) return true;
try {
return parsePageRange(imagePageRangeMode, imagePageRange, pdfDoc.numPages).has(pdfPageIndex);
} catch {
return true;
}
}
// applyTextOverlay is defined later (complete version with overPdf + repeat support)
function getRepeatPositions(pageWidth, pageHeight, itemWidth, itemHeight, gap, settings, offsetX = 0, offsetY = 0) {
const positions = [];
let stepX = Math.max(itemWidth + gap, 30);
let stepY = Math.max(itemHeight + gap, 30);
if (settings.pattern === 'dense') {
stepX = Math.max(itemWidth + gap * 0.6, 24);
stepY = Math.max(itemHeight + gap * 0.6, 24);
}
if (settings.pattern === 'wide') {
stepX = Math.max(itemWidth + gap * 1.25, 40);
stepY = Math.max(itemHeight + gap * 1.25, 40);
}
if (settings.pattern === 'diamond') {
stepX = Math.max(itemWidth + gap * 1.1, 36);
stepY = Math.max(itemHeight + gap * 1.1, 36);
}
// Clamp: only draw within the page boundary (with a small safe margin)
const margin = Math.max(itemWidth * 0.7, itemHeight * 0.7, 20);
const startX = -stepX + offsetX;
const endX = pageWidth + stepX + offsetX;
const startY = -stepY + offsetY;
const endY = pageHeight + stepY + offsetY;
let y = startY;
let rowIndex = 0;
while (y <= endY) {
let xStart = startX;
if (settings.pattern === 'checker') xStart += rowIndex % 2 ? stepX / 2 : 0;
if (settings.pattern === 'brick') xStart += rowIndex % 2 ? stepX / 2 : 0;
if (settings.pattern === 'diagonal') xStart += rowIndex * Math.max(gap * 0.34, itemWidth * 0.18, 12);
if (settings.pattern === 'stair') xStart += rowIndex * Math.max(stepX * 0.2, 12);
if (settings.pattern === 'cross') xStart += rowIndex % 2 ? stepX / 2 : 0;
if (settings.pattern === 'diamond') xStart += rowIndex % 2 ? stepX / 2 : 0;
for (let x = xStart; x <= endX; x += stepX) {
let yOffset = 0;
if (settings.pattern === 'ripple') {
yOffset = Math.sin((x / Math.max(stepX, 1)) * 0.7) * Math.max(10, gap * 0.18);
}
const px = x - offsetX;
const py = y + yOffset - offsetY;
// Hard-clip: skip positions whose center is outside the page with margin
if (px < -margin || px > pageWidth + margin) continue;
if (py < -margin || py > pageHeight + margin) continue;
positions.push({ x, y: y + yOffset });
}
rowIndex++;
y += stepY;
}
return positions;
}
function getRepeatImageOffset(pageWidth, pageHeight) {
return {
offsetX: (imageState.xRatio - 0.5) * pageWidth,
offsetY: (imageState.yRatio - 0.5) * pageHeight
};
}
function getRepeatTextOffset(pageWidth, pageHeight) {
return {
offsetX: (textRepeatState.xRatio - 0.5) * pageWidth,
offsetY: (textRepeatState.yRatio - 0.5) * pageHeight
};
}
function renderRepeatedImagePreview() {
resetRepeatedImagePreview();
if (!previewReady || !showLogoWatermark.checked || !wmImageBytes || !wmImageObjectUrl || !repeatLogoWatermark.checked || !isCurrentPageSelectedForImage()) {
return;
}
const width = parseFloat(imageWidth.value || 140);
const naturalWidth = imagePreview.naturalWidth || 1;
const naturalHeight = imagePreview.naturalHeight || 1;
const ratio = naturalHeight / naturalWidth;
const height = width * ratio;
const gap = parseFloat(repeatGap.value || 120);
const { offsetX, offsetY } = getRepeatImageOffset(pdfCanvas.width, pdfCanvas.height);
const positions = getRepeatPositions(pdfCanvas.width, pdfCanvas.height, width, height, gap, logoRepeatSettings, offsetX, offsetY);
repeatImageLayer.classList.remove('hidden');
repeatImageLayer.style.zIndex = imageState.overPdf ? '19' : '2';
repeatImageLayer.style.mixBlendMode = imageState.overPdf ? 'normal' : 'multiply';
repeatImageLayer.style.pointerEvents = 'auto';
repeatImageLayer.style.cursor = 'move';
positions.forEach((pos) => {
const item = document.createElement('img');
item.src = wmImageObjectUrl;
item.className = 'repeat-image-item';
item.style.width = `${width}px`;
item.style.left = `${pos.x}px`;
item.style.top = `${pos.y}px`;
item.style.opacity = String(parseFloat(imageOpacity.value || 0.80));
item.style.transform = `translate(-50%, -50%) rotate(${parseFloat(imageRotation.value || 0)}deg)`;
repeatImageLayer.appendChild(item);
});
}
function applyImageOverlay() {
imageState.rotation = parseFloat(imageRotation.value || 0);
imageState.width = parseFloat(imageWidth.value || 140);
imageState.opacity = parseFloat(imageOpacity.value || 0.80);
imageOverlay.style.left = `${imageState.xRatio * pdfCanvas.width}px`;
imageOverlay.style.top = `${imageState.yRatio * pdfCanvas.height}px`;
imageOverlay.style.transform = `translate(-50%, -50%) rotate(${imageState.rotation}deg)`;
imagePreview.style.width = `${imageState.width}px`;
imagePreview.style.height = 'auto';
imagePreview.style.opacity = String(imageState.opacity);
if (imageState.overPdf) {
imageOverlay.style.zIndex = '20';
imageOverlay.style.mixBlendMode = 'normal';
} else {
imageOverlay.style.zIndex = '12';
imageOverlay.style.mixBlendMode = 'multiply';
}
const liveImageLink = showLogoWatermark.checked && enableImageLink.checked ? normalizeHyperlink(wmImageLink.value) : '';
imageVisual.classList.toggle('has-link', !!liveImageLink);
imageVisual.style.cursor = liveImageLink ? 'pointer' : 'move';
imageVisual.title = liveImageLink || '';
if (wmImageBytes && previewReady && showLogoWatermark.checked && !repeatLogoWatermark.checked && isCurrentPageSelectedForImage()) {
imageOverlay.classList.remove('hidden');
} else {
imageOverlay.classList.add('hidden');
}
if (!(wmImageBytes && previewReady && showLogoWatermark.checked && isCurrentPageSelectedForImage())) {
imageToolbar.classList.remove('open');
}
syncImageToolbar();
renderRepeatedImagePreview();
positionImageToolbar();
}
function applyAllOverlays() {
invalidateGeneratedPdf();
syncHyperlinkUi();
syncSizeInputs();
applyTextOverlay();
applyImageOverlay();
}
function quickPosition(state, key) {
const map = {
'top-left': [0.16, 0.14],
'top-middle': [0.50, 0.14],
'center': [0.50, 0.50],
'bottom-right': [0.84, 0.86]
};
[state.xRatio, state.yRatio] = map[key] || [0.5, 0.5];
}
function syncRepeatPickerTrigger(pickerEl, pattern, label) {
if (!pickerEl) return;
const trigger = pickerEl.querySelector(".repeat-style-trigger");
if (trigger) {
trigger.querySelector(".rs-mini").className = "rs-mini " + pattern;
trigger.querySelector(".rs-label").textContent = label;
}
pickerEl.querySelectorAll(".rs-option").forEach(o => o.classList.toggle("active", o.dataset.value === pattern));
}
function setActiveRepeatPreset(group, pattern, label) {
const settings = group === 'logo' ? logoRepeatSettings : textRepeatSettings;
settings.pattern = pattern;
settings.label = label;
watermarkToolRoot.querySelectorAll(`.repeat-preset[data-group="${group}"]`).forEach(btn => {
btn.classList.toggle('active', btn.dataset.pattern === pattern);
});
if (group === 'text' && toolbarTextRepeatStyle) { toolbarTextRepeatStyle.value = pattern; syncRepeatPickerTrigger(toolbarTextRepeatStylePicker, pattern, label); }
if (group === 'logo' && toolbarLogoRepeatStyle) { toolbarLogoRepeatStyle.value = pattern; syncRepeatPickerTrigger(toolbarLogoRepeatStylePicker, pattern, label); }
}
function getQuickPositionValue(state) {
const positions = {
'top-left': [0.16, 0.14],
'top-middle': [0.50, 0.14],
center: [0.50, 0.50],
'bottom-right': [0.84, 0.86]
};
const match = Object.entries(positions).find(([, coords]) => (
Math.abs(state.xRatio - coords[0]) < 0.025 &&
Math.abs(state.yRatio - coords[1]) < 0.025
));
return match ? match[0] : 'custom';
}
function applyToolbarQuickPosition(target, value) {
if (!value || value === 'custom') return;
if (target === 'text') {
quickPosition(textState, value);
applyAllOverlays();
openTextToolbar();
} else {
quickPosition(imageState, value);
applyAllOverlays();
openImageToolbar();
}
}
function applyToolbarRepeatStyle(group, pattern) {
const preset = watermarkToolRoot.querySelector(`.repeat-preset[data-group="${group}"][data-pattern="${pattern}"]`);
if (!preset) return;
if (group === 'text') repeatWatermark.checked = true;
else repeatLogoWatermark.checked = true;
repeatGap.value = preset.dataset.gap;
setActiveRepeatPreset(group, preset.dataset.pattern, preset.dataset.label);
applyAllOverlays();
if (group === 'text') openTextToolbar();
else openImageToolbar();
}
async function renderPdfPage() {
if (!pdfDoc) return;
try {
previewStatus.style.display = 'block';
previewStatus.textContent = 'Rendering PDF preview...';
const page = await pdfDoc.getPage(pdfPageIndex);
const viewport = page.getViewport({ scale: 1.4 });
const ctx = pdfCanvas.getContext('2d');
pdfCanvas.width = viewport.width;
pdfCanvas.height = viewport.height;
stage.style.width = `${viewport.width}px`;
stage.style.height = `${viewport.height}px`;
await page.render({ canvasContext: ctx, viewport }).promise;
pageNum.textContent = pdfPageIndex;
pageCount.textContent = pdfDoc.numPages;
pageJumpInput.value = pdfPageIndex;
pageControls.style.display = 'flex';
previewReady = true;
previewStatus.style.display = 'none';
applyAllOverlays();
} catch (err) {
previewReady = false;
previewStatus.style.display = 'block';
previewStatus.textContent = 'PDF preview could not be loaded. Try another PDF.';
console.error(err);
}
}
async function loadPdf(file) {
try {
previewStatus.style.display = 'block';
previewStatus.textContent = 'Loading PDF...';
currentFileName = file.name.replace(/\.pdf$/i, '') || 'watermarked-pdf';
const rawBuffer = await readFileWithProgress(file, 'pdf', 'Uploading PDF...', 'PDF upload complete');
pdfBytes = new Uint8Array(rawBuffer);
await PDFDocument.load(freshPdfBytes(), { ignoreEncryption: true });
pdfDoc = await pdfjsLib.getDocument({ data: freshPdfBytes() }).promise;
pdfPageIndex = 1;
previewReady = false;
pdfFileName.innerHTML = `PDF${escapeHtml(file.name)}`;
pdfFileMeta.textContent = `Size: ${formatFileSize(file.size)} | Pages: ${pdfDoc.numPages}`;
pdfLabel.textContent = 'Replace PDF File';
removePdfBtn.style.display = 'inline-block';
pdfDropText.style.display = 'none';
await renderPdfPage();
} catch (err) {
pdfBytes = null;
pdfDoc = null;
previewReady = false;
pageControls.style.display = 'none';
previewStatus.style.display = 'block';
previewStatus.textContent = 'This PDF is not compatible for editing. Try another PDF.';
pdfFileName.textContent = 'No file selected';
pdfFileMeta.textContent = 'Size: — | Pages: —';
removePdfBtn.style.display = 'none';
console.error(err);
alert('This PDF is restricted, damaged, or not compatible for editing.');
}
}
async function loadWatermarkImage(file) {
try {
if (!file.type.startsWith('image/')) return alert('Please upload a valid image file.');
if (file.type === 'image/webp') return alert('WEBP is not supported. Please use PNG or JPG.');
const rawBuffer = await readFileWithProgress(file, 'image', 'Uploading logo...', 'Logo upload complete');
wmImageBytes = new Uint8Array(rawBuffer);
wmImageMime = file.type || 'image/png';
if (wmImageObjectUrl) URL.revokeObjectURL(wmImageObjectUrl);
wmImageObjectUrl = URL.createObjectURL(file);
imagePreview.src = wmImageObjectUrl;
updateLogoThumbUi(file, wmImageObjectUrl);
removeLogoBtn.style.display = 'inline-block';
showLogoWatermark.checked = true;
applyImageOverlay();
} catch (err) {
resetLogoUi();
console.error(err);
alert('Could not load watermark image.');
}
}
function getPdfFontKey() {
const family = (fontFamily.value || 'Arial').toLowerCase();
const bold = textBold.checked;
const italic = textItalic.checked;
if (family === 'times new roman' || family === 'georgia' || family === 'lora' || family === 'playfair display' || family === 'merriweather') {
if (bold && italic) return PDFLib.StandardFonts.TimesRomanBoldItalic;
if (bold) return PDFLib.StandardFonts.TimesRomanBold;
if (italic) return PDFLib.StandardFonts.TimesRomanItalic;
return PDFLib.StandardFonts.TimesRoman;
}
if (family === 'courier new') {
if (bold && italic) return PDFLib.StandardFonts.CourierBoldOblique;
if (bold) return PDFLib.StandardFonts.CourierBold;
if (italic) return PDFLib.StandardFonts.CourierOblique;
return PDFLib.StandardFonts.Courier;
}
if (bold && italic) return PDFLib.StandardFonts.HelveticaBoldOblique;
if (bold) return PDFLib.StandardFonts.HelveticaBold;
if (italic) return PDFLib.StandardFonts.HelveticaOblique;
return PDFLib.StandardFonts.Helvetica;
}
function hexToRgb(hex) {
const clean = (hex || '#000000').replace('#', '').trim();
const full = clean.length === 3 ? clean.split('').map(ch => ch + ch).join('') : clean.padEnd(6, '0').slice(0, 6);
const int = parseInt(full, 16);
return {
r: ((int >> 16) & 255) / 255,
g: ((int >> 8) & 255) / 255,
b: (int & 255) / 255
};
}
function degreesToRadians(deg) {
return deg * Math.PI / 180;
}
function getPreviewToPdfScale(pageWidth, pageHeight) {
return {
x: pageWidth / Math.max(1, pdfCanvas.width),
y: pageHeight / Math.max(1, pdfCanvas.height)
};
}
function getCanvasFontDeclaration(sizePx) {
const weight = textBold.checked ? '700' : '400';
const style = textItalic.checked ? 'italic' : 'normal';
return `${style} ${weight} ${sizePx}px ${getPreviewFontStack(fontFamily.value)}`;
}
async function ensurePreviewFontLoaded(sizePx, text) {
if (!document.fonts?.load) return;
const sample = String(text || 'A');
try {
await document.fonts.load(getCanvasFontDeclaration(sizePx), sample);
await document.fonts.ready;
} catch (err) {}
}
function blobToUint8Array(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(new Uint8Array(reader.result));
reader.onerror = () => reject(reader.error || new Error('Blob could not be read.'));
reader.readAsArrayBuffer(blob);
});
}
function isNonLatinScript(text, language) {
// Non-Latin scripts that cannot be encoded by PDF WinAnsi standard fonts
const nonLatinLanguages = ['arabic', 'hebrew', 'hindi', 'urdu', 'persian', 'japanese', 'chinese'];
if (nonLatinLanguages.includes(language)) return true;
// Also detect by unicode range for auto-detected scripts
if (/[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text)) return true; // Arabic/Urdu/Persian
if (/[\u0590-\u05FF]/.test(text)) return true; // Hebrew
if (/[\u0900-\u097F]/.test(text)) return true; // Devanagari/Hindi
if (/[\u3000-\u9FFF\uF900-\uFAFF]/.test(text)) return true; // CJK
if (/[\u0400-\u04FF]/.test(text)) return true; // Cyrillic/Russian
return false;
}
async function createTextWatermarkAsset(pdf) {
const text = preparePdfText(wmText.value);
const language = languageSelect.value;
const sizePt = parseFloat(fontSize.value || 48);
const useCanvas = isNonLatinScript(text, language);
if (!useCanvas) {
// Latin text: embed a standard PDF font (native searchable text)
const fontKey = getPdfFontKey();
const pdfFont = await pdf.embedFont(fontKey);
const textWidth = pdfFont.widthOfTextAtSize(text, sizePt);
const textHeight = pdfFont.heightAtSize(sizePt);
return { type: 'native', font: pdfFont, text, sizePt, textWidth, textHeight };
}
// Non-Latin: render to canvas then embed as PNG (only way to support these scripts in pdf-lib)
const sizePx = sizePt; // 1pt ≈ 1px at screen resolution; scale handles the rest
const rtl = isRtlLanguage(language) || isLikelyRtlText(text);
const qualityScale = 3;
const padding = Math.max(10, sizePx * 0.28);
await ensurePreviewFontLoaded(sizePx, text);
const measureCanvas = document.createElement('canvas');
const measureCtx = measureCanvas.getContext('2d');
measureCtx.font = getCanvasFontDeclaration(sizePx);
measureCtx.direction = rtl ? 'rtl' : 'ltr';
const metrics = measureCtx.measureText(text);
const textWidth = Math.max(1, Math.ceil(metrics.width || sizePx));
const ascent = Math.ceil(metrics.actualBoundingBoxAscent || sizePx * 0.82);
const descent = Math.ceil(metrics.actualBoundingBoxDescent || sizePx * 0.28);
const underlineExtra = textUnderline.checked ? Math.ceil(sizePx * 0.18) : 0;
const logicalWidth = textWidth + Math.ceil(padding * 2);
const logicalHeight = ascent + descent + Math.ceil(padding * 2) + underlineExtra;
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.ceil(logicalWidth * qualityScale));
canvas.height = Math.max(1, Math.ceil(logicalHeight * qualityScale));
const ctx = canvas.getContext('2d');
ctx.scale(qualityScale, qualityScale);
ctx.clearRect(0, 0, logicalWidth, logicalHeight);
ctx.font = getCanvasFontDeclaration(sizePx);
ctx.fillStyle = fontColor.value;
ctx.textBaseline = 'alphabetic';
ctx.textAlign = rtl ? 'right' : 'left';
ctx.direction = rtl ? 'rtl' : 'ltr';
const textX = rtl ? logicalWidth - padding : padding;
const textY = padding + ascent;
ctx.fillText(text, textX, textY);
if (textUnderline.checked) {
const underlineOffset = Math.max(2, sizePx * 0.1);
const underlineThickness = Math.max(1, sizePx * 0.05);
const startX = rtl ? logicalWidth - padding - textWidth : padding;
const endX = startX + textWidth;
const lineY = textY + underlineOffset;
ctx.beginPath();
ctx.moveTo(startX, lineY);
ctx.lineTo(endX, lineY);
ctx.lineWidth = underlineThickness;
ctx.strokeStyle = fontColor.value;
ctx.stroke();
}
const blob = await new Promise((resolve, reject) => {
canvas.toBlob((result) => {
if (result) resolve(result);
else reject(new Error('Text watermark image could not be created.'));
}, 'image/png');
});
const pngBytes = await blobToUint8Array(blob);
const embeddedImage = await pdf.embedPng(pngBytes);
return { type: 'image', image: embeddedImage, width: logicalWidth, height: logicalHeight, sizePt };
}
function getImageDrawSizeForPdf(img, pageWidth, pageHeight) {
const scale = getPreviewToPdfScale(pageWidth, pageHeight);
const drawWidth = parseFloat(imageWidth.value || 140) * scale.x;
const ratio = img.height / img.width;
return { width: drawWidth, height: drawWidth * ratio };
}
function getTextAnchorForPdf(pageWidth, pageHeight) {
return { x: textState.xRatio * pageWidth, y: (1 - textState.yRatio) * pageHeight };
}
function getImageAnchorForPdf(pageWidth, pageHeight) {
return { x: imageState.xRatio * pageWidth, y: (1 - imageState.yRatio) * pageHeight };
}
function getTextImageDrawSizeForPdf(asset, pageWidth, pageHeight) {
const scale = getPreviewToPdfScale(pageWidth, pageHeight);
return {
width: asset.width * scale.x,
height: asset.height * scale.y
};
}
function drawSingleTextWatermarkNative(pdf, page, asset, pageWidth, pageHeight, anchorX, anchorY, hyperlink = '') {
const scale = getPreviewToPdfScale(pageWidth, pageHeight);
const rotationDeg = parseFloat(textRotation.value || 0);
const opacity = parseFloat(textOpacity.value || 0.80);
if (asset.type === 'native') {
const sizePt = asset.sizePt * Math.min(scale.x, scale.y);
const textWidth = asset.font.widthOfTextAtSize(asset.text, sizePt);
const textHeight = asset.font.heightAtSize(sizePt);
const color = hexToRgb(fontColor.value);
const x = anchorX - textWidth / 2;
const y = anchorY - textHeight / 2;
page.drawText(asset.text, {
x, y,
size: sizePt,
font: asset.font,
color: PDFLib.rgb(color.r, color.g, color.b),
opacity,
rotate: PDFLib.degrees(rotationDeg)
});
if (hyperlink) addHyperlinkAnnotation(pdf, page, getRotatedBoundingRect(x, y, textWidth, textHeight, rotationDeg), hyperlink);
return { width: textWidth, height: textHeight };
} else {
// Canvas-image fallback for non-Latin scripts
const { width, height } = getTextImageDrawSizeForPdf(asset, pageWidth, pageHeight);
const x = anchorX - width / 2;
const y = anchorY - height / 2;
page.drawImage(asset.image, {
x, y, width, height,
opacity,
rotate: PDFLib.degrees(rotationDeg)
});
if (hyperlink) addHyperlinkAnnotation(pdf, page, getRotatedBoundingRect(x, y, width, height, rotationDeg), hyperlink);
return { width, height };
}
}
function drawRepeatedTextWatermarkNative(pdf, page, asset, pageWidth, pageHeight, hyperlink = '') {
const scale = getPreviewToPdfScale(pageWidth, pageHeight);
let width, height;
if (asset.type === 'native') {
const sizePt = asset.sizePt * Math.min(scale.x, scale.y);
width = asset.font.widthOfTextAtSize(asset.text, sizePt);
height = asset.font.heightAtSize(sizePt);
} else {
const dims = getTextImageDrawSizeForPdf(asset, pageWidth, pageHeight);
width = dims.width;
height = dims.height;
}
const gap = parseFloat(repeatGap.value || 120) * scale.x;
const { offsetX, offsetY } = getRepeatTextOffset(pageWidth, pageHeight);
const positions = getRepeatPositions(pageWidth, pageHeight, width, height, gap, textRepeatSettings, offsetX, -offsetY);
const padding = Math.max(width * 0.7, height * 0.7, 20);
for (const pos of positions) {
if (pos.x < -padding || pos.x > pageWidth + padding) continue;
if (pos.y < -padding || pos.y > pageHeight + padding) continue;
drawSingleTextWatermarkNative(pdf, page, asset, pageWidth, pageHeight, pos.x, pos.y, hyperlink);
}
}
async function embedWatermarkImage(pdf) {
if (!wmImageBytes) return null;
if (wmImageMime === 'image/png') return await pdf.embedPng(wmImageBytes);
if (wmImageMime === 'image/jpeg' || wmImageMime === 'image/jpg') return await pdf.embedJpg(wmImageBytes);
throw new Error('Unsupported image format. Use PNG or JPG.');
}
function drawSingleImageWatermark(pdf, page, img, pageWidth, pageHeight, anchorX, anchorY, hyperlink = '') {
const { width, height } = getImageDrawSizeForPdf(img, pageWidth, pageHeight);
const rotationDeg = parseFloat(imageRotation.value || 0);
const x = anchorX - width / 2;
const y = anchorY - height / 2;
page.drawImage(img, {
x, y, width, height,
opacity: parseFloat(imageOpacity.value || 0.80),
rotate: PDFLib.degrees(rotationDeg)
});
if (hyperlink) addHyperlinkAnnotation(pdf, page, getRotatedBoundingRect(x, y, width, height, rotationDeg), hyperlink);
return { width, height };
}
function drawRepeatedImageWatermarks(pdf, page, img, pageWidth, pageHeight, hyperlink = '') {
const { width, height } = getImageDrawSizeForPdf(img, pageWidth, pageHeight);
const scale = getPreviewToPdfScale(pageWidth, pageHeight);
const gap = parseFloat(repeatGap.value || 120) * scale.x;
const { offsetX, offsetY } = getRepeatImageOffset(pageWidth, pageHeight);
// getRepeatPositions uses top-down Y; flip offsetY for PDF bottom-up coords
const positions = getRepeatPositions(pageWidth, pageHeight, width, height, gap, logoRepeatSettings, offsetX, -offsetY);
const padding = Math.max(width * 0.7, height * 0.7, 20);
for (const pos of positions) {
if (pos.x < -padding || pos.x > pageWidth + padding) continue;
if (pos.y < -padding || pos.y > pageHeight + padding) continue;
// Flip Y: PDF origin is bottom-left, preview origin is top-left
const pdfY = pageHeight - pos.y;
drawSingleImageWatermark(pdf, page, img, pageWidth, pageHeight, pos.x, pdfY, hyperlink);
}
}
async function buildWatermarkedPdfBlob() {
if (!pdfBytes) return alert('Please upload a PDF first.');
if (!previewReady) return alert('Please wait for preview.');
const sourcePdf = await PDFDocument.load(freshPdfBytes(), { ignoreEncryption: true });
const outPdf = await PDFDocument.create();
const textPages = parsePageRange(pageRangeMode, pageRange, sourcePdf.getPageCount());
const imagePages = getSelectedImagePages(sourcePdf.getPageCount());
const preparedText = showTextWatermark.checked ? preparePdfText(wmText.value) : '';
const textHyperlink = getWatermarkHyperlink();
const imageHyperlink = getImageWatermarkHyperlink();
const textAsset = preparedText ? await createTextWatermarkAsset(outPdf) : null;
const imageEmbed = showLogoWatermark.checked && wmImageBytes ? await embedWatermarkImage(outPdf) : null;
const sourcePages = sourcePdf.getPages();
const embeddedPages = await outPdf.embedPages(sourcePages);
for (let i = 0; i < sourcePages.length; i++) {
const pageNumber = i + 1;
const sourcePage = sourcePages[i];
const embeddedPage = embeddedPages[i];
const { width, height } = sourcePage.getSize();
const page = outPdf.addPage([width, height]);
const shouldDrawImageOnPage = imagePages.has(pageNumber) && imageEmbed;
const keepFirstPageLogoVisible = pageNumber === 1;
if (shouldDrawImageOnPage && !imageState.overPdf && !keepFirstPageLogoVisible) {
if (repeatLogoWatermark.checked) {
drawRepeatedImageWatermarks(outPdf, page, imageEmbed, width, height, imageHyperlink);
} else {
const anchor = getImageAnchorForPdf(width, height);
drawSingleImageWatermark(outPdf, page, imageEmbed, width, height, anchor.x, anchor.y, imageHyperlink);
}
}
page.drawPage(embeddedPage, { x: 0, y: 0, width, height });
if (textPages.has(pageNumber) && textAsset && preparedText) {
if (repeatWatermark.checked) {
drawRepeatedTextWatermarkNative(outPdf, page, textAsset, width, height, textHyperlink);
} else {
const anchor = getTextAnchorForPdf(width, height);
drawSingleTextWatermarkNative(outPdf, page, textAsset, width, height, anchor.x, anchor.y, textHyperlink);
}
}
if (shouldDrawImageOnPage && (imageState.overPdf || keepFirstPageLogoVisible)) {
if (repeatLogoWatermark.checked) {
drawRepeatedImageWatermarks(outPdf, page, imageEmbed, width, height, imageHyperlink);
} else {
const anchor = getImageAnchorForPdf(width, height);
drawSingleImageWatermark(outPdf, page, imageEmbed, width, height, anchor.x, anchor.y, imageHyperlink);
}
}
}
const outBytes = await outPdf.save();
return new Blob([outBytes], { type: 'application/pdf' });
}
async function generateWatermarkedPdf() {
try {
setGenerateLoading(true);
actionStatus.textContent = 'Preparing watermark with current preview and PDF placement. Please wait...';
const blob = await buildWatermarkedPdfBlob();
if (!blob) return;
generatedPdfBlob = blob;
if (generatedPdfUrl) URL.revokeObjectURL(generatedPdfUrl);
generatedPdfUrl = URL.createObjectURL(blob);
downloadBtn.style.display = 'inline-block';
actionStatus.textContent = 'Watermarked PDF is ready. Review the preview above and click Download PDF.';
} catch (err) {
console.error(err);
const msg = String((err && err.message) || err || '');
if (msg.includes('Invalid hyperlink')) {
alert('Invalid hyperlink. Use a full URL like https://example.com, or mailto:someone@example.com.');
} else {
alert(`Error while creating watermarked PDF: ${msg}`);
}
actionStatus.textContent = 'Could not prepare the watermarked PDF. Please review your settings and try again.';
} finally {
setGenerateLoading(false);
}
}
function downloadGeneratedPdf() {
if (!generatedPdfBlob || !generatedPdfUrl) return alert('Click Add Watermark first.');
const a = document.createElement('a');
a.href = generatedPdfUrl;
a.download = `${currentFileName || 'watermarked-pdf'}-watermarked.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
}
function clientPointFromEvent(e) {
if (e.touches && e.touches[0]) return { x: e.touches[0].clientX, y: e.touches[0].clientY };
if (e.changedTouches && e.changedTouches[0]) return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
return { x: e.clientX, y: e.clientY };
}
function openForTarget(target) {
closeToolbars();
if (target === 'text') openTextToolbar();
if (target === 'image') openImageToolbar();
}
function clampState(state) {
state.xRatio = Math.max(0.02, Math.min(0.98, state.xRatio));
state.yRatio = Math.max(0.02, Math.min(0.98, state.yRatio));
}
function startDrag(target, e) {
if (!previewReady) return;
const point = clientPointFromEvent(e);
const isRepeat = target === 'text' ? repeatWatermark.checked : repeatLogoWatermark.checked;
const state = target === 'text' ? (isRepeat ? textRepeatState : textState) : imageState;
let centerX, centerY;
if (isRepeat) {
const rect = getCanvasRect();
centerX = rect.left + state.xRatio * rect.width;
centerY = rect.top + state.yRatio * rect.height;
} else {
const overlay = target === 'text' ? textOverlay : imageOverlay;
const overlayRect = overlay.getBoundingClientRect();
centerX = overlayRect.left + overlayRect.width / 2;
centerY = overlayRect.top + overlayRect.height / 2;
}
activeDrag = target;
dragOffsetX = point.x - centerX;
dragOffsetY = point.y - centerY;
openForTarget(target);
e.preventDefault();
}
function startResize(target, e) {
if (!previewReady) return;
if (target === 'image' && repeatLogoWatermark.checked) return;
const point = clientPointFromEvent(e);
const center = getCenterFromState(target === 'text' ? textState : imageState);
resizeStartDistance = Math.hypot(point.x - center.x, point.y - center.y) || 1;
resizeStartValue = target === 'text' ? parseFloat(fontSize.value || 48) : parseFloat(imageWidth.value || 140);
activeResize = target;
openForTarget(target);
e.preventDefault();
e.stopPropagation();
}
function startRotate(target, e) {
if (!previewReady) return;
if (target === 'image' && repeatLogoWatermark.checked) return;
activeRotate = target;
openForTarget(target);
e.preventDefault();
e.stopPropagation();
}
function handlePointerMove(e) {
if (!previewReady) return;
const point = clientPointFromEvent(e);
const rect = getCanvasRect();
if (activeDrag) {
const isRepeat = activeDrag === 'text' ? repeatWatermark.checked : repeatLogoWatermark.checked;
const state = activeDrag === 'text' ? (isRepeat ? textRepeatState : textState) : imageState;
const localX = point.x - rect.left - dragOffsetX;
const localY = point.y - rect.top - dragOffsetY;
state.xRatio = localX / rect.width;
state.yRatio = localY / rect.height;
clampState(state);
if (activeDrag === 'text') applyTextOverlay();
else applyImageOverlay();
e.preventDefault();
return;
}
if (activeResize) {
const center = getCenterFromState(activeResize === 'text' ? textState : imageState);
const distance = Math.hypot(point.x - center.x, point.y - center.y) || 1;
const ratio = distance / resizeStartDistance;
if (activeResize === 'text') {
fontSize.value = String(Math.max(8, Math.min(300, Math.round(resizeStartValue * ratio))));
applyAllOverlays();
} else {
imageWidth.value = String(Math.max(20, Math.min(500, Math.round(resizeStartValue * ratio))));
applyAllOverlays();
}
e.preventDefault();
return;
}
if (activeRotate) {
const state = activeRotate === 'text' ? textState : imageState;
const center = getCenterFromState(state);
const angle = Math.atan2(point.y - center.y, point.x - center.x) * 180 / Math.PI + 90;
if (activeRotate === 'text') {
textRotation.value = String(Math.round(angle));
applyTextOverlay();
} else {
imageRotation.value = String(Math.round(angle));
applyImageOverlay();
}
e.preventDefault();
}
}
function endPointerAction() {
activeDrag = null;
activeResize = null;
activeRotate = null;
}
async function jumpToPage() {
if (!pdfDoc) return;
const total = pdfDoc.numPages;
const requested = parseInt(pageJumpInput.value, 10);
if (!Number.isFinite(requested) || requested < 1 || requested > total) {
alert(`Enter a page number between 1 and ${total}.`);
pageJumpInput.value = pdfPageIndex;
return;
}
pdfPageIndex = requested;
await renderPdfPage();
}
pdfInput.addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (file) {
await loadPdf(file);
// Reset input value so the same file can be re-uploaded after removal
pdfInput.value = '';
}
});
wmImageInput.addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (file) {
await loadWatermarkImage(file);
wmImageInput.value = '';
}
});
['dragenter', 'dragover'].forEach(eventName => {
pdfDropArea.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
pdfDropArea.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(eventName => {
pdfDropArea.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
pdfDropArea.classList.remove('dragover');
});
});
pdfDropArea.addEventListener('drop', async (e) => {
const file = e.dataTransfer?.files?.[0];
if (!file) return;
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
alert('Please drop a valid PDF file.');
return;
}
await loadPdf(file);
});
removePdfBtn.addEventListener('click', resetPdfUi);
removeLogoBtn.addEventListener('click', removeLogoWatermark);
pageRangeMode.addEventListener('change', () => {
updatePageRangeUi(pageRangeMode, pageRange, pageRangeHelper, 'text');
applyAllOverlays();
});
imagePageRangeMode.addEventListener('change', () => {
updatePageRangeUi(imagePageRangeMode, imagePageRange, imagePageRangeHelper, 'image');
applyAllOverlays();
});
updatePageRangeUi(pageRangeMode, pageRange, pageRangeHelper, 'text');
updatePageRangeUi(imagePageRangeMode, imagePageRange, imagePageRangeHelper, 'image');
[
showTextWatermark, showLogoWatermark, wmText, enableTextLink, wmTextLink, enableImageLink, wmImageLink,
fontSize, fontFamily, fontColor, textRotation, textBold, textItalic, textUnderline,
textOpacity, imageWidth, imageRotation, imageOpacity, languageSelect,
repeatWatermark, repeatLogoWatermark, repeatGap, pageRangeMode, pageRange, imagePageRangeMode, imagePageRange
].forEach(el => {
el.addEventListener('input', applyAllOverlays);
el.addEventListener('change', applyAllOverlays);
});
languageSwitch.querySelectorAll('[data-language]').forEach(btn => {
btn.addEventListener('click', () => {
languageSelect.value = btn.dataset.language;
syncLanguageAndFonts(btn.dataset.language, fontFamily.value);
openTextToolbar();
});
});
extraLanguageSelect.addEventListener('change', () => {
if (!extraLanguageSelect.value) return;
languageSelect.value = extraLanguageSelect.value;
syncLanguageAndFonts(extraLanguageSelect.value, fontFamily.value);
openTextToolbar();
});
languageSelect.addEventListener('change', () => {
syncLanguageAndFonts(languageSelect.value, fontFamily.value);
openTextToolbar();
});
fontFamily.addEventListener('change', () => {
updateSelectPreviewStyle(fontFamily, fontFamily.value);
toolbarFont.value = fontFamily.value;
updateSelectPreviewStyle(toolbarFont, toolbarFont.value);
applyAllOverlays();
openTextToolbar();
});
fontSizeRange.addEventListener('input', () => {
fontSize.value = fontSizeRange.value;
applyAllOverlays();
});
imageWidthRange.addEventListener('input', () => {
imageWidth.value = imageWidthRange.value;
applyAllOverlays();
});
watermarkToolRoot.querySelectorAll('.quickPos').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.target === 'text') quickPosition(textState, btn.dataset.pos);
else quickPosition(imageState, btn.dataset.pos);
applyAllOverlays();
});
});
watermarkToolRoot.querySelectorAll('.repeat-preset').forEach(btn => {
btn.addEventListener('click', () => {
const group = btn.dataset.group;
if (group === 'text') repeatWatermark.checked = true;
else repeatLogoWatermark.checked = true;
repeatGap.value = btn.dataset.gap;
setActiveRepeatPreset(group, btn.dataset.pattern, btn.dataset.label);
applyAllOverlays();
});
});
watermarkToolRoot.querySelectorAll('.repeat-chip').forEach(btn => {
btn.addEventListener('click', () => {
const group = btn.dataset.group;
if (group === 'text') repeatWatermark.checked = true;
else repeatLogoWatermark.checked = true;
repeatGap.value = btn.dataset.gapOnly;
applyAllOverlays();
});
});
toolbarTextQuickPosition.addEventListener('change', () => {
applyToolbarQuickPosition('text', toolbarTextQuickPosition.value);
});
toolbarLogoQuickPosition.addEventListener('change', () => {
applyToolbarQuickPosition('logo', toolbarLogoQuickPosition.value);
});
toolbarLogoPageRange.addEventListener('change', () => {
imagePageRangeMode.value = toolbarLogoPageRange.value;
updatePageRangeUi(imagePageRangeMode, imagePageRange, imagePageRangeHelper, 'image');
applyAllOverlays();
openImageToolbar();
});
toolbarTextRepeatToggle.addEventListener('change', () => {
repeatWatermark.checked = toolbarTextRepeatToggle.checked;
applyAllOverlays();
openTextToolbar();
});
toolbarLogoRepeatToggle.addEventListener('change', () => {
repeatLogoWatermark.checked = toolbarLogoRepeatToggle.checked;
applyAllOverlays();
openImageToolbar();
});
toolbarTextRepeatStyle.addEventListener('change', () => {
applyToolbarRepeatStyle('text', toolbarTextRepeatStyle.value);
});
toolbarLogoRepeatStyle.addEventListener('change', () => {
applyToolbarRepeatStyle('logo', toolbarLogoRepeatStyle.value);
});
generateBtn.addEventListener('click', generateWatermarkedPdf);
downloadBtn.addEventListener('click', downloadGeneratedPdf);
toolbarBoldBtn.addEventListener('click', () => { textBold.checked = !textBold.checked; applyAllOverlays(); openTextToolbar(); });
toolbarItalicBtn.addEventListener('click', () => { textItalic.checked = !textItalic.checked; applyAllOverlays(); openTextToolbar(); });
toolbarUnderlineBtn.addEventListener('click', () => { textUnderline.checked = !textUnderline.checked; applyAllOverlays(); openTextToolbar(); });
toolbarColor.addEventListener('input', () => {
fontColor.value = toolbarColor.value;
applyAllOverlays();
openTextToolbar();
});
toolbarLanguage.addEventListener('change', () => {
languageSelect.value = toolbarLanguage.value;
syncLanguageAndFonts(toolbarLanguage.value, fontFamily.value);
openTextToolbar();
});
toolbarFont.addEventListener('change', () => {
fontFamily.value = toolbarFont.value;
updateSelectPreviewStyle(fontFamily, fontFamily.value);
updateSelectPreviewStyle(toolbarFont, toolbarFont.value);
applyAllOverlays();
openTextToolbar();
});
toolbarSize.addEventListener('input', () => {
fontSize.value = toolbarSize.value || '48';
applyAllOverlays();
openTextToolbar();
});
toolbarSizeRange.addEventListener('input', () => {
fontSize.value = toolbarSizeRange.value;
applyAllOverlays();
openTextToolbar();
});
toolbarOpacity.addEventListener('input', () => {
textOpacity.value = toolbarOpacity.value;
applyTextOverlay();
openTextToolbar();
});
if (toolbarCenterBtn) {
toolbarCenterBtn.addEventListener('click', () => {
textState.xRatio = 0.5;
textState.yRatio = 0.15;
applyAllOverlays();
openTextToolbar();
});
}
toolbarRemoveTextBtn.addEventListener('click', removeTextWatermark);
toolbarImageOpacity.addEventListener('input', () => {
imageOpacity.value = toolbarImageOpacity.value;
applyImageOverlay();
openImageToolbar();
});
toolbarImageSize.addEventListener('input', () => {
imageWidth.value = toolbarImageSize.value || '140';
applyImageOverlay();
openImageToolbar();
});
toolbarImageSizeRange.addEventListener('input', () => {
imageWidth.value = toolbarImageSizeRange.value;
applyImageOverlay();
openImageToolbar();
});
toolbarImageLayerBtn.addEventListener('click', () => {
imageLayerMode.value = imageLayerMode.value === 'belowPdf' ? 'overPdf' : 'belowPdf';
applyImageOverlay();
openImageToolbar();
});
toolbarRemoveImageBtn.addEventListener('click', removeLogoWatermark);
resetTextPosBtn.addEventListener('click', () => {
textState.xRatio = 0.5;
textState.yRatio = 0.15;
applyAllOverlays();
});
resetImagePosBtn.addEventListener('click', () => {
imageState.xRatio = 0.5;
imageState.yRatio = 0.5;
applyAllOverlays();
});
prevPage.addEventListener('click', async () => {
if (!pdfDoc || pdfPageIndex <= 1) return;
pdfPageIndex--;
await renderPdfPage();
});
nextPage.addEventListener('click', async () => {
if (!pdfDoc || pdfPageIndex >= pdfDoc.numPages) return;
pdfPageIndex++;
await renderPdfPage();
});
pageJumpBtn.addEventListener('click', jumpToPage);
pageJumpInput.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') {
e.preventDefault();
await jumpToPage();
}
});
wmText.addEventListener('input', () => {
let detectedLanguage = languageSelect.value;
if (/[\u0590-\u05FF]/.test(wmText.value)) detectedLanguage = 'hebrew';
else if (/[\u0900-\u097F]/.test(wmText.value)) detectedLanguage = 'hindi';
else if (containsArabic(wmText.value)) {
detectedLanguage = ['arabic', 'urdu', 'persian'].includes(languageSelect.value) ? languageSelect.value : 'arabic';
} else {
detectedLanguage = 'english';
}
if (detectedLanguage !== languageSelect.value) {
syncLanguageAndFonts(detectedLanguage, fontFamily.value);
}
if (wmText.value.trim()) showTextWatermark.checked = true;
if (!textVisual.classList.contains('editing')) applyTextOverlay();
});
wmTextLink.addEventListener('input', applyTextOverlay);
wmImageLink.addEventListener('input', applyImageOverlay);
enableTextLink.addEventListener('change', () => {
syncHyperlinkUi();
applyTextOverlay();
});
enableImageLink.addEventListener('change', () => {
syncHyperlinkUi();
applyImageOverlay();
});
textOverlay.addEventListener('pointerdown', (e) => {
if (textVisual.classList.contains('editing') || !showTextWatermark.checked) return;
if (e.target === textRotateHandle || e.target === textResizeHandle) return;
startDrag('text', e);
});
imageOverlay.addEventListener('pointerdown', (e) => {
if (!showLogoWatermark.checked || repeatLogoWatermark.checked) return;
if (e.target === imageRotateHandle || e.target === imageResizeHandle) return;
startDrag('image', e);
});
repeatImageLayer.addEventListener('pointerdown', (e) => {
if (!showLogoWatermark.checked || !repeatLogoWatermark.checked || repeatImageLayer.classList.contains('hidden')) return;
startDrag('image', e);
});
repeatTextLayer.addEventListener('pointerdown', (e) => {
if (!showTextWatermark.checked || !repeatWatermark.checked || repeatTextLayer.classList.contains('hidden')) return;
startDrag('text', e);
});
textOverlay.addEventListener('click', (e) => {
if (textToolbar.contains(e.target)) return;
openTextToolbar();
// On touch/mobile, a single tap also triggers inline text editing for convenience
if (window.matchMedia('(max-width:600px)').matches && !textVisual.classList.contains('editing')) {
startInlineTextEdit();
}
});
imageOverlay.addEventListener('click', (e) => {
if (imageToolbar.contains(e.target)) return;
openImageToolbar();
});
repeatImageLayer.addEventListener('click', (e) => {
if (imageToolbar.contains(e.target)) return;
openImageToolbar();
});
repeatTextLayer.addEventListener('click', (e) => {
if (textToolbar.contains(e.target)) return;
openTextToolbar();
});
textRotateHandle.addEventListener('pointerdown', (e) => startRotate('text', e));
imageRotateHandle.addEventListener('pointerdown', (e) => startRotate('image', e));
textResizeHandle.addEventListener('pointerdown', (e) => startResize('text', e));
imageResizeHandle.addEventListener('pointerdown', (e) => startResize('image', e));
textPreview.addEventListener('dblclick', (e) => {
e.stopPropagation();
startInlineTextEdit();
});
textPreview.addEventListener('blur', () => {
if (textVisual.classList.contains('editing')) stopInlineTextEdit();
});
textPreview.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
textPreview.blur();
}
if (e.key === 'Escape') {
e.preventDefault();
textPreview.textContent = wmText.value || '';
textPreview.blur();
}
});
function keepToolbarInteractive(toolbar, reopenFn) {
['pointerdown', 'mousedown', 'touchstart', 'click'].forEach(evtName => {
toolbar.addEventListener(evtName, (e) => {
e.stopPropagation();
reopenFn();
}, { passive: false });
});
}
keepToolbarInteractive(textToolbar, openTextToolbar);
keepToolbarInteractive(imageToolbar, openImageToolbar);
document.addEventListener('pointermove', handlePointerMove, { passive: false });
document.addEventListener('pointerup', endPointerAction);
document.addEventListener('pointercancel', endPointerAction);
stage.addEventListener('pointerdown', (e) => {
if (textToolbar.contains(e.target)) {
openTextToolbar();
return;
}
if (imageToolbar.contains(e.target)) {
openImageToolbar();
return;
}
const insideText = textOverlay.contains(e.target) || repeatTextLayer.contains(e.target);
const insideImage = imageOverlay.contains(e.target);
const insideRepeatLayer = repeatImageLayer.contains(e.target);
if (!insideText && !insideImage && !insideRepeatLayer) closeToolbars();
else if (insideText && !insideImage && !insideRepeatLayer) { closeToolbars(); openTextToolbar(); }
else if ((insideImage || insideRepeatLayer) && !insideText) { closeToolbars(); openImageToolbar(); }
});
window.addEventListener('resize', () => {
applyAllOverlays();
if (textToolbar.classList.contains('open')) positionTextToolbar();
if (imageToolbar.classList.contains('open')) positionImageToolbar();
});
function applyTextOverlay() {
textState.rotation = parseFloat(textRotation.value || 0);
textState.size = parseFloat(fontSize.value || 48);
textState.opacity = parseFloat(textOpacity.value || 0.80);
textState.language = languageSelect.value;
textState.overPdf = textLayerMode.value !== 'belowPdf';
const nextText = preparePdfText(wmText.value);
applyTextDirection(nextText);
if (document.activeElement !== textPreview) textPreview.textContent = nextText;
textOverlay.style.left = `${textState.xRatio * pdfCanvas.width}px`;
textOverlay.style.top = `${textState.yRatio * pdfCanvas.height}px`;
textOverlay.style.transform = `translate(-50%, -50%) rotate(${textState.rotation}deg)`;
textPreview.style.fontSize = `${textState.size}px`;
textPreview.style.fontFamily = getPreviewFontStack(fontFamily.value);
textPreview.style.color = fontColor.value;
textPreview.style.fontWeight = textBold.checked ? '700' : '400';
textPreview.style.fontStyle = textItalic.checked ? 'italic' : 'normal';
const liveLink = showTextWatermark.checked && enableTextLink.checked ? normalizeHyperlink(wmTextLink.value) : '';
textPreview.style.textDecoration = textUnderline.checked ? 'underline' : (liveLink ? 'underline dotted' : 'none');
textPreview.style.opacity = String(textState.opacity);
textPreview.style.cursor = liveLink ? 'pointer' : 'move';
textPreview.title = liveLink || '';
textVisual.classList.toggle('has-link', !!liveLink);
textOverlay.style.zIndex = textState.overPdf ? '18' : '8';
textOverlay.style.mixBlendMode = textState.overPdf ? 'normal' : 'multiply';
// Show single overlay only when NOT in repeat mode
if (previewReady && showTextWatermark.checked && nextText && isCurrentPageSelectedForText() && !repeatWatermark.checked) {
textOverlay.classList.remove('hidden');
} else {
textOverlay.classList.add('hidden');
// Only close toolbar if NOT in repeat mode — repeat mode keeps toolbar open
if (!repeatWatermark.checked) {
textToolbarShell.classList.remove('open');
textToolbar.classList.remove('open');
}
}
// In repeat mode, ensure toolbar stays open/visible as a standalone toolbar
if (previewReady && showTextWatermark.checked && nextText && isCurrentPageSelectedForText() && repeatWatermark.checked) {
textToolbarShell.classList.add('open');
textToolbar.classList.add('open');
}
syncTextToolbar();
renderRepeatedTextPreview();
positionTextToolbar();
}
function applyImageOverlay() {
imageState.rotation = parseFloat(imageRotation.value || 0);
imageState.width = parseFloat(imageWidth.value || 140);
imageState.opacity = parseFloat(imageOpacity.value || 0.80);
imageState.overPdf = imageLayerMode.value !== 'belowPdf';
imageOverlay.style.left = `${imageState.xRatio * pdfCanvas.width}px`;
imageOverlay.style.top = `${imageState.yRatio * pdfCanvas.height}px`;
imageOverlay.style.transform = `translate(-50%, -50%) rotate(${imageState.rotation}deg)`;
imagePreview.style.width = `${imageState.width}px`;
imagePreview.style.height = 'auto';
imagePreview.style.opacity = String(imageState.opacity);
if (imageState.overPdf) {
imageOverlay.style.zIndex = '20';
imageOverlay.style.mixBlendMode = 'normal';
} else {
imageOverlay.style.zIndex = '12';
imageOverlay.style.mixBlendMode = 'multiply';
}
const liveImageLink = showLogoWatermark.checked && enableImageLink.checked ? normalizeHyperlink(wmImageLink.value) : '';
imageVisual.classList.toggle('has-link', !!liveImageLink);
imageVisual.style.cursor = liveImageLink ? 'pointer' : 'move';
imageVisual.title = liveImageLink || '';
if (wmImageBytes && previewReady && showLogoWatermark.checked && !repeatLogoWatermark.checked && isCurrentPageSelectedForImage()) {
imageOverlay.classList.remove('hidden');
} else {
imageOverlay.classList.add('hidden');
}
if (!(wmImageBytes && previewReady && showLogoWatermark.checked && isCurrentPageSelectedForImage())) {
imageToolbarShell.classList.remove('open');
imageToolbar.classList.remove('open');
}
syncImageToolbar();
renderRepeatedImagePreview();
positionImageToolbar();
}
async function buildWatermarkedPdfBlob() {
if (!pdfBytes) return alert('Please upload a PDF first.');
if (!previewReady) return alert('Please wait for preview.');
const sourcePdf = await PDFDocument.load(freshPdfBytes(), { ignoreEncryption: true });
const outPdf = await PDFDocument.create();
const textPages = parsePageRange(pageRangeMode, pageRange, sourcePdf.getPageCount());
const imagePages = getSelectedImagePages(sourcePdf.getPageCount());
const preparedText = showTextWatermark.checked ? preparePdfText(wmText.value) : '';
const textHyperlink = getWatermarkHyperlink();
const imageHyperlink = getImageWatermarkHyperlink();
const textAsset = preparedText ? await createTextWatermarkAsset(outPdf) : null;
const imageEmbed = showLogoWatermark.checked && wmImageBytes ? await embedWatermarkImage(outPdf) : null;
const sourcePages = sourcePdf.getPages();
const embeddedPages = await outPdf.embedPages(sourcePages);
for (let i = 0; i < sourcePages.length; i++) {
const pageNumber = i + 1;
const sourcePage = sourcePages[i];
const embeddedPage = embeddedPages[i];
const { width, height } = sourcePage.getSize();
const page = outPdf.addPage([width, height]);
const shouldDrawImageOnPage = imagePages.has(pageNumber) && imageEmbed;
const keepFirstPageLogoVisible = pageNumber === 1;
if (textPages.has(pageNumber) && textAsset && preparedText && !textState.overPdf) {
if (repeatWatermark.checked) {
drawRepeatedTextWatermarkNative(outPdf, page, textAsset, width, height, textHyperlink);
} else {
const anchor = getTextAnchorForPdf(width, height);
drawSingleTextWatermarkNative(outPdf, page, textAsset, width, height, anchor.x, anchor.y, textHyperlink);
}
}
if (shouldDrawImageOnPage && !imageState.overPdf && !keepFirstPageLogoVisible) {
if (repeatLogoWatermark.checked) {
drawRepeatedImageWatermarks(outPdf, page, imageEmbed, width, height, imageHyperlink);
} else {
const anchor = getImageAnchorForPdf(width, height);
drawSingleImageWatermark(outPdf, page, imageEmbed, width, height, anchor.x, anchor.y, imageHyperlink);
}
}
page.drawPage(embeddedPage, { x: 0, y: 0, width, height });
if (textPages.has(pageNumber) && textAsset && preparedText && textState.overPdf) {
if (repeatWatermark.checked) {
drawRepeatedTextWatermarkNative(outPdf, page, textAsset, width, height, textHyperlink);
} else {
const anchor = getTextAnchorForPdf(width, height);
drawSingleTextWatermarkNative(outPdf, page, textAsset, width, height, anchor.x, anchor.y, textHyperlink);
}
}
if (shouldDrawImageOnPage && (imageState.overPdf || keepFirstPageLogoVisible)) {
if (repeatLogoWatermark.checked) {
drawRepeatedImageWatermarks(outPdf, page, imageEmbed, width, height, imageHyperlink);
} else {
const anchor = getImageAnchorForPdf(width, height);
drawSingleImageWatermark(outPdf, page, imageEmbed, width, height, anchor.x, anchor.y, imageHyperlink);
}
}
}
const outBytes = await outPdf.save();
return new Blob([outBytes], { type: 'application/pdf' });
}
textLayerMode.addEventListener('change', () => {
applyTextOverlay();
if (showTextWatermark.checked) openTextToolbar();
});
imageLayerMode.addEventListener('change', () => {
applyImageOverlay();
if (showLogoWatermark.checked) openImageToolbar();
});
toolbarTextLayerBtn.addEventListener('click', () => {
textLayerMode.value = textLayerMode.value === 'belowPdf' ? 'overPdf' : 'belowPdf';
applyTextOverlay();
openTextToolbar();
});
textToolbarMoreBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleToolbarExpanded('text', true);
openTextToolbar();
});
if (textToolbarLessBtn) {
textToolbarLessBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleToolbarExpanded('text', false);
openTextToolbar();
});
}
if (imageToolbarMoreBtn) {
imageToolbarMoreBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleToolbarExpanded('image', true);
openImageToolbar();
});
}
if (imageToolbarLessBtn) {
imageToolbarLessBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleToolbarExpanded('image', false);
openImageToolbar();
});
}
textToolbarCloseBtn.addEventListener('click', (e) => {
e.stopPropagation();
closeToolbars();
});
imageToolbarCloseBtn.addEventListener('click', (e) => {
e.stopPropagation();
closeToolbars();
});
setActiveRepeatPreset('text', 'grid', 'Classic Grid');
setActiveRepeatPreset('logo', 'grid', 'Classic Grid');
applyAllOverlays();
})();