File size: 6,916 Bytes
a9f4e61 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 | """Local test server for Quill.
python server.py -> http://127.0.0.1:8792
"""
import json
import os
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
from api import render_query
from quill import INKS, STYLES
PORT = int(os.environ.get("PORT") or (sys.argv[1] if len(sys.argv) > 1 else 8792))
HOST = "0.0.0.0" if "PORT" in os.environ else "127.0.0.1" # Heroku sets PORT
PAGE = """<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Quill</title>
<style>
:root { --bg:#f6f5f1; --fg:#1d1d1f; --muted:#6b6b70; --card:#fff; --line:#dcdad4; --accent:#7a3b1e; }
@media (prefers-color-scheme: dark) { :root { --bg:#16161a; --fg:#ececf0; --muted:#9a9aa3; --card:#202026; --line:#33333b; --accent:#d08a5c; } }
body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.5 system-ui, sans-serif; }
main { max-width:1000px; margin:0 auto; padding:24px 16px 48px; }
h1 { font-size:22px; margin:0 0 4px; } .sub { color:var(--muted); margin:0 0 16px; }
textarea, select { padding:10px 12px; font:inherit; border:1px solid var(--line); border-radius:8px;
background:var(--card); color:var(--fg); }
textarea { width:100%; box-sizing:border-box; min-height:84px; resize:vertical; }
.controls { display:flex; flex-wrap:wrap; gap:12px; align-items:center; margin:10px 0 0; }
label { display:flex; gap:6px; align-items:center; color:var(--muted); }
button { padding:10px 18px; font:inherit; border:0; border-radius:8px; background:var(--accent); color:#fff; cursor:pointer; }
button.ghost { background:var(--card); color:var(--fg); border:1px solid var(--line); }
button:disabled { opacity:.5; cursor:wait; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:12px; }
.chip { padding:4px 10px; border:1px solid var(--line); border-radius:999px; background:var(--card); cursor:pointer; font-size:13px; }
#out { margin-top:20px; display:grid; gap:16px; }
figure { margin:0; } figure img { max-width:100%; display:block; border-radius:10px; border:1px solid var(--line); }
figcaption { color:var(--muted); font-size:13px; margin-top:4px; display:flex; gap:12px; }
figcaption a { color:var(--accent); }
#status { color:var(--muted); margin-top:12px; min-height:1.5em; }
</style></head><body><main>
<h1>Quill</h1>
<p class="sub">Calligraphy with hand-set pens over public stroke data (Hershey letters, KanjiVG kanji and kana). No training. Kanji or kana switch to the shodo brush automatically.</p>
<textarea id="t">Ars longa, vita brevis</textarea>
<div class="controls">
<label>Style <select id="style">__STYLES__</select></label>
<label>Ink <select id="ink"><option value="">style default</option>__INKS__</select></label>
<label>Size <input id="size" type="range" min="32" max="140" value="72"><span id="sv">72</span>px</label>
<label><input id="fl" type="checkbox"> Flourish</label>
<label><input id="ini" type="checkbox"> Red initial</label>
<label>Seal <input id="seal" maxlength="4" size="4" placeholder="和 / VK"></label>
<label><input id="anim" type="checkbox"> Animate</label>
<button id="go">Write</button>
<button id="all" class="ghost">All styles</button>
</div>
<div class="chips" id="chips"></div>
<div id="status"></div>
<div id="out"></div>
</main><script>
const $ = id => document.getElementById(id);
const demos = ["Ars longa, vita brevis", "Happy Birthday\\nwith love from all of us", "Here beginneth the tale",
"With kind regards", "Quiet rain on the lake", "一期一会\\n花鳥風月", "永", "夢", "ありがとう"];
demos.forEach(d => { const c = document.createElement('span'); c.className = 'chip'; c.textContent = d.replace('\\n', ' / ');
c.onclick = () => { $('t').value = d; write([$('style').value]); }; $('chips').appendChild(c); });
$('size').oninput = () => $('sv').textContent = $('size').value;
async function write(styles) {
$('go').disabled = $('all').disabled = true; $('out').innerHTML = '';
for (const s of styles) {
$('status').textContent = 'Writing ' + s + '…';
const q = new URLSearchParams({ text: $('t').value, style: s, ink: $('ink').value, size: $('size').value,
flourish: $('fl').checked ? '1' : '', initial: $('ini').checked ? '1' : '',
seal: $('seal').value, animate: $('anim').checked ? '1' : '' });
const t0 = performance.now();
const r = await fetch('/render?' + q);
const j = await r.json();
if (!r.ok) { $('status').textContent = j.error; break; }
const f = document.createElement('figure');
f.innerHTML = '<img alt=""><figcaption><span></span><a download>Download PNG</a></figcaption>';
f.querySelector('img').src = f.querySelector('a').href = j.src;
const ext = j.src.slice(11, j.src.indexOf(';'));
f.querySelector('a').download = 'quill_' + s + '.' + ext;
f.querySelector('a').textContent = ext === 'png' ? 'Download PNG' : 'Download animation';
f.querySelector('span').textContent = (j.style || s) + ' · ' + ((performance.now() - t0) / 1000).toFixed(1) + ' s';
$('out').appendChild(f);
}
$('status').textContent = ''; $('go').disabled = $('all').disabled = false;
}
$('go').onclick = () => write([$('style').value]);
$('all').onclick = () => write(/[\\u3040-\\u9fff]/.test($('t').value) ? ['shodo'] : __ALL__.filter(s => s !== 'shodo'));
write([$('style').value]);
</script></body></html>"""
def page():
opts = lambda xs: "".join(f'<option value="{x}">{x}</option>' for x in xs)
return (PAGE.replace("__STYLES__", opts(STYLES)).replace("__INKS__", opts(INKS))
.replace("__ALL__", json.dumps(list(STYLES))))
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # HTTP/1.0 closes right after writing; on this Windows box that RSTs larger bodies
def send(self, body, ctype, code=200):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
url = urlparse(self.path)
if url.path == "/":
self.send(page().encode(), "text/html; charset=utf-8")
elif url.path == "/render":
code, body = render_query(url.query)
self.send(json.dumps(body).encode(), "application/json", code)
else:
self.send(b"not found", "text/plain", 404)
def log_message(self, *a):
pass
if __name__ == "__main__":
print(f"Quill on http://{HOST}:{PORT}", flush=True)
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|