feat: OCR scans with tesseract, drop docling from the default path.
pdftotext still wins on born-digital PDFs. Empty text layers go through pdftoppm + tesseract eng+deu. Optional OCR_ENGINE=paddle. Gitea #6.
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
// Package ocr runs Tesseract (eng+deu) on images and scanned PDFs.
|
||||
//
|
||||
// Default engine is the tesseract CLI, not gosseract CGO: Ladybug CGO stays
|
||||
// Zig-only (D21). Same engine, no gocv. OCR_ENGINE=paddle selects paddleocr
|
||||
// when that binary is on PATH (compose profile ocr-paddle).
|
||||
package ocr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const TessLang = "eng+deu"
|
||||
|
||||
func ImageFile(path string) (string, error) {
|
||||
engine := os.Getenv("OCR_ENGINE")
|
||||
if engine == "paddle" {
|
||||
return runPaddle(path)
|
||||
}
|
||||
return runTesseract(path)
|
||||
}
|
||||
|
||||
func PDFFile(path string) (string, error) {
|
||||
text, err := pdfToText(path)
|
||||
if err == nil && strings.TrimSpace(text) != "" {
|
||||
return strings.TrimSpace(text), nil
|
||||
}
|
||||
ocr, oerr := pdfPages(path)
|
||||
if oerr != nil {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", oerr
|
||||
}
|
||||
if strings.TrimSpace(ocr) != "" {
|
||||
return strings.TrimSpace(ocr), nil
|
||||
}
|
||||
if text != "" {
|
||||
return strings.TrimSpace(text), nil
|
||||
}
|
||||
return "", fmt.Errorf("pdf has no text layer (ocr unavailable)")
|
||||
}
|
||||
|
||||
func pdfToText(path string) (string, error) {
|
||||
cmd := exec.Command("pdftotext", "-layout", path, "-")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func pdfPages(path string) (string, error) {
|
||||
dir, err := os.MkdirTemp("", "2dph-ocr-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
prefix := filepath.Join(dir, "page")
|
||||
cmd := exec.Command("pdftoppm", "-png", "-r", "200", path, prefix)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
matches, err := filepath.Glob(prefix + "*.png")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parts []string
|
||||
for _, img := range matches {
|
||||
t, err := ImageFile(img)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if s := strings.TrimSpace(t); s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n\n"), nil
|
||||
}
|
||||
|
||||
func runTesseract(path string) (string, error) {
|
||||
pre, err := preprocessFile(path)
|
||||
if err != nil {
|
||||
pre = path
|
||||
} else {
|
||||
defer os.Remove(pre)
|
||||
}
|
||||
cmd := exec.Command("tesseract", pre, "stdout", "-l", TessLang, "--psm", "6")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func runPaddle(path string) (string, error) {
|
||||
cmd := exec.Command("paddleocr", "ocr", "-i", path)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func preprocessFile(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
img, err := png.Decode(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := filepath.Join(os.TempDir(), filepath.Base(path)+".gray.png")
|
||||
w, err := os.Create(out)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer w.Close()
|
||||
if err := png.Encode(w, GrayContrast(img)); err != nil {
|
||||
os.Remove(out)
|
||||
return "", err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GrayContrast is a stdlib preprocess (no gocv): grayscale + stretch.
|
||||
func GrayContrast(src image.Image) image.Image {
|
||||
b := src.Bounds()
|
||||
dst := image.NewGray(b)
|
||||
var minL, maxL uint8 = 255, 0
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
g := color.GrayModel.Convert(src.At(x, y)).(color.Gray)
|
||||
if g.Y < minL {
|
||||
minL = g.Y
|
||||
}
|
||||
if g.Y > maxL {
|
||||
maxL = g.Y
|
||||
}
|
||||
}
|
||||
}
|
||||
span := int(maxL) - int(minL)
|
||||
if span < 1 {
|
||||
span = 1
|
||||
}
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
g := color.GrayModel.Convert(src.At(x, y)).(color.Gray)
|
||||
v := uint8((int(g.Y) - int(minL)) * 255 / span)
|
||||
dst.SetGray(x, y, color.Gray{Y: v})
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
Reference in New Issue
Block a user