Quote API Deploy commited on
Commit
2413e0a
·
unverified ·
0 Parent(s):

feat: deploy quote-api to Hugging Face Spaces

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules
3
+
4
+ # Git
5
+ .git
6
+ .gitignore
7
+
8
+ # Environment
9
+ .env
10
+ .env.*
11
+ !.env.example
12
+
13
+ # IDE
14
+ .vscode
15
+ .idea
16
+ *.swp
17
+ *.swo
18
+
19
+ # Logs
20
+ *.log
21
+ npm-debug.log*
22
+
23
+ # Testing & Coverage
24
+ coverage
25
+ .nyc_output
26
+
27
+ # Documentation
28
+ *.md
29
+ !README.md
30
+
31
+ # OS
32
+ .DS_Store
33
+ Thumbs.db
34
+
35
+ # Build artifacts
36
+ build
37
+ dist
38
+
39
+ # Docker (don't copy docker files into image)
40
+ Dockerfile
41
+ docker-compose*.yml
42
+ .dockerignore
43
+ nginx*.conf
44
+
45
+ # Dev tools
46
+ .eslintrc*
47
+ .editorconfig
48
+ .github
.editorconfig ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ insert_final_newline = true
7
+ trim_trailing_whitespace = true
8
+ indent_style = space
9
+ indent_size = 2
.env.example ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ PORT="4888"
2
+ BOT_TOKEN=""
3
+ EMOJI_DOMAIN="https://emojipedia.org/"
.eslintrc.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "env": {
3
+ "es6": true,
4
+ "node": true
5
+ },
6
+ "extends": "standard",
7
+ "globals": {
8
+ "Atomics": "readonly",
9
+ "SharedArrayBuffer": "readonly"
10
+ },
11
+ "parserOptions": {
12
+ "ecmaVersion": 2018,
13
+ "sourceType": "module"
14
+ },
15
+ "rules": {
16
+ }
17
+ }
.gitattributes ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ assets/emoji/*.json filter=lfs diff=lfs merge=lfs -text
2
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
3
+ *.gif filter=lfs diff=lfs merge=lfs -text
4
+ *.webp filter=lfs diff=lfs merge=lfs -text
5
+ *.svg filter=lfs diff=lfs merge=lfs -text
6
+ *.png filter=lfs diff=lfs merge=lfs -text
7
+ *.jpg filter=lfs diff=lfs merge=lfs -text
.github/workflows/docker.yml ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Docker
2
+
3
+ on:
4
+ push:
5
+ branches: [ "master" ]
6
+ pull_request:
7
+ branches: [ "master" ]
8
+
9
+ env:
10
+ REGISTRY: ghcr.io
11
+ IMAGE_NAME: ${{ github.repository }}
12
+
13
+
14
+ jobs:
15
+ build:
16
+ runs-on: ubuntu-latest
17
+ permissions:
18
+ contents: read
19
+ packages: write
20
+ id-token: write
21
+
22
+ steps:
23
+ - name: Checkout repository
24
+ uses: actions/checkout@v4
25
+
26
+ - name: Set up Docker Buildx
27
+ uses: docker/setup-buildx-action@v3
28
+
29
+ - name: Log into registry ${{ env.REGISTRY }}
30
+ if: github.event_name != 'pull_request'
31
+ uses: docker/login-action@v3
32
+ with:
33
+ registry: ${{ env.REGISTRY }}
34
+ username: ${{ github.actor }}
35
+ password: ${{ secrets.GITHUB_TOKEN }}
36
+
37
+ - name: Extract Docker metadata
38
+ id: meta
39
+ uses: docker/metadata-action@v5
40
+ with:
41
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
42
+ tags: |
43
+ type=raw,value=latest
44
+ type=ref,event=tag
45
+ type=sha
46
+ - name: Build and push Docker image
47
+ id: build-and-push
48
+ uses: docker/build-push-action@v5
49
+ with:
50
+ context: .
51
+ push: ${{ github.event_name != 'pull_request' }}
52
+ tags: ${{ steps.meta.outputs.tags }}
53
+ labels: ${{ steps.meta.outputs.labels }}
54
+ cache-from: type=gha
55
+ cache-to: type=gha,mode=max
.gitignore ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Environment
5
+ .env
6
+ .env.local
7
+ .env.*.local
8
+
9
+ # Logs
10
+ logs/
11
+ *.log
12
+ npm-debug.log*
13
+
14
+ # Runtime
15
+ pids/
16
+ *.pid
17
+ *.pid.lock
18
+
19
+ # Coverage
20
+ coverage/
21
+ .nyc_output/
22
+
23
+ # Build
24
+ build/
25
+ dist/
26
+
27
+ # IDE
28
+ .idea/
29
+ .vscode/
30
+ *.swp
31
+ *.swo
32
+ *~
33
+
34
+ # OS
35
+ .DS_Store
36
+ Thumbs.db
37
+
38
+ # Project specific
39
+ assets/fonts/*
40
+ assets/emojis/*
41
+ !.gitkeep
42
+ .superpowers/
43
+ test-output/
44
+ assets/fonts/*.ttf
Dockerfile ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces — quote-api
2
+ # Port wajib 7860 sesuai requirement HF Spaces Docker.
3
+ FROM node:22-bookworm AS builder
4
+
5
+ WORKDIR /app
6
+
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ build-essential g++ make python3 \
9
+ libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev libpixman-1-dev pkg-config \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ COPY package*.json ./
13
+ RUN npm ci --omit=dev
14
+
15
+ # ── Runtime stage ──────────────────────────────────────────────
16
+ FROM node:22-bookworm-slim
17
+
18
+ WORKDIR /app
19
+
20
+ ENV NODE_ENV=production
21
+ # HF Spaces hanya expose port 7860
22
+ ENV PORT=7860
23
+
24
+ RUN apt-get update && apt-get install -y --no-install-recommends \
25
+ fonts-noto fonts-noto-cjk fonts-noto-extra fontconfig \
26
+ libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libjpeg62-turbo libgif7 librsvg2-2 libpixman-1-0 libvips42 \
27
+ ffmpeg curl \
28
+ && rm -rf /var/lib/apt/lists/* \
29
+ && fc-cache -f -v
30
+
31
+ # HF Spaces menjalankan container sebagai user 1000 (non-root)
32
+ RUN groupadd -r appuser && useradd -r -g appuser -u 1000 appuser
33
+
34
+ COPY --from=builder /app/node_modules ./node_modules
35
+ COPY --chown=appuser:appuser . .
36
+
37
+ USER appuser
38
+
39
+ EXPOSE 7860
40
+
41
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
42
+ CMD curl -f http://localhost:7860/health || exit 1
43
+
44
+ CMD ["node", "index.js"]
README.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Quote API
3
+ emoji: 💬
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ short_description: Quote image generation API for Telegram bots
10
+ ---
11
+
12
+ # Quote API
13
+
14
+ REST API untuk generate gambar quote, digunakan oleh Telegram bot.
15
+
16
+ ## Endpoint
17
+
18
+ ```
19
+ POST /generate
20
+ GET /health
21
+ ```
22
+
23
+ ## Environment Variables (Secrets)
24
+
25
+ | Nama | Keterangan |
26
+ |---|---|
27
+ | `BOT_TOKEN` | Token bot Telegram (untuk bypass rate limit) |
28
+ | `EMOJI_DOMAIN` | Domain emoji (default: `https://emojipedia.org/`) |
29
+
30
+ > Port yang digunakan: **7860** (wajib untuk HF Spaces Docker).
app.js ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const logger = require('koa-logger')
2
+ const responseTime = require('koa-response-time')
3
+ const bodyParser = require('koa-bodyparser')
4
+ const ratelimit = require('koa-ratelimit')
5
+ const Router = require('koa-router')
6
+ const Koa = require('koa')
7
+ const { loadFonts } = require('./utils')
8
+
9
+ const app = new Koa()
10
+
11
+ app.use(logger())
12
+ app.use(responseTime())
13
+ app.use(bodyParser())
14
+
15
+ const ratelimitDb = new Map()
16
+
17
+ app.use(ratelimit({
18
+ driver: 'memory',
19
+ db: ratelimitDb,
20
+ duration: 1000 * 55,
21
+ errorMessage: {
22
+ ok: false,
23
+ error: {
24
+ code: 429,
25
+ message: 'Rate limit exceeded. See "Retry-After"'
26
+ }
27
+ },
28
+ id: (ctx) => ctx.ip,
29
+ headers: {
30
+ remaining: 'Rate-Limit-Remaining',
31
+ reset: 'Rate-Limit-Reset',
32
+ total: 'Rate-Limit-Total'
33
+ },
34
+ max: 20,
35
+ disableHeader: false,
36
+ whitelist: (ctx) => {
37
+ // The bot sends its token in the request body (kept out of the URL/access
38
+ // logs); accept either location so its own requests stay un-throttled.
39
+ const token = ctx.query.botToken || (ctx.request.body && ctx.request.body.botToken)
40
+ return token === process.env.BOT_TOKEN
41
+ },
42
+ blacklist: (ctx) => {
43
+ }
44
+ }))
45
+
46
+ app.use(require('./helpers').helpersApi)
47
+
48
+ const route = new Router()
49
+
50
+ const routes = require('./routes')
51
+
52
+ // Health check endpoint for Docker/Coolify
53
+ route.get('/health', (ctx) => {
54
+ ctx.status = 200
55
+ ctx.body = { status: 'ok', timestamp: Date.now() }
56
+ })
57
+
58
+ route.use('/*', routes.routeApi.routes())
59
+
60
+ app.use(route.routes())
61
+
62
+ const port = process.env.PORT || 3000
63
+
64
+ async function start () {
65
+ await loadFonts()
66
+ app.listen(port, () => {
67
+ console.log('Listening on localhost, port', port)
68
+ })
69
+ }
70
+
71
+ start()
assets/Quotly.png ADDED

Git LFS Details

  • SHA256: 5e491139d8a5782cc6a7667306478ea7627032b2893ec894a65350a0f18c891e
  • Pointer size: 130 Bytes
  • Size of remote file: 28.9 kB
assets/emoji/emoji-apple-image.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f3d4ea82dc010845551aaea65da156fff95389b6f35a0194281a3e7363d9ad7f
3
+ size 29380588
assets/emoji/emoji-blob-image.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d24c3f079ea99585e6005e66cf3d8f1efd0feee5af58a0474084a4dbed264d83
3
+ size 2092162
assets/emoji/emoji-google-image.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9cbfd8c3b184e5d3f5d456ce488c2a13f5e9cba7b45d92acc490b12234716328
3
+ size 18892340
assets/emoji/emoji-joypixels-image.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0f9f1323fddae35bd891dfbe5ad218380e78d197834e4a250a073257d487faad
3
+ size 18326902
assets/emoji/emoji-twitter-image.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:17b49c2d0b80e62d383ee6a9798da668106b4bf4a5ab56a56a370f96c280c453
3
+ size 14470600
assets/fonts/.gitkeep ADDED
File without changes
assets/icons/insert_drive_file.svg ADDED

Git LFS Details

  • SHA256: b7a56b2ace1051167ee454a2f49e0db294c822fd370e79c3c7c00e3754058096
  • Pointer size: 128 Bytes
  • Size of remote file: 196 Bytes
assets/icons/music_note.svg ADDED

Git LFS Details

  • SHA256: 2b5c610d246fc796dcd2385a5b058ae76ddaf65ccb93c18d5b13117417a6a1a8
  • Pointer size: 128 Bytes
  • Size of remote file: 186 Bytes
assets/icons/play_arrow.svg ADDED

Git LFS Details

  • SHA256: 54077d93aca5c4b0dbc80d177dfe003e689ef3e6dfffdf306ee8027b53282c38
  • Pointer size: 128 Bytes
  • Size of remote file: 114 Bytes
assets/pattern_02.png ADDED

Git LFS Details

  • SHA256: 6e1fe5a9e622a58651674a15ecd6b3cd333c809347b69e2d6eb902c2aca18bee
  • Pointer size: 131 Bytes
  • Size of remote file: 484 kB
assets/pattern_ny.png ADDED

Git LFS Details

  • SHA256: ce1d27dfe6ac5e717d578415bdcd24824293e44288e2a51391842c5c9e083499
  • Pointer size: 131 Bytes
  • Size of remote file: 366 kB
assets/pattern_ny_old.png ADDED

Git LFS Details

  • SHA256: 498deafb36f8ccd3c154b87f520b10f7edba8c0bc0dc266b9ed38541fef6688d
  • Pointer size: 132 Bytes
  • Size of remote file: 1.01 MB
helpers/api.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module.exports = async (ctx, next) => {
2
+ ctx.props = Object.assign({}, ctx.query || {}, ctx.request.body || {})
3
+
4
+ try {
5
+ await next()
6
+
7
+ if (!ctx.body) {
8
+ ctx.assert(ctx.result, 404, 'Not Found')
9
+
10
+ if (ctx.result.error) {
11
+ ctx.status = 400
12
+ ctx.body = {
13
+ ok: false,
14
+ error: {
15
+ code: 400,
16
+ message: ctx.result.error
17
+ }
18
+ }
19
+ } else {
20
+ if (ctx.result.ext) {
21
+ if (ctx.result.ext === 'webp') ctx.response.set('content-type', 'image/webp')
22
+ if (ctx.result.ext === 'png') ctx.response.set('content-type', 'image/png')
23
+ ctx.response.set('quote-type', ctx.result.type)
24
+ ctx.response.set('quote-width', ctx.result.width)
25
+ ctx.response.set('quote-height', ctx.result.height)
26
+ ctx.body = ctx.result.image
27
+ } else {
28
+ ctx.body = {
29
+ ok: true,
30
+ result: ctx.result
31
+ }
32
+ }
33
+ }
34
+ }
35
+ } catch (error) {
36
+ console.error(error)
37
+ ctx.status = error.statusCode || error.status || 500
38
+ ctx.body = {
39
+ ok: false,
40
+ error: {
41
+ code: ctx.status,
42
+ message: error.message,
43
+ description: error.description
44
+ }
45
+ }
46
+ }
47
+ }
helpers/index.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ const helpersApi = require('./api')
2
+
3
+ module.exports = {
4
+ helpersApi
5
+ }
index.js ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ require('dotenv').config({ path: './.env' })
2
+ require('./app')
methods/generate.js ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const path = require('path')
2
+ const { QuoteGenerate } = require('../utils')
3
+ const { createCanvas, loadImage } = require('canvas')
4
+ const sharp = require('sharp')
5
+ const { parseBackgroundColor, colorLuminance, lightOrDark, hexToHsl, hslToHex } = require('../utils/quote-generate/color')
6
+ const { brands: emojiBrands } = require('../utils/emoji-image')
7
+
8
+ const ALLOWED_EMOJI_BRANDS = new Set(Object.keys(emojiBrands))
9
+
10
+ let cachedPatternImage = null
11
+ async function getPatternImage () {
12
+ if (!cachedPatternImage) {
13
+ cachedPatternImage = await loadImage(path.join(__dirname, '..', 'assets', 'pattern_02.png'))
14
+ }
15
+ return cachedPatternImage
16
+ }
17
+
18
+ const imageAlpha = (image, alpha) => {
19
+ const canvas = createCanvas(image.width, image.height)
20
+ const canvasCtx = canvas.getContext('2d')
21
+ canvasCtx.globalAlpha = alpha
22
+ canvasCtx.drawImage(image, 0, 0)
23
+ return canvas
24
+ }
25
+
26
+ function normalizeMessage (message) {
27
+ if (!message.from) {
28
+ message.from = { id: 0 }
29
+ }
30
+ if (!message.from.photo) {
31
+ message.from.photo = {}
32
+ }
33
+ if (message.from.name !== false && !message.from.name && (message.from.first_name || message.from.last_name)) {
34
+ message.from.name = [message.from.first_name, message.from.last_name]
35
+ .filter(Boolean)
36
+ .join(' ')
37
+ }
38
+ if (message.replyMessage) {
39
+ if (!message.replyMessage.chatId) {
40
+ message.replyMessage.chatId = message.from.id || 0
41
+ }
42
+ if (!message.replyMessage.entities) {
43
+ message.replyMessage.entities = []
44
+ }
45
+ if (!message.replyMessage.from) {
46
+ message.replyMessage.from = {
47
+ name: message.replyMessage.name,
48
+ photo: {}
49
+ }
50
+ } else if (!message.replyMessage.from.photo) {
51
+ message.replyMessage.from.photo = {}
52
+ }
53
+ }
54
+ }
55
+
56
+ async function drawPatternBackground (canvas, centerColor, edgeColor, patternImage, patternAlpha) {
57
+ const ctx = canvas.getContext('2d')
58
+
59
+ const gradient = ctx.createRadialGradient(
60
+ canvas.width / 2, canvas.height / 2, 0,
61
+ canvas.width / 2, canvas.height / 2, canvas.width / 2
62
+ )
63
+ gradient.addColorStop(0, centerColor)
64
+ gradient.addColorStop(1, edgeColor)
65
+
66
+ ctx.fillStyle = gradient
67
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
68
+
69
+ const pattern = ctx.createPattern(imageAlpha(patternImage, patternAlpha), 'repeat')
70
+ ctx.fillStyle = pattern
71
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
72
+ }
73
+
74
+ // Wallpaper colors derived from the bubble color. The bubble must sit ON the
75
+ // wallpaper, not dissolve into it:
76
+ // • dark bubbles → a much darker backdrop (luminance drop + vignette);
77
+ // • light bubbles → a PASTEL backdrop, like Telegram's light wallpapers:
78
+ // saturate the hue and keep lightness high — darkening a near-white just
79
+ // makes mud. Near-gray inputs fall back to a soft Telegram-ish blue.
80
+ function wallpaperColors (colorOne) {
81
+ if (lightOrDark(colorOne) === 'dark') {
82
+ return {
83
+ center: colorLuminance(colorOne, -0.35),
84
+ edge: colorLuminance(colorOne, -0.6),
85
+ patternAlpha: 0.22
86
+ }
87
+ }
88
+ let [h, s] = hexToHsl(colorOne)
89
+ if (s < 0.08) {
90
+ h = 207 // washed-out gray/white → soft blue
91
+ s = 0.45
92
+ } else {
93
+ s = Math.min(1, Math.max(s * 1.8, 0.35))
94
+ }
95
+ return {
96
+ center: hslToHex(h, s, 0.8),
97
+ edge: hslToHex(h, s, 0.62),
98
+ patternAlpha: 0.18
99
+ }
100
+ }
101
+
102
+ module.exports = async (parm) => {
103
+ if (!parm) return { error: 'query_empty' }
104
+ if (!Array.isArray(parm.messages) || parm.messages.length < 1) return { error: 'messages_empty' }
105
+
106
+ const botToken = parm.botToken || process.env.BOT_TOKEN
107
+ const quoteGenerate = new QuoteGenerate(botToken)
108
+ const rawScale = parseFloat(parm.scale) || 2
109
+ const scale = Math.min(20, Math.max(1, Number.isFinite(rawScale) ? rawScale : 2))
110
+ const rawBrand = parm.emojiBrand || 'apple'
111
+ const emojiBrand = ALLOWED_EMOJI_BRANDS.has(rawBrand) ? rawBrand : 'apple'
112
+
113
+ const background = parseBackgroundColor(parm.backgroundColor)
114
+
115
+ // Normalize all messages first (sync, no I/O)
116
+ const validMessages = parm.messages.filter(Boolean)
117
+ for (const message of validMessages) {
118
+ normalizeMessage(message)
119
+ }
120
+
121
+ // Same-sender runs render with grouped corners (small radii between
122
+ // neighbours), like consecutive messages in Telegram. The avatar (and with
123
+ // it the bubble tail) belongs to the LAST message of a group only — the
124
+ // reserved left column keeps the other bubbles aligned.
125
+ for (let i = 0; i < validMessages.length; i++) {
126
+ const prevSame = i > 0 && validMessages[i - 1].chatId === validMessages[i].chatId
127
+ const nextSame = i < validMessages.length - 1 && validMessages[i + 1].chatId === validMessages[i].chatId
128
+ validMessages[i].groupPos = prevSame && nextSame ? 'middle' : prevSame ? 'last' : nextSame ? 'first' : 'single'
129
+ if (nextSame) validMessages[i].avatar = false
130
+ }
131
+
132
+ // Generate quotes with concurrency limit to avoid Telegram API rate limits
133
+ const CONCURRENCY = 3
134
+ const quoteImages = new Array(validMessages.length).fill(null)
135
+ let running = 0
136
+ let nextIndex = 0
137
+
138
+ await new Promise((resolve) => {
139
+ function runNext () {
140
+ while (running < CONCURRENCY && nextIndex < validMessages.length) {
141
+ const index = nextIndex++
142
+ running++
143
+
144
+ quoteGenerate.generate(
145
+ background.colorOne,
146
+ background.colorTwo,
147
+ validMessages[index],
148
+ parm.width,
149
+ parm.height,
150
+ scale,
151
+ emojiBrand
152
+ ).then((canvas) => {
153
+ if (canvas) quoteImages[index] = canvas
154
+ else console.warn('Failed to generate quote for message, skipping')
155
+ }).catch((error) => {
156
+ console.error('Error generating quote for message:', error.message)
157
+ }).finally(() => {
158
+ running--
159
+ if (nextIndex >= validMessages.length && running === 0) resolve()
160
+ else runNext()
161
+ })
162
+ }
163
+ if (validMessages.length === 0) resolve()
164
+ }
165
+ runNext()
166
+ })
167
+
168
+ // Filter nulls (failed messages) while preserving order, keeping each
169
+ // image paired with its source message (for grouped-margin decisions).
170
+ const pairs = validMessages
171
+ .map((message, i) => ({ message, image: quoteImages[i] }))
172
+ .filter((p) => p.image)
173
+ const filteredImages = pairs.map((p) => p.image)
174
+
175
+ if (filteredImages.length === 0) {
176
+ return { error: 'empty_messages' }
177
+ }
178
+
179
+ let canvasQuote
180
+
181
+ if (filteredImages.length > 1) {
182
+ let width = 0
183
+ let height = 0
184
+
185
+ for (let index = 0; index < filteredImages.length; index++) {
186
+ if (filteredImages[index].width > width) width = filteredImages[index].width
187
+ height += filteredImages[index].height
188
+ }
189
+
190
+ // Tighter spacing inside a same-sender group, roomier between groups.
191
+ const margins = []
192
+ let totalMargin = 0
193
+ for (let index = 0; index < pairs.length - 1; index++) {
194
+ const grouped = pairs[index].message.chatId === pairs[index + 1].message.chatId
195
+ const m = (grouped ? 2 : 6) * scale
196
+ margins.push(m)
197
+ totalMargin += m
198
+ }
199
+
200
+ const canvas = createCanvas(width, height + totalMargin)
201
+ const canvasCtx = canvas.getContext('2d')
202
+
203
+ let imageY = 0
204
+ for (let index = 0; index < filteredImages.length; index++) {
205
+ canvasCtx.drawImage(filteredImages[index], 0, imageY)
206
+ imageY += filteredImages[index].height + (margins[index] || 0)
207
+ }
208
+ canvasQuote = canvas
209
+ } else {
210
+ canvasQuote = filteredImages[0]
211
+ }
212
+
213
+ let quoteImage
214
+
215
+ let { type, format, ext } = parm
216
+
217
+ if (!type && ext) type = 'png'
218
+ if (type !== 'image' && type !== 'stories' && canvasQuote.height > 1024 * 2) type = 'png'
219
+
220
+ if (type === 'quote') {
221
+ const downPadding = 75
222
+ const maxWidth = 512
223
+ const maxHeight = 512
224
+
225
+ const imageQuoteSharp = sharp(canvasQuote.toBuffer())
226
+
227
+ if (canvasQuote.height > canvasQuote.width) imageQuoteSharp.resize({ height: maxHeight })
228
+ else imageQuoteSharp.resize({ width: maxWidth })
229
+
230
+ const canvasImage = await loadImage(await imageQuoteSharp.toBuffer())
231
+
232
+ const canvasPadding = createCanvas(canvasImage.width, canvasImage.height + downPadding)
233
+ const canvasPaddingCtx = canvasPadding.getContext('2d')
234
+ canvasPaddingCtx.drawImage(canvasImage, 0, 0)
235
+
236
+ const imageSharp = sharp(canvasPadding.toBuffer())
237
+
238
+ if (canvasPadding.height >= canvasPadding.width) imageSharp.resize({ height: maxHeight })
239
+ else imageSharp.resize({ width: maxWidth })
240
+
241
+ if (format === 'png') quoteImage = await imageSharp.png().toBuffer()
242
+ else quoteImage = await imageSharp.webp({ lossless: true, force: true }).toBuffer()
243
+ } else if (type === 'image') {
244
+ const heightPadding = 75 * scale
245
+ const widthPadding = 95 * scale
246
+
247
+ // Draw canvas-to-canvas directly — no need for toBuffer() -> loadImage() round-trip
248
+ const canvasPic = createCanvas(canvasQuote.width + widthPadding, canvasQuote.height + heightPadding)
249
+ const canvasPicCtx = canvasPic.getContext('2d')
250
+
251
+ const patternImage = await getPatternImage()
252
+ const wp = wallpaperColors(background.colorOne)
253
+ await drawPatternBackground(canvasPic, wp.center, wp.edge, patternImage, wp.patternAlpha)
254
+
255
+ canvasPicCtx.shadowOffsetX = 8
256
+ canvasPicCtx.shadowOffsetY = 8
257
+ canvasPicCtx.shadowBlur = 13
258
+ canvasPicCtx.shadowColor = 'rgba(0, 0, 0, 0.5)'
259
+
260
+ canvasPicCtx.drawImage(canvasQuote, widthPadding / 2, heightPadding / 2)
261
+
262
+ canvasPicCtx.shadowOffsetX = 0
263
+ canvasPicCtx.shadowOffsetY = 0
264
+ canvasPicCtx.shadowBlur = 0
265
+ canvasPicCtx.shadowColor = 'rgba(0, 0, 0, 0)'
266
+
267
+ canvasPicCtx.fillStyle = 'rgba(0, 0, 0, 0.3)'
268
+ canvasPicCtx.font = `${8 * scale}px Noto Sans`
269
+ canvasPicCtx.textAlign = 'right'
270
+ canvasPicCtx.fillText('@QuotLyBot', canvasPic.width - 25, canvasPic.height - 25)
271
+
272
+ quoteImage = await sharp(canvasPic.toBuffer()).png({ lossless: true, force: true }).toBuffer()
273
+ } else if (type === 'stories') {
274
+ const canvasPic = createCanvas(720, 1280)
275
+ const canvasPicCtx = canvasPic.getContext('2d')
276
+
277
+ const patternImage = await getPatternImage()
278
+ const storyWp = wallpaperColors(background.colorOne)
279
+ await drawPatternBackground(canvasPic, storyWp.center, storyWp.edge, patternImage, storyWp.patternAlpha)
280
+
281
+ canvasPicCtx.shadowOffsetX = 8
282
+ canvasPicCtx.shadowOffsetY = 8
283
+ canvasPicCtx.shadowBlur = 13
284
+ canvasPicCtx.shadowColor = 'rgba(0, 0, 0, 0.5)'
285
+
286
+ const minPadding = 110
287
+ const maxW = canvasPic.width - minPadding * 2
288
+ const maxH = canvasPic.height - minPadding * 2
289
+
290
+ // Use canvas dimensions directly to decide if resize is needed — avoid toBuffer() -> loadImage()
291
+ let drawSource = canvasQuote
292
+ if (canvasQuote.width > maxW || canvasQuote.height > maxH) {
293
+ const resizedBuffer = await sharp(canvasQuote.toBuffer()).resize({
294
+ width: maxW,
295
+ height: maxH,
296
+ fit: 'contain',
297
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
298
+ }).toBuffer()
299
+ drawSource = await loadImage(resizedBuffer)
300
+ }
301
+
302
+ const imageX = (canvasPic.width - drawSource.width) / 2
303
+ const imageY = (canvasPic.height - drawSource.height) / 2
304
+
305
+ canvasPicCtx.drawImage(drawSource, imageX, imageY)
306
+
307
+ canvasPicCtx.shadowOffsetX = 0
308
+ canvasPicCtx.shadowOffsetY = 0
309
+ canvasPicCtx.shadowBlur = 0
310
+
311
+ canvasPicCtx.fillStyle = 'rgba(0, 0, 0, 0.4)'
312
+ canvasPicCtx.font = `${16 * scale}px Noto Sans`
313
+ canvasPicCtx.textAlign = 'center'
314
+ canvasPicCtx.translate(70, canvasPic.height / 2)
315
+ canvasPicCtx.rotate(-Math.PI / 2)
316
+ canvasPicCtx.fillText('@QuotLyBot', 0, 0)
317
+
318
+ quoteImage = await sharp(canvasPic.toBuffer()).png({ lossless: true, force: true }).toBuffer()
319
+ } else {
320
+ quoteImage = canvasQuote.toBuffer()
321
+ }
322
+
323
+ // Use sharp metadata only when we went through sharp pipeline, otherwise use canvas dimensions
324
+ let width, height
325
+ if (type === 'quote' || type === 'image' || type === 'stories') {
326
+ const imageMetadata = await sharp(quoteImage).metadata()
327
+ width = imageMetadata.width
328
+ height = imageMetadata.height
329
+ } else {
330
+ width = canvasQuote.width
331
+ height = canvasQuote.height
332
+ }
333
+
334
+ let image
335
+ if (ext) image = quoteImage
336
+ else image = quoteImage.toString('base64')
337
+
338
+ return { image, type, width, height, ext }
339
+ }
methods/index.js ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const crypto = require('crypto')
2
+ const LRU = require('lru-cache')
3
+ const sizeof = require('object-sizeof')
4
+
5
+ const generate = require('./generate')
6
+
7
+ const methods = {
8
+ generate
9
+ }
10
+
11
+ const cache = new LRU({
12
+ max: 1000 * 1000 * 1000,
13
+ length: (n) => { return sizeof(n) },
14
+ maxAge: 1000 * 60 * 45
15
+ })
16
+
17
+ module.exports = async (method, parm) => {
18
+ if (methods[method]) {
19
+ let methodResult = {}
20
+
21
+ let cacheString = crypto.createHash('md5').update(JSON.stringify({ method, parm })).digest('hex')
22
+ const methodResultCache = cache.get(cacheString)
23
+
24
+ if (!methodResultCache) {
25
+ methodResult = await methods[method](parm)
26
+
27
+ if (!methodResult.error) cache.set(cacheString, methodResult)
28
+ } else {
29
+ methodResult = methodResultCache
30
+ }
31
+
32
+ return methodResult
33
+ } else {
34
+ return {
35
+ error: 'method not found'
36
+ }
37
+ }
38
+ }
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "quote-api",
3
+ "version": "0.14.0",
4
+ "description": "Quote image generation API",
5
+ "main": "index.js",
6
+ "engines": {
7
+ "node": ">=22.0.0"
8
+ },
9
+ "scripts": {
10
+ "start": "node index.js",
11
+ "lint": "eslint --ext js .",
12
+ "lint:fix": "eslint --fix --ext js .",
13
+ "test": "node test-metrics.js && node test-style.js && node test-attachments.js && node test-fixes.js",
14
+ "test:visual": "node test-real.js"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/LyoSU/quote-api.git"
19
+ },
20
+ "author": "LyoSU",
21
+ "license": "MIT",
22
+ "bugs": {
23
+ "url": "https://github.com/LyoSU/quote-api/issues"
24
+ },
25
+ "homepage": "https://github.com/LyoSU/quote-api#readme",
26
+ "dependencies": {
27
+ "canvas": "^2.8.0",
28
+ "dotenv": "^7.0.0",
29
+ "emoji-db": "^16.0.1",
30
+ "jimp": "^1.6.0",
31
+ "jsdom": "^16.5.3",
32
+ "koa": "^2.11.0",
33
+ "koa-bodyparser": "^4.2.1",
34
+ "koa-logger": "^3.2.1",
35
+ "koa-ratelimit": "^4.2.1",
36
+ "koa-response-time": "^2.1.0",
37
+ "koa-router": "^7.4.0",
38
+ "lru-cache": "^5.1.1",
39
+ "object-sizeof": "^1.6.0",
40
+ "runes": "^0.4.3",
41
+ "sharp": "^0.32.5",
42
+ "smartcrop-sharp": "^2.0.7",
43
+ "telegraf": "^3.38.0"
44
+ },
45
+ "devDependencies": {
46
+ "eslint": "^8.6.0",
47
+ "eslint-config-standard": "^12.0.0",
48
+ "eslint-plugin-import": "^2.17.3",
49
+ "eslint-plugin-node": "^9.1.0",
50
+ "eslint-plugin-promise": "^4.1.1",
51
+ "eslint-plugin-standard": "^4.0.0"
52
+ }
53
+ }
routes/api.js ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const Router = require('koa-router')
2
+ const api = new Router()
3
+
4
+ const method = require('../methods')
5
+
6
+ const apiHandle = async (ctx) => {
7
+ const methodWithExt = ctx.params[0].match(/(.*).(png|webp)/)
8
+ if (methodWithExt) ctx.props.ext = methodWithExt[2]
9
+ ctx.result = await method(methodWithExt ? methodWithExt[1] : ctx.params[0], ctx.props)
10
+ }
11
+
12
+ api.post('/', apiHandle)
13
+
14
+ module.exports = api
routes/index.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ const routeApi = require('./api')
2
+
3
+ module.exports = {
4
+ routeApi
5
+ }
scripts/send-preview-stickers.js ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Надсилає прев'ю-стікери редизайну в Telegram.
2
+ // Run: node scripts/send-preview-stickers.js [chat_id]
3
+
4
+ require('dotenv').config()
5
+ const { Telegram } = require('telegraf')
6
+ const generate = require('../methods/generate')
7
+
8
+ const CHAT_ID = Number(process.argv[2] || 66478514)
9
+ const BOT_TOKEN = process.env.BOT_TOKEN
10
+ const BG = '//#252e44' // gradient pair (lighten/darken) → glass bubble
11
+
12
+ async function main () {
13
+ if (!BOT_TOKEN) throw new Error('BOT_TOKEN missing in .env')
14
+ const telegram = new Telegram(BOT_TOKEN)
15
+
16
+ const cases = [
17
+ ['текст + реплай', [{
18
+ chatId: 101,
19
+ avatar: true,
20
+ from: { id: 101, name: 'Олена Ковальчук' },
21
+ replyMessage: { name: 'Андрій', text: 'давай завтра о 10:00?', chatId: 102 },
22
+ text: 'Домовились, скину нагадування 🔥'
23
+ }]],
24
+ ['voice', [{
25
+ chatId: 103,
26
+ avatar: true,
27
+ from: { id: 103, name: 'Макс Голос' },
28
+ voice: { waveform: Array.from({ length: 60 }, (_, i) => 4 + ((i * 13) % 28), 0), duration: 83 }
29
+ }]],
30
+ ['документ', [{
31
+ chatId: 104,
32
+ avatar: true,
33
+ from: { id: 104, name: 'Бухгалтерія' },
34
+ document: { file_name: 'квартальний_звіт_2026.pdf', file_size: 2.4 * 1024 * 1024 },
35
+ text: 'фінальна версія'
36
+ }]],
37
+ ['музика', [{
38
+ chatId: 105,
39
+ avatar: true,
40
+ from: { id: 105, name: 'Меломан' },
41
+ audio: { title: 'Водограй', performer: 'Володимир Івасюк', duration: 215 }
42
+ }]],
43
+ ['відео', [{
44
+ chatId: 106,
45
+ avatar: true,
46
+ from: { id: 106, name: 'Відеограф' },
47
+ media: { url: 'https://picsum.photos/seed/quoteapi/640/360' },
48
+ mediaType: 'video',
49
+ mediaDuration: 42,
50
+ text: 'подивіться до кінця 🔥'
51
+ }]],
52
+ ['діалог (групування)', [
53
+ {
54
+ chatId: 107,
55
+ avatar: true,
56
+ from: { id: 107, name: 'assasinfil' },
57
+ text: 'Хто знає як зробити NFC клон?'
58
+ },
59
+ {
60
+ chatId: 107,
61
+ avatar: true,
62
+ from: { id: 107, name: 'assasinfil' },
63
+ text: 'Треба Flipper Zero для цього'
64
+ },
65
+ {
66
+ chatId: 108,
67
+ avatar: true,
68
+ from: { id: 108, name: 'nowiks' },
69
+ replyMessage: { name: 'assasinfil', text: 'Хто знає як зробити NFC клон?', chatId: 107 },
70
+ text: 'Ну получить можна… не понял..'
71
+ }
72
+ ]]
73
+ ]
74
+
75
+ await telegram.sendMessage(CHAT_ID, '🎨 Прев\'ю редизайну цитат — глянь стікери нижче')
76
+
77
+ for (const [label, messages] of cases) {
78
+ try {
79
+ const result = await generate({
80
+ botToken: BOT_TOKEN,
81
+ messages,
82
+ type: 'quote',
83
+ format: 'webp',
84
+ ext: 'webp',
85
+ scale: 2,
86
+ backgroundColor: BG
87
+ })
88
+ if (result.error) throw new Error(result.error)
89
+ await telegram.sendSticker(CHAT_ID, { source: Buffer.from(result.image) })
90
+ console.log(`✓ ${label} (${result.width}x${result.height})`)
91
+ } catch (err) {
92
+ console.error(`✗ ${label}: ${err.message}`)
93
+ }
94
+ }
95
+ }
96
+
97
+ main().catch((err) => {
98
+ console.error('FAIL:', err.message)
99
+ process.exit(1)
100
+ })
scripts/update-emoji.js ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Downloads emoji images from emoji-datasource CDN (jsdelivr) and saves them
5
+ * as base64 JSON files for each brand.
6
+ *
7
+ * Supported brands via emoji-datasource: apple, google, twitter
8
+ * Unsupported (kept as-is): joypixels, blob
9
+ *
10
+ * Usage:
11
+ * node scripts/update-emoji.js # update all supported brands
12
+ * node scripts/update-emoji.js apple # update only apple
13
+ * node scripts/update-emoji.js google twitter # update specific brands
14
+ */
15
+
16
+ const path = require('path')
17
+ const fs = require('fs')
18
+ const https = require('https')
19
+
20
+ const EmojiDbLib = require('emoji-db')
21
+ const emojiDb = new EmojiDbLib({ useDefaultDb: true })
22
+
23
+ const EMOJI_DIR = path.resolve(__dirname, '../assets/emoji/')
24
+
25
+ const CDN_VERSION = '16.0.0'
26
+ const CDN_SIZE = 64 // px
27
+ const CONCURRENCY = 50
28
+
29
+ const SUPPORTED_BRANDS = {
30
+ apple: {
31
+ pkg: 'emoji-datasource-apple',
32
+ folder: 'apple'
33
+ },
34
+ google: {
35
+ pkg: 'emoji-datasource-google',
36
+ folder: 'google'
37
+ },
38
+ twitter: {
39
+ pkg: 'emoji-datasource-twitter',
40
+ folder: 'twitter'
41
+ }
42
+ }
43
+
44
+ const BRAND_JSON_FILES = {
45
+ apple: 'emoji-apple-image.json',
46
+ google: 'emoji-google-image.json',
47
+ twitter: 'emoji-twitter-image.json',
48
+ joypixels: 'emoji-joypixels-image.json',
49
+ blob: 'emoji-blob-image.json'
50
+ }
51
+
52
+ function fetchBuffer (url) {
53
+ return new Promise((resolve, reject) => {
54
+ const req = https.get(url, (res) => {
55
+ if (res.statusCode !== 200) {
56
+ res.resume()
57
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`))
58
+ }
59
+ const chunks = []
60
+ res.on('data', (chunk) => chunks.push(chunk))
61
+ res.on('end', () => resolve(Buffer.concat(chunks)))
62
+ res.on('error', reject)
63
+ })
64
+ req.on('error', reject)
65
+ req.setTimeout(10000, () => {
66
+ req.destroy()
67
+ reject(new Error(`Timeout for ${url}`))
68
+ })
69
+ })
70
+ }
71
+
72
+ function getCdnUrl (brand, emojiCode) {
73
+ const config = SUPPORTED_BRANDS[brand]
74
+ return `https://cdn.jsdelivr.net/npm/${config.pkg}@${CDN_VERSION}/img/${config.folder}/${CDN_SIZE}/${emojiCode}.png`
75
+ }
76
+
77
+ async function runPool (tasks, concurrency) {
78
+ const results = []
79
+ let index = 0
80
+
81
+ async function worker () {
82
+ while (index < tasks.length) {
83
+ const i = index++
84
+ results[i] = await tasks[i]().catch(() => null)
85
+ }
86
+ }
87
+
88
+ const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker())
89
+ await Promise.all(workers)
90
+ return results
91
+ }
92
+
93
+ async function updateBrand (brand) {
94
+ const config = SUPPORTED_BRANDS[brand]
95
+ if (!config) {
96
+ console.log(` Skipping ${brand} — not supported by emoji-datasource (keeping existing JSON)`)
97
+ return
98
+ }
99
+
100
+ const jsonFile = path.join(EMOJI_DIR, BRAND_JSON_FILES[brand])
101
+
102
+ // Load existing data
103
+ let existing = {}
104
+ try {
105
+ if (fs.existsSync(jsonFile)) {
106
+ existing = JSON.parse(fs.readFileSync(jsonFile, 'utf8'))
107
+ }
108
+ } catch (err) {
109
+ console.warn(` Warning: could not read existing ${jsonFile}: ${err.message}`)
110
+ }
111
+
112
+ const dbData = emojiDb.dbData
113
+ const codes = Object.keys(dbData).filter(k => !dbData[k].qualified)
114
+
115
+ // Find missing emoji codes
116
+ const missing = codes.filter(code => !existing[code])
117
+ const total = codes.length
118
+ const alreadyHave = total - missing.length
119
+
120
+ console.log(` ${brand}: ${total} emoji total, ${alreadyHave} cached, ${missing.length} to download`)
121
+
122
+ if (missing.length === 0) {
123
+ console.log(` ${brand}: up to date`)
124
+ return
125
+ }
126
+
127
+ let downloaded = 0
128
+ let failed = 0
129
+
130
+ const tasks = missing.map(code => async () => {
131
+ try {
132
+ const url = getCdnUrl(brand, code)
133
+ const buffer = await fetchBuffer(url)
134
+ const base64 = buffer.toString('base64')
135
+
136
+ if (base64.length > 0) {
137
+ downloaded++
138
+ if (downloaded % 100 === 0) process.stdout.write(` ${brand}: ${downloaded}/${missing.length}\r`)
139
+ return { code, base64 }
140
+ }
141
+ } catch (err) {
142
+ failed++
143
+ return null
144
+ }
145
+ })
146
+
147
+ const results = await runPool(tasks, CONCURRENCY)
148
+
149
+ for (const result of results) {
150
+ if (result) existing[result.code] = result.base64
151
+ }
152
+
153
+ console.log(` ${brand}: downloaded ${downloaded}, failed ${failed}, total ${Object.keys(existing).length}`)
154
+
155
+ // Write atomically
156
+ const tmpFile = jsonFile + '.tmp'
157
+ fs.writeFileSync(tmpFile, JSON.stringify(existing))
158
+ fs.renameSync(tmpFile, jsonFile)
159
+
160
+ console.log(` ${brand}: saved to ${path.basename(jsonFile)}`)
161
+ }
162
+
163
+ async function main () {
164
+ const args = process.argv.slice(2)
165
+ const brands = args.length > 0 ? args : Object.keys(BRAND_JSON_FILES)
166
+
167
+ console.log('Emoji updater — CDN: emoji-datasource@' + CDN_VERSION + ' via jsdelivr')
168
+ console.log('Brands to update:', brands.join(', '))
169
+ console.log()
170
+
171
+ for (const brand of brands) {
172
+ if (!BRAND_JSON_FILES[brand]) {
173
+ console.error(`Unknown brand: ${brand}`)
174
+ continue
175
+ }
176
+ await updateBrand(brand)
177
+ }
178
+
179
+ console.log('\nDone.')
180
+ }
181
+
182
+ main().catch(err => {
183
+ console.error('Fatal error:', err)
184
+ process.exit(1)
185
+ })
test-attachments.js ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Numeric assertions for attachment rows and media badges.
2
+ // Run: node test-attachments.js → exits non-zero on any failed assertion.
3
+
4
+ const assert = require('assert')
5
+ const { createCanvas } = require('canvas')
6
+ const {
7
+ loadIcons, drawVoiceRow, drawDocumentRow, drawAudioRow,
8
+ formatDuration, formatFileSize, resampleWaveform, ROW
9
+ } = require('./utils/quote-generate/attachments')
10
+ const { drawQuote } = require('./utils/quote-generate/composer')
11
+ const { fontMetrics } = require('./utils/quote-generate/text-prepare')
12
+
13
+ const ACCENT = '#ffa357' // r=255 g=163 b=87
14
+
15
+ function px (canvas, x, y) {
16
+ const d = canvas.getContext('2d').getImageData(Math.round(x), Math.round(y), 1, 1).data
17
+ return { r: d[0], g: d[1], b: d[2], a: d[3] }
18
+ }
19
+
20
+ function isAccent (p) {
21
+ return Math.abs(p.r - 255) <= 8 && Math.abs(p.g - 163) <= 12 && Math.abs(p.b - 87) <= 12 && p.a > 200
22
+ }
23
+
24
+ async function main () {
25
+ const loaded = await loadIcons()
26
+ assert.ok(loaded && loaded.play && loaded.file && loaded.note, 'official icon sprites must rasterize')
27
+
28
+ // 1. Formatters — pure math.
29
+ assert.strictEqual(formatDuration(0), '0:00')
30
+ assert.strictEqual(formatDuration(65), '1:05')
31
+ assert.strictEqual(formatDuration(3661), '61:01')
32
+ assert.strictEqual(formatFileSize(999), '999 B')
33
+ assert.strictEqual(formatFileSize(2048), '2.0 KB')
34
+ assert.strictEqual(formatFileSize(5.3 * 1024 * 1024), '5.3 MB')
35
+ assert.strictEqual(formatFileSize(1.5 * 1024 * 1024 * 1024), '1.5 GB')
36
+
37
+ // 2. Waveform resampling: averages buckets, normalizes to 0..1.
38
+ assert.deepStrictEqual(resampleWaveform([31, 31, 0, 0], 2), [1, 0])
39
+ assert.strictEqual(resampleWaveform(Array(100).fill(31), 40).length, 40)
40
+
41
+ // 3. Voice row: height = disc, width within cap, disc center & bars accent.
42
+ const scale = 2
43
+ const maxW = 600
44
+ const wave = Array.from({ length: 50 }, (_, i) => (i * 11) % 32)
45
+ const voice = drawVoiceRow(wave, 42, ACCENT, '#fff', scale, maxW)
46
+ const d = ROW.disc * scale
47
+ assert.strictEqual(voice.height, d, `voice row height ${voice.height} != disc ${d}`)
48
+ assert.ok(voice.width <= maxW, `voice row ${voice.width} > cap ${maxW}`)
49
+ assert.ok(isAccent(px(voice, d / 2, d / 2 - d * 0.3)), 'play disc must be accent')
50
+ // First bar column: bars start at d+gap; min height 4·s ensures ink at center.
51
+ const barX = d + ROW.gap * scale + (ROW.bar * scale) / 2
52
+ assert.ok(isAccent(px(voice, barX, d / 2)), 'first waveform bar must be accent')
53
+
54
+ // 4. Document row height: max(disc, two metric text lines) — exact math.
55
+ const titleH = Math.max(1, Math.ceil(fontMetrics(ROW.title * scale).ascent + fontMetrics(ROW.title * scale).descent))
56
+ const metaH = Math.max(1, Math.ceil(fontMetrics(ROW.meta * scale).ascent + fontMetrics(ROW.meta * scale).descent))
57
+ const expRowH = Math.max(d, titleH + ROW.lineGap * scale + metaH)
58
+ const doc = drawDocumentRow({ file_name: 'звіт.pdf', file_size: 2.4 * 1024 * 1024 }, ACCENT, '#fff', scale, maxW)
59
+ assert.strictEqual(doc.height, expRowH, `doc row ${doc.height} != max(disc, texts) ${expRowH}`)
60
+ const docDisc = px(doc, d / 2, doc.height / 2)
61
+ assert.ok(docDisc.r > 240 && docDisc.g > 240 && docDisc.b > 240, 'page glyph must be white at disc center')
62
+ assert.ok(isAccent(px(doc, d / 2, (doc.height - d) / 2 + d * 0.08)), 'disc rim must be accent')
63
+
64
+ // 5. Audio row without thumb: accent note disc; with thumb: thumb pixels.
65
+ const audio = drawAudioRow({ title: 'Пісня', performer: 'Гурт', duration: 215 }, ACCENT, '#fff', scale, maxW)
66
+ assert.strictEqual(audio.height, expRowH, `audio row ${audio.height} != ${expRowH}`)
67
+ assert.ok(isAccent(px(audio, d * 0.15, audio.height / 2)), 'note disc must be accent')
68
+
69
+ const thumb = createCanvas(100, 100)
70
+ thumb.getContext('2d').fillStyle = '#00ff00'
71
+ thumb.getContext('2d').fillRect(0, 0, 100, 100)
72
+ const audioT = drawAudioRow({ title: 'X' }, ACCENT, '#fff', scale, maxW, thumb)
73
+ const cover = px(audioT, d / 2, audioT.height / 2)
74
+ assert.ok(cover.g > 200 && cover.r < 60, 'cover must show the thumb image')
75
+
76
+ // 6. Media badges through the composer (destination-space overlay).
77
+ // Red 600×300 media, maxSize 200 → drawn 200×100. Bubble is media-only:
78
+ // x = (50+10)·s? scale 1 → 60, y = shadowPadTop 4.
79
+ const red = createCanvas(600, 300)
80
+ red.getContext('2d').fillStyle = '#ff0000'
81
+ red.getContext('2d').fillRect(0, 0, 600, 300)
82
+ const q = drawQuote({
83
+ scale: 1,
84
+ background: { colorOne: '#1b1429', colorTwo: '#1b1429', textColor: '#fff' },
85
+ media: { canvas: red, type: 'video', maxSize: 200, badge: { play: true, label: '0:42' } }
86
+ })
87
+ const mx = 60
88
+ const my = 4
89
+ const cx = mx + 100
90
+ const cy = my + 50
91
+ // Center: white play triangle.
92
+ const center = px(q, cx, cy)
93
+ assert.ok(center.g > 180 && center.b > 180, `center must be white triangle, got ${JSON.stringify(center)}`)
94
+ // Inside the dark disc, left of the triangle: red dimmed by ~50% black.
95
+ const ring = px(q, cx - 14, cy)
96
+ assert.ok(ring.r > 100 && ring.r < 170 && ring.g < 60, `ring must be dimmed red, got ${JSON.stringify(ring)}`)
97
+ // Outside the disc: pure media.
98
+ const raw = px(q, mx + 30, cy)
99
+ assert.ok(raw.r > 240 && raw.g < 15, `media must stay untouched, got ${JSON.stringify(raw)}`)
100
+ // Duration chip bottom-left: dark backdrop over red.
101
+ const chip = px(q, mx + 6 + 4, my + 100 - 6 - 6)
102
+ assert.ok(chip.r < 200 && chip.g < 80, `chip backdrop missing, got ${JSON.stringify(chip)}`)
103
+
104
+ console.log('OK: attachment assertions passed')
105
+ console.log(` voice ${voice.width}x${voice.height}, doc ${doc.width}x${doc.height}, audio ${audio.width}x${audio.height}`)
106
+ console.log(` badge pixels: center=${JSON.stringify(center)} ring=${JSON.stringify(ring)}`)
107
+ }
108
+
109
+ main().catch((err) => {
110
+ console.error('FAIL:', err.message)
111
+ process.exit(1)
112
+ })
test-emoji-gif.js ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Assertions for the two rendering bugs:
2
+ // 1. Emoji in the user name must keep their own colors (gradientTint used to
3
+ // recolor them into flat silhouettes via source-in).
4
+ // 2. An animation that arrives as a raw video file (mp4, no thumbnail) must
5
+ // still produce a media canvas — first frame extracted via ffmpeg.
6
+ // Run: node test-emoji-gif.js → exits non-zero on any failed assertion.
7
+
8
+ const assert = require('assert')
9
+ const http = require('http')
10
+ const { execFileSync } = require('child_process')
11
+ const fs = require('fs')
12
+ const os = require('os')
13
+ const path = require('path')
14
+ const { loadImage, createCanvas } = require('canvas')
15
+ const generateMethod = require('./methods/generate')
16
+ const { downloadMediaImage } = require('./utils/quote-generate/media')
17
+
18
+ function pixels (img) {
19
+ const c = createCanvas(img.width, img.height)
20
+ const ctx = c.getContext('2d')
21
+ ctx.drawImage(img, 0, 0)
22
+ return ctx.getImageData(0, 0, img.width, img.height).data
23
+ }
24
+
25
+ // Count pixels matching a predicate over {r,g,b,a}
26
+ function countPx (data, fn) {
27
+ let n = 0
28
+ for (let i = 0; i < data.length; i += 4) {
29
+ if (fn({ r: data[i], g: data[i + 1], b: data[i + 2], a: data[i + 3] })) n++
30
+ }
31
+ return n
32
+ }
33
+
34
+ async function testNameEmojiKeepsColor () {
35
+ // Name with the Ukrainian flag: a correct render contains BOTH saturated
36
+ // blue and saturated yellow pixels. The tinted-silhouette bug leaves only
37
+ // the (pink-ish) name-color pixels.
38
+ const r = await generateMethod({
39
+ messages: [{
40
+ chatId: 125,
41
+ avatar: false,
42
+ from: { id: 125, name: 'Yūri 🇺🇦 💜', photo: {} },
43
+ text: 'x'
44
+ }],
45
+ scale: 2,
46
+ backgroundColor: '#1b1429'
47
+ })
48
+ assert.ok(!r.error, `generate failed: ${r.error}`)
49
+ const data = pixels(await loadImage(Buffer.from(r.image, 'base64')))
50
+
51
+ const blue = countPx(data, p => p.a > 200 && p.b > 120 && p.b - p.r > 60 && p.b - p.g > 40)
52
+ const yellow = countPx(data, p => p.a > 200 && p.r > 180 && p.g > 140 && p.r - p.b > 100)
53
+ assert.ok(blue > 30, `flag blue missing in name (got ${blue} px) — emoji recolored by gradientTint?`)
54
+ assert.ok(yellow > 30, `flag yellow missing in name (got ${yellow} px) — emoji recolored by gradientTint?`)
55
+ console.log(` name emoji: blue=${blue}px yellow=${yellow}px — colors preserved`)
56
+ }
57
+
58
+ async function testVideoBufferDecodesToFrame () {
59
+ let ffmpegOk = true
60
+ try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }) } catch (e) { ffmpegOk = false }
61
+ if (!ffmpegOk) {
62
+ console.log(' video frame: ffmpeg not installed — skipping')
63
+ return
64
+ }
65
+
66
+ // Solid-red 64x64 mp4, like a Telegram animation without thumbnail.
67
+ const tmp = path.join(os.tmpdir(), `quote-test-${process.pid}.mp4`)
68
+ execFileSync('ffmpeg', [
69
+ '-y', '-f', 'lavfi', '-i', 'color=c=red:s=64x64:d=0.2:r=5',
70
+ '-pix_fmt', 'yuv420p', tmp
71
+ ], { stdio: 'ignore' })
72
+ const mp4 = fs.readFileSync(tmp)
73
+
74
+ const server = http.createServer((req, res) => {
75
+ res.writeHead(200, { 'Content-Type': 'video/mp4' })
76
+ res.end(mp4)
77
+ })
78
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
79
+ const url = `http://127.0.0.1:${server.address().port}/anim.mp4`
80
+
81
+ try {
82
+ const img = await downloadMediaImage(url, 512, 'url', false, null)
83
+ assert.ok(img, 'downloadMediaImage returned null for an mp4 animation')
84
+ const data = pixels(img)
85
+ const red = countPx(data, p => p.a > 200 && p.r > 180 && p.g < 80 && p.b < 80)
86
+ assert.ok(red > 1000, `decoded frame is not the red video frame (red=${red}px)`)
87
+ console.log(` video frame: decoded ${img.width}x${img.height}, red=${red}px`)
88
+ } finally {
89
+ server.close()
90
+ fs.unlinkSync(tmp)
91
+ }
92
+ }
93
+
94
+ async function main () {
95
+ await testNameEmojiKeepsColor()
96
+ await testVideoBufferDecodesToFrame()
97
+ console.log('OK: emoji/gif assertions passed')
98
+ }
99
+
100
+ main().catch((err) => {
101
+ console.error('FAIL:', err.message)
102
+ process.exit(1)
103
+ })
test-fixes.js ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Numeric assertions for the group-avatar and media-scale fixes.
2
+ // Run: node test-fixes.js → exits non-zero on any failed assertion.
3
+
4
+ const assert = require('assert')
5
+ const { loadImage, createCanvas } = require('canvas')
6
+ const generateMethod = require('./methods/generate')
7
+ const QuoteGenerate = require('./utils/quote-generate')
8
+
9
+ // Count opaque pixels in the left avatar column (x < colW) of a PNG buffer.
10
+ function leftColumnInk (img, colW) {
11
+ const canvas = createCanvas(img.width, img.height)
12
+ const ctx = canvas.getContext('2d')
13
+ ctx.drawImage(img, 0, 0)
14
+ const data = ctx.getImageData(0, 0, colW, img.height).data
15
+ let n = 0
16
+ for (let i = 3; i < data.length; i += 4) if (data[i] > 32) n++
17
+ return n
18
+ }
19
+
20
+ async function main () {
21
+ const scale = 2
22
+ const msg = (chatId, text) => ({
23
+ chatId,
24
+ avatar: true,
25
+ from: { id: chatId, name: `User ${chatId}`, photo: {} },
26
+ text
27
+ })
28
+
29
+ // 1. Same-sender group renders ONE avatar (on the last bubble); the
30
+ // two-sender variant renders two. Avatar circles are solid disks of
31
+ // d = 50·scale, so the grouped left column must hold roughly half the
32
+ // opaque pixels of the ungrouped one (±25% for the tail overlap).
33
+ const grouped = await generateMethod({ messages: [msg(7, 'перше'), msg(7, 'друге')], scale })
34
+ const ungrouped = await generateMethod({ messages: [msg(7, 'перше'), msg(8, 'друге')], scale })
35
+ assert.ok(!grouped.error && !ungrouped.error, 'generate failed')
36
+
37
+ const colW = 50 * scale
38
+ const inkGrouped = leftColumnInk(await loadImage(Buffer.from(grouped.image, 'base64')), colW)
39
+ const inkUngrouped = leftColumnInk(await loadImage(Buffer.from(ungrouped.image, 'base64')), colW)
40
+ const ratio = inkGrouped / inkUngrouped
41
+ assert.ok(ratio > 0.35 && ratio < 0.65,
42
+ `grouped/ungrouped avatar ink ratio ${ratio.toFixed(2)} — expected ≈ 0.5 (one avatar of two)`)
43
+
44
+ // 2. Voice renders as an in-bubble attachment row capped at ⅔ of the
45
+ // target width. The bubble width is fully deterministic:
46
+ // (avatar 50 + gap 10)·s + (rowW + 2·padX 16·s) + shadowPad 12·s,
47
+ // where rowW comes from the same drawVoiceRow inputs.
48
+ const { drawVoiceRow } = require('./utils/quote-generate/attachments')
49
+ const { NAME_COLORS_DARK } = require('./utils/quote-generate/constants')
50
+ const waveform = Array.from({ length: 60 }, (_, i) => (i * 7) % 32)
51
+ const qg = new QuoteGenerate('0:x')
52
+ const voice = await qg.generate('#1b1429', '#1b1429', {
53
+ from: { id: 1, name: 'V' },
54
+ voice: { waveform, duration: 42 },
55
+ avatar: false
56
+ }, 512, 512, scale, 'apple')
57
+ // from.id=1 → NAME_COLORS_DARK[1]; dark background → textColor #fff
58
+ const row = drawVoiceRow(waveform, 42, NAME_COLORS_DARK[1], '#fff', scale, 512 * scale * 2 / 3)
59
+ assert.ok(row.width <= Math.ceil(512 * scale * 2 / 3), `voice row ${row.width} exceeds ⅔ cap`)
60
+ const expectedW = (50 + 10) * scale + (row.width + 2 * 16 * scale) + 12 * scale
61
+ assert.ok(Math.abs(voice.width - expectedW) <= 2,
62
+ `voice bubble width ${voice.width} != expected ${expectedW}`)
63
+
64
+ // 3. Image mode: the wallpaper must contrast with the bubble, not blend.
65
+ // Sample the wallpaper corner vs the bubble interior; perceived
66
+ // brightness (BT.601) must differ noticeably for dark AND light themes.
67
+ const brightness = (p) => (p.r * 299 + p.g * 587 + p.b * 114) / 1000
68
+ for (const [bgColor, label] of [['#252e44', 'dark'], ['#e8ecf3', 'light']]) {
69
+ const img = await generateMethod({ messages: [msg(7, 'контраст фону')], scale, type: 'image', backgroundColor: bgColor })
70
+ assert.ok(!img.error, 'image generate failed')
71
+ const pic = await loadImage(Buffer.from(img.image, 'base64'))
72
+ const c = createCanvas(pic.width, pic.height)
73
+ const cx = c.getContext('2d')
74
+ cx.drawImage(pic, 0, 0)
75
+ const get = (x, y) => {
76
+ const d = cx.getImageData(x, y, 1, 1).data
77
+ return { r: d[0], g: d[1], b: d[2] }
78
+ }
79
+ // Wallpaper near the corner; bubble interior right of the avatar column
80
+ // ((50+10+16)·s + margin, vertical center).
81
+ const wallPx = get(8, 8)
82
+ const wall = brightness(wallPx)
83
+ const bubble = brightness(get(95 * scale / 2 + (50 + 10 + 20) * scale, Math.round(pic.height / 2)))
84
+ assert.ok(Math.abs(bubble - wall) >= 18,
85
+ `${label}: bubble (${bubble.toFixed(0)}) blends into wallpaper (${wall.toFixed(0)})`)
86
+ // Light wallpapers must be pastel, not gray: require visible chroma.
87
+ if (label === 'light') {
88
+ const chroma = Math.max(wallPx.r, wallPx.g, wallPx.b) - Math.min(wallPx.r, wallPx.g, wallPx.b)
89
+ assert.ok(chroma >= 15, `light wallpaper is gray (chroma ${chroma}) — expected a pastel tint`)
90
+ }
91
+ console.log(` image/${label}: bubble ${bubble.toFixed(0)} vs wallpaper ${wall.toFixed(0)} (Δ${Math.abs(bubble - wall).toFixed(0)})`)
92
+ }
93
+
94
+ console.log('OK: fixes assertions passed')
95
+ console.log(` avatar ink ratio grouped/ungrouped = ${ratio.toFixed(3)} (≈0.5)`)
96
+ console.log(` voice bubble width = ${voice.width} (expected ${expectedW}, row ${row.width})`)
97
+ }
98
+
99
+ main().catch((err) => {
100
+ console.error('FAIL:', err.message)
101
+ process.exit(1)
102
+ })
test-metrics.js ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Numeric assertions for the metric text layout: line geometry must be a
2
+ // constant of the font (em box), never of the glyph shapes drawn.
3
+ // Run: node test-metrics.js → exits non-zero on any failed assertion.
4
+
5
+ const assert = require('assert')
6
+ const { createCanvas } = require('canvas')
7
+ const { drawMultilineText } = require('./utils/quote-generate/text-renderer')
8
+ const { drawLabel } = require('./utils/quote-generate/canvas-utils')
9
+ const { leaf } = require('./utils/quote-generate/layout-box')
10
+ const { fontMetrics } = require('./utils/quote-generate/text-prepare')
11
+
12
+ const FS = 24
13
+
14
+ async function main () {
15
+ const { ascent, descent } = fontMetrics(FS)
16
+ const lineHeight = FS * 1.2
17
+ const W = 2000
18
+ const H = 2000
19
+
20
+ // 1. Glyph shapes must not change the canvas height (the old ink-trim bug):
21
+ // no ascenders/descenders vs full ascenders/descenders/diacritics.
22
+ const low = await drawMultilineText('осе ссс еео', [], FS, '#fff', 0, FS, W, H, 'apple', null)
23
+ const tall = await drawMultilineText('Іфj Ďjq Ції', [], FS, '#fff', 0, FS, W, H, 'apple', null)
24
+ assert.strictEqual(low.height, tall.height,
25
+ `single line: ${low.height} (low ink) != ${tall.height} (tall ink)`)
26
+
27
+ // 2. Single line height is exactly the font em box.
28
+ assert.strictEqual(low.height, Math.max(1, Math.ceil(ascent + descent)),
29
+ `single line height ${low.height} != ceil(ascent+descent) ${Math.ceil(ascent + descent)}`)
30
+
31
+ // 3. n-line text: height = (n-1)*lineHeight + ascent + descent, regardless of glyphs.
32
+ for (const n of [2, 3, 5]) {
33
+ const lowText = Array(n).fill('ооо').join('\n')
34
+ const tallText = Array(n).fill('Іфj').join('\n')
35
+ const a = await drawMultilineText(lowText, [], FS, '#fff', 0, FS, W, H, 'apple', null)
36
+ const b = await drawMultilineText(tallText, [], FS, '#fff', 0, FS, W, H, 'apple', null)
37
+ const expected = Math.ceil((n - 1) * lineHeight + ascent + descent)
38
+ assert.strictEqual(a.height, expected, `${n} lines (low): ${a.height} != ${expected}`)
39
+ assert.strictEqual(b.height, expected, `${n} lines (tall): ${b.height} != ${expected}`)
40
+ }
41
+
42
+ // 4. leaf() takes canvases at face value (no ink scanning) and drops 1×1 stubs.
43
+ const l = leaf(low)
44
+ assert.strictEqual(l.h, low.height, 'leaf height must equal canvas height')
45
+ assert.strictEqual(l.srcY, 0, 'leaf must not crop the top')
46
+ const stub = await drawMultilineText('', [], FS, '#fff', 0, FS, W, H, 'apple', null)
47
+ assert.strictEqual(leaf(stub), null, 'empty-text stub must resolve to null leaf')
48
+
49
+ // 5. drawLabel: same metric rule at label sizes, glyph-independent.
50
+ const em13 = fontMetrics(13)
51
+ const lab1 = drawLabel('ооо', 13, '#fff')
52
+ const lab2 = drawLabel('Іфj', 13, '#fff')
53
+ assert.strictEqual(lab1.height, lab2.height, 'label heights must match')
54
+ assert.strictEqual(lab1.height, Math.max(1, Math.ceil(em13.ascent + em13.descent)),
55
+ `label height ${lab1.height} != em box ${Math.ceil(em13.ascent + em13.descent)}`)
56
+
57
+ // 6. The line box covers everything that gets drawn into it (no clipping):
58
+ // emoji images span [baseline−0.85·fs, baseline+0.30·fs]; probe glyph ink
59
+ // must fit too. Checked directly against the measured extents.
60
+ const probeCtx = createCanvas(1, 1).getContext('2d')
61
+ probeCtx.font = `${FS}px NotoSans`
62
+ const inkAsc = probeCtx.measureText('ẤÅЇĎ').actualBoundingBoxAscent
63
+ const inkDesc = probeCtx.measureText('jqyḑộ').actualBoundingBoxDescent
64
+ assert.ok(ascent >= FS * 0.85, `ascent ${ascent} < emoji top ${FS * 0.85}`)
65
+ assert.ok(descent >= FS * 0.3, `descent ${descent} < emoji bottom ${FS * 0.3}`)
66
+ assert.ok(ascent >= inkAsc, `ascent ${ascent} < probe ink ascent ${inkAsc}`)
67
+ assert.ok(descent >= inkDesc, `descent ${descent} < probe ink descent ${inkDesc}`)
68
+
69
+ // 7. End-to-end: two messages with identical structure but different ink
70
+ // extents must produce pixel-identical bubble dimensions.
71
+ const QuoteGenerate = require('./utils/quote-generate')
72
+ const qg = new QuoteGenerate('0:x')
73
+ const mk = (text) => qg.generate('#1b1429', '#1b1429', {
74
+ from: { id: 42, name: 'Тест Тестенко' },
75
+ text,
76
+ avatar: false
77
+ }, 512, 512, 2, 'apple')
78
+ const qLow = await mk('осе ссс еео')
79
+ const qTall = await mk('Іфj Ďjq Ції')
80
+ assert.strictEqual(qLow.height, qTall.height,
81
+ `bubble heights differ: ${qLow.height} vs ${qTall.height}`)
82
+
83
+ console.log('OK: metric layout assertions passed')
84
+ console.log(` e2e bubble: ${qLow.width}x${qLow.height} == ${qTall.width}x${qTall.height}`)
85
+ console.log(` font em box @${FS}px: ascent=${ascent} descent=${descent} lineHeight=${lineHeight}`)
86
+ console.log(` 1 line=${low.height}px, n lines=(n-1)*${lineHeight}+${ascent}+${descent}`)
87
+ }
88
+
89
+ main().catch((err) => {
90
+ console.error('FAIL:', err.message)
91
+ process.exit(1)
92
+ })
test-real.js ADDED
@@ -0,0 +1,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Real rendering test using actual drawMultilineText
2
+ // Run: node test-real.js
3
+
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const { createCanvas } = require('canvas')
7
+ const { drawQuote } = require('./utils/quote-generate/composer')
8
+ const { loadFonts, gradientTint } = require('./utils/quote-generate/index')
9
+ const { avatarImageLetters } = require('./utils/quote-generate/avatar')
10
+ const { drawAvatar } = require('./utils/quote-generate/avatar')
11
+ const { AVATAR_COLORS } = require('./utils/quote-generate/constants')
12
+ const { colorLuminance } = require('./utils/quote-generate/color')
13
+ const { loadIcons, drawVoiceRow, drawDocumentRow, drawAudioRow } = require('./utils/quote-generate/attachments')
14
+
15
+ const OUT = path.join(__dirname, 'test-output')
16
+ fs.mkdirSync(OUT, { recursive: true })
17
+
18
+ function makeAvatar (color1, color2, letter) {
19
+ const size = 100
20
+ const canvas = createCanvas(size, size)
21
+ const ctx = canvas.getContext('2d')
22
+ const grad = ctx.createLinearGradient(0, 0, size, size)
23
+ grad.addColorStop(0, color1)
24
+ grad.addColorStop(1, color2)
25
+ ctx.fillStyle = grad
26
+ ctx.beginPath()
27
+ ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2)
28
+ ctx.fill()
29
+ ctx.fillStyle = '#fff'
30
+ ctx.font = `bold ${size * 0.45}px sans-serif`
31
+ ctx.textAlign = 'center'
32
+ ctx.fillText(letter, size / 2, size / 2 + size * 0.16)
33
+ return canvas
34
+ }
35
+
36
+ async function main () {
37
+ await loadFonts()
38
+ await loadIcons()
39
+ const { drawMultilineText } = require('./utils/quote-generate/text-renderer')
40
+
41
+ const scale = 2
42
+ const width = 512 * scale
43
+ const height = 512 * scale
44
+ const tg = { getCustomEmojiStickers: async () => [] }
45
+
46
+ // Mirrors the production scale in utils/quote-generate/index.js
47
+ const nameSize = 18 * scale
48
+ const fontSize = 24 * scale
49
+ const replyNameSize = 14 * scale
50
+ const replyTextSize = 15 * scale
51
+
52
+ async function mkName (text, color) {
53
+ const canvas = await drawMultilineText(text, [{ type: 'bold', offset: 0, length: text.length }], nameSize, color, 0, nameSize, width, nameSize, 'apple', tg)
54
+ return gradientTint(canvas, color, colorLuminance(color, 0.25))
55
+ }
56
+ async function mkText (text, color) {
57
+ return drawMultilineText(text, [], fontSize, color || '#fff', 0, fontSize, width, height, 'apple', tg)
58
+ }
59
+ async function mkReplyName (text, color) {
60
+ return drawMultilineText(text, 'bold', replyNameSize, color, 0, replyNameSize, width * 0.9, replyNameSize, 'apple', tg)
61
+ }
62
+ async function mkReplyText (text, color) {
63
+ return drawMultilineText(text, [], replyTextSize, color || '#fff', 0, replyTextSize, width * 0.9, replyTextSize, 'apple', tg)
64
+ }
65
+
66
+ const bg = { colorOne: '#292232', colorTwo: '#292232', textColor: '#fff' }
67
+ const bgLight = { colorOne: '#e8e8e8', colorTwo: '#e8e8e8', textColor: '#000' }
68
+ const bgGrad = { colorOne: '#667eea', colorTwo: '#764ba2', textColor: '#fff' }
69
+
70
+ const tests = []
71
+
72
+ // 1. Basic dark
73
+ tests.push({
74
+ name: 'real-01-basic',
75
+ opts: {
76
+ scale, background: bg,
77
+ avatar: makeAvatar('#FF885E', '#FF516A', 'A'),
78
+ name: await mkName('assasinfil', '#FF8E86'),
79
+ text: await mkText('Переполнение вошло в чат'),
80
+ nameColor: '#FF8E86'
81
+ }
82
+ })
83
+
84
+ // 2. Basic light
85
+ tests.push({
86
+ name: 'real-02-light',
87
+ opts: {
88
+ scale, background: bgLight,
89
+ avatar: makeAvatar('#72D5FD', '#2A9EF1', 'N'),
90
+ name: await mkName('nowiks', '#3CA5EC'),
91
+ text: await mkText('Ну получить можно... не понял..', '#000'),
92
+ nameColor: '#3CA5EC'
93
+ }
94
+ })
95
+
96
+ // Debug reply canvas sizes
97
+ const dbgReplyName = await mkReplyName('Виталий', '#B18FFF')
98
+ const dbgReplyText = await mkReplyText('не понял..')
99
+ console.log(`Reply name canvas: ${dbgReplyName.width}x${dbgReplyName.height}`)
100
+ console.log(`Reply text canvas: ${dbgReplyText.width}x${dbgReplyText.height}`)
101
+
102
+ // 3. Reply
103
+ tests.push({
104
+ name: 'real-03-reply',
105
+ opts: {
106
+ scale, background: { colorOne: '#1a1a2e', colorTwo: '#16213e', textColor: '#fff' },
107
+ avatar: makeAvatar('#FF885E', '#FF516A', 'A'),
108
+ reply: {
109
+ name: await mkReplyName('Виталий', '#B18FFF'),
110
+ nameColor: '#B18FFF',
111
+ text: await mkReplyText('не понял..')
112
+ },
113
+ name: await mkName('assasinfil', '#FF8E86'),
114
+ text: await mkText('Переполнение вошло в чат'),
115
+ nameColor: '#FF8E86'
116
+ }
117
+ })
118
+
119
+ // 4. No avatar, no name (continuation)
120
+ tests.push({
121
+ name: 'real-04-noname',
122
+ opts: {
123
+ scale, background: bg,
124
+ avatar: null, name: null,
125
+ text: await mkText('понял..')
126
+ }
127
+ })
128
+
129
+ // 5. Gradient bg
130
+ tests.push({
131
+ name: 'real-05-gradient',
132
+ opts: {
133
+ scale, background: bgGrad,
134
+ avatar: makeAvatar('#FFCD6A', '#FFA85C', 'Y'),
135
+ name: await mkName('Yuri', '#FFCD6A'),
136
+ text: await mkText('This is a gradient background test with a longer message that wraps across multiple lines'),
137
+ nameColor: '#FFCD6A'
138
+ }
139
+ })
140
+
141
+ // 6. Forward
142
+ tests.push({
143
+ name: 'real-06-forward',
144
+ opts: {
145
+ scale, background: bg,
146
+ avatar: makeAvatar('#A0DE7E', '#54CB68', 'P'),
147
+ name: await mkName('Pavel Durov', '#4DD6BF'),
148
+ text: await mkText('Telegram is the most secure messenger in the world.'),
149
+ isForward: true,
150
+ forwardLabel: 'Forwarded from Durov\'s Channel',
151
+ nameColor: '#4DD6BF'
152
+ }
153
+ })
154
+
155
+ // 7. Forward + reply
156
+ tests.push({
157
+ name: 'real-07-fwd-reply',
158
+ opts: {
159
+ scale, background: { colorOne: '#1e1e2e', colorTwo: '#1e1e2e', textColor: '#fff' },
160
+ avatar: makeAvatar('#53EDD6', '#28C9B7', 'M'),
161
+ reply: {
162
+ name: await mkReplyName('Alice', '#7AC9FF'),
163
+ nameColor: '#7AC9FF',
164
+ text: await mkReplyText('What did he say?')
165
+ },
166
+ name: await mkName('Mike', '#45E8D1'),
167
+ text: await mkText('He said something very interesting about the project roadmap'),
168
+ isForward: true,
169
+ forwardLabel: 'Forwarded from Project Updates',
170
+ nameColor: '#45E8D1'
171
+ }
172
+ })
173
+
174
+ // 8. Sender tag
175
+ tests.push({
176
+ name: 'real-08-tag',
177
+ opts: {
178
+ scale, background: bg,
179
+ avatar: makeAvatar('#FF885E', '#FF516A', 'A'),
180
+ name: await mkName('Admin User', '#FF8E86'),
181
+ text: await mkText('Welcome to the group! Please read the rules.'),
182
+ nameColor: '#FF8E86',
183
+ senderTag: 'Admin'
184
+ }
185
+ })
186
+
187
+ // 9. Tag + forward
188
+ tests.push({
189
+ name: 'real-09-tag-fwd',
190
+ opts: {
191
+ scale, background: { colorOne: '#0d1117', colorTwo: '#161b22', textColor: '#e6edf3' },
192
+ avatar: makeAvatar('#FFA8A8', '#FF719A', 'C'),
193
+ name: await mkName('Channel Bot', '#FF7FD5'),
194
+ text: await mkText('Important announcement: server maintenance scheduled for tonight at 3 AM UTC'),
195
+ isForward: true,
196
+ forwardLabel: 'Forwarded from System Alerts',
197
+ nameColor: '#FF7FD5',
198
+ senderTag: 'NFC/SubGHz Dev'
199
+ }
200
+ })
201
+
202
+ // 10. Long text with reply
203
+ tests.push({
204
+ name: 'real-10-long-reply',
205
+ opts: {
206
+ scale, background: { colorOne: '#2d2d44', colorTwo: '#2d2d44', textColor: '#fff' },
207
+ avatar: makeAvatar('#72D5FD', '#2A9EF1', 'L'),
208
+ reply: {
209
+ name: await mkReplyName('Previous User', '#3CA5EC'),
210
+ nameColor: '#3CA5EC',
211
+ text: await mkReplyText('Can you explain?')
212
+ },
213
+ name: await mkName('Long Text User', '#7AC9FF'),
214
+ text: await mkText('So basically what happens is that the system processes the input through several stages. First it validates the data, then transforms it, and finally persists it to the database.'),
215
+ nameColor: '#7AC9FF'
216
+ }
217
+ })
218
+
219
+ // 11. Short text
220
+ tests.push({
221
+ name: 'real-11-short',
222
+ opts: {
223
+ scale, background: bg,
224
+ avatar: makeAvatar('#FFCD6A', '#FFA85C', 'S'),
225
+ name: await mkName('User', '#FFA357'),
226
+ text: await mkText('ok'),
227
+ nameColor: '#FFA357'
228
+ }
229
+ })
230
+
231
+ // 12. Long name + tag
232
+ tests.push({
233
+ name: 'real-12-longname-tag',
234
+ opts: {
235
+ scale, background: bg,
236
+ avatar: makeAvatar('#E0A2F3', '#D669ED', 'X'),
237
+ name: await mkName('Alexander Superlong Name', '#B18FFF'),
238
+ text: await mkText('Testing a very long name with a tag'),
239
+ nameColor: '#B18FFF',
240
+ senderTag: 'Lead Developer & Architect'
241
+ }
242
+ })
243
+
244
+ // 13. Partial quote (isQuote)
245
+ tests.push({
246
+ name: 'real-13-quote',
247
+ opts: {
248
+ scale, background: bg,
249
+ avatar: makeAvatar('#72D5FD', '#2A9EF1', 'Q'),
250
+ reply: {
251
+ name: await mkReplyName('Original Author', '#7AC9FF'),
252
+ nameColor: '#7AC9FF',
253
+ text: await mkReplyText('only this part was quoted')
254
+ },
255
+ name: await mkName('Quoter', '#45E8D1'),
256
+ text: await mkText('Exactly, this part is the key insight'),
257
+ nameColor: '#45E8D1',
258
+ isQuote: true
259
+ }
260
+ })
261
+
262
+ // 14. Reply no avatar
263
+ tests.push({
264
+ name: 'real-14-reply-noavatar',
265
+ opts: {
266
+ scale, background: bg,
267
+ avatar: null,
268
+ reply: {
269
+ name: await mkReplyName('Someone', '#FF8E86'),
270
+ nameColor: '#FF8E86',
271
+ text: await mkReplyText('Original message text here')
272
+ },
273
+ name: null,
274
+ text: await mkText('Reply to that')
275
+ }
276
+ })
277
+
278
+ // Helper: make circular avatar from avatarImageLetters (same as drawAvatar does)
279
+ function makeRealAvatar (letters, colorIndex) {
280
+ const avatarCanvas = avatarImageLetters(letters, AVATAR_COLORS[colorIndex % 7])
281
+ const size = avatarCanvas.height
282
+ const circle = createCanvas(size, size)
283
+ const cctx = circle.getContext('2d')
284
+ cctx.beginPath()
285
+ cctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2, true)
286
+ cctx.clip()
287
+ cctx.drawImage(avatarCanvas, 0, 0, size, size)
288
+ return circle
289
+ }
290
+
291
+ // 16. Initials avatar — two letters
292
+ tests.push({
293
+ name: 'real-16-initials-2',
294
+ opts: {
295
+ scale, background: bg,
296
+ avatar: makeRealAvatar('YL', 3),
297
+ name: await mkName('Yuri Ly', '#4DD6BF'),
298
+ text: await mkText('Testing two-letter initials avatar'),
299
+ nameColor: '#4DD6BF'
300
+ }
301
+ })
302
+
303
+ // 17. Initials avatar — one letter
304
+ tests.push({
305
+ name: 'real-17-initials-1',
306
+ opts: {
307
+ scale, background: bg,
308
+ avatar: makeRealAvatar('А', 5),
309
+ name: await mkName('Антон', '#B18FFF'),
310
+ text: await mkText('Тестуємо аватарку з одною літерою'),
311
+ nameColor: '#B18FFF'
312
+ }
313
+ })
314
+
315
+ // 18. Initials avatar — Cyrillic two letters
316
+ tests.push({
317
+ name: 'real-18-initials-cyrillic',
318
+ opts: {
319
+ scale, background: bgLight,
320
+ avatar: makeRealAvatar('ВН', 2),
321
+ name: await mkName('Виктор Никитчук', '#3CA5EC'),
322
+ text: await mkText('Кириллические буквы в аватарке'),
323
+ nameColor: '#3CA5EC'
324
+ }
325
+ })
326
+
327
+ // 19. Standalone avatar renders (for direct comparison)
328
+ const avatarTests = [
329
+ { letters: 'YL', idx: 0 },
330
+ { letters: 'А', idx: 1 },
331
+ { letters: 'ВН', idx: 2 },
332
+ { letters: 'JD', idx: 3 },
333
+ { letters: 'М', idx: 4 },
334
+ { letters: '?', idx: 5 },
335
+ { letters: 'PD', idx: 6 }
336
+ ]
337
+ const avSize = 200
338
+ const avPad = 20
339
+ const avRow = createCanvas(avatarTests.length * (avSize + avPad), avSize)
340
+ const avCtx = avRow.getContext('2d')
341
+ for (let i = 0; i < avatarTests.length; i++) {
342
+ const av = makeRealAvatar(avatarTests[i].letters, avatarTests[i].idx)
343
+ avCtx.drawImage(av, i * (avSize + avPad), 0, avSize, avSize)
344
+ }
345
+ fs.writeFileSync(path.join(OUT, 'real-19-avatars-row.png'), avRow.toBuffer('image/png'))
346
+ console.log(` ✓ real-19-avatars-row (${avRow.width}x${avRow.height})`)
347
+
348
+ // 20. Voice message
349
+ tests.push({
350
+ name: 'real-20-voice',
351
+ opts: {
352
+ scale, background: bg,
353
+ avatar: makeAvatar('#FF885E', '#FF516A', 'A'),
354
+ name: await mkName('assasinfil', '#FF8E86'),
355
+ attachment: { canvas: drawVoiceRow(Array.from({ length: 50 }, (_, i) => 6 + ((i * 13) % 26), 0), 83, '#FF8E86', '#fff', scale, width * 2 / 3) },
356
+ nameColor: '#FF8E86'
357
+ }
358
+ })
359
+
360
+ // 21. Document
361
+ tests.push({
362
+ name: 'real-21-document',
363
+ opts: {
364
+ scale, background: bg,
365
+ avatar: makeAvatar('#72D5FD', '#2A9EF1', 'N'),
366
+ name: await mkName('nowiks', '#7AC9FF'),
367
+ attachment: { canvas: drawDocumentRow({ file_name: 'квартальний_звіт_2026.pdf', file_size: 2.4 * 1024 * 1024 }, '#7AC9FF', '#fff', scale, width * 2 / 3) },
368
+ nameColor: '#7AC9FF'
369
+ }
370
+ })
371
+
372
+ // 22. Audio (no cover → note disc)
373
+ tests.push({
374
+ name: 'real-22-audio',
375
+ opts: {
376
+ scale, background: bg,
377
+ avatar: makeAvatar('#A0DE7E', '#54CB68', 'M'),
378
+ name: await mkName('Музика', '#4DD6BF'),
379
+ attachment: { canvas: drawAudioRow({ title: 'Водограй', performer: 'Володимир Івасюк', duration: 215 }, '#4DD6BF', '#fff', scale, width * 2 / 3) },
380
+ nameColor: '#4DD6BF'
381
+ }
382
+ })
383
+
384
+ // 23. Video with play badge + duration, plus caption
385
+ const videoFrame = createCanvas(800, 450)
386
+ {
387
+ const vctx = videoFrame.getContext('2d')
388
+ const grad = vctx.createLinearGradient(0, 0, 800, 450)
389
+ grad.addColorStop(0, '#2b5876')
390
+ grad.addColorStop(1, '#4e4376')
391
+ vctx.fillStyle = grad
392
+ vctx.fillRect(0, 0, 800, 450)
393
+ }
394
+ tests.push({
395
+ name: 'real-23-video',
396
+ opts: {
397
+ scale, background: bg,
398
+ avatar: makeAvatar('#FFCD6A', '#FFA85C', 'V'),
399
+ name: await mkName('Відеограф', '#FFA357'),
400
+ media: { canvas: videoFrame, type: 'video', maxSize: width * 2 / 3, badge: { play: true, label: '0:42' } },
401
+ text: await mkText('подивіться до кінця 🔥'),
402
+ nameColor: '#FFA357'
403
+ }
404
+ })
405
+
406
+ // 24. GIF (no caption → flush media with GIF chip)
407
+ const gifFrame = createCanvas(640, 360)
408
+ {
409
+ const gctx = gifFrame.getContext('2d')
410
+ const grad = gctx.createLinearGradient(0, 360, 640, 0)
411
+ grad.addColorStop(0, '#f83600')
412
+ grad.addColorStop(1, '#f9d423')
413
+ gctx.fillStyle = grad
414
+ gctx.fillRect(0, 0, 640, 360)
415
+ }
416
+ tests.push({
417
+ name: 'real-24-gif',
418
+ opts: {
419
+ scale, background: bg,
420
+ avatar: makeAvatar('#E0A2F3', '#D669ED', 'G'),
421
+ name: await mkName('Гіфка', '#B18FFF'),
422
+ media: { canvas: gifFrame, type: 'animation', maxSize: width * 2 / 3, badge: { label: 'GIF' } },
423
+ nameColor: '#B18FFF'
424
+ }
425
+ })
426
+
427
+ // Generate single tests
428
+ for (const test of tests) {
429
+ try {
430
+ const canvas = drawQuote(test.opts)
431
+ fs.writeFileSync(path.join(OUT, `${test.name}.png`), canvas.toBuffer('image/png'))
432
+ console.log(` ✓ ${test.name} (${canvas.width}x${canvas.height})`)
433
+ } catch (err) {
434
+ console.error(` ✗ ${test.name}: ${err.message}`)
435
+ }
436
+ }
437
+
438
+ // 15. MULTI-MESSAGE DIALOG
439
+ const dialogOpts = [
440
+ {
441
+ scale, background: bg,
442
+ avatar: null, // avatar rides on the LAST message of a streak
443
+ name: await mkName('assasinfil', '#FF8E86'),
444
+ text: await mkText('Хто знає як зробити NFC клон?'),
445
+ nameColor: '#FF8E86',
446
+ senderTag: 'NFC/SubGHz Dev',
447
+ groupPos: 'first'
448
+ },
449
+ {
450
+ scale, background: bg,
451
+ avatar: makeAvatar('#FF885E', '#FF516A', 'A'),
452
+ name: null,
453
+ text: await mkText('Треба Flipper Zero для цього'),
454
+ groupPos: 'last'
455
+ },
456
+ {
457
+ scale, background: bg,
458
+ avatar: makeAvatar('#72D5FD', '#2A9EF1', 'N'),
459
+ reply: {
460
+ name: await mkReplyName('assasinfil', '#FF8E86'),
461
+ nameColor: '#FF8E86',
462
+ text: await mkReplyText('Хто знає як зробити NFC клон?')
463
+ },
464
+ name: await mkName('nowiks', '#7AC9FF'),
465
+ text: await mkText('Ну получить можно... не понял..'),
466
+ nameColor: '#7AC9FF',
467
+ isQuote: true
468
+ },
469
+ {
470
+ scale, background: bg,
471
+ avatar: makeAvatar('#E0A2F3', '#D669ED', 'В'),
472
+ name: await mkName('Виктор Никитчук', '#B18FFF'),
473
+ text: await mkText('Звучит очень по гейски'),
474
+ isForward: true,
475
+ forwardLabel: 'Forwarded from Misha Myte',
476
+ nameColor: '#B18FFF'
477
+ },
478
+ {
479
+ scale, background: bg,
480
+ avatar: makeAvatar('#FF885E', '#FF516A', 'A'),
481
+ name: await mkName('assasinfil', '#FF8E86'),
482
+ text: await mkText('Переполнение вошло в чат'),
483
+ nameColor: '#FF8E86',
484
+ senderTag: 'NFC/SubGHz Dev'
485
+ },
486
+ {
487
+ scale, background: bg,
488
+ avatar: makeAvatar('#72D5FD', '#2A9EF1', 'N'),
489
+ name: await mkName('nowiks', '#7AC9FF'),
490
+ text: await mkText('понял..'),
491
+ nameColor: '#7AC9FF'
492
+ }
493
+ ]
494
+
495
+ const quoteMargin = 5 * scale
496
+ const dialogCanvases = dialogOpts.map(o => drawQuote(o))
497
+ let totalW = 0, totalH = 0
498
+ for (const c of dialogCanvases) {
499
+ if (c.width > totalW) totalW = c.width
500
+ totalH += c.height + quoteMargin
501
+ }
502
+ const dialogCanvas = createCanvas(totalW, totalH)
503
+ const dctx = dialogCanvas.getContext('2d')
504
+ let y = 0
505
+ for (const c of dialogCanvases) {
506
+ dctx.drawImage(c, 0, y)
507
+ y += c.height + quoteMargin
508
+ }
509
+ fs.writeFileSync(path.join(OUT, 'real-15-dialog.png'), dialogCanvas.toBuffer('image/png'))
510
+ console.log(` ✓ real-15-dialog (${dialogCanvas.width}x${dialogCanvas.height})`)
511
+
512
+ console.log('\nDone!')
513
+ }
514
+
515
+ main().catch(console.error)
test-style.js ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Numeric assertions for the glass bubble style — pixel samples and
2
+ // geometry math, no eyeballing.
3
+ // Run: node test-style.js → exits non-zero on any failed assertion.
4
+
5
+ const assert = require('assert')
6
+ const { drawRoundRect } = require('./utils/quote-generate/canvas-utils')
7
+ const { SP } = require('./utils/quote-generate/composer')
8
+
9
+ function px (canvas, x, y) {
10
+ const d = canvas.getContext('2d').getImageData(x, y, 1, 1).data
11
+ return { r: d[0], g: d[1], b: d[2], a: d[3] }
12
+ }
13
+
14
+ // Fill #222b3c → r=34. Glass adds white on top edge and a hairline border.
15
+ const FILL = { r: 34, g: 43, b: 60 }
16
+ const W = 200
17
+ const H = 100
18
+ const c = drawRoundRect('#222b3c', W, H, 20, 0, 1.25)
19
+
20
+ // 1. Middle of the bubble is the pure fill (glass must not wash the body).
21
+ const mid = px(c, W / 2, H / 2)
22
+ assert.strictEqual(mid.r, FILL.r, `body tinted: r=${mid.r} != ${FILL.r}`)
23
+
24
+ // 2. Top edge is brighter than the body (top highlight, alpha ≈ .16+.07).
25
+ const top = px(c, W / 2, 1)
26
+ assert.ok(top.r > mid.r + 15, `top edge not highlighted: r=${top.r} vs body ${mid.r}`)
27
+
28
+ // 3. Side edge carries the hairline border. Math: inner stroke is
29
+ // glassLw 1.25px wide from x=0, so pixel column 0 is fully covered:
30
+ // expected r ≈ 34 + (255−34)·0.07 ≈ 49.
31
+ const side = px(c, 0, H / 2)
32
+ assert.ok(side.r > mid.r + 8, `side border missing: r=${side.r} vs body ${mid.r}`)
33
+
34
+ // 4. Highlight fades: at 70% height the edge pixel is border-only (≈ side).
35
+ const lower = px(c, 0, Math.round(H * 0.7))
36
+ assert.ok(Math.abs(lower.r - side.r) <= 6,
37
+ `highlight must fade out by 40% height: lower-edge r=${lower.r}, side r=${side.r}`)
38
+
39
+ // 5. Without glassLw the canvas is untouched fill (back-compat for chips).
40
+ const plain = drawRoundRect('#222b3c', W, H, 20, 0)
41
+ assert.strictEqual(px(plain, W / 2, 1).r, FILL.r, 'plain rect must have no glass')
42
+
43
+ // 6. Shadow headroom math: blur spills (blur − offsetY) above the bubble;
44
+ // the top margin must cover it. (composer: blur 6, offsetY 2 — logical px)
45
+ assert.ok(SP.shadowPadTop >= 6 - 2,
46
+ `shadowPadTop ${SP.shadowPadTop} < blur−offset 4 — shadow clips at the top`)
47
+ assert.ok(SP.shadowPad >= 6 + 2,
48
+ `shadowPad ${SP.shadowPad} < blur+offset 8 — shadow clips at the bottom`)
49
+
50
+ console.log('OK: glass style assertions passed')
51
+ console.log(` body r=${mid.r}, top r=${top.r}, side r=${side.r}, lower r=${lower.r}`)
utils/emoji-db.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ const EmojiDbLib = require('emoji-db')
2
+
3
+ module.exports = new EmojiDbLib({ useDefaultDb: true })
utils/emoji-image.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const path = require('path')
2
+ const fs = require('fs')
3
+ const emojiDb = require('./emoji-db')
4
+
5
+ const emojiJFilesDir = '../assets/emoji/'
6
+
7
+ const emojiJsonByBrand = {
8
+ apple: 'emoji-apple-image.json',
9
+ google: 'emoji-google-image.json',
10
+ twitter: 'emoji-twitter-image.json',
11
+ joypixels: 'emoji-joypixels-image.json',
12
+ blob: 'emoji-blob-image.json'
13
+ }
14
+
15
+ // Lazy-loaded emoji data — only apple is loaded eagerly (default brand)
16
+ const emojiImageByBrand = {}
17
+
18
+ function loadBrand (brand) {
19
+ if (emojiImageByBrand[brand]) return emojiImageByBrand[brand]
20
+
21
+ const jsonFile = emojiJsonByBrand[brand]
22
+ if (!jsonFile) return {}
23
+
24
+ const filePath = path.resolve(__dirname, emojiJFilesDir + jsonFile)
25
+
26
+ try {
27
+ if (fs.existsSync(filePath)) {
28
+ emojiImageByBrand[brand] = require(filePath)
29
+ } else {
30
+ emojiImageByBrand[brand] = {}
31
+ }
32
+ } catch (error) {
33
+ console.error('Failed to load emoji brand', brand, error.message)
34
+ emojiImageByBrand[brand] = {}
35
+ }
36
+
37
+ return emojiImageByBrand[brand]
38
+ }
39
+
40
+ // Eager-load apple (default brand) at startup
41
+ loadBrand('apple')
42
+
43
+ module.exports = { loadBrand, brands: emojiJsonByBrand }
utils/image-load-path.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs')
2
+
3
+ module.exports = (filePath) => {
4
+ return new Promise((resolve, reject) => {
5
+ fs.readFile(filePath, (error, data) => {
6
+ if (error) reject(error)
7
+ else resolve(data)
8
+ })
9
+ })
10
+ }
utils/image-load-url.js ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const https = require('https')
2
+ const http = require('http')
3
+
4
+ const REQUEST_TIMEOUT_MS = 10_000
5
+ const MAX_RESPONSE_BYTES = 20 * 1024 * 1024
6
+ const MAX_REDIRECTS = 5
7
+
8
+ function doRequest (url, filter, redirectCount) {
9
+ return new Promise((resolve, reject) => {
10
+ const parsed = new URL(url)
11
+ const transport = parsed.protocol === 'https:' ? https : http
12
+
13
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
14
+ return reject(new Error(`Unsupported protocol ${parsed.protocol} for ${url}`))
15
+ }
16
+
17
+ const options = {
18
+ hostname: parsed.hostname,
19
+ port: parsed.port,
20
+ path: parsed.pathname + parsed.search,
21
+ headers: { 'User-Agent': 'curl/8.4.0' }
22
+ }
23
+
24
+ const req = transport.get(options, (res) => {
25
+ // Follow redirects (301, 302, 307, 308)
26
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
27
+ res.resume()
28
+ if (redirectCount >= MAX_REDIRECTS) {
29
+ return reject(new Error(`Too many redirects (${MAX_REDIRECTS}) for ${url}`))
30
+ }
31
+ const redirectUrl = new URL(res.headers.location, url).href
32
+ return resolve(doRequest(redirectUrl, filter, redirectCount + 1))
33
+ }
34
+
35
+ if (res.statusCode < 200 || res.statusCode >= 300) {
36
+ res.resume()
37
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`))
38
+ }
39
+
40
+ if (filter && filter(res.headers)) {
41
+ res.resume()
42
+ return resolve(Buffer.concat([]))
43
+ }
44
+
45
+ const chunks = []
46
+ let totalBytes = 0
47
+
48
+ res.on('error', (err) => reject(err))
49
+ res.on('data', (chunk) => {
50
+ totalBytes += chunk.length
51
+ if (totalBytes > MAX_RESPONSE_BYTES) {
52
+ req.destroy()
53
+ res.destroy()
54
+ return reject(new Error(`Response exceeded ${MAX_RESPONSE_BYTES} bytes for ${url}`))
55
+ }
56
+ chunks.push(chunk)
57
+ })
58
+ res.on('end', () => resolve(Buffer.concat(chunks)))
59
+ })
60
+
61
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => {
62
+ req.destroy()
63
+ reject(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms for ${url}`))
64
+ })
65
+
66
+ req.on('error', (err) => reject(err))
67
+ })
68
+ }
69
+
70
+ module.exports = (url, filter = false) => doRequest(url, filter, 0)
utils/index.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ const QuoteGenerate = require('./quote-generate/index')
2
+
3
+ module.exports = {
4
+ QuoteGenerate,
5
+ loadFonts: QuoteGenerate.loadFonts,
6
+ loadImageFromUrl: require('./image-load-url'),
7
+ loadImageFromPath: require('./image-load-path'),
8
+ promiseAllStepN: require('./promise-concurrent'),
9
+ userName: require('./user-name')
10
+ }
utils/promise-concurrent.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function promiseAllStepN (n, list) {
2
+ let tail = list.splice(n)
3
+ let head = list
4
+ let resolved = []
5
+ let processed = 0
6
+ return new Promise(resolve => {
7
+ head.forEach(x => {
8
+ let res = x()
9
+ resolved.push(res)
10
+ res.then(y => {
11
+ runNext()
12
+ return y
13
+ })
14
+ })
15
+ function runNext () {
16
+ if (processed == tail.length) {
17
+ resolve(Promise.all(resolved))
18
+ } else {
19
+ resolved.push(tail[processed]().then(x => {
20
+ runNext()
21
+ return x
22
+ }))
23
+ processed++
24
+ }
25
+ }
26
+ })
27
+ }
28
+
29
+ module.exports = n => list => promiseAllStepN(n, list)
utils/quote-generate/attachments.js ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // utils/quote-generate/attachments.js
2
+ //
3
+ // Row-style attachments rendered inside the bubble (voice, document, audio)
4
+ // and overlay badges for media (video play button, duration/GIF chips).
5
+ // All sizes below are logical px — multiplied by `scale` at use.
6
+
7
+ const fs = require('fs')
8
+ const path = require('path')
9
+ const { createCanvas, loadImage } = require('canvas')
10
+ const sharp = require('sharp')
11
+ const { drawLabel } = require('./canvas-utils')
12
+
13
+ // Official Material Design icons (Apache-2.0), vendored as-is from
14
+ // @material-design-icons/svg into assets/icons/. Rasterized once at 256px
15
+ // white via sharp and drawn scaled; the geometric fallbacks below only kick
16
+ // in if SVG rasterization is unavailable.
17
+ const ICON_FILES = {
18
+ play: 'play_arrow.svg',
19
+ file: 'insert_drive_file.svg',
20
+ note: 'music_note.svg'
21
+ }
22
+ const ICONS_DIR = path.resolve(__dirname, '../../assets/icons')
23
+
24
+ let icons = null
25
+ let iconsLoading = null
26
+
27
+ // Warm the white icon sprites (256px). Call (and await) once before any
28
+ // drawVoiceRow/drawDocumentRow/drawAudioRow/paintMediaBadges usage.
29
+ async function loadIcons () {
30
+ if (icons) return icons
31
+ if (!iconsLoading) {
32
+ iconsLoading = (async () => {
33
+ const out = {}
34
+ for (const [key, file] of Object.entries(ICON_FILES)) {
35
+ const svg = await fs.promises.readFile(path.join(ICONS_DIR, file), 'utf8')
36
+ // The vendored icons carry no fill (default black) — paint them white.
37
+ const white = svg.replace('<svg ', '<svg fill="#ffffff" ')
38
+ out[key] = await loadImage(
39
+ await sharp(Buffer.from(white), { density: 256 / 24 * 72 }).resize(256, 256).png().toBuffer()
40
+ )
41
+ }
42
+ icons = out
43
+ return icons
44
+ })().catch((err) => {
45
+ console.warn('Icon rasterization failed, using geometric fallbacks:', err.message)
46
+ iconsLoading = null
47
+ return null
48
+ })
49
+ }
50
+ return iconsLoading
51
+ }
52
+
53
+ // Draws a warmed white icon centered in a box, or runs the fallback painter.
54
+ function paintIcon (ctx, name, x, y, size, fallback) {
55
+ if (icons && icons[name]) {
56
+ ctx.imageSmoothingEnabled = true
57
+ ctx.imageSmoothingQuality = 'high'
58
+ ctx.drawImage(icons[name], x, y, size, size)
59
+ } else {
60
+ fallback()
61
+ }
62
+ }
63
+
64
+ const ROW = {
65
+ disc: 40, // play/file/cover disc side
66
+ gap: 11, // disc → texts/waveform
67
+ title: 16, // first line (file name, track title)
68
+ meta: 13, // second line (size, performer, duration)
69
+ metaAlpha: 0.55,
70
+ lineGap: 4, // between the two text lines
71
+ bar: 3, // waveform bar width
72
+ barGap: 2.5, // waveform bar pitch gap
73
+ barMin: 4, // shortest bar
74
+ barMax: 26, // tallest bar
75
+ cover: 8 // audio cover corner radius
76
+ }
77
+
78
+ // m:ss (Telegram never shows hours on voice/audio chips)
79
+ function formatDuration (seconds) {
80
+ const total = Math.max(0, Math.round(Number(seconds) || 0))
81
+ const m = Math.floor(total / 60)
82
+ const s = total % 60
83
+ return `${m}:${String(s).padStart(2, '0')}`
84
+ }
85
+
86
+ // Binary units, one decimal from KB up: 999 B, 2.0 KB, 5.3 MB, 1.2 GB
87
+ function formatFileSize (bytes) {
88
+ const b = Math.max(0, Number(bytes) || 0)
89
+ if (b < 1024) return `${Math.round(b)} B`
90
+ const units = ['KB', 'MB', 'GB']
91
+ let v = b
92
+ let u = -1
93
+ do {
94
+ v /= 1024
95
+ u++
96
+ } while (v >= 1024 && u < units.length - 1)
97
+ return `${v.toFixed(1)} ${units[u]}`
98
+ }
99
+
100
+ // Solid disc with a white play icon — the voice row lead-in.
101
+ function drawPlayDisc (d, accent) {
102
+ const canvas = createCanvas(d, d)
103
+ const ctx = canvas.getContext('2d')
104
+ ctx.fillStyle = accent
105
+ ctx.beginPath()
106
+ ctx.arc(d / 2, d / 2, d / 2, 0, Math.PI * 2)
107
+ ctx.fill()
108
+ const size = d * 0.62
109
+ paintIcon(ctx, 'play', (d - size) / 2, (d - size) / 2, size, () => {
110
+ // Triangle fallback: optical center sits slightly right of geometric.
111
+ const r = d * 0.22
112
+ const cx = d * 0.54
113
+ const cy = d / 2
114
+ ctx.fillStyle = '#fff'
115
+ ctx.beginPath()
116
+ ctx.moveTo(cx - r * 0.7, cy - r)
117
+ ctx.lineTo(cx - r * 0.7, cy + r)
118
+ ctx.lineTo(cx + r * 1.1, cy)
119
+ ctx.closePath()
120
+ ctx.fill()
121
+ })
122
+ return canvas
123
+ }
124
+
125
+ // Solid disc with a white file icon — the document lead-in.
126
+ function drawFileDisc (d, accent) {
127
+ const canvas = createCanvas(d, d)
128
+ const ctx = canvas.getContext('2d')
129
+ ctx.fillStyle = accent
130
+ ctx.beginPath()
131
+ ctx.arc(d / 2, d / 2, d / 2, 0, Math.PI * 2)
132
+ ctx.fill()
133
+ const size = d * 0.55
134
+ paintIcon(ctx, 'file', (d - size) / 2, (d - size) / 2, size, () => {
135
+ // Page-with-folded-corner fallback
136
+ const w = d * 0.34
137
+ const h = d * 0.44
138
+ const x = (d - w) / 2
139
+ const y = (d - h) / 2
140
+ const fold = w * 0.38
141
+ ctx.fillStyle = '#fff'
142
+ ctx.beginPath()
143
+ ctx.moveTo(x, y)
144
+ ctx.lineTo(x + w - fold, y)
145
+ ctx.lineTo(x + w, y + fold)
146
+ ctx.lineTo(x + w, y + h)
147
+ ctx.lineTo(x, y + h)
148
+ ctx.closePath()
149
+ ctx.fill()
150
+ ctx.fillStyle = accent
151
+ ctx.beginPath()
152
+ ctx.moveTo(x + w - fold, y)
153
+ ctx.lineTo(x + w - fold, y + fold)
154
+ ctx.lineTo(x + w, y + fold)
155
+ ctx.closePath()
156
+ ctx.fill()
157
+ })
158
+ return canvas
159
+ }
160
+
161
+ // Disc with a white music note icon — audio fallback cover.
162
+ function drawNoteDisc (d, accent) {
163
+ const canvas = createCanvas(d, d)
164
+ const ctx = canvas.getContext('2d')
165
+ ctx.fillStyle = accent
166
+ ctx.beginPath()
167
+ ctx.arc(d / 2, d / 2, d / 2, 0, Math.PI * 2)
168
+ ctx.fill()
169
+ const size = d * 0.55
170
+ paintIcon(ctx, 'note', (d - size) / 2, (d - size) / 2, size, () => {
171
+ // Geometric eighth-note fallback (no font dependency)
172
+ ctx.fillStyle = '#fff'
173
+ const headR = d * 0.09
174
+ const hx = d * 0.42
175
+ const hy = d * 0.66
176
+ const stemH = d * 0.34
177
+ const stemW = Math.max(1, d * 0.045)
178
+ ctx.beginPath()
179
+ ctx.ellipse(hx, hy, headR * 1.25, headR, -0.4, 0, Math.PI * 2)
180
+ ctx.fill()
181
+ ctx.fillRect(hx + headR * 1.1 - stemW, hy - stemH, stemW, stemH)
182
+ ctx.beginPath()
183
+ ctx.moveTo(hx + headR * 1.1, hy - stemH)
184
+ ctx.quadraticCurveTo(d * 0.62, hy - stemH + d * 0.07, d * 0.58, hy - stemH + d * 0.2)
185
+ ctx.quadraticCurveTo(d * 0.56, hy - stemH + d * 0.12, hx + headR * 1.1, hy - stemH + d * 0.1)
186
+ ctx.closePath()
187
+ ctx.fill()
188
+ })
189
+ return canvas
190
+ }
191
+
192
+ // Resamples a Telegram waveform (values 0..31) to `n` buckets by averaging.
193
+ function resampleWaveform (data, n) {
194
+ if (data.length <= n) return data.map((v) => v / 31)
195
+ const out = []
196
+ for (let i = 0; i < n; i++) {
197
+ const from = Math.floor(i * data.length / n)
198
+ const to = Math.max(from + 1, Math.floor((i + 1) * data.length / n))
199
+ let sum = 0
200
+ for (let j = from; j < to; j++) sum += data[j]
201
+ out.push(sum / (to - from) / 31)
202
+ }
203
+ return out
204
+ }
205
+
206
+ /**
207
+ * Voice message row: [accent play disc] [rounded waveform bars] [duration].
208
+ * `maxWidth` caps the full row (device px); bars resample to fit.
209
+ */
210
+ function drawVoiceRow (waveform, duration, accent, textColor, scale, maxWidth) {
211
+ const s = (v) => v * scale
212
+ const d = s(ROW.disc)
213
+ const durLabel = drawLabel(formatDuration(duration), s(ROW.meta), textColor, { alpha: ROW.metaAlpha })
214
+
215
+ const pitch = s(ROW.bar) + s(ROW.barGap)
216
+ const barsAvail = Math.max(pitch * 8, (maxWidth || s(220)) - d - s(ROW.gap) * 2 - durLabel.width)
217
+ const barCount = Math.min(Math.max(8, waveform.length), Math.floor(barsAvail / pitch))
218
+ const heights = resampleWaveform(waveform, barCount)
219
+ const barsW = barCount * pitch - s(ROW.barGap)
220
+
221
+ const w = Math.ceil(d + s(ROW.gap) + barsW + s(ROW.gap) + durLabel.width)
222
+ const h = d
223
+ const canvas = createCanvas(w, h)
224
+ const ctx = canvas.getContext('2d')
225
+
226
+ ctx.drawImage(drawPlayDisc(d, accent), 0, 0)
227
+
228
+ ctx.fillStyle = accent
229
+ const cy = h / 2
230
+ for (let i = 0; i < barCount; i++) {
231
+ const bh = Math.max(s(ROW.barMin), heights[i] * s(ROW.barMax))
232
+ const x = d + s(ROW.gap) + i * pitch
233
+ roundedVBar(ctx, x, cy - bh / 2, s(ROW.bar), bh)
234
+ }
235
+
236
+ ctx.drawImage(durLabel, d + s(ROW.gap) + barsW + s(ROW.gap), Math.round((h - durLabel.height) / 2))
237
+ return canvas
238
+ }
239
+
240
+ /**
241
+ * Document row: [accent file disc] [file name / size · EXT].
242
+ */
243
+ function drawDocumentRow (doc, accent, textColor, scale, maxWidth) {
244
+ const s = (v) => v * scale
245
+ const d = s(ROW.disc)
246
+ const name = String(doc.file_name || 'File')
247
+ const ext = name.includes('.') ? name.split('.').pop().toUpperCase() : ''
248
+ const metaText = [doc.file_size != null ? formatFileSize(doc.file_size) : null, ext || null]
249
+ .filter(Boolean).join(' · ')
250
+
251
+ const title = drawLabel(name, s(ROW.title), textColor, { bold: true })
252
+ const meta = metaText ? drawLabel(metaText, s(ROW.meta), textColor, { alpha: ROW.metaAlpha }) : null
253
+ return assembleRow(drawFileDisc(d, accent), title, meta, scale, maxWidth)
254
+ }
255
+
256
+ /**
257
+ * Audio row: [cover or accent note disc] [title / performer · duration].
258
+ * `thumb` is an optional Image/Canvas (already loaded).
259
+ */
260
+ function drawAudioRow (audio, accent, textColor, scale, maxWidth, thumb) {
261
+ const s = (v) => v * scale
262
+ const d = s(ROW.disc)
263
+
264
+ let lead
265
+ if (thumb) {
266
+ lead = createCanvas(d, d)
267
+ const ctx = lead.getContext('2d')
268
+ const r = s(ROW.cover)
269
+ ctx.beginPath()
270
+ ctx.moveTo(r, 0)
271
+ ctx.arcTo(d, 0, d, d, r)
272
+ ctx.arcTo(d, d, 0, d, r)
273
+ ctx.arcTo(0, d, 0, 0, r)
274
+ ctx.arcTo(0, 0, d, 0, r)
275
+ ctx.closePath()
276
+ ctx.clip()
277
+ ctx.imageSmoothingEnabled = true
278
+ ctx.imageSmoothingQuality = 'high'
279
+ const side = Math.min(thumb.width, thumb.height)
280
+ ctx.drawImage(thumb, (thumb.width - side) / 2, (thumb.height - side) / 2, side, side, 0, 0, d, d)
281
+ } else {
282
+ lead = drawNoteDisc(d, accent)
283
+ }
284
+
285
+ const metaText = [audio.performer || null, audio.duration != null ? formatDuration(audio.duration) : null]
286
+ .filter(Boolean).join(' · ')
287
+ const title = drawLabel(String(audio.title || 'Audio'), s(ROW.title), textColor, { bold: true })
288
+ const meta = metaText ? drawLabel(metaText, s(ROW.meta), textColor, { alpha: ROW.metaAlpha }) : null
289
+ return assembleRow(lead, title, meta, scale, maxWidth)
290
+ }
291
+
292
+ // [disc] + up to two text lines, vertically centered against the disc.
293
+ function assembleRow (disc, title, meta, scale, maxWidth) {
294
+ const s = (v) => v * scale
295
+ const gap = s(ROW.gap)
296
+ const textW = Math.max(title.width, meta ? meta.width : 0)
297
+ let w = Math.ceil(disc.width + gap + textW)
298
+ if (maxWidth && w > maxWidth) w = Math.ceil(maxWidth)
299
+ const textsH = title.height + (meta ? s(ROW.lineGap) + meta.height : 0)
300
+ const h = Math.max(disc.height, textsH)
301
+
302
+ const canvas = createCanvas(w, h)
303
+ const ctx = canvas.getContext('2d')
304
+ ctx.drawImage(disc, 0, Math.round((h - disc.height) / 2))
305
+
306
+ const maxTextW = w - disc.width - gap
307
+ let ty = Math.round((h - textsH) / 2)
308
+ ctx.drawImage(clampWidth(title, maxTextW), disc.width + gap, ty)
309
+ ty += title.height + s(ROW.lineGap)
310
+ if (meta) ctx.drawImage(clampWidth(meta, maxTextW), disc.width + gap, ty)
311
+ return canvas
312
+ }
313
+
314
+ // Hard-crops a label canvas with a trailing fade (same look as layout-box).
315
+ function clampWidth (canvas, maxW) {
316
+ if (canvas.width <= maxW) return canvas
317
+ const w = Math.max(1, Math.floor(maxW))
318
+ const out = createCanvas(w, canvas.height)
319
+ const ctx = out.getContext('2d')
320
+ ctx.drawImage(canvas, 0, 0)
321
+ const fadeW = Math.min(w, Math.round(canvas.height * 0.9))
322
+ const grad = ctx.createLinearGradient(w - fadeW, 0, w, 0)
323
+ grad.addColorStop(0, 'rgba(0, 0, 0, 0)')
324
+ grad.addColorStop(1, 'rgba(0, 0, 0, 1)')
325
+ ctx.globalCompositeOperation = 'destination-out'
326
+ ctx.fillStyle = grad
327
+ ctx.fillRect(w - fadeW, 0, fadeW, canvas.height)
328
+ return out
329
+ }
330
+
331
+ function roundedVBar (ctx, x, y, w, h) {
332
+ const r = w / 2
333
+ ctx.beginPath()
334
+ ctx.moveTo(x, y + r)
335
+ ctx.arcTo(x, y, x + w, y, r)
336
+ ctx.arcTo(x + w, y, x + w, y + h, r)
337
+ ctx.lineTo(x + w, y + h - r)
338
+ ctx.arcTo(x + w, y + h, x, y + h, r)
339
+ ctx.arcTo(x, y + h, x, y + h - r, r)
340
+ ctx.closePath()
341
+ ctx.fill()
342
+ }
343
+
344
+ /**
345
+ * Overlay badges for media, painted in DESTINATION space (after the media
346
+ * is scaled into the bubble): centered play button and/or a bottom-left chip
347
+ * ("GIF", "0:42"). `ctx` is the final canvas, (x, y, w, h) the media rect.
348
+ */
349
+ function paintMediaBadges (ctx, x, y, w, h, badge, scale) {
350
+ const s = (v) => v * scale
351
+ if (!badge) return
352
+ ctx.save()
353
+ if (badge.play) {
354
+ const d = Math.min(s(44), w * 0.45, h * 0.45)
355
+ const cx = x + w / 2
356
+ const cy = y + h / 2
357
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
358
+ ctx.beginPath()
359
+ ctx.arc(cx, cy, d / 2, 0, Math.PI * 2)
360
+ ctx.fill()
361
+ const size = d * 0.62
362
+ paintIcon(ctx, 'play', cx - size / 2, cy - size / 2, size, () => {
363
+ const r = d * 0.24
364
+ ctx.fillStyle = '#fff'
365
+ ctx.beginPath()
366
+ ctx.moveTo(cx - r * 0.62, cy - r)
367
+ ctx.lineTo(cx - r * 0.62, cy + r)
368
+ ctx.lineTo(cx + r * 1.05, cy)
369
+ ctx.closePath()
370
+ ctx.fill()
371
+ })
372
+ }
373
+ if (badge.label) {
374
+ const label = drawLabel(badge.label, s(13), '#fff')
375
+ const padX = s(7)
376
+ const padY = s(2)
377
+ const bw = label.width + padX * 2
378
+ const bh = label.height + padY * 2
379
+ const bx = x + s(6)
380
+ const by = y + h - bh - s(6)
381
+ const r = bh / 2
382
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.45)'
383
+ ctx.beginPath()
384
+ ctx.moveTo(bx + r, by)
385
+ ctx.arcTo(bx + bw, by, bx + bw, by + bh, r)
386
+ ctx.arcTo(bx + bw, by + bh, bx, by + bh, r)
387
+ ctx.arcTo(bx, by + bh, bx, by, r)
388
+ ctx.arcTo(bx, by, bx + bw, by, r)
389
+ ctx.closePath()
390
+ ctx.fill()
391
+ ctx.drawImage(label, bx + padX, by + padY)
392
+ }
393
+ ctx.restore()
394
+ }
395
+
396
+ module.exports = {
397
+ loadIcons,
398
+ drawVoiceRow,
399
+ drawDocumentRow,
400
+ drawAudioRow,
401
+ paintMediaBadges,
402
+ formatDuration,
403
+ formatFileSize,
404
+ resampleWaveform,
405
+ ROW
406
+ }
utils/quote-generate/avatar.js ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // utils/quote-generate/avatar.js
2
+
3
+ const { createCanvas, loadImage } = require('canvas')
4
+ const LRU = require('lru-cache')
5
+ const runes = require('runes')
6
+ const loadImageFromUrl = require('../image-load-url')
7
+ const { AVATAR_COLORS } = require('./constants')
8
+
9
+ const avatarCache = new LRU({
10
+ max: 20,
11
+ maxAge: 1000 * 60 * 5
12
+ })
13
+
14
+ function avatarImageLetters (letters, color) {
15
+ const size = 500
16
+ const canvas = createCanvas(size, size)
17
+ const context = canvas.getContext('2d')
18
+
19
+ const gradient = context.createLinearGradient(0, 0, canvas.width, canvas.height)
20
+ gradient.addColorStop(0, color[0])
21
+ gradient.addColorStop(1, color[1])
22
+
23
+ context.fillStyle = gradient
24
+ context.fillRect(0, 0, canvas.width, canvas.height)
25
+
26
+ const letterCount = runes(letters).length
27
+ const fontSize = letterCount > 1 ? size * 0.38 : size * 0.48
28
+ context.font = `600 ${fontSize}px NotoSans`
29
+ context.fillStyle = '#FFF'
30
+ context.textAlign = 'center'
31
+ context.textBaseline = 'middle'
32
+ context.fillText(letters, size / 2, size / 2)
33
+
34
+ return canvas
35
+ }
36
+
37
+ async function downloadAvatarImage (user, telegram) {
38
+ let avatarImage
39
+
40
+ let nameLetters
41
+ if (user.first_name && user.last_name) {
42
+ nameLetters = runes(user.first_name)[0] + (runes(user.last_name || '')[0])
43
+ } else {
44
+ let name = user.first_name || (user.name && user.name !== false && user.name) || user.title || '?'
45
+ if (typeof name !== 'string') name = String(name)
46
+ name = name.toUpperCase()
47
+ const nameWord = name.split(' ')
48
+
49
+ if (nameWord.length > 1) nameLetters = runes(nameWord[0])[0] + runes(nameWord[nameWord.length - 1])[0]
50
+ else nameLetters = runes(nameWord[0])[0]
51
+ }
52
+
53
+ const cacheKey = user.id != null ? user.id : `noId:${user.username || nameLetters}`
54
+ const avatarImageCached = avatarCache.get(cacheKey)
55
+ const nameIndex = user.id != null ? Math.abs(user.id) % 7 : 0
56
+ const avatarColor = AVATAR_COLORS[nameIndex]
57
+
58
+ if (avatarImageCached) {
59
+ return avatarImageCached
60
+ }
61
+
62
+ if (user.photo && user.photo.url) {
63
+ avatarImage = await loadImage(user.photo.url).catch(() => null)
64
+ }
65
+
66
+ if (!avatarImage) {
67
+ try {
68
+ let userPhoto, userPhotoUrl
69
+
70
+ if (user.photo && user.photo.big_file_id) {
71
+ userPhotoUrl = await telegram.getFileLink(user.photo.big_file_id).catch(() => {})
72
+ }
73
+
74
+ if (!userPhotoUrl) {
75
+ const getChat = user.id != null
76
+ ? await telegram.getChat(user.id).catch(() => {})
77
+ : null
78
+
79
+ if (getChat) {
80
+ // Use chat data to fix missing initials
81
+ if (nameLetters === '?') {
82
+ const chatName = getChat.first_name || getChat.title
83
+ if (chatName) {
84
+ const upper = chatName.toUpperCase()
85
+ const words = upper.split(' ')
86
+ if (words.length > 1) nameLetters = runes(words[0])[0] + runes(words[words.length - 1])[0]
87
+ else nameLetters = runes(words[0])[0]
88
+ }
89
+ }
90
+
91
+ if (getChat.photo && getChat.photo.big_file_id) {
92
+ userPhoto = getChat.photo.big_file_id
93
+ }
94
+ }
95
+
96
+ if (userPhoto) {
97
+ userPhotoUrl = await telegram.getFileLink(userPhoto).catch(() => {})
98
+ } else if (user.username) {
99
+ userPhotoUrl = `https://telega.one/i/userpic/320/${user.username}.jpg`
100
+ }
101
+ }
102
+
103
+ if (userPhotoUrl) {
104
+ const imageBuffer = await loadImageFromUrl(userPhotoUrl).catch((error) => {
105
+ console.warn('Failed to load user photo from URL:', error.message)
106
+ return null
107
+ })
108
+
109
+ if (imageBuffer) {
110
+ avatarImage = await loadImage(imageBuffer).catch((error) => {
111
+ console.warn('Failed to process user photo buffer:', error.message)
112
+ return null
113
+ })
114
+ }
115
+ }
116
+
117
+ if (avatarImage) {
118
+ avatarCache.set(cacheKey, avatarImage)
119
+ }
120
+ } catch (error) {
121
+ console.warn('Error getting user photo:', error.message)
122
+ avatarImage = null
123
+ }
124
+ }
125
+
126
+ // Final fallback — initials avatar
127
+ if (!avatarImage) {
128
+ try {
129
+ avatarImage = avatarImageLetters(nameLetters, avatarColor)
130
+ avatarCache.set(cacheKey, avatarImage)
131
+ } catch (error) {
132
+ console.warn('Failed to create letters avatar:', error.message)
133
+ avatarImage = null
134
+ }
135
+ }
136
+
137
+ return avatarImage
138
+ }
139
+
140
+ async function drawAvatar (user, telegram) {
141
+ try {
142
+ const avatarImage = await downloadAvatarImage(user, telegram)
143
+
144
+ if (avatarImage) {
145
+ const avatarSize = avatarImage.naturalHeight || avatarImage.height
146
+
147
+ const canvas = createCanvas(avatarSize, avatarSize)
148
+ const canvasCtx = canvas.getContext('2d')
149
+
150
+ canvasCtx.beginPath()
151
+ canvasCtx.arc(avatarSize / 2, avatarSize / 2, avatarSize / 2, 0, Math.PI * 2, true)
152
+ canvasCtx.save()
153
+ canvasCtx.clip()
154
+ canvasCtx.closePath()
155
+ canvasCtx.drawImage(avatarImage, 0, 0, avatarSize, avatarSize)
156
+ canvasCtx.restore()
157
+
158
+ return canvas
159
+ }
160
+ console.warn('No avatar image available for user')
161
+ return null
162
+ } catch (error) {
163
+ console.warn('Error drawing avatar:', error.message)
164
+ return null
165
+ }
166
+ }
167
+
168
+ module.exports = { drawAvatar, downloadAvatarImage, avatarImageLetters }
utils/quote-generate/canvas-utils.js ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { createCanvas } = require('canvas')
2
+
3
+ // Draws the bubble path. When tailSize > 0, the bottom-left corner
4
+ // becomes a tail: flat bottom extending left, rounded top curving up.
5
+ // `r` is a single radius or per-corner {tl, tr, br, bl} (grouped bubbles
6
+ // flatten the corners that face their neighbours, like Telegram).
7
+ function bubblePath (ctx, w, h, r, tailSize) {
8
+ let { tl, tr, br, bl } = typeof r === 'number' ? { tl: r, tr: r, br: r, bl: r } : r
9
+ const cap = (v) => Math.min(v, w / 2, h / 2)
10
+ tl = cap(tl)
11
+ tr = cap(tr)
12
+ br = cap(br)
13
+ bl = cap(bl)
14
+
15
+ ctx.beginPath()
16
+ ctx.moveTo(tl, 0)
17
+ // top-right corner
18
+ ctx.arcTo(w, 0, w, h, tr)
19
+ // bottom-right corner
20
+ ctx.arcTo(w, h, 0, h, br)
21
+
22
+ if (tailSize > 0) {
23
+ const t = tailSize
24
+
25
+ // Bottom edge continues FLAT to the left, past the bubble edge
26
+ ctx.lineTo(-t, h)
27
+
28
+ // Rounded curve going up-right, back to the left edge of the bubble.
29
+ // Bottom stays flat, top is smoothly rounded.
30
+ ctx.bezierCurveTo(
31
+ -t * 0.4, h,
32
+ 0, h - bl * 0.3,
33
+ 0, h - bl
34
+ )
35
+ } else {
36
+ // Normal bottom-left rounded corner
37
+ ctx.arcTo(0, h, 0, 0, bl)
38
+ }
39
+
40
+ // top-left corner
41
+ ctx.arcTo(0, 0, w, 0, tl)
42
+ ctx.closePath()
43
+ }
44
+
45
+ // Frosted-glass finish over the bubble fill: a hairline inner border plus a
46
+ // light top edge that fades out — both clipped to the bubble path so they
47
+ // follow the corners and the tail. `lw` is the hairline width in device px.
48
+ function paintGlass (ctx, w, h, r, tailSize, lw) {
49
+ ctx.save()
50
+ bubblePath(ctx, w, h, r, tailSize)
51
+ ctx.clip()
52
+
53
+ // Uniform hairline border. Stroke is centered on the path; with the clip
54
+ // active only the inner half remains, so double the width.
55
+ bubblePath(ctx, w, h, r, tailSize)
56
+ ctx.lineWidth = lw * 2
57
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.07)'
58
+ ctx.stroke()
59
+
60
+ // Light top edge fading out by ~40% of the height.
61
+ const grad = ctx.createLinearGradient(0, 0, 0, h * 0.4)
62
+ grad.addColorStop(0, 'rgba(255, 255, 255, 0.16)')
63
+ grad.addColorStop(1, 'rgba(255, 255, 255, 0)')
64
+ ctx.lineWidth = lw * 2.6
65
+ ctx.strokeStyle = grad
66
+ ctx.stroke()
67
+ ctx.restore()
68
+ }
69
+
70
+ function drawRoundRect (color, w, h, r, tailSize = 0, glassLw = 0) {
71
+ const extraLeft = tailSize > 0 ? Math.ceil(tailSize * 0.8) : 0
72
+ const canvas = createCanvas(w + extraLeft, h)
73
+ const ctx = canvas.getContext('2d')
74
+ ctx.translate(extraLeft, 0)
75
+ ctx.fillStyle = color
76
+ bubblePath(ctx, w, h, r, tailSize)
77
+ ctx.fill()
78
+ if (glassLw > 0) paintGlass(ctx, w, h, r, tailSize, glassLw)
79
+ canvas._tailOffset = extraLeft
80
+ return canvas
81
+ }
82
+
83
+ function drawGradientRoundRect (colorOne, colorTwo, w, h, r, tailSize = 0, glassLw = 0) {
84
+ const extraLeft = tailSize > 0 ? Math.ceil(tailSize * 0.8) : 0
85
+ const canvas = createCanvas(w + extraLeft, h)
86
+ const ctx = canvas.getContext('2d')
87
+ ctx.translate(extraLeft, 0)
88
+ const gradient = ctx.createLinearGradient(0, 0, w, h)
89
+ gradient.addColorStop(0, colorOne)
90
+ gradient.addColorStop(1, colorTwo)
91
+ ctx.fillStyle = gradient
92
+ bubblePath(ctx, w, h, r, tailSize)
93
+ ctx.fill()
94
+ if (glassLw > 0) paintGlass(ctx, w, h, r, tailSize, glassLw)
95
+ canvas._tailOffset = extraLeft
96
+ return canvas
97
+ }
98
+
99
+ // Rounds an image's corners. `r` is a single radius or per-corner
100
+ // {tl, tr, br, bl} — flush media inherits the bubble's corner radii on the
101
+ // edges it touches while keeping the small media radius elsewhere.
102
+ function roundImage (image, r) {
103
+ const w = image.width
104
+ const h = image.height
105
+ const canvas = createCanvas(w, h)
106
+ const ctx = canvas.getContext('2d')
107
+ ctx.imageSmoothingEnabled = true
108
+ ctx.imageSmoothingQuality = 'high'
109
+ let { tl, tr, br, bl } = typeof r === 'number' ? { tl: r, tr: r, br: r, bl: r } : r
110
+ const cap = (v) => Math.min(v, w / 2, h / 2)
111
+ tl = cap(tl)
112
+ tr = cap(tr)
113
+ br = cap(br)
114
+ bl = cap(bl)
115
+ ctx.beginPath()
116
+ ctx.moveTo(tl, 0)
117
+ ctx.arcTo(w, 0, w, h, tr)
118
+ ctx.arcTo(w, h, 0, h, br)
119
+ ctx.arcTo(0, h, 0, 0, bl)
120
+ ctx.arcTo(0, 0, w, 0, tl)
121
+ ctx.save()
122
+ ctx.clip()
123
+ ctx.closePath()
124
+ ctx.drawImage(image, 0, 0)
125
+ ctx.restore()
126
+ return canvas
127
+ }
128
+
129
+ function drawReplyLine (lineWidth, height, color) {
130
+ const r = lineWidth
131
+ const w = lineWidth
132
+ const h = height + lineWidth
133
+ const x = 5
134
+ const canvas = createCanvas(w + 10, h)
135
+ const ctx = canvas.getContext('2d')
136
+
137
+ ctx.fillStyle = color
138
+ ctx.beginPath()
139
+ // Top-left: rounded
140
+ ctx.moveTo(x, r)
141
+ ctx.arcTo(x, 0, x + w, 0, r)
142
+ // Top-right: square
143
+ ctx.lineTo(x + w, 0)
144
+ // Right edge straight down
145
+ ctx.lineTo(x + w, h)
146
+ // Bottom-right: square
147
+ ctx.lineTo(x + r, h)
148
+ // Bottom-left: rounded
149
+ ctx.arcTo(x, h, x, 0, r)
150
+ ctx.closePath()
151
+ ctx.fill()
152
+
153
+ return canvas
154
+ }
155
+
156
+ function drawQuoteIcon (size, color, alpha = 0.15) {
157
+ const canvas = createCanvas(size, size)
158
+ const ctx = canvas.getContext('2d')
159
+
160
+ // Draw as a single combined path so nothing overlaps
161
+ ctx.fillStyle = color
162
+ ctx.globalAlpha = alpha
163
+
164
+ const s = size
165
+ const r = s * 0.09
166
+ const gapX = s * 0.36
167
+
168
+ // Build one unified path for both quote marks
169
+ ctx.beginPath()
170
+ addQuoteMarkPath(ctx, s * 0.08, s * 0.15, r, s)
171
+ addQuoteMarkPath(ctx, s * 0.08 + gapX, s * 0.15, r, s)
172
+ ctx.fill()
173
+
174
+ return canvas
175
+ }
176
+
177
+ function addQuoteMarkPath (ctx, x, y, r, s) {
178
+ // Comma-shaped glyph: circle on top, tail curving down-left
179
+ const cx = x + r
180
+ const cy = y + r
181
+ // Circle
182
+ ctx.moveTo(cx + r, cy)
183
+ ctx.arc(cx, cy, r, 0, Math.PI * 2)
184
+ // Tail
185
+ ctx.moveTo(x + r * 2, cy)
186
+ ctx.quadraticCurveTo(x + r * 2.2, y + s * 0.35, x + r * 0.3, y + s * 0.4)
187
+ ctx.lineTo(x + r * 0.3, y + s * 0.32)
188
+ ctx.quadraticCurveTo(x + r * 1.5, y + s * 0.27, x + r * 0.8, cy)
189
+ ctx.closePath()
190
+ }
191
+
192
+ // Vertical ink bounds of a canvas (rows with any non-transparent pixel),
193
+ // or null for a blank canvas. Text canvases from drawMultilineText carry
194
+ // transparent slack (~0.8×fontSize below the last baseline), so geometry
195
+ // that should follow the VISIBLE glyphs must use these bounds instead of
196
+ // canvas height.
197
+ function inkBounds (canvas) {
198
+ const w = canvas.width
199
+ const h = canvas.height
200
+ // Plain Images (loadImage results) have no getContext — callers must not
201
+ // trim those; treat them as fully opaque.
202
+ if (w < 1 || h < 1 || typeof canvas.getContext !== 'function') return null
203
+ const data = canvas.getContext('2d').getImageData(0, 0, w, h).data
204
+ const rowHasInk = (y) => {
205
+ const off = y * w * 4 + 3
206
+ for (let x = 0; x < w; x++) if (data[off + x * 4] > 8) return true
207
+ return false
208
+ }
209
+ // Scan from both ends — skips the (usually large) solid middle.
210
+ let top = -1
211
+ for (let y = 0; y < h; y++) if (rowHasInk(y)) { top = y; break }
212
+ if (top === -1) return null
213
+ let bottom = top
214
+ for (let y = h - 1; y > top; y--) if (rowHasInk(y)) { bottom = y; break }
215
+ return { top, bottom }
216
+ }
217
+
218
+ // One-line label drawn at metric size: canvas height = ascent + descent of
219
+ // the font em box, baseline at ascent. Same geometry rules as multiline
220
+ // text — glyph shapes never change the box.
221
+ function drawLabel (text, fontSize, color, opts = {}) {
222
+ // Lazy require — canvas-utils is loaded by layout-box before text-prepare.
223
+ const { fontMetrics } = require('./text-prepare')
224
+ const { ascent, descent } = fontMetrics(fontSize)
225
+ const font = `${opts.bold ? 'bold ' : ''}${fontSize}px NotoSans`
226
+ const measure = createCanvas(1, 1).getContext('2d')
227
+ measure.font = font
228
+ const m = measure.measureText(text)
229
+
230
+ const canvas = createCanvas(Math.max(1, Math.ceil(m.width)), Math.max(1, Math.ceil(ascent + descent)))
231
+ const ctx = canvas.getContext('2d')
232
+ ctx.font = font
233
+ ctx.fillStyle = color
234
+ if (opts.alpha !== undefined) ctx.globalAlpha = opts.alpha
235
+ ctx.fillText(text, 0, ascent)
236
+ return canvas
237
+ }
238
+
239
+ function drawForwardLabel (text, fontSize, color) {
240
+ return drawLabel(text, fontSize, color, { bold: true })
241
+ }
242
+
243
+ module.exports = { drawRoundRect, drawGradientRoundRect, roundImage, drawReplyLine, drawQuoteIcon, drawLabel, drawForwardLabel, inkBounds }
utils/quote-generate/color.js ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { createCanvas } = require('canvas')
2
+
3
+ class ColorContrast {
4
+ constructor () {
5
+ this.brightnessThreshold = 175
6
+ }
7
+
8
+ getBrightness (color) {
9
+ const [r, g, b] = hexToRgb(color)
10
+ return (r * 299 + g * 587 + b * 114) / 1000
11
+ }
12
+
13
+ adjustBrightness (color, amount) {
14
+ const [r, g, b] = hexToRgb(color)
15
+ const newR = Math.max(0, Math.min(255, r + amount))
16
+ const newG = Math.max(0, Math.min(255, g + amount))
17
+ const newB = Math.max(0, Math.min(255, b + amount))
18
+ return rgbToHex([newR, newG, newB])
19
+ }
20
+
21
+ getContrastRatio (background, foreground) {
22
+ const brightness1 = this.getBrightness(background)
23
+ const brightness2 = this.getBrightness(foreground)
24
+ const lightest = Math.max(brightness1, brightness2)
25
+ const darkest = Math.min(brightness1, brightness2)
26
+ return (lightest + 0.05) / (darkest + 0.05)
27
+ }
28
+
29
+ adjustContrast (background, foreground) {
30
+ const contrastRatio = this.getContrastRatio(background, foreground)
31
+ const brightnessDiff = this.getBrightness(background) - this.getBrightness(foreground)
32
+ if (contrastRatio >= 4.5) {
33
+ return foreground
34
+ } else if (brightnessDiff >= 0) {
35
+ const amount = Math.ceil((this.brightnessThreshold - this.getBrightness(foreground)) / 2)
36
+ return this.adjustBrightness(foreground, amount)
37
+ } else {
38
+ const amount = Math.ceil((this.getBrightness(foreground) - this.brightnessThreshold) / 2)
39
+ return this.adjustBrightness(foreground, -amount)
40
+ }
41
+ }
42
+ }
43
+
44
+ function hexToRgb (hex) {
45
+ hex = String(hex).replace(/^#/, '')
46
+ if (hex.length === 3) {
47
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
48
+ }
49
+ const r = parseInt(hex.substring(0, 2), 16)
50
+ const g = parseInt(hex.substring(2, 4), 16)
51
+ const b = parseInt(hex.substring(4, 6), 16)
52
+ return [r, g, b]
53
+ }
54
+
55
+ function rgbToHex ([r, g, b]) {
56
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
57
+ }
58
+
59
+ const normalizeColorCache = new Map()
60
+ const NORMALIZE_COLOR_CACHE_MAX = 1000
61
+
62
+ function normalizeColor (color) {
63
+ const cached = normalizeColorCache.get(color)
64
+ if (cached !== undefined) return cached
65
+
66
+ const canvas = createCanvas(0, 0)
67
+ const canvasCtx = canvas.getContext('2d')
68
+ canvasCtx.fillStyle = color
69
+ const result = canvasCtx.fillStyle
70
+
71
+ if (normalizeColorCache.size >= NORMALIZE_COLOR_CACHE_MAX) {
72
+ const firstKey = normalizeColorCache.keys().next().value
73
+ normalizeColorCache.delete(firstKey)
74
+ }
75
+ normalizeColorCache.set(color, result)
76
+
77
+ return result
78
+ }
79
+
80
+ function colorLuminance (hex, lum) {
81
+ hex = String(hex).replace(/[^0-9a-f]/gi, '')
82
+ if (hex.length < 6) {
83
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
84
+ }
85
+ lum = lum || 0
86
+ let rgb = '#'
87
+ for (let i = 0; i < 3; i++) {
88
+ let c = parseInt(hex.substr(i * 2, 2), 16)
89
+ c = Math.round(Math.min(Math.max(0, c + (c * lum)), 255)).toString(16)
90
+ rgb += ('00' + c).substr(c.length)
91
+ }
92
+ return rgb
93
+ }
94
+
95
+ function lightOrDark (color) {
96
+ let r, g, b
97
+ if (color.match(/^rgb/)) {
98
+ color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/)
99
+ r = color[1]
100
+ g = color[2]
101
+ b = color[3]
102
+ } else {
103
+ color = +('0x' + color.slice(1).replace(color.length < 5 && /./g, '$&$&'))
104
+ r = color >> 16
105
+ g = color >> 8 & 255
106
+ b = color & 255
107
+ }
108
+ const hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b))
109
+ return hsp > 127.5 ? 'light' : 'dark'
110
+ }
111
+
112
+ function parseBackgroundColor (backgroundColor) {
113
+ if (typeof backgroundColor !== 'string') backgroundColor = null
114
+ backgroundColor = backgroundColor || '//#292232'
115
+ const split = backgroundColor.split('/')
116
+ if (split.length > 1 && split[0] !== '') {
117
+ return { colorOne: normalizeColor(split[0]), colorTwo: normalizeColor(split[1]) }
118
+ } else if (backgroundColor.startsWith('//')) {
119
+ const base = normalizeColor(backgroundColor.replace('//', ''))
120
+ return { colorOne: colorLuminance(base, 0.35), colorTwo: colorLuminance(base, -0.15) }
121
+ } else {
122
+ const base = normalizeColor(backgroundColor)
123
+ return { colorOne: base, colorTwo: base }
124
+ }
125
+ }
126
+
127
+ function hexToHsl (hex) {
128
+ const [r, g, b] = hexToRgb(hex).map((v) => v / 255)
129
+ const max = Math.max(r, g, b)
130
+ const min = Math.min(r, g, b)
131
+ const l = (max + min) / 2
132
+ if (max === min) return [0, 0, l]
133
+ const d = max - min
134
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
135
+ let h
136
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6
137
+ else if (max === g) h = ((b - r) / d + 2) / 6
138
+ else h = ((r - g) / d + 4) / 6
139
+ return [h * 360, s, l]
140
+ }
141
+
142
+ function hslToHex (h, s, l) {
143
+ h = ((h % 360) + 360) % 360 / 360
144
+ const hue2rgb = (p, q, t) => {
145
+ if (t < 0) t += 1
146
+ if (t > 1) t -= 1
147
+ if (t < 1 / 6) return p + (q - p) * 6 * t
148
+ if (t < 1 / 2) return q
149
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
150
+ return p
151
+ }
152
+ let r, g, b
153
+ if (s === 0) {
154
+ r = g = b = l
155
+ } else {
156
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s
157
+ const p = 2 * l - q
158
+ r = hue2rgb(p, q, h + 1 / 3)
159
+ g = hue2rgb(p, q, h)
160
+ b = hue2rgb(p, q, h - 1 / 3)
161
+ }
162
+ return rgbToHex([Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)])
163
+ }
164
+
165
+ module.exports = {
166
+ ColorContrast, hexToRgb, rgbToHex, normalizeColor, colorLuminance, lightOrDark, parseBackgroundColor, hexToHsl, hslToHex
167
+ }