diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..11be630d6c220e75c4595fadc0690dcbfbbb369d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,48 @@ +# Dependencies +node_modules + +# Git +.git +.gitignore + +# Environment +.env +.env.* +!.env.example + +# IDE +.vscode +.idea +*.swp +*.swo + +# Logs +*.log +npm-debug.log* + +# Testing & Coverage +coverage +.nyc_output + +# Documentation +*.md +!README.md + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +build +dist + +# Docker (don't copy docker files into image) +Dockerfile +docker-compose*.yml +.dockerignore +nginx*.conf + +# Dev tools +.eslintrc* +.editorconfig +.github diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000000000000000000000000000000000..79621be8e4b593a41c64fabc8e8285925ede1e74 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..668350710fc1c82c3082d445da39eb5423d175dd --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +PORT="4888" +BOT_TOKEN="" +EMOJI_DOMAIN="https://emojipedia.org/" diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000000000000000000000000000000000000..d9a334003be7a3e7b7faaddcfe71aa3dd06eb7d9 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,17 @@ +{ + "env": { + "es6": true, + "node": true + }, + "extends": "standard", + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly" + }, + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "module" + }, + "rules": { + } +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..c3bfa395e05cc3bc6492a89b7b268509cba2a13a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +assets/emoji/*.json filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text +*.svg filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000000000000000000000000000000000000..11992c531e35ac53c28e2bba9d25d8b5bf8c2643 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,55 @@ +name: Docker + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=ref,event=tag + type=sha + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..40ce65e6df12b728a222f5644b45f717078463b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Dependencies +node_modules/ + +# Environment +.env +.env.local +.env.*.local + +# Logs +logs/ +*.log +npm-debug.log* + +# Runtime +pids/ +*.pid +*.pid.lock + +# Coverage +coverage/ +.nyc_output/ + +# Build +build/ +dist/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Project specific +assets/fonts/* +assets/emojis/* +!.gitkeep +.superpowers/ +test-output/ +assets/fonts/*.ttf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..f35b3bbccec37c39eb7b8fd35b24d7988b583fac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Hugging Face Spaces — quote-api +# Port wajib 7860 sesuai requirement HF Spaces Docker. +FROM node:22-bookworm AS builder + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential g++ make python3 \ + libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev libpixman-1-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* + +COPY package*.json ./ +RUN npm ci --omit=dev + +# ── Runtime stage ────────────────────────────────────────────── +FROM node:22-bookworm-slim + +WORKDIR /app + +ENV NODE_ENV=production +# HF Spaces hanya expose port 7860 +ENV PORT=7860 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + fonts-noto fonts-noto-cjk fonts-noto-extra fontconfig \ + libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libjpeg62-turbo libgif7 librsvg2-2 libpixman-1-0 libvips42 \ + ffmpeg curl \ + && rm -rf /var/lib/apt/lists/* \ + && fc-cache -f -v + +# HF Spaces menjalankan container sebagai user 1000 (non-root) +RUN groupadd -r appuser && useradd -r -g appuser -u 1000 appuser + +COPY --from=builder /app/node_modules ./node_modules +COPY --chown=appuser:appuser . . + +USER appuser + +EXPOSE 7860 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:7860/health || exit 1 + +CMD ["node", "index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..121d13e577cfc600badcdbeba8a6379c07cebb63 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +--- +title: Quote API +emoji: 💬 +colorFrom: indigo +colorTo: purple +sdk: docker +pinned: false +license: mit +short_description: Quote image generation API for Telegram bots +--- + +# Quote API + +REST API untuk generate gambar quote, digunakan oleh Telegram bot. + +## Endpoint + +``` +POST /generate +GET /health +``` + +## Environment Variables (Secrets) + +| Nama | Keterangan | +|---|---| +| `BOT_TOKEN` | Token bot Telegram (untuk bypass rate limit) | +| `EMOJI_DOMAIN` | Domain emoji (default: `https://emojipedia.org/`) | + +> Port yang digunakan: **7860** (wajib untuk HF Spaces Docker). diff --git a/app.js b/app.js new file mode 100644 index 0000000000000000000000000000000000000000..46ea75e74dfaac709ec2ae01b17cb1aacb70a624 --- /dev/null +++ b/app.js @@ -0,0 +1,71 @@ +const logger = require('koa-logger') +const responseTime = require('koa-response-time') +const bodyParser = require('koa-bodyparser') +const ratelimit = require('koa-ratelimit') +const Router = require('koa-router') +const Koa = require('koa') +const { loadFonts } = require('./utils') + +const app = new Koa() + +app.use(logger()) +app.use(responseTime()) +app.use(bodyParser()) + +const ratelimitDb = new Map() + +app.use(ratelimit({ + driver: 'memory', + db: ratelimitDb, + duration: 1000 * 55, + errorMessage: { + ok: false, + error: { + code: 429, + message: 'Rate limit exceeded. See "Retry-After"' + } + }, + id: (ctx) => ctx.ip, + headers: { + remaining: 'Rate-Limit-Remaining', + reset: 'Rate-Limit-Reset', + total: 'Rate-Limit-Total' + }, + max: 20, + disableHeader: false, + whitelist: (ctx) => { + // The bot sends its token in the request body (kept out of the URL/access + // logs); accept either location so its own requests stay un-throttled. + const token = ctx.query.botToken || (ctx.request.body && ctx.request.body.botToken) + return token === process.env.BOT_TOKEN + }, + blacklist: (ctx) => { + } +})) + +app.use(require('./helpers').helpersApi) + +const route = new Router() + +const routes = require('./routes') + +// Health check endpoint for Docker/Coolify +route.get('/health', (ctx) => { + ctx.status = 200 + ctx.body = { status: 'ok', timestamp: Date.now() } +}) + +route.use('/*', routes.routeApi.routes()) + +app.use(route.routes()) + +const port = process.env.PORT || 3000 + +async function start () { + await loadFonts() + app.listen(port, () => { + console.log('Listening on localhost, port', port) + }) +} + +start() diff --git a/assets/Quotly.png b/assets/Quotly.png new file mode 100644 index 0000000000000000000000000000000000000000..4ffea5a5454564505409fcbe52fb2cf9c9a8a294 --- /dev/null +++ b/assets/Quotly.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5e491139d8a5782cc6a7667306478ea7627032b2893ec894a65350a0f18c891e +size 28918 diff --git a/assets/emoji/emoji-apple-image.json b/assets/emoji/emoji-apple-image.json new file mode 100644 index 0000000000000000000000000000000000000000..f31eda4fff3cd74784069cc4806dc455a42d73ba --- /dev/null +++ b/assets/emoji/emoji-apple-image.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3d4ea82dc010845551aaea65da156fff95389b6f35a0194281a3e7363d9ad7f +size 29380588 diff --git a/assets/emoji/emoji-blob-image.json b/assets/emoji/emoji-blob-image.json new file mode 100644 index 0000000000000000000000000000000000000000..ed9bdd146c5c88ab636f48d7ed519240c4c9e570 --- /dev/null +++ b/assets/emoji/emoji-blob-image.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d24c3f079ea99585e6005e66cf3d8f1efd0feee5af58a0474084a4dbed264d83 +size 2092162 diff --git a/assets/emoji/emoji-google-image.json b/assets/emoji/emoji-google-image.json new file mode 100644 index 0000000000000000000000000000000000000000..adfc618b852d5894bf15aa833ab25d1c89ecf3ac --- /dev/null +++ b/assets/emoji/emoji-google-image.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9cbfd8c3b184e5d3f5d456ce488c2a13f5e9cba7b45d92acc490b12234716328 +size 18892340 diff --git a/assets/emoji/emoji-joypixels-image.json b/assets/emoji/emoji-joypixels-image.json new file mode 100644 index 0000000000000000000000000000000000000000..406143ded87b4a78d1535ad7c79eb578c9b04d40 --- /dev/null +++ b/assets/emoji/emoji-joypixels-image.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f9f1323fddae35bd891dfbe5ad218380e78d197834e4a250a073257d487faad +size 18326902 diff --git a/assets/emoji/emoji-twitter-image.json b/assets/emoji/emoji-twitter-image.json new file mode 100644 index 0000000000000000000000000000000000000000..ec02ccfab420177e33214b8b7d4e16a7eff14bf2 --- /dev/null +++ b/assets/emoji/emoji-twitter-image.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:17b49c2d0b80e62d383ee6a9798da668106b4bf4a5ab56a56a370f96c280c453 +size 14470600 diff --git a/assets/fonts/.gitkeep b/assets/fonts/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/assets/icons/insert_drive_file.svg b/assets/icons/insert_drive_file.svg new file mode 100644 index 0000000000000000000000000000000000000000..fea0f0f8726e75d75c89d9d6903d7ea5d9514c78 --- /dev/null +++ b/assets/icons/insert_drive_file.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7a56b2ace1051167ee454a2f49e0db294c822fd370e79c3c7c00e3754058096 +size 196 diff --git a/assets/icons/music_note.svg b/assets/icons/music_note.svg new file mode 100644 index 0000000000000000000000000000000000000000..6d61f677188cca482e567758d2bc916e30fee107 --- /dev/null +++ b/assets/icons/music_note.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b5c610d246fc796dcd2385a5b058ae76ddaf65ccb93c18d5b13117417a6a1a8 +size 186 diff --git a/assets/icons/play_arrow.svg b/assets/icons/play_arrow.svg new file mode 100644 index 0000000000000000000000000000000000000000..15d40d20ece290219bac0928e7f1e66d6bed4807 --- /dev/null +++ b/assets/icons/play_arrow.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54077d93aca5c4b0dbc80d177dfe003e689ef3e6dfffdf306ee8027b53282c38 +size 114 diff --git a/assets/pattern_02.png b/assets/pattern_02.png new file mode 100644 index 0000000000000000000000000000000000000000..9954e1aefbf2aa7e4b81b96bbc6c8f088ee9b944 --- /dev/null +++ b/assets/pattern_02.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e1fe5a9e622a58651674a15ecd6b3cd333c809347b69e2d6eb902c2aca18bee +size 483530 diff --git a/assets/pattern_ny.png b/assets/pattern_ny.png new file mode 100644 index 0000000000000000000000000000000000000000..9076077bc38850233e53449f3e7e35ba0dc95049 --- /dev/null +++ b/assets/pattern_ny.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce1d27dfe6ac5e717d578415bdcd24824293e44288e2a51391842c5c9e083499 +size 366082 diff --git a/assets/pattern_ny_old.png b/assets/pattern_ny_old.png new file mode 100644 index 0000000000000000000000000000000000000000..d07ee7ce47b8fdbdec981d4a1f2ee65fe85b6b53 --- /dev/null +++ b/assets/pattern_ny_old.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:498deafb36f8ccd3c154b87f520b10f7edba8c0bc0dc266b9ed38541fef6688d +size 1006938 diff --git a/helpers/api.js b/helpers/api.js new file mode 100644 index 0000000000000000000000000000000000000000..e0eb033d575d8bbf4ad371b2bb83318867225e95 --- /dev/null +++ b/helpers/api.js @@ -0,0 +1,47 @@ +module.exports = async (ctx, next) => { + ctx.props = Object.assign({}, ctx.query || {}, ctx.request.body || {}) + + try { + await next() + + if (!ctx.body) { + ctx.assert(ctx.result, 404, 'Not Found') + + if (ctx.result.error) { + ctx.status = 400 + ctx.body = { + ok: false, + error: { + code: 400, + message: ctx.result.error + } + } + } else { + if (ctx.result.ext) { + if (ctx.result.ext === 'webp') ctx.response.set('content-type', 'image/webp') + if (ctx.result.ext === 'png') ctx.response.set('content-type', 'image/png') + ctx.response.set('quote-type', ctx.result.type) + ctx.response.set('quote-width', ctx.result.width) + ctx.response.set('quote-height', ctx.result.height) + ctx.body = ctx.result.image + } else { + ctx.body = { + ok: true, + result: ctx.result + } + } + } + } + } catch (error) { + console.error(error) + ctx.status = error.statusCode || error.status || 500 + ctx.body = { + ok: false, + error: { + code: ctx.status, + message: error.message, + description: error.description + } + } + } +} diff --git a/helpers/index.js b/helpers/index.js new file mode 100644 index 0000000000000000000000000000000000000000..db929b4a3c05687ebc7d7d22b997e95729065cf2 --- /dev/null +++ b/helpers/index.js @@ -0,0 +1,5 @@ +const helpersApi = require('./api') + +module.exports = { + helpersApi +} diff --git a/index.js b/index.js new file mode 100644 index 0000000000000000000000000000000000000000..292e0211e4844c6e79bffa9ed40582775f5c7aae --- /dev/null +++ b/index.js @@ -0,0 +1,2 @@ +require('dotenv').config({ path: './.env' }) +require('./app') diff --git a/methods/generate.js b/methods/generate.js new file mode 100644 index 0000000000000000000000000000000000000000..6d694fca97dca6a018b595cca60134c5d69b75cc --- /dev/null +++ b/methods/generate.js @@ -0,0 +1,339 @@ +const path = require('path') +const { QuoteGenerate } = require('../utils') +const { createCanvas, loadImage } = require('canvas') +const sharp = require('sharp') +const { parseBackgroundColor, colorLuminance, lightOrDark, hexToHsl, hslToHex } = require('../utils/quote-generate/color') +const { brands: emojiBrands } = require('../utils/emoji-image') + +const ALLOWED_EMOJI_BRANDS = new Set(Object.keys(emojiBrands)) + +let cachedPatternImage = null +async function getPatternImage () { + if (!cachedPatternImage) { + cachedPatternImage = await loadImage(path.join(__dirname, '..', 'assets', 'pattern_02.png')) + } + return cachedPatternImage +} + +const imageAlpha = (image, alpha) => { + const canvas = createCanvas(image.width, image.height) + const canvasCtx = canvas.getContext('2d') + canvasCtx.globalAlpha = alpha + canvasCtx.drawImage(image, 0, 0) + return canvas +} + +function normalizeMessage (message) { + if (!message.from) { + message.from = { id: 0 } + } + if (!message.from.photo) { + message.from.photo = {} + } + if (message.from.name !== false && !message.from.name && (message.from.first_name || message.from.last_name)) { + message.from.name = [message.from.first_name, message.from.last_name] + .filter(Boolean) + .join(' ') + } + if (message.replyMessage) { + if (!message.replyMessage.chatId) { + message.replyMessage.chatId = message.from.id || 0 + } + if (!message.replyMessage.entities) { + message.replyMessage.entities = [] + } + if (!message.replyMessage.from) { + message.replyMessage.from = { + name: message.replyMessage.name, + photo: {} + } + } else if (!message.replyMessage.from.photo) { + message.replyMessage.from.photo = {} + } + } +} + +async function drawPatternBackground (canvas, centerColor, edgeColor, patternImage, patternAlpha) { + const ctx = canvas.getContext('2d') + + const gradient = ctx.createRadialGradient( + canvas.width / 2, canvas.height / 2, 0, + canvas.width / 2, canvas.height / 2, canvas.width / 2 + ) + gradient.addColorStop(0, centerColor) + gradient.addColorStop(1, edgeColor) + + ctx.fillStyle = gradient + ctx.fillRect(0, 0, canvas.width, canvas.height) + + const pattern = ctx.createPattern(imageAlpha(patternImage, patternAlpha), 'repeat') + ctx.fillStyle = pattern + ctx.fillRect(0, 0, canvas.width, canvas.height) +} + +// Wallpaper colors derived from the bubble color. The bubble must sit ON the +// wallpaper, not dissolve into it: +// • dark bubbles → a much darker backdrop (luminance drop + vignette); +// • light bubbles → a PASTEL backdrop, like Telegram's light wallpapers: +// saturate the hue and keep lightness high — darkening a near-white just +// makes mud. Near-gray inputs fall back to a soft Telegram-ish blue. +function wallpaperColors (colorOne) { + if (lightOrDark(colorOne) === 'dark') { + return { + center: colorLuminance(colorOne, -0.35), + edge: colorLuminance(colorOne, -0.6), + patternAlpha: 0.22 + } + } + let [h, s] = hexToHsl(colorOne) + if (s < 0.08) { + h = 207 // washed-out gray/white → soft blue + s = 0.45 + } else { + s = Math.min(1, Math.max(s * 1.8, 0.35)) + } + return { + center: hslToHex(h, s, 0.8), + edge: hslToHex(h, s, 0.62), + patternAlpha: 0.18 + } +} + +module.exports = async (parm) => { + if (!parm) return { error: 'query_empty' } + if (!Array.isArray(parm.messages) || parm.messages.length < 1) return { error: 'messages_empty' } + + const botToken = parm.botToken || process.env.BOT_TOKEN + const quoteGenerate = new QuoteGenerate(botToken) + const rawScale = parseFloat(parm.scale) || 2 + const scale = Math.min(20, Math.max(1, Number.isFinite(rawScale) ? rawScale : 2)) + const rawBrand = parm.emojiBrand || 'apple' + const emojiBrand = ALLOWED_EMOJI_BRANDS.has(rawBrand) ? rawBrand : 'apple' + + const background = parseBackgroundColor(parm.backgroundColor) + + // Normalize all messages first (sync, no I/O) + const validMessages = parm.messages.filter(Boolean) + for (const message of validMessages) { + normalizeMessage(message) + } + + // Same-sender runs render with grouped corners (small radii between + // neighbours), like consecutive messages in Telegram. The avatar (and with + // it the bubble tail) belongs to the LAST message of a group only — the + // reserved left column keeps the other bubbles aligned. + for (let i = 0; i < validMessages.length; i++) { + const prevSame = i > 0 && validMessages[i - 1].chatId === validMessages[i].chatId + const nextSame = i < validMessages.length - 1 && validMessages[i + 1].chatId === validMessages[i].chatId + validMessages[i].groupPos = prevSame && nextSame ? 'middle' : prevSame ? 'last' : nextSame ? 'first' : 'single' + if (nextSame) validMessages[i].avatar = false + } + + // Generate quotes with concurrency limit to avoid Telegram API rate limits + const CONCURRENCY = 3 + const quoteImages = new Array(validMessages.length).fill(null) + let running = 0 + let nextIndex = 0 + + await new Promise((resolve) => { + function runNext () { + while (running < CONCURRENCY && nextIndex < validMessages.length) { + const index = nextIndex++ + running++ + + quoteGenerate.generate( + background.colorOne, + background.colorTwo, + validMessages[index], + parm.width, + parm.height, + scale, + emojiBrand + ).then((canvas) => { + if (canvas) quoteImages[index] = canvas + else console.warn('Failed to generate quote for message, skipping') + }).catch((error) => { + console.error('Error generating quote for message:', error.message) + }).finally(() => { + running-- + if (nextIndex >= validMessages.length && running === 0) resolve() + else runNext() + }) + } + if (validMessages.length === 0) resolve() + } + runNext() + }) + + // Filter nulls (failed messages) while preserving order, keeping each + // image paired with its source message (for grouped-margin decisions). + const pairs = validMessages + .map((message, i) => ({ message, image: quoteImages[i] })) + .filter((p) => p.image) + const filteredImages = pairs.map((p) => p.image) + + if (filteredImages.length === 0) { + return { error: 'empty_messages' } + } + + let canvasQuote + + if (filteredImages.length > 1) { + let width = 0 + let height = 0 + + for (let index = 0; index < filteredImages.length; index++) { + if (filteredImages[index].width > width) width = filteredImages[index].width + height += filteredImages[index].height + } + + // Tighter spacing inside a same-sender group, roomier between groups. + const margins = [] + let totalMargin = 0 + for (let index = 0; index < pairs.length - 1; index++) { + const grouped = pairs[index].message.chatId === pairs[index + 1].message.chatId + const m = (grouped ? 2 : 6) * scale + margins.push(m) + totalMargin += m + } + + const canvas = createCanvas(width, height + totalMargin) + const canvasCtx = canvas.getContext('2d') + + let imageY = 0 + for (let index = 0; index < filteredImages.length; index++) { + canvasCtx.drawImage(filteredImages[index], 0, imageY) + imageY += filteredImages[index].height + (margins[index] || 0) + } + canvasQuote = canvas + } else { + canvasQuote = filteredImages[0] + } + + let quoteImage + + let { type, format, ext } = parm + + if (!type && ext) type = 'png' + if (type !== 'image' && type !== 'stories' && canvasQuote.height > 1024 * 2) type = 'png' + + if (type === 'quote') { + const downPadding = 75 + const maxWidth = 512 + const maxHeight = 512 + + const imageQuoteSharp = sharp(canvasQuote.toBuffer()) + + if (canvasQuote.height > canvasQuote.width) imageQuoteSharp.resize({ height: maxHeight }) + else imageQuoteSharp.resize({ width: maxWidth }) + + const canvasImage = await loadImage(await imageQuoteSharp.toBuffer()) + + const canvasPadding = createCanvas(canvasImage.width, canvasImage.height + downPadding) + const canvasPaddingCtx = canvasPadding.getContext('2d') + canvasPaddingCtx.drawImage(canvasImage, 0, 0) + + const imageSharp = sharp(canvasPadding.toBuffer()) + + if (canvasPadding.height >= canvasPadding.width) imageSharp.resize({ height: maxHeight }) + else imageSharp.resize({ width: maxWidth }) + + if (format === 'png') quoteImage = await imageSharp.png().toBuffer() + else quoteImage = await imageSharp.webp({ lossless: true, force: true }).toBuffer() + } else if (type === 'image') { + const heightPadding = 75 * scale + const widthPadding = 95 * scale + + // Draw canvas-to-canvas directly — no need for toBuffer() -> loadImage() round-trip + const canvasPic = createCanvas(canvasQuote.width + widthPadding, canvasQuote.height + heightPadding) + const canvasPicCtx = canvasPic.getContext('2d') + + const patternImage = await getPatternImage() + const wp = wallpaperColors(background.colorOne) + await drawPatternBackground(canvasPic, wp.center, wp.edge, patternImage, wp.patternAlpha) + + canvasPicCtx.shadowOffsetX = 8 + canvasPicCtx.shadowOffsetY = 8 + canvasPicCtx.shadowBlur = 13 + canvasPicCtx.shadowColor = 'rgba(0, 0, 0, 0.5)' + + canvasPicCtx.drawImage(canvasQuote, widthPadding / 2, heightPadding / 2) + + canvasPicCtx.shadowOffsetX = 0 + canvasPicCtx.shadowOffsetY = 0 + canvasPicCtx.shadowBlur = 0 + canvasPicCtx.shadowColor = 'rgba(0, 0, 0, 0)' + + canvasPicCtx.fillStyle = 'rgba(0, 0, 0, 0.3)' + canvasPicCtx.font = `${8 * scale}px Noto Sans` + canvasPicCtx.textAlign = 'right' + canvasPicCtx.fillText('@QuotLyBot', canvasPic.width - 25, canvasPic.height - 25) + + quoteImage = await sharp(canvasPic.toBuffer()).png({ lossless: true, force: true }).toBuffer() + } else if (type === 'stories') { + const canvasPic = createCanvas(720, 1280) + const canvasPicCtx = canvasPic.getContext('2d') + + const patternImage = await getPatternImage() + const storyWp = wallpaperColors(background.colorOne) + await drawPatternBackground(canvasPic, storyWp.center, storyWp.edge, patternImage, storyWp.patternAlpha) + + canvasPicCtx.shadowOffsetX = 8 + canvasPicCtx.shadowOffsetY = 8 + canvasPicCtx.shadowBlur = 13 + canvasPicCtx.shadowColor = 'rgba(0, 0, 0, 0.5)' + + const minPadding = 110 + const maxW = canvasPic.width - minPadding * 2 + const maxH = canvasPic.height - minPadding * 2 + + // Use canvas dimensions directly to decide if resize is needed — avoid toBuffer() -> loadImage() + let drawSource = canvasQuote + if (canvasQuote.width > maxW || canvasQuote.height > maxH) { + const resizedBuffer = await sharp(canvasQuote.toBuffer()).resize({ + width: maxW, + height: maxH, + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 } + }).toBuffer() + drawSource = await loadImage(resizedBuffer) + } + + const imageX = (canvasPic.width - drawSource.width) / 2 + const imageY = (canvasPic.height - drawSource.height) / 2 + + canvasPicCtx.drawImage(drawSource, imageX, imageY) + + canvasPicCtx.shadowOffsetX = 0 + canvasPicCtx.shadowOffsetY = 0 + canvasPicCtx.shadowBlur = 0 + + canvasPicCtx.fillStyle = 'rgba(0, 0, 0, 0.4)' + canvasPicCtx.font = `${16 * scale}px Noto Sans` + canvasPicCtx.textAlign = 'center' + canvasPicCtx.translate(70, canvasPic.height / 2) + canvasPicCtx.rotate(-Math.PI / 2) + canvasPicCtx.fillText('@QuotLyBot', 0, 0) + + quoteImage = await sharp(canvasPic.toBuffer()).png({ lossless: true, force: true }).toBuffer() + } else { + quoteImage = canvasQuote.toBuffer() + } + + // Use sharp metadata only when we went through sharp pipeline, otherwise use canvas dimensions + let width, height + if (type === 'quote' || type === 'image' || type === 'stories') { + const imageMetadata = await sharp(quoteImage).metadata() + width = imageMetadata.width + height = imageMetadata.height + } else { + width = canvasQuote.width + height = canvasQuote.height + } + + let image + if (ext) image = quoteImage + else image = quoteImage.toString('base64') + + return { image, type, width, height, ext } +} diff --git a/methods/index.js b/methods/index.js new file mode 100644 index 0000000000000000000000000000000000000000..ec3ae4f0d16f7373bb475b2815727556793ff389 --- /dev/null +++ b/methods/index.js @@ -0,0 +1,38 @@ +const crypto = require('crypto') +const LRU = require('lru-cache') +const sizeof = require('object-sizeof') + +const generate = require('./generate') + +const methods = { + generate +} + +const cache = new LRU({ + max: 1000 * 1000 * 1000, + length: (n) => { return sizeof(n) }, + maxAge: 1000 * 60 * 45 +}) + +module.exports = async (method, parm) => { + if (methods[method]) { + let methodResult = {} + + let cacheString = crypto.createHash('md5').update(JSON.stringify({ method, parm })).digest('hex') + const methodResultCache = cache.get(cacheString) + + if (!methodResultCache) { + methodResult = await methods[method](parm) + + if (!methodResult.error) cache.set(cacheString, methodResult) + } else { + methodResult = methodResultCache + } + + return methodResult + } else { + return { + error: 'method not found' + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..d653bdf764ce15aac6b53b5a783b32f0b3aaad31 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5390 @@ +{ + "name": "quote-api", + "version": "0.14.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "quote-api", + "version": "0.14.0", + "license": "MIT", + "dependencies": { + "canvas": "^2.8.0", + "dotenv": "^7.0.0", + "emoji-db": "^16.0.1", + "jimp": "^1.6.0", + "jsdom": "^16.5.3", + "koa": "^2.11.0", + "koa-bodyparser": "^4.2.1", + "koa-logger": "^3.2.1", + "koa-ratelimit": "^4.2.1", + "koa-response-time": "^2.1.0", + "koa-router": "^7.4.0", + "lru-cache": "^5.1.1", + "object-sizeof": "^1.6.0", + "runes": "^0.4.3", + "sharp": "^0.32.5", + "smartcrop-sharp": "^2.0.7", + "telegraf": "^3.38.0" + }, + "devDependencies": { + "eslint": "^8.6.0", + "eslint-config-standard": "^12.0.0", + "eslint-plugin-import": "^2.17.3", + "eslint-plugin-node": "^9.1.0", + "eslint-plugin-promise": "^4.1.1", + "eslint-plugin-standard": "^4.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "dev": true + }, + "node_modules/@jimp/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.0.tgz", + "integrity": "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w==", + "dependencies": { + "@jimp/file-ops": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "await-to-js": "^3.0.0", + "exif-parser": "^0.1.12", + "file-type": "^16.0.0", + "mime": "3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/diff": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.0.tgz", + "integrity": "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw==", + "dependencies": { + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "pixelmatch": "^5.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/file-ops": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.0.tgz", + "integrity": "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ==", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-bmp": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.0.tgz", + "integrity": "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "bmp-ts": "^1.0.9" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-gif": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.0.tgz", + "integrity": "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "gifwrap": "^0.10.1", + "omggif": "^1.0.10" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-jpeg": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.0.tgz", + "integrity": "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "jpeg-js": "^0.4.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-png": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.0.tgz", + "integrity": "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "pngjs": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-tiff": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.0.tgz", + "integrity": "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "utif2": "^4.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blit": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.0.tgz", + "integrity": "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA==", + "dependencies": { + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blur": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.0.tgz", + "integrity": "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/utils": "1.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-circle": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.0.tgz", + "integrity": "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw==", + "dependencies": { + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-color": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.0.tgz", + "integrity": "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "tinycolor2": "^1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-contain": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.0.tgz", + "integrity": "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-blit": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-cover": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.0.tgz", + "integrity": "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-crop": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-crop": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.0.tgz", + "integrity": "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-displace": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.0.tgz", + "integrity": "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q==", + "dependencies": { + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-dither": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.0.tgz", + "integrity": "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ==", + "dependencies": { + "@jimp/types": "1.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-fisheye": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.0.tgz", + "integrity": "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA==", + "dependencies": { + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-flip": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.0.tgz", + "integrity": "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg==", + "dependencies": { + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-hash": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.0.tgz", + "integrity": "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/js-bmp": "1.6.0", + "@jimp/js-jpeg": "1.6.0", + "@jimp/js-png": "1.6.0", + "@jimp/js-tiff": "1.6.0", + "@jimp/plugin-color": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "any-base": "^1.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-mask": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.0.tgz", + "integrity": "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA==", + "dependencies": { + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-print": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.0.tgz", + "integrity": "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/js-jpeg": "1.6.0", + "@jimp/js-png": "1.6.0", + "@jimp/plugin-blit": "1.6.0", + "@jimp/types": "1.6.0", + "parse-bmfont-ascii": "^1.0.6", + "parse-bmfont-binary": "^1.0.6", + "parse-bmfont-xml": "^1.1.6", + "simple-xml-to-json": "^1.2.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-quantize": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.0.tgz", + "integrity": "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg==", + "dependencies": { + "image-q": "^4.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-resize": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.0.tgz", + "integrity": "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-rotate": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.0.tgz", + "integrity": "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-crop": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-threshold": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.0.tgz", + "integrity": "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-color": "1.6.0", + "@jimp/plugin-hash": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/types": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.0.tgz", + "integrity": "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg==", + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/utils": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.0.tgz", + "integrity": "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA==", + "dependencies": { + "@jimp/types": "1.6.0", + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, + "node_modules/@types/node": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", + "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", + "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "dependencies": { + "acorn": "^7.1.1", + "acorn-walk": "^7.1.1" + } + }, + "node_modules/acorn-globals/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", + "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/await-to-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz", + "integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/b4a": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", + "integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/bare-events": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.2.2.tgz", + "integrity": "sha512-h7z00dWdG0PYOQEvChhOSWvOfkIKsdZGkWr083FgN/HyoQuebSew/cgirYqh9SCuy/hRvxc5Vy6Fw8xAmYHLkQ==", + "optional": true + }, + "node_modules/bare-fs": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.0.tgz", + "integrity": "sha512-TNFqa1B4N99pds2a5NYHR15o0ZpdNKbAeKTE/+G6ED/UeOavv8RY3dr/Fu99HW3zU3pXpo2kDNO8Sjsm2esfOw==", + "optional": true, + "dependencies": { + "bare-events": "^2.0.0", + "bare-path": "^2.0.0", + "bare-stream": "^1.0.0" + } + }, + "node_modules/bare-os": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.3.0.tgz", + "integrity": "sha512-oPb8oMM1xZbhRQBngTgpcQ5gXw6kjOaRsSWsIeNyRxGed2w/ARyP7ScBYpWR1qfX2E5rS3gBw6OWcSQo+s+kUg==", + "optional": true + }, + "node_modules/bare-path": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz", + "integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==", + "optional": true, + "dependencies": { + "bare-os": "^2.1.0" + } + }, + "node_modules/bare-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-1.0.0.tgz", + "integrity": "sha512-KhNUoDL40iP4gFaLSsoGE479t0jHijfYdIcxRn/XtezA2BaUD0NRf/JGRpsMq6dMNM+SrCrB0YSSo/5wBY4rOQ==", + "optional": true, + "dependencies": { + "streamx": "^2.16.1" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bmp-ts": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz", + "integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cache-content-type": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", + "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", + "dependencies": { + "mime-types": "^2.1.18", + "ylru": "^1.2.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/co-body": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/co-body/-/co-body-6.1.0.tgz", + "integrity": "sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==", + "dependencies": { + "inflation": "^2.0.0", + "qs": "^6.5.2", + "raw-body": "^2.3.3", + "type-is": "^1.6.16" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-to": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/copy-to/-/copy-to-2.0.1.tgz", + "integrity": "sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==" + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssom": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", + "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==" + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==" + }, + "node_modules/data-urls": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", + "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", + "dependencies": { + "abab": "^2.0.3", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + }, + "node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==" + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/domexception": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", + "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", + "deprecated": "Use your platform's native DOMException instead", + "dependencies": { + "webidl-conversions": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/domexception/node_modules/webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-7.0.0.tgz", + "integrity": "sha512-M3NhsLbV1i6HuGzBUH8vXrtxOk+tWmzWKDMbAVSUp3Zsjm7ywFeuwrUXhmhQyRK1q5B5GGy7hcXPbj3bnfZg2g==", + "engines": { + "node": ">=6" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/emoji-db": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/emoji-db/-/emoji-db-16.0.1.tgz", + "integrity": "sha512-dZOQpVH3O4qRLQRYgX4DY31QL/MUzweBSMItmPD7adxVKHF2gsCdyfOUkn7Bnv1MexOsWOrREVf45UCEUHdiJA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-abstract": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-standard": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-12.0.0.tgz", + "integrity": "sha512-COUz8FnXhqFitYj4DTqHzidjIL/t4mumGZto5c7DrBpvWoie+Sn3P4sLEzUGeYhRElWuFEf8K1S1EfvD1vixCQ==", + "dev": true, + "peerDependencies": { + "eslint": ">=5.0.0", + "eslint-plugin-import": ">=2.13.0", + "eslint-plugin-node": ">=7.0.0", + "eslint-plugin-promise": ">=4.0.0", + "eslint-plugin-standard": ">=4.0.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz", + "integrity": "sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-es": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-1.4.1.tgz", + "integrity": "sha512-5fa/gR2yR3NxQf+UXkeLeP8FBBl6tSgdrAz1+cF84v1FMM4twGwQoqTnn+QxFLcPOrF4pdKEJKDB/q9GoyJrCA==", + "dev": true, + "dependencies": { + "eslint-utils": "^1.4.2", + "regexpp": "^2.0.1" + }, + "engines": { + "node": ">=6.5.0" + }, + "peerDependencies": { + "eslint": ">=4.19.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", + "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.8.0", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", + "semver": "^6.3.1", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-node": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-9.2.0.tgz", + "integrity": "sha512-2abNmzAH/JpxI4gEOwd6K8wZIodK3BmHbTxz4s79OIYwwIt2gkpEXlAouJXu4H1c9ySTnRso0tsuthSOZbUMlA==", + "dev": true, + "dependencies": { + "eslint-plugin-es": "^1.4.1", + "eslint-utils": "^1.4.2", + "ignore": "^5.1.1", + "minimatch": "^3.0.4", + "resolve": "^1.10.1", + "semver": "^6.1.0" + }, + "engines": { + "node": ">=8.10.0" + }, + "peerDependencies": { + "eslint": ">=5.16.0" + } + }, + "node_modules/eslint-plugin-node/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-promise": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-4.3.1.tgz", + "integrity": "sha512-bY2sGqyptzFBDLh/GMbAxfdJC+b0f23ME63FOE4+Jao0oZ3E1LEwFtWJX/1pGMJLiTtrSSern2CRM/g+dfc0eQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-plugin-standard": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-standard/-/eslint-plugin-standard-4.1.0.tgz", + "integrity": "sha512-ZL7+QRixjTR6/528YNGyDotyffm5OQst/sGxKDwGb9Uqs4In5Egi4+jbobhqJoyoCM6/7v/1A5fhQ7ScMtDjaQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "peerDependencies": { + "eslint": ">=5.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz", + "integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/exif-parser": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", + "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==" + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gifwrap": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz", + "integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==", + "dependencies": { + "image-q": "^4.0.0", + "omggif": "^1.0.10" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", + "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", + "dependencies": { + "whatwg-encoding": "^1.0.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/humanize-number": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/humanize-number/-/humanize-number-0.0.2.tgz", + "integrity": "sha512-un3ZAcNQGI7RzaWGZzQDH47HETM4Wrj6z6E4TId8Yeq9w5ZKUVB1nrT2jwFheTUjEmqcgTjXDc959jum+ai1kQ==" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-q": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz", + "integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==", + "dependencies": { + "@types/node": "16.9.1" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflation": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/inflation/-/inflation-2.1.0.tgz", + "integrity": "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "dev": true, + "dependencies": { + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jimp": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.0.tgz", + "integrity": "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/diff": "1.6.0", + "@jimp/js-bmp": "1.6.0", + "@jimp/js-gif": "1.6.0", + "@jimp/js-jpeg": "1.6.0", + "@jimp/js-png": "1.6.0", + "@jimp/js-tiff": "1.6.0", + "@jimp/plugin-blit": "1.6.0", + "@jimp/plugin-blur": "1.6.0", + "@jimp/plugin-circle": "1.6.0", + "@jimp/plugin-color": "1.6.0", + "@jimp/plugin-contain": "1.6.0", + "@jimp/plugin-cover": "1.6.0", + "@jimp/plugin-crop": "1.6.0", + "@jimp/plugin-displace": "1.6.0", + "@jimp/plugin-dither": "1.6.0", + "@jimp/plugin-fisheye": "1.6.0", + "@jimp/plugin-flip": "1.6.0", + "@jimp/plugin-hash": "1.6.0", + "@jimp/plugin-mask": "1.6.0", + "@jimp/plugin-print": "1.6.0", + "@jimp/plugin-quantize": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/plugin-rotate": "1.6.0", + "@jimp/plugin-threshold": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", + "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", + "dependencies": { + "abab": "^2.0.5", + "acorn": "^8.2.4", + "acorn-globals": "^6.0.0", + "cssom": "^0.4.4", + "cssstyle": "^2.3.0", + "data-urls": "^2.0.0", + "decimal.js": "^10.2.1", + "domexception": "^2.0.1", + "escodegen": "^2.0.0", + "form-data": "^3.0.0", + "html-encoding-sniffer": "^2.0.1", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^2.0.0", + "webidl-conversions": "^6.1.0", + "whatwg-encoding": "^1.0.5", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.5.0", + "ws": "^7.4.6", + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/koa": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.3.tgz", + "integrity": "sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==", + "dependencies": { + "accepts": "^1.3.5", + "cache-content-type": "^1.0.0", + "content-disposition": "~0.5.2", + "content-type": "^1.0.4", + "cookies": "~0.9.0", + "debug": "^4.3.2", + "delegates": "^1.0.0", + "depd": "^2.0.0", + "destroy": "^1.0.4", + "encodeurl": "^1.0.2", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.3.0", + "http-errors": "^1.6.3", + "is-generator-function": "^1.0.7", + "koa-compose": "^4.1.0", + "koa-convert": "^2.0.0", + "on-finished": "^2.3.0", + "only": "~0.0.2", + "parseurl": "^1.3.2", + "statuses": "^1.5.0", + "type-is": "^1.6.16", + "vary": "^1.1.2" + }, + "engines": { + "node": "^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4" + } + }, + "node_modules/koa-bodyparser": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/koa-bodyparser/-/koa-bodyparser-4.4.1.tgz", + "integrity": "sha512-kBH3IYPMb+iAXnrxIhXnW+gXV8OTzCu8VPDqvcDHW9SQrbkHmqPQtiZwrltNmSq6/lpipHnT7k7PsjlVD7kK0w==", + "dependencies": { + "co-body": "^6.0.0", + "copy-to": "^2.0.1", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==" + }, + "node_modules/koa-convert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", + "dependencies": { + "co": "^4.6.0", + "koa-compose": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/koa-logger": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa-logger/-/koa-logger-3.2.1.tgz", + "integrity": "sha512-MjlznhLLKy9+kG8nAXKJLM0/ClsQp/Or2vI3a5rbSQmgl8IJBQO0KI5FA70BvW+hqjtxjp49SpH2E7okS6NmHg==", + "dependencies": { + "bytes": "^3.1.0", + "chalk": "^2.4.2", + "humanize-number": "0.0.2", + "passthrough-counter": "^1.0.0" + }, + "engines": { + "node": ">= 7.6.0" + } + }, + "node_modules/koa-logger/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/koa-logger/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/koa-logger/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/koa-logger/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/koa-logger/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/koa-logger/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/koa-logger/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/koa-ratelimit": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/koa-ratelimit/-/koa-ratelimit-4.3.0.tgz", + "integrity": "sha512-Q6vothwYLyebWcdqplHwqJ9nd+QUqKmbRz1CqzCCUhNsEpEvGXaY7ZVXl3g4RiQq7LnAj643zlWD1WX8UllQBw==", + "dependencies": { + "debug": "^4.1.1", + "lodash.defaults": "^4.2.0", + "ms": "^2.1.1", + "ratelimiter": "^3.2.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/koa-response-time": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/koa-response-time/-/koa-response-time-2.1.0.tgz", + "integrity": "sha512-W2YvnmqmmdEwjJh2rvUPqDD+yjnKvIQry6JhV9dHEwGpfcRzjmQBRVNg62Czbvut/Dpf+l7b9uFauet157UeUQ==" + }, + "node_modules/koa-router": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/koa-router/-/koa-router-7.4.0.tgz", + "integrity": "sha512-IWhaDXeAnfDBEpWS6hkGdZ1ablgr6Q6pGdXCyK38RbzuH4LkUOpPqPw+3f8l8aTDrQmBQ7xJc0bs2yV4dzcO+g==", + "deprecated": "**IMPORTANT 10x+ PERFORMANCE UPGRADE**: Please upgrade to v12.0.1+ as we have fixed an issue with debuglog causing 10x slower router benchmark performance, see https://github.com/koajs/router/pull/173", + "dependencies": { + "debug": "^3.1.0", + "http-errors": "^1.3.1", + "koa-compose": "^3.0.0", + "methods": "^1.0.1", + "path-to-regexp": "^1.1.1", + "urijs": "^1.19.0" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/koa-router/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/koa-router/node_modules/koa-compose": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-3.2.1.tgz", + "integrity": "sha512-8gen2cvKHIZ35eDEik5WOo8zbVp9t4cP8p4hW4uE55waxolLRexKKrqfCpwhGVppnB40jWeF8bZeTVg99eZgPw==", + "dependencies": { + "any-promise": "^1.1.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/module-alias": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/module-alias/-/module-alias-2.2.3.tgz", + "integrity": "sha512-23g5BFj4zdQL/b6tor7Ji+QY4pEfNH784BMslY9Qb0UnJWRAt+lQGLYmRaM0KDBwIG23ffEBELhZDP2rhi9f/Q==" + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/nan": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.19.0.tgz", + "integrity": "sha512-nO1xXxfh/RWNxfd/XPfbIfFk5vgLsAxUR9y5O0cHMJu/AW9U95JLXqthYHjEp+8gQ5p96K9jUp8nbVOxCdRbtw==" + }, + "node_modules/napi-build-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.62.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.62.0.tgz", + "integrity": "sha512-CPMcGa+y33xuL1E0TcNIu4YyaZCxnnvkVaEXrsosR3FxN+fV8xvb7Mzpb7IgKler10qeMkE6+Dp8qJhpzdq35g==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz", + "integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object-sizeof": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/object-sizeof/-/object-sizeof-1.6.3.tgz", + "integrity": "sha512-LGtilAKuDGKCcvu1Xg3UvAhAeJJlFmblo3faltmOQ80xrGwAHxnauIXucalKdTEksHp/Pq9tZGz1hfyEmjFJPQ==", + "dependencies": { + "buffer": "^5.6.0" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", + "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-bmfont-ascii": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", + "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==" + }, + "node_modules/parse-bmfont-binary": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz", + "integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==" + }, + "node_modules/parse-bmfont-xml": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz", + "integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==", + "dependencies": { + "xml-parse-from-string": "^1.0.0", + "xml2js": "^0.5.0" + } + }, + "node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/passthrough-counter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passthrough-counter/-/passthrough-counter-1.0.0.tgz", + "integrity": "sha512-Wy8PXTLqPAN0oEgBrlnsXPMww3SYJ44tQ8aVrGAI4h4JZYCS0oYqsPqtPR8OhJpv6qFbpbB7XAn0liKV7EXubA==" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/pixelmatch": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz", + "integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==", + "dependencies": { + "pngjs": "^6.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pixelmatch/node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz", + "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^1.0.1", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/prebuild-install/node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prebuild-install/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prebuild-install/node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", + "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" + }, + "node_modules/ratelimiter": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/ratelimiter/-/ratelimiter-3.4.1.tgz", + "integrity": "sha512-5FJbRW/Jkkdk29ksedAfWFkQkhbUrMx3QJGwMKAypeIiQf4yrLW+gtPKZiaWt4zPrtw1uGufOjGO7UGM6VllsQ==" + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", + "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", + "dependencies": { + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpp": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz", + "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", + "dev": true, + "engines": { + "node": ">=6.5.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/runes": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/runes/-/runes-0.4.3.tgz", + "integrity": "sha512-K6p9y4ZyL9wPzA+PMDloNQPfoDGTiFYDvdlXznyGKgD10BJpcAosvATKrExRKOrNLgD8E7Um7WGW0lxsnOuNLg==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sandwich-stream": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/sandwich-stream/-/sandwich-stream-2.0.2.tgz", + "integrity": "sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" + }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sharp/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sharp/node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-xml-to-json": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.3.tgz", + "integrity": "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA==", + "engines": { + "node": ">=20.12.2" + } + }, + "node_modules/smartcrop": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/smartcrop/-/smartcrop-2.0.5.tgz", + "integrity": "sha512-aXoHTM8XlC51g96kgZkYxZ2mx09/ibOrIVLiUNOFozV/MHmFSgEr1/5CKVBoFD5vd+re2wSy0xra21CyjRITzA==" + }, + "node_modules/smartcrop-sharp": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/smartcrop-sharp/-/smartcrop-sharp-2.0.8.tgz", + "integrity": "sha512-0rpK/ddGAx7ou6YqqvJRkw/xyR4qgqmJeUQ2EWDnN3hSuJUk4InPGE6Sa45+TYOfGEEQJrchjF1ujBPMhoe9iA==", + "dependencies": { + "smartcrop": "^2.0.5" + }, + "peerDependencies": { + "sharp": "^0.32.5" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/streamx": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.16.1.tgz", + "integrity": "sha512-m9QYj6WygWyWa3H1YY69amr4nVgy61xfjys7xO7kviL5rfIEc2naf+ewFiOA+aEJD7y0JO3h2GoiUv4TDwEGzQ==", + "dependencies": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz", + "integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^2.1.1", + "bare-path": "^2.1.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/telegraf": { + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/telegraf/-/telegraf-3.40.0.tgz", + "integrity": "sha512-wulVjRHrX2zQwbk1/jzg3Ll9Kr0xC7ofXsRPJgcV2yoCFx5oSU41ZaDKNRPsLhjXxW4s9iNM2S2N/ESyiZxTMQ==", + "dependencies": { + "debug": "^4.0.1", + "minimist": "^1.2.0", + "module-alias": "^2.2.2", + "node-fetch": "^2.2.0", + "sandwich-stream": "^2.0.1", + "typegram": "^3.10.0" + }, + "bin": { + "telegraf": "bin/telegraf" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typegram": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/typegram/-/typegram-3.12.0.tgz", + "integrity": "sha512-/VrU0sJv8BdOsBIpYT4w35C7dPg5YyKP6fLiYN9qYXRZ86TVIiw0ZypkzElTAfDVsJtJSluGAufUrcX7VRSIYQ==" + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==" + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/utif2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz", + "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==", + "dependencies": { + "pako": "^1.0.11" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", + "dependencies": { + "browser-process-hrtime": "^1.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", + "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", + "dependencies": { + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/webidl-conversions": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "engines": { + "node": ">=10.4" + } + }, + "node_modules/whatwg-encoding": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", + "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "dependencies": { + "iconv-lite": "0.4.24" + } + }, + "node_modules/whatwg-mimetype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==" + }, + "node_modules/whatwg-url": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "dependencies": { + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==" + }, + "node_modules/xml-parse-from-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", + "integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==" + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, + "node_modules/ylru": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz", + "integrity": "sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..6115d6f915a16185e6d94606c84ffc73dbeae371 --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "quote-api", + "version": "0.14.0", + "description": "Quote image generation API", + "main": "index.js", + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "start": "node index.js", + "lint": "eslint --ext js .", + "lint:fix": "eslint --fix --ext js .", + "test": "node test-metrics.js && node test-style.js && node test-attachments.js && node test-fixes.js", + "test:visual": "node test-real.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/LyoSU/quote-api.git" + }, + "author": "LyoSU", + "license": "MIT", + "bugs": { + "url": "https://github.com/LyoSU/quote-api/issues" + }, + "homepage": "https://github.com/LyoSU/quote-api#readme", + "dependencies": { + "canvas": "^2.8.0", + "dotenv": "^7.0.0", + "emoji-db": "^16.0.1", + "jimp": "^1.6.0", + "jsdom": "^16.5.3", + "koa": "^2.11.0", + "koa-bodyparser": "^4.2.1", + "koa-logger": "^3.2.1", + "koa-ratelimit": "^4.2.1", + "koa-response-time": "^2.1.0", + "koa-router": "^7.4.0", + "lru-cache": "^5.1.1", + "object-sizeof": "^1.6.0", + "runes": "^0.4.3", + "sharp": "^0.32.5", + "smartcrop-sharp": "^2.0.7", + "telegraf": "^3.38.0" + }, + "devDependencies": { + "eslint": "^8.6.0", + "eslint-config-standard": "^12.0.0", + "eslint-plugin-import": "^2.17.3", + "eslint-plugin-node": "^9.1.0", + "eslint-plugin-promise": "^4.1.1", + "eslint-plugin-standard": "^4.0.0" + } +} diff --git a/routes/api.js b/routes/api.js new file mode 100644 index 0000000000000000000000000000000000000000..0623aa3dc6d245212e08b729a73639d8eea8a475 --- /dev/null +++ b/routes/api.js @@ -0,0 +1,14 @@ +const Router = require('koa-router') +const api = new Router() + +const method = require('../methods') + +const apiHandle = async (ctx) => { + const methodWithExt = ctx.params[0].match(/(.*).(png|webp)/) + if (methodWithExt) ctx.props.ext = methodWithExt[2] + ctx.result = await method(methodWithExt ? methodWithExt[1] : ctx.params[0], ctx.props) +} + +api.post('/', apiHandle) + +module.exports = api diff --git a/routes/index.js b/routes/index.js new file mode 100644 index 0000000000000000000000000000000000000000..a4f4147bb7c598556f313a6d99fa966560ec7abf --- /dev/null +++ b/routes/index.js @@ -0,0 +1,5 @@ +const routeApi = require('./api') + +module.exports = { + routeApi +} diff --git a/scripts/send-preview-stickers.js b/scripts/send-preview-stickers.js new file mode 100644 index 0000000000000000000000000000000000000000..1f1b8ac05848c8edccf2ef3d4fdcb4c125677846 --- /dev/null +++ b/scripts/send-preview-stickers.js @@ -0,0 +1,100 @@ +// Надсилає прев'ю-стікери редизайну в Telegram. +// Run: node scripts/send-preview-stickers.js [chat_id] + +require('dotenv').config() +const { Telegram } = require('telegraf') +const generate = require('../methods/generate') + +const CHAT_ID = Number(process.argv[2] || 66478514) +const BOT_TOKEN = process.env.BOT_TOKEN +const BG = '//#252e44' // gradient pair (lighten/darken) → glass bubble + +async function main () { + if (!BOT_TOKEN) throw new Error('BOT_TOKEN missing in .env') + const telegram = new Telegram(BOT_TOKEN) + + const cases = [ + ['текст + реплай', [{ + chatId: 101, + avatar: true, + from: { id: 101, name: 'Олена Ковальчук' }, + replyMessage: { name: 'Андрій', text: 'давай завтра о 10:00?', chatId: 102 }, + text: 'Домовились, скину нагадування 🔥' + }]], + ['voice', [{ + chatId: 103, + avatar: true, + from: { id: 103, name: 'Макс Голос' }, + voice: { waveform: Array.from({ length: 60 }, (_, i) => 4 + ((i * 13) % 28), 0), duration: 83 } + }]], + ['документ', [{ + chatId: 104, + avatar: true, + from: { id: 104, name: 'Бухгалтерія' }, + document: { file_name: 'квартальний_звіт_2026.pdf', file_size: 2.4 * 1024 * 1024 }, + text: 'фінальна версія' + }]], + ['музика', [{ + chatId: 105, + avatar: true, + from: { id: 105, name: 'Меломан' }, + audio: { title: 'Водограй', performer: 'Володимир Івасюк', duration: 215 } + }]], + ['відео', [{ + chatId: 106, + avatar: true, + from: { id: 106, name: 'Відеограф' }, + media: { url: 'https://picsum.photos/seed/quoteapi/640/360' }, + mediaType: 'video', + mediaDuration: 42, + text: 'подивіться до кінця 🔥' + }]], + ['діалог (групування)', [ + { + chatId: 107, + avatar: true, + from: { id: 107, name: 'assasinfil' }, + text: 'Хто знає як зробити NFC клон?' + }, + { + chatId: 107, + avatar: true, + from: { id: 107, name: 'assasinfil' }, + text: 'Треба Flipper Zero для цього' + }, + { + chatId: 108, + avatar: true, + from: { id: 108, name: 'nowiks' }, + replyMessage: { name: 'assasinfil', text: 'Хто знає як зробити NFC клон?', chatId: 107 }, + text: 'Ну получить можна… не понял..' + } + ]] + ] + + await telegram.sendMessage(CHAT_ID, '🎨 Прев\'ю редизайну цитат — глянь стікери нижче') + + for (const [label, messages] of cases) { + try { + const result = await generate({ + botToken: BOT_TOKEN, + messages, + type: 'quote', + format: 'webp', + ext: 'webp', + scale: 2, + backgroundColor: BG + }) + if (result.error) throw new Error(result.error) + await telegram.sendSticker(CHAT_ID, { source: Buffer.from(result.image) }) + console.log(`✓ ${label} (${result.width}x${result.height})`) + } catch (err) { + console.error(`✗ ${label}: ${err.message}`) + } + } +} + +main().catch((err) => { + console.error('FAIL:', err.message) + process.exit(1) +}) diff --git a/scripts/update-emoji.js b/scripts/update-emoji.js new file mode 100644 index 0000000000000000000000000000000000000000..c602cfaa90fa7c86d1b3cfb826d6639fca6c0e44 --- /dev/null +++ b/scripts/update-emoji.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +/** + * Downloads emoji images from emoji-datasource CDN (jsdelivr) and saves them + * as base64 JSON files for each brand. + * + * Supported brands via emoji-datasource: apple, google, twitter + * Unsupported (kept as-is): joypixels, blob + * + * Usage: + * node scripts/update-emoji.js # update all supported brands + * node scripts/update-emoji.js apple # update only apple + * node scripts/update-emoji.js google twitter # update specific brands + */ + +const path = require('path') +const fs = require('fs') +const https = require('https') + +const EmojiDbLib = require('emoji-db') +const emojiDb = new EmojiDbLib({ useDefaultDb: true }) + +const EMOJI_DIR = path.resolve(__dirname, '../assets/emoji/') + +const CDN_VERSION = '16.0.0' +const CDN_SIZE = 64 // px +const CONCURRENCY = 50 + +const SUPPORTED_BRANDS = { + apple: { + pkg: 'emoji-datasource-apple', + folder: 'apple' + }, + google: { + pkg: 'emoji-datasource-google', + folder: 'google' + }, + twitter: { + pkg: 'emoji-datasource-twitter', + folder: 'twitter' + } +} + +const BRAND_JSON_FILES = { + apple: 'emoji-apple-image.json', + google: 'emoji-google-image.json', + twitter: 'emoji-twitter-image.json', + joypixels: 'emoji-joypixels-image.json', + blob: 'emoji-blob-image.json' +} + +function fetchBuffer (url) { + return new Promise((resolve, reject) => { + const req = https.get(url, (res) => { + if (res.statusCode !== 200) { + res.resume() + return reject(new Error(`HTTP ${res.statusCode} for ${url}`)) + } + const chunks = [] + res.on('data', (chunk) => chunks.push(chunk)) + res.on('end', () => resolve(Buffer.concat(chunks))) + res.on('error', reject) + }) + req.on('error', reject) + req.setTimeout(10000, () => { + req.destroy() + reject(new Error(`Timeout for ${url}`)) + }) + }) +} + +function getCdnUrl (brand, emojiCode) { + const config = SUPPORTED_BRANDS[brand] + return `https://cdn.jsdelivr.net/npm/${config.pkg}@${CDN_VERSION}/img/${config.folder}/${CDN_SIZE}/${emojiCode}.png` +} + +async function runPool (tasks, concurrency) { + const results = [] + let index = 0 + + async function worker () { + while (index < tasks.length) { + const i = index++ + results[i] = await tasks[i]().catch(() => null) + } + } + + const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker()) + await Promise.all(workers) + return results +} + +async function updateBrand (brand) { + const config = SUPPORTED_BRANDS[brand] + if (!config) { + console.log(` Skipping ${brand} — not supported by emoji-datasource (keeping existing JSON)`) + return + } + + const jsonFile = path.join(EMOJI_DIR, BRAND_JSON_FILES[brand]) + + // Load existing data + let existing = {} + try { + if (fs.existsSync(jsonFile)) { + existing = JSON.parse(fs.readFileSync(jsonFile, 'utf8')) + } + } catch (err) { + console.warn(` Warning: could not read existing ${jsonFile}: ${err.message}`) + } + + const dbData = emojiDb.dbData + const codes = Object.keys(dbData).filter(k => !dbData[k].qualified) + + // Find missing emoji codes + const missing = codes.filter(code => !existing[code]) + const total = codes.length + const alreadyHave = total - missing.length + + console.log(` ${brand}: ${total} emoji total, ${alreadyHave} cached, ${missing.length} to download`) + + if (missing.length === 0) { + console.log(` ${brand}: up to date`) + return + } + + let downloaded = 0 + let failed = 0 + + const tasks = missing.map(code => async () => { + try { + const url = getCdnUrl(brand, code) + const buffer = await fetchBuffer(url) + const base64 = buffer.toString('base64') + + if (base64.length > 0) { + downloaded++ + if (downloaded % 100 === 0) process.stdout.write(` ${brand}: ${downloaded}/${missing.length}\r`) + return { code, base64 } + } + } catch (err) { + failed++ + return null + } + }) + + const results = await runPool(tasks, CONCURRENCY) + + for (const result of results) { + if (result) existing[result.code] = result.base64 + } + + console.log(` ${brand}: downloaded ${downloaded}, failed ${failed}, total ${Object.keys(existing).length}`) + + // Write atomically + const tmpFile = jsonFile + '.tmp' + fs.writeFileSync(tmpFile, JSON.stringify(existing)) + fs.renameSync(tmpFile, jsonFile) + + console.log(` ${brand}: saved to ${path.basename(jsonFile)}`) +} + +async function main () { + const args = process.argv.slice(2) + const brands = args.length > 0 ? args : Object.keys(BRAND_JSON_FILES) + + console.log('Emoji updater — CDN: emoji-datasource@' + CDN_VERSION + ' via jsdelivr') + console.log('Brands to update:', brands.join(', ')) + console.log() + + for (const brand of brands) { + if (!BRAND_JSON_FILES[brand]) { + console.error(`Unknown brand: ${brand}`) + continue + } + await updateBrand(brand) + } + + console.log('\nDone.') +} + +main().catch(err => { + console.error('Fatal error:', err) + process.exit(1) +}) diff --git a/test-attachments.js b/test-attachments.js new file mode 100644 index 0000000000000000000000000000000000000000..78524e800f416fb88f294f5ada2e18e2385078fb --- /dev/null +++ b/test-attachments.js @@ -0,0 +1,112 @@ +// Numeric assertions for attachment rows and media badges. +// Run: node test-attachments.js → exits non-zero on any failed assertion. + +const assert = require('assert') +const { createCanvas } = require('canvas') +const { + loadIcons, drawVoiceRow, drawDocumentRow, drawAudioRow, + formatDuration, formatFileSize, resampleWaveform, ROW +} = require('./utils/quote-generate/attachments') +const { drawQuote } = require('./utils/quote-generate/composer') +const { fontMetrics } = require('./utils/quote-generate/text-prepare') + +const ACCENT = '#ffa357' // r=255 g=163 b=87 + +function px (canvas, x, y) { + const d = canvas.getContext('2d').getImageData(Math.round(x), Math.round(y), 1, 1).data + return { r: d[0], g: d[1], b: d[2], a: d[3] } +} + +function isAccent (p) { + return Math.abs(p.r - 255) <= 8 && Math.abs(p.g - 163) <= 12 && Math.abs(p.b - 87) <= 12 && p.a > 200 +} + +async function main () { + const loaded = await loadIcons() + assert.ok(loaded && loaded.play && loaded.file && loaded.note, 'official icon sprites must rasterize') + + // 1. Formatters — pure math. + assert.strictEqual(formatDuration(0), '0:00') + assert.strictEqual(formatDuration(65), '1:05') + assert.strictEqual(formatDuration(3661), '61:01') + assert.strictEqual(formatFileSize(999), '999 B') + assert.strictEqual(formatFileSize(2048), '2.0 KB') + assert.strictEqual(formatFileSize(5.3 * 1024 * 1024), '5.3 MB') + assert.strictEqual(formatFileSize(1.5 * 1024 * 1024 * 1024), '1.5 GB') + + // 2. Waveform resampling: averages buckets, normalizes to 0..1. + assert.deepStrictEqual(resampleWaveform([31, 31, 0, 0], 2), [1, 0]) + assert.strictEqual(resampleWaveform(Array(100).fill(31), 40).length, 40) + + // 3. Voice row: height = disc, width within cap, disc center & bars accent. + const scale = 2 + const maxW = 600 + const wave = Array.from({ length: 50 }, (_, i) => (i * 11) % 32) + const voice = drawVoiceRow(wave, 42, ACCENT, '#fff', scale, maxW) + const d = ROW.disc * scale + assert.strictEqual(voice.height, d, `voice row height ${voice.height} != disc ${d}`) + assert.ok(voice.width <= maxW, `voice row ${voice.width} > cap ${maxW}`) + assert.ok(isAccent(px(voice, d / 2, d / 2 - d * 0.3)), 'play disc must be accent') + // First bar column: bars start at d+gap; min height 4·s ensures ink at center. + const barX = d + ROW.gap * scale + (ROW.bar * scale) / 2 + assert.ok(isAccent(px(voice, barX, d / 2)), 'first waveform bar must be accent') + + // 4. Document row height: max(disc, two metric text lines) — exact math. + const titleH = Math.max(1, Math.ceil(fontMetrics(ROW.title * scale).ascent + fontMetrics(ROW.title * scale).descent)) + const metaH = Math.max(1, Math.ceil(fontMetrics(ROW.meta * scale).ascent + fontMetrics(ROW.meta * scale).descent)) + const expRowH = Math.max(d, titleH + ROW.lineGap * scale + metaH) + const doc = drawDocumentRow({ file_name: 'звіт.pdf', file_size: 2.4 * 1024 * 1024 }, ACCENT, '#fff', scale, maxW) + assert.strictEqual(doc.height, expRowH, `doc row ${doc.height} != max(disc, texts) ${expRowH}`) + const docDisc = px(doc, d / 2, doc.height / 2) + assert.ok(docDisc.r > 240 && docDisc.g > 240 && docDisc.b > 240, 'page glyph must be white at disc center') + assert.ok(isAccent(px(doc, d / 2, (doc.height - d) / 2 + d * 0.08)), 'disc rim must be accent') + + // 5. Audio row without thumb: accent note disc; with thumb: thumb pixels. + const audio = drawAudioRow({ title: 'Пісня', performer: 'Гурт', duration: 215 }, ACCENT, '#fff', scale, maxW) + assert.strictEqual(audio.height, expRowH, `audio row ${audio.height} != ${expRowH}`) + assert.ok(isAccent(px(audio, d * 0.15, audio.height / 2)), 'note disc must be accent') + + const thumb = createCanvas(100, 100) + thumb.getContext('2d').fillStyle = '#00ff00' + thumb.getContext('2d').fillRect(0, 0, 100, 100) + const audioT = drawAudioRow({ title: 'X' }, ACCENT, '#fff', scale, maxW, thumb) + const cover = px(audioT, d / 2, audioT.height / 2) + assert.ok(cover.g > 200 && cover.r < 60, 'cover must show the thumb image') + + // 6. Media badges through the composer (destination-space overlay). + // Red 600×300 media, maxSize 200 → drawn 200×100. Bubble is media-only: + // x = (50+10)·s? scale 1 → 60, y = shadowPadTop 4. + const red = createCanvas(600, 300) + red.getContext('2d').fillStyle = '#ff0000' + red.getContext('2d').fillRect(0, 0, 600, 300) + const q = drawQuote({ + scale: 1, + background: { colorOne: '#1b1429', colorTwo: '#1b1429', textColor: '#fff' }, + media: { canvas: red, type: 'video', maxSize: 200, badge: { play: true, label: '0:42' } } + }) + const mx = 60 + const my = 4 + const cx = mx + 100 + const cy = my + 50 + // Center: white play triangle. + const center = px(q, cx, cy) + assert.ok(center.g > 180 && center.b > 180, `center must be white triangle, got ${JSON.stringify(center)}`) + // Inside the dark disc, left of the triangle: red dimmed by ~50% black. + const ring = px(q, cx - 14, cy) + assert.ok(ring.r > 100 && ring.r < 170 && ring.g < 60, `ring must be dimmed red, got ${JSON.stringify(ring)}`) + // Outside the disc: pure media. + const raw = px(q, mx + 30, cy) + assert.ok(raw.r > 240 && raw.g < 15, `media must stay untouched, got ${JSON.stringify(raw)}`) + // Duration chip bottom-left: dark backdrop over red. + const chip = px(q, mx + 6 + 4, my + 100 - 6 - 6) + assert.ok(chip.r < 200 && chip.g < 80, `chip backdrop missing, got ${JSON.stringify(chip)}`) + + console.log('OK: attachment assertions passed') + console.log(` voice ${voice.width}x${voice.height}, doc ${doc.width}x${doc.height}, audio ${audio.width}x${audio.height}`) + console.log(` badge pixels: center=${JSON.stringify(center)} ring=${JSON.stringify(ring)}`) +} + +main().catch((err) => { + console.error('FAIL:', err.message) + process.exit(1) +}) diff --git a/test-emoji-gif.js b/test-emoji-gif.js new file mode 100644 index 0000000000000000000000000000000000000000..43bf09283a60b50332699f11a4c42a3cf1e033bb --- /dev/null +++ b/test-emoji-gif.js @@ -0,0 +1,103 @@ +// Assertions for the two rendering bugs: +// 1. Emoji in the user name must keep their own colors (gradientTint used to +// recolor them into flat silhouettes via source-in). +// 2. An animation that arrives as a raw video file (mp4, no thumbnail) must +// still produce a media canvas — first frame extracted via ffmpeg. +// Run: node test-emoji-gif.js → exits non-zero on any failed assertion. + +const assert = require('assert') +const http = require('http') +const { execFileSync } = require('child_process') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { loadImage, createCanvas } = require('canvas') +const generateMethod = require('./methods/generate') +const { downloadMediaImage } = require('./utils/quote-generate/media') + +function pixels (img) { + const c = createCanvas(img.width, img.height) + const ctx = c.getContext('2d') + ctx.drawImage(img, 0, 0) + return ctx.getImageData(0, 0, img.width, img.height).data +} + +// Count pixels matching a predicate over {r,g,b,a} +function countPx (data, fn) { + let n = 0 + for (let i = 0; i < data.length; i += 4) { + if (fn({ r: data[i], g: data[i + 1], b: data[i + 2], a: data[i + 3] })) n++ + } + return n +} + +async function testNameEmojiKeepsColor () { + // Name with the Ukrainian flag: a correct render contains BOTH saturated + // blue and saturated yellow pixels. The tinted-silhouette bug leaves only + // the (pink-ish) name-color pixels. + const r = await generateMethod({ + messages: [{ + chatId: 125, + avatar: false, + from: { id: 125, name: 'Yūri 🇺🇦 💜', photo: {} }, + text: 'x' + }], + scale: 2, + backgroundColor: '#1b1429' + }) + assert.ok(!r.error, `generate failed: ${r.error}`) + const data = pixels(await loadImage(Buffer.from(r.image, 'base64'))) + + const blue = countPx(data, p => p.a > 200 && p.b > 120 && p.b - p.r > 60 && p.b - p.g > 40) + const yellow = countPx(data, p => p.a > 200 && p.r > 180 && p.g > 140 && p.r - p.b > 100) + assert.ok(blue > 30, `flag blue missing in name (got ${blue} px) — emoji recolored by gradientTint?`) + assert.ok(yellow > 30, `flag yellow missing in name (got ${yellow} px) — emoji recolored by gradientTint?`) + console.log(` name emoji: blue=${blue}px yellow=${yellow}px — colors preserved`) +} + +async function testVideoBufferDecodesToFrame () { + let ffmpegOk = true + try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }) } catch (e) { ffmpegOk = false } + if (!ffmpegOk) { + console.log(' video frame: ffmpeg not installed — skipping') + return + } + + // Solid-red 64x64 mp4, like a Telegram animation without thumbnail. + const tmp = path.join(os.tmpdir(), `quote-test-${process.pid}.mp4`) + execFileSync('ffmpeg', [ + '-y', '-f', 'lavfi', '-i', 'color=c=red:s=64x64:d=0.2:r=5', + '-pix_fmt', 'yuv420p', tmp + ], { stdio: 'ignore' }) + const mp4 = fs.readFileSync(tmp) + + const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'video/mp4' }) + res.end(mp4) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const url = `http://127.0.0.1:${server.address().port}/anim.mp4` + + try { + const img = await downloadMediaImage(url, 512, 'url', false, null) + assert.ok(img, 'downloadMediaImage returned null for an mp4 animation') + const data = pixels(img) + const red = countPx(data, p => p.a > 200 && p.r > 180 && p.g < 80 && p.b < 80) + assert.ok(red > 1000, `decoded frame is not the red video frame (red=${red}px)`) + console.log(` video frame: decoded ${img.width}x${img.height}, red=${red}px`) + } finally { + server.close() + fs.unlinkSync(tmp) + } +} + +async function main () { + await testNameEmojiKeepsColor() + await testVideoBufferDecodesToFrame() + console.log('OK: emoji/gif assertions passed') +} + +main().catch((err) => { + console.error('FAIL:', err.message) + process.exit(1) +}) diff --git a/test-fixes.js b/test-fixes.js new file mode 100644 index 0000000000000000000000000000000000000000..b20604284c420ede68bb7f8bbe3b4e29bf1fb300 --- /dev/null +++ b/test-fixes.js @@ -0,0 +1,102 @@ +// Numeric assertions for the group-avatar and media-scale fixes. +// Run: node test-fixes.js → exits non-zero on any failed assertion. + +const assert = require('assert') +const { loadImage, createCanvas } = require('canvas') +const generateMethod = require('./methods/generate') +const QuoteGenerate = require('./utils/quote-generate') + +// Count opaque pixels in the left avatar column (x < colW) of a PNG buffer. +function leftColumnInk (img, colW) { + const canvas = createCanvas(img.width, img.height) + const ctx = canvas.getContext('2d') + ctx.drawImage(img, 0, 0) + const data = ctx.getImageData(0, 0, colW, img.height).data + let n = 0 + for (let i = 3; i < data.length; i += 4) if (data[i] > 32) n++ + return n +} + +async function main () { + const scale = 2 + const msg = (chatId, text) => ({ + chatId, + avatar: true, + from: { id: chatId, name: `User ${chatId}`, photo: {} }, + text + }) + + // 1. Same-sender group renders ONE avatar (on the last bubble); the + // two-sender variant renders two. Avatar circles are solid disks of + // d = 50·scale, so the grouped left column must hold roughly half the + // opaque pixels of the ungrouped one (±25% for the tail overlap). + const grouped = await generateMethod({ messages: [msg(7, 'перше'), msg(7, 'друге')], scale }) + const ungrouped = await generateMethod({ messages: [msg(7, 'перше'), msg(8, 'друге')], scale }) + assert.ok(!grouped.error && !ungrouped.error, 'generate failed') + + const colW = 50 * scale + const inkGrouped = leftColumnInk(await loadImage(Buffer.from(grouped.image, 'base64')), colW) + const inkUngrouped = leftColumnInk(await loadImage(Buffer.from(ungrouped.image, 'base64')), colW) + const ratio = inkGrouped / inkUngrouped + assert.ok(ratio > 0.35 && ratio < 0.65, + `grouped/ungrouped avatar ink ratio ${ratio.toFixed(2)} — expected ≈ 0.5 (one avatar of two)`) + + // 2. Voice renders as an in-bubble attachment row capped at ⅔ of the + // target width. The bubble width is fully deterministic: + // (avatar 50 + gap 10)·s + (rowW + 2·padX 16·s) + shadowPad 12·s, + // where rowW comes from the same drawVoiceRow inputs. + const { drawVoiceRow } = require('./utils/quote-generate/attachments') + const { NAME_COLORS_DARK } = require('./utils/quote-generate/constants') + const waveform = Array.from({ length: 60 }, (_, i) => (i * 7) % 32) + const qg = new QuoteGenerate('0:x') + const voice = await qg.generate('#1b1429', '#1b1429', { + from: { id: 1, name: 'V' }, + voice: { waveform, duration: 42 }, + avatar: false + }, 512, 512, scale, 'apple') + // from.id=1 → NAME_COLORS_DARK[1]; dark background → textColor #fff + const row = drawVoiceRow(waveform, 42, NAME_COLORS_DARK[1], '#fff', scale, 512 * scale * 2 / 3) + assert.ok(row.width <= Math.ceil(512 * scale * 2 / 3), `voice row ${row.width} exceeds ⅔ cap`) + const expectedW = (50 + 10) * scale + (row.width + 2 * 16 * scale) + 12 * scale + assert.ok(Math.abs(voice.width - expectedW) <= 2, + `voice bubble width ${voice.width} != expected ${expectedW}`) + + // 3. Image mode: the wallpaper must contrast with the bubble, not blend. + // Sample the wallpaper corner vs the bubble interior; perceived + // brightness (BT.601) must differ noticeably for dark AND light themes. + const brightness = (p) => (p.r * 299 + p.g * 587 + p.b * 114) / 1000 + for (const [bgColor, label] of [['#252e44', 'dark'], ['#e8ecf3', 'light']]) { + const img = await generateMethod({ messages: [msg(7, 'контраст фону')], scale, type: 'image', backgroundColor: bgColor }) + assert.ok(!img.error, 'image generate failed') + const pic = await loadImage(Buffer.from(img.image, 'base64')) + const c = createCanvas(pic.width, pic.height) + const cx = c.getContext('2d') + cx.drawImage(pic, 0, 0) + const get = (x, y) => { + const d = cx.getImageData(x, y, 1, 1).data + return { r: d[0], g: d[1], b: d[2] } + } + // Wallpaper near the corner; bubble interior right of the avatar column + // ((50+10+16)·s + margin, vertical center). + const wallPx = get(8, 8) + const wall = brightness(wallPx) + const bubble = brightness(get(95 * scale / 2 + (50 + 10 + 20) * scale, Math.round(pic.height / 2))) + assert.ok(Math.abs(bubble - wall) >= 18, + `${label}: bubble (${bubble.toFixed(0)}) blends into wallpaper (${wall.toFixed(0)})`) + // Light wallpapers must be pastel, not gray: require visible chroma. + if (label === 'light') { + const chroma = Math.max(wallPx.r, wallPx.g, wallPx.b) - Math.min(wallPx.r, wallPx.g, wallPx.b) + assert.ok(chroma >= 15, `light wallpaper is gray (chroma ${chroma}) — expected a pastel tint`) + } + console.log(` image/${label}: bubble ${bubble.toFixed(0)} vs wallpaper ${wall.toFixed(0)} (Δ${Math.abs(bubble - wall).toFixed(0)})`) + } + + console.log('OK: fixes assertions passed') + console.log(` avatar ink ratio grouped/ungrouped = ${ratio.toFixed(3)} (≈0.5)`) + console.log(` voice bubble width = ${voice.width} (expected ${expectedW}, row ${row.width})`) +} + +main().catch((err) => { + console.error('FAIL:', err.message) + process.exit(1) +}) diff --git a/test-metrics.js b/test-metrics.js new file mode 100644 index 0000000000000000000000000000000000000000..8706abb57c04982a7ee5b61d02248e02f92d4355 --- /dev/null +++ b/test-metrics.js @@ -0,0 +1,92 @@ +// Numeric assertions for the metric text layout: line geometry must be a +// constant of the font (em box), never of the glyph shapes drawn. +// Run: node test-metrics.js → exits non-zero on any failed assertion. + +const assert = require('assert') +const { createCanvas } = require('canvas') +const { drawMultilineText } = require('./utils/quote-generate/text-renderer') +const { drawLabel } = require('./utils/quote-generate/canvas-utils') +const { leaf } = require('./utils/quote-generate/layout-box') +const { fontMetrics } = require('./utils/quote-generate/text-prepare') + +const FS = 24 + +async function main () { + const { ascent, descent } = fontMetrics(FS) + const lineHeight = FS * 1.2 + const W = 2000 + const H = 2000 + + // 1. Glyph shapes must not change the canvas height (the old ink-trim bug): + // no ascenders/descenders vs full ascenders/descenders/diacritics. + const low = await drawMultilineText('осе ссс еео', [], FS, '#fff', 0, FS, W, H, 'apple', null) + const tall = await drawMultilineText('Іфj Ďjq Ції', [], FS, '#fff', 0, FS, W, H, 'apple', null) + assert.strictEqual(low.height, tall.height, + `single line: ${low.height} (low ink) != ${tall.height} (tall ink)`) + + // 2. Single line height is exactly the font em box. + assert.strictEqual(low.height, Math.max(1, Math.ceil(ascent + descent)), + `single line height ${low.height} != ceil(ascent+descent) ${Math.ceil(ascent + descent)}`) + + // 3. n-line text: height = (n-1)*lineHeight + ascent + descent, regardless of glyphs. + for (const n of [2, 3, 5]) { + const lowText = Array(n).fill('ооо').join('\n') + const tallText = Array(n).fill('Іфj').join('\n') + const a = await drawMultilineText(lowText, [], FS, '#fff', 0, FS, W, H, 'apple', null) + const b = await drawMultilineText(tallText, [], FS, '#fff', 0, FS, W, H, 'apple', null) + const expected = Math.ceil((n - 1) * lineHeight + ascent + descent) + assert.strictEqual(a.height, expected, `${n} lines (low): ${a.height} != ${expected}`) + assert.strictEqual(b.height, expected, `${n} lines (tall): ${b.height} != ${expected}`) + } + + // 4. leaf() takes canvases at face value (no ink scanning) and drops 1×1 stubs. + const l = leaf(low) + assert.strictEqual(l.h, low.height, 'leaf height must equal canvas height') + assert.strictEqual(l.srcY, 0, 'leaf must not crop the top') + const stub = await drawMultilineText('', [], FS, '#fff', 0, FS, W, H, 'apple', null) + assert.strictEqual(leaf(stub), null, 'empty-text stub must resolve to null leaf') + + // 5. drawLabel: same metric rule at label sizes, glyph-independent. + const em13 = fontMetrics(13) + const lab1 = drawLabel('ооо', 13, '#fff') + const lab2 = drawLabel('Іфj', 13, '#fff') + assert.strictEqual(lab1.height, lab2.height, 'label heights must match') + assert.strictEqual(lab1.height, Math.max(1, Math.ceil(em13.ascent + em13.descent)), + `label height ${lab1.height} != em box ${Math.ceil(em13.ascent + em13.descent)}`) + + // 6. The line box covers everything that gets drawn into it (no clipping): + // emoji images span [baseline−0.85·fs, baseline+0.30·fs]; probe glyph ink + // must fit too. Checked directly against the measured extents. + const probeCtx = createCanvas(1, 1).getContext('2d') + probeCtx.font = `${FS}px NotoSans` + const inkAsc = probeCtx.measureText('ẤÅЇĎ').actualBoundingBoxAscent + const inkDesc = probeCtx.measureText('jqyḑộ').actualBoundingBoxDescent + assert.ok(ascent >= FS * 0.85, `ascent ${ascent} < emoji top ${FS * 0.85}`) + assert.ok(descent >= FS * 0.3, `descent ${descent} < emoji bottom ${FS * 0.3}`) + assert.ok(ascent >= inkAsc, `ascent ${ascent} < probe ink ascent ${inkAsc}`) + assert.ok(descent >= inkDesc, `descent ${descent} < probe ink descent ${inkDesc}`) + + // 7. End-to-end: two messages with identical structure but different ink + // extents must produce pixel-identical bubble dimensions. + const QuoteGenerate = require('./utils/quote-generate') + const qg = new QuoteGenerate('0:x') + const mk = (text) => qg.generate('#1b1429', '#1b1429', { + from: { id: 42, name: 'Тест Тестенко' }, + text, + avatar: false + }, 512, 512, 2, 'apple') + const qLow = await mk('осе ссс еео') + const qTall = await mk('Іфj Ďjq Ції') + assert.strictEqual(qLow.height, qTall.height, + `bubble heights differ: ${qLow.height} vs ${qTall.height}`) + + console.log('OK: metric layout assertions passed') + console.log(` e2e bubble: ${qLow.width}x${qLow.height} == ${qTall.width}x${qTall.height}`) + console.log(` font em box @${FS}px: ascent=${ascent} descent=${descent} lineHeight=${lineHeight}`) + console.log(` 1 line=${low.height}px, n lines=(n-1)*${lineHeight}+${ascent}+${descent}`) +} + +main().catch((err) => { + console.error('FAIL:', err.message) + process.exit(1) +}) diff --git a/test-real.js b/test-real.js new file mode 100644 index 0000000000000000000000000000000000000000..035609d8a6e31e6031ef33b88470beaf632fc5cc --- /dev/null +++ b/test-real.js @@ -0,0 +1,515 @@ +// Real rendering test using actual drawMultilineText +// Run: node test-real.js + +const fs = require('fs') +const path = require('path') +const { createCanvas } = require('canvas') +const { drawQuote } = require('./utils/quote-generate/composer') +const { loadFonts, gradientTint } = require('./utils/quote-generate/index') +const { avatarImageLetters } = require('./utils/quote-generate/avatar') +const { drawAvatar } = require('./utils/quote-generate/avatar') +const { AVATAR_COLORS } = require('./utils/quote-generate/constants') +const { colorLuminance } = require('./utils/quote-generate/color') +const { loadIcons, drawVoiceRow, drawDocumentRow, drawAudioRow } = require('./utils/quote-generate/attachments') + +const OUT = path.join(__dirname, 'test-output') +fs.mkdirSync(OUT, { recursive: true }) + +function makeAvatar (color1, color2, letter) { + const size = 100 + const canvas = createCanvas(size, size) + const ctx = canvas.getContext('2d') + const grad = ctx.createLinearGradient(0, 0, size, size) + grad.addColorStop(0, color1) + grad.addColorStop(1, color2) + ctx.fillStyle = grad + ctx.beginPath() + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = '#fff' + ctx.font = `bold ${size * 0.45}px sans-serif` + ctx.textAlign = 'center' + ctx.fillText(letter, size / 2, size / 2 + size * 0.16) + return canvas +} + +async function main () { + await loadFonts() + await loadIcons() + const { drawMultilineText } = require('./utils/quote-generate/text-renderer') + + const scale = 2 + const width = 512 * scale + const height = 512 * scale + const tg = { getCustomEmojiStickers: async () => [] } + + // Mirrors the production scale in utils/quote-generate/index.js + const nameSize = 18 * scale + const fontSize = 24 * scale + const replyNameSize = 14 * scale + const replyTextSize = 15 * scale + + async function mkName (text, color) { + const canvas = await drawMultilineText(text, [{ type: 'bold', offset: 0, length: text.length }], nameSize, color, 0, nameSize, width, nameSize, 'apple', tg) + return gradientTint(canvas, color, colorLuminance(color, 0.25)) + } + async function mkText (text, color) { + return drawMultilineText(text, [], fontSize, color || '#fff', 0, fontSize, width, height, 'apple', tg) + } + async function mkReplyName (text, color) { + return drawMultilineText(text, 'bold', replyNameSize, color, 0, replyNameSize, width * 0.9, replyNameSize, 'apple', tg) + } + async function mkReplyText (text, color) { + return drawMultilineText(text, [], replyTextSize, color || '#fff', 0, replyTextSize, width * 0.9, replyTextSize, 'apple', tg) + } + + const bg = { colorOne: '#292232', colorTwo: '#292232', textColor: '#fff' } + const bgLight = { colorOne: '#e8e8e8', colorTwo: '#e8e8e8', textColor: '#000' } + const bgGrad = { colorOne: '#667eea', colorTwo: '#764ba2', textColor: '#fff' } + + const tests = [] + + // 1. Basic dark + tests.push({ + name: 'real-01-basic', + opts: { + scale, background: bg, + avatar: makeAvatar('#FF885E', '#FF516A', 'A'), + name: await mkName('assasinfil', '#FF8E86'), + text: await mkText('Переполнение вошло в чат'), + nameColor: '#FF8E86' + } + }) + + // 2. Basic light + tests.push({ + name: 'real-02-light', + opts: { + scale, background: bgLight, + avatar: makeAvatar('#72D5FD', '#2A9EF1', 'N'), + name: await mkName('nowiks', '#3CA5EC'), + text: await mkText('Ну получить можно... не понял..', '#000'), + nameColor: '#3CA5EC' + } + }) + + // Debug reply canvas sizes + const dbgReplyName = await mkReplyName('Виталий', '#B18FFF') + const dbgReplyText = await mkReplyText('не понял..') + console.log(`Reply name canvas: ${dbgReplyName.width}x${dbgReplyName.height}`) + console.log(`Reply text canvas: ${dbgReplyText.width}x${dbgReplyText.height}`) + + // 3. Reply + tests.push({ + name: 'real-03-reply', + opts: { + scale, background: { colorOne: '#1a1a2e', colorTwo: '#16213e', textColor: '#fff' }, + avatar: makeAvatar('#FF885E', '#FF516A', 'A'), + reply: { + name: await mkReplyName('Виталий', '#B18FFF'), + nameColor: '#B18FFF', + text: await mkReplyText('не понял..') + }, + name: await mkName('assasinfil', '#FF8E86'), + text: await mkText('Переполнение вошло в чат'), + nameColor: '#FF8E86' + } + }) + + // 4. No avatar, no name (continuation) + tests.push({ + name: 'real-04-noname', + opts: { + scale, background: bg, + avatar: null, name: null, + text: await mkText('понял..') + } + }) + + // 5. Gradient bg + tests.push({ + name: 'real-05-gradient', + opts: { + scale, background: bgGrad, + avatar: makeAvatar('#FFCD6A', '#FFA85C', 'Y'), + name: await mkName('Yuri', '#FFCD6A'), + text: await mkText('This is a gradient background test with a longer message that wraps across multiple lines'), + nameColor: '#FFCD6A' + } + }) + + // 6. Forward + tests.push({ + name: 'real-06-forward', + opts: { + scale, background: bg, + avatar: makeAvatar('#A0DE7E', '#54CB68', 'P'), + name: await mkName('Pavel Durov', '#4DD6BF'), + text: await mkText('Telegram is the most secure messenger in the world.'), + isForward: true, + forwardLabel: 'Forwarded from Durov\'s Channel', + nameColor: '#4DD6BF' + } + }) + + // 7. Forward + reply + tests.push({ + name: 'real-07-fwd-reply', + opts: { + scale, background: { colorOne: '#1e1e2e', colorTwo: '#1e1e2e', textColor: '#fff' }, + avatar: makeAvatar('#53EDD6', '#28C9B7', 'M'), + reply: { + name: await mkReplyName('Alice', '#7AC9FF'), + nameColor: '#7AC9FF', + text: await mkReplyText('What did he say?') + }, + name: await mkName('Mike', '#45E8D1'), + text: await mkText('He said something very interesting about the project roadmap'), + isForward: true, + forwardLabel: 'Forwarded from Project Updates', + nameColor: '#45E8D1' + } + }) + + // 8. Sender tag + tests.push({ + name: 'real-08-tag', + opts: { + scale, background: bg, + avatar: makeAvatar('#FF885E', '#FF516A', 'A'), + name: await mkName('Admin User', '#FF8E86'), + text: await mkText('Welcome to the group! Please read the rules.'), + nameColor: '#FF8E86', + senderTag: 'Admin' + } + }) + + // 9. Tag + forward + tests.push({ + name: 'real-09-tag-fwd', + opts: { + scale, background: { colorOne: '#0d1117', colorTwo: '#161b22', textColor: '#e6edf3' }, + avatar: makeAvatar('#FFA8A8', '#FF719A', 'C'), + name: await mkName('Channel Bot', '#FF7FD5'), + text: await mkText('Important announcement: server maintenance scheduled for tonight at 3 AM UTC'), + isForward: true, + forwardLabel: 'Forwarded from System Alerts', + nameColor: '#FF7FD5', + senderTag: 'NFC/SubGHz Dev' + } + }) + + // 10. Long text with reply + tests.push({ + name: 'real-10-long-reply', + opts: { + scale, background: { colorOne: '#2d2d44', colorTwo: '#2d2d44', textColor: '#fff' }, + avatar: makeAvatar('#72D5FD', '#2A9EF1', 'L'), + reply: { + name: await mkReplyName('Previous User', '#3CA5EC'), + nameColor: '#3CA5EC', + text: await mkReplyText('Can you explain?') + }, + name: await mkName('Long Text User', '#7AC9FF'), + 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.'), + nameColor: '#7AC9FF' + } + }) + + // 11. Short text + tests.push({ + name: 'real-11-short', + opts: { + scale, background: bg, + avatar: makeAvatar('#FFCD6A', '#FFA85C', 'S'), + name: await mkName('User', '#FFA357'), + text: await mkText('ok'), + nameColor: '#FFA357' + } + }) + + // 12. Long name + tag + tests.push({ + name: 'real-12-longname-tag', + opts: { + scale, background: bg, + avatar: makeAvatar('#E0A2F3', '#D669ED', 'X'), + name: await mkName('Alexander Superlong Name', '#B18FFF'), + text: await mkText('Testing a very long name with a tag'), + nameColor: '#B18FFF', + senderTag: 'Lead Developer & Architect' + } + }) + + // 13. Partial quote (isQuote) + tests.push({ + name: 'real-13-quote', + opts: { + scale, background: bg, + avatar: makeAvatar('#72D5FD', '#2A9EF1', 'Q'), + reply: { + name: await mkReplyName('Original Author', '#7AC9FF'), + nameColor: '#7AC9FF', + text: await mkReplyText('only this part was quoted') + }, + name: await mkName('Quoter', '#45E8D1'), + text: await mkText('Exactly, this part is the key insight'), + nameColor: '#45E8D1', + isQuote: true + } + }) + + // 14. Reply no avatar + tests.push({ + name: 'real-14-reply-noavatar', + opts: { + scale, background: bg, + avatar: null, + reply: { + name: await mkReplyName('Someone', '#FF8E86'), + nameColor: '#FF8E86', + text: await mkReplyText('Original message text here') + }, + name: null, + text: await mkText('Reply to that') + } + }) + + // Helper: make circular avatar from avatarImageLetters (same as drawAvatar does) + function makeRealAvatar (letters, colorIndex) { + const avatarCanvas = avatarImageLetters(letters, AVATAR_COLORS[colorIndex % 7]) + const size = avatarCanvas.height + const circle = createCanvas(size, size) + const cctx = circle.getContext('2d') + cctx.beginPath() + cctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2, true) + cctx.clip() + cctx.drawImage(avatarCanvas, 0, 0, size, size) + return circle + } + + // 16. Initials avatar — two letters + tests.push({ + name: 'real-16-initials-2', + opts: { + scale, background: bg, + avatar: makeRealAvatar('YL', 3), + name: await mkName('Yuri Ly', '#4DD6BF'), + text: await mkText('Testing two-letter initials avatar'), + nameColor: '#4DD6BF' + } + }) + + // 17. Initials avatar — one letter + tests.push({ + name: 'real-17-initials-1', + opts: { + scale, background: bg, + avatar: makeRealAvatar('А', 5), + name: await mkName('Антон', '#B18FFF'), + text: await mkText('Тестуємо аватарку з одною літерою'), + nameColor: '#B18FFF' + } + }) + + // 18. Initials avatar — Cyrillic two letters + tests.push({ + name: 'real-18-initials-cyrillic', + opts: { + scale, background: bgLight, + avatar: makeRealAvatar('ВН', 2), + name: await mkName('Виктор Никитчук', '#3CA5EC'), + text: await mkText('Кириллические буквы в аватарке'), + nameColor: '#3CA5EC' + } + }) + + // 19. Standalone avatar renders (for direct comparison) + const avatarTests = [ + { letters: 'YL', idx: 0 }, + { letters: 'А', idx: 1 }, + { letters: 'ВН', idx: 2 }, + { letters: 'JD', idx: 3 }, + { letters: 'М', idx: 4 }, + { letters: '?', idx: 5 }, + { letters: 'PD', idx: 6 } + ] + const avSize = 200 + const avPad = 20 + const avRow = createCanvas(avatarTests.length * (avSize + avPad), avSize) + const avCtx = avRow.getContext('2d') + for (let i = 0; i < avatarTests.length; i++) { + const av = makeRealAvatar(avatarTests[i].letters, avatarTests[i].idx) + avCtx.drawImage(av, i * (avSize + avPad), 0, avSize, avSize) + } + fs.writeFileSync(path.join(OUT, 'real-19-avatars-row.png'), avRow.toBuffer('image/png')) + console.log(` ✓ real-19-avatars-row (${avRow.width}x${avRow.height})`) + + // 20. Voice message + tests.push({ + name: 'real-20-voice', + opts: { + scale, background: bg, + avatar: makeAvatar('#FF885E', '#FF516A', 'A'), + name: await mkName('assasinfil', '#FF8E86'), + attachment: { canvas: drawVoiceRow(Array.from({ length: 50 }, (_, i) => 6 + ((i * 13) % 26), 0), 83, '#FF8E86', '#fff', scale, width * 2 / 3) }, + nameColor: '#FF8E86' + } + }) + + // 21. Document + tests.push({ + name: 'real-21-document', + opts: { + scale, background: bg, + avatar: makeAvatar('#72D5FD', '#2A9EF1', 'N'), + name: await mkName('nowiks', '#7AC9FF'), + attachment: { canvas: drawDocumentRow({ file_name: 'квартальний_звіт_2026.pdf', file_size: 2.4 * 1024 * 1024 }, '#7AC9FF', '#fff', scale, width * 2 / 3) }, + nameColor: '#7AC9FF' + } + }) + + // 22. Audio (no cover → note disc) + tests.push({ + name: 'real-22-audio', + opts: { + scale, background: bg, + avatar: makeAvatar('#A0DE7E', '#54CB68', 'M'), + name: await mkName('Музика', '#4DD6BF'), + attachment: { canvas: drawAudioRow({ title: 'Водограй', performer: 'Володимир Івасюк', duration: 215 }, '#4DD6BF', '#fff', scale, width * 2 / 3) }, + nameColor: '#4DD6BF' + } + }) + + // 23. Video with play badge + duration, plus caption + const videoFrame = createCanvas(800, 450) + { + const vctx = videoFrame.getContext('2d') + const grad = vctx.createLinearGradient(0, 0, 800, 450) + grad.addColorStop(0, '#2b5876') + grad.addColorStop(1, '#4e4376') + vctx.fillStyle = grad + vctx.fillRect(0, 0, 800, 450) + } + tests.push({ + name: 'real-23-video', + opts: { + scale, background: bg, + avatar: makeAvatar('#FFCD6A', '#FFA85C', 'V'), + name: await mkName('Відеограф', '#FFA357'), + media: { canvas: videoFrame, type: 'video', maxSize: width * 2 / 3, badge: { play: true, label: '0:42' } }, + text: await mkText('подивіться до кінця 🔥'), + nameColor: '#FFA357' + } + }) + + // 24. GIF (no caption → flush media with GIF chip) + const gifFrame = createCanvas(640, 360) + { + const gctx = gifFrame.getContext('2d') + const grad = gctx.createLinearGradient(0, 360, 640, 0) + grad.addColorStop(0, '#f83600') + grad.addColorStop(1, '#f9d423') + gctx.fillStyle = grad + gctx.fillRect(0, 0, 640, 360) + } + tests.push({ + name: 'real-24-gif', + opts: { + scale, background: bg, + avatar: makeAvatar('#E0A2F3', '#D669ED', 'G'), + name: await mkName('Гіфка', '#B18FFF'), + media: { canvas: gifFrame, type: 'animation', maxSize: width * 2 / 3, badge: { label: 'GIF' } }, + nameColor: '#B18FFF' + } + }) + + // Generate single tests + for (const test of tests) { + try { + const canvas = drawQuote(test.opts) + fs.writeFileSync(path.join(OUT, `${test.name}.png`), canvas.toBuffer('image/png')) + console.log(` ✓ ${test.name} (${canvas.width}x${canvas.height})`) + } catch (err) { + console.error(` ✗ ${test.name}: ${err.message}`) + } + } + + // 15. MULTI-MESSAGE DIALOG + const dialogOpts = [ + { + scale, background: bg, + avatar: null, // avatar rides on the LAST message of a streak + name: await mkName('assasinfil', '#FF8E86'), + text: await mkText('Хто знає як зробити NFC клон?'), + nameColor: '#FF8E86', + senderTag: 'NFC/SubGHz Dev', + groupPos: 'first' + }, + { + scale, background: bg, + avatar: makeAvatar('#FF885E', '#FF516A', 'A'), + name: null, + text: await mkText('Треба Flipper Zero для цього'), + groupPos: 'last' + }, + { + scale, background: bg, + avatar: makeAvatar('#72D5FD', '#2A9EF1', 'N'), + reply: { + name: await mkReplyName('assasinfil', '#FF8E86'), + nameColor: '#FF8E86', + text: await mkReplyText('Хто знає як зробити NFC клон?') + }, + name: await mkName('nowiks', '#7AC9FF'), + text: await mkText('Ну получить можно... не понял..'), + nameColor: '#7AC9FF', + isQuote: true + }, + { + scale, background: bg, + avatar: makeAvatar('#E0A2F3', '#D669ED', 'В'), + name: await mkName('Виктор Никитчук', '#B18FFF'), + text: await mkText('Звучит очень по гейски'), + isForward: true, + forwardLabel: 'Forwarded from Misha Myte', + nameColor: '#B18FFF' + }, + { + scale, background: bg, + avatar: makeAvatar('#FF885E', '#FF516A', 'A'), + name: await mkName('assasinfil', '#FF8E86'), + text: await mkText('Переполнение вошло в чат'), + nameColor: '#FF8E86', + senderTag: 'NFC/SubGHz Dev' + }, + { + scale, background: bg, + avatar: makeAvatar('#72D5FD', '#2A9EF1', 'N'), + name: await mkName('nowiks', '#7AC9FF'), + text: await mkText('понял..'), + nameColor: '#7AC9FF' + } + ] + + const quoteMargin = 5 * scale + const dialogCanvases = dialogOpts.map(o => drawQuote(o)) + let totalW = 0, totalH = 0 + for (const c of dialogCanvases) { + if (c.width > totalW) totalW = c.width + totalH += c.height + quoteMargin + } + const dialogCanvas = createCanvas(totalW, totalH) + const dctx = dialogCanvas.getContext('2d') + let y = 0 + for (const c of dialogCanvases) { + dctx.drawImage(c, 0, y) + y += c.height + quoteMargin + } + fs.writeFileSync(path.join(OUT, 'real-15-dialog.png'), dialogCanvas.toBuffer('image/png')) + console.log(` ✓ real-15-dialog (${dialogCanvas.width}x${dialogCanvas.height})`) + + console.log('\nDone!') +} + +main().catch(console.error) diff --git a/test-style.js b/test-style.js new file mode 100644 index 0000000000000000000000000000000000000000..e92ae3e5f2a29d9bc7e5617e37378138a4646545 --- /dev/null +++ b/test-style.js @@ -0,0 +1,51 @@ +// Numeric assertions for the glass bubble style — pixel samples and +// geometry math, no eyeballing. +// Run: node test-style.js → exits non-zero on any failed assertion. + +const assert = require('assert') +const { drawRoundRect } = require('./utils/quote-generate/canvas-utils') +const { SP } = require('./utils/quote-generate/composer') + +function px (canvas, x, y) { + const d = canvas.getContext('2d').getImageData(x, y, 1, 1).data + return { r: d[0], g: d[1], b: d[2], a: d[3] } +} + +// Fill #222b3c → r=34. Glass adds white on top edge and a hairline border. +const FILL = { r: 34, g: 43, b: 60 } +const W = 200 +const H = 100 +const c = drawRoundRect('#222b3c', W, H, 20, 0, 1.25) + +// 1. Middle of the bubble is the pure fill (glass must not wash the body). +const mid = px(c, W / 2, H / 2) +assert.strictEqual(mid.r, FILL.r, `body tinted: r=${mid.r} != ${FILL.r}`) + +// 2. Top edge is brighter than the body (top highlight, alpha ≈ .16+.07). +const top = px(c, W / 2, 1) +assert.ok(top.r > mid.r + 15, `top edge not highlighted: r=${top.r} vs body ${mid.r}`) + +// 3. Side edge carries the hairline border. Math: inner stroke is +// glassLw 1.25px wide from x=0, so pixel column 0 is fully covered: +// expected r ≈ 34 + (255−34)·0.07 ≈ 49. +const side = px(c, 0, H / 2) +assert.ok(side.r > mid.r + 8, `side border missing: r=${side.r} vs body ${mid.r}`) + +// 4. Highlight fades: at 70% height the edge pixel is border-only (≈ side). +const lower = px(c, 0, Math.round(H * 0.7)) +assert.ok(Math.abs(lower.r - side.r) <= 6, + `highlight must fade out by 40% height: lower-edge r=${lower.r}, side r=${side.r}`) + +// 5. Without glassLw the canvas is untouched fill (back-compat for chips). +const plain = drawRoundRect('#222b3c', W, H, 20, 0) +assert.strictEqual(px(plain, W / 2, 1).r, FILL.r, 'plain rect must have no glass') + +// 6. Shadow headroom math: blur spills (blur − offsetY) above the bubble; +// the top margin must cover it. (composer: blur 6, offsetY 2 — logical px) +assert.ok(SP.shadowPadTop >= 6 - 2, + `shadowPadTop ${SP.shadowPadTop} < blur−offset 4 — shadow clips at the top`) +assert.ok(SP.shadowPad >= 6 + 2, + `shadowPad ${SP.shadowPad} < blur+offset 8 — shadow clips at the bottom`) + +console.log('OK: glass style assertions passed') +console.log(` body r=${mid.r}, top r=${top.r}, side r=${side.r}, lower r=${lower.r}`) diff --git a/utils/emoji-db.js b/utils/emoji-db.js new file mode 100644 index 0000000000000000000000000000000000000000..55f4c698c9645ebfe7cb2474e9b647c30d0d817d --- /dev/null +++ b/utils/emoji-db.js @@ -0,0 +1,3 @@ +const EmojiDbLib = require('emoji-db') + +module.exports = new EmojiDbLib({ useDefaultDb: true }) diff --git a/utils/emoji-image.js b/utils/emoji-image.js new file mode 100644 index 0000000000000000000000000000000000000000..4265a2ff6e2877f68b81c179e69117f7e06ccc6d --- /dev/null +++ b/utils/emoji-image.js @@ -0,0 +1,43 @@ +const path = require('path') +const fs = require('fs') +const emojiDb = require('./emoji-db') + +const emojiJFilesDir = '../assets/emoji/' + +const emojiJsonByBrand = { + apple: 'emoji-apple-image.json', + google: 'emoji-google-image.json', + twitter: 'emoji-twitter-image.json', + joypixels: 'emoji-joypixels-image.json', + blob: 'emoji-blob-image.json' +} + +// Lazy-loaded emoji data — only apple is loaded eagerly (default brand) +const emojiImageByBrand = {} + +function loadBrand (brand) { + if (emojiImageByBrand[brand]) return emojiImageByBrand[brand] + + const jsonFile = emojiJsonByBrand[brand] + if (!jsonFile) return {} + + const filePath = path.resolve(__dirname, emojiJFilesDir + jsonFile) + + try { + if (fs.existsSync(filePath)) { + emojiImageByBrand[brand] = require(filePath) + } else { + emojiImageByBrand[brand] = {} + } + } catch (error) { + console.error('Failed to load emoji brand', brand, error.message) + emojiImageByBrand[brand] = {} + } + + return emojiImageByBrand[brand] +} + +// Eager-load apple (default brand) at startup +loadBrand('apple') + +module.exports = { loadBrand, brands: emojiJsonByBrand } diff --git a/utils/image-load-path.js b/utils/image-load-path.js new file mode 100644 index 0000000000000000000000000000000000000000..5984fe6e7aa64bbfda1d19eb09a2c3e178dca41c --- /dev/null +++ b/utils/image-load-path.js @@ -0,0 +1,10 @@ +const fs = require('fs') + +module.exports = (filePath) => { + return new Promise((resolve, reject) => { + fs.readFile(filePath, (error, data) => { + if (error) reject(error) + else resolve(data) + }) + }) +} diff --git a/utils/image-load-url.js b/utils/image-load-url.js new file mode 100644 index 0000000000000000000000000000000000000000..9cde7ae1a0765c6d2efa08110352dca6b7a72562 --- /dev/null +++ b/utils/image-load-url.js @@ -0,0 +1,70 @@ +const https = require('https') +const http = require('http') + +const REQUEST_TIMEOUT_MS = 10_000 +const MAX_RESPONSE_BYTES = 20 * 1024 * 1024 +const MAX_REDIRECTS = 5 + +function doRequest (url, filter, redirectCount) { + return new Promise((resolve, reject) => { + const parsed = new URL(url) + const transport = parsed.protocol === 'https:' ? https : http + + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return reject(new Error(`Unsupported protocol ${parsed.protocol} for ${url}`)) + } + + const options = { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname + parsed.search, + headers: { 'User-Agent': 'curl/8.4.0' } + } + + const req = transport.get(options, (res) => { + // Follow redirects (301, 302, 307, 308) + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume() + if (redirectCount >= MAX_REDIRECTS) { + return reject(new Error(`Too many redirects (${MAX_REDIRECTS}) for ${url}`)) + } + const redirectUrl = new URL(res.headers.location, url).href + return resolve(doRequest(redirectUrl, filter, redirectCount + 1)) + } + + if (res.statusCode < 200 || res.statusCode >= 300) { + res.resume() + return reject(new Error(`HTTP ${res.statusCode} for ${url}`)) + } + + if (filter && filter(res.headers)) { + res.resume() + return resolve(Buffer.concat([])) + } + + const chunks = [] + let totalBytes = 0 + + res.on('error', (err) => reject(err)) + res.on('data', (chunk) => { + totalBytes += chunk.length + if (totalBytes > MAX_RESPONSE_BYTES) { + req.destroy() + res.destroy() + return reject(new Error(`Response exceeded ${MAX_RESPONSE_BYTES} bytes for ${url}`)) + } + chunks.push(chunk) + }) + res.on('end', () => resolve(Buffer.concat(chunks))) + }) + + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.destroy() + reject(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms for ${url}`)) + }) + + req.on('error', (err) => reject(err)) + }) +} + +module.exports = (url, filter = false) => doRequest(url, filter, 0) diff --git a/utils/index.js b/utils/index.js new file mode 100644 index 0000000000000000000000000000000000000000..5591e287638da160e0a304002d2686f069ca5850 --- /dev/null +++ b/utils/index.js @@ -0,0 +1,10 @@ +const QuoteGenerate = require('./quote-generate/index') + +module.exports = { + QuoteGenerate, + loadFonts: QuoteGenerate.loadFonts, + loadImageFromUrl: require('./image-load-url'), + loadImageFromPath: require('./image-load-path'), + promiseAllStepN: require('./promise-concurrent'), + userName: require('./user-name') +} diff --git a/utils/promise-concurrent.js b/utils/promise-concurrent.js new file mode 100644 index 0000000000000000000000000000000000000000..7c827745237d7c63a2b06f6520275b998b71be45 --- /dev/null +++ b/utils/promise-concurrent.js @@ -0,0 +1,29 @@ +function promiseAllStepN (n, list) { + let tail = list.splice(n) + let head = list + let resolved = [] + let processed = 0 + return new Promise(resolve => { + head.forEach(x => { + let res = x() + resolved.push(res) + res.then(y => { + runNext() + return y + }) + }) + function runNext () { + if (processed == tail.length) { + resolve(Promise.all(resolved)) + } else { + resolved.push(tail[processed]().then(x => { + runNext() + return x + })) + processed++ + } + } + }) +} + +module.exports = n => list => promiseAllStepN(n, list) diff --git a/utils/quote-generate/attachments.js b/utils/quote-generate/attachments.js new file mode 100644 index 0000000000000000000000000000000000000000..3ee79c4bc2eaabb4efcdab6866809fcaa0b0e688 --- /dev/null +++ b/utils/quote-generate/attachments.js @@ -0,0 +1,406 @@ +// utils/quote-generate/attachments.js +// +// Row-style attachments rendered inside the bubble (voice, document, audio) +// and overlay badges for media (video play button, duration/GIF chips). +// All sizes below are logical px — multiplied by `scale` at use. + +const fs = require('fs') +const path = require('path') +const { createCanvas, loadImage } = require('canvas') +const sharp = require('sharp') +const { drawLabel } = require('./canvas-utils') + +// Official Material Design icons (Apache-2.0), vendored as-is from +// @material-design-icons/svg into assets/icons/. Rasterized once at 256px +// white via sharp and drawn scaled; the geometric fallbacks below only kick +// in if SVG rasterization is unavailable. +const ICON_FILES = { + play: 'play_arrow.svg', + file: 'insert_drive_file.svg', + note: 'music_note.svg' +} +const ICONS_DIR = path.resolve(__dirname, '../../assets/icons') + +let icons = null +let iconsLoading = null + +// Warm the white icon sprites (256px). Call (and await) once before any +// drawVoiceRow/drawDocumentRow/drawAudioRow/paintMediaBadges usage. +async function loadIcons () { + if (icons) return icons + if (!iconsLoading) { + iconsLoading = (async () => { + const out = {} + for (const [key, file] of Object.entries(ICON_FILES)) { + const svg = await fs.promises.readFile(path.join(ICONS_DIR, file), 'utf8') + // The vendored icons carry no fill (default black) — paint them white. + const white = svg.replace(' { + console.warn('Icon rasterization failed, using geometric fallbacks:', err.message) + iconsLoading = null + return null + }) + } + return iconsLoading +} + +// Draws a warmed white icon centered in a box, or runs the fallback painter. +function paintIcon (ctx, name, x, y, size, fallback) { + if (icons && icons[name]) { + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = 'high' + ctx.drawImage(icons[name], x, y, size, size) + } else { + fallback() + } +} + +const ROW = { + disc: 40, // play/file/cover disc side + gap: 11, // disc → texts/waveform + title: 16, // first line (file name, track title) + meta: 13, // second line (size, performer, duration) + metaAlpha: 0.55, + lineGap: 4, // between the two text lines + bar: 3, // waveform bar width + barGap: 2.5, // waveform bar pitch gap + barMin: 4, // shortest bar + barMax: 26, // tallest bar + cover: 8 // audio cover corner radius +} + +// m:ss (Telegram never shows hours on voice/audio chips) +function formatDuration (seconds) { + const total = Math.max(0, Math.round(Number(seconds) || 0)) + const m = Math.floor(total / 60) + const s = total % 60 + return `${m}:${String(s).padStart(2, '0')}` +} + +// Binary units, one decimal from KB up: 999 B, 2.0 KB, 5.3 MB, 1.2 GB +function formatFileSize (bytes) { + const b = Math.max(0, Number(bytes) || 0) + if (b < 1024) return `${Math.round(b)} B` + const units = ['KB', 'MB', 'GB'] + let v = b + let u = -1 + do { + v /= 1024 + u++ + } while (v >= 1024 && u < units.length - 1) + return `${v.toFixed(1)} ${units[u]}` +} + +// Solid disc with a white play icon — the voice row lead-in. +function drawPlayDisc (d, accent) { + const canvas = createCanvas(d, d) + const ctx = canvas.getContext('2d') + ctx.fillStyle = accent + ctx.beginPath() + ctx.arc(d / 2, d / 2, d / 2, 0, Math.PI * 2) + ctx.fill() + const size = d * 0.62 + paintIcon(ctx, 'play', (d - size) / 2, (d - size) / 2, size, () => { + // Triangle fallback: optical center sits slightly right of geometric. + const r = d * 0.22 + const cx = d * 0.54 + const cy = d / 2 + ctx.fillStyle = '#fff' + ctx.beginPath() + ctx.moveTo(cx - r * 0.7, cy - r) + ctx.lineTo(cx - r * 0.7, cy + r) + ctx.lineTo(cx + r * 1.1, cy) + ctx.closePath() + ctx.fill() + }) + return canvas +} + +// Solid disc with a white file icon — the document lead-in. +function drawFileDisc (d, accent) { + const canvas = createCanvas(d, d) + const ctx = canvas.getContext('2d') + ctx.fillStyle = accent + ctx.beginPath() + ctx.arc(d / 2, d / 2, d / 2, 0, Math.PI * 2) + ctx.fill() + const size = d * 0.55 + paintIcon(ctx, 'file', (d - size) / 2, (d - size) / 2, size, () => { + // Page-with-folded-corner fallback + const w = d * 0.34 + const h = d * 0.44 + const x = (d - w) / 2 + const y = (d - h) / 2 + const fold = w * 0.38 + ctx.fillStyle = '#fff' + ctx.beginPath() + ctx.moveTo(x, y) + ctx.lineTo(x + w - fold, y) + ctx.lineTo(x + w, y + fold) + ctx.lineTo(x + w, y + h) + ctx.lineTo(x, y + h) + ctx.closePath() + ctx.fill() + ctx.fillStyle = accent + ctx.beginPath() + ctx.moveTo(x + w - fold, y) + ctx.lineTo(x + w - fold, y + fold) + ctx.lineTo(x + w, y + fold) + ctx.closePath() + ctx.fill() + }) + return canvas +} + +// Disc with a white music note icon — audio fallback cover. +function drawNoteDisc (d, accent) { + const canvas = createCanvas(d, d) + const ctx = canvas.getContext('2d') + ctx.fillStyle = accent + ctx.beginPath() + ctx.arc(d / 2, d / 2, d / 2, 0, Math.PI * 2) + ctx.fill() + const size = d * 0.55 + paintIcon(ctx, 'note', (d - size) / 2, (d - size) / 2, size, () => { + // Geometric eighth-note fallback (no font dependency) + ctx.fillStyle = '#fff' + const headR = d * 0.09 + const hx = d * 0.42 + const hy = d * 0.66 + const stemH = d * 0.34 + const stemW = Math.max(1, d * 0.045) + ctx.beginPath() + ctx.ellipse(hx, hy, headR * 1.25, headR, -0.4, 0, Math.PI * 2) + ctx.fill() + ctx.fillRect(hx + headR * 1.1 - stemW, hy - stemH, stemW, stemH) + ctx.beginPath() + ctx.moveTo(hx + headR * 1.1, hy - stemH) + ctx.quadraticCurveTo(d * 0.62, hy - stemH + d * 0.07, d * 0.58, hy - stemH + d * 0.2) + ctx.quadraticCurveTo(d * 0.56, hy - stemH + d * 0.12, hx + headR * 1.1, hy - stemH + d * 0.1) + ctx.closePath() + ctx.fill() + }) + return canvas +} + +// Resamples a Telegram waveform (values 0..31) to `n` buckets by averaging. +function resampleWaveform (data, n) { + if (data.length <= n) return data.map((v) => v / 31) + const out = [] + for (let i = 0; i < n; i++) { + const from = Math.floor(i * data.length / n) + const to = Math.max(from + 1, Math.floor((i + 1) * data.length / n)) + let sum = 0 + for (let j = from; j < to; j++) sum += data[j] + out.push(sum / (to - from) / 31) + } + return out +} + +/** + * Voice message row: [accent play disc] [rounded waveform bars] [duration]. + * `maxWidth` caps the full row (device px); bars resample to fit. + */ +function drawVoiceRow (waveform, duration, accent, textColor, scale, maxWidth) { + const s = (v) => v * scale + const d = s(ROW.disc) + const durLabel = drawLabel(formatDuration(duration), s(ROW.meta), textColor, { alpha: ROW.metaAlpha }) + + const pitch = s(ROW.bar) + s(ROW.barGap) + const barsAvail = Math.max(pitch * 8, (maxWidth || s(220)) - d - s(ROW.gap) * 2 - durLabel.width) + const barCount = Math.min(Math.max(8, waveform.length), Math.floor(barsAvail / pitch)) + const heights = resampleWaveform(waveform, barCount) + const barsW = barCount * pitch - s(ROW.barGap) + + const w = Math.ceil(d + s(ROW.gap) + barsW + s(ROW.gap) + durLabel.width) + const h = d + const canvas = createCanvas(w, h) + const ctx = canvas.getContext('2d') + + ctx.drawImage(drawPlayDisc(d, accent), 0, 0) + + ctx.fillStyle = accent + const cy = h / 2 + for (let i = 0; i < barCount; i++) { + const bh = Math.max(s(ROW.barMin), heights[i] * s(ROW.barMax)) + const x = d + s(ROW.gap) + i * pitch + roundedVBar(ctx, x, cy - bh / 2, s(ROW.bar), bh) + } + + ctx.drawImage(durLabel, d + s(ROW.gap) + barsW + s(ROW.gap), Math.round((h - durLabel.height) / 2)) + return canvas +} + +/** + * Document row: [accent file disc] [file name / size · EXT]. + */ +function drawDocumentRow (doc, accent, textColor, scale, maxWidth) { + const s = (v) => v * scale + const d = s(ROW.disc) + const name = String(doc.file_name || 'File') + const ext = name.includes('.') ? name.split('.').pop().toUpperCase() : '' + const metaText = [doc.file_size != null ? formatFileSize(doc.file_size) : null, ext || null] + .filter(Boolean).join(' · ') + + const title = drawLabel(name, s(ROW.title), textColor, { bold: true }) + const meta = metaText ? drawLabel(metaText, s(ROW.meta), textColor, { alpha: ROW.metaAlpha }) : null + return assembleRow(drawFileDisc(d, accent), title, meta, scale, maxWidth) +} + +/** + * Audio row: [cover or accent note disc] [title / performer · duration]. + * `thumb` is an optional Image/Canvas (already loaded). + */ +function drawAudioRow (audio, accent, textColor, scale, maxWidth, thumb) { + const s = (v) => v * scale + const d = s(ROW.disc) + + let lead + if (thumb) { + lead = createCanvas(d, d) + const ctx = lead.getContext('2d') + const r = s(ROW.cover) + ctx.beginPath() + ctx.moveTo(r, 0) + ctx.arcTo(d, 0, d, d, r) + ctx.arcTo(d, d, 0, d, r) + ctx.arcTo(0, d, 0, 0, r) + ctx.arcTo(0, 0, d, 0, r) + ctx.closePath() + ctx.clip() + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = 'high' + const side = Math.min(thumb.width, thumb.height) + ctx.drawImage(thumb, (thumb.width - side) / 2, (thumb.height - side) / 2, side, side, 0, 0, d, d) + } else { + lead = drawNoteDisc(d, accent) + } + + const metaText = [audio.performer || null, audio.duration != null ? formatDuration(audio.duration) : null] + .filter(Boolean).join(' · ') + const title = drawLabel(String(audio.title || 'Audio'), s(ROW.title), textColor, { bold: true }) + const meta = metaText ? drawLabel(metaText, s(ROW.meta), textColor, { alpha: ROW.metaAlpha }) : null + return assembleRow(lead, title, meta, scale, maxWidth) +} + +// [disc] + up to two text lines, vertically centered against the disc. +function assembleRow (disc, title, meta, scale, maxWidth) { + const s = (v) => v * scale + const gap = s(ROW.gap) + const textW = Math.max(title.width, meta ? meta.width : 0) + let w = Math.ceil(disc.width + gap + textW) + if (maxWidth && w > maxWidth) w = Math.ceil(maxWidth) + const textsH = title.height + (meta ? s(ROW.lineGap) + meta.height : 0) + const h = Math.max(disc.height, textsH) + + const canvas = createCanvas(w, h) + const ctx = canvas.getContext('2d') + ctx.drawImage(disc, 0, Math.round((h - disc.height) / 2)) + + const maxTextW = w - disc.width - gap + let ty = Math.round((h - textsH) / 2) + ctx.drawImage(clampWidth(title, maxTextW), disc.width + gap, ty) + ty += title.height + s(ROW.lineGap) + if (meta) ctx.drawImage(clampWidth(meta, maxTextW), disc.width + gap, ty) + return canvas +} + +// Hard-crops a label canvas with a trailing fade (same look as layout-box). +function clampWidth (canvas, maxW) { + if (canvas.width <= maxW) return canvas + const w = Math.max(1, Math.floor(maxW)) + const out = createCanvas(w, canvas.height) + const ctx = out.getContext('2d') + ctx.drawImage(canvas, 0, 0) + const fadeW = Math.min(w, Math.round(canvas.height * 0.9)) + const grad = ctx.createLinearGradient(w - fadeW, 0, w, 0) + grad.addColorStop(0, 'rgba(0, 0, 0, 0)') + grad.addColorStop(1, 'rgba(0, 0, 0, 1)') + ctx.globalCompositeOperation = 'destination-out' + ctx.fillStyle = grad + ctx.fillRect(w - fadeW, 0, fadeW, canvas.height) + return out +} + +function roundedVBar (ctx, x, y, w, h) { + const r = w / 2 + ctx.beginPath() + ctx.moveTo(x, y + r) + ctx.arcTo(x, y, x + w, y, r) + ctx.arcTo(x + w, y, x + w, y + h, r) + ctx.lineTo(x + w, y + h - r) + ctx.arcTo(x + w, y + h, x, y + h, r) + ctx.arcTo(x, y + h, x, y + h - r, r) + ctx.closePath() + ctx.fill() +} + +/** + * Overlay badges for media, painted in DESTINATION space (after the media + * is scaled into the bubble): centered play button and/or a bottom-left chip + * ("GIF", "0:42"). `ctx` is the final canvas, (x, y, w, h) the media rect. + */ +function paintMediaBadges (ctx, x, y, w, h, badge, scale) { + const s = (v) => v * scale + if (!badge) return + ctx.save() + if (badge.play) { + const d = Math.min(s(44), w * 0.45, h * 0.45) + const cx = x + w / 2 + const cy = y + h / 2 + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)' + ctx.beginPath() + ctx.arc(cx, cy, d / 2, 0, Math.PI * 2) + ctx.fill() + const size = d * 0.62 + paintIcon(ctx, 'play', cx - size / 2, cy - size / 2, size, () => { + const r = d * 0.24 + ctx.fillStyle = '#fff' + ctx.beginPath() + ctx.moveTo(cx - r * 0.62, cy - r) + ctx.lineTo(cx - r * 0.62, cy + r) + ctx.lineTo(cx + r * 1.05, cy) + ctx.closePath() + ctx.fill() + }) + } + if (badge.label) { + const label = drawLabel(badge.label, s(13), '#fff') + const padX = s(7) + const padY = s(2) + const bw = label.width + padX * 2 + const bh = label.height + padY * 2 + const bx = x + s(6) + const by = y + h - bh - s(6) + const r = bh / 2 + ctx.fillStyle = 'rgba(0, 0, 0, 0.45)' + ctx.beginPath() + ctx.moveTo(bx + r, by) + ctx.arcTo(bx + bw, by, bx + bw, by + bh, r) + ctx.arcTo(bx + bw, by + bh, bx, by + bh, r) + ctx.arcTo(bx, by + bh, bx, by, r) + ctx.arcTo(bx, by, bx + bw, by, r) + ctx.closePath() + ctx.fill() + ctx.drawImage(label, bx + padX, by + padY) + } + ctx.restore() +} + +module.exports = { + loadIcons, + drawVoiceRow, + drawDocumentRow, + drawAudioRow, + paintMediaBadges, + formatDuration, + formatFileSize, + resampleWaveform, + ROW +} diff --git a/utils/quote-generate/avatar.js b/utils/quote-generate/avatar.js new file mode 100644 index 0000000000000000000000000000000000000000..44c1fd0ab658446e29726a5fcedc5597582e0dbc --- /dev/null +++ b/utils/quote-generate/avatar.js @@ -0,0 +1,168 @@ +// utils/quote-generate/avatar.js + +const { createCanvas, loadImage } = require('canvas') +const LRU = require('lru-cache') +const runes = require('runes') +const loadImageFromUrl = require('../image-load-url') +const { AVATAR_COLORS } = require('./constants') + +const avatarCache = new LRU({ + max: 20, + maxAge: 1000 * 60 * 5 +}) + +function avatarImageLetters (letters, color) { + const size = 500 + const canvas = createCanvas(size, size) + const context = canvas.getContext('2d') + + const gradient = context.createLinearGradient(0, 0, canvas.width, canvas.height) + gradient.addColorStop(0, color[0]) + gradient.addColorStop(1, color[1]) + + context.fillStyle = gradient + context.fillRect(0, 0, canvas.width, canvas.height) + + const letterCount = runes(letters).length + const fontSize = letterCount > 1 ? size * 0.38 : size * 0.48 + context.font = `600 ${fontSize}px NotoSans` + context.fillStyle = '#FFF' + context.textAlign = 'center' + context.textBaseline = 'middle' + context.fillText(letters, size / 2, size / 2) + + return canvas +} + +async function downloadAvatarImage (user, telegram) { + let avatarImage + + let nameLetters + if (user.first_name && user.last_name) { + nameLetters = runes(user.first_name)[0] + (runes(user.last_name || '')[0]) + } else { + let name = user.first_name || (user.name && user.name !== false && user.name) || user.title || '?' + if (typeof name !== 'string') name = String(name) + name = name.toUpperCase() + const nameWord = name.split(' ') + + if (nameWord.length > 1) nameLetters = runes(nameWord[0])[0] + runes(nameWord[nameWord.length - 1])[0] + else nameLetters = runes(nameWord[0])[0] + } + + const cacheKey = user.id != null ? user.id : `noId:${user.username || nameLetters}` + const avatarImageCached = avatarCache.get(cacheKey) + const nameIndex = user.id != null ? Math.abs(user.id) % 7 : 0 + const avatarColor = AVATAR_COLORS[nameIndex] + + if (avatarImageCached) { + return avatarImageCached + } + + if (user.photo && user.photo.url) { + avatarImage = await loadImage(user.photo.url).catch(() => null) + } + + if (!avatarImage) { + try { + let userPhoto, userPhotoUrl + + if (user.photo && user.photo.big_file_id) { + userPhotoUrl = await telegram.getFileLink(user.photo.big_file_id).catch(() => {}) + } + + if (!userPhotoUrl) { + const getChat = user.id != null + ? await telegram.getChat(user.id).catch(() => {}) + : null + + if (getChat) { + // Use chat data to fix missing initials + if (nameLetters === '?') { + const chatName = getChat.first_name || getChat.title + if (chatName) { + const upper = chatName.toUpperCase() + const words = upper.split(' ') + if (words.length > 1) nameLetters = runes(words[0])[0] + runes(words[words.length - 1])[0] + else nameLetters = runes(words[0])[0] + } + } + + if (getChat.photo && getChat.photo.big_file_id) { + userPhoto = getChat.photo.big_file_id + } + } + + if (userPhoto) { + userPhotoUrl = await telegram.getFileLink(userPhoto).catch(() => {}) + } else if (user.username) { + userPhotoUrl = `https://telega.one/i/userpic/320/${user.username}.jpg` + } + } + + if (userPhotoUrl) { + const imageBuffer = await loadImageFromUrl(userPhotoUrl).catch((error) => { + console.warn('Failed to load user photo from URL:', error.message) + return null + }) + + if (imageBuffer) { + avatarImage = await loadImage(imageBuffer).catch((error) => { + console.warn('Failed to process user photo buffer:', error.message) + return null + }) + } + } + + if (avatarImage) { + avatarCache.set(cacheKey, avatarImage) + } + } catch (error) { + console.warn('Error getting user photo:', error.message) + avatarImage = null + } + } + + // Final fallback — initials avatar + if (!avatarImage) { + try { + avatarImage = avatarImageLetters(nameLetters, avatarColor) + avatarCache.set(cacheKey, avatarImage) + } catch (error) { + console.warn('Failed to create letters avatar:', error.message) + avatarImage = null + } + } + + return avatarImage +} + +async function drawAvatar (user, telegram) { + try { + const avatarImage = await downloadAvatarImage(user, telegram) + + if (avatarImage) { + const avatarSize = avatarImage.naturalHeight || avatarImage.height + + const canvas = createCanvas(avatarSize, avatarSize) + const canvasCtx = canvas.getContext('2d') + + canvasCtx.beginPath() + canvasCtx.arc(avatarSize / 2, avatarSize / 2, avatarSize / 2, 0, Math.PI * 2, true) + canvasCtx.save() + canvasCtx.clip() + canvasCtx.closePath() + canvasCtx.drawImage(avatarImage, 0, 0, avatarSize, avatarSize) + canvasCtx.restore() + + return canvas + } + console.warn('No avatar image available for user') + return null + } catch (error) { + console.warn('Error drawing avatar:', error.message) + return null + } +} + +module.exports = { drawAvatar, downloadAvatarImage, avatarImageLetters } diff --git a/utils/quote-generate/canvas-utils.js b/utils/quote-generate/canvas-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..34dd0553dc98e0f4b47200412cbee856751eb6c5 --- /dev/null +++ b/utils/quote-generate/canvas-utils.js @@ -0,0 +1,243 @@ +const { createCanvas } = require('canvas') + +// Draws the bubble path. When tailSize > 0, the bottom-left corner +// becomes a tail: flat bottom extending left, rounded top curving up. +// `r` is a single radius or per-corner {tl, tr, br, bl} (grouped bubbles +// flatten the corners that face their neighbours, like Telegram). +function bubblePath (ctx, w, h, r, tailSize) { + let { tl, tr, br, bl } = typeof r === 'number' ? { tl: r, tr: r, br: r, bl: r } : r + const cap = (v) => Math.min(v, w / 2, h / 2) + tl = cap(tl) + tr = cap(tr) + br = cap(br) + bl = cap(bl) + + ctx.beginPath() + ctx.moveTo(tl, 0) + // top-right corner + ctx.arcTo(w, 0, w, h, tr) + // bottom-right corner + ctx.arcTo(w, h, 0, h, br) + + if (tailSize > 0) { + const t = tailSize + + // Bottom edge continues FLAT to the left, past the bubble edge + ctx.lineTo(-t, h) + + // Rounded curve going up-right, back to the left edge of the bubble. + // Bottom stays flat, top is smoothly rounded. + ctx.bezierCurveTo( + -t * 0.4, h, + 0, h - bl * 0.3, + 0, h - bl + ) + } else { + // Normal bottom-left rounded corner + ctx.arcTo(0, h, 0, 0, bl) + } + + // top-left corner + ctx.arcTo(0, 0, w, 0, tl) + ctx.closePath() +} + +// Frosted-glass finish over the bubble fill: a hairline inner border plus a +// light top edge that fades out — both clipped to the bubble path so they +// follow the corners and the tail. `lw` is the hairline width in device px. +function paintGlass (ctx, w, h, r, tailSize, lw) { + ctx.save() + bubblePath(ctx, w, h, r, tailSize) + ctx.clip() + + // Uniform hairline border. Stroke is centered on the path; with the clip + // active only the inner half remains, so double the width. + bubblePath(ctx, w, h, r, tailSize) + ctx.lineWidth = lw * 2 + ctx.strokeStyle = 'rgba(255, 255, 255, 0.07)' + ctx.stroke() + + // Light top edge fading out by ~40% of the height. + const grad = ctx.createLinearGradient(0, 0, 0, h * 0.4) + grad.addColorStop(0, 'rgba(255, 255, 255, 0.16)') + grad.addColorStop(1, 'rgba(255, 255, 255, 0)') + ctx.lineWidth = lw * 2.6 + ctx.strokeStyle = grad + ctx.stroke() + ctx.restore() +} + +function drawRoundRect (color, w, h, r, tailSize = 0, glassLw = 0) { + const extraLeft = tailSize > 0 ? Math.ceil(tailSize * 0.8) : 0 + const canvas = createCanvas(w + extraLeft, h) + const ctx = canvas.getContext('2d') + ctx.translate(extraLeft, 0) + ctx.fillStyle = color + bubblePath(ctx, w, h, r, tailSize) + ctx.fill() + if (glassLw > 0) paintGlass(ctx, w, h, r, tailSize, glassLw) + canvas._tailOffset = extraLeft + return canvas +} + +function drawGradientRoundRect (colorOne, colorTwo, w, h, r, tailSize = 0, glassLw = 0) { + const extraLeft = tailSize > 0 ? Math.ceil(tailSize * 0.8) : 0 + const canvas = createCanvas(w + extraLeft, h) + const ctx = canvas.getContext('2d') + ctx.translate(extraLeft, 0) + const gradient = ctx.createLinearGradient(0, 0, w, h) + gradient.addColorStop(0, colorOne) + gradient.addColorStop(1, colorTwo) + ctx.fillStyle = gradient + bubblePath(ctx, w, h, r, tailSize) + ctx.fill() + if (glassLw > 0) paintGlass(ctx, w, h, r, tailSize, glassLw) + canvas._tailOffset = extraLeft + return canvas +} + +// Rounds an image's corners. `r` is a single radius or per-corner +// {tl, tr, br, bl} — flush media inherits the bubble's corner radii on the +// edges it touches while keeping the small media radius elsewhere. +function roundImage (image, r) { + const w = image.width + const h = image.height + const canvas = createCanvas(w, h) + const ctx = canvas.getContext('2d') + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = 'high' + let { tl, tr, br, bl } = typeof r === 'number' ? { tl: r, tr: r, br: r, bl: r } : r + const cap = (v) => Math.min(v, w / 2, h / 2) + tl = cap(tl) + tr = cap(tr) + br = cap(br) + bl = cap(bl) + ctx.beginPath() + ctx.moveTo(tl, 0) + ctx.arcTo(w, 0, w, h, tr) + ctx.arcTo(w, h, 0, h, br) + ctx.arcTo(0, h, 0, 0, bl) + ctx.arcTo(0, 0, w, 0, tl) + ctx.save() + ctx.clip() + ctx.closePath() + ctx.drawImage(image, 0, 0) + ctx.restore() + return canvas +} + +function drawReplyLine (lineWidth, height, color) { + const r = lineWidth + const w = lineWidth + const h = height + lineWidth + const x = 5 + const canvas = createCanvas(w + 10, h) + const ctx = canvas.getContext('2d') + + ctx.fillStyle = color + ctx.beginPath() + // Top-left: rounded + ctx.moveTo(x, r) + ctx.arcTo(x, 0, x + w, 0, r) + // Top-right: square + ctx.lineTo(x + w, 0) + // Right edge straight down + ctx.lineTo(x + w, h) + // Bottom-right: square + ctx.lineTo(x + r, h) + // Bottom-left: rounded + ctx.arcTo(x, h, x, 0, r) + ctx.closePath() + ctx.fill() + + return canvas +} + +function drawQuoteIcon (size, color, alpha = 0.15) { + const canvas = createCanvas(size, size) + const ctx = canvas.getContext('2d') + + // Draw as a single combined path so nothing overlaps + ctx.fillStyle = color + ctx.globalAlpha = alpha + + const s = size + const r = s * 0.09 + const gapX = s * 0.36 + + // Build one unified path for both quote marks + ctx.beginPath() + addQuoteMarkPath(ctx, s * 0.08, s * 0.15, r, s) + addQuoteMarkPath(ctx, s * 0.08 + gapX, s * 0.15, r, s) + ctx.fill() + + return canvas +} + +function addQuoteMarkPath (ctx, x, y, r, s) { + // Comma-shaped glyph: circle on top, tail curving down-left + const cx = x + r + const cy = y + r + // Circle + ctx.moveTo(cx + r, cy) + ctx.arc(cx, cy, r, 0, Math.PI * 2) + // Tail + ctx.moveTo(x + r * 2, cy) + ctx.quadraticCurveTo(x + r * 2.2, y + s * 0.35, x + r * 0.3, y + s * 0.4) + ctx.lineTo(x + r * 0.3, y + s * 0.32) + ctx.quadraticCurveTo(x + r * 1.5, y + s * 0.27, x + r * 0.8, cy) + ctx.closePath() +} + +// Vertical ink bounds of a canvas (rows with any non-transparent pixel), +// or null for a blank canvas. Text canvases from drawMultilineText carry +// transparent slack (~0.8×fontSize below the last baseline), so geometry +// that should follow the VISIBLE glyphs must use these bounds instead of +// canvas height. +function inkBounds (canvas) { + const w = canvas.width + const h = canvas.height + // Plain Images (loadImage results) have no getContext — callers must not + // trim those; treat them as fully opaque. + if (w < 1 || h < 1 || typeof canvas.getContext !== 'function') return null + const data = canvas.getContext('2d').getImageData(0, 0, w, h).data + const rowHasInk = (y) => { + const off = y * w * 4 + 3 + for (let x = 0; x < w; x++) if (data[off + x * 4] > 8) return true + return false + } + // Scan from both ends — skips the (usually large) solid middle. + let top = -1 + for (let y = 0; y < h; y++) if (rowHasInk(y)) { top = y; break } + if (top === -1) return null + let bottom = top + for (let y = h - 1; y > top; y--) if (rowHasInk(y)) { bottom = y; break } + return { top, bottom } +} + +// One-line label drawn at metric size: canvas height = ascent + descent of +// the font em box, baseline at ascent. Same geometry rules as multiline +// text — glyph shapes never change the box. +function drawLabel (text, fontSize, color, opts = {}) { + // Lazy require — canvas-utils is loaded by layout-box before text-prepare. + const { fontMetrics } = require('./text-prepare') + const { ascent, descent } = fontMetrics(fontSize) + const font = `${opts.bold ? 'bold ' : ''}${fontSize}px NotoSans` + const measure = createCanvas(1, 1).getContext('2d') + measure.font = font + const m = measure.measureText(text) + + const canvas = createCanvas(Math.max(1, Math.ceil(m.width)), Math.max(1, Math.ceil(ascent + descent))) + const ctx = canvas.getContext('2d') + ctx.font = font + ctx.fillStyle = color + if (opts.alpha !== undefined) ctx.globalAlpha = opts.alpha + ctx.fillText(text, 0, ascent) + return canvas +} + +function drawForwardLabel (text, fontSize, color) { + return drawLabel(text, fontSize, color, { bold: true }) +} + +module.exports = { drawRoundRect, drawGradientRoundRect, roundImage, drawReplyLine, drawQuoteIcon, drawLabel, drawForwardLabel, inkBounds } diff --git a/utils/quote-generate/color.js b/utils/quote-generate/color.js new file mode 100644 index 0000000000000000000000000000000000000000..f9f30b1ce7a72b92fa6dc3fc10d61fb15886f562 --- /dev/null +++ b/utils/quote-generate/color.js @@ -0,0 +1,167 @@ +const { createCanvas } = require('canvas') + +class ColorContrast { + constructor () { + this.brightnessThreshold = 175 + } + + getBrightness (color) { + const [r, g, b] = hexToRgb(color) + return (r * 299 + g * 587 + b * 114) / 1000 + } + + adjustBrightness (color, amount) { + const [r, g, b] = hexToRgb(color) + const newR = Math.max(0, Math.min(255, r + amount)) + const newG = Math.max(0, Math.min(255, g + amount)) + const newB = Math.max(0, Math.min(255, b + amount)) + return rgbToHex([newR, newG, newB]) + } + + getContrastRatio (background, foreground) { + const brightness1 = this.getBrightness(background) + const brightness2 = this.getBrightness(foreground) + const lightest = Math.max(brightness1, brightness2) + const darkest = Math.min(brightness1, brightness2) + return (lightest + 0.05) / (darkest + 0.05) + } + + adjustContrast (background, foreground) { + const contrastRatio = this.getContrastRatio(background, foreground) + const brightnessDiff = this.getBrightness(background) - this.getBrightness(foreground) + if (contrastRatio >= 4.5) { + return foreground + } else if (brightnessDiff >= 0) { + const amount = Math.ceil((this.brightnessThreshold - this.getBrightness(foreground)) / 2) + return this.adjustBrightness(foreground, amount) + } else { + const amount = Math.ceil((this.getBrightness(foreground) - this.brightnessThreshold) / 2) + return this.adjustBrightness(foreground, -amount) + } + } +} + +function hexToRgb (hex) { + hex = String(hex).replace(/^#/, '') + if (hex.length === 3) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2] + } + const r = parseInt(hex.substring(0, 2), 16) + const g = parseInt(hex.substring(2, 4), 16) + const b = parseInt(hex.substring(4, 6), 16) + return [r, g, b] +} + +function rgbToHex ([r, g, b]) { + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` +} + +const normalizeColorCache = new Map() +const NORMALIZE_COLOR_CACHE_MAX = 1000 + +function normalizeColor (color) { + const cached = normalizeColorCache.get(color) + if (cached !== undefined) return cached + + const canvas = createCanvas(0, 0) + const canvasCtx = canvas.getContext('2d') + canvasCtx.fillStyle = color + const result = canvasCtx.fillStyle + + if (normalizeColorCache.size >= NORMALIZE_COLOR_CACHE_MAX) { + const firstKey = normalizeColorCache.keys().next().value + normalizeColorCache.delete(firstKey) + } + normalizeColorCache.set(color, result) + + return result +} + +function colorLuminance (hex, lum) { + hex = String(hex).replace(/[^0-9a-f]/gi, '') + if (hex.length < 6) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2] + } + lum = lum || 0 + let rgb = '#' + for (let i = 0; i < 3; i++) { + let c = parseInt(hex.substr(i * 2, 2), 16) + c = Math.round(Math.min(Math.max(0, c + (c * lum)), 255)).toString(16) + rgb += ('00' + c).substr(c.length) + } + return rgb +} + +function lightOrDark (color) { + let r, g, b + if (color.match(/^rgb/)) { + color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/) + r = color[1] + g = color[2] + b = color[3] + } else { + color = +('0x' + color.slice(1).replace(color.length < 5 && /./g, '$&$&')) + r = color >> 16 + g = color >> 8 & 255 + b = color & 255 + } + const hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)) + return hsp > 127.5 ? 'light' : 'dark' +} + +function parseBackgroundColor (backgroundColor) { + if (typeof backgroundColor !== 'string') backgroundColor = null + backgroundColor = backgroundColor || '//#292232' + const split = backgroundColor.split('/') + if (split.length > 1 && split[0] !== '') { + return { colorOne: normalizeColor(split[0]), colorTwo: normalizeColor(split[1]) } + } else if (backgroundColor.startsWith('//')) { + const base = normalizeColor(backgroundColor.replace('//', '')) + return { colorOne: colorLuminance(base, 0.35), colorTwo: colorLuminance(base, -0.15) } + } else { + const base = normalizeColor(backgroundColor) + return { colorOne: base, colorTwo: base } + } +} + +function hexToHsl (hex) { + const [r, g, b] = hexToRgb(hex).map((v) => v / 255) + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const l = (max + min) / 2 + if (max === min) return [0, 0, l] + const d = max - min + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + let h + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6 + else if (max === g) h = ((b - r) / d + 2) / 6 + else h = ((r - g) / d + 4) / 6 + return [h * 360, s, l] +} + +function hslToHex (h, s, l) { + h = ((h % 360) + 360) % 360 / 360 + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1 + if (t > 1) t -= 1 + if (t < 1 / 6) return p + (q - p) * 6 * t + if (t < 1 / 2) return q + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 + return p + } + let r, g, b + if (s === 0) { + r = g = b = l + } else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s + const p = 2 * l - q + r = hue2rgb(p, q, h + 1 / 3) + g = hue2rgb(p, q, h) + b = hue2rgb(p, q, h - 1 / 3) + } + return rgbToHex([Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]) +} + +module.exports = { + ColorContrast, hexToRgb, rgbToHex, normalizeColor, colorLuminance, lightOrDark, parseBackgroundColor, hexToHsl, hslToHex +} diff --git a/utils/quote-generate/composer.js b/utils/quote-generate/composer.js new file mode 100644 index 0000000000000000000000000000000000000000..d764b274cc2b752c296deb61c70568319314d9b3 --- /dev/null +++ b/utils/quote-generate/composer.js @@ -0,0 +1,329 @@ +// utils/quote-generate/composer.js +// +// Composes a quote bubble from pre-rendered canvases using a DOM/CSS-style +// box model (see layout-box.js): the bubble is a column with padding and a +// uniform vertical gap; every spacing constant lives in SP. No element is +// positioned with ad-hoc offsets — parents size themselves from children. + +const { createCanvas } = require('canvas') +const { drawRoundRect, drawGradientRoundRect, roundImage, drawQuoteIcon, drawLabel, drawForwardLabel } = require('./canvas-utils') +const { paintMediaBadges } = require('./attachments') +const { leaf, box, measure, place, render } = require('./layout-box') + +// All spacing in logical px (multiplied by scale at use). The single place +// to tune how a quote breathes. +const SP = { + padX: 16, // bubble inner padding → ink, horizontal + padY: 12, // bubble inner padding → first metric box (which adds its own slack) + // Vertical rhythm between solid blocks (reply chip, media, attachment). + // Text nodes override it with mt 0: their metric line box already carries + // the air above the cap line, so stacking at 0 lands on the same + // baseline-to-baseline rhythm as the text's own line height. + gap: 5, + headerGap: 8, // min gap between name and sender tag + maxHeader: 300, // header/forward-label width cap — longer names fade out instead of inflating the bubble + radius: 25, // bubble corner radius + radiusGrouped: 7, // corner radius facing a same-sender neighbour bubble + replyThumb: 34, // reply media thumbnail side + shadowPad: 12, // canvas margin (right/bottom) so the drop shadow isn't clipped + shadowPadTop: 4, // canvas margin above the bubble (shadow blur spills up a little) + glass: 1.25, // frosted-glass hairline width (border + top edge highlight) + tail: 14, // bubble tail size (when avatar is shown) + minWidth: 100, // min bubble width + avatar: 50, // avatar diameter + avatarGap: 10, // avatar → bubble + mediaRound: 12, // media corner radius (inside a bubble) + // Accent block — the modern-Telegram rounded tinted block used for both + // the reply preview and the partial-quote body: solid bar on the left, + // accent tint behind, optional ❝ in the corner. + block: { padY: 6, padL: 10, padR: 10, padRIcon: 22, bar: 3.5, icon: 15, iconInset: 5, radius: 7, tint: 0.14, gap: 3 } +} + +function drawQuote (options) { + const { + scale = 1, + background, + avatar, + reply, + name, + text, + textBlocks, // [{ canvas, quote: bool }] — text split around blockquote entities + media, + attachment, // pre-rendered in-bubble row canvas (voice/document/audio) + isForward, + forwardLabel, + nameColor, + senderTag, + viaBot, // pre-rendered "via @bot" canvas (or null) + groupPos = 'single', // single | first | middle | last — corners facing a same-sender neighbour flatten + isQuote + } = options + + const s = (v) => v * scale + const accent = nameColor || background.textColor || '#fff' + + const mediaType = media ? media.type : null + const mediaCanvas = media ? media.canvas : null + const isSticker = mediaType === 'sticker' + const nameCanvas = isSticker ? null : name + + // ---- Leaves ------------------------------------------------------------- + + let headerNode = null + if (nameCanvas) { + let tagLeaf = null + if (senderTag) { + tagLeaf = leaf(drawLabel(senderTag, s(13), background.textColor || '#fff', { alpha: 0.45 })) + } + // The header fits into maxHeader as a whole: the name yields (fades) + // first, "via @bot" and the tag always stay visible. + const viaLeaf = viaBot ? leaf(viaBot) : null + let nameMax = s(SP.maxHeader) + if (viaLeaf) nameMax -= viaLeaf.w + s(6) + if (tagLeaf) nameMax -= tagLeaf.w + s(SP.headerGap) + const nameLeaf = leaf(nameCanvas, { maxW: Math.max(s(40), nameMax) }) + const nameSide = viaLeaf + ? box({ dir: 'row', align: 'center', gap: s(6), children: [nameLeaf, viaLeaf] }) + : nameLeaf + headerNode = tagLeaf + ? box({ dir: 'row', justify: 'between', align: 'center', gap: s(SP.headerGap), stretch: true, children: [nameSide, tagLeaf] }) + : nameSide + } + + let forwardNode = null + if (isForward && forwardLabel) { + forwardNode = leaf(drawForwardLabel(forwardLabel, s(13), accent), { maxW: s(SP.maxHeader) }) + } + + let replyNode = null + if (reply) { + // Modern Telegram renders the reply preview as a tinted accent block in + // the replied sender's color — same visual language as a quote. A media + // thumbnail (when the replied message has one) sits left of the texts. + const replyTexts = box({ dir: 'col', gap: s(SP.block.gap), children: [leaf(reply.name), leaf(reply.text)] }) + const inner = reply.thumb + ? box({ + dir: 'row', + gap: s(7), + align: 'center', + children: [ + leaf(reply.thumb, { + trim: false, + w: s(SP.replyThumb), + h: s(SP.replyThumb), + paint: (ctx, n) => ctx.drawImage(roundImage(coverSquare(n.canvas), s(4)), n.x, n.y, n.w, n.h) + }), + replyTexts + ] + }) + : replyTexts + replyNode = accentBlock(s, reply.nameColor, { children: [inner] }) + } + + // Media-only bubbles (photo with no caption/name/reply) are pure media: + // the photo IS the bubble, rounded with the bubble radius. + const mediaOnly = !!mediaCanvas && !nameCanvas && !text && !reply && !forwardLabel && !attachment + + // Grouped bubbles flatten the left corners that face their neighbours. + const R = s(SP.radius) + const rSmall = s(SP.radiusGrouped) + const radii = { + tl: groupPos === 'middle' || groupPos === 'last' ? rSmall : R, + tr: R, + br: R, + bl: groupPos === 'first' || groupPos === 'middle' ? rSmall : R + } + + // Like Telegram, media hugs the bubble edge it borders: with no caption + // below (or no header above) the bubble padding on that side collapses and + // the media corners inherit the bubble's own radii. + const isRound = mediaType === 'video_note' // round video — circular mask + const hasCaption = Boolean(text) || (Array.isArray(textBlocks) && textBlocks.length > 0) || Boolean(attachment) + const flushable = !!mediaCanvas && !mediaOnly && !isSticker && !isRound + const flushBottom = flushable && !hasCaption + const flushTop = flushable && !nameCanvas && !(isForward && forwardLabel) && !reply + + let mediaNode = null + if (mediaCanvas) { + const maxMediaSize = media.maxSize + let mediaWidth = mediaCanvas.width * (maxMediaSize / mediaCanvas.height) + let mediaHeight = maxMediaSize + if (mediaWidth >= maxMediaSize) { + mediaWidth = maxMediaSize + mediaHeight = mediaCanvas.height * (maxMediaSize / mediaCanvas.width) + } + const mr = s(SP.mediaRound) + const mediaRadius = mediaOnly || isSticker + ? s(SP.radius * 0.6) + : { + tl: flushTop ? radii.tl : mr, + tr: flushTop ? radii.tr : mr, + br: flushBottom ? radii.br : mr, + bl: flushBottom ? radii.bl : mr + } + mediaNode = leaf(mediaCanvas, { + trim: false, + bleed: !isRound, + w: isRound ? Math.min(mediaWidth, mediaHeight) : mediaWidth, + h: isRound ? Math.min(mediaWidth, mediaHeight) : mediaHeight, + paint: (ctx, n) => { + ctx.save() + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = 'high' + if (isRound) { + ctx.beginPath() + ctx.arc(n.x + n.w / 2, n.y + n.h / 2, n.w / 2, 0, Math.PI * 2) + ctx.clip() + ctx.drawImage(coverSquare(n.canvas), n.x, n.y, n.w, n.h) + } else { + // roundImage clips in SOURCE pixel space; the leaf then scales the + // result down to n.w×n.h — so the radii must scale up by the same + // factor or hi-res photos end up with visually smaller corners. + const k = n.canvas.width / n.w + const rSrc = typeof mediaRadius === 'number' + ? mediaRadius * k + : { tl: mediaRadius.tl * k, tr: mediaRadius.tr * k, br: mediaRadius.br * k, bl: mediaRadius.bl * k } + ctx.drawImage(roundImage(n.canvas, rSrc), n.x, n.y, n.w, n.h) + } + // Video/GIF overlays are painted in destination space so their size + // doesn't depend on the source media resolution. + if (media.badge) paintMediaBadges(ctx, n.x, n.y, n.w, n.h, media.badge, scale) + ctx.restore() + } + }) + } + + // Voice/document/audio rows sit in the media slot but behave like text: + // padded by the bubble, never flush. + const attachmentNode = attachment ? leaf(attachment.canvas) : null + + let textNode = null + if (Array.isArray(textBlocks) && textBlocks.length > 0 && !isQuote) { + // Text with blockquote entities: plain runs and quote runs stack in one + // column; each quote run gets the accent block treatment. + const parts = textBlocks.map((b) => { + if (b.quote) return accentBlock(s, accent, { icon: true, children: [leaf(b.canvas)] }) + const l = leaf(b.canvas) + if (l) l.mt = s(2) // plain runs carry their own metric air + return l + }) + textNode = box({ dir: 'col', gap: s(5), children: parts }) + } else if (text) { + textNode = isQuote + ? accentBlock(s, accent, { icon: true, children: [leaf(text)] }) + : leaf(text) + } + // Text supplies its own air above the cap line (metric ascent slack) — + // no extra flow gap, the name reads like the previous text line. + if (textNode && !isQuote) textNode.mt = 0 + + // ---- Tree --------------------------------------------------------------- + + const bubblePad = { + t: flushTop ? 0 : s(SP.padY), + r: s(SP.padX), + b: flushBottom ? 0 : s(SP.padY), + l: s(SP.padX) + } + const tailSize = avatar ? s(SP.tail) : 0 + + const bubbleBg = (ctx, n) => { + const one = background.colorOne + const two = background.colorTwo + const glassLw = s(SP.glass) + const rect = one === two + ? drawRoundRect(one, n.w, n.h, radii, tailSize, glassLw) + : drawGradientRoundRect(one, two, n.w, n.h, radii, tailSize, glassLw) + ctx.save() + // A soft neutral drop shadow lifts the sticker off any chat wallpaper. + ctx.shadowColor = 'rgba(0, 0, 0, 0.24)' + ctx.shadowBlur = s(6) + ctx.shadowOffsetY = s(2) + ctx.drawImage(rect, n.x - (rect._tailOffset || 0), n.y) + ctx.restore() + } + + let root + if (isSticker) { + // Sticker: no bubble; an optional dark overlay chip holds the reply. + const chip = replyNode + ? box({ + pad: bubblePad, + bg: (ctx, n) => ctx.drawImage(drawRoundRect('rgba(0, 0, 0, 0.5)', n.w, n.h, s(SP.radius), 0), n.x, n.y), + children: [replyNode] + }) + : null + root = box({ dir: 'col', gap: s(SP.gap), children: [chip, mediaNode] }) + } else { + root = box({ + dir: 'col', + gap: s(SP.gap), + pad: mediaOnly ? 0 : bubblePad, + minW: mediaOnly ? 0 : s(SP.minWidth), + bg: bubbleBg, + children: [headerNode, forwardNode, replyNode, mediaNode, attachmentNode, textNode] + }) + } + + // ---- Compose ------------------------------------------------------------ + + measure(root) + + const shadowPad = s(SP.shadowPad) + const shadowPadTop = s(SP.shadowPadTop) + const bubblePosX = s(SP.avatar) + s(SP.avatarGap) + const width = bubblePosX + root.w + shadowPad + const height = shadowPadTop + Math.max(root.h, avatar ? s(SP.avatar) + s(2) : 0) + shadowPad + + place(root, bubblePosX, shadowPadTop) + + const canvas = createCanvas(width, height) + const ctx = canvas.getContext('2d') + render(ctx, root) + + // Avatar at the bottom-left, over the bubble tail. + if (avatar) { + const avatarY = Math.max(0, height - shadowPad - s(SP.avatar) - s(2)) + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = 'high' + ctx.drawImage(avatar, 0, avatarY, s(SP.avatar), s(SP.avatar)) + } + + return canvas +} + +// Center-crops an image/canvas to a square (cover fit) for round/thumb media. +function coverSquare (img) { + const side = Math.min(img.width, img.height) + if (img.width === img.height) return img + const out = createCanvas(side, side) + const ctx = out.getContext('2d') + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = 'high' + ctx.drawImage(img, (img.width - side) / 2, (img.height - side) / 2, side, side, 0, 0, side, side) + return out +} + +// The modern-Telegram accent block: rounded backdrop tinted with the accent +// color, solid accent bar on the left, optional solid ❝ in the top-right +// corner. Used for the reply preview (accent = replied sender's color) and +// the partial-quote body (accent = quoted sender's color). +function accentBlock (s, accent, { icon = false, children }) { + const b = SP.block + return box({ + gap: s(b.gap), + pad: { t: s(b.padY), r: s(icon ? b.padRIcon : b.padR), b: s(b.padY), l: s(b.padL) }, + bg: (ctx, n) => { + const solid = drawRoundRect(accent, n.w, n.h, s(b.radius), 0) + ctx.save() + ctx.globalAlpha = b.tint + ctx.drawImage(solid, n.x, n.y) + ctx.restore() + ctx.drawImage(solid, 0, 0, s(b.bar), n.h, n.x, n.y, s(b.bar), n.h) + if (icon) ctx.drawImage(drawQuoteIcon(s(b.icon), accent, 1), n.x + n.w - s(b.icon) - s(b.iconInset), n.y + s(b.iconInset)) + }, + children + }) +} + +module.exports = { drawQuote, SP } diff --git a/utils/quote-generate/constants.js b/utils/quote-generate/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..54e564181e29b9c88a1ff17ba62f821612055704 --- /dev/null +++ b/utils/quote-generate/constants.js @@ -0,0 +1,78 @@ +const NAME_COLORS_LIGHT = [ + '#FC5C51', '#FA790F', '#895DD5', '#0FB297', '#0FC9D6', '#3CA5EC', '#D54FAF' +] + +const NAME_COLORS_DARK = [ + '#FF8E86', '#FFA357', '#B18FFF', '#4DD6BF', '#45E8D1', '#7AC9FF', '#FF7FD5' +] + +const AVATAR_COLORS = [ + ['#FF885E', '#FF516A'], ['#FFCD6A', '#FFA85C'], ['#E0A2F3', '#D669ED'], + ['#A0DE7E', '#54CB68'], ['#53EDD6', '#28C9B7'], ['#72D5FD', '#2A9EF1'], + ['#FFA8A8', '#FF719A'] +] + +const BREAK_REGEX = /
|\n|\r/ +const SPACE_REGEX = /[\f\n\r\t\v\u0020\u1680\u2000-\u200a\u2028\u2029\u205f\u3000]/ +const CJK_REGEX = /[\u1100-\u11ff\u2e80-\u2eff\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\u3100-\u312f\u3130-\u318f\u3190-\u319f\u31a0-\u31bf\u31c0-\u31ef\u31f0-\u31ff\u3200-\u32ff\u3300-\u33ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/ +const RTL_REGEX = /[\u0591-\u07FF\u200F\u202B\u202E\uFB1D-\uFDFD\uFE70-\uFEFC]/ +const NEUTRAL_REGEX = /[\u0001-\u0040\u005B-\u0060\u007B-\u00BF\u00D7\u00F7\u02B9-\u02FF\u2000-\u2BFF\u2010-\u2029\u202C\u202F-\u2BFF\u1F300-\u1F5FF\u1F600-\u1F64F]/ + +const ENTITY_TYPES_MONOSPACE = ['pre', 'code', 'pre_code'] +const ENTITY_TYPES_MENTION = ['mention', 'text_mention', 'hashtag', 'email', 'phone_number', 'bot_command', 'url', 'text_link'] + +// Consistent emoji scale factor for both measurement and drawing +const EMOJI_SCALE = 1.15 + +// Punctuation that sticks to the preceding text segment (never starts a new line) +const LEFT_STICKY_PUNCTUATION = new Set([ + '.', ',', '!', '?', ':', ';', ')', ']', '}', '%', '"', "'", + '\u2019', // right single quote ' + '\u201D', // right double quote " + '\u2026', // ellipsis … + '\u3001', // ideographic comma 、 + '\u3002', // ideographic period 。 + '\uFF0C', // fullwidth comma , + '\uFF0E', // fullwidth period . + '\uFF01', // fullwidth exclamation ! + '\uFF09', // fullwidth right paren ) + '\uFF3D' // fullwidth right bracket ] +]) + +// Kinsoku Shori: characters prohibited at line start (closing marks, prolonged sound) +const KINSOKU_START = new Set([ + '\u3001', '\u3002', '\uFF0C', '\uFF0E', + '\u3005', + '\u3009', '\u300B', '\u300D', '\u300F', '\u3011', + '\u301F', + '\u30FC', + '\u30FD', '\u30FE', + '\u309D', '\u309E', + '\uFF09', '\uFF3D', '\uFF5D', + '\uFF01', '\uFF1F' +]) + +// Kinsoku Shori: characters prohibited at line end (opening marks) +const KINSOKU_END = new Set([ + '\u3008', '\u300A', '\u300C', '\u300E', '\u3010', + '\u301D', + '\uFF08', '\uFF3B', '\uFF5B', + '\u2018', '\u201C' +]) + +module.exports = { + NAME_COLORS_LIGHT: NAME_COLORS_LIGHT, + NAME_COLORS_DARK: NAME_COLORS_DARK, + AVATAR_COLORS: AVATAR_COLORS, + BREAK_REGEX: BREAK_REGEX, + SPACE_REGEX: SPACE_REGEX, + CJK_REGEX: CJK_REGEX, + RTL_REGEX: RTL_REGEX, + NEUTRAL_REGEX: NEUTRAL_REGEX, + ENTITY_TYPES_MONOSPACE: ENTITY_TYPES_MONOSPACE, + ENTITY_TYPES_MENTION: ENTITY_TYPES_MENTION, + EMOJI_SCALE: EMOJI_SCALE, + LEFT_STICKY_PUNCTUATION: LEFT_STICKY_PUNCTUATION, + KINSOKU_START: KINSOKU_START, + KINSOKU_END: KINSOKU_END +} diff --git a/utils/quote-generate/index.js b/utils/quote-generate/index.js new file mode 100644 index 0000000000000000000000000000000000000000..c2efb3091619eef4f033703efbd39650c8a4b5ab --- /dev/null +++ b/utils/quote-generate/index.js @@ -0,0 +1,412 @@ +// utils/quote-generate/index.js + +const fs = require('fs') +const path = require('path') +const { registerFont, loadImage } = require('canvas') +const { Telegram } = require('telegraf') +const loadImageFromUrl = require('../image-load-url') +const emojiDb = require('../emoji-db') + +const { drawMultilineText } = require('./text-renderer') +const { drawAvatar } = require('./avatar') +const { downloadMediaImage } = require('./media') +const { drawQuote } = require('./composer') +const { drawLabel } = require('./canvas-utils') +const { loadIcons, drawVoiceRow, drawDocumentRow, drawAudioRow, formatDuration } = require('./attachments') +const { ColorContrast, lightOrDark, colorLuminance } = require('./color') +const { NAME_COLORS_LIGHT, NAME_COLORS_DARK } = require('./constants') + +async function loadFonts () { + const fontsDir = path.resolve(__dirname, '../../assets/fonts/') + + let files + try { + files = await fs.promises.readdir(fontsDir) + } catch (err) { + console.warn('Could not read fonts directory:', err.message) + return + } + + for (const file of files) { + if (file.startsWith('.')) continue + try { + // "NotoSans-BoldItalic.ttf" → family NotoSans, weight bold, style italic. + // Files without a recognized suffix register as plain family names. + let family = file.replace(/\.[^/.]+$/, '') + const desc = { } + const m = family.match(/^(.*?)-(Regular|Bold|Italic|BoldItalic)$/) + if (m) { + family = m[1] + if (m[2].includes('Bold')) desc.weight = 'bold' + if (m[2].includes('Italic')) desc.style = 'italic' + } + registerFont(path.join(fontsDir, file), { family, ...desc }) + } catch (error) { + console.warn(`${file} is not a font file`) + } + } + console.log('Fonts loaded') +} + +class QuoteGenerate { + constructor (botToken) { + // Self-hosted Bot API server (getFile + file downloads served cloud-style). + // Without the env the behavior is unchanged (Telegram cloud). + this.telegram = new Telegram(botToken, process.env.BOT_API_ROOT ? { apiRoot: process.env.BOT_API_ROOT } : undefined) + } + + async generate (backgroundColorOne, backgroundColorTwo, message, width, height, scale, emojiBrand) { + await loadIcons() // warm white icon sprites (no-op after first call) + scale = scale || 2 + if (!Number.isFinite(scale) || scale < 1) scale = 1 + if (scale > 20) scale = 20 + width = Math.max(1, (width || 512) * scale) + height = Math.max(1, (height || 512) * scale) + + const backStyle = lightOrDark(backgroundColorOne) + const nameColorArray = backStyle === 'light' ? NAME_COLORS_LIGHT : NAME_COLORS_DARK + + let nameIndex = 1 + if (message.from && message.from.id) nameIndex = Math.abs(message.from.id) % 7 + + let nameColor = nameColorArray[nameIndex] + + const colorContrast = new ColorContrast() + const contrast = colorContrast.getContrastRatio(colorLuminance(backgroundColorOne, 0.55), nameColor) + if (contrast > 90 || contrast < 30) { + nameColor = colorContrast.adjustContrast(colorLuminance(backgroundColorTwo, 0.55), nameColor) + } + + // Name is noticeably smaller than the message text (like Telegram), so + // the eye lands on the content first. + const nameSize = 18 * scale + + let nameCanvas + if (message.from && message.from.name !== false && (message.from.name || message.from.first_name || message.from.last_name)) { + let name = message.from.name || `${message.from.first_name || ''} ${message.from.last_name || ''}`.trim() + if (!name) name = 'User' + + const nameEntities = [{ + type: 'bold', + offset: 0, + length: name.length + }] + + if (message.from.emoji_status) { + name += ' \uD83E\uDD21' + nameEntities.push({ + type: 'custom_emoji', + offset: name.length - 2, + length: 2, + custom_emoji_id: message.from.emoji_status + }) + } + + try { + nameCanvas = await drawMultilineText( + name, nameEntities, nameSize, nameColor, + 0, nameSize, width, nameSize, emojiBrand, this.telegram + ) + // Gradient accent on the name (base → lightened). Skipped when any + // emoji image is in the canvas — source-in would tint it into a + // flat silhouette (emoji status or regular emoji in the name). + const nameHasEmoji = emojiDb.searchFromText({ input: name, fixCodePoints: true }).length > 0 + if (!message.from.emoji_status && !nameHasEmoji) { + nameCanvas = gradientTint(nameCanvas, nameColor, colorLuminance(nameColor, 0.25)) + } + } catch (error) { + console.error('Failed to render name:', error.message, error.stack) + // Retry without entities (drop emoji status etc) + try { + const plainName = name.replace(/\s*\uD83E\uDD21$/, '') // strip emoji placeholder + nameCanvas = await drawMultilineText( + plainName, [{ type: 'bold', offset: 0, length: plainName.length }], + nameSize, nameColor, 0, nameSize, width, nameSize, emojiBrand, this.telegram + ) + } catch (_) { /* name is optional — continue without it */ } + } + } + + const fontSize = 24 * scale + let textColor = backStyle === 'light' ? '#000' : '#fff' + + let textCanvas + let textBlocks = null + if (message.text) { + const text = typeof message.text === 'string' ? message.text : String(message.text) + try { + // Blockquote entities split the text into plain/quote runs, each + // rendered separately so the composer can give quotes the accent + // block treatment. + const parts = splitByBlockquotes(text, message.entities) + if (parts) { + textBlocks = [] + for (const part of parts) { + const canvas = await drawMultilineText( + part.text, part.entities, fontSize, textColor, + 0, fontSize, width, height - fontSize, emojiBrand, this.telegram + ) + textBlocks.push({ canvas, quote: part.quote }) + } + textCanvas = textBlocks[0] && textBlocks[0].canvas // width hints below + } else { + textCanvas = await drawMultilineText( + text, message.entities, fontSize, textColor, + 0, fontSize, width, height - fontSize, emojiBrand, this.telegram + ) + } + } catch (error) { + console.error('Failed to render message text:', error.message, error.stack) + // Retry without entities (plain text fallback) + try { + textBlocks = null + textCanvas = await drawMultilineText( + text, [], fontSize, textColor, + 0, fontSize, width, height - fontSize, emojiBrand, this.telegram + ) + } catch (retryError) { + console.error('Failed to render plain text fallback:', retryError.message) + return null + } + } + } + + let avatarCanvas + if (message.avatar && message.from) { + try { + avatarCanvas = await drawAvatar(message.from, this.telegram) + } catch (error) { + console.warn('Error drawing avatar:', error.message) + avatarCanvas = null + } + } + + let replyData = null + if (message.replyMessage && message.replyMessage.name && message.replyMessage.text) { + try { + const chatId = message.replyMessage.chatId || 0 + const replyNameIndex = Math.abs(chatId) % 7 + const replyNameColor = nameColorArray[replyNameIndex] + + const replyName = typeof message.replyMessage.name === 'string' ? message.replyMessage.name : String(message.replyMessage.name) + const replyText = typeof message.replyMessage.text === 'string' ? message.replyMessage.text : String(message.replyMessage.text) + + const replyNameFontSize = 14 * scale + const replyNameCanvas = await drawMultilineText( + replyName, 'bold', replyNameFontSize, replyNameColor, + 0, replyNameFontSize, width * 0.9, replyNameFontSize, emojiBrand, this.telegram + ) + + const replyTextFontSize = 15 * scale + const replyTextCanvas = await drawMultilineText( + replyText, message.replyMessage.entities || [], + replyTextFontSize, textColor, + 0, replyTextFontSize, width * 0.9, replyTextFontSize, emojiBrand, this.telegram + ) + + if (replyNameCanvas && replyTextCanvas) { + replyData = { name: replyNameCanvas, nameColor: replyNameColor, text: replyTextCanvas } + + // Thumbnail of the replied media (photo/video/sticker…), like the + // modern Telegram reply preview. Best-effort — silently skipped. + const replyMedia = message.replyMessage.media + if (replyMedia && replyMedia.fileId) { + try { + const fileUrl = await this.telegram.getFileLink(replyMedia.fileId) + const buffer = await loadImageFromUrl(fileUrl) + replyData.thumb = await loadImage(buffer) + } catch (error) { + console.warn('Failed to load reply thumb:', error.message) + } + } + } + } catch (error) { + console.error('Failed to render reply:', error.message, error.stack) + replyData = null + } + } + + let mediaCanvas = null + let mediaType = null + let maxMediaSize = null + + if (message.media) { + let media, type + let crop = !!message.mediaCrop + + if (message.media.url) { + type = 'url' + media = message.media.url + } else { + type = 'id' + if (message.media.length > 1) { + // BUG FIX: was message.media.pop() which mutated input + media = crop ? message.media[1] : message.media[message.media.length - 1] + } else { + media = message.media[0] + } + } + + // Media caps at ⅔ of the target width (like Telegram photos). `width` + // already carries the scale factor; the old `width / 3 * scale` only + // matched this at scale 2 and ballooned at higher scales. + maxMediaSize = width * 2 / 3 + if (message.text && textCanvas && maxMediaSize < textCanvas.width) maxMediaSize = textCanvas.width + + if (media && media.is_animated) { + if (media.thumb) { + media = media.thumb + maxMediaSize = maxMediaSize / 2 + } else { + media = null + } + } + + try { + mediaCanvas = await downloadMediaImage(media, maxMediaSize, type, crop, this.telegram) + if (mediaCanvas) { + mediaType = message.mediaType + } else { + console.warn('Failed to download media image, skipping') + } + } catch (error) { + console.warn('Error downloading media image:', error.message) + } + } + + // Row-style attachments (rendered inside the bubble, like Telegram). + let attachment = null + const attachMaxW = width * 2 / 3 + if (message.voice && Array.isArray(message.voice.waveform)) { + attachment = drawVoiceRow( + message.voice.waveform, message.voice.duration, + nameColor, textColor, scale, attachMaxW + ) + } else if (message.document) { + attachment = drawDocumentRow(message.document, nameColor, textColor, scale, attachMaxW) + } else if (message.audio) { + let audioThumb = null + const thumbId = message.audio.thumb && (message.audio.thumb.file_id || message.audio.thumb) + if (thumbId) { + try { + const fileUrl = typeof thumbId === 'string' && thumbId.startsWith('http') + ? thumbId + : await this.telegram.getFileLink(thumbId) + audioThumb = await loadImage(await loadImageFromUrl(fileUrl)) + } catch (error) { + console.warn('Failed to load audio thumb:', error.message) + } + } + attachment = drawAudioRow(message.audio, nameColor, textColor, scale, attachMaxW, audioThumb) + } + + // Video/GIF media badges, painted over the media by the composer. + let mediaBadge = null + if (mediaCanvas) { + if (message.mediaType === 'video') { + mediaBadge = { play: true, label: message.mediaDuration != null ? formatDuration(message.mediaDuration) : null } + } else if (message.mediaType === 'gif' || message.mediaType === 'animation') { + mediaBadge = { label: 'GIF' } + } + } + + // Forward label + const isForward = !!message.forward + const forwardLabel = isForward ? (message.forward.label || 'Forwarded message') : null + + // Sender tag (user role in group) + const senderTag = message.senderTag || null + + // "via @bot" chip (inline-bot messages) + let viaBotCanvas = null + if (message.viaBot) { + const viaText = `via @${String(message.viaBot).replace(/^@/, '')}` + viaBotCanvas = drawLabel(viaText, 13 * scale, nameColor, { alpha: 0.8 }) + } + + // Nothing to render — skip this message + if (!textCanvas && !nameCanvas && !mediaCanvas && !replyData && !attachment) { + return null + } + + return drawQuote({ + scale, + background: { colorOne: backgroundColorOne, colorTwo: backgroundColorTwo, textColor }, + avatar: avatarCanvas, + reply: replyData, + name: nameCanvas, + text: textCanvas, + textBlocks, + media: mediaCanvas ? { canvas: mediaCanvas, type: mediaType, maxSize: maxMediaSize, badge: mediaBadge } : null, + attachment: attachment ? { canvas: attachment } : null, + isForward, + forwardLabel, + nameColor, + senderTag, + viaBot: viaBotCanvas, + groupPos: message.groupPos || 'single', + isQuote: !!message.isQuote + }) + } +} + +/** + * Recolors every opaque pixel of a text canvas with a horizontal gradient + * (source-in compositing keeps the glyph alpha, replaces the color). + */ +function gradientTint (canvas, colorFrom, colorTo) { + if (!canvas || canvas.width < 2) return canvas + const ctx = canvas.getContext('2d') + const grad = ctx.createLinearGradient(0, 0, canvas.width, 0) + grad.addColorStop(0, colorFrom) + grad.addColorStop(1, colorTo) + ctx.save() + ctx.globalCompositeOperation = 'source-in' + ctx.fillStyle = grad + ctx.fillRect(0, 0, canvas.width, canvas.height) + ctx.restore() + return canvas +} + +/** + * Splits text into plain/quote runs around blockquote entities. Returns + * null when there are none (the common case — render as a single canvas). + * Entity offsets are UTF-16 code units, which is exactly how JS slices. + */ +function splitByBlockquotes (text, entities) { + if (!Array.isArray(entities)) return null + const quotes = entities + .filter((e) => e.type === 'blockquote' || e.type === 'expandable_blockquote') + .sort((a, b) => a.offset - b.offset) + if (quotes.length === 0) return null + + const sliceEntities = (start, end) => entities + .filter((e) => e.type !== 'blockquote' && e.type !== 'expandable_blockquote') + .filter((e) => e.offset < end && e.offset + e.length > start) + .map((e) => { + const from = Math.max(e.offset, start) + const to = Math.min(e.offset + e.length, end) + return { ...e, offset: from - start, length: to - from } + }) + + const parts = [] + let pos = 0 + for (const q of quotes) { + if (q.offset < pos) continue // overlapping quotes — keep the first + if (q.offset > pos) { + const plain = text.slice(pos, q.offset).replace(/\n+$/, '') + if (plain) parts.push({ text: plain, entities: sliceEntities(pos, q.offset), quote: false }) + } + parts.push({ text: text.slice(q.offset, q.offset + q.length), entities: sliceEntities(q.offset, q.offset + q.length), quote: true }) + pos = q.offset + q.length + } + if (pos < text.length) { + const tail = text.slice(pos).replace(/^\n+/, '') + if (tail) parts.push({ text: tail, entities: sliceEntities(pos, text.length), quote: false }) + } + return parts.length > 0 ? parts : null +} + +module.exports = QuoteGenerate +module.exports.loadFonts = loadFonts +module.exports.gradientTint = gradientTint diff --git a/utils/quote-generate/layout-box.js b/utils/quote-generate/layout-box.js new file mode 100644 index 0000000000000000000000000000000000000000..89ff643f1a681ef03676f5620b03f71ab9bd5674 --- /dev/null +++ b/utils/quote-generate/layout-box.js @@ -0,0 +1,199 @@ +// utils/quote-generate/layout-box.js +// +// Tiny DOM/CSS-style box-model layout for the quote composer. +// +// Parents derive their size from children + padding + gap, children are +// positioned by flow (column/row) — nothing is placed with hand-tuned +// absolute offsets. +// +// Text canvases from drawMultilineText are metric-exact: their height is +// (lines-1)*lineHeight + ascent + descent — a constant of the font, never +// of the glyphs drawn. So a leaf takes every canvas at face value; the only +// special case is the 1×1 stub returned for empty text, which is dropped so +// it doesn't occupy a slot in the flow. + +const { createCanvas } = require('canvas') + +const ZERO_PAD = { t: 0, r: 0, b: 0, l: 0 } + +function normPad (pad) { + if (!pad) return ZERO_PAD + if (typeof pad === 'number') return { t: pad, r: pad, b: pad, l: pad } + return { t: pad.t || 0, r: pad.r || 0, b: pad.b || 0, l: pad.l || 0 } +} + +/** + * A canvas leaf, taken at its metric size. Empty-text stubs (1×1) are not + * elements and resolve to null. `w`/`h` force a paint size (media scaling), + * `paint` overrides drawing entirely. + */ +function leaf (canvas, opts = {}) { + if (!canvas) return null + if (canvas.width <= 1 && canvas.height <= 1) return null + let w = opts.w !== undefined ? opts.w : canvas.width + if (opts.maxW && w > opts.maxW) w = opts.maxW // overflow renders as a fade + return { + kind: 'leaf', + canvas, + srcY: 0, + srcH: canvas.height, + w, + h: opts.h !== undefined ? opts.h : canvas.height, + bleed: !!opts.bleed, + paint: opts.paint || null + } +} + +/** + * A container. dir: 'col' | 'row'; gap between children; pad inside; + * align: 'start' | 'center' (cross axis); justify: 'start' | 'between' + * (row main axis); bg/fg: painters called with (ctx, node) before/after + * children; minW: minimum outer width. + */ +function box (opts = {}) { + return { + kind: 'box', + dir: opts.dir || 'col', + gap: opts.gap || 0, + pad: normPad(opts.pad), + align: opts.align || 'start', + justify: opts.justify || 'start', + stretch: !!opts.stretch, + bg: opts.bg || null, + fg: opts.fg || null, + minW: opts.minW || 0, + maxW: opts.maxW || 0, + children: (opts.children || []).filter(Boolean) + } +} + +// Vertical flow gap before a column child: the box gap by default, or the +// child's own `mt` (margin-top) when set — text nodes carry metric slack +// above their cap line, so they ask for mt 0 and supply the air themselves. +function gapBefore (box, child, index) { + if (index === 0) return 0 + return child.mt !== undefined ? child.mt : box.gap +} + +/** Bottom-up natural sizing: parent = children + gaps + padding. */ +function measure (n) { + if (n.kind === 'leaf') return n + let w = 0 + let h = 0 + for (const c of n.children) measure(c) + if (n.dir === 'col') { + // A `bleed` child ignores the horizontal padding (full-width media). + let outerW = 0 + for (let i = 0; i < n.children.length; i++) { + const c = n.children[i] + const cw = c.bleed ? c.w : c.w + n.pad.l + n.pad.r + if (cw > outerW) outerW = cw + h += gapBefore(n, c, i) + c.h + } + n.w = outerW + } else { + for (const c of n.children) { + if (c.h > h) h = c.h + w += c.w + } + w += n.gap * Math.max(0, n.children.length - 1) + n.w = w + n.pad.l + n.pad.r + } + // Whole-pixel sizes: fractional widths (scaled media) would otherwise be + // truncated by createCanvas in bg painters, clipping the backdrop by 1px. + n.w = Math.ceil(n.w) + n.h = Math.ceil(h + n.pad.t + n.pad.b) + if (n.w < n.minW) n.w = n.minW + // A capped box keeps its children's natural sizes; overflowing leaves + // are resolved at place/render time (justify-between squeeze → fade). + if (n.maxW && n.w > n.maxW) n.w = n.maxW + return n +} + +/** + * Top-down placement. Children flagged `stretch` fill the inner width. + * Coordinates are snapped to whole pixels — drawing a text canvas at a + * fractional position (e.g. after centering) makes the glyphs blurry. + */ +function place (n, x, y, stretchW) { + if (stretchW !== undefined && n.stretch) n.w = stretchW + n.x = Math.round(x) + n.y = Math.round(y) + x = n.x + y = n.y + if (n.kind === 'leaf') return + const innerW = n.w - n.pad.l - n.pad.r + if (n.dir === 'col') { + let cy = y + n.pad.t + for (let i = 0; i < n.children.length; i++) { + const c = n.children[i] + let cx = x + n.pad.l + if (c.bleed) cx = x + Math.max(0, (n.w - c.w) / 2) // full-width child, centered + else if (n.align === 'center' && c.w < innerW) cx += (innerW - c.w) / 2 + cy += gapBefore(n, c, i) + place(c, cx, cy, innerW) + cy += c.h + } + } else { + const crossY = (c) => n.align === 'center' + ? y + n.pad.t + (n.h - n.pad.t - n.pad.b - c.h) / 2 + : y + n.pad.t + if (n.justify === 'between' && n.children.length === 2) { + const [a, b] = n.children + // Never let the leading child run into the trailing one (gap = min gap). + const availA = innerW - b.w - n.gap + if (a.w > availA) a.w = Math.max(0, availA) + place(a, x + n.pad.l, crossY(a)) + place(b, x + n.w - n.pad.r - b.w, crossY(b)) + return + } + let cx = x + n.pad.l + for (const c of n.children) { + place(c, cx, crossY(c)) + cx += c.w + n.gap + } + } +} + +/** Paint: bg → children (clipped to their box) → fg. */ +function render (ctx, n) { + if (n.bg) n.bg(ctx, n) + if (n.kind === 'leaf') { + if (n.paint) { + n.paint(ctx, n) + } else if (n.canvas.width > n.w + 1) { + // Overflowing leaf (e.g. a long name sharing a row with a tag): + // fade the trailing edge out instead of a hard mid-glyph cut. + ctx.drawImage(fadeOverflow(n), n.x, n.y) + } else { + ctx.drawImage(n.canvas, 0, n.srcY, n.canvas.width, n.srcH, n.x, n.y, n.canvas.width, n.srcH) + } + } else { + for (const c of n.children) render(ctx, c) + } + if (n.fg) n.fg(ctx, n) +} + +/** + * Crops a leaf's canvas to its assigned width and dissolves the trailing + * ~one-glyph stretch to transparent, so truncation reads as a graceful + * fade rather than a sliced character. + */ +function fadeOverflow (n) { + const w = Math.max(1, Math.round(n.w)) + const out = createCanvas(w, n.srcH) + const ctx = out.getContext('2d') + ctx.drawImage(n.canvas, 0, n.srcY, w, n.srcH, 0, 0, w, n.srcH) + + // Fade width ≈ the line height (about one character), capped to the box. + const fadeW = Math.min(w, Math.round(n.srcH * 0.9)) + const grad = ctx.createLinearGradient(w - fadeW, 0, w, 0) + grad.addColorStop(0, 'rgba(0, 0, 0, 0)') + grad.addColorStop(1, 'rgba(0, 0, 0, 1)') + ctx.globalCompositeOperation = 'destination-out' + ctx.fillStyle = grad + ctx.fillRect(w - fadeW, 0, fadeW, n.srcH) + return out +} + +module.exports = { leaf, box, measure, place, render } diff --git a/utils/quote-generate/media.js b/utils/quote-generate/media.js new file mode 100644 index 0000000000000000000000000000000000000000..66ab0d1869efe93bee68be58861e3c68f92383d8 --- /dev/null +++ b/utils/quote-generate/media.js @@ -0,0 +1,166 @@ +// utils/quote-generate/media.js + +const { loadImage } = require('canvas') +const sharp = require('sharp') +const { Jimp, JimpMime } = require('jimp') +const smartcrop = require('smartcrop-sharp') +const loadImageFromUrl = require('../image-load-url') +const { execFile } = require('child_process') +const crypto = require('crypto') +const fs = require('fs') +const os = require('os') +const path = require('path') + +// Telegram animations (GIFs) are mp4 files; thumbnail-less ones reach us as +// raw video buffers that neither canvas nor sharp can decode. Detect a video +// container by magic bytes and extract the first frame via ffmpeg +// (best-effort: no ffmpeg installed → behave like before, media is skipped). +function isVideoBuffer (buffer) { + if (!buffer || buffer.length < 12) return false + // mp4/mov/m4v: "ftyp" box at offset 4 + if (buffer.slice(4, 8).toString('latin1') === 'ftyp') return true + // webm/mkv: EBML header + if (buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) return true + return false +} + +function extractVideoFrame (buffer) { + return new Promise((resolve) => { + // ffmpeg needs a seekable input for mp4 (moov atom may sit at the end), + // so go through a temp file; the PNG frame comes back on stdout. + const tmp = path.join(os.tmpdir(), `quote-media-${crypto.randomBytes(8).toString('hex')}`) + fs.writeFile(tmp, buffer, (writeErr) => { + if (writeErr) return resolve(null) + execFile('ffmpeg', [ + '-y', '-i', tmp, + '-frames:v', '1', '-f', 'image2pipe', '-c:v', 'png', 'pipe:1' + ], { encoding: 'buffer', maxBuffer: 64 * 1024 * 1024, timeout: 15000 }, (err, stdout) => { + fs.unlink(tmp, () => {}) + if (err || !stdout || stdout.length === 0) { + if (err) console.warn('ffmpeg frame extraction failed:', err.message) + return resolve(null) + } + resolve(stdout) + }) + }) + }) +} + +async function downloadMediaImage (media, mediaSize, type, crop, telegram) { + type = type || 'id' + crop = crop !== undefined ? crop : true + + try { + let mediaUrl + if (type === 'id') mediaUrl = await telegram.getFileLink(media).catch(console.error) + else mediaUrl = media + + if (!mediaUrl) { + console.warn('Failed to get media URL, skipping media') + return null + } + + let load = await loadImageFromUrl(mediaUrl).catch((error) => { + console.warn('Failed to load image from URL:', error.message) + return null + }) + + if (!load) { + console.warn('Failed to load media, skipping') + return null + } + + // Raw video (thumbnail-less GIF/animation) → first frame, then the + // normal image pipeline below applies unchanged. + if (isVideoBuffer(load)) { + const frame = await extractVideoFrame(load) + if (!frame) { + console.warn('Media is a video and no frame could be extracted, skipping') + return null + } + load = frame + } + + if (crop || (mediaUrl && mediaUrl.match(/.webp/))) { + try { + const imageSharp = sharp(load) + const imageMetadata = await imageSharp.metadata() + const sharpPng = await imageSharp.png({ lossless: true, force: true }).toBuffer() + + if (!imageMetadata || !imageMetadata.width || !imageMetadata.height || !sharpPng) { + try { + return await loadImage(load) + } catch (fallbackError) { + console.warn('Failed to load original image as fallback:', fallbackError.message) + return null + } + } + + let croppedImage + + if (imageMetadata.format === 'webp') { + try { + const jimpImage = await Jimp.read(sharpPng) + croppedImage = await jimpImage.autocrop().getBuffer(JimpMime.png) + } catch (jimpError) { + console.warn('Failed to process webp with Jimp, using original:', jimpError.message) + croppedImage = sharpPng + } + } else { + try { + const smartcropResult = await smartcrop.crop(sharpPng, { width: mediaSize, height: imageMetadata.height }) + const cropArea = smartcropResult.topCrop + // Create fresh sharp instance for extract (pipeline not reusable after toBuffer) + croppedImage = await sharp(load).extract({ + width: cropArea.width, + height: cropArea.height, + left: cropArea.x, + top: cropArea.y + }).png({ lossless: true, force: true }).toBuffer() + } catch (cropError) { + console.warn('Failed to crop image, using original:', cropError.message) + croppedImage = sharpPng + } + } + + try { + return await loadImage(croppedImage) + } catch (loadError) { + console.warn('Failed to load processed image, trying original:', loadError.message) + try { + return await loadImage(load) + } catch (originalError) { + console.warn('Failed to load original image as final fallback:', originalError.message) + return null + } + } + } catch (sharpError) { + console.warn('Failed to process image with Sharp, trying original:', sharpError.message) + try { + return await loadImage(load) + } catch (originalError) { + console.warn('Failed to load original image:', originalError.message) + return null + } + } + } else { + try { + return await loadImage(load) + } catch (loadError) { + // canvas can't decode this format natively — convert via sharp to PNG + try { + const pngBuffer = await sharp(load).png({ force: true }).toBuffer() + return await loadImage(pngBuffer) + } catch (convertError) { + console.warn('Failed to load image:', loadError.message) + return null + } + } + } + } catch (error) { + console.error('Critical error in downloadMediaImage:', error.message) + return null + } +} + +module.exports = { downloadMediaImage } diff --git a/utils/quote-generate/text-layout.js b/utils/quote-generate/text-layout.js new file mode 100644 index 0000000000000000000000000000000000000000..29a2af9921f13ea17100e30bb8db1f98ad17c075 --- /dev/null +++ b/utils/quote-generate/text-layout.js @@ -0,0 +1,367 @@ +// utils/quote-generate/text-layout.js +// Layout phase: line breaking, positioning, shrink-wrap. +// Pure arithmetic — no canvas calls, no async. Can be called many times cheaply. + +const { RTL_REGEX, NEUTRAL_REGEX } = require('./constants') + +/** + * Determine text direction for a line starting at segmentIndex. + * Scans ahead until a strong directional character is found. + */ +function getLineDirection (segments, startIndex) { + for (let i = startIndex; i < segments.length; i++) { + const seg = segments[i] + if (seg.kind === 'space' || seg.kind === 'break') continue + if (seg.text.match(RTL_REGEX)) return 'rtl' + if (!seg.text.match(NEUTRAL_REGEX)) return 'ltr' + } + return 'ltr' +} + +/** + * Layout text segments into lines within maxWidth/maxHeight constraints. + * + * Ported algorithms from pretext: + * - Trailing space hanging (spaces at line end don't count for width) + * - Pending-break tracking (rewind to last valid break point on overflow) + * - Overflow-wrap at grapheme boundaries + */ +function layoutText (prepared, maxWidth, maxHeight) { + const { segments, fontSize, lineHeight, emojiSize } = prepared + // Font em-box metrics: line geometry must be a constant of the font, not + // of the glyphs drawn (no ink-dependent heights). Fallback to the legacy + // fontSize bounds when a caller passes a hand-built `prepared`. + const ascent = Number.isFinite(prepared.ascent) ? prepared.ascent : fontSize + const descent = Number.isFinite(prepared.descent) ? prepared.descent : fontSize + if (segments.length === 0) { + return { lines: [], width: 0, height: 0, lineCount: 0, truncated: false } + } + + // Clamp max dimensions + if (maxWidth > 10000) maxWidth = 10000 + if (maxHeight > 10000) maxHeight = 10000 + + const lines = [] + let currentLine = { segments: [], width: 0, direction: 'ltr' } + let lineY = ascent // First baseline sits at the font ascent + let maxLineWidth = 0 + let truncated = false + + // Pending break tracking (from pretext) + // lineWidth excludes trailing space — matches hanging-space semantics + let pendingBreak = null // { segIndex, lineWidth, lineSegCount } + + currentLine.direction = getLineDirection(segments, 0) + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] + + // Hard break + if (seg.kind === 'break') { + // Only truncate if there is actual content after this break + const hasMoreContent = i + 1 < segments.length + if (hasMoreContent && maxHeight > 0 && lineY + lineHeight > maxHeight) { + truncated = true + applyTruncation(currentLine, prepared, maxWidth) + break + } + + // Finalize current line + if (currentLine.width > maxLineWidth) maxLineWidth = currentLine.width + currentLine.y = lineY + lines.push(currentLine) + lineY += lineHeight + pendingBreak = null + + // Start new line + currentLine = { segments: [], width: 0, direction: getLineDirection(segments, i + 1) } + continue + } + + const segWidth = seg.kind === 'emoji' ? emojiSize : seg.width + + // Trailing space hanging (from pretext): spaces past the edge don't trigger wrap + if (seg.kind === 'space' && currentLine.width + segWidth > maxWidth) { + // Hang the space — add to line but don't count in width + currentLine.segments.push({ index: i, width: segWidth }) + continue + } + + // Handle overwide segment on an empty line — loop-split across lines + if (currentLine.segments.length === 0 && segWidth > maxWidth && seg.kind === 'text' && prepared.computeGraphemeWidths) { + var gData = prepared.computeGraphemeWidths(seg) + if (gData && gData.widths.length > 1) { + var cursor = 0 + while (cursor < gData.texts.length) { + var fitCount = 0 + var fitWidth = 0 + for (var g = cursor; g < gData.widths.length; g++) { + if (fitWidth + gData.widths[g] > maxWidth && fitCount > 0) break + fitWidth += gData.widths[g] + fitCount++ + } + if (fitCount === 0) { fitCount = 1; fitWidth = gData.widths[cursor] } + + var isLast = cursor + fitCount >= gData.texts.length + currentLine.segments.push({ + index: i, + width: fitWidth, + graphemeStart: cursor > 0 ? cursor : undefined, + graphemeEnd: isLast ? undefined : cursor + fitCount + }) + currentLine.width += fitWidth + + if (!isLast) { + if (currentLine.width > maxLineWidth) maxLineWidth = currentLine.width + currentLine.y = lineY + lines.push(currentLine) + lineY += lineHeight + currentLine = { segments: [], width: 0, direction: currentLine.direction } + } + cursor += fitCount + } + continue + } + } + + // Check if segment fits + if (currentLine.width + segWidth > maxWidth && currentLine.segments.length > 0) { + // Overflow — need to break + + // Check if next line would exceed maxHeight + if (maxHeight > 0 && lineY + lineHeight > maxHeight) { + truncated = true + applyTruncation(currentLine, prepared, maxWidth) + break + } + + if (pendingBreak) { + // Rewind to last break opportunity (from pretext) + const { segIndex, lineWidth, lineSegCount } = pendingBreak + + // Trim current line back to pending break point + currentLine.segments.length = lineSegCount + currentLine.width = lineWidth + + // Finalize line + if (currentLine.width > maxLineWidth) maxLineWidth = currentLine.width + currentLine.y = lineY + lines.push(currentLine) + lineY += lineHeight + pendingBreak = null + + // Start new line, replay from segment after the break + const newDirection = getLineDirection(segments, segIndex + 1) + currentLine = { segments: [], width: 0, direction: newDirection } + i = segIndex // loop will i++ to segIndex + 1 + continue + } + + // No pending break — try overflow-wrap at grapheme boundaries + if (prepared.computeGraphemeWidths) { + const remainingWidth = currentLine.width === 0 ? maxWidth : maxWidth - currentLine.width + const gData = prepared.computeGraphemeWidths(seg) + if (gData && gData.widths.length > 1) { + let fitCount = 0 + let fitWidth = 0 + + for (let g = 0; g < gData.widths.length; g++) { + if (fitWidth + gData.widths[g] > remainingWidth && fitCount > 0) break + fitWidth += gData.widths[g] + fitCount++ + } + + if (fitCount > 0 && fitCount < gData.texts.length) { + // Split segment: put fitCount graphemes on current line + currentLine.segments.push({ index: i, width: fitWidth, graphemeEnd: fitCount }) + currentLine.width += fitWidth + + // Finalize current line + if (currentLine.width > maxLineWidth) maxLineWidth = currentLine.width + currentLine.y = lineY + lines.push(currentLine) + lineY += lineHeight + + // Loop-split remainder across as many lines as needed + var remCursor = fitCount + var remDirection = getLineDirection(segments, i) + currentLine = { segments: [], width: 0, direction: remDirection } + + while (remCursor < gData.texts.length) { + var rFit = 0 + var rWidth = 0 + for (var rg = remCursor; rg < gData.widths.length; rg++) { + if (rWidth + gData.widths[rg] > maxWidth && rFit > 0) break + rWidth += gData.widths[rg] + rFit++ + } + if (rFit === 0) { rFit = 1; rWidth = gData.widths[remCursor] } + + var remIsLast = remCursor + rFit >= gData.texts.length + currentLine.segments.push({ + index: i, + width: rWidth, + graphemeStart: remCursor > 0 ? remCursor : undefined, + graphemeEnd: remIsLast ? undefined : remCursor + rFit + }) + currentLine.width += rWidth + + if (!remIsLast) { + if (currentLine.width > maxLineWidth) maxLineWidth = currentLine.width + currentLine.y = lineY + lines.push(currentLine) + lineY += lineHeight + currentLine = { segments: [], width: 0, direction: remDirection } + } + remCursor += rFit + } + continue + } + } + } + + // Finalize current line (no split happened) + if (currentLine.width > maxLineWidth) maxLineWidth = currentLine.width + currentLine.y = lineY + lines.push(currentLine) + lineY += lineHeight + + // Start new line with current segment + const newDirection = getLineDirection(segments, i) + currentLine = { segments: [], width: 0, direction: newDirection } + + if (seg.kind !== 'space') { + currentLine.segments.push({ index: i, width: segWidth }) + currentLine.width += segWidth + } + + pendingBreak = null + continue + } + + // Track break opportunities (spaces) + if (seg.kind === 'space') { + pendingBreak = { + segIndex: i, + lineWidth: currentLine.width, // Exclude space width (hanging-space semantics) + lineSegCount: currentLine.segments.length + } + } + + // Add segment to current line + currentLine.segments.push({ index: i, width: segWidth }) + currentLine.width += segWidth + } + + // Finalize last line + if (currentLine.segments.length > 0) { + if (currentLine.width > maxLineWidth) maxLineWidth = currentLine.width + currentLine.y = lineY + lines.push(currentLine) + } + + // Compute x positions for each segment within its line + for (const line of lines) { + let x = 0 + // Also compute actual content width (excluding trailing spaces) + let contentWidth = 0 + for (const seg of line.segments) { + seg.x = x + x += seg.width + const srcSeg = segments[seg.index] + if (srcSeg && srcSeg.kind !== 'space') { + contentWidth = x + } + } + line.contentWidth = contentWidth || line.width + } + + const totalHeight = lines.length > 0 ? lines[lines.length - 1].y + descent : 0 + + return { + lines, + width: maxLineWidth, + height: totalHeight, + lineCount: lines.length, + truncated + } +} + +/** + * Trim segments from line to fit within maxWidth and mark as truncated. + */ +function applyTruncation (line, prepared, maxWidth) { + // Empty line (e.g. truncation hit at a \n\n boundary) — nothing to trim + if (line.segments.length === 0) { + line.width = 0 + line.truncated = true + return + } + + const ctx = require('./text-prepare').getMeasureCtx() + const ellipsis = '\u2026' + // Set font to last segment's font so ellipsis is measured at the correct size + const lastLayoutSeg = line.segments[line.segments.length - 1] + if (lastLayoutSeg) { + const lastSeg = prepared.segments[lastLayoutSeg.index] + if (lastSeg) ctx.font = lastSeg.font + } + const ellipsisWidth = ctx.measureText(ellipsis).width + const targetWidth = maxWidth - ellipsisWidth + + // Trim segments until total width fits within targetWidth + let totalWidth = 0 + let trimIndex = line.segments.length + for (let s = 0; s < line.segments.length; s++) { + if (totalWidth + line.segments[s].width > targetWidth) { + trimIndex = s + break + } + totalWidth += line.segments[s].width + } + + line.segments.length = Math.max(1, trimIndex) + if (totalWidth === 0) { + totalWidth = line.segments[0].width + } + line.width = totalWidth + line.truncated = true +} + +/** + * Find the tightest container width without increasing line count. + * Binary search over maxWidth (from pretext's walkLineRanges concept). + */ +function shrinkWrap (prepared, maxWidth, maxHeight) { + const initial = layoutText(prepared, maxWidth, maxHeight) + + // Single line, empty, or already truncated — don't shrink further + if (initial.lineCount <= 1 || initial.truncated) return initial + + // Find the widest segment (absolute minimum width) + let maxSegWidth = 0 + for (const seg of prepared.segments) { + if (seg.width > maxSegWidth) maxSegWidth = seg.width + } + + // Binary search for tightest width + let lo = Math.max(maxSegWidth, initial.width / initial.lineCount * 0.7) + let hi = initial.width + const targetLineCount = initial.lineCount + + // ~10 iterations for 2px precision on typical widths + while (hi - lo > 2) { + const mid = (lo + hi) / 2 + const trial = layoutText(prepared, mid, maxHeight) + + if (trial.lineCount <= targetLineCount && !trial.truncated) { + hi = mid // fits — try narrower + } else { + lo = mid // too narrow — widen + } + } + + return layoutText(prepared, Math.ceil(hi), maxHeight) +} + +module.exports = { layoutText, shrinkWrap, getLineDirection } diff --git a/utils/quote-generate/text-prepare.js b/utils/quote-generate/text-prepare.js new file mode 100644 index 0000000000000000000000000000000000000000..86378f2d377d66d7163bf6f2d46d29100ad691c1 --- /dev/null +++ b/utils/quote-generate/text-prepare.js @@ -0,0 +1,537 @@ +// utils/quote-generate/text-prepare.js +// Prepare phase: tokenize text, load emoji, measure segments. +// Called once per text block. Returns PreparedText for layout/render phases. +// +// NOTE: text.split('') and entity offsets both use UTF-16 code units, +// consistent with Telegram API and JS string indexing. Do not refactor +// to Array.from(text) (codepoint split) without updating all offset logic. + +const { createCanvas, loadImage } = require('canvas') +const sharp = require('sharp') +const loadImageFromUrl = require('../image-load-url') +const emojiDb = require('../emoji-db') +const { loadBrand } = require('../emoji-image') +const { + BREAK_REGEX, SPACE_REGEX, CJK_REGEX, + ENTITY_TYPES_MONOSPACE, ENTITY_TYPES_MENTION, + EMOJI_SCALE, LEFT_STICKY_PUNCTUATION, KINSOKU_START, KINSOKU_END +} = require('./constants') + +// Shared segmenters (singletons, like pretext) +const wordSegmenter = new Intl.Segmenter('en', { granularity: 'word' }) +const graphemeSegmenter = new Intl.Segmenter('en', { granularity: 'grapheme' }) + +// Shared measurement canvas (singleton, like pretext) +let measureCanvas = null +let measureCtx = null + +function getMeasureCtx () { + if (!measureCtx) { + measureCanvas = createCanvas(1, 1) + measureCtx = measureCanvas.getContext('2d') + } + return measureCtx +} + +// Module-level emoji image cache — persists across calls +const emojiImageCache = new Map() + +// Vertical metrics of the base font at a given size — a constant of the +// font, so glyph shapes never affect line geometry (no "breathing" bubbles). +// The box is the union of three deterministic extents: +// • the font em box (emHeightAscent/Descent), +// • the ink of fixed probe strings with extreme diacritics/descenders +// (some fonts draw stacked accents outside their em box), +// • the emoji draw box: images sit at [baseline − 0.85·fs, baseline + 0.30·fs] +// (see text-render.js: y = baseline − fs + 0.15·fs, size = 1.15·fs). +const PROBE_TALL = 'ẤÅЇĎ' +const PROBE_DEEP = 'jqyḑộ' +const fontMetricsCache = new Map() + +function fontMetrics (fontSize) { + let m = fontMetricsCache.get(fontSize) + if (m) return m + const ctx = getMeasureCtx() + ctx.font = `${fontSize}px NotoSans` + const em = ctx.measureText('Mg') + const tall = ctx.measureText(PROBE_TALL) + const deep = ctx.measureText(PROBE_DEEP) + const emAscent = Number.isFinite(em.emHeightAscent) ? em.emHeightAscent : fontSize * 1.05 + const emDescent = Number.isFinite(em.emHeightDescent) ? em.emHeightDescent : fontSize * 0.3 + m = { + ascent: Math.ceil(Math.max(emAscent, tall.actualBoundingBoxAscent || 0, fontSize * 0.85)), + descent: Math.ceil(Math.max(emDescent, deep.actualBoundingBoxDescent || 0, fontSize * 0.3)) + } + fontMetricsCache.set(fontSize, m) + return m +} + +// Resolve the font string for a set of styles +function resolveFont (styles, fontSize) { + let fontType = '' + let fontName = 'NotoSans' + + if (styles.includes('bold')) fontType += 'bold ' + if (styles.includes('italic')) fontType += 'italic ' + if (styles.includes('monospace')) fontName = 'NotoSansMono' + + return `${fontType}${fontSize}px ${fontName}` +} + +// Parse entities into per-character style/emoji data +function buildStyledChars (text, entities) { + const chars = text.split('') + const styledChars = chars.map(char => ({ char, styles: [] })) + + // Apply string-type entities (e.g. 'bold' passed as string) + if (entities && typeof entities === 'string') { + for (const sc of styledChars) sc.styles.push(entities) + } + + // Apply entity array + if (Array.isArray(entities)) { + for (const entity of entities) { + const style = ENTITY_TYPES_MONOSPACE.includes(entity.type) + ? 'monospace' + : ENTITY_TYPES_MENTION.includes(entity.type) + ? 'mention' + : entity.type + + if (entity.type === 'custom_emoji') { + styledChars[entity.offset].customEmojiId = entity.custom_emoji_id + } + + for (let i = entity.offset; i < entity.offset + entity.length; i++) { + if (styledChars[i]) styledChars[i].styles.push(style) + } + } + } + + return styledChars +} + +// Detect and map emoji positions onto styled chars +function mapEmojis (text, styledChars) { + const emojis = emojiDb.searchFromText({ input: text, fixCodePoints: true }) + for (let eIdx = 0; eIdx < emojis.length; eIdx++) { + const emoji = emojis[eIdx] + for (let i = emoji.offset; i < emoji.offset + emoji.length; i++) { + if (styledChars[i]) { + styledChars[i].emoji = { index: eIdx, code: emoji.found } + } + } + } + return emojis +} + +// Load emoji images (with module-level cache + in-flight dedup) +const emojiLoadingPromises = new Map() + +async function loadEmojiImages (emojis, emojiBrand) { + const emojiImageJson = loadBrand(emojiBrand || 'apple') + let fallbackBrand = 'apple' + if (emojiBrand === 'blob') fallbackBrand = 'google' + const fallbackJson = loadBrand(fallbackBrand) + + const localMap = new Map() + const promises = [] + + for (const emoji of emojis) { + const cacheKey = `${emojiBrand}:${emoji.found}` + + if (emojiImageCache.has(cacheKey)) { + localMap.set(emoji.found, emojiImageCache.get(cacheKey)) + } else if (emojiLoadingPromises.has(cacheKey)) { + // Deduplicate concurrent loads for the same emoji + promises.push(emojiLoadingPromises.get(cacheKey).then(img => { + if (img) localMap.set(emoji.found, img) + })) + } else if (!localMap.has(emoji.found)) { + const p = (async () => { + const base = emojiImageJson[emoji.found] + let image = null + + if (base) { + try { + image = await loadImage(Buffer.from(base, 'base64')) + } catch (e) { + try { image = await loadImage(Buffer.from(fallbackJson[emoji.found], 'base64')) } catch (e2) { /* skip */ } + } + } else { + try { image = await loadImage(Buffer.from(fallbackJson[emoji.found], 'base64')) } catch (e) { /* skip */ } + } + + if (image) { + emojiImageCache.set(cacheKey, image) + localMap.set(emoji.found, image) + } + return image + })() + emojiLoadingPromises.set(cacheKey, p) + promises.push(p.finally(() => emojiLoadingPromises.delete(cacheKey))) + } + } + + await Promise.all(promises) + return localMap +} + +// Load custom emoji stickers via Telegram API +async function loadCustomEmojis (customEmojiIds, telegram) { + const result = {} + if (customEmojiIds.length === 0 || !telegram) return result + + const stickers = await telegram.callApi('getCustomEmojiStickers', { + custom_emoji_ids: customEmojiIds + }).catch(() => null) + + if (!stickers) return result + + const promises = stickers.map(async sticker => { + if (!sticker.thumb || !sticker.thumb.file_id) return + const fileLink = await telegram.getFileLink(sticker.thumb.file_id).catch(() => null) + if (!fileLink) return + const data = await loadImageFromUrl(fileLink).catch(() => null) + if (!data) return + const png = await sharp(data).png({ lossless: true, force: true }).toBuffer() + result[sticker.custom_emoji_id] = await loadImage(png).catch(() => null) + }) + + await Promise.all(promises).catch(() => {}) + return result +} + +// Tokenize styled chars into raw segments using Intl.Segmenter +function tokenize (text, styledChars, fontSize, emojiMap, customEmojiMap) { + const segments = [] + const emojiSize = fontSize * EMOJI_SCALE + + // Use Intl.Segmenter for word boundaries + const wordSegs = [...wordSegmenter.segment(text)] + + for (const wordSeg of wordSegs) { + const start = wordSeg.index + const end = start + wordSeg.segment.length + + // Further split each word segment by: style boundaries, emoji boundaries, explicit breaks + let subStart = start + for (let i = start; i < end; i++) { + const cur = styledChars[i] + const prev = i > subStart ? styledChars[i - 1] : null + + // Break chars always split unconditionally (even at segment start) + const isBreak = cur.char.match(BREAK_REGEX) + + const needsSplit = isBreak || (prev && ( + // Emoji boundary + (cur.emoji && !prev.emoji) || + (!cur.emoji && prev.emoji) || + (cur.emoji && prev.emoji && cur.emoji.index !== prev.emoji.index) || + // Style change + (cur.styles.toString() !== prev.styles.toString()) + )) + + if (needsSplit) { + if (i > subStart) { + pushSegment(segments, styledChars, subStart, i, fontSize, emojiSize, emojiMap, customEmojiMap) + } + subStart = i + } + } + + // Push remaining + if (subStart < end) { + pushSegment(segments, styledChars, subStart, end, fontSize, emojiSize, emojiMap, customEmojiMap) + } + } + + return segments +} + +// Create a segment from a range of styled chars +function pushSegment (segments, styledChars, start, end, fontSize, emojiSize, emojiMap, customEmojiMap) { + const first = styledChars[start] + let text = '' + for (let i = start; i < end; i++) text += styledChars[i].char + + // Determine segment kind + let kind = 'text' + if (text.match(BREAK_REGEX)) kind = 'break' + else if (text.match(SPACE_REGEX) && !text.match(/\S/)) kind = 'space' + else if (first.emoji) kind = 'emoji' + + // Resolve emoji image + let emojiImage = null + const emojiCode = first.emoji ? first.emoji.code : null + const customEmojiId = first.customEmojiId || null + + if (first.emoji) { + if (customEmojiId && customEmojiMap[customEmojiId]) { + emojiImage = customEmojiMap[customEmojiId] + } else { + emojiImage = emojiMap.get(first.emoji.code) || null + } + kind = 'emoji' + } + + segments.push({ + text, + kind, + styles: [...first.styles], + font: resolveFont(first.styles, fontSize), + emojiImage, + emojiCode, + customEmojiId, + width: 0 // measured later + }) +} + +// Split CJK segments into individual graphemes for per-character wrapping. +// Handles mixed CJK+non-CJK segments by splitting at CJK boundaries. +function splitCJKSegments (segments, fontSize) { + const result = [] + + for (const seg of segments) { + if (seg.kind !== 'text' || !seg.text.match(CJK_REGEX)) { + result.push(seg) + continue + } + + const graphemes = [...graphemeSegmenter.segment(seg.text)] + if (graphemes.length <= 1) { + result.push(seg) + continue + } + + // Split at CJK/non-CJK boundaries and make each CJK char a separate segment + let runStart = 0 + let runIsCJK = !!graphemes[0].segment.match(CJK_REGEX) + + for (let g = 1; g <= graphemes.length; g++) { + const curIsCJK = g < graphemes.length ? !!graphemes[g].segment.match(CJK_REGEX) : !runIsCJK + + if (curIsCJK !== runIsCJK || g === graphemes.length) { + if (runIsCJK) { + // Each CJK grapheme gets its own segment + for (let j = runStart; j < g; j++) { + result.push({ + text: graphemes[j].segment, + kind: 'text', + styles: [...seg.styles], + font: seg.font, + emojiImage: null, + emojiCode: null, + customEmojiId: null, + width: 0 + }) + } + } else { + // Non-CJK run stays together + let runText = '' + for (let j = runStart; j < g; j++) runText += graphemes[j].segment + result.push({ + text: runText, + kind: 'text', + styles: [...seg.styles], + font: seg.font, + emojiImage: null, + emojiCode: null, + customEmojiId: null, + width: 0 + }) + } + runStart = g + runIsCJK = curIsCJK + } + } + } + + return result +} + +// Merge trailing punctuation into preceding text segment (from pretext) +function mergePunctuation (segments) { + const result = [] + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] + + // Check if this segment is left-sticky punctuation that should merge with previous + if ( + seg.kind === 'text' && + seg.text.length === 1 && + LEFT_STICKY_PUNCTUATION.has(seg.text) && + result.length > 0 + ) { + const prev = result[result.length - 1] + if (prev.kind === 'text' && prev.styles.toString() === seg.styles.toString()) { + prev.text += seg.text + prev.width = 0 // re-measure later + prev._graphemeWidths = null // invalidate cache + continue + } + } + + result.push(seg) + } + + return result +} + +// Apply kinsoku rules: merge prohibited line-start chars with preceding, +// and prohibited line-end chars with following. +// Creates new segment objects instead of mutating input array. +function applyKinsoku (segments) { + const result = [] + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] + + // Merge kinsoku-start chars into preceding segment + if ( + seg.kind === 'text' && + seg.text.length === 1 && + KINSOKU_START.has(seg.text) && + result.length > 0 + ) { + const prev = result[result.length - 1] + if (prev.kind === 'text' && prev.styles.toString() === seg.styles.toString()) { + prev.text += seg.text + prev.width = 0 + prev._graphemeWidths = null + continue + } + } + + // Merge kinsoku-end chars with following segment — create new merged segment + if ( + seg.kind === 'text' && + seg.text.length === 1 && + KINSOKU_END.has(seg.text) && + i + 1 < segments.length + ) { + const next = segments[i + 1] + if (next.kind === 'text' && seg.styles.toString() === next.styles.toString()) { + // Create a new segment with the merged text, don't mutate input + segments[i + 1] = { + text: seg.text + next.text, + kind: next.kind, + styles: [...next.styles], + font: next.font, + emojiImage: next.emojiImage, + emojiCode: next.emojiCode, + customEmojiId: next.customEmojiId, + width: 0, + _graphemeWidths: null + } + continue + } + } + + result.push(seg) + } + + return result +} + +// Measure all segments using canvas measureText +function measureSegments (segments, fontSize) { + const ctx = getMeasureCtx() + const emojiSize = fontSize * EMOJI_SCALE + let currentFont = null + + for (const seg of segments) { + if (seg.kind === 'emoji') { + seg.width = emojiSize + continue + } + if (seg.kind === 'break') { + seg.width = 0 + continue + } + + // Set correct font before measuring + if (currentFont !== seg.font) { + ctx.font = seg.font + currentFont = seg.font + } + + seg.width = ctx.measureText(seg.text).width + } +} + +// Compute per-grapheme widths for overflow-wrap (lazy, only when needed) +function computeGraphemeWidths (segment) { + if (segment._graphemeWidths) return segment._graphemeWidths + + const ctx = getMeasureCtx() + ctx.font = segment.font + + const graphemes = [...graphemeSegmenter.segment(segment.text)] + const widths = graphemes.map(g => ctx.measureText(g.segment).width) + const texts = graphemes.map(g => g.segment) + + segment._graphemeWidths = { widths, texts } + return segment._graphemeWidths +} + +/** + * Prepare text for layout. Handles all async I/O (emoji loading, Telegram API). + * Returns a PreparedText object that can be passed to layoutText/renderText. + */ +async function prepareText (text, entities, fontSize, emojiBrand, telegram) { + if (!text) { + return { + segments: [], + fontSize, + lineHeight: fontSize * 1.2, + emojiSize: fontSize * EMOJI_SCALE, + ...fontMetrics(fontSize) + } + } + + // Normalize Ukrainian і → Latin i (existing behavior) + text = text.replace(/і/g, 'i') + + // 1. Build per-character style data + const styledChars = buildStyledChars(text, entities) + + // 2. Detect and map emojis + const emojis = mapEmojis(text, styledChars) + + // 3. Load emoji images (async I/O) + const emojiMap = await loadEmojiImages(emojis, emojiBrand) + + // 4. Collect and load custom emoji (async I/O) + const customEmojiIds = [] + for (const sc of styledChars) { + if (sc.customEmojiId) customEmojiIds.push(sc.customEmojiId) + } + const customEmojiMap = await loadCustomEmojis(customEmojiIds, telegram) + + // 5. Tokenize via Intl.Segmenter + let segments = tokenize(text, styledChars, fontSize, emojiMap, customEmojiMap) + + // 6. Split CJK segments into per-grapheme segments + segments = splitCJKSegments(segments, fontSize) + + // 7. Merge trailing punctuation (from pretext) + segments = mergePunctuation(segments) + + // 8. Apply kinsoku rules (from pretext) + segments = applyKinsoku(segments) + + // 9. Measure all segments with correct fonts + measureSegments(segments, fontSize) + + return { + segments, + fontSize, + lineHeight: fontSize * 1.2, + emojiSize: fontSize * EMOJI_SCALE, + ...fontMetrics(fontSize), + computeGraphemeWidths + } +} + +module.exports = { prepareText, graphemeSegmenter, getMeasureCtx, fontMetrics } diff --git a/utils/quote-generate/text-render.js b/utils/quote-generate/text-render.js new file mode 100644 index 0000000000000000000000000000000000000000..05508c0618f2a04bcf8356585baebaae42fd92a6 --- /dev/null +++ b/utils/quote-generate/text-render.js @@ -0,0 +1,128 @@ +// utils/quote-generate/text-render.js +// Render phase: draw laid-out text to a canvas. +// Sync, no I/O. Takes a TextLayout + PreparedText and produces a canvas. + +const { createCanvas } = require('canvas') +const { hexToRgb, normalizeColor } = require('./color') + +/** + * Render laid-out text to a canvas. + */ +function renderText (layout, prepared, fontColor) { + const { lines, width, height } = layout + const { segments, fontSize, emojiSize } = prepared + + if (lines.length === 0 || width === 0) { + return createCanvas(1, 1) + } + + // Create exact-size canvas — no oversized buffer + const canvasWidth = Math.max(1, Math.ceil(width)) + const canvasHeight = Math.max(1, Math.ceil(height)) + const canvas = createCanvas(canvasWidth, canvasHeight) + const ctx = canvas.getContext('2d') + + let currentFont = null + let currentFillStyle = null + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx] + + for (const layoutSeg of line.segments) { + const seg = segments[layoutSeg.index] + if (!seg) continue + + // Resolve fill style for this segment + let fillStyle = fontColor + if (seg.styles.includes('monospace')) fillStyle = '#5887a7' + else if (seg.styles.includes('mention')) fillStyle = '#6ab7ec' + else if (seg.styles.includes('spoiler')) { + const rgb = hexToRgb(normalizeColor(fontColor)) + fillStyle = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.15)` + } + + // Set context state (only when changed) + if (currentFont !== seg.font) { + ctx.font = seg.font + currentFont = seg.font + } + if (currentFillStyle !== fillStyle) { + ctx.fillStyle = fillStyle + currentFillStyle = fillStyle + } + + // Compute x position (RTL mirrors relative to canvas width for right-alignment) + let drawX = layoutSeg.x + if (line.direction === 'rtl') { + const segWidth = seg.kind === 'emoji' ? emojiSize : layoutSeg.width + drawX = width - layoutSeg.x - segWidth + } + + const drawY = line.y + + if (seg.kind === 'emoji' && seg.emojiImage) { + // Draw emoji image at consistent size + ctx.drawImage( + seg.emojiImage, + drawX, + drawY - fontSize + (fontSize * 0.15), + emojiSize, + emojiSize + ) + } else if (seg.kind === 'space' || seg.kind === 'break') { + // Don't draw whitespace + } else { + // Determine text to draw (handle grapheme splits from overflow-wrap) + let drawText = seg.text + if (layoutSeg.graphemeStart !== undefined || layoutSeg.graphemeEnd !== undefined) { + if (prepared.computeGraphemeWidths) { + const gData = prepared.computeGraphemeWidths(seg) + if (gData) { + const start = layoutSeg.graphemeStart !== undefined ? layoutSeg.graphemeStart : 0 + const end = layoutSeg.graphemeEnd !== undefined ? layoutSeg.graphemeEnd : gData.texts.length + drawText = gData.texts.slice(start, end).join('') + } + } + } + + ctx.fillText(drawText, drawX, drawY) + + // Strikethrough decoration + if (seg.styles.includes('strikethrough')) { + const textWidth = ctx.measureText(drawText).width + ctx.fillRect(drawX, drawY - fontSize / 2.8, textWidth, fontSize * 0.1) + } + + // Underline decoration + if (seg.styles.includes('underline')) { + const textWidth = ctx.measureText(drawText).width + ctx.fillRect(drawX, drawY + 2, textWidth, fontSize * 0.1) + } + } + } + + // Handle truncation ellipsis on last line + if (lineIdx === lines.length - 1 && line.truncated) { + const ellipsis = '\u2026' + if (currentFillStyle !== fontColor) { + ctx.fillStyle = fontColor + currentFillStyle = fontColor + } + + const lastSeg = line.segments[line.segments.length - 1] + if (lastSeg) { + let ellipsisX = lastSeg.x + lastSeg.width + // Apply RTL mirror to ellipsis position + if (line.direction === 'rtl') { + const eWidth = ctx.measureText(ellipsis).width + ellipsisX = width - lastSeg.x - lastSeg.width - eWidth + } + ctx.fillText(ellipsis, ellipsisX, line.y) + } + } + } + + return canvas +} + +module.exports = { renderText } diff --git a/utils/quote-generate/text-renderer.js b/utils/quote-generate/text-renderer.js new file mode 100644 index 0000000000000000000000000000000000000000..83a4502527ba308d6b6b7ee3f432b155fb1cdbc8 --- /dev/null +++ b/utils/quote-generate/text-renderer.js @@ -0,0 +1,39 @@ +// utils/quote-generate/text-renderer.js +// Thin wrapper composing prepare → shrink-wrap → render phases. +// Same API as before — zero changes needed in callers. + +const { prepareText } = require('./text-prepare') +const { shrinkWrap, getLineDirection } = require('./text-layout') +const { renderText } = require('./text-render') + +/** + * Draw multiline text with entity styling, emoji, and automatic layout. + * Uses prepare/layout/render split for shrink-wrapped, balanced output. + * + * @param {string} text - Text to render + * @param {Array|string} entities - Telegram entities or style string + * @param {number} fontSize - Font size in pixels + * @param {string} fontColor - CSS color + * @param {number} textX - X offset (kept for API compat, unused in new architecture) + * @param {number} textY - Y offset (kept for API compat, unused in new architecture) + * @param {number} maxWidth - Maximum width + * @param {number} maxHeight - Maximum height + * @param {string} emojiBrand - Emoji brand + * @param {object} telegram - Telegraf instance + * @returns {Canvas} + */ +async function drawMultilineText (text, entities, fontSize, fontColor, textX, textY, maxWidth, maxHeight, emojiBrand, telegram) { + const prepared = await prepareText(text, entities, fontSize, emojiBrand, telegram) + + if (prepared.segments.length === 0) { + const { createCanvas } = require('canvas') + return createCanvas(1, 1) + } + + // Use shrink-wrap for multi-line text, plain layout for single-line + const layout = shrinkWrap(prepared, maxWidth, maxHeight) + + return renderText(layout, prepared, fontColor) +} + +module.exports = { drawMultilineText, getLineDirection } diff --git a/utils/user-name.js b/utils/user-name.js new file mode 100644 index 0000000000000000000000000000000000000000..63073c9dba8a21804773d7421bb0f48b20bf9b21 --- /dev/null +++ b/utils/user-name.js @@ -0,0 +1,9 @@ +module.exports = (user, url = false) => { + let name = user.first_name + + if (user.last_name) name += ` ${user.last_name}` + name = name.replace(//g, '>') + + if (url) return `${name}` + return name +}