Structured OCR path for agents alongside ocrmypdf searchable PDF. as-md --hocr selects the same pipeline for OO downloads. Co-authored-by: Cursor <cursoragent@cursor.com>
154 lines
4.3 KiB
Go
154 lines
4.3 KiB
Go
package docpipe
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
hocr "github.com/eslider/go-hocr"
|
||
)
|
||
|
||
// HOCRResult is structured OCR output for agents.
|
||
type HOCRResult struct {
|
||
HOCRPath string
|
||
Markdown string
|
||
YAML string
|
||
DidOCR bool
|
||
Source string
|
||
}
|
||
|
||
// ImageToHOCR runs tesseract hOCR into outBase+".hocr" (tesseract adds the extension).
|
||
// outBase must not include ".hocr". dpi 0 uses tesseract default; phone photos often need 200–300.
|
||
func (t Tools) ImageToHOCR(imgPath, outBase, lang string, dpi int) (string, error) {
|
||
if t.Tesseract == "" {
|
||
return "", fmt.Errorf("tesseract not found on PATH")
|
||
}
|
||
if lang == "" {
|
||
lang = "eng"
|
||
}
|
||
if outBase == "" {
|
||
return "", fmt.Errorf("hOCR output base path required")
|
||
}
|
||
args := []string{imgPath, outBase, "-l", lang}
|
||
if dpi > 0 {
|
||
args = append(args, "--dpi", fmt.Sprintf("%d", dpi))
|
||
}
|
||
args = append(args, resolveHOCRConfig())
|
||
cmd := exec.Command(t.Tesseract, args...)
|
||
var stderr bytes.Buffer
|
||
cmd.Stderr = &stderr
|
||
if err := cmd.Run(); err != nil {
|
||
return "", fmt.Errorf("tesseract hocr: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||
}
|
||
out := outBase + ".hocr"
|
||
if _, err := os.Stat(out); err != nil {
|
||
// Some builds write .html
|
||
alt := outBase + ".html"
|
||
if _, err2 := os.Stat(alt); err2 == nil {
|
||
return alt, nil
|
||
}
|
||
return "", fmt.Errorf("tesseract hocr: missing output %s (%s)", out, strings.TrimSpace(stderr.String()))
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// resolveHOCRConfig returns a tesseract config path that works when TESSDATA_PREFIX
|
||
// points at a custom traineddata dir without relative "hocr" configs.
|
||
func resolveHOCRConfig() string {
|
||
var candidates []string
|
||
if p := strings.TrimSpace(os.Getenv("TESSDATA_PREFIX")); p != "" {
|
||
candidates = append(candidates,
|
||
filepath.Join(p, "configs", "hocr"),
|
||
filepath.Join(p, "tessdata", "configs", "hocr"),
|
||
)
|
||
}
|
||
candidates = append(candidates,
|
||
"/usr/share/tesseract-ocr/5/tessdata/configs/hocr",
|
||
"/usr/share/tesseract-ocr/4.00/tessdata/configs/hocr",
|
||
"/usr/share/tessdata/configs/hocr",
|
||
)
|
||
for _, c := range candidates {
|
||
if _, err := os.Stat(c); err == nil {
|
||
return c
|
||
}
|
||
}
|
||
return "hocr"
|
||
}
|
||
|
||
// HOCRToMarkdown parses an hOCR file via go-hocr and returns Markdown (+ optional YAML).
|
||
// Words with confidence in (0, minConf) are dropped; minConf 0 keeps all.
|
||
func HOCRToMarkdown(hocrPath string, minConf float32) (md string, yml string, err error) {
|
||
doc, err := hocr.ReadFile(hocrPath)
|
||
if err != nil {
|
||
return "", "", fmt.Errorf("go-hocr: %w", err)
|
||
}
|
||
md = doc.ToMarkdown(minConf)
|
||
yml, err = doc.ToYaml()
|
||
if err != nil {
|
||
return md, "", err
|
||
}
|
||
return md, yml, nil
|
||
}
|
||
|
||
// ToHOCRMarkdown OCRs an image (or rasterizes first PDF page) to hOCR → Markdown/YAML.
|
||
func (t Tools) ToHOCRMarkdown(path, workDir, lang string, dpi int, minConf float32) (HOCRResult, error) {
|
||
res := HOCRResult{Source: path}
|
||
if workDir == "" {
|
||
workDir = os.TempDir()
|
||
}
|
||
ext := Ext(path)
|
||
img := path
|
||
switch ext {
|
||
case ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".gif", ".bmp":
|
||
// ok
|
||
case ".pdf":
|
||
raster, err := t.pdfFirstPagePNG(path, workDir)
|
||
if err != nil {
|
||
return res, err
|
||
}
|
||
img = raster
|
||
if dpi == 0 {
|
||
dpi = 300
|
||
}
|
||
default:
|
||
return res, fmt.Errorf("hOCR path expects image or PDF, got %q", ext)
|
||
}
|
||
base := filepath.Join(workDir, trimExt(filepath.Base(path))+".hocr-out")
|
||
hocrPath, err := t.ImageToHOCR(img, base, lang, dpi)
|
||
if err != nil {
|
||
return res, err
|
||
}
|
||
res.DidOCR = true
|
||
res.HOCRPath = hocrPath
|
||
md, yml, err := HOCRToMarkdown(hocrPath, minConf)
|
||
if err != nil {
|
||
return res, err
|
||
}
|
||
res.Markdown = wrapMD(filepath.Base(path), strings.TrimSpace(md))
|
||
res.YAML = yml
|
||
return res, nil
|
||
}
|
||
|
||
// pdfFirstPagePNG uses pdftoppm when available.
|
||
func (t Tools) pdfFirstPagePNG(pdfPath, workDir string) (string, error) {
|
||
pdftoppm, err := exec.LookPath("pdftoppm")
|
||
if err != nil {
|
||
return "", fmt.Errorf("pdftoppm not found (needed to rasterize PDF for hOCR)")
|
||
}
|
||
outBase := filepath.Join(workDir, trimExt(filepath.Base(pdfPath))+".page")
|
||
cmd := exec.Command(pdftoppm, "-png", "-f", "1", "-singlefile", "-r", "200", pdfPath, outBase)
|
||
var stderr bytes.Buffer
|
||
cmd.Stderr = &stderr
|
||
if err := cmd.Run(); err != nil {
|
||
return "", fmt.Errorf("pdftoppm: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||
}
|
||
png := outBase + ".png"
|
||
if _, err := os.Stat(png); err != nil {
|
||
return "", fmt.Errorf("pdftoppm: missing %s", png)
|
||
}
|
||
return png, nil
|
||
}
|