code.pablomurad.com

goascii

Web app that renders images as colored ASCII art

Initial commit

Made-with: Cursor
author
Pablo Murad
date
commit
12638a2f6a6e7827030e73e0af110c9dd6de9c3f
changes
13 files +1307 −0
@@ -0,0 +1,7 @@+pic2html
+pic2html.exe
+*.test
+*.out
+
+.DS_Store
+Thumbs.db
@@ -0,0 +1,27 @@+# goASCII
+
+goASCII is a terminal-style web app by Pablo Murad.
+
+It renders images as colored ASCII art in the browser.
+
+## Features
+
+- built with Go
+- default demo image at `/`
+- remote image rendering with `/?url=https://example.com/image.png`
+- terminal-inspired black interface
+
+## Run
+
+```bash
+go run .
+```
+
+Open:
+
+- `http://localhost:8080/`
+- `http://localhost:8080/?url=https://example.com/image.png`
+
+## Credit
+
+Original idea and earlier version by Patrik Roos.
@@ -0,0 +1,17 @@+			</td></tr>
+		</table>
+
+	</td></tr>
+</table>
+<P>
+Read the <A HREF="http://www.text-image.com/help.html">help page</A> for information on how to copy the text image.
+<P>
+<P>
+
+<table width="70%">
+<tr><td>
+Please note that these images are not stored on the web server, so you cannot link to these pages expecting anyone to see the image you have converted.
+</td><tr>
+</table>
+</body>
+</html>
@@ -0,0 +1,17 @@+			</td></tr>
+		</table>
+
+	</td></tr>
+</table>
+<P>
+Read the <A HREF="http://www.text-image.com/help.html">help page</A> for information on how to copy the text image.
+<P>
+<P>
+
+<table width="70%">
+<tr><td>
+Please note that these images are not stored on the web server, so you cannot link to these pages expecting anyone to see the image you have converted.
+</td><tr>
+</table>
+</body>
+</html>
@@ -0,0 +1,15 @@+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
+<html>
+<head>
+<META http-equiv="Content-Type" content="text/html; charset=UTF-8">
+<title>pic2html :: Result</title>
+</head>
+
+<body>
+
+<center>
+<p>
+<table width="80%">
+	<tr>
+		<td>
+		<h2>Result</h2>
@@ -0,0 +1,146 @@+# Como rodar o goASCII no Debian 12
+
+Este guia mostra uma forma simples de rodar o site no Debian 12.
+
+## 1. Instalar dependências
+
+Atualize o sistema e instale os pacotes básicos:
+
+```bash
+sudo apt update
+sudo apt install -y git curl ca-certificates build-essential
+```
+
+## 2. Instalar Go
+
+O projeto usa Go moderno. No Debian 12, prefira usar Go 1.26 ou mais novo.
+
+Verifique sua versão atual:
+
+```bash
+go version
+```
+
+Se o comando não existir, ou se a versão for antiga demais, instale uma versão atual do Go antes de continuar.
+
+Depois confirme novamente:
+
+```bash
+go version
+```
+
+## 3. Entrar no projeto
+
+Se você já tiver o projeto na máquina:
+
+```bash
+cd /caminho/para/goASCII1/pic2html
+```
+
+Se for clonar do seu repositório:
+
+```bash
+git clone https://github.com/runawaydevil/goascii.git
+cd goascii
+```
+
+## 4. Baixar dependências do Go
+
+```bash
+go mod download
+```
+
+## 5. Testar o projeto
+
+```bash
+go test ./...
+```
+
+## 6. Gerar o binário
+
+```bash
+go build -o goascii
+```
+
+Isso vai gerar um executável chamado `goascii`.
+
+## 7. Rodar o site
+
+```bash
+./goascii
+```
+
+Por padrão, o site sobe em:
+
+```text
+http://localhost:8080/
+```
+
+Para usar outra porta:
+
+```bash
+PORT=3000 ./goascii
+```
+
+## 8. Testar no navegador
+
+Abra:
+
+```text
+http://localhost:8080/
+```
+
+Para renderizar uma imagem externa:
+
+```text
+http://localhost:8080/?url=https://exemplo.com/imagem.png
+```
+
+## 9. Rodar em segundo plano com systemd
+
+Se quiser deixar o site ativo como serviço no Debian 12, crie um arquivo:
+
+```bash
+sudo nano /etc/systemd/system/goascii.service
+```
+
+Conteúdo sugerido:
+
+```ini
+[Unit]
+Description=goASCII web app
+After=network.target
+
+[Service]
+Type=simple
+User=www-data
+WorkingDirectory=/caminho/para/goASCII1/pic2html
+Environment=PORT=8080
+ExecStart=/caminho/para/goASCII1/pic2html/goascii
+Restart=always
+RestartSec=3
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Depois rode:
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable goascii
+sudo systemctl start goascii
+sudo systemctl status goascii
+```
+
+## 10. Ver logs
+
+```bash
+sudo journalctl -u goascii -f
+```
+
+## Observações
+
+- O projeto serve a imagem demo local da pasta `img`.
+- O parâmetro `?url=` aceita imagens públicas via `http` ou `https`.
+- O site faz o download da imagem no servidor e aplica validações básicas de segurança.
@@ -0,0 +1,5 @@+module pic2html
+
+go 1.26
+
+require golang.org/x/image v0.31.0
@@ -0,0 +1,2 @@+golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
+golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
@@ -0,0 +1,84 @@+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
+<html>
+<head>
+<META http-equiv="Content-Type" content="text/html; charset=UTF-8">
+<title>pic2html :: Convert</title>
+</head>
+
+<body>
+
+<center>
+
+<table>
+	<tr>
+		<td>
+		<h2>Convert into HTML</h2>
+		<form method="post" enctype="multipart/form-data" action="pic2html.cgi">
+		<table cellspacing="8">
+		<tr>
+			<td><label for="image">Image file: </label></td><td><input name="image" size="50" id="image" type="file"></td>
+		</tr>
+
+		<tr>
+			<td><label for="characters">Characters: </label></td><td><input name="characters" value="01" id="characters" type="text">  <input checked="checked" name="textType" value="random" type="radio">-Random <input name="textType" value="sequence" type="radio">-Sequence</td>
+		</tr>
+		<tr>
+			<td><label for="fontsize">Font size: </label></td>
+			<td>
+			<select name="fontsize" id="fontsize">
+			<option selected="selected" value="-3">Smallest</option>
+			<option value="-1">Small</option>
+			<option value="1">Normal</option>
+			<option value="3">Large</option>
+			<option value="7">Largest</option>
+			</select>
+			</td>
+		</tr>
+		<tr>
+			<td><label for="width">Image width: </label></td><td><input name="width" value="130" size="3" maxlength="3" id="width" type="text"> (1-500) characters</td>
+		</tr>
+		<tr>
+			<td><label for="grayscale">Show it in: </label></td>
+			<td>
+			<select name="grayscale" id="grayscale">
+			<option selected="selected" value="0">Colour</option>
+			<option value="1">Grayscale</option>
+			<option value="2">Monochrome</option>
+			</select>
+			</td>
+		</tr>
+		<tr>
+			<td><label for="bgcolor">Background: </label></td><td><input name="bgcolor" value="BLACK" id="bgcolor" type="text"></td>
+		</tr>
+                <tr>
+                        <td><label for="contrast">Extra contrast: </label></td>
+			<td>
+			<select name="contrast" id="contrast">
+				<option selected="selected" value="0">No</option>
+				<option value="1">Yes</option>
+			</select>
+			</td>
+                </tr>
+		<tr>
+			<td><label for="browser">Web browser: </label></td>
+			<td>
+			<select name="browser" id="browser">
+			<option value="ie">Internet Explorer</option>
+			<option selected="selected" value="firefox">Mozilla Firefox</option>
+			<option value="firefox">Other</option>
+			</select>
+			</td>
+		</tr>
+		<tr>
+			<td><br><input value="Convert!" type="submit"></td>
+		</tr>
+		</table>
+		</form>
+		<p><br>For help on using the converter, see the <a href="http://www.text-image.com/help.html">help page</a>.
+		</td>
+	</tr>
+</table>
+
+</center>
+</body>
+</html>
@@ -0,0 +1,757 @@+package main
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"html"
+	"html/template"
+	"image"
+	_ "image/gif"
+	_ "image/jpeg"
+	_ "image/png"
+	"io"
+	"log"
+	"math/rand"
+	"net"
+	"net/http"
+	"net/netip"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	_ "golang.org/x/image/bmp"
+	_ "golang.org/x/image/tiff"
+)
+
+const (
+	defaultWidth      = 130
+	defaultCharacters = "01"
+	asciiHeightScale  = 0.58
+	maxImageBytes     = 8 << 20
+	maxRedirects      = 4
+	requestTimeout    = 8 * time.Second
+)
+
+type parameters struct {
+	bgcolor    string
+	browser    string
+	characters string
+	contrast   int
+	fontsize   string
+	grayscale  int
+	texttype   string
+	width      int
+
+	intensity string
+	textcolor string
+	invert    int
+}
+
+type pageData struct {
+	Title       string
+	ASCII       template.HTML
+	SourceLabel string
+	SourceURL   string
+	StatusLine  string
+	Error       string
+}
+
+func defaults() parameters {
+	return parameters{
+		bgcolor:    "BLACK",
+		browser:    "firefox",
+		characters: defaultCharacters,
+		contrast:   0,
+		fontsize:   "-3",
+		grayscale:  0,
+		texttype:   "sequence",
+		width:      defaultWidth,
+		intensity:  " `.-:/+osyhdmNM",
+		invert:     0,
+		textcolor:  "BLACK",
+	}
+}
+
+func nextChar(kind, characters string) func() string {
+	chars := []rune(characters)
+	if len(chars) == 0 {
+		chars = []rune(defaultCharacters)
+	}
+	if kind == "random" {
+		rng := rand.New(rand.NewSource(time.Now().UnixNano()))
+		return func() string {
+			return string(chars[rng.Intn(len(chars))])
+		}
+	}
+	counter := 0
+	return func() string {
+		i := counter % len(chars)
+		counter++
+		return string(chars[i])
+	}
+}
+
+func convertImage(img image.Image, settings map[string]string) (template.HTML, error) {
+	p := defaults()
+
+	if v := settings["bgcolor"]; v != "" {
+		p.bgcolor = v
+	}
+	if v := settings["browser"]; v != "" {
+		p.browser = v
+	}
+	if v := settings["characters"]; v != "" {
+		p.characters = v
+	}
+	if v := settings["contrast"]; v != "" {
+		p.contrast, _ = strconv.Atoi(v)
+	}
+	if v := settings["fontsize"]; v != "" {
+		p.fontsize = v
+	}
+	if v := settings["grayscale"]; v != "" {
+		p.grayscale, _ = strconv.Atoi(v)
+	}
+	if v := settings["textType"]; v != "" {
+		p.texttype = v
+	}
+	if v := settings["width"]; v != "" {
+		p.width, _ = strconv.Atoi(v)
+		if p.width < 1 || p.width > 500 {
+			p.width = defaultWidth
+		}
+	}
+
+	if p.contrast != 0 {
+		img = applyContrast(img)
+	}
+
+	height := int(float64(p.width) * (float64(img.Bounds().Dy()) / float64(img.Bounds().Dx())) * asciiHeightScale)
+	if height < 1 {
+		height = 1
+	}
+
+	resized, err := resize(img, p.width, height)
+	if err != nil {
+		return "", err
+	}
+
+	m, ok := resized.(*image.RGBA)
+	if !ok {
+		return "", errors.New("failed to prepare image buffer")
+	}
+
+	switch p.grayscale {
+	case 1:
+		grayscale(m)
+	case 2:
+		monochrome(m)
+	}
+
+	var buffer bytes.Buffer
+	next := nextChar(p.texttype, p.characters)
+	b := m.Bounds()
+
+	buffer.WriteString(`<div class="ascii-frame"><pre class="ascii-art">`)
+	for y := b.Min.Y; y < b.Max.Y; y++ {
+		j := m.PixOffset(0, y)
+		current := uint(m.Pix[j+0])<<16 | uint(m.Pix[j+1])<<8 | uint(m.Pix[j+2])
+		buffer.WriteString(`<span style="color:#`)
+		buffer.WriteString(hex(current))
+		buffer.WriteString(`">`)
+		buffer.WriteString(html.EscapeString(next()))
+		previous := current
+
+		for x := b.Min.X + 1; x < b.Max.X; x++ {
+			i := m.PixOffset(x, y)
+			current = uint(m.Pix[i+0])<<16 | uint(m.Pix[i+1])<<8 | uint(m.Pix[i+2])
+			if previous != current {
+				buffer.WriteString(`</span><span style="color:#`)
+				buffer.WriteString(hex(current))
+				buffer.WriteString(`">`)
+			}
+			buffer.WriteString(html.EscapeString(next()))
+			previous = current
+		}
+
+		buffer.WriteString("</span>\n")
+	}
+	buffer.WriteString("</pre></div>")
+
+	return template.HTML(buffer.String()), nil
+}
+
+func applyContrast(img image.Image) image.Image {
+	src, ok := img.(*image.RGBA)
+	if !ok {
+		cloned, err := resize(img, img.Bounds().Dx(), img.Bounds().Dy())
+		if err != nil {
+			return img
+		}
+		src, _ = cloned.(*image.RGBA)
+	}
+
+	if src == nil {
+		return img
+	}
+
+	out := image.NewRGBA(src.Bounds())
+	copy(out.Pix, src.Pix)
+
+	for i := 0; i < len(out.Pix); i += 4 {
+		out.Pix[i+0] = contrastValue(out.Pix[i+0])
+		out.Pix[i+1] = contrastValue(out.Pix[i+1])
+		out.Pix[i+2] = contrastValue(out.Pix[i+2])
+	}
+
+	return out
+}
+
+func contrastValue(v uint8) uint8 {
+	n := (float64(v) - 128.0) * 1.22
+	n += 128.0
+	if n < 0 {
+		return 0
+	}
+	if n > 255 {
+		return 255
+	}
+	return uint8(n)
+}
+
+func main() {
+	logger := log.New(os.Stdout, "", log.LstdFlags)
+	app := newApp(logger)
+	logger.Printf("ASCII server listening on http://localhost:%s", app.port)
+	if err := http.ListenAndServe(":"+app.port, app.routes()); err != nil {
+		logger.Fatal(err)
+	}
+}
+
+type app struct {
+	logger     *log.Logger
+	httpClient *http.Client
+	port       string
+	demoPath   string
+}
+
+func newApp(logger *log.Logger) *app {
+	transport := &http.Transport{
+		Proxy:                 http.ProxyFromEnvironment,
+		DialContext:           safeDialContext,
+		ForceAttemptHTTP2:     true,
+		MaxIdleConns:          20,
+		IdleConnTimeout:       30 * time.Second,
+		TLSHandshakeTimeout:   5 * time.Second,
+		ResponseHeaderTimeout: 5 * time.Second,
+		ExpectContinueTimeout: 1 * time.Second,
+	}
+
+	client := &http.Client{
+		Transport: transport,
+		Timeout:   requestTimeout,
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			if len(via) >= maxRedirects {
+				return errors.New("too many redirects")
+			}
+			return validateRemoteURL(req.URL)
+		},
+	}
+
+	return &app{
+		logger:     logger,
+		httpClient: client,
+		port:       envOrDefault("PORT", "8080"),
+		demoPath:   filepath.Join("img", "cat.png"),
+	}
+}
+
+func (a *app) routes() http.Handler {
+	mux := http.NewServeMux()
+	mux.Handle("/img/", http.StripPrefix("/img/", http.FileServer(http.Dir("img"))))
+	mux.HandleFunc("/", a.handleHome)
+	return mux
+}
+
+func (a *app) handleHome(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	sourceURL := strings.TrimSpace(r.URL.Query().Get("url"))
+	start := time.Now()
+
+	img, sourceLabel, sourceDisplay, err := a.loadImage(r.Context(), sourceURL)
+	if err != nil {
+		a.renderPage(w, http.StatusBadRequest, pageData{
+			Title:       "ASCII Terminal",
+			SourceLabel: "source:error",
+			SourceURL:   sourceDisplay,
+			StatusLine:  "status:error",
+			Error:       err.Error(),
+		})
+		return
+	}
+
+	ascii, err := convertImage(img, map[string]string{
+		"width":      strconv.Itoa(defaultWidth),
+		"textType":   "sequence",
+		"characters": defaultCharacters,
+	})
+	if err != nil {
+		a.renderPage(w, http.StatusInternalServerError, pageData{
+			Title:       "ASCII Terminal",
+			SourceLabel: sourceLabel,
+			SourceURL:   sourceDisplay,
+			StatusLine:  "status:error",
+			Error:       "could not render image as ASCII",
+		})
+		return
+	}
+
+	a.renderPage(w, http.StatusOK, pageData{
+		Title:       "ASCII Terminal",
+		ASCII:       ascii,
+		SourceLabel: sourceLabel,
+		SourceURL:   sourceDisplay,
+		StatusLine:  fmt.Sprintf("status:rendered in %s", time.Since(start).Round(time.Millisecond)),
+	})
+}
+
+func (a *app) loadImage(ctx context.Context, sourceURL string) (image.Image, string, string, error) {
+	if sourceURL == "" {
+		f, err := os.Open(a.demoPath)
+		if err != nil {
+			return nil, "source: cat.png", "/img/cat.png", errors.New("demo image is unavailable")
+		}
+		defer f.Close()
+
+		img, _, err := image.Decode(f)
+		if err != nil {
+			return nil, "source: cat.png", "/img/cat.png", errors.New("demo image is invalid")
+		}
+		return img, "source: cat.png", "/img/cat.png", nil
+	}
+
+	u, err := url.Parse(sourceURL)
+	if err != nil {
+		return nil, "source: remote", sourceURL, errors.New("invalid image URL")
+	}
+	if err := validateRemoteURL(u); err != nil {
+		return nil, "source: remote", sourceURL, err
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+	if err != nil {
+		return nil, "source: remote", sourceURL, errors.New("invalid image request")
+	}
+	req.Header.Set("User-Agent", "ascii-terminal/1.0")
+	req.Header.Set("Accept", "image/*")
+
+	resp, err := a.httpClient.Do(req)
+	if err != nil {
+		return nil, "source: remote", sourceURL, errors.New("failed to fetch remote image")
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil, "source: remote", sourceURL, fmt.Errorf("image request returned %s", resp.Status)
+	}
+
+	contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
+	if contentType != "" && !strings.HasPrefix(contentType, "image/") {
+		return nil, "source: remote", sourceURL, errors.New("remote content is not an image")
+	}
+
+	limited := io.LimitReader(resp.Body, maxImageBytes+1)
+	body, err := io.ReadAll(limited)
+	if err != nil {
+		return nil, "source: remote", sourceURL, errors.New("failed to read remote image")
+	}
+	if len(body) > maxImageBytes {
+		return nil, "source: remote", sourceURL, errors.New("remote image is too large")
+	}
+
+	img, _, err := image.Decode(bytes.NewReader(body))
+	if err != nil {
+		return nil, "source: remote", sourceURL, errors.New("failed to decode remote image")
+	}
+
+	return img, "source: remote", sourceURL, nil
+}
+
+func (a *app) renderPage(w http.ResponseWriter, status int, data pageData) {
+	if data.Title == "" {
+		data.Title = "ASCII Terminal"
+	}
+	if data.SourceLabel == "" {
+		data.SourceLabel = "source:unknown"
+	}
+	if data.SourceURL == "" {
+		data.SourceURL = "/img/cat.png"
+	}
+	if data.StatusLine == "" {
+		data.StatusLine = "status:idle"
+	}
+
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	w.WriteHeader(status)
+
+	if err := pageTemplate.Execute(w, data); err != nil {
+		a.logger.Printf("render error: %v", err)
+	}
+}
+
+func envOrDefault(key, fallback string) string {
+	if v := strings.TrimSpace(os.Getenv(key)); v != "" {
+		return v
+	}
+	return fallback
+}
+
+func validateRemoteURL(u *url.URL) error {
+	if u == nil {
+		return errors.New("invalid image URL")
+	}
+	if u.Scheme != "http" && u.Scheme != "https" {
+		return errors.New("image URL must use http or https")
+	}
+	if u.Host == "" {
+		return errors.New("image URL must include a host")
+	}
+
+	host := strings.TrimSpace(u.Hostname())
+	if host == "" {
+		return errors.New("image URL must include a host")
+	}
+	if isBlockedHostname(host) {
+		return errors.New("target host is not allowed")
+	}
+
+	ips, err := net.DefaultResolver.LookupNetIP(context.Background(), "ip", host)
+	if err != nil {
+		return errors.New("failed to resolve image host")
+	}
+	if len(ips) == 0 {
+		return errors.New("failed to resolve image host")
+	}
+	for _, ip := range ips {
+		if !ip.IsValid() || isPrivateOrLocal(ip) {
+			return errors.New("target host is not allowed")
+		}
+	}
+
+	return nil
+}
+
+func isBlockedHostname(host string) bool {
+	host = strings.ToLower(strings.TrimSuffix(host, "."))
+	if host == "localhost" || strings.HasSuffix(host, ".localhost") {
+		return true
+	}
+
+	if addr, err := netip.ParseAddr(host); err == nil {
+		return isPrivateOrLocal(addr)
+	}
+
+	return false
+}
+
+func isPrivateOrLocal(ip netip.Addr) bool {
+	return ip.IsLoopback() ||
+		ip.IsPrivate() ||
+		ip.IsLinkLocalUnicast() ||
+		ip.IsLinkLocalMulticast() ||
+		ip.IsMulticast() ||
+		ip.IsUnspecified()
+}
+
+func safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
+	host, _, err := net.SplitHostPort(address)
+	if err != nil {
+		return nil, err
+	}
+	if isBlockedHostname(host) {
+		return nil, errors.New("blocked host")
+	}
+
+	ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
+	if err != nil {
+		return nil, err
+	}
+	for _, ip := range ips {
+		if isPrivateOrLocal(ip) {
+			return nil, errors.New("blocked host")
+		}
+	}
+
+	var d net.Dialer
+	return d.DialContext(ctx, network, address)
+}
+
+var pageTemplate = template.Must(template.New("page").Parse(`<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>{{.Title}}</title>
+  <style>
+    :root {
+      --bg: #020202;
+      --panel: rgba(255, 255, 255, 0.04);
+      --line: rgba(255, 255, 255, 0.1);
+      --text: #f3f3ef;
+      --muted: #b7b7b0;
+      --error: #ffb4a8;
+    }
+
+    * { box-sizing: border-box; }
+
+    body {
+      margin: 0;
+      min-height: 100vh;
+      background:
+        radial-gradient(circle at top left, rgba(255,255,255,0.05), transparent 28rem),
+        linear-gradient(180deg, rgba(255,255,255,0.03), transparent 18rem),
+        var(--bg);
+      color: var(--text);
+      font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
+    }
+
+    .shell {
+      position: relative;
+      min-height: 100vh;
+      padding: 2rem 1.25rem 2rem;
+    }
+
+    .meta {
+      margin-bottom: 1.25rem;
+      max-width: min(34rem, 100%);
+      color: var(--muted);
+      font-size: 0.82rem;
+      line-height: 1.55;
+      text-align: left;
+      white-space: pre-line;
+    }
+
+    .terminal {
+      margin-bottom: 1.5rem;
+      color: var(--text);
+      font-size: 0.82rem;
+      line-height: 1.6;
+      max-width: min(42rem, 100%);
+    }
+
+    .terminal-output {
+      white-space: pre-wrap;
+      word-break: break-word;
+    }
+
+    .terminal-line {
+      display: inline-flex;
+      align-items: center;
+      gap: 0.08ch;
+      white-space: nowrap;
+      max-width: 100%;
+      position: relative;
+      cursor: text;
+      border: 0;
+      outline: none;
+    }
+
+    .terminal-prompt {
+      color: var(--muted);
+      flex: 0 0 auto;
+    }
+
+    .terminal-line:focus-visible {
+      outline: 1px solid rgba(255, 255, 255, 0.18);
+      outline-offset: 0.22rem;
+    }
+
+    .terminal-current {
+      display: inline;
+      min-width: 0;
+      color: var(--text);
+    }
+
+    .terminal-cursor {
+      display: inline-block;
+      width: 0.62ch;
+      height: 1.05em;
+      background: var(--text);
+      vertical-align: middle;
+      animation: blink 1s steps(1, end) infinite;
+    }
+
+    .canvas {
+      width: max-content;
+      max-width: 100%;
+      margin: 0;
+      padding: 0;
+      overflow: auto;
+    }
+
+    .ascii-frame {
+      display: inline-block;
+    }
+
+    .ascii-art {
+      margin: 0;
+      color: var(--text);
+      font-size: clamp(0.34rem, 0.55vw, 0.6rem);
+      line-height: 1;
+      white-space: pre;
+    }
+
+    .error {
+      color: var(--error);
+      font-size: 0.95rem;
+      line-height: 1.6;
+      white-space: pre-wrap;
+    }
+
+    @media (max-width: 900px) {
+      .shell {
+        padding-top: 0.75rem;
+      }
+
+      .meta {
+        text-align: left;
+      }
+
+      .canvas {
+        margin-left: 0;
+      }
+    }
+
+    @keyframes blink {
+      0%, 49% { opacity: 1; }
+      50%, 100% { opacity: 0; }
+    }
+  </style>
+</head>
+<body>
+  <main class="shell">
+    <div class="meta">goASCII - developed by Pablo Murad - 2026
+{{.SourceLabel}}
+{{.StatusLine}}</div>
+
+    <section class="terminal" aria-label="terminal prompt">
+      <div class="terminal-output" id="terminal-output">Type help for usage.</div>
+      <div id="terminal-line" class="terminal-line" tabindex="0" role="textbox" aria-label="terminal input" aria-multiline="false">
+        <span class="terminal-prompt">guest@goascii:~$</span>
+        <span id="terminal-current" class="terminal-current"></span>
+        <span class="terminal-cursor" aria-hidden="true"></span>
+      </div>
+    </section>
+
+    <section class="canvas" aria-live="polite">
+      {{if .Error}}
+        <div class="error">{{.Error}}
+
+source: {{.SourceURL}}</div>
+      {{else}}
+        {{.ASCII}}
+      {{end}}
+    </section>
+  </main>
+  <script>
+    (() => {
+      const line = document.getElementById("terminal-line");
+      const output = document.getElementById("terminal-output");
+      const current = document.getElementById("terminal-current");
+      if (!line || !output || !current) return;
+
+      const promptLabel = "guest@goascii:~$";
+      const helpText = [
+        "goASCII help",
+        "",
+        "Render ASCII from the current page URL.",
+        "Append ?url=https://... to this site address.",
+        "Accepted sources: public http/https image URLs.",
+        "Example: /?url=https://example.com/cat.png",
+        "Available commands:",
+        "  help  Show this help message."
+      ].join("\n");
+      let command = "";
+
+      const renderLines = (lines) => {
+        output.textContent = lines.join("\n\n");
+      };
+
+      const syncCurrentValue = () => {
+        current.textContent = command;
+      };
+
+      const shouldIgnoreKey = (event) => {
+        if (event.ctrlKey || event.metaKey || event.altKey) return true;
+        const target = event.target;
+        if (!target) return false;
+        const tagName = target.tagName;
+        return tagName === "INPUT" || tagName === "TEXTAREA" || target.isContentEditable;
+      };
+
+      const handleCommand = (rawValue) => {
+        const value = rawValue.trim();
+        if (!value) return;
+
+        const lines = [promptLabel + " " + value];
+        if (value.toLowerCase() === "help") {
+          lines.push(helpText);
+        } else {
+          lines.push("unknown command: " + value + "\nType help for usage.");
+        }
+        renderLines(lines);
+      };
+
+      const handleTerminalKeydown = (event) => {
+        if (shouldIgnoreKey(event)) return;
+
+        if (event.key === "Enter") {
+          event.preventDefault();
+          handleCommand(command);
+          command = "";
+          syncCurrentValue();
+          return;
+        }
+
+        if (event.key === "Backspace") {
+          event.preventDefault();
+          command = command.slice(0, -1);
+          syncCurrentValue();
+          return;
+        }
+
+        if (event.key === "Escape") {
+          event.preventDefault();
+          command = "";
+          syncCurrentValue();
+          return;
+        }
+
+        if (event.key.length === 1) {
+          event.preventDefault();
+          command += event.key;
+          syncCurrentValue();
+        }
+      };
+
+      document.addEventListener("keydown", handleTerminalKeydown);
+      document.addEventListener("pointerdown", () => line.focus());
+      line.addEventListener("pointerdown", () => line.focus());
+      window.addEventListener("load", () => {
+        syncCurrentValue();
+        line.focus();
+      });
+    })();
+  </script>
+</body>
+</html>
+`))
@@ -0,0 +1,106 @@+package main
+
+import (
+	"log"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestValidateRemoteURLRejectsBlockedTargets(t *testing.T) {
+	t.Parallel()
+
+	cases := []string{
+		"http://localhost/image.png",
+		"http://127.0.0.1/image.png",
+		"http://[::1]/image.png",
+	}
+
+	for _, raw := range cases {
+		u, err := url.Parse(raw)
+		if err != nil {
+			t.Fatalf("parse %q: %v", raw, err)
+		}
+		if err := validateRemoteURL(u); err == nil {
+			t.Fatalf("expected %q to be blocked", raw)
+		}
+	}
+}
+
+func TestValidateRemoteURLAcceptsPublicHTTPS(t *testing.T) {
+	t.Parallel()
+
+	u, err := url.Parse("https://example.com/image.png")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := validateRemoteURL(u); err != nil {
+		t.Fatalf("expected public URL to be valid: %v", err)
+	}
+}
+
+func TestHandleHomeUsesDemoByDefault(t *testing.T) {
+	t.Parallel()
+
+	app := newApp(log.New(os.Stdout, "", 0))
+	app.demoPath = filepath.Join("img", "cat.png")
+
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	rec := httptest.NewRecorder()
+
+	app.handleHome(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Fatalf("expected status 200, got %d", rec.Code)
+	}
+	body := rec.Body.String()
+	if !strings.Contains(body, "goASCII - developed by Pablo Murad - 2026") {
+		t.Fatalf("expected header text in response")
+	}
+	if strings.Contains(body, "hint: append ?url=https://example.com/image.png") {
+		t.Fatalf("expected old hint to be removed")
+	}
+	if !strings.Contains(body, "source: cat.png") {
+		t.Fatalf("expected cat demo source in response")
+	}
+	if !strings.Contains(body, `id="terminal-line"`) {
+		t.Fatalf("expected terminal line markup in response")
+	}
+	if !strings.Contains(body, "Type help for usage.") {
+		t.Fatalf("expected help prompt text in response")
+	}
+}
+
+func TestHandleHomeRejectsMalformedURL(t *testing.T) {
+	t.Parallel()
+
+	app := newApp(log.New(os.Stdout, "", 0))
+	req := httptest.NewRequest(http.MethodGet, "/?url=://bad-url", nil)
+	rec := httptest.NewRecorder()
+
+	app.handleHome(rec, req)
+
+	if rec.Code != http.StatusBadRequest {
+		t.Fatalf("expected status 400, got %d", rec.Code)
+	}
+	if !strings.Contains(rec.Body.String(), "invalid image URL") {
+		t.Fatalf("expected invalid URL error, got %q", rec.Body.String())
+	}
+}
+
+func TestLoadImageMarksRemoteSources(t *testing.T) {
+	t.Parallel()
+
+	app := newApp(log.New(os.Stdout, "", 0))
+	_, sourceLabel, _, err := app.loadImage(t.Context(), "https://example.invalid/cat.png")
+	if err == nil {
+		t.Fatalf("expected remote image lookup to fail in test")
+	}
+	if sourceLabel != "source: remote" {
+		t.Fatalf("expected remote source label, got %q", sourceLabel)
+	}
+}
@@ -0,0 +1,124 @@+package main
+
+import (
+	"fmt"
+	"image"
+	"image/draw"
+)
+
+func hex(num uint) string {
+	temp := []byte{'0', '0', '0', '0', '0', '0'}
+
+	for counter := 0; num > 0; counter++ {
+		n := uint8(num & 0xF)
+		if n < 10 {
+			temp[5-counter] = '0' + n
+		} else {
+			temp[5-counter] = ('A' - 10) + n
+		}
+		num >>= 4
+	}
+
+	return string(temp)
+}
+
+func gray(r, g, b uint8) uint8 {
+	return r/5 + uint8(uint(g)*7/10) + b/10
+}
+
+func grayscale(img image.Image) {
+	a := img.(*image.RGBA)
+	for i := a.PixOffset(0, 0); i < len(a.Pix); i += 4 {
+		g := gray(a.Pix[i], a.Pix[i+1], a.Pix[i+2])
+		a.Pix[i] = g
+		a.Pix[i+1] = g
+		a.Pix[i+2] = g
+	}
+}
+
+func monochrome(img image.Image) {
+	a := img.(*image.RGBA)
+	for i := a.PixOffset(0, 0); i < len(a.Pix); i += 4 {
+		g := gray(a.Pix[i], a.Pix[i+1], a.Pix[i+2]) / 128 * 255
+		a.Pix[i] = g
+		a.Pix[i+1] = g
+		a.Pix[i+2] = g
+	}
+}
+
+func resize(original image.Image, w, h int) (image.Image, error) {
+	src, ok := original.(*image.RGBA)
+	if !ok {
+		b := original.Bounds()
+		src = image.NewRGBA(b)
+		draw.Draw(src, b, original, b.Min, draw.Src)
+	}
+
+	width := src.Bounds().Dx()
+	height := src.Bounds().Dy()
+
+	if width < 1 || height < 1 {
+		return image.Image(src), fmt.Errorf("image dimensions invalid -- %v, %v", width, height)
+	}
+
+	if h == 0 {
+		h = int(float32(w) / (float32(width) / float32(height)))
+	}
+	if w < 1 || h < 1 {
+		return image.Image(src), fmt.Errorf("resize values invalid -- %v, %v", w, h)
+	}
+
+	dst := image.NewRGBA(image.Rect(0, 0, w, h))
+
+	xRatio := float32(width) / float32(w)
+	yRatio := float32(height) / float32(h)
+
+	if width > w {
+		b := src.Bounds()
+		i := src.PixOffset(0, 0)
+		checklist := make([]bool, len(dst.Pix)>>2)
+
+		for y := b.Min.Y; y < b.Max.Y; y++ {
+			oy := int(float32(y) / yRatio)
+			for x := b.Min.X; x < b.Max.X; x++ {
+				ox := int(float32(x) / xRatio)
+				o := dst.PixOffset(ox, oy)
+
+				if !checklist[o>>2] {
+					checklist[o>>2] = true
+					dst.Pix[o+0] = src.Pix[i+0]
+					dst.Pix[o+1] = src.Pix[i+1]
+					dst.Pix[o+2] = src.Pix[i+2]
+					dst.Pix[o+3] = src.Pix[i+3]
+				} else {
+					dst.Pix[o+0] = uint8((uint64(dst.Pix[o+0]) + uint64(src.Pix[i+0])) >> 1)
+					dst.Pix[o+1] = uint8((uint64(dst.Pix[o+1]) + uint64(src.Pix[i+1])) >> 1)
+					dst.Pix[o+2] = uint8((uint64(dst.Pix[o+2]) + uint64(src.Pix[i+2])) >> 1)
+					dst.Pix[o+3] = uint8((uint64(dst.Pix[o+3]) + uint64(src.Pix[i+3])) >> 1)
+				}
+
+				i += 4
+			}
+		}
+	} else {
+		b := dst.Bounds()
+		i := dst.PixOffset(0, 0)
+
+		for y := b.Min.Y; y < b.Max.Y; y++ {
+			oy := int(float32(y) * yRatio)
+			for x := b.Min.X; x < b.Max.X; x++ {
+				ox := int(float32(x) * xRatio)
+				o := src.PixOffset(ox, oy)
+
+				dst.Pix[i+0] = src.Pix[o+0]
+				dst.Pix[i+1] = src.Pix[o+1]
+				dst.Pix[i+2] = src.Pix[o+2]
+				dst.Pix[i+3] = src.Pix[o+3]
+
+				i += 4
+			}
+		}
+	}
+
+	return dst, nil
+}