| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Granite Docling Image Converter</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> |
| <style> |
| body { |
| font-family: "Inter", sans-serif; |
| } |
| .loader { |
| border-top-color: #3498db; |
| animation: spin 1s linear infinite; |
| } |
| .loader-large { |
| border: 8px solid #e5e7eb; |
| border-top: 8px solid #3498db; |
| animation: spin 1s linear infinite; |
| } |
| .loader-small { |
| border: 4px solid #e5e7eb; |
| border-top: 4px solid #3498db; |
| animation: spin 1s linear infinite; |
| } |
| @keyframes spin { |
| 0% { |
| transform: rotate(0deg); |
| } |
| 100% { |
| transform: rotate(360deg); |
| } |
| } |
| |
| .toggle-checkbox:checked { |
| right: 0; |
| border-color: #4f46e5; |
| } |
| .toggle-checkbox:checked + .toggle-label { |
| background-color: #4f46e5; |
| } |
| .overlay { |
| border: 2px solid var(--overlay-color); |
| transition: background-color 0.2s; |
| } |
| .overlay:hover { |
| background-color: rgba(var(--overlay-color-rgb), 0.7); |
| } |
| </style> |
| </head> |
| <body class="bg-gray-100 text-gray-800 antialiased"> |
| <div id="model-loader-overlay" class="fixed inset-0 bg-black bg-opacity-60 flex flex-col items-center justify-center z-50"> |
| <div class="loader-large ease-linear rounded-full h-24 w-24 mb-4"></div> |
| <h2 class="text-center text-white text-xl font-semibold">Loading Model...</h2> |
| <p class="text-center text-white text-md mt-2">This may take a moment. The model is being downloaded to your browser.</p> |
| <progress id="model-progress" value="0" max="100" class="w-64 mt-4 bg-gray-200 rounded-full h-2"></progress> |
| <p id="progress-text" class="text-center text-white text-sm mt-2">0%</p> |
| </div> |
|
|
| <main class="container mx-auto p-4 md:p-8"> |
| <header class="text-center mb-8"> |
| <h1 class="text-4xl font-bold text-gray-900">Granite Docling WebGPU</h1> |
| <p class="text-lg text-gray-600 mt-2">Convert document images to HTML using 🤗 Transformers.js!</p> |
| </header> |
|
|
| <div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> |
| |
| <div class="bg-white p-6 rounded-lg shadow-md"> |
| <h2 class="text-2xl font-semibold mb-4">1. Select an Image</h2> |
|
|
| <div |
| id="image-drop-area" |
| class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer transition-colors duration-200 hover:border-indigo-500 hover:bg-indigo-50" |
| > |
| <div id="image-placeholder"> |
| <svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true"> |
| <path |
| d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8" |
| stroke-width="2" |
| stroke-linecap="round" |
| stroke-linejoin="round" |
| /> |
| </svg> |
| <p class="mt-2 text-sm text-gray-600"> |
| <span class="font-semibold text-indigo-600">Drag and drop</span> |
| or click to select a file |
| </p> |
| <p class="text-xs text-gray-500">PNG, JPG, WEBP</p> |
| <input type="file" id="file-input" class="hidden" accept="image/*" /> |
| </div> |
| <div id="image-preview-container" class="hidden relative"> |
| <img id="image-preview" src="" alt="Selected image" class="mx-auto rounded-md shadow-sm" /> |
| <button |
| id="remove-image-btn" |
| class="absolute top-2 right-2 z-10 bg-red-500 text-white rounded-full p-2 hover:bg-red-600 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" |
| > |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> |
| <path |
| fill-rule="evenodd" |
| d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" |
| clip-rule="evenodd" |
| /> |
| </svg> |
| </button> |
| </div> |
| </div> |
|
|
| <div class="mt-4 flex"> |
| <input |
| type="text" |
| id="prompt-input" |
| class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" |
| value="Convert this page to docling." |
| /> |
| <button |
| id="generate-btn" |
| class="ml-2 px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 disabled:bg-gray-400 disabled:cursor-not-allowed" |
| > |
| Generate |
| </button> |
| </div> |
|
|
| <h3 class="text-lg font-semibold mt-6 mb-3" id="examples-title">Or try an example:</h3> |
| <div class="flex space-x-4 overflow-x-auto" id="examples-container"> |
| <img |
| src="./assets/document.png" |
| class="example-image h-36 w-auto border-2 border-gray-200 rounded-md cursor-pointer hover:border-indigo-500 transition-colors" |
| alt="Example document" |
| data-prompt="Convert this page to docling." |
| title="Document parsing" |
| /> |
| <img |
| src="./assets/chart.png" |
| class="example-image h-36 w-auto border-2 border-gray-200 rounded-md cursor-pointer hover:border-indigo-500 transition-colors" |
| alt="Example chart" |
| data-prompt="Convert chart to OTSL." |
| title="Chart parsing" |
| /> |
| <img |
| src="./assets/table.jpg" |
| class="example-image h-36 w-auto border-2 border-gray-200 rounded-md cursor-pointer hover:border-indigo-500 transition-colors" |
| alt="Example table" |
| data-prompt="Convert this table to OTSL." |
| title="Table parsing" |
| /> |
| <img |
| src="./assets/code.jpg" |
| class="example-image h-36 w-auto border-2 border-gray-200 rounded-md cursor-pointer hover:border-indigo-500 transition-colors" |
| alt="Example code" |
| data-prompt="Convert code to text." |
| title="Code parsing" |
| /> |
| </div> |
| </div> |
|
|
| |
| <div class="bg-white p-6 rounded-lg shadow-md flex flex-col"> |
| <div class="flex justify-between items-center mb-4"> |
| <h2 class="text-2xl font-semibold">2. View Result</h2> |
| <div id="processing-indicator" class="flex items-center space-x-2 text-gray-500 hidden"> |
| <div class="loader-small ease-linear rounded-full h-6 w-6"></div> |
| <p class="text-sm">Processing image...</p> |
| </div> |
| <div class="flex items-center space-x-2"> |
| <span class="text-sm font-medium">Docling</span> |
| <div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in"> |
| <input |
| type="checkbox" |
| name="toggle" |
| id="view-toggle" |
| class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer" |
| checked |
| /> |
| <label for="view-toggle" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label> |
| </div> |
| <span class="text-sm font-medium text-indigo-600">HTML</span> |
| </div> |
| </div> |
|
|
| <div id="output-container" class="flex-1 border border-gray-200 rounded-lg overflow-hidden bg-gray-50"> |
| <div id="welcome-message" class="h-full flex items-center justify-center text-center text-gray-500"> |
| <p>Select an image to see the result here.</p> |
| </div> |
|
|
| |
| <div id="docling-view" class="h-full p-4 hidden"> |
| <pre class="h-full whitespace-pre-wrap text-sm overflow-auto"><code id="docling-output"></code></pre> |
| </div> |
|
|
| |
| <div id="html-view" class="h-full w-full"> |
| <iframe id="html-iframe" sandbox="allow-scripts" class="w-full h-full border-0"></iframe> |
| </div> |
| </div> |
| </div> |
| </div> |
| </main> |
|
|
| |
| <canvas id="hidden-canvas" class="hidden"></canvas> |
|
|
| <script type="module"> |
| import { AutoProcessor, AutoModelForVision2Seq, RawImage, TextStreamer, load_image } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.5"; |
| import { doclingToHtml } from "./parser.js"; |
| |
| const modelLoaderOverlay = document.getElementById("model-loader-overlay"); |
| const imageDropArea = document.getElementById("image-drop-area"); |
| const imagePlaceholder = document.getElementById("image-placeholder"); |
| const imagePreviewContainer = document.getElementById("image-preview-container"); |
| const imagePreview = document.getElementById("image-preview"); |
| const removeImageBtn = document.getElementById("remove-image-btn"); |
| const fileInput = document.getElementById("file-input"); |
| const exampleImages = document.querySelectorAll(".example-image"); |
| const examplesContainer = document.getElementById("examples-container"); |
| const examplesTitle = document.getElementById("examples-title"); |
| |
| const processingIndicator = document.getElementById("processing-indicator"); |
| const welcomeMessage = document.getElementById("welcome-message"); |
| const doclingView = document.getElementById("docling-view"); |
| const htmlView = document.getElementById("html-view"); |
| const doclingOutput = document.getElementById("docling-output"); |
| const htmlIframe = document.getElementById("html-iframe"); |
| const viewToggle = document.getElementById("view-toggle"); |
| const hiddenCanvas = document.getElementById("hidden-canvas"); |
| const promptInput = document.getElementById("prompt-input"); |
| const generateBtn = document.getElementById("generate-btn"); |
| |
| let model, processor; |
| let currentImageWidth, currentImageHeight; |
| let currentImage = null; |
| |
| |
| |
| |
| async function initializeModel() { |
| try { |
| const model_id = "onnx-community/granite-docling-258M-ONNX"; |
| processor = await AutoProcessor.from_pretrained(model_id); |
| |
| const progress = {}; |
| model = await AutoModelForVision2Seq.from_pretrained(model_id, { |
| dtype: { |
| embed_tokens: "fp16", |
| vision_encoder: "fp32", |
| decoder_model_merged: "fp32", |
| }, |
| device: "webgpu", |
| progress_callback: (data) => { |
| if (data.status === "progress" && data.file?.endsWith?.("onnx_data")) { |
| progress[data.file] = data; |
| const progressPercent = Math.round(data.progress); |
| |
| if (Object.keys(progress).length !== 3) return; |
| let sum = 0; |
| let total = 0; |
| for (const [key, val] of Object.entries(progress)) { |
| sum += val.loaded; |
| total += val.total; |
| } |
| |
| const overallPercent = Math.round((sum / total) * 100); |
| document.getElementById("model-progress").value = overallPercent; |
| document.getElementById("progress-text").textContent = overallPercent + "%"; |
| } |
| }, |
| }); |
| modelLoaderOverlay.style.display = "none"; |
| console.log("Model loaded successfully."); |
| } catch (error) { |
| console.error("Failed to load model:", error); |
| modelLoaderOverlay.innerHTML = ` |
| <h2 class="text-center text-red-500 text-xl font-semibold">Failed to Load Model</h2> |
| <p class="text-center text-white text-md mt-2">Please refresh the page to try again. Check the console for errors.</p> |
| `; |
| } |
| } |
| |
| |
| |
| |
| |
| async function processImage(imageObject) { |
| if (!model || !processor) { |
| alert("Model is not loaded yet. Please wait."); |
| return; |
| } |
| |
| |
| setUiState("processing"); |
| clearOverlays(); |
| let fullText = ""; |
| doclingOutput.textContent = ""; |
| htmlIframe.srcdoc = ""; |
| |
| try { |
| |
| const ctx = hiddenCanvas.getContext("2d"); |
| hiddenCanvas.width = imageObject.width; |
| hiddenCanvas.height = imageObject.height; |
| ctx.drawImage(imageObject, 0, 0); |
| const image = RawImage.fromCanvas(hiddenCanvas); |
| |
| |
| const messages = [ |
| { |
| role: "user", |
| content: [{ type: "image" }, { type: "text", text: promptInput.value }], |
| }, |
| ]; |
| |
| |
| const text = processor.apply_chat_template(messages, { |
| add_generation_prompt: true, |
| }); |
| const inputs = await processor(text, [image], { |
| do_image_splitting: true, |
| }); |
| |
| await model.generate({ |
| ...inputs, |
| max_new_tokens: 4096, |
| streamer: new TextStreamer(processor.tokenizer, { |
| skip_prompt: true, |
| skip_special_tokens: false, |
| callback_function: (streamedText) => { |
| fullText += streamedText; |
| doclingOutput.textContent += streamedText; |
| }, |
| }), |
| }); |
| |
| |
| fullText = fullText.replace(/<\|end_of_text\|>$/, ""); |
| doclingOutput.textContent = fullText; |
| |
| |
| const tagRegex = /<(\w+)><loc_(\d+)><loc_(\d+)><loc_(\d+)><loc_(\d+)>/g; |
| const overlays = []; |
| let match; |
| while ((match = tagRegex.exec(fullText)) !== null) { |
| const tagType = match[1]; |
| const locs = [parseInt(match[2]), parseInt(match[3]), parseInt(match[4]), parseInt(match[5])]; |
| overlays.push({ tagType, locs }); |
| } |
| const colorMap = {}; |
| function getRandomColor() { |
| return `rgb(${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)})`; |
| } |
| const imgRect = imagePreview.getBoundingClientRect(); |
| const containerRect = imagePreviewContainer.getBoundingClientRect(); |
| const imageOffsetLeft = imgRect.left - containerRect.left; |
| const imageOffsetTop = imgRect.top - containerRect.top; |
| const scaleX = imgRect.width / currentImageWidth; |
| const scaleY = imgRect.height / currentImageHeight; |
| overlays.forEach(({ tagType, locs }) => { |
| const color = colorMap[tagType] || (colorMap[tagType] = getRandomColor()); |
| const [leftLoc, topLoc, rightLoc, bottomLoc] = locs; |
| const left = imageOffsetLeft + (leftLoc / 500) * currentImageWidth * scaleX; |
| const top = imageOffsetTop + (topLoc / 500) * currentImageHeight * scaleY; |
| const width = ((rightLoc - leftLoc) / 500) * currentImageWidth * scaleX; |
| const height = ((bottomLoc - topLoc) / 500) * currentImageHeight * scaleY; |
| const overlay = document.createElement("div"); |
| overlay.className = "overlay"; |
| overlay.style.setProperty('--overlay-color', color); |
| const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); |
| overlay.style.setProperty('--overlay-color-rgb', `${rgbMatch[1]},${rgbMatch[2]},${rgbMatch[3]}`); |
| overlay.style.position = "absolute"; |
| overlay.style.left = left + "px"; |
| overlay.style.top = top + "px"; |
| overlay.style.width = width + "px"; |
| overlay.style.height = height + "px"; |
| imagePreviewContainer.appendChild(overlay); |
| }); |
| |
| |
| htmlIframe.srcdoc = doclingToHtml(fullText); |
| } catch (error) { |
| console.error("Error during image processing:", error); |
| doclingOutput.textContent = `An error occurred: ${error.message}`; |
| } finally { |
| setUiState("result"); |
| } |
| } |
| |
| |
| |
| |
| |
| function handleImageSelection(source) { |
| const reader = new FileReader(); |
| const img = new Image(); |
| |
| img.onload = () => { |
| currentImageWidth = img.naturalWidth; |
| currentImageHeight = img.naturalHeight; |
| currentImage = img; |
| imagePreview.src = img.src; |
| imagePlaceholder.classList.add("hidden"); |
| imagePreviewContainer.classList.remove("hidden"); |
| examplesContainer.classList.add("hidden"); |
| examplesTitle.classList.add("hidden"); |
| processImage(img); |
| }; |
| |
| img.onerror = () => { |
| alert("Failed to load image."); |
| }; |
| |
| if (typeof source === "string") { |
| |
| |
| fetch(source) |
| .then((res) => res.blob()) |
| .then((blob) => { |
| img.src = URL.createObjectURL(blob); |
| }) |
| .catch((e) => { |
| console.error("CORS issue likely. Trying proxy or direct load.", e); |
| |
| img.crossOrigin = "anonymous"; |
| img.src = source; |
| }); |
| } else { |
| |
| reader.onload = (e) => { |
| img.src = e.target.result; |
| }; |
| reader.readAsDataURL(source); |
| } |
| } |
| |
| |
| |
| |
| |
| function setUiState(state) { |
| welcomeMessage.style.display = "none"; |
| processingIndicator.classList.add("hidden"); |
| doclingView.classList.add("hidden"); |
| htmlView.classList.add("hidden"); |
| |
| if (state === "initial") { |
| welcomeMessage.style.display = "flex"; |
| generateBtn.disabled = true; |
| } else if (state === "processing") { |
| viewToggle.checked = false; |
| processingIndicator.classList.remove("hidden"); |
| doclingView.classList.remove("hidden"); |
| generateBtn.disabled = true; |
| } else if (state === "result") { |
| viewToggle.checked = true; |
| htmlView.classList.remove("hidden"); |
| generateBtn.disabled = false; |
| } |
| } |
| |
| |
| |
| |
| function clearOverlays() { |
| document.querySelectorAll(".overlay").forEach((el) => el.remove()); |
| } |
| |
| |
| imageDropArea.addEventListener("click", () => fileInput.click()); |
| imageDropArea.addEventListener("dragover", (e) => { |
| e.preventDefault(); |
| imageDropArea.classList.add("border-indigo-500", "bg-indigo-50"); |
| }); |
| imageDropArea.addEventListener("dragleave", () => { |
| imageDropArea.classList.remove("border-indigo-500", "bg-indigo-50"); |
| }); |
| imageDropArea.addEventListener("drop", (e) => { |
| e.preventDefault(); |
| imageDropArea.classList.remove("border-indigo-500", "bg-indigo-50"); |
| const files = e.dataTransfer.files; |
| if (files.length > 0 && files[0].type.startsWith("image/")) { |
| handleImageSelection(files[0]); |
| } |
| }); |
| |
| |
| fileInput.addEventListener("change", (e) => { |
| const files = e.target.files; |
| if (files.length > 0) { |
| handleImageSelection(files[0]); |
| } |
| }); |
| |
| |
| exampleImages.forEach((img) => { |
| img.addEventListener("click", () => { |
| promptInput.value = img.dataset.prompt; |
| handleImageSelection(img.src); |
| }); |
| }); |
| |
| |
| removeImageBtn.addEventListener("click", (e) => { |
| e.stopPropagation(); |
| currentImage = null; |
| imagePreview.src = ""; |
| fileInput.value = ""; |
| imagePlaceholder.classList.remove("hidden"); |
| imagePreviewContainer.classList.add("hidden"); |
| examplesContainer.classList.remove("hidden"); |
| examplesTitle.classList.remove("hidden"); |
| setUiState("initial"); |
| doclingOutput.textContent = ""; |
| htmlIframe.srcdoc = ""; |
| clearOverlays(); |
| }); |
| |
| |
| viewToggle.addEventListener("change", () => { |
| const isHtmlView = viewToggle.checked; |
| htmlView.classList.toggle("hidden", !isHtmlView); |
| doclingView.classList.toggle("hidden", isHtmlView); |
| }); |
| |
| |
| generateBtn.addEventListener("click", () => { |
| if (currentImage) { |
| processImage(currentImage); |
| } |
| }); |
| |
| document.addEventListener("DOMContentLoaded", () => { |
| setUiState("initial"); |
| initializeModel(); |
| }); |
| </script> |
| </body> |
| </html> |
|
|