feat: initial commit — ASCIInator v0.1
Vue 3 + Vite + Tailwind frontend, Fastify API server, execa shell bridge. All components built, server validated, 41 unit tests passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
c1cf06ed67
8
.editorconfig
Normal file
8
.editorconfig
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
end_of_line = lf
|
||||||
|
max_line_length = 100
|
||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
*.timestamp-*-*.mjs
|
||||||
|
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
10
.oxlintrc.json
Normal file
10
.oxlintrc.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["eslint", "unicorn", "oxc", "vue", "vitest"],
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
.prettierrc.json
Normal file
8
.prettierrc.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 100,
|
||||||
|
"vueIndentScriptAndStyle": false
|
||||||
|
}
|
||||||
11
.vscode/extensions.json
vendored
Normal file
11
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"vitest.explorer",
|
||||||
|
"ms-playwright.playwright",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"oxc.oxc-vscode",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
342
CLAUDE.md
Normal file
342
CLAUDE.md
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
# ASCIInator — CLAUDE.md
|
||||||
|
|
||||||
|
> Project spec for Claude Code. Read this fully before touching any file.
|
||||||
|
> Stack: Vue 3 + Vite + Tailwind (no component lib) + Fastify + execa
|
||||||
|
> Purpose: Headless GUI front-end for system ASCII conversion binaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Project Overview
|
||||||
|
|
||||||
|
ASCIInator converts images to ASCII art by orchestrating system binaries
|
||||||
|
(chafa, jp2a, ascii-image-converter, img2txt) via ImageMagick preprocessing.
|
||||||
|
It is a **pure front-end UI** that talks to a **local Fastify API server**
|
||||||
|
which shells out to the binaries via `execa`.
|
||||||
|
|
||||||
|
Output format: **plain text**
|
||||||
|
Input methods: file upload, clipboard paste, drag-and-drop
|
||||||
|
Theme: dark default, respects `prefers-color-scheme`
|
||||||
|
CSS: Tailwind utility classes only — no component libraries, no Vuetify, no PrimeVue
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
asciinator/
|
||||||
|
├── CLAUDE.md ← you are here
|
||||||
|
├── package.json ← root (workspaces)
|
||||||
|
├── vite.config.js
|
||||||
|
├── tailwind.config.js
|
||||||
|
├── index.html
|
||||||
|
├── src/
|
||||||
|
│ ├── main.js
|
||||||
|
│ ├── App.vue
|
||||||
|
│ ├── assets/
|
||||||
|
│ │ └── main.css ← Tailwind base + custom properties
|
||||||
|
│ └── components/
|
||||||
|
│ ├── ImageInput.vue ← Component 1: input (upload / paste / drag-drop)
|
||||||
|
│ ├── ToolSelector.vue ← Component 2: ASCII tool picker + per-tool options
|
||||||
|
│ ├── ShellBridge.vue ← Component 3: translates GUI state → API call → shell
|
||||||
|
│ ├── OutputDisplay.vue ← Component 4: renders plain-text ASCII result
|
||||||
|
│ ├── ErrorLog.vue ← Component 5: watches and surfaces stderr / API errors
|
||||||
|
│ └── options/ ← Per-tool flag panels (extracted from ToolSelector)
|
||||||
|
│ ├── ChafaOptions.vue
|
||||||
|
│ ├── Jp2aOptions.vue
|
||||||
|
│ ├── AsciiOptions.vue
|
||||||
|
│ └── ImgTxtOptions.vue
|
||||||
|
└── server/
|
||||||
|
├── index.js ← Fastify server entry
|
||||||
|
├── routes/
|
||||||
|
│ └── convert.js ← POST /convert route
|
||||||
|
└── lib/
|
||||||
|
├── imagemagick.js ← ImageMagick pre/post processing via execa
|
||||||
|
└── converters.js ← per-tool execa invocations
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Component Architecture
|
||||||
|
|
||||||
|
### 3.1 `ImageInput.vue`
|
||||||
|
- Accepts: file picker, drag-and-drop onto drop zone, clipboard paste (`Ctrl+V`)
|
||||||
|
- Emits: `image-ready` with a `{ dataUrl, mimeType, filename }` payload
|
||||||
|
- Sends raw file to server as `multipart/form-data`
|
||||||
|
- Shows image preview thumbnail before conversion
|
||||||
|
- Validates: image/* MIME types only; max 20 MB
|
||||||
|
|
||||||
|
### 3.2 `ToolSelector.vue`
|
||||||
|
- Provides a segmented control to choose between:
|
||||||
|
- `chafa` (default)
|
||||||
|
- `jp2a`
|
||||||
|
- `ascii-image-converter`
|
||||||
|
- `img2txt`
|
||||||
|
- Each tool reveals its own option panel when selected
|
||||||
|
- **chafa options** (full flag exposure):
|
||||||
|
- `--width` / `--height` (integer inputs — combined server-side into `--size=WxH`)
|
||||||
|
- `--colors` (none | 2 | 8 | 16/8 | 16 | 240 | 256 | full)
|
||||||
|
- `--symbols` (text input, e.g. `block+border+extra`)
|
||||||
|
- `--dither` (none | ordered | diffusion | noise)
|
||||||
|
- `--threshold` (0.0–1.0 slider)
|
||||||
|
- `--font-ratio` (float input)
|
||||||
|
- **jp2a options**: `--width`, `--height`, `--chars`, `--background`
|
||||||
|
- **ascii-image-converter options**: `--width`, `--height`, `--color`, `--braille`, `--threshold`
|
||||||
|
- **img2txt options**: `--width`, `--height`, `--format` (ansi/utf8/html), `--dither`, `--gamma`
|
||||||
|
- Emits: `tool-config` with `{ tool: string, flags: Record<string, string|number|boolean> }`
|
||||||
|
|
||||||
|
### 3.3 `ShellBridge.vue`
|
||||||
|
- Renderless component (`<template><slot /></template>`)
|
||||||
|
- Props: `image` (`{ dataUrl, mimeType, filename }`), `toolConfig` (`{ tool, flags }`)
|
||||||
|
- Exposes: `convert()` — called via template ref from App.vue on button click
|
||||||
|
- Converts `dataUrl` back to Blob via `fetch()`, sends as `multipart/form-data`
|
||||||
|
- POSTs to `/api/convert` (proxied by Vite dev server to `http://localhost:3001/convert`)
|
||||||
|
- Emits: `conversion-result` (stdout string) | `conversion-error` (stderr string)
|
||||||
|
- Handles: 30s timeout via `AbortController`, network errors, non-2xx responses
|
||||||
|
|
||||||
|
### 3.4 `OutputDisplay.vue`
|
||||||
|
- Receives plain-text ASCII output string as prop
|
||||||
|
- Renders in a `<pre>` block with monospace font (JetBrains Mono or Fira Code via CDN)
|
||||||
|
- Controls: copy-to-clipboard button, download as `.txt`, font-size slider
|
||||||
|
- Preserves whitespace exactly — never reformat or trim output
|
||||||
|
|
||||||
|
### 3.5 `ErrorLog.vue`
|
||||||
|
- Receives array of `{ timestamp, source, message }` error objects as prop
|
||||||
|
- Collapsible panel, pinned to bottom of viewport
|
||||||
|
- Color-coded: yellow = warning, red = error
|
||||||
|
- Shows last 50 entries, auto-scrolls to latest
|
||||||
|
- Clear button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Fastify Server (`server/`)
|
||||||
|
|
||||||
|
### Entry: `server/index.js`
|
||||||
|
```js
|
||||||
|
// Fastify + @fastify/multipart
|
||||||
|
// Listens on localhost:3001
|
||||||
|
// Single route: POST /convert
|
||||||
|
// CORS: origin: true (reflects requester — permissive for local dev)
|
||||||
|
// Logger: warn level only (startup logged via console.log)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route: `POST /convert`
|
||||||
|
1. Parse multipart body — extract image file + JSON fields (`tool`, `flags`)
|
||||||
|
2. Write image to temp file (`/tmp/asciinator-<uuid>.<ext>`)
|
||||||
|
3. Pass through ImageMagick preprocessing (see §4.1)
|
||||||
|
4. Invoke selected tool via `converters.js`
|
||||||
|
5. Stream stdout back as `text/plain`
|
||||||
|
6. Clean up temp files in `finally`
|
||||||
|
7. On non-zero exit: return 422 with stderr body
|
||||||
|
|
||||||
|
### 4.1 ImageMagick Pipeline (`lib/imagemagick.js`)
|
||||||
|
**Preprocessing** (always runs before ASCII tool):
|
||||||
|
- Convert to PNG by default, JPEG for jp2a (`magick input.x output.{png,jpg}`)
|
||||||
|
- Strip metadata (`-strip`)
|
||||||
|
- Auto-orient (`-auto-orient`)
|
||||||
|
- Resize to max 2000px on longest side if larger (`-resize 2000x2000>`)
|
||||||
|
- `jp2a` receives JPEG — it links against libjpeg and rejects PNG input
|
||||||
|
|
||||||
|
**Post-processing** (optional, future hook — stub it now):
|
||||||
|
- Reserved for potential brightness/contrast adjustment on output
|
||||||
|
|
||||||
|
### 4.2 Converters (`lib/converters.js`)
|
||||||
|
Each converter is a function:
|
||||||
|
```js
|
||||||
|
async function runChafa(imagePath, flags) → { stdout, stderr }
|
||||||
|
async function runJp2a(imagePath, flags) → { stdout, stderr }
|
||||||
|
async function runAsciiImageConverter(imagePath, flags) → { stdout, stderr }
|
||||||
|
async function runImgToTxt(imagePath, flags) → { stdout, stderr }
|
||||||
|
```
|
||||||
|
- Use `execa` for all invocations
|
||||||
|
- Map the `flags` object to CLI args (e.g. `{ width: 80 }` → `['--width', '80']`)
|
||||||
|
- Validate flag values before constructing args — reject unknown flags
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. State Management
|
||||||
|
|
||||||
|
Use Vue 3 `reactive()` in `App.vue` — no Pinia for this scale.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const state = reactive({
|
||||||
|
image: null, // { dataUrl, mimeType, filename }
|
||||||
|
toolConfig: { tool: 'chafa', flags: {} },
|
||||||
|
result: '', // plain text ASCII output
|
||||||
|
errors: [], // [{ timestamp, source, message }]
|
||||||
|
isConverting: false,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass state slices as props, bubble events up. No global store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Styling Conventions
|
||||||
|
|
||||||
|
- **Tailwind only** — no inline styles, no external CSS except `src/assets/main.css`
|
||||||
|
- Always dark — `class="dark"` hardcoded on `<html>`. Light mode support planned (needs CSS variable overrides)
|
||||||
|
- CSS custom properties in `main.css` for brand tokens:
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--ascii-green: #39ff14; /* neon accent */
|
||||||
|
--ascii-bg: #0d0d0d;
|
||||||
|
--ascii-surface: #1a1a1a;
|
||||||
|
--ascii-border: #2a2a2a;
|
||||||
|
--ascii-text: #e0e0e0;
|
||||||
|
--ascii-error: #ff4444;
|
||||||
|
--ascii-warn: #ffaa00;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Monospace font for all output: `font-family: 'JetBrains Mono', 'Fira Code', monospace`
|
||||||
|
- UI font: `font-family: 'IBM Plex Sans', sans-serif`
|
||||||
|
- Load both via Google Fonts / Bunny Fonts in `index.html`
|
||||||
|
|
||||||
|
**`@layer components` utility classes** (defined in `main.css` — use these in templates):
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `.ascii-input` | Dark-themed text input or select — full width, bordered, focus ring |
|
||||||
|
| `.ascii-label` | Column-flex label wrapper with muted text — wraps a `.ascii-input` |
|
||||||
|
| `.ascii-btn` | Ghost button — transparent bg, border, hover turns neon green |
|
||||||
|
| `.ascii-btn-primary` | Solid neon-green button — used for the main Convert action |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Neovim IDE v2 Setup
|
||||||
|
|
||||||
|
### 7.1 LSP & Tooling (expected config)
|
||||||
|
| Tool | Role |
|
||||||
|
|------|------|
|
||||||
|
| `volar` | Vue 3 LSP (use Takeover mode — disable `tsserver` for `.vue` files) |
|
||||||
|
| `eslint-lsp` | Linting via `eslint_d` or native LSP client |
|
||||||
|
| `prettier` | Formatting — `.prettierrc` at root |
|
||||||
|
| `emmet-ls` | HTML/template expansion in `.vue` files |
|
||||||
|
| `cssls` | Tailwind class intellisense (pair with `tailwindcss-language-server`) |
|
||||||
|
|
||||||
|
### 7.2 `.prettierrc`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 100,
|
||||||
|
"vueIndentScriptAndStyle": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 `.eslintrc.cjs`
|
||||||
|
```js
|
||||||
|
module.exports = {
|
||||||
|
extends: ['plugin:vue/vue3-recommended', 'prettier'],
|
||||||
|
rules: {
|
||||||
|
'vue/multi-word-component-names': 'off'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 Neovim v2 Test Notes
|
||||||
|
This project is a test bed for **Neovim IDE v2.1.x**. Log observations here:
|
||||||
|
|
||||||
|
- [ ] Volar Takeover mode stability with Vite HMR running
|
||||||
|
- [ ] Tailwind intellisense triggering inside `class=""` in `.vue` templates
|
||||||
|
- [ ] `emmet-ls` expansion in `<template>` blocks
|
||||||
|
- [ ] ESLint auto-fix on save (`BufWritePre` autocmd)
|
||||||
|
- [ ] Prettier format on save — confirm it doesn't fight ESLint
|
||||||
|
- [ ] `gd` (go-to-definition) across `.vue` SFC boundaries
|
||||||
|
- [ ] Telescope file search respecting `.gitignore` (node_modules, dist)
|
||||||
|
- [ ] DAP (debugger) attach to Vite dev server — note any issues
|
||||||
|
- [ ] Terminal split for running Fastify server alongside Vite
|
||||||
|
|
||||||
|
**Add test results below as you work:**
|
||||||
|
```
|
||||||
|
DATE | COMPONENT | OBSERVATION | PASS/FAIL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Dev Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start Fastify server (terminal 1)
|
||||||
|
node server/index.js
|
||||||
|
|
||||||
|
# Start Vite dev server (terminal 2)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Both should be running simultaneously during development
|
||||||
|
# Vue: http://localhost:5173
|
||||||
|
# Fastify API: http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
### npm scripts (`package.json`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"server": "node server/index.js",
|
||||||
|
"server:dev": "node --watch server/index.js",
|
||||||
|
"test:unit": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"lint": "run-s lint:oxlint lint:eslint",
|
||||||
|
"format": "prettier --write --experimental-cli src/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Key Constraints for Claude Code
|
||||||
|
|
||||||
|
- **Never** install a Vue component library (no Vuetify, Headless UI, PrimeVue, etc.)
|
||||||
|
- **Never** add TypeScript — this is a JS project
|
||||||
|
- **Never** use `Options API` — Composition API with `<script setup>` only
|
||||||
|
- **Never** add Pinia — use `reactive()` in App.vue
|
||||||
|
- **Never** use `localStorage` for anything
|
||||||
|
- **Always** use `execa` for shell invocations — never `exec()` or raw `spawn()`
|
||||||
|
- **Always** sanitize/validate flag values before building CLI args
|
||||||
|
- **Always** clean up `/tmp/asciinator-*` files after each conversion
|
||||||
|
- **Always** use `<script setup>` SFC format
|
||||||
|
- **Always** keep components under ~150 lines — extract composables if growing
|
||||||
|
- **Never** remove Vitest or Playwright — they were scaffolded by Vite and are part of the test bed
|
||||||
|
- **Unit tests** live in `src/__tests__/` (App) and `src/components/__tests__/` (components) — Vitest, not Jest
|
||||||
|
- **e2e tests** live in `e2e/` — Playwright
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Dependencies
|
||||||
|
|
||||||
|
### All in root `package.json` (single package, no workspaces)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5",
|
||||||
|
"fastify": "^5.x",
|
||||||
|
"@fastify/multipart": "^10.x",
|
||||||
|
"@fastify/cors": "^11.x",
|
||||||
|
"execa": "^9.x"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^8.x",
|
||||||
|
"@vitejs/plugin-vue": "^6.x",
|
||||||
|
"tailwindcss": "^3.x",
|
||||||
|
"autoprefixer": "^10.x",
|
||||||
|
"postcss": "^8.x",
|
||||||
|
"vitest": "^4.x",
|
||||||
|
"@playwright/test": "^1.x",
|
||||||
|
"@vue/test-utils": "^2.x",
|
||||||
|
"eslint": "^10.x",
|
||||||
|
"eslint-plugin-vue": "^10.x",
|
||||||
|
"oxlint": "^1.x",
|
||||||
|
"prettier": "^3.x"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: 2026-04-28 — ASCIInator v0.1 (all components built)*
|
||||||
330
README.md
Normal file
330
README.md
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
# ASCIInator
|
||||||
|
|
||||||
|
```
|
||||||
|
█████╗ ███████╗ ██████╗██╗██╗███╗ ██╗ █████╗ ████████╗ ██████╗ ██████╗
|
||||||
|
██╔══██╗██╔════╝██╔════╝██║██║████╗ ██║██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗
|
||||||
|
███████║███████╗██║ ██║██║██╔██╗ ██║███████║ ██║ ██║ ██║██████╔╝
|
||||||
|
██╔══██║╚════██║██║ ██║██║██║╚██╗██║██╔══██║ ██║ ██║ ██║██╔══██╗
|
||||||
|
██║ ██║███████║╚██████╗██║██║██║ ╚████║██║ ██║ ██║ ╚██████╔╝██║ ██║
|
||||||
|
╚═╝ ╚═╝╚══════╝ ╚═════╝╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
|
||||||
|
```
|
||||||
|
|
||||||
|
> Turn any image into ASCII art. Headless Vue 3 front-end for your system's ASCII conversion binaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
ASCIInator is a local-first GUI wrapper around the ASCII conversion tools already on your system. Drop an image in, pick your tool, tune the flags, get plain-text ASCII out. No cloud, no SaaS, no telemetry — just your binaries, a thin Fastify bridge, and a Vue 3 interface.
|
||||||
|
|
||||||
|
Built as a test bed for **Neovim IDE v2.1.x**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Converters
|
||||||
|
|
||||||
|
| Tool | Notes |
|
||||||
|
|------|-------|
|
||||||
|
| **chafa** | Default. Best quality, full flag exposure |
|
||||||
|
| **jp2a** | JPEG-focused, classic output |
|
||||||
|
| **ascii-image-converter** | Braille support, good for fine detail |
|
||||||
|
| **img2txt** | libcaca-based, multiple output formats |
|
||||||
|
|
||||||
|
All tools must be installed on your system. ASCIInator does not install them for you.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Tech |
|
||||||
|
|-------|------|
|
||||||
|
| Frontend | Vue 3 + Vite |
|
||||||
|
| Styling | Tailwind CSS (no component lib) |
|
||||||
|
| API server | Fastify |
|
||||||
|
| Shell invocation | execa |
|
||||||
|
| Image processing | ImageMagick v7 (`magick`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Vue 3 Frontend │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ ImageInput │ │ ToolSelector │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ • Upload │ │ • chafa │ │
|
||||||
|
│ │ • Paste │ │ • jp2a │ │
|
||||||
|
│ │ • Drag-drop │ │ • ascii-i-c │ │
|
||||||
|
│ └──────┬──────┘ │ • img2txt │ │
|
||||||
|
│ │ └────────┬────────┘ │
|
||||||
|
│ └────────┬──────────┘ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌───────────────┐ │
|
||||||
|
│ │ ShellBridge │ (renderless) │
|
||||||
|
│ └───────┬───────┘ │
|
||||||
|
│ │ POST /convert │
|
||||||
|
└──────────────────┼──────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────┼──────────────────────┐
|
||||||
|
│ Fastify :3001 │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ImageMagick preprocessing │
|
||||||
|
│ ↓ │
|
||||||
|
│ execa → binary │
|
||||||
|
│ ↓ │
|
||||||
|
│ stdout → response │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────┼──────────────────────┐
|
||||||
|
│ Vue 3 Frontend │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────┐ ┌───────────┐ │
|
||||||
|
│ │ OutputDisplay │ │ ErrorLog │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ <pre> ASCII art │ │ stderr │ │
|
||||||
|
│ │ copy / download │ │ warnings │ │
|
||||||
|
│ └────────────────────┘ └───────────┘ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
**System binaries** (install via your package manager):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install chafa jp2a libcaca imagemagick
|
||||||
|
brew install ascii-image-converter
|
||||||
|
|
||||||
|
# Debian/Ubuntu
|
||||||
|
apt install chafa jp2a caca-utils imagemagick
|
||||||
|
# ascii-image-converter: see https://github.com/TheZoraiz/ascii-image-converter
|
||||||
|
|
||||||
|
# Arch
|
||||||
|
pacman -S chafa jp2a libcaca imagemagick
|
||||||
|
yay -S ascii-image-converter # AUR
|
||||||
|
```
|
||||||
|
|
||||||
|
**Node** — v20.19+ or v22.12+ required
|
||||||
|
**Vite** — installed via npm
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url>
|
||||||
|
cd asciinator
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IDE Setup
|
||||||
|
|
||||||
|
**Neovim** (primary — this is a Neovim v2.1.x test bed):
|
||||||
|
- Volar LSP in Takeover mode (disable tsserver for `.vue` files)
|
||||||
|
- `tailwindcss-language-server` for class intellisense
|
||||||
|
- `eslint-lsp` + `prettier` for format on save
|
||||||
|
- See `CLAUDE.md §7` for the full Neovim config checklist
|
||||||
|
|
||||||
|
**Browser DevTools:**
|
||||||
|
- [Vue DevTools for Chrome/Brave](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||||
|
- [Vue DevTools for Firefox](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||||
|
- Enable Custom Object Formatters in DevTools for readable Vue reactive state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
You need two terminals (or one split — you're using Neovim, you know the drill).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 — Fastify API server
|
||||||
|
node server/index.js
|
||||||
|
|
||||||
|
# Terminal 2 — Vite dev server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
| Service | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| Vue app | http://localhost:5173 |
|
||||||
|
| Fastify API | http://localhost:3001 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
asciinator/
|
||||||
|
├── CLAUDE.md ← Full spec for Claude Code
|
||||||
|
├── README.md ← You are here
|
||||||
|
├── index.html
|
||||||
|
├── vite.config.js
|
||||||
|
├── tailwind.config.js
|
||||||
|
├── src/
|
||||||
|
│ ├── main.js
|
||||||
|
│ ├── App.vue
|
||||||
|
│ ├── assets/
|
||||||
|
│ │ └── main.css
|
||||||
|
│ └── components/
|
||||||
|
│ ├── ImageInput.vue ← Upload / paste / drag-drop
|
||||||
|
│ ├── ToolSelector.vue ← Tool picker + flag controls
|
||||||
|
│ ├── ShellBridge.vue ← GUI state → API call (renderless)
|
||||||
|
│ ├── OutputDisplay.vue ← Plain-text ASCII result
|
||||||
|
│ ├── ErrorLog.vue ← stderr / error surface
|
||||||
|
│ └── options/
|
||||||
|
│ ├── ChafaOptions.vue
|
||||||
|
│ ├── Jp2aOptions.vue
|
||||||
|
│ ├── AsciiOptions.vue
|
||||||
|
│ └── ImgTxtOptions.vue
|
||||||
|
└── server/
|
||||||
|
├── index.js
|
||||||
|
├── routes/
|
||||||
|
│ └── convert.js ← POST /convert handler
|
||||||
|
└── lib/
|
||||||
|
├── imagemagick.js ← Pre/post processing
|
||||||
|
└── converters.js ← Per-tool execa invocations
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## npm Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Vite dev server (HMR)
|
||||||
|
npm run build # Production build
|
||||||
|
npm run preview # Preview production build
|
||||||
|
npm run server # Fastify API server
|
||||||
|
npm run server:dev # Fastify with --watch (auto-restart)
|
||||||
|
npm run lint # oxlint + ESLint (with autofix)
|
||||||
|
npm run format # Prettier
|
||||||
|
npm run test:unit # Vitest unit tests
|
||||||
|
npm run test:e2e # Playwright end-to-end tests
|
||||||
|
```
|
||||||
|
|
||||||
|
For e2e tests, build first if running on CI:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run test:e2e
|
||||||
|
npm run test:e2e -- --project=chromium # Chromium only
|
||||||
|
npm run test:e2e -- --debug # Debug mode
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `POST /convert`
|
||||||
|
|
||||||
|
**Request** — `multipart/form-data`
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `image` | file | Image file to convert |
|
||||||
|
| `tool` | string | `chafa` \| `jp2a` \| `ascii-image-converter` \| `img2txt` |
|
||||||
|
| `flags` | JSON string | Tool-specific flags object |
|
||||||
|
|
||||||
|
**Response** — `text/plain` — raw ASCII output on success, stderr on 422.
|
||||||
|
|
||||||
|
**curl example:**
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:3001/convert \
|
||||||
|
-F "image=@/path/to/photo.jpg" \
|
||||||
|
-F "tool=chafa" \
|
||||||
|
-F 'flags={"width":"80","colors":"256"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flag Reference
|
||||||
|
|
||||||
|
All flags are optional. Omitting a flag lets the binary use its own default.
|
||||||
|
|
||||||
|
### chafa
|
||||||
|
|
||||||
|
| Flag | Type | Values | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `width` | integer | e.g. `80` | Output width in characters (combined into `--size=WxH`) |
|
||||||
|
| `height` | integer | e.g. `40` | Output height in characters (combined into `--size=WxH`) |
|
||||||
|
| `colors` | enum | `none` `2` `8` `16/8` `16` `240` `256` `full` | Color depth |
|
||||||
|
| `symbols` | string | e.g. `block+border+extra` | Symbol sets to use |
|
||||||
|
| `dither` | enum | `none` `ordered` `diffusion` `noise` | Dithering algorithm |
|
||||||
|
| `threshold` | float | `0.0`–`1.0` | Alpha threshold |
|
||||||
|
| `font-ratio` | float | e.g. `0.5` | Terminal font width/height ratio |
|
||||||
|
|
||||||
|
### jp2a
|
||||||
|
|
||||||
|
| Flag | Type | Values | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `width` | integer | e.g. `80` | Output width in characters |
|
||||||
|
| `height` | integer | e.g. `40` | Output height in characters |
|
||||||
|
| `background` | enum | `light` `dark` | Assumed terminal background |
|
||||||
|
| `chars` | string | e.g. `.:-=+*#%@` | Character ramp to use |
|
||||||
|
|
||||||
|
### ascii-image-converter
|
||||||
|
|
||||||
|
| Flag | Type | Values | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `width` | integer | e.g. `80` | Output width in characters |
|
||||||
|
| `height` | integer | e.g. `40` | Output height in characters |
|
||||||
|
| `color` | boolean | `true` | Emit ANSI color codes |
|
||||||
|
| `braille` | boolean | `true` | Use braille dot patterns |
|
||||||
|
| `threshold` | integer | `0`–`255` | Brightness threshold |
|
||||||
|
|
||||||
|
### img2txt
|
||||||
|
|
||||||
|
| Flag | Type | Values | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `width` | integer | e.g. `80` | Output width in characters |
|
||||||
|
| `height` | integer | e.g. `40` | Output height in characters |
|
||||||
|
| `format` | enum | `ansi` `utf8` `html` | Output format |
|
||||||
|
| `dither` | string | e.g. `fstein` | Dithering mode |
|
||||||
|
| `gamma` | float | e.g. `1.0` | Gamma correction |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**`command not found: chafa` (or jp2a, img2txt, ascii-image-converter)**
|
||||||
|
The binary isn't on PATH. Install it via your package manager — see Prerequisites above.
|
||||||
|
|
||||||
|
**`convert: command not found`**
|
||||||
|
ImageMagick isn't installed. It's required for all conversions regardless of tool.
|
||||||
|
|
||||||
|
**CORS error in the browser console**
|
||||||
|
The Fastify server isn't running, or it's on a different port. Start it with `npm run server` and confirm it logs `Server listening at http://127.0.0.1:3001`.
|
||||||
|
|
||||||
|
**422 from the server**
|
||||||
|
The binary returned a non-zero exit code. The full stderr is in the response body and shown in the ErrorLog panel.
|
||||||
|
|
||||||
|
**Blank or truncated output**
|
||||||
|
Some tools default to terminal width detection which may return 0 in a non-TTY context. Set an explicit `--width` flag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
- Always dark — light mode not wired (planned)
|
||||||
|
- No component library — raw Tailwind only
|
||||||
|
- Monospace output: JetBrains Mono
|
||||||
|
- UI font: IBM Plex Sans
|
||||||
|
- Accent: `#39ff14` (neon green — because ASCII)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Neovim v2 Test Bed
|
||||||
|
|
||||||
|
This project exists partly to stress-test the **Neovim IDE v2.1.x** setup across a real Vue 3 + JS project. See `CLAUDE.md §7` for the full test checklist covering Volar, Tailwind LSP, ESLint, DAP, and more.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
23
e2e/vue.spec.js
Normal file
23
e2e/vue.spec.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test('page loads with correct title', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.locator('h1')).toHaveText('ASCIInator')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drop zone is visible', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByText('Drop, paste (Ctrl+V), or click to upload')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('four tool buttons are rendered', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
for (const label of ['chafa', 'jp2a', 'ascii-image-converter', 'img2txt']) {
|
||||||
|
await expect(page.getByRole('button', { name: label })).toBeVisible()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('convert button is disabled until an image is loaded', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByRole('button', { name: 'Convert' })).toBeDisabled()
|
||||||
|
})
|
||||||
42
eslint.config.js
Normal file
42
eslint.config.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
import globals from 'globals'
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import pluginPlaywright from 'eslint-plugin-playwright'
|
||||||
|
import pluginVitest from '@vitest/eslint-plugin'
|
||||||
|
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||||
|
import skipFormatting from 'eslint-config-prettier/flat'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
{
|
||||||
|
name: 'app/files-to-lint',
|
||||||
|
files: ['**/*.{vue,js,mjs,jsx}'],
|
||||||
|
},
|
||||||
|
|
||||||
|
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||||
|
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
js.configs.recommended,
|
||||||
|
...pluginVue.configs['flat/essential'],
|
||||||
|
|
||||||
|
{
|
||||||
|
...pluginPlaywright.configs['flat/recommended'],
|
||||||
|
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
...pluginVitest.configs.recommended,
|
||||||
|
files: ['src/**/__tests__/*'],
|
||||||
|
},
|
||||||
|
|
||||||
|
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||||
|
|
||||||
|
skipFormatting,
|
||||||
|
])
|
||||||
18
index.html
Normal file
18
index.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ASCIInator</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.bunny.net" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.bunny.net/css?family=ibm-plex-sans:400,500,600|jetbrains-mono:400,500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
jsconfig.json
Normal file
8
jsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
7521
package-lock.json
generated
Normal file
7521
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "asciinator",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test:unit": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"server": "node server/index.js",
|
||||||
|
"server:dev": "node --watch server/index.js",
|
||||||
|
"lint": "run-s lint:*",
|
||||||
|
"lint:oxlint": "oxlint . --fix",
|
||||||
|
"lint:eslint": "eslint . --fix --cache",
|
||||||
|
"format": "prettier --write --experimental-cli src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/multipart": "^10.0.0",
|
||||||
|
"execa": "^9.6.1",
|
||||||
|
"fastify": "^5.8.5",
|
||||||
|
"vue": "^3.5.32"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"@vitest/eslint-plugin": "^1.6.16",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-oxlint": "~1.60.0",
|
||||||
|
"eslint-plugin-playwright": "^2.10.1",
|
||||||
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
|
"globals": "^17.5.0",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
|
"oxlint": "~1.60.0",
|
||||||
|
"postcss": "^8.5.12",
|
||||||
|
"prettier": "3.8.3",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"vite": "^8.0.8",
|
||||||
|
"vite-plugin-vue-devtools": "^8.1.1",
|
||||||
|
"vitest": "^4.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
110
playwright.config.js
Normal file
110
playwright.config.js
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import process from 'node:process'
|
||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// require('dotenv').config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
/* Maximum time one test can run for. */
|
||||||
|
timeout: 30 * 1000,
|
||||||
|
expect: {
|
||||||
|
/**
|
||||||
|
* Maximum time expect() should wait for the condition to be met.
|
||||||
|
* For example in `await expect(locator).toHaveText();`
|
||||||
|
*/
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||||
|
actionTimeout: 0,
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
|
||||||
|
/* Only on CI systems run the tests headless */
|
||||||
|
headless: !!process.env.CI,
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Firefox'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Safari'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: {
|
||||||
|
// ...devices['Pixel 5'],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: {
|
||||||
|
// ...devices['iPhone 12'],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: {
|
||||||
|
// channel: 'msedge',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: {
|
||||||
|
// channel: 'chrome',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||||
|
// outputDir: 'test-results/',
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
webServer: {
|
||||||
|
/**
|
||||||
|
* Use the dev server by default for faster feedback loop.
|
||||||
|
* Use the preview server on CI for more realistic testing.
|
||||||
|
* Playwright will re-use the local server if there is already a dev-server running.
|
||||||
|
*/
|
||||||
|
command: process.env.CI ? 'npm run preview' : 'npm run dev',
|
||||||
|
port: process.env.CI ? 4173 : 5173,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
})
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
47
server/__tests__/converters.spec.js
Normal file
47
server/__tests__/converters.spec.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { buildArgs } from '../lib/converters.js'
|
||||||
|
|
||||||
|
describe('buildArgs', () => {
|
||||||
|
it('returns empty array for empty flags', () => {
|
||||||
|
expect(buildArgs('chafa', {})).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps value flags to --key value pairs', () => {
|
||||||
|
expect(buildArgs('chafa', { size: '80x40' })).toEqual(['--size', '80x40'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes bool flag only when true', () => {
|
||||||
|
expect(buildArgs('ascii-image-converter', { color: true })).toContain('--color')
|
||||||
|
expect(buildArgs('ascii-image-converter', { color: false })).not.toContain('--color')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown flags', () => {
|
||||||
|
expect(() => buildArgs('chafa', { evil: 'value' })).toThrow('Unknown flag')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid enum values', () => {
|
||||||
|
expect(() => buildArgs('chafa', { colors: 'invalid' })).toThrow('Invalid value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts valid enum values', () => {
|
||||||
|
expect(() => buildArgs('chafa', { colors: '256' })).not.toThrow()
|
||||||
|
expect(() => buildArgs('chafa', { colors: '16/8' })).not.toThrow()
|
||||||
|
expect(() => buildArgs('chafa', { dither: 'noise' })).not.toThrow()
|
||||||
|
expect(() => buildArgs('jp2a', { background: 'dark' })).not.toThrow()
|
||||||
|
expect(() => buildArgs('img2txt', { format: 'ansi' })).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on unknown tool', () => {
|
||||||
|
expect(() => buildArgs('malicious-tool', {})).toThrow('Unknown tool')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stringifies numeric values', () => {
|
||||||
|
const args = buildArgs('chafa', { size: 80 })
|
||||||
|
expect(args).toEqual(['--size', '80'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles font-ratio hyphenated key', () => {
|
||||||
|
const args = buildArgs('chafa', { 'font-ratio': '0.5' })
|
||||||
|
expect(args).toEqual(['--font-ratio', '0.5'])
|
||||||
|
})
|
||||||
|
})
|
||||||
35
server/__tests__/extSanitize.spec.js
Normal file
35
server/__tests__/extSanitize.spec.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const SAFE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.avif'])
|
||||||
|
|
||||||
|
function sanitizeExt(filename) {
|
||||||
|
const rawExt = path.extname(filename || '').toLowerCase()
|
||||||
|
return SAFE_EXTS.has(rawExt) ? rawExt : '.jpg'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('extension sanitization', () => {
|
||||||
|
it('allows known image extensions', () => {
|
||||||
|
expect(sanitizeExt('photo.png')).toBe('.png')
|
||||||
|
expect(sanitizeExt('photo.jpg')).toBe('.jpg')
|
||||||
|
expect(sanitizeExt('photo.WEBP')).toBe('.webp')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to .jpg for dangerous extensions', () => {
|
||||||
|
expect(sanitizeExt('exploit.svg')).toBe('.jpg')
|
||||||
|
expect(sanitizeExt('exploit.mvg')).toBe('.jpg')
|
||||||
|
expect(sanitizeExt('exploit.msl')).toBe('.jpg')
|
||||||
|
expect(sanitizeExt('exploit.sh')).toBe('.jpg')
|
||||||
|
expect(sanitizeExt('exploit.eps')).toBe('.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to .jpg for missing or empty filename', () => {
|
||||||
|
expect(sanitizeExt('')).toBe('.jpg')
|
||||||
|
expect(sanitizeExt(null)).toBe('.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores path components — only extension is used', () => {
|
||||||
|
expect(sanitizeExt('../../etc/passwd.png')).toBe('.png')
|
||||||
|
expect(sanitizeExt('../../etc/passwd')).toBe('.jpg')
|
||||||
|
})
|
||||||
|
})
|
||||||
22
server/index.js
Normal file
22
server/index.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import Fastify from 'fastify'
|
||||||
|
import multipart from '@fastify/multipart'
|
||||||
|
import cors from '@fastify/cors'
|
||||||
|
import { convertRoute } from './routes/convert.js'
|
||||||
|
|
||||||
|
const fastify = Fastify({ logger: { level: 'warn' } })
|
||||||
|
|
||||||
|
await fastify.register(cors, { origin: true })
|
||||||
|
|
||||||
|
await fastify.register(multipart, {
|
||||||
|
limits: { fileSize: 20 * 1024 * 1024 },
|
||||||
|
})
|
||||||
|
|
||||||
|
await fastify.register(convertRoute)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fastify.listen({ port: 3001, host: '127.0.0.1' })
|
||||||
|
console.log('ASCIInator API → http://127.0.0.1:3001')
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
79
server/lib/converters.js
Normal file
79
server/lib/converters.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { execa } from 'execa'
|
||||||
|
|
||||||
|
const TIMEOUT = 30_000
|
||||||
|
|
||||||
|
const SCHEMAS = {
|
||||||
|
chafa: {
|
||||||
|
size: { type: 'value' },
|
||||||
|
colors: { type: 'value', valid: ['none', '2', '8', '16/8', '16', '240', '256', 'full'] },
|
||||||
|
symbols: { type: 'value' },
|
||||||
|
dither: { type: 'value', valid: ['none', 'ordered', 'diffusion', 'noise'] },
|
||||||
|
threshold: { type: 'value' },
|
||||||
|
'font-ratio': { type: 'value' },
|
||||||
|
},
|
||||||
|
jp2a: {
|
||||||
|
width: { type: 'value' },
|
||||||
|
height: { type: 'value' },
|
||||||
|
chars: { type: 'value' },
|
||||||
|
background: { type: 'value', valid: ['light', 'dark'] },
|
||||||
|
},
|
||||||
|
'ascii-image-converter': {
|
||||||
|
width: { type: 'value' },
|
||||||
|
height: { type: 'value' },
|
||||||
|
color: { type: 'bool' },
|
||||||
|
braille: { type: 'bool' },
|
||||||
|
threshold: { type: 'value' },
|
||||||
|
},
|
||||||
|
img2txt: {
|
||||||
|
width: { type: 'value' },
|
||||||
|
height: { type: 'value' },
|
||||||
|
format: { type: 'value', valid: ['ansi', 'utf8', 'html'] },
|
||||||
|
dither: { type: 'value' },
|
||||||
|
gamma: { type: 'value' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildArgs(tool, flags) {
|
||||||
|
const schema = SCHEMAS[tool]
|
||||||
|
if (!schema) throw new Error(`Unknown tool: ${tool}`)
|
||||||
|
|
||||||
|
const args = []
|
||||||
|
for (const [key, value] of Object.entries(flags)) {
|
||||||
|
const def = schema[key]
|
||||||
|
if (!def) throw new Error(`Unknown flag for ${tool}: --${key}`)
|
||||||
|
if (def.valid && !def.valid.includes(String(value))) {
|
||||||
|
throw new Error(`Invalid value for --${key}: ${value}. Expected one of: ${def.valid.join(', ')}`)
|
||||||
|
}
|
||||||
|
if (def.type === 'bool') {
|
||||||
|
if (value) args.push(`--${key}`)
|
||||||
|
} else {
|
||||||
|
args.push(`--${key}`, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runChafa(imagePath, flags) {
|
||||||
|
const { width, height, ...rest } = flags
|
||||||
|
const processed = { ...rest }
|
||||||
|
if (width && height) processed.size = `${width}x${height}`
|
||||||
|
else if (width) processed.size = `${width}x`
|
||||||
|
else if (height) processed.size = `x${height}`
|
||||||
|
const args = buildArgs('chafa', processed)
|
||||||
|
return execa('chafa', [...args, imagePath], { timeout: TIMEOUT })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runJp2a(imagePath, flags) {
|
||||||
|
const args = buildArgs('jp2a', flags)
|
||||||
|
return execa('jp2a', [...args, imagePath], { timeout: TIMEOUT })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAsciiImageConverter(imagePath, flags) {
|
||||||
|
const args = buildArgs('ascii-image-converter', flags)
|
||||||
|
return execa('ascii-image-converter', [...args, imagePath], { timeout: TIMEOUT })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runImgToTxt(imagePath, flags) {
|
||||||
|
const args = buildArgs('img2txt', flags)
|
||||||
|
return execa('img2txt', [...args, imagePath], { timeout: TIMEOUT })
|
||||||
|
}
|
||||||
17
server/lib/imagemagick.js
Normal file
17
server/lib/imagemagick.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { execa } from 'execa'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
|
||||||
|
export async function preprocess(inputPath, format = 'png') {
|
||||||
|
const outputPath = `/tmp/asciinator-${randomUUID()}-processed.${format}`
|
||||||
|
await execa('magick', [
|
||||||
|
inputPath,
|
||||||
|
'-strip',
|
||||||
|
'-auto-orient',
|
||||||
|
'-resize', '2000x2000>',
|
||||||
|
outputPath,
|
||||||
|
])
|
||||||
|
return outputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserved for future brightness/contrast post-processing
|
||||||
|
export async function postprocess(_imagePath) {}
|
||||||
66
server/routes/convert.js
Normal file
66
server/routes/convert.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { writeFile, unlink } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { preprocess } from '../lib/imagemagick.js'
|
||||||
|
import {
|
||||||
|
runChafa,
|
||||||
|
runJp2a,
|
||||||
|
runAsciiImageConverter,
|
||||||
|
runImgToTxt,
|
||||||
|
} from '../lib/converters.js'
|
||||||
|
|
||||||
|
const RUNNERS = {
|
||||||
|
chafa: runChafa,
|
||||||
|
jp2a: runJp2a,
|
||||||
|
'ascii-image-converter': runAsciiImageConverter,
|
||||||
|
img2txt: runImgToTxt,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertRoute(fastify) {
|
||||||
|
fastify.post('/convert', async (request, reply) => {
|
||||||
|
let tempInput = null
|
||||||
|
let tempProcessed = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = request.parts()
|
||||||
|
let tool = null
|
||||||
|
let flags = {}
|
||||||
|
|
||||||
|
for await (const part of parts) {
|
||||||
|
if (part.type === 'file' && part.fieldname === 'image') {
|
||||||
|
const SAFE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.avif'])
|
||||||
|
const rawExt = path.extname(part.filename || '').toLowerCase()
|
||||||
|
const ext = SAFE_EXTS.has(rawExt) ? rawExt : '.jpg'
|
||||||
|
tempInput = `/tmp/asciinator-${randomUUID()}${ext}`
|
||||||
|
const buf = await part.toBuffer()
|
||||||
|
await writeFile(tempInput, buf)
|
||||||
|
} else if (part.type === 'field') {
|
||||||
|
if (part.fieldname === 'tool') tool = part.value
|
||||||
|
if (part.fieldname === 'flags') {
|
||||||
|
try { flags = JSON.parse(part.value) } catch { flags = {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tempInput) return reply.code(400).type('text/plain').send('No image provided')
|
||||||
|
if (!tool) return reply.code(400).type('text/plain').send('No tool specified')
|
||||||
|
|
||||||
|
const runner = RUNNERS[tool]
|
||||||
|
if (!runner) return reply.code(400).type('text/plain').send(`Unknown tool: ${tool}`)
|
||||||
|
|
||||||
|
const format = tool === 'jp2a' ? 'jpg' : 'png'
|
||||||
|
tempProcessed = await preprocess(tempInput, format)
|
||||||
|
const result = await runner(tempProcessed, flags)
|
||||||
|
|
||||||
|
return reply.type('text/plain').send(result.stdout)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.exitCode !== undefined) {
|
||||||
|
return reply.code(422).type('text/plain').send(err.stderr || err.message)
|
||||||
|
}
|
||||||
|
return reply.code(400).type('text/plain').send(err.message)
|
||||||
|
} finally {
|
||||||
|
if (tempInput) await unlink(tempInput).catch(() => {})
|
||||||
|
if (tempProcessed) await unlink(tempProcessed).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
99
src/App.vue
Normal file
99
src/App.vue
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import ImageInput from './components/ImageInput.vue'
|
||||||
|
import ToolSelector from './components/ToolSelector.vue'
|
||||||
|
import ShellBridge from './components/ShellBridge.vue'
|
||||||
|
import OutputDisplay from './components/OutputDisplay.vue'
|
||||||
|
import ErrorLog from './components/ErrorLog.vue'
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
image: null,
|
||||||
|
toolConfig: { tool: 'chafa', flags: {} },
|
||||||
|
result: '',
|
||||||
|
errors: [],
|
||||||
|
isConverting: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const bridge = ref(null)
|
||||||
|
|
||||||
|
function addError(message, source = 'error') {
|
||||||
|
state.errors.push({
|
||||||
|
timestamp: new Date().toLocaleTimeString(),
|
||||||
|
source,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runConvert() {
|
||||||
|
if (!state.image) {
|
||||||
|
addError('No image selected.', 'warn')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.isConverting = true
|
||||||
|
state.result = ''
|
||||||
|
try {
|
||||||
|
await bridge.value.convert()
|
||||||
|
} finally {
|
||||||
|
state.isConverting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResult(text) {
|
||||||
|
state.result = text
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError(message) {
|
||||||
|
addError(message)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen pb-20" style="background-color: var(--ascii-bg)">
|
||||||
|
<header class="px-6 py-4 border-b" style="border-color: var(--ascii-border)">
|
||||||
|
<h1
|
||||||
|
class="text-lg font-semibold tracking-widest font-mono"
|
||||||
|
style="color: var(--ascii-green)"
|
||||||
|
>
|
||||||
|
ASCIInator
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-5xl mx-auto px-6 py-8 flex flex-col gap-8">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<section>
|
||||||
|
<h2 class="text-xs font-medium uppercase tracking-wider mb-3"
|
||||||
|
style="color: rgba(224,224,224,.35)">Image</h2>
|
||||||
|
<ImageInput @image-ready="state.image = $event" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="text-xs font-medium uppercase tracking-wider mb-3"
|
||||||
|
style="color: rgba(224,224,224,.35)">Tool & Options</h2>
|
||||||
|
<ToolSelector @tool-config="state.toolConfig = $event" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<button
|
||||||
|
class="ascii-btn-primary"
|
||||||
|
:disabled="state.isConverting || !state.image"
|
||||||
|
@click="runConvert"
|
||||||
|
>
|
||||||
|
{{ state.isConverting ? 'Converting...' : 'Convert' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ShellBridge
|
||||||
|
ref="bridge"
|
||||||
|
:image="state.image"
|
||||||
|
:tool-config="state.toolConfig"
|
||||||
|
@conversion-result="onResult"
|
||||||
|
@conversion-error="onError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OutputDisplay :result="state.result" />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ErrorLog :errors="state.errors" @clear="state.errors = []" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
10
src/__tests__/App.spec.js
Normal file
10
src/__tests__/App.spec.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { shallowMount } from '@vue/test-utils'
|
||||||
|
import App from '../App.vue'
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('mounts', () => {
|
||||||
|
const wrapper = shallowMount(App)
|
||||||
|
expect(wrapper.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
79
src/assets/main.css
Normal file
79
src/assets/main.css
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ascii-green: #39ff14;
|
||||||
|
--ascii-bg: #0d0d0d;
|
||||||
|
--ascii-surface: #1a1a1a;
|
||||||
|
--ascii-border: #2a2a2a;
|
||||||
|
--ascii-text: #e0e0e0;
|
||||||
|
--ascii-error: #ff4444;
|
||||||
|
--ascii-warn: #ffaa00;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--ascii-bg);
|
||||||
|
color: var(--ascii-text);
|
||||||
|
font-family: 'IBM Plex Sans', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.ascii-input {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background-color: var(--ascii-surface);
|
||||||
|
border: 1px solid var(--ascii-border);
|
||||||
|
color: var(--ascii-text);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.ascii-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(57, 255, 20, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ascii-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(224, 224, 224, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ascii-btn {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: 1px solid var(--ascii-border);
|
||||||
|
color: var(--ascii-text);
|
||||||
|
background: transparent;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ascii-btn:hover {
|
||||||
|
border-color: var(--ascii-green);
|
||||||
|
color: var(--ascii-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ascii-btn-primary {
|
||||||
|
padding: 0.5rem 2rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: none;
|
||||||
|
background-color: var(--ascii-green);
|
||||||
|
color: #0d0d0d;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.ascii-btn-primary:hover:not(:disabled) {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.ascii-btn-primary:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/components/ErrorLog.vue
Normal file
64
src/components/ErrorLog.vue
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
errors: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['clear'])
|
||||||
|
|
||||||
|
const collapsed = ref(false)
|
||||||
|
const logEl = ref(null)
|
||||||
|
|
||||||
|
const recent = computed(() => props.errors.slice(-50))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.errors.length,
|
||||||
|
async () => {
|
||||||
|
if (!collapsed.value) {
|
||||||
|
await nextTick()
|
||||||
|
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="errors.length > 0"
|
||||||
|
class="fixed bottom-0 left-0 right-0 z-50"
|
||||||
|
style="background-color: var(--ascii-surface); border-top: 1px solid var(--ascii-border)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 px-4 py-2">
|
||||||
|
<span class="text-xs font-medium" style="color: var(--ascii-text)">
|
||||||
|
Errors ({{ errors.length }})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="text-xs ml-2"
|
||||||
|
style="color: rgba(224,224,224,.4)"
|
||||||
|
@click="collapsed = !collapsed"
|
||||||
|
>
|
||||||
|
{{ collapsed ? 'Show' : 'Hide' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-xs ml-auto"
|
||||||
|
style="color: rgba(224,224,224,.4)"
|
||||||
|
@click="$emit('clear')"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="!collapsed" ref="logEl" class="max-h-36 overflow-y-auto px-4 pb-3 space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="(err, i) in recent"
|
||||||
|
:key="i"
|
||||||
|
class="text-xs font-mono leading-snug"
|
||||||
|
:style="{ color: err.source === 'warn' ? 'var(--ascii-warn)' : 'var(--ascii-error)' }"
|
||||||
|
>
|
||||||
|
<span style="color: rgba(224,224,224,.3)">{{ err.timestamp }}</span>
|
||||||
|
<span class="mx-1" style="color: rgba(224,224,224,.2)">[{{ err.source }}]</span>
|
||||||
|
{{ err.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
89
src/components/ImageInput.vue
Normal file
89
src/components/ImageInput.vue
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['image-ready'])
|
||||||
|
|
||||||
|
const preview = ref(null)
|
||||||
|
const filename = ref('')
|
||||||
|
const dragOver = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
function processFile(file) {
|
||||||
|
error.value = ''
|
||||||
|
if (!file || !file.type.startsWith('image/')) {
|
||||||
|
error.value = 'Only image files are supported.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size > 20 * 1024 * 1024) {
|
||||||
|
error.value = 'File exceeds the 20 MB limit.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
preview.value = e.target.result
|
||||||
|
filename.value = file.name
|
||||||
|
emit('image-ready', { dataUrl: e.target.result, mimeType: file.type, filename: file.name })
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileInput(e) {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (file) processFile(file)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e) {
|
||||||
|
dragOver.value = false
|
||||||
|
processFile(e.dataTransfer.files[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalPaste(e) {
|
||||||
|
const item = Array.from(e.clipboardData?.items ?? []).find((i) => i.type.startsWith('image/'))
|
||||||
|
if (item) processFile(item.getAsFile())
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('paste', onGlobalPaste))
|
||||||
|
onUnmounted(() => window.removeEventListener('paste', onGlobalPaste))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label
|
||||||
|
class="relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 cursor-pointer transition-colors"
|
||||||
|
:style="{
|
||||||
|
borderColor: dragOver ? 'var(--ascii-green)' : 'var(--ascii-border)',
|
||||||
|
backgroundColor: dragOver ? 'rgba(57,255,20,.04)' : 'transparent',
|
||||||
|
}"
|
||||||
|
@dragover.prevent="dragOver = true"
|
||||||
|
@dragleave="dragOver = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
>
|
||||||
|
<input type="file" accept="image/*" class="sr-only" @change="onFileInput" />
|
||||||
|
<template v-if="preview">
|
||||||
|
<img
|
||||||
|
:src="preview"
|
||||||
|
:alt="filename"
|
||||||
|
class="max-h-48 max-w-full rounded object-contain"
|
||||||
|
/>
|
||||||
|
<span class="text-xs" style="color: rgba(224,224,224,.4)">{{ filename }}</span>
|
||||||
|
<span class="text-xs" style="color: rgba(57,255,20,.6)">Click or drop to replace</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.5"
|
||||||
|
style="color: rgba(224,224,224,.25)"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm" style="color: rgba(224,224,224,.45)">
|
||||||
|
Drop, paste (Ctrl+V), or click to upload
|
||||||
|
</span>
|
||||||
|
<span class="text-xs" style="color: rgba(224,224,224,.25)">image/* · max 20 MB</span>
|
||||||
|
</template>
|
||||||
|
</label>
|
||||||
|
<p v-if="error" class="text-xs" style="color: var(--ascii-error)">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
56
src/components/OutputDisplay.vue
Normal file
56
src/components/OutputDisplay.vue
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
result: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const fontSize = ref(12)
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
async function copyToClipboard() {
|
||||||
|
await navigator.clipboard.writeText(props.result)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadTxt() {
|
||||||
|
const blob = new Blob([props.result], { type: 'text/plain' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'ascii-output.txt'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="result" class="mt-6">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<button class="ascii-btn" @click="copyToClipboard">
|
||||||
|
{{ copied ? 'Copied!' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
<button class="ascii-btn" @click="downloadTxt">Download .txt</button>
|
||||||
|
<label class="flex items-center gap-2 text-xs ml-auto" style="color: rgba(224,224,224,.5)">
|
||||||
|
{{ fontSize }}px
|
||||||
|
<input
|
||||||
|
v-model.number="fontSize"
|
||||||
|
type="range" min="8" max="24" step="1"
|
||||||
|
class="w-24"
|
||||||
|
style="accent-color: var(--ascii-green)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
class="rounded p-4 overflow-auto leading-tight whitespace-pre"
|
||||||
|
:style="{
|
||||||
|
fontSize: fontSize + 'px',
|
||||||
|
fontFamily: '\'JetBrains Mono\', \'Fira Code\', monospace',
|
||||||
|
backgroundColor: 'var(--ascii-surface)',
|
||||||
|
border: '1px solid var(--ascii-border)',
|
||||||
|
color: 'var(--ascii-text)',
|
||||||
|
}"
|
||||||
|
>{{ result }}</pre>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
44
src/components/ShellBridge.vue
Normal file
44
src/components/ShellBridge.vue
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
image: { type: Object, default: null },
|
||||||
|
toolConfig: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['conversion-result', 'conversion-error'])
|
||||||
|
|
||||||
|
async function convert() {
|
||||||
|
if (!props.image || !props.toolConfig) return
|
||||||
|
|
||||||
|
const blob = await fetch(props.image.dataUrl).then((r) => r.blob())
|
||||||
|
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('image', blob, props.image.filename)
|
||||||
|
fd.append('tool', props.toolConfig.tool)
|
||||||
|
fd.append('flags', JSON.stringify(props.toolConfig.flags))
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30_000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/convert', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
const text = await res.text()
|
||||||
|
if (!res.ok) {
|
||||||
|
emit('conversion-error', text)
|
||||||
|
} else {
|
||||||
|
emit('conversion-result', text)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
emit('conversion-error', err.name === 'AbortError' ? 'Conversion timed out (30s)' : err.message)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ convert })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template><slot /></template>
|
||||||
73
src/components/ToolSelector.vue
Normal file
73
src/components/ToolSelector.vue
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
import ChafaOptions from './options/ChafaOptions.vue'
|
||||||
|
import Jp2aOptions from './options/Jp2aOptions.vue'
|
||||||
|
import AsciiOptions from './options/AsciiOptions.vue'
|
||||||
|
import ImgTxtOptions from './options/ImgTxtOptions.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['tool-config'])
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{ id: 'chafa', label: 'chafa' },
|
||||||
|
{ id: 'jp2a', label: 'jp2a' },
|
||||||
|
{ id: 'ascii-image-converter', label: 'ascii-image-converter' },
|
||||||
|
{ id: 'img2txt', label: 'img2txt' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const OPTIONS_MAP = {
|
||||||
|
chafa: ChafaOptions,
|
||||||
|
jp2a: Jp2aOptions,
|
||||||
|
'ascii-image-converter': AsciiOptions,
|
||||||
|
img2txt: ImgTxtOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTool = ref('chafa')
|
||||||
|
|
||||||
|
const toolFlags = reactive({
|
||||||
|
chafa: { width: '', height: '', colors: '', symbols: '', dither: '', threshold: '', 'font-ratio': '' },
|
||||||
|
jp2a: { width: '', height: '', chars: '', background: '' },
|
||||||
|
'ascii-image-converter': { width: '', height: '', color: false, braille: false, threshold: '' },
|
||||||
|
img2txt: { width: '', height: '', format: '', dither: '', gamma: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
function onFlagsUpdate(newFlags) {
|
||||||
|
Object.assign(toolFlags[selectedTool.value], newFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[selectedTool, toolFlags],
|
||||||
|
() => {
|
||||||
|
const flags = Object.fromEntries(
|
||||||
|
Object.entries(toolFlags[selectedTool.value]).filter(([, v]) => v !== '' && v !== false),
|
||||||
|
)
|
||||||
|
emit('tool-config', { tool: selectedTool.value, flags })
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
v-for="t in TOOLS"
|
||||||
|
:key="t.id"
|
||||||
|
class="px-3 py-1 text-xs font-mono rounded border transition-colors"
|
||||||
|
:style="{
|
||||||
|
borderColor: selectedTool === t.id ? 'var(--ascii-green)' : 'var(--ascii-border)',
|
||||||
|
color: selectedTool === t.id ? 'var(--ascii-green)' : 'rgba(224,224,224,.5)',
|
||||||
|
backgroundColor: selectedTool === t.id ? 'rgba(57,255,20,.06)' : 'transparent',
|
||||||
|
}"
|
||||||
|
@click="selectedTool = t.id"
|
||||||
|
>
|
||||||
|
{{ t.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<component
|
||||||
|
:is="OPTIONS_MAP[selectedTool]"
|
||||||
|
:flags="toolFlags[selectedTool]"
|
||||||
|
@update:flags="onFlagsUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
41
src/components/__tests__/ChafaOptions.spec.js
Normal file
41
src/components/__tests__/ChafaOptions.spec.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ChafaOptions from '../options/ChafaOptions.vue'
|
||||||
|
|
||||||
|
const EMPTY_FLAGS = {
|
||||||
|
width: '', height: '', colors: '', symbols: '',
|
||||||
|
dither: '', threshold: '', 'font-ratio': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChafaOptions', () => {
|
||||||
|
it('renders without error', () => {
|
||||||
|
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
||||||
|
expect(wrapper.exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:flags with updated value on input change', async () => {
|
||||||
|
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
||||||
|
const widthInput = wrapper.find('input[type="number"]')
|
||||||
|
await widthInput.setValue('120')
|
||||||
|
const emitted = wrapper.emitted('update:flags')
|
||||||
|
expect(emitted).toBeTruthy()
|
||||||
|
expect(emitted[0][0].width).toBe('120')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves other flag values when one changes', async () => {
|
||||||
|
const flags = { ...EMPTY_FLAGS, colors: '256' }
|
||||||
|
const wrapper = mount(ChafaOptions, { props: { flags } })
|
||||||
|
const widthInput = wrapper.find('input[type="number"]')
|
||||||
|
await widthInput.setValue('80')
|
||||||
|
const emitted = wrapper.emitted('update:flags')
|
||||||
|
expect(emitted[0][0].colors).toBe('256')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:flags with selected value on select change', async () => {
|
||||||
|
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
|
||||||
|
const select = wrapper.find('select')
|
||||||
|
await select.setValue('256')
|
||||||
|
const emitted = wrapper.emitted('update:flags')
|
||||||
|
expect(emitted[0][0].colors).toBe('256')
|
||||||
|
})
|
||||||
|
})
|
||||||
44
src/components/__tests__/ErrorLog.spec.js
Normal file
44
src/components/__tests__/ErrorLog.spec.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ErrorLog from '../ErrorLog.vue'
|
||||||
|
|
||||||
|
function makeErrors(n, source = 'error') {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
timestamp: '12:00:00',
|
||||||
|
source,
|
||||||
|
message: `Error ${i}`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ErrorLog', () => {
|
||||||
|
it('renders nothing when errors array is empty', () => {
|
||||||
|
const wrapper = mount(ErrorLog)
|
||||||
|
expect(wrapper.find('.fixed').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error count in header', () => {
|
||||||
|
const wrapper = mount(ErrorLog, { props: { errors: makeErrors(3) } })
|
||||||
|
expect(wrapper.text()).toContain('Errors (3)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits clear when clear button is clicked', async () => {
|
||||||
|
const wrapper = mount(ErrorLog, { props: { errors: makeErrors(1) } })
|
||||||
|
const clearBtn = wrapper.findAll('button').find((b) => b.text() === 'Clear')
|
||||||
|
await clearBtn.trigger('click')
|
||||||
|
expect(wrapper.emitted('clear')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps display at 50 entries', () => {
|
||||||
|
const wrapper = mount(ErrorLog, { props: { errors: makeErrors(60) } })
|
||||||
|
const entries = wrapper.findAll('.font-mono.leading-snug')
|
||||||
|
expect(entries.length).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles log visibility when hide/show is clicked', async () => {
|
||||||
|
const wrapper = mount(ErrorLog, { props: { errors: makeErrors(2) } })
|
||||||
|
const toggle = wrapper.findAll('button').find((b) => b.text() === 'Hide')
|
||||||
|
await toggle.trigger('click')
|
||||||
|
expect(wrapper.text()).toContain('Show')
|
||||||
|
expect(wrapper.findAll('.font-mono.leading-snug').length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
70
src/components/__tests__/ImageInput.spec.js
Normal file
70
src/components/__tests__/ImageInput.spec.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ImageInput from '../ImageInput.vue'
|
||||||
|
|
||||||
|
function makeFile(name, type, size = 100) {
|
||||||
|
return new File(['x'.repeat(size)], name, { type })
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFiles(input, files) {
|
||||||
|
Object.defineProperty(input.element, 'files', { value: files, configurable: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ImageInput', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
it('renders the drop zone and file input', () => {
|
||||||
|
const wrapper = mount(ImageInput)
|
||||||
|
expect(wrapper.find('label').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('input[type="file"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error for non-image file', async () => {
|
||||||
|
const wrapper = mount(ImageInput)
|
||||||
|
const input = wrapper.find('input[type="file"]')
|
||||||
|
setFiles(input, [makeFile('doc.pdf', 'application/pdf')])
|
||||||
|
await input.trigger('change')
|
||||||
|
expect(wrapper.text()).toContain('Only image files are supported.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error for file exceeding 20 MB', async () => {
|
||||||
|
const wrapper = mount(ImageInput)
|
||||||
|
const input = wrapper.find('input[type="file"]')
|
||||||
|
setFiles(input, [makeFile('big.png', 'image/png', 21 * 1024 * 1024)])
|
||||||
|
await input.trigger('change')
|
||||||
|
expect(wrapper.text()).toContain('20 MB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits image-ready with correct shape on valid image', async () => {
|
||||||
|
class MockFileReader {
|
||||||
|
readAsDataURL() {
|
||||||
|
this.onload({ target: { result: 'data:image/png;base64,abc' } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('FileReader', MockFileReader)
|
||||||
|
|
||||||
|
const wrapper = mount(ImageInput)
|
||||||
|
const input = wrapper.find('input[type="file"]')
|
||||||
|
setFiles(input, [makeFile('photo.png', 'image/png')])
|
||||||
|
await input.trigger('change')
|
||||||
|
|
||||||
|
const emitted = wrapper.emitted('image-ready')
|
||||||
|
expect(emitted).toBeTruthy()
|
||||||
|
expect(emitted[0][0]).toMatchObject({
|
||||||
|
dataUrl: 'data:image/png;base64,abc',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
filename: 'photo.png',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attaches paste listener on mount and removes it on unmount', () => {
|
||||||
|
const add = vi.spyOn(window, 'addEventListener')
|
||||||
|
const remove = vi.spyOn(window, 'removeEventListener')
|
||||||
|
|
||||||
|
const wrapper = mount(ImageInput)
|
||||||
|
expect(add).toHaveBeenCalledWith('paste', expect.any(Function))
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
expect(remove).toHaveBeenCalledWith('paste', expect.any(Function))
|
||||||
|
})
|
||||||
|
})
|
||||||
41
src/components/__tests__/OutputDisplay.spec.js
Normal file
41
src/components/__tests__/OutputDisplay.spec.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import OutputDisplay from '../OutputDisplay.vue'
|
||||||
|
|
||||||
|
describe('OutputDisplay', () => {
|
||||||
|
it('renders nothing when result is empty', () => {
|
||||||
|
const wrapper = mount(OutputDisplay)
|
||||||
|
expect(wrapper.find('pre').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders pre block with result text', () => {
|
||||||
|
const wrapper = mount(OutputDisplay, { props: { result: 'hello ascii' } })
|
||||||
|
expect(wrapper.find('pre').text()).toBe('hello ascii')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows copy and download buttons when result is provided', () => {
|
||||||
|
const wrapper = mount(OutputDisplay, { props: { result: 'art' } })
|
||||||
|
const buttons = wrapper.findAll('button')
|
||||||
|
expect(buttons.some((b) => b.text().includes('Copy'))).toBe(true)
|
||||||
|
expect(buttons.some((b) => b.text().includes('Download'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('copy to clipboard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
it('calls clipboard.writeText with the result', async () => {
|
||||||
|
const wrapper = mount(OutputDisplay, { props: { result: 'copy me' } })
|
||||||
|
const copyBtn = wrapper.findAll('button').find((b) => b.text().includes('Copy'))
|
||||||
|
await copyBtn.trigger('click')
|
||||||
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('copy me')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
59
src/components/__tests__/ShellBridge.spec.js
Normal file
59
src/components/__tests__/ShellBridge.spec.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ShellBridge from '../ShellBridge.vue'
|
||||||
|
|
||||||
|
const IMAGE = {
|
||||||
|
dataUrl: 'data:image/png;base64,abc',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
filename: 'test.png',
|
||||||
|
}
|
||||||
|
const TOOL_CONFIG = { tool: 'chafa', flags: { width: '80' } }
|
||||||
|
|
||||||
|
function mockFetch(blobResponse, convertResponse) {
|
||||||
|
return vi.fn()
|
||||||
|
.mockResolvedValueOnce({ blob: vi.fn().mockResolvedValue(new Blob([''])) })
|
||||||
|
.mockResolvedValueOnce(convertResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ShellBridge', () => {
|
||||||
|
beforeEach(() => vi.stubGlobal('fetch', vi.fn()))
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
it('does nothing if image prop is null', async () => {
|
||||||
|
const wrapper = mount(ShellBridge, { props: { image: null, toolConfig: TOOL_CONFIG } })
|
||||||
|
await wrapper.vm.convert()
|
||||||
|
expect(fetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits conversion-result with stdout on 200', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
mockFetch(null, { ok: true, text: vi.fn().mockResolvedValue('ascii art') }),
|
||||||
|
)
|
||||||
|
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
|
||||||
|
await wrapper.vm.convert()
|
||||||
|
expect(wrapper.emitted('conversion-result')?.[0][0]).toBe('ascii art')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits conversion-error with body on non-2xx', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
mockFetch(null, { ok: false, text: vi.fn().mockResolvedValue('tool failed') }),
|
||||||
|
)
|
||||||
|
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
|
||||||
|
await wrapper.vm.convert()
|
||||||
|
expect(wrapper.emitted('conversion-error')?.[0][0]).toBe('tool failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits conversion-error on network failure', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn()
|
||||||
|
.mockResolvedValueOnce({ blob: vi.fn().mockResolvedValue(new Blob([''])) })
|
||||||
|
.mockRejectedValueOnce(new Error('Network error')),
|
||||||
|
)
|
||||||
|
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
|
||||||
|
await wrapper.vm.convert()
|
||||||
|
expect(wrapper.emitted('conversion-error')?.[0][0]).toBe('Network error')
|
||||||
|
})
|
||||||
|
})
|
||||||
50
src/components/__tests__/ToolSelector.spec.js
Normal file
50
src/components/__tests__/ToolSelector.spec.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ToolSelector from '../ToolSelector.vue'
|
||||||
|
|
||||||
|
describe('ToolSelector', () => {
|
||||||
|
it('renders four tool buttons', () => {
|
||||||
|
const wrapper = mount(ToolSelector)
|
||||||
|
expect(wrapper.findAll('button').length).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits tool-config immediately with chafa as default', () => {
|
||||||
|
const wrapper = mount(ToolSelector)
|
||||||
|
const emitted = wrapper.emitted('tool-config')
|
||||||
|
expect(emitted).toBeTruthy()
|
||||||
|
expect(emitted[0][0].tool).toBe('chafa')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips empty and false flag values before emitting', () => {
|
||||||
|
const wrapper = mount(ToolSelector)
|
||||||
|
const flags = wrapper.emitted('tool-config')[0][0].flags
|
||||||
|
const hasEmpty = Object.values(flags).some((v) => v === '' || v === false)
|
||||||
|
expect(hasEmpty).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches active tool and emits updated tool-config', async () => {
|
||||||
|
const wrapper = mount(ToolSelector)
|
||||||
|
const buttons = wrapper.findAll('button')
|
||||||
|
await buttons[1].trigger('click') // jp2a
|
||||||
|
const emitted = wrapper.emitted('tool-config')
|
||||||
|
const latest = emitted[emitted.length - 1][0]
|
||||||
|
expect(latest.tool).toBe('jp2a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves flags for a tool when switching away and back', async () => {
|
||||||
|
const wrapper = mount(ToolSelector)
|
||||||
|
const [chafaBtn, jp2aBtn] = wrapper.findAll('button')
|
||||||
|
|
||||||
|
// Set width on chafa via ChafaOptions input
|
||||||
|
const widthInput = wrapper.find('input[type="number"]')
|
||||||
|
await widthInput.setValue('120')
|
||||||
|
|
||||||
|
// Switch to jp2a and back
|
||||||
|
await jp2aBtn.trigger('click')
|
||||||
|
await chafaBtn.trigger('click')
|
||||||
|
|
||||||
|
const emitted = wrapper.emitted('tool-config')
|
||||||
|
const latest = emitted[emitted.length - 1][0]
|
||||||
|
expect(latest.flags.width).toBe('120')
|
||||||
|
})
|
||||||
|
})
|
||||||
40
src/components/options/AsciiOptions.vue
Normal file
40
src/components/options/AsciiOptions.vue
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({ flags: { type: Object, required: true } })
|
||||||
|
const emit = defineEmits(['update:flags'])
|
||||||
|
|
||||||
|
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="ascii-label">
|
||||||
|
Width
|
||||||
|
<input class="ascii-input" type="number" min="1" :value="flags.width"
|
||||||
|
@input="set('width', $event.target.value)" placeholder="auto" />
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label">
|
||||||
|
Height
|
||||||
|
<input class="ascii-input" type="number" min="1" :value="flags.height"
|
||||||
|
@input="set('height', $event.target.value)" placeholder="auto" />
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label">
|
||||||
|
Threshold (0–255)
|
||||||
|
<input class="ascii-input" type="number" min="0" max="255" :value="flags.threshold"
|
||||||
|
@input="set('threshold', $event.target.value)" placeholder="128" />
|
||||||
|
</label>
|
||||||
|
<div class="ascii-label justify-center gap-3 flex-row items-center col-span-2">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" :checked="flags.color"
|
||||||
|
@change="set('color', $event.target.checked)"
|
||||||
|
style="accent-color: var(--ascii-green)" />
|
||||||
|
Color
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" :checked="flags.braille"
|
||||||
|
@change="set('braille', $event.target.checked)"
|
||||||
|
style="accent-color: var(--ascii-green)" />
|
||||||
|
Braille
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
52
src/components/options/ChafaOptions.vue
Normal file
52
src/components/options/ChafaOptions.vue
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({ flags: { type: Object, required: true } })
|
||||||
|
const emit = defineEmits(['update:flags'])
|
||||||
|
|
||||||
|
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="ascii-label">
|
||||||
|
Width
|
||||||
|
<input class="ascii-input" type="number" min="1" :value="flags.width"
|
||||||
|
@input="set('width', $event.target.value)" placeholder="auto" />
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label">
|
||||||
|
Height
|
||||||
|
<input class="ascii-input" type="number" min="1" :value="flags.height"
|
||||||
|
@input="set('height', $event.target.value)" placeholder="auto" />
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label">
|
||||||
|
Colors
|
||||||
|
<select class="ascii-input" :value="flags.colors" @change="set('colors', $event.target.value)">
|
||||||
|
<option value="">default</option>
|
||||||
|
<option>none</option><option>2</option><option>8</option>
|
||||||
|
<option value="16/8">16/8</option><option>16</option>
|
||||||
|
<option>240</option><option>256</option><option>full</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label">
|
||||||
|
Dither
|
||||||
|
<select class="ascii-input" :value="flags.dither" @change="set('dither', $event.target.value)">
|
||||||
|
<option value="">default</option>
|
||||||
|
<option>none</option><option>ordered</option><option>diffusion</option><option>noise</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label">
|
||||||
|
Symbols
|
||||||
|
<input class="ascii-input" type="text" :value="flags.symbols"
|
||||||
|
@input="set('symbols', $event.target.value)" placeholder="e.g. block+border" />
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label">
|
||||||
|
Threshold (0–1)
|
||||||
|
<input class="ascii-input" type="number" min="0" max="1" step="0.05" :value="flags.threshold"
|
||||||
|
@input="set('threshold', $event.target.value)" placeholder="0.5" />
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label col-span-2">
|
||||||
|
Font ratio
|
||||||
|
<input class="ascii-input" type="number" min="0.1" step="0.05" :value="flags['font-ratio']"
|
||||||
|
@input="set('font-ratio', $event.target.value)" placeholder="auto" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
39
src/components/options/ImgTxtOptions.vue
Normal file
39
src/components/options/ImgTxtOptions.vue
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({ flags: { type: Object, required: true } })
|
||||||
|
const emit = defineEmits(['update:flags'])
|
||||||
|
|
||||||
|
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="ascii-label">
|
||||||
|
Width
|
||||||
|
<input class="ascii-input" type="number" min="1" :value="flags.width"
|
||||||
|
@input="set('width', $event.target.value)" placeholder="auto" />
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label">
|
||||||
|
Height
|
||||||
|
<input class="ascii-input" type="number" min="1" :value="flags.height"
|
||||||
|
@input="set('height', $event.target.value)" placeholder="auto" />
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label">
|
||||||
|
Format
|
||||||
|
<select class="ascii-input" :value="flags.format"
|
||||||
|
@change="set('format', $event.target.value)">
|
||||||
|
<option value="">default</option>
|
||||||
|
<option>ansi</option><option>utf8</option><option>html</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label">
|
||||||
|
Dither
|
||||||
|
<input class="ascii-input" type="text" :value="flags.dither"
|
||||||
|
@input="set('dither', $event.target.value)" placeholder="default" />
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label col-span-2">
|
||||||
|
Gamma
|
||||||
|
<input class="ascii-input" type="number" min="0.1" step="0.1" :value="flags.gamma"
|
||||||
|
@input="set('gamma', $event.target.value)" placeholder="1.0" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
34
src/components/options/Jp2aOptions.vue
Normal file
34
src/components/options/Jp2aOptions.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({ flags: { type: Object, required: true } })
|
||||||
|
const emit = defineEmits(['update:flags'])
|
||||||
|
|
||||||
|
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="ascii-label">
|
||||||
|
Width
|
||||||
|
<input class="ascii-input" type="number" min="1" :value="flags.width"
|
||||||
|
@input="set('width', $event.target.value)" placeholder="auto" />
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label">
|
||||||
|
Height
|
||||||
|
<input class="ascii-input" type="number" min="1" :value="flags.height"
|
||||||
|
@input="set('height', $event.target.value)" placeholder="auto" />
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label">
|
||||||
|
Background
|
||||||
|
<select class="ascii-input" :value="flags.background"
|
||||||
|
@change="set('background', $event.target.value)">
|
||||||
|
<option value="">default</option>
|
||||||
|
<option>light</option><option>dark</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="ascii-label">
|
||||||
|
Chars
|
||||||
|
<input class="ascii-input" type="text" :value="flags.chars"
|
||||||
|
@input="set('chars', $event.target.value)" placeholder="e.g. .:-=+*#%@" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
5
src/main.js
Normal file
5
src/main.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import './assets/main.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
9
tailwind.config.js
Normal file
9
tailwind.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: ['./index.html', './src/**/*.{vue,js}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
26
vite.config.js
Normal file
26
vite.config.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueDevTools(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
14
vitest.config.js
Normal file
14
vitest.config.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||||
|
import viteConfig from './vite.config'
|
||||||
|
|
||||||
|
export default mergeConfig(
|
||||||
|
viteConfig,
|
||||||
|
defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||||
|
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user