DEV Community

Alain Airom
Alain Airom

Posted on

Docling + Go + Bob: The Modern Document Stack

Last Bob project of 2025: Docling document processing in Go!

This is the latest project I’ve developed in collaboration with IBM Bob. My goal was to architect a comprehensive document processing ecosystem that leverages the advanced parsing intelligence of Docling. By utilizing Bob’s capabilities, I was able to transition from a conceptual idea to a secure, fully scalable application prototype that bridges the gap between raw unstructured data and machine-ready insights.

Streamlining Document Intelligence: Introducing Docling-Serve Bob

In the evolving landscape of AI-driven data extraction, bridging the gap between sophisticated backend services and seamless user experience is a common challenge. The demonstrator; Docling-Serve Bob, a high-performance desktop application engineered in Go that brings the power of IBM’s Docling service directly the users’ fingertips. Built with the sleek Fyne framework, this application transforms the complex task of document conversion into an intuitive, point-and-click experience.

Whether being a developer looking for flexible deployment — ranging from local Python environments to robust containerized clusters — or a power user needing to batch-process documents into Markdown, JSON, or DocTags, Docling-Serve Bob handles the heavy lifting. With built-in server health monitoring, comprehensive error handling, and a focus on speed, it serves as the ultimate bridge between unstructured data and machine-ready insights.

| Feature                    | Benefit                                                      |
| -------------------------- | ------------------------------------------------------------ |
| **Go-Based Performance**   | Lightning-fast execution and efficient resource management.  |
| **Fyne GUI**               | A modern, responsive interface for cross-platform desktop use. |
| **Deployment Flexibility** | Run via local Python, Docker containers, or connect to remote servers. |
| **Rich Output Formats**    | Seamlessly export to Markdown, JSON, Text, or DocTags.       |
| **Automated Monitoring**   | Real-time health checks and detailed logging to ensure uptime. |
Enter fullscreen mode Exit fullscreen mode

Deployment Modes: Flexibility for Every Environment

One of the standout features of Docling-Serve Bob is its ability to adapt to your specific infrastructure. Whether you are running a quick local test or integrating into a production-grade Kubernetes cluster, the application offers three distinct deployment modes:

Containerized (Docker/Podman)

Ideal for users who want a “zero-install” experience for the Docling backend. In this mode, the application orchestrates a container (using images like quay.io/docling-project/docling-serve) to handle the heavy AI processing.

  • Best for: Consistent environments and users who don’t want to manage Python dependencies.
  • How it works: Bob manages the container lifecycle, ensuring the docling-serve API is up and healthy before processing starts.

Local Python Environment

For developers who prefer to run services natively, this mode leverages a local Python installation where docling-serve has been installed via pip.

  • Best for: Debugging, custom model paths, and environments where containerization isn’t available.
  • Setup: Requires a simple pip install docling-serve on the host machine. Bob then executes the server as a background process.

Remote/Existing Server

If your organization already hosts a centralized Docling instance (on a remote server, OpenShift, or AWS), Bob can simply act as a thin client.

  • Best for: Teams sharing a powerful GPU-enabled server or air-gapped enterprise environments.
  • Configuration: Simply input the server URL (e.g., http://docling.internal.company.com:5001) into the Bob interface to begin converting documents immediately.

Health Monitoring & Auto-Management

Regardless of the mode chosen, Docling-Serve Bob includes an Automatic Server Management layer. It doesn’t just send requests into a void; it performs real-time health checks on the /health endpoint and provides visual feedback through the Fyne-based GUI, ensuring you never start a batch job on a disconnected service.


Batch Processing & Output Formats: Precision at Scale

Converting a single document is easy, but processing hundreds requires a specialized workflow. Docling-Serve Bob is designed to handle high-volume workloads without sacrificing the granular control that researchers and developers need.

The Power of Batch Processing

Instead of manually selecting files one by one, users can feed entire directories into the application. Leveraging Go’s native concurrency primitives, Bob manages the submission of multiple documents to the Docling backend efficiently.

  • Queue Management: Monitor the progress of each file in real-time through the GUI.
  • Resilience: If one document fails due to corruption, Bob logs the error and continues with the rest of the batch, ensuring your entire pipeline doesn’t stall.
  • Efficiency: Automated workflows reduce the manual overhead of document preparation for LLM training or RAG (Retrieval-Augmented Generation) systems.

Versatile Output Formats

Docling’s core strength lies in its ability to understand the structure of a document — not just the text. Bob exposes these capabilities by allowing users to choose the output format that best fits their downstream application:

  • Markdown: Perfect for LLM ingestion and RAG pipelines, preserving headers, lists, and tables in a clean, readable format.
  • JSON: The go-to for developers needing programmatic access to the document’s underlying object model and metadata.
  • Text: A simplified, raw extraction for basic search indexing or NLP tasks.
  • DocTags: A specialized format that includes semantic tagging of document elements, ideal for training custom models or sophisticated document analysis.

Automatic Server Management & Error Handling

To ensure a “set-and-forget” experience, Bob includes a robust management layer that acts as a watchdog for your conversion tasks:

  • Health Monitoring: Constant polling of the Docling API ensures the service is ready before a batch starts.
  • Comprehensive Logging: Every conversion step is recorded. If a table fails to render or a font is missing, you’ll find the exact reason in the logs.
  • Visual Feedback: The Fyne-based interface provides immediate visual cues — green for success, red for errors — keeping the user informed without needing to tail a terminal.

Code Implementation

Main Go Application

The main application implemented in Go is represented below ⤵️

package main

import (
 "bytes"
 "encoding/json"
 "fmt"
 "io"
 "log"
 "mime/multipart"
 "net/http"
 "os"
 "os/exec"
 "path/filepath"
 "time"

 "fyne.io/fyne/v2"
 "fyne.io/fyne/v2/app"
 "fyne.io/fyne/v2/container"
 "fyne.io/fyne/v2/dialog"
 "fyne.io/fyne/v2/widget"
)

const (
 defaultDoclingURL = "http://localhost:5001"
 outputDir         = "./output"
 logDir            = "./log"
)

var (
 logger           *log.Logger
 doclingServerURL string
 serverProcess    *exec.Cmd
)

type DoclingClient struct {
 baseURL string
 client  *http.Client
}

func initLogger() error {
 if err := os.MkdirAll(logDir, 0755); err != nil {
  return fmt.Errorf("failed to create log directory: %w", err)
 }

 logFileName := filepath.Join(logDir, fmt.Sprintf("docling-serve-bob_%s.log", time.Now().Format("20060102_150405")))
 logFile, err := os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
 if err != nil {
  return fmt.Errorf("failed to open log file: %w", err)
 }

 logger = log.New(logFile, "", log.LstdFlags|log.Lshortfile)
 logger.Println("=== Docling-Serve Bob Started ===")
 logger.Printf("Log file: %s\n", logFileName)
 logger.Printf("Docling server URL: %s\n", doclingServerURL)
 logger.Printf("Output directory: %s\n", outputDir)

 return nil
}

func NewDoclingClient(baseURL string) *DoclingClient {
 logger.Printf("Creating Docling client with base URL: %s\n", baseURL)
 client := &DoclingClient{
  baseURL: baseURL,
  client:  &http.Client{Timeout: 300 * time.Second},
 }

 // Try to discover available endpoints in background
 go client.discoverEndpoints()

 return client
}

func (dc *DoclingClient) discoverEndpoints() {
 logger.Println("Attempting to discover available API endpoints...")

 // Try to get OpenAPI/Swagger docs
 endpoints := []string{
  "/docs",
  "/openapi.json",
  "/api/docs",
  "/swagger.json",
  "",
 }

 for _, endpoint := range endpoints {
  url := dc.baseURL + endpoint
  resp, err := dc.client.Get(url)
  if err != nil {
   continue
  }
  defer resp.Body.Close()

  if resp.StatusCode == http.StatusOK {
   body, _ := io.ReadAll(resp.Body)
   logger.Printf("Found endpoint %s (status %d), response length: %d bytes\n", url, resp.StatusCode, len(body))
   if len(body) > 0 && len(body) < 10000 {
    logger.Printf("Response preview: %s\n", string(body[:min(500, len(body))]))
   }
  }
 }
}

func min(a, b int) int {
 if a < b {
  return a
 }
 return b
}

func (dc *DoclingClient) ConvertDocument(filePath string) ([]byte, error) {
 logger.Printf("Starting conversion for file: %s\n", filePath)

 url := dc.baseURL + "/v1/convert/file"
 logger.Printf("Using endpoint: %s\n", url)

 file, err := os.Open(filePath)
 if err != nil {
  logger.Printf("ERROR: Failed to open file %s: %v\n", filePath, err)
  return nil, fmt.Errorf("failed to open file: %w", err)
 }
 defer file.Close()

 // Use a pipe to stream the file without loading it all into memory
 pr, pw := io.Pipe()
 writer := multipart.NewWriter(pw)

 // Write the multipart form in a goroutine
 go func() {
  defer pw.Close()
  defer writer.Close()

  part, err := writer.CreateFormFile("files", filepath.Base(filePath))
  if err != nil {
   logger.Printf("ERROR: Failed to create form file: %v\n", err)
   pw.CloseWithError(err)
   return
  }

  if _, err := io.Copy(part, file); err != nil {
   logger.Printf("ERROR: Failed to copy file content: %v\n", err)
   pw.CloseWithError(err)
   return
  }
 }()

 req, err := http.NewRequest("POST", url, pr)
 if err != nil {
  logger.Printf("ERROR: Failed to create request: %v\n", err)
  return nil, fmt.Errorf("failed to create request: %w", err)
 }

 req.Header.Set("Content-Type", writer.FormDataContentType())
 req.Header.Set("Transfer-Encoding", "chunked")
 logger.Printf("Request headers: %v\n", req.Header)
 logger.Println("Sending request with streaming upload...")

 resp, err := dc.client.Do(req)
 if err != nil {
  logger.Printf("ERROR: Failed to send request: %v\n", err)
  return nil, fmt.Errorf("failed to send request: %w", err)
 }
 defer resp.Body.Close()

 logger.Printf("Response status: %d\n", resp.StatusCode)

 if resp.StatusCode != http.StatusOK {
  bodyBytes, _ := io.ReadAll(resp.Body)
  logger.Printf("Response body: %s\n", string(bodyBytes))
  return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(bodyBytes))
 }

 result, err := io.ReadAll(resp.Body)
 if err != nil {
  logger.Printf("ERROR: Failed to read response: %v\n", err)
  return nil, fmt.Errorf("failed to read response: %w", err)
 }

 logger.Printf("Successfully converted document, response size: %d bytes\n", len(result))
 return result, nil
}

func (dc *DoclingClient) ConvertDocumentWithPython(filePath string) ([]byte, error) {
 logger.Printf("Starting Python library conversion for file: %s\n", filePath)

 // Use the Python converter script
 pythonScript := "./docling_converter.py"

 // Check if script exists
 if _, err := os.Stat(pythonScript); os.IsNotExist(err) {
  logger.Printf("ERROR: Python converter script not found: %s\n", pythonScript)
  return nil, fmt.Errorf("python converter script not found: %s", pythonScript)
 }

 // Check if venv exists
 venvPython := "./docling-venv/bin/python3"
 if _, err := os.Stat(venvPython); os.IsNotExist(err) {
  logger.Printf("ERROR: Python venv not found. Please run setup-docling-venv.sh first\n")
  return nil, fmt.Errorf("python venv not found. Run setup-docling-venv.sh first")
 }

 // Execute Python script
 cmd := exec.Command(venvPython, pythonScript, filePath, outputDir)
 output, err := cmd.CombinedOutput()

 if err != nil {
  logger.Printf("ERROR: Python conversion failed: %v\nOutput: %s\n", err, string(output))
  return nil, fmt.Errorf("python conversion failed: %w\nOutput: %s", err, string(output))
 }

 logger.Printf("Python conversion completed successfully\n")
 logger.Printf("Python output: %s\n", string(output))

 return output, nil
}

func ensureOutputDir() error {
 if _, err := os.Stat(outputDir); os.IsNotExist(err) {
  logger.Printf("Creating output directory: %s\n", outputDir)
  if err := os.MkdirAll(outputDir, 0755); err != nil {
   logger.Printf("ERROR: Failed to create output directory: %v\n", err)
   return fmt.Errorf("failed to create output directory: %w", err)
  }
 }
 return nil
}

func generateOutputFilename(originalPath string) string {
 timestamp := time.Now().Format("20060102_150405")
 baseName := filepath.Base(originalPath)
 ext := filepath.Ext(baseName)
 nameWithoutExt := baseName[:len(baseName)-len(ext)]
 return fmt.Sprintf("%s_%s.json", nameWithoutExt, timestamp)
}

func saveResult(data []byte, originalPath string) error {
 if err := ensureOutputDir(); err != nil {
  return err
 }

 outputFilename := generateOutputFilename(originalPath)
 outputPath := filepath.Join(outputDir, outputFilename)
 logger.Printf("Saving result to: %s\n", outputPath)

 // Pretty print JSON
 var prettyJSON bytes.Buffer
 if err := json.Indent(&prettyJSON, data, "", "  "); err != nil {
  // If it's not JSON, save as is
  logger.Printf("Data is not JSON, saving as-is\n")
  if err := os.WriteFile(outputPath, data, 0644); err != nil {
   logger.Printf("ERROR: Failed to write file: %v\n", err)
   return err
  }
  logger.Printf("Successfully saved result to: %s\n", outputPath)
  return nil
 }

 if err := os.WriteFile(outputPath, prettyJSON.Bytes(), 0644); err != nil {
  logger.Printf("ERROR: Failed to write file: %v\n", err)
  return err
 }
 logger.Printf("Successfully saved result to: %s\n", outputPath)
 return nil
}

func startPodmanServer() error {
 logger.Println("Starting Docling server via Podman...")
 cmd := exec.Command("podman", "run", "-d", "--rm",
  "-p", "5001:5001",
  "--name", "docling-serve",
  "docker.io/docling/docling-serve:latest")

 output, err := cmd.CombinedOutput()
 if err != nil {
  logger.Printf("ERROR: Failed to start Podman container: %v\nOutput: %s\n", err, string(output))
  return fmt.Errorf("failed to start Podman container: %w\nOutput: %s", err, string(output))
 }

 logger.Printf("Podman container started successfully: %s\n", string(output))
 return nil
}

func startPythonVenvServer() error {
 logger.Println("Starting Docling server via Python virtual environment...")

 // Check if setup script exists
 if _, err := os.Stat("./setup-docling-venv.sh"); os.IsNotExist(err) {
  return fmt.Errorf("setup-docling-venv.sh not found. Please ensure the script exists")
 }

 // Check if venv exists, if not run setup
 if _, err := os.Stat("./docling-venv"); os.IsNotExist(err) {
  logger.Println("Virtual environment not found, running setup...")
  setupCmd := exec.Command("bash", "./setup-docling-venv.sh")
  setupCmd.Stdout = os.Stdout
  setupCmd.Stderr = os.Stderr
  if err := setupCmd.Run(); err != nil {
   logger.Printf("ERROR: Failed to setup virtual environment: %v\n", err)
   return fmt.Errorf("failed to setup virtual environment: %w", err)
  }
 }

 // Start the server
 logger.Println("Starting docling-serve from virtual environment...")
 serverProcess = exec.Command("bash", "./start-docling-serve.sh")
 serverProcess.Stdout = os.Stdout
 serverProcess.Stderr = os.Stderr

 if err := serverProcess.Start(); err != nil {
  logger.Printf("ERROR: Failed to start docling-serve: %v\n", err)
  return fmt.Errorf("failed to start docling-serve: %w", err)
 }

 logger.Printf("Docling-serve started with PID: %d\n", serverProcess.Process.Pid)

 // Wait a bit for server to start
 time.Sleep(3 * time.Second)

 return nil
}

func stopServer() {
 if serverProcess != nil && serverProcess.Process != nil {
  logger.Println("Stopping docling-serve process...")
  serverProcess.Process.Kill()
  serverProcess.Wait()
  logger.Println("Docling-serve process stopped")
 }
}

func showServerSelectionDialog(myApp fyne.App, onComplete func(string)) {
 selectionWindow := myApp.NewWindow("Select Docling Server Connection")
 selectionWindow.Resize(fyne.NewSize(600, 400))

 var selectedOption string

 title := widget.NewLabel("Choose how to connect to Docling-Serve:")
 title.TextStyle = fyne.TextStyle{Bold: true}

 // Option 1: Existing server
 option1Label := widget.NewLabel("Connect to existing Docling server")
 option1Desc := widget.NewLabel("Use if you already have Docling-Serve running\n(default: http://localhost:5001)")
 option1Desc.Wrapping = fyne.TextWrapWord

 // Option 2: Podman
 option2Label := widget.NewLabel("Start Docling-Serve via Podman")
 option2Desc := widget.NewLabel("Automatically starts a Podman container\nRequires: Podman installed and configured")
 option2Desc.Wrapping = fyne.TextWrapWord

 // Option 3: Python venv
 option3Label := widget.NewLabel("Start Docling-Serve via Python Virtual Environment")
 option3Desc := widget.NewLabel("Automatically sets up and runs docling-serve in a Python venv\nRequires: Python 3.9+ installed")
 option3Desc.Wrapping = fyne.TextWrapWord

 // Create clickable containers for each option
 option1Container := container.NewVBox(
  widget.NewSeparator(),
  container.NewHBox(
   widget.NewCheck("", func(checked bool) {
    if checked {
     selectedOption = "existing"
    }
   }),
   container.NewVBox(option1Label, option1Desc),
  ),
 )

 option2Container := container.NewVBox(
  widget.NewSeparator(),
  container.NewHBox(
   widget.NewCheck("", func(checked bool) {
    if checked {
     selectedOption = "podman"
    }
   }),
   container.NewVBox(option2Label, option2Desc),
  ),
 )

 option3Container := container.NewVBox(
  widget.NewSeparator(),
  container.NewHBox(
   widget.NewCheck("", func(checked bool) {
    if checked {
     selectedOption = "venv"
    }
   }),
   container.NewVBox(option3Label, option3Desc),
  ),
 )

 continueButton := widget.NewButton("Continue", func() {
  if selectedOption == "" {
   dialog.ShowInformation("Selection Required", "Please select a connection method", selectionWindow)
   return
  }

  selectionWindow.Close()
  onComplete(selectedOption)
 })
 continueButton.Importance = widget.HighImportance

 content := container.NewVBox(
  title,
  option1Container,
  option2Container,
  option3Container,
  widget.NewSeparator(),
  container.NewCenter(continueButton),
 )

 selectionWindow.SetContent(container.NewPadded(content))
 selectionWindow.Show()
}

func main() {
 // Initialize logger
 if err := initLogger(); err != nil {
  fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err)
  os.Exit(1)
 }

 logger.Println("Application starting...")

 myApp := app.New()

 // Show server selection dialog first
 showServerSelectionDialog(myApp, func(option string) {
  logger.Printf("User selected connection method: %s\n", option)

  // Set default URL
  doclingServerURL = defaultDoclingURL

  // Handle server startup based on selection
  switch option {
  case "podman":
   if err := startPodmanServer(); err != nil {
    dialog.ShowError(fmt.Errorf("Failed to start Podman server: %w", err), nil)
    logger.Printf("ERROR: %v\n", err)
    os.Exit(1)
   }
   // Wait for server to be ready
   time.Sleep(5 * time.Second)

  case "venv":
   if err := startPythonVenvServer(); err != nil {
    dialog.ShowError(fmt.Errorf("Failed to start Python venv server: %w", err), nil)
    logger.Printf("ERROR: %v\n", err)
    os.Exit(1)
   }

  case "existing":
   logger.Println("Using existing Docling server")
  }

  // Now create the main window
  logger.Println("Creating main application window...")
  myWindow := myApp.NewWindow("Docling-Serve Client")
  myWindow.Resize(fyne.NewSize(900, 600))
  logger.Println("Main window created and resized")

  // Cleanup on close
  myWindow.SetOnClosed(func() {
   logger.Println("Main window closed, cleaning up...")
   stopServer()
  })

  logger.Println("Creating Docling client...")
  client := NewDoclingClient(doclingServerURL)
  logger.Println("Docling client created")

  // UI Components
  statusLabel := widget.NewLabel("Ready to process documents")
  progressBar := widget.NewProgressBar()
  progressBar.Hide()

  // Add checkbox for Python library mode
  usePythonLib := widget.NewCheck("Use Python Library (Advanced: OCR, Tables, Multiple Formats)", func(checked bool) {
   if checked {
    logger.Println("Python library mode enabled")
    statusLabel.SetText("Python library mode: OCR, table extraction, multiple export formats")
   } else {
    logger.Println("REST API mode enabled")
    statusLabel.SetText("Ready to process documents")
   }
  })

  selectedFiles := []string{}
  fileList := widget.NewList(
   func() int { return len(selectedFiles) },
   func() fyne.CanvasObject {
    return widget.NewLabel("")
   },
   func(id widget.ListItemID, obj fyne.CanvasObject) {
    obj.(*widget.Label).SetText(filepath.Base(selectedFiles[id]))
   },
  )

  selectButton := widget.NewButton("➕ Add Document (click multiple times for multiple files)", func() {
   logger.Println("User clicked Add Document button")
   dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
    if err != nil {
     logger.Printf("ERROR: File dialog error: %v\n", err)
     dialog.ShowError(err, myWindow)
     return
    }
    if reader == nil {
     logger.Println("File dialog cancelled by user")
     return
    }
    defer reader.Close()

    filePath := reader.URI().Path()
    logger.Printf("User selected file: %s\n", filePath)

    // Check if file is already selected
    for _, existing := range selectedFiles {
     if existing == filePath {
      logger.Printf("File already selected: %s\n", filePath)
      dialog.ShowInformation("Duplicate", "This file is already selected", myWindow)
      return
     }
    }

    selectedFiles = append(selectedFiles, filePath)
    fileList.Refresh()
    if len(selectedFiles) == 1 {
     statusLabel.SetText("1 document selected - click 'Add Document' again to add more")
    } else {
     statusLabel.SetText(fmt.Sprintf("%d documents selected - click 'Add Document' to add more", len(selectedFiles)))
    }
    logger.Printf("Total files selected: %d\n", len(selectedFiles))
   }, myWindow)
  })

  clearButton := widget.NewButton("🗑️ Clear All", func() {
   logger.Printf("Clearing selection of %d files\n", len(selectedFiles))
   selectedFiles = []string{}
   fileList.Refresh()
   statusLabel.SetText("Ready to process documents")
  })

  var processButton *widget.Button
  processButton = widget.NewButton("▶️ Process All Documents", func() {
   if len(selectedFiles) == 0 {
    logger.Println("Process button clicked but no files selected")
    dialog.ShowInformation("No Files", "Please select at least one document to process", myWindow)
    return
   }

   logger.Printf("Starting batch processing of %d files\n", len(selectedFiles))
   processButton.Disable()
   selectButton.Disable()
   clearButton.Disable()
   progressBar.Show()

   go func() {
    successCount := 0
    errorCount := 0

    for i, filePath := range selectedFiles {
     logger.Printf("Processing file %d/%d: %s\n", i+1, len(selectedFiles), filePath)
     statusLabel.SetText(fmt.Sprintf("Processing %d/%d: %s", i+1, len(selectedFiles), filepath.Base(filePath)))
     progressBar.SetValue(float64(i) / float64(len(selectedFiles)))

     var result []byte
     var err error

     // Choose conversion method based on checkbox
     if usePythonLib.Checked {
      result, err = client.ConvertDocumentWithPython(filePath)
     } else {
      result, err = client.ConvertDocument(filePath)
     }

     if err != nil {
      errorCount++
      logger.Printf("ERROR: Failed to convert %s: %v\n", filePath, err)
      dialog.ShowError(fmt.Errorf("failed to process %s: %w", filepath.Base(filePath), err), myWindow)
      continue
     }

     // Only save result if using REST API (Python lib saves its own files)
     if !usePythonLib.Checked {
      if err := saveResult(result, filePath); err != nil {
       errorCount++
       logger.Printf("ERROR: Failed to save result for %s: %v\n", filePath, err)
       dialog.ShowError(fmt.Errorf("failed to save result for %s: %w", filepath.Base(filePath), err), myWindow)
       continue
      }
     }

     successCount++
    }

    progressBar.SetValue(1.0)
    statusLabel.SetText(fmt.Sprintf("Completed: %d successful, %d failed", successCount, errorCount))
    logger.Printf("Batch processing complete: %d successful, %d failed\n", successCount, errorCount)

    processButton.Enable()
    selectButton.Enable()
    clearButton.Enable()
    progressBar.Hide()

    if successCount > 0 {
     dialog.ShowInformation("Success",
      fmt.Sprintf("Processed %d document(s) successfully.\nResults saved to %s\nLogs saved to %s", successCount, outputDir, logDir),
      myWindow)
    }
   }()
  })

  buttonBox := container.NewHBox(selectButton, clearButton, processButton)

  content := container.NewBorder(
   container.NewVBox(
    widget.NewLabel("Docling-Serve Document Processor"),
    widget.NewSeparator(),
    usePythonLib,
    buttonBox,
    statusLabel,
    progressBar,
   ),
   nil,
   nil,
   nil,
   fileList,
  )

  logger.Println("Setting window content...")
  myWindow.SetContent(content)
  logger.Println("Showing main window...")
  myWindow.Show()
  logger.Println("Main window should now be visible")
 })

 myApp.Run()
}

// Made with Bob
Enter fullscreen mode Exit fullscreen mode

Why some part of the application required Python implementation?

While I asked a full “Go” implementation of the process in Go language, Bob provided a part of the application regarding the batch document processing in Python. The reasons provided are enumerated hereafter;

The Docling document conversion library is a Python package that provides direct access to advanced features:

  • OCR (Optical Character Recognition)
  • Advanced table structure extraction
  • Multiple export formats (JSON, Markdown, Text, DocTags)
  • ML model integration for document analysis
from docling.document_converter import DocumentConverter, PdfFormatOption
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import PdfPipelineOptions
Enter fullscreen mode Exit fullscreen mode

No Official Go Bindings

There are no native Go bindings for the Docling library. The only way to access Docling’s functionality from Go is either:

  • Via REST API (docling-serve) — simpler but limited features
  • Via Python subprocess execution — full feature access

Advanced Feature Access

The Python script provides features not available through the REST API:

| Feature                   | REST API | Python Library |
| :------------------------ | :------- | :------------- |
| OCR                       | ❌        | ✅              |
| Advanced Table Extraction | ❌        | ✅              |
| Multiple Export Formats   | 1        | 4              |
| Pipeline Configuration    | Limited  | Full Control   |
Enter fullscreen mode Exit fullscreen mode

Architectural Design Pattern

The application uses a hybrid architecture:

┌─────────────────┐
│   Go GUI App    │ ← User Interface (Fyne)
│   (main.go)     │ ← HTTP Client
└────────┬────────┘
         │
    ┌────┴────┐
    │         │
    ▼         ▼
┌─────────┐ ┌──────────────────┐
│REST API │ │ Python Subprocess│ ← Advanced Features
│  Mode   │ │ (docling_converter.py)
└─────────┘ └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

From main.go, the Go application executes the Python script as a subprocess:

func (dc *DoclingClient) ConvertDocumentWithPython(filePath string) ([]byte, error) {
    pythonScript := "./docling_converter.py"
    venvPython := "./docling-venv/bin/python3"

    cmd := exec.Command(venvPython, pythonScript, filePath, outputDir)
    output, err := cmd.CombinedOutput()

    return output, err
}
Enter fullscreen mode Exit fullscreen mode

Separation of Concerns

  • Go handles: GUI, user interaction, file management, HTTP communication, process orchestration
  • Python handles: Document conversion with ML models, OCR processing, complex data transformations
  • This separation allows each language to do what it does best. In essence rewriting in Go would require:

  • ❌ Reimplementing all Docling ML models and algorithms

  • ❌ Porting complex Python dependencies (PyTorch, transformers, etc.)

  • ❌ Maintaining feature parity with upstream Docling updates

  • ❌ Significant development effort with minimal benefit

docling_converter.py exists because:

  • ✅ Docling is a Python-native library
  • ✅ Provides advanced features unavailable via REST API
  • ✅ Enables full pipeline configuration and control
  • ✅ Follows a pragmatic hybrid architecture pattern
  • ✅ Allows the Go application to leverage Python’s ML ecosystem

The design is intentional and optimal — Go provides the excellent GUI and system integration, while Python provides access to the powerful Docling document processing capabilities.

#!/usr/bin/env python3
"""
Docling Document Converter with Advanced Features
This script uses the Docling Python library directly for advanced document conversion
"""

import sys
import json
import time
import logging
from pathlib import Path
from docling.document_converter import DocumentConverter, PdfFormatOption
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import PdfPipelineOptions

# Setup logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)
_log = logging.getLogger(__name__)


def convert_document(input_path: str, output_dir: str = "./output") -> dict:
    """
    Convert a document using Docling with advanced options

    Args:
        input_path: Path to input document
        output_dir: Directory to save output files

    Returns:
        dict with conversion results and file paths
    """
    try:
        # Configure PDF pipeline options
        pipeline_options = PdfPipelineOptions()
        pipeline_options.do_ocr = True
        pipeline_options.do_table_structure = True
        # Note: do_cell_matching may not be available in all versions
        # pipeline_options.table_structure_options.do_cell_matching = True

        # Create document converter with advanced options
        doc_converter = DocumentConverter(
            allowed_formats=[
                InputFormat.PDF,
                InputFormat.IMAGE,
                InputFormat.DOCX,
                InputFormat.HTML,
                InputFormat.PPTX,
                InputFormat.ASCIIDOC,
                InputFormat.MD,
            ],
            format_options={
                InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options),
            },
        )

        # Convert document
        _log.info(f"Converting document: {input_path}")
        start_time = time.time()
        conv_result = doc_converter.convert(input_path)
        end_time = time.time() - start_time
        _log.info(f"Document converted in {end_time:.2f} seconds.")

        # Prepare output directory
        output_path = Path(output_dir)
        output_path.mkdir(parents=True, exist_ok=True)
        doc_filename = conv_result.input.file.stem

        # Export to multiple formats
        output_files = {}

        # Export Deep Search document JSON format
        json_file = output_path / f"{doc_filename}.json"
        with json_file.open("w", encoding="utf-8") as fp:
            fp.write(json.dumps(conv_result.document.export_to_dict(), indent=2))
        output_files['json'] = str(json_file)
        _log.info(f"Exported JSON: {json_file}")

        # Export Text format
        text_file = output_path / f"{doc_filename}.txt"
        with text_file.open("w", encoding="utf-8") as fp:
            fp.write(conv_result.document.export_to_text())
        output_files['text'] = str(text_file)
        _log.info(f"Exported Text: {text_file}")

        # Export Markdown format
        md_file = output_path / f"{doc_filename}.md"
        with md_file.open("w", encoding="utf-8") as fp:
            fp.write(conv_result.document.export_to_markdown())
        output_files['markdown'] = str(md_file)
        _log.info(f"Exported Markdown: {md_file}")

        # Export Document Tags format
        doctags_file = output_path / f"{doc_filename}.doctags"
        with doctags_file.open("w", encoding="utf-8") as fp:
            fp.write(conv_result.document.export_to_document_tokens())
        output_files['doctags'] = str(doctags_file)
        _log.info(f"Exported DocTags: {doctags_file}")

        # Return results
        return {
            "status": "success",
            "input_file": input_path,
            "processing_time": end_time,
            "output_files": output_files,
            "document_info": {
                "filename": doc_filename,
                "page_count": len(conv_result.document.pages) if hasattr(conv_result.document, 'pages') else None,
            }
        }

    except Exception as e:
        _log.error(f"Error converting document: {e}", exc_info=True)
        return {
            "status": "error",
            "input_file": input_path,
            "error": str(e)
        }


def main():
    """Main entry point for CLI usage"""
    if len(sys.argv) < 2:
        print("Usage: python docling_converter.py <input_file> [output_dir]", file=sys.stderr)
        sys.exit(1)

    input_file = sys.argv[1]
    output_dir = sys.argv[2] if len(sys.argv) > 2 else "./output"

    # Convert document
    result = convert_document(input_file, output_dir)

    # Print result as JSON
    print(json.dumps(result, indent=2))

    # Exit with appropriate code
    sys.exit(0 if result["status"] == "success" else 1)


if __name__ == "__main__":
    main()

# Made with Bob
Enter fullscreen mode Exit fullscreen mode

Conclusion: Redefining Document Workflows

By combining the structural intelligence of IBM’s Docling with the performance and portability of Go, Docling-Serve Bob eliminates the friction often associated with document AI. It’s no longer just about extracting text; it’s about preserving the “soul” of a document — its tables, its hierarchy, and its context — all through a user-friendly GUI that fits right into a developer’s daily toolkit.

Whether you’re building a RAG pipeline or simply need to clean up a mountain of legacy PDFs, this application provides the flexibility to deploy anywhere and the power to process at scale.

Next Steps: Get Started with Docling-Serve Bob

Ready to transform your document processing? Here is how to hit the ground running:

  • Clone the Repository: Visit the GitHub repo and pull the latest Go source code.
  • Install Fyne: Ensure you have the Fyne.io prerequisites installed for your OS (Windows, macOS, or Linux).
  • Choose Your Backend: * If you have Docker, Bob will handle the rest (if you prefer Python, run pip install docling-serve).
  • Run the App: Execute go run . and start your first batch conversion!

Thanks for reading 🤗

Links

Top comments (0)