Handwritten character recognition (HCR) has become a key technology in many modern applications such as digital notetaking, form processing, and assistive devices. While the problem has been tackled for decades, the rise of webbased platforms demands solutions that can operate entirely in a browser, without relying on serverside processing. The Lipi Toolkitan opensource JavaScript libraryprovides exactly that: a clientside engine capable of recognizing handwriting on HTML canvas elements in real time.
Traditional HCR pipelines often require heavy preprocessing, feature extraction, and classification stages that are executed on powerful backend servers. This approach introduces latency, dataprivacy concerns, and dependence on network connectivity. An online, clientside implementation offers several advantages:
Lipi Toolkit (formerly Lipi.js) is a lightweight JavaScript library that implements a trained neural network for recognizing a wide range of handwritten characters, including:
The library is built on top of TensorFlow.js, allowing it to run entirely in the browser using WebGL for accelerated computation. Lipi provides a simple API to initialize a canvas, capture strokes, and obtain the predicted character.
<canvas> element where users write characters.<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.9.0"></script><script src="https://cdn.jsdelivr.net/npm/lipi-toolkit@2.1.0/dist/lipi.min.js"></script> Both scripts are served from a CDN, keeping the page size small.
<canvas id="drawArea" width="300" height="300" style="border:1px solid #ccc; border-radius:4px;"></canvas> Using simple mouse and touch listeners we store points in an array. The snippet below works for both desktop and mobile devices.
const canvas = document.getElementById('drawArea');const ctx = canvas.getContext('2d');let drawing = false;let points = [];// Start drawingfunction start(event) { drawing = true; points = []; const pos = getPos(event); points.push(pos); ctx.beginPath(); ctx.moveTo(pos.x, pos.y);}// Continue drawingfunction move(event) { if (!drawing) return; const pos = getPos(event); points.push(pos); ctx.lineTo(pos.x, pos.y); ctx.strokeStyle = '#000'; ctx.lineWidth = 8; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke();}// End drawingfunction end() { drawing = false; ctx.closePath(); recognize(); // Trigger recognition after each stroke}// Utility to get cursor/touch position relative to canvasfunction getPos(e) { const rect = canvas.getBoundingClientRect(); const touch = e.touches ? e.touches[0] : e; return { x: touch.clientX - rect.left, y: touch.clientY - rect.top };}// Attach listenerscanvas.addEventListener('mousedown', start);canvas.addEventListener('mousemove', move);canvas.addEventListener('mouseup', end);canvas.addEventListener('mouseleave', end);canvas.addEventListener('touchstart', start);canvas.addEventListener('touchmove', move);canvas.addEventListener('touchend', end); Lipi expects a 2828 pixel grayscale image (the same size used for the MNIST dataset). The following function extracts the canvas content, resizes it, and normalizes pixel values.
function getImageData() { // Create an offscreen canvas for resizing const off = document.createElement('canvas'); off.width = off.height = 28; const offCtx = off.getContext('2d'); // Fill background with white (important for contrast) offCtx.fillStyle = '#fff'; offCtx.fillRect(0, 0, 28, 28); // Draw the original canvas into the offscreen canvas, scaling down offCtx.drawImage(canvas, 0, 0, 28, 28); // Get pixel data and convert to a Float32Array normalized to [0,1] const imgData = offCtx.getImageData(0, 0, 28, 28); const data = imgData.data; const gray = new Float32Array(28 * 28); for (let i = 0, j = 0; i < data.length; i += 4, j++) { // Convert RGB to luminosity; canvas is black on white, so invert const lum = (0.299 * data[i] + 0.587 * data[i+1] + 0.114 * data[i+2]) / 255; gray[j] = 1 - lum; // Invert: dark strokes become high values } return tf.tensor(gray, [1, 28, 28, 1]);} Lipi provides a predict method that returns an object containing the predicted character and confidence.
async function recognize() { const inputTensor = getImageData(); // Lipi's model is loaded automatically on first call const result = await Lipi.predict(inputTensor); displayResult(result); // Clean up tensors to avoid memory leaks tf.dispose(inputTensor);} function displayResult(res) { const out = document.getElementById('output'); out.innerHTML = `Prediction: ${res.char} Confidence: ${(res.probability*100).toFixed(2)}%`;} The following snippet combines all the pieces into a single, functional page. Copy it into an .html file and open it with any modern browser.
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <title>Handwritten Character Recognition with Lipi</title> <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.9.0"></script> <script src="https://cdn.jsdelivr.net/npm/lipi-toolkit@2.1.0/dist/lipi.min.js"></script> <style> body{font-family:Arial,Helvetica,sans-serif;margin:20px;background:#f9f9f9;color:#333} #drawArea{border:1px solid #ccc;border-radius:4px;cursor:crosshair} #output{margin-top:10px;font-size:1.2em} </style></head><body> <h1>Online Handwritten Character Recognition</h1> <canvas id="drawArea" width="300" height="300"></canvas> <div id="output">Write a character above</div> <script> const canvas = document.getElementById('drawArea'); const ctx = canvas.getContext('2d'); let drawing = false; let points = []; function getPos(e){ const rect = canvas.getBoundingClientRect(); const touch = e.touches ? e.touches[0] : e; return {x:touch.clientX-rect.left, y:touch.clientY-rect.top}; } function start(e){ drawing=true; points=[]; const p=getPos(e); points.push(p); ctx.beginPath(); ctx.moveTo(p.x,p.y); } function move(e){ if(!drawing) return; const p=getPos(e); points.push(p); ctx.lineTo(p.x,p.y); ctx.strokeStyle='#000'; ctx.lineWidth=8; ctx.lineCap='round'; ctx.lineJoin='round'; ctx.stroke(); } function end(){ drawing=false; ctx.closePath(); recognize(); } canvas.addEventListener('mousedown',start); canvas.addEventListener('mousemove',move); canvas.addEventListener('mouseup',end); canvas.addEventListener('mouseleave',end); canvas.addEventListener('touchstart',start); canvas.addEventListener('touchmove',move); canvas.addEventListener('touchend',end); function getImageData(){ const off=document.createElement('canvas'); off.width=off.height=28; const offCtx=off.getContext('2d'); offCtx.fillStyle='#fff'; offCtx.fillRect(0,0,28,28); offCtx.drawImage(canvas,0,0,28,28); const img=offCtx.getImageData(0,0,28,28); const data=img.data; const gray=new Float32Array(28*28); for(let i=0,j=0;i${result.char} Confidence: ${(result.probability*100).toFixed(2)}%`; tf.dispose(tensor); } </script></body></html> The minimal example above can be enriched in many ways:
tfjs creating a personalized recognizer.event.preventDefault() inside the touch listeners.Lipi Toolkit makes it remarkably easy to embed handwritten character recognition directly into a web page. By leveraging TensorFlow.js, the heavy lifting of neuralnetwork inference happens on the client, giving users instant feedback while keeping their data private. The example provided demonstrates a clean, lightweight implementation that can serve as a foundation for more sophisticated applications such as notetaking apps, educational tools, or multilingual input methods.
With a few enhancementssupport for multiple scripts, realtime confidence visualizations, or userdriven model adaptationdevelopers can build robust, productionready HCR experiences using only standard web technologies.
