| 1 |
const siteLocale = () => window.extSharedData?.wpLanguage?.replace('_', '-'); |
| 2 |
|
| 3 |
const RTL_LANGUAGES = new Set(['ar', 'ary', 'fa', 'he', 'ur']); |
| 4 |
|
| 5 |
// Already all-caps in the site's locale; casing it here would break Turkish and Greek. |
| 6 |
const aiLabelText = () => window.extSharedData?.aiImageLabel ?? 'AI GENERATED'; |
| 7 |
|
| 8 |
const setPillFont = (ctx, fontSize) => { |
| 9 |
ctx.font = `bold ${fontSize}px system-ui, sans-serif`; |
| 10 |
// Older engines lack tracking; the label still reads without it. |
| 11 |
if ('letterSpacing' in ctx) ctx.letterSpacing = `${fontSize * 0.06}px`; |
| 12 |
// A Hebrew label with a Latin run inside orders wrongly under an ltr base. |
| 13 |
const language = (siteLocale() || '').split('-')[0].toLowerCase(); |
| 14 |
ctx.direction = RTL_LANGUAGES.has(language) ? 'rtl' : 'ltr'; |
| 15 |
}; |
| 16 |
|
| 17 |
const pillSize = (ctx, fontSize, label) => { |
| 18 |
setPillFont(ctx, fontSize); |
| 19 |
return { |
| 20 |
width: ctx.measureText(label).width + fontSize * 1.2, |
| 21 |
height: fontSize * 1.7, |
| 22 |
}; |
| 23 |
}; |
| 24 |
|
| 25 |
const pillPath = (ctx, x, y, width, height) => { |
| 26 |
const radius = height / 2; |
| 27 |
ctx.beginPath(); |
| 28 |
ctx.moveTo(x + radius, y); |
| 29 |
ctx.arcTo(x + width, y, x + width, y + height, radius); |
| 30 |
ctx.arcTo(x + width, y + height, x, y + height, radius); |
| 31 |
ctx.arcTo(x, y + height, x, y, radius); |
| 32 |
ctx.arcTo(x, y, x + width, y, radius); |
| 33 |
ctx.closePath(); |
| 34 |
}; |
| 35 |
|
| 36 |
const pillLineWidth = (fontSize) => Math.max(1, fontSize / 12); |
| 37 |
|
| 38 |
const drawPill = (ctx, x, y, fontSize) => { |
| 39 |
const label = aiLabelText(); |
| 40 |
const { width, height } = pillSize(ctx, fontSize, label); |
| 41 |
const lineWidth = pillLineWidth(fontSize); |
| 42 |
pillPath(ctx, x, y, width, height); |
| 43 |
ctx.fillStyle = '#1a1a1a'; |
| 44 |
ctx.fill(); |
| 45 |
// A centered stroke would eat half the border out of the fill box. |
| 46 |
pillPath( |
| 47 |
ctx, |
| 48 |
x - lineWidth / 2, |
| 49 |
y - lineWidth / 2, |
| 50 |
width + lineWidth, |
| 51 |
height + lineWidth, |
| 52 |
); |
| 53 |
ctx.lineWidth = lineWidth; |
| 54 |
ctx.strokeStyle = '#fff'; |
| 55 |
ctx.stroke(); |
| 56 |
ctx.fillStyle = '#fff'; |
| 57 |
ctx.textAlign = 'center'; |
| 58 |
const metrics = ctx.measureText(label); |
| 59 |
const { actualBoundingBoxAscent: ascent, actualBoundingBoxDescent: descent } = |
| 60 |
metrics; |
| 61 |
if (ascent === undefined || descent === undefined) { |
| 62 |
ctx.textBaseline = 'middle'; |
| 63 |
ctx.fillText(label, x + width / 2, y + height / 2); |
| 64 |
return; |
| 65 |
} |
| 66 |
// The 'middle' baseline centers the em box, which sits the glyphs too high. |
| 67 |
ctx.textBaseline = 'alphabetic'; |
| 68 |
ctx.fillText(label, x + width / 2, y + height / 2 + (ascent - descent) / 2); |
| 69 |
}; |
| 70 |
|
| 71 |
// Beyond 87.5% our narrowest crop, a 3:4 cover, eats the label. |
| 72 |
const PILL_RIGHT_EDGE = 0.85; |
| 73 |
|
| 74 |
export const stampAiLabel = (ctx, width, height) => { |
| 75 |
const fontSize = Math.max(12, Math.round(Math.min(width, height) * 0.02)); |
| 76 |
const pill = pillSize(ctx, fontSize, aiLabelText()); |
| 77 |
drawPill( |
| 78 |
ctx, |
| 79 |
width * PILL_RIGHT_EDGE - pill.width - pillLineWidth(fontSize), |
| 80 |
(height - pill.height) / 2, |
| 81 |
fontSize, |
| 82 |
); |
| 83 |
}; |
| 84 |
|
| 85 |
// GD on the server has no font for the localized label; ship it a rendered PNG. |
| 86 |
export const renderAiLabelPill = async () => { |
| 87 |
const canvas = document.createElement('canvas'); |
| 88 |
const ctx = canvas.getContext('2d'); |
| 89 |
if (!ctx) return null; |
| 90 |
const fontSize = 48; |
| 91 |
// Below the line width, the border clips at the bitmap edge. |
| 92 |
const margin = Math.ceil(fontSize / 12); |
| 93 |
const { width, height } = pillSize(ctx, fontSize, aiLabelText()); |
| 94 |
canvas.width = Math.ceil(width) + margin * 2; |
| 95 |
canvas.height = Math.ceil(height) + margin * 2; |
| 96 |
drawPill(ctx, margin, margin, fontSize); |
| 97 |
return await new Promise((resolve) => { |
| 98 |
canvas.toBlob((blob) => resolve(blob), 'image/png'); |
| 99 |
}); |
| 100 |
}; |
| 101 |
|
| 102 |
export const stampImageBlob = async (imageUrl) => { |
| 103 |
try { |
| 104 |
const image = new Image(); |
| 105 |
image.crossOrigin = 'anonymous'; |
| 106 |
const loaded = new Promise((resolve, reject) => { |
| 107 |
image.onload = () => resolve(image); |
| 108 |
image.onerror = reject; |
| 109 |
}); |
| 110 |
image.src = imageUrl; |
| 111 |
await loaded; |
| 112 |
const canvas = document.createElement('canvas'); |
| 113 |
canvas.width = image.naturalWidth || image.width; |
| 114 |
canvas.height = image.naturalHeight || image.height; |
| 115 |
const ctx = canvas.getContext('2d'); |
| 116 |
if (!ctx) return null; |
| 117 |
ctx.drawImage(image, 0, 0); |
| 118 |
stampAiLabel(ctx, canvas.width, canvas.height); |
| 119 |
return await new Promise((resolve) => { |
| 120 |
canvas.toBlob((blob) => resolve(blob), 'image/jpeg'); |
| 121 |
}); |
| 122 |
} catch { |
| 123 |
return null; |
| 124 |
} |
| 125 |
}; |
| 126 |
|