Quote API Deploy commited on
Commit ·
2413e0a
unverified ·
0
Parent(s):
feat: deploy quote-api to Hugging Face Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +48 -0
- .editorconfig +9 -0
- .env.example +3 -0
- .eslintrc.json +17 -0
- .gitattributes +7 -0
- .github/workflows/docker.yml +55 -0
- .gitignore +44 -0
- Dockerfile +44 -0
- README.md +30 -0
- app.js +71 -0
- assets/Quotly.png +3 -0
- assets/emoji/emoji-apple-image.json +3 -0
- assets/emoji/emoji-blob-image.json +3 -0
- assets/emoji/emoji-google-image.json +3 -0
- assets/emoji/emoji-joypixels-image.json +3 -0
- assets/emoji/emoji-twitter-image.json +3 -0
- assets/fonts/.gitkeep +0 -0
- assets/icons/insert_drive_file.svg +3 -0
- assets/icons/music_note.svg +3 -0
- assets/icons/play_arrow.svg +3 -0
- assets/pattern_02.png +3 -0
- assets/pattern_ny.png +3 -0
- assets/pattern_ny_old.png +3 -0
- helpers/api.js +47 -0
- helpers/index.js +5 -0
- index.js +2 -0
- methods/generate.js +339 -0
- methods/index.js +38 -0
- package-lock.json +0 -0
- package.json +53 -0
- routes/api.js +14 -0
- routes/index.js +5 -0
- scripts/send-preview-stickers.js +100 -0
- scripts/update-emoji.js +185 -0
- test-attachments.js +112 -0
- test-emoji-gif.js +103 -0
- test-fixes.js +102 -0
- test-metrics.js +92 -0
- test-real.js +515 -0
- test-style.js +51 -0
- utils/emoji-db.js +3 -0
- utils/emoji-image.js +43 -0
- utils/image-load-path.js +10 -0
- utils/image-load-url.js +70 -0
- utils/index.js +10 -0
- utils/promise-concurrent.js +29 -0
- utils/quote-generate/attachments.js +406 -0
- utils/quote-generate/avatar.js +168 -0
- utils/quote-generate/canvas-utils.js +243 -0
- utils/quote-generate/color.js +167 -0
.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
|
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
|
assets/icons/music_note.svg
ADDED
|
|
Git LFS Details
|
assets/icons/play_arrow.svg
ADDED
|
|
Git LFS Details
|
assets/pattern_02.png
ADDED
|
Git LFS Details
|
assets/pattern_ny.png
ADDED
|
Git LFS Details
|
assets/pattern_ny_old.png
ADDED
|
Git LFS Details
|
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 |
+
}
|