//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// PaddleOCR client for the async Job API.
// Mirrors Python's deepdoc/parser/paddleocr_parser.py:
// - Submit job via POST /api/v2/ocr/jobs
// - Poll with exponential backoff until "done" or "failed"
// - Fetch result JSONL and extract text
//
// For image parsing, only parse_image() semantics are needed;
// parse_pdf() is handled by the PDF vision dispatch path.
package parser
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"regexp"
"strings"
"time"
"ragflow/internal/common"
)
// imgTagPattern matches HTML
tags and
wrappers, mirroring
// Python's _MARKDOWN_IMAGE_PATTERN in deepdoc/parser/paddleocr_parser.py.
var imgTagPattern = regexp.MustCompile(
`(?is)
]*>\s*
![]()
]*/>\s*
|
![]()
]*/>`,
)
const (
defaultPaddleOCRBaseURL = "https://paddleocr.aistudio-app.com"
defaultPaddleOCRTimeout = 600 * time.Second
defaultPaddleOCRAlgo = "PaddleOCR-VL"
paddleOCRSubmitPath = "/api/v2/ocr/jobs"
paddleOCRPollInterval = 3 * time.Second
paddleOCRPollMultiplier = 1.5
paddleOCRPollMaxInterval = 15 * time.Second
)
// PaddleOCRClient talks to the PaddleOCR async Job API.
type PaddleOCRClient struct {
BaseURL string
AccessToken string
Algorithm string
Timeout time.Duration
httpClient *http.Client
}
// NewPaddleOCRClientFromEnv creates a client from environment variables:
//
// PADDLEOCR_BASE_URL – base URL (default https://paddleocr.aistudio-app.com)
// PADDLEOCR_ACCESS_TOKEN – bearer token
// PADDLEOCR_ALGORITHM – algorithm name (default PaddleOCR-VL)
func NewPaddleOCRClientFromEnv() *PaddleOCRClient {
baseURL := common.GetEnv(common.EnvPaddleOCRBaseUrl)
if baseURL == "" {
baseURL = defaultPaddleOCRBaseURL
}
return &PaddleOCRClient{
BaseURL: strings.TrimRight(baseURL, "/"),
AccessToken: common.GetEnv(common.EnvPaddleOCRAccessToken),
Algorithm: firstNonEmpty(common.GetEnv(common.EnvPaddleOCRAlgorithm), defaultPaddleOCRAlgo),
Timeout: defaultPaddleOCRTimeout,
httpClient: &http.Client{
Timeout: 30 * time.Second, // per-request timeout; polling uses deadline
},
}
}
// Enabled reports whether the client has a usable access token.
func (c *PaddleOCRClient) Enabled() bool {
return c != nil && c.AccessToken != ""
}
// ParseImage submits the image to PaddleOCR and returns extracted text.
// Mirrors Python's PaddleOCRParser.parse_image().
func (c *PaddleOCRClient) ParseImage(binary []byte, filename string) (string, error) {
deadline := time.Now().Add(c.Timeout)
// Step 1: Submit job
jobID, err := c.submitJob(binary, filename, deadline)
if err != nil {
return "", fmt.Errorf("paddleocr submit: %w", err)
}
// Step 2: Poll until done
resultData, err := c.pollJob(jobID, deadline)
if err != nil {
return "", fmt.Errorf("paddleocr poll: %w", err)
}
// Step 3: Fetch result JSONL
raw, err := c.fetchResult(resultData, deadline)
if err != nil {
return "", fmt.Errorf("paddleocr fetch: %w", err)
}
// Step 4: Parse result
return c.extractImageText(raw), nil
}
// submitJob POSTs the image file to /api/v2/ocr/jobs and returns the job ID.
func (c *PaddleOCRClient) submitJob(binary []byte, filename string, deadline time.Time) (string, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
// Write the image file field
fw, err := w.CreateFormFile("file", filename)
if err != nil {
return "", err
}
if _, err := fw.Write(binary); err != nil {
return "", err
}
// Write form fields
if err := w.WriteField("model", c.Algorithm); err != nil {
return "", err
}
optionalPayload := map[string]any{"formatBlockContent": true}
payloadBytes, _ := json.Marshal(optionalPayload)
if err := w.WriteField("optionalPayload", string(payloadBytes)); err != nil {
return "", err
}
w.Close()
url := c.BaseURL + paddleOCRSubmitPath
req, err := http.NewRequest(http.MethodPost, url, &buf)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Client-Platform", "ragflow")
if c.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
}
req = req.WithContext(withDeadline(req.Context(), deadline))
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("POST %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var submitResp struct {
Data struct {
JobID string `json:"jobId"`
} `json:"data"`
JobID string `json:"jobId"`
}
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
return "", fmt.Errorf("decode submit response: %w", err)
}
jobID := submitResp.Data.JobID
if jobID == "" {
jobID = submitResp.JobID
}
if jobID == "" {
return "", fmt.Errorf("no jobId in submit response")
}
slog.Info("paddleocr: job submitted", "jobId", jobID)
return jobID, nil
}
// pollJob polls the job status until it reaches "done" or "failed".
// Uses exponential backoff: 3s initial, 1.5x multiplier, 15s max.
// Returns the final poll response data.
func (c *PaddleOCRClient) pollJob(jobID string, deadline time.Time) (map[string]any, error) {
pollURL := fmt.Sprintf("%s/%s", c.BaseURL+paddleOCRSubmitPath, jobID)
interval := paddleOCRPollInterval
for {
if time.Now().After(deadline) {
return nil, fmt.Errorf("job %s timed out after %v", jobID, c.Timeout)
}
remaining := time.Until(deadline)
if remaining <= 0 {
return nil, fmt.Errorf("job %s timed out", jobID)
}
req, err := http.NewRequest(http.MethodGet, pollURL, nil)
if err != nil {
return nil, err
}
if c.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
}
req.Header.Set("Client-Platform", "ragflow")
req = req.WithContext(withDeadline(req.Context(), deadline))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("poll %s: %w", jobID, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("poll HTTP %d: %s", resp.StatusCode, string(body))
}
var pollResp struct {
Data struct {
State string `json:"state"`
ErrorMsg string `json:"errorMsg"`
} `json:"data"`
State string `json:"state"`
ErrorMsg string `json:"errorMsg"`
}
bodyBytes, _ := io.ReadAll(resp.Body)
if err := json.Unmarshal(bodyBytes, &pollResp); err != nil {
return nil, fmt.Errorf("decode poll response: %w", err)
}
state := pollResp.Data.State
if state == "" {
state = pollResp.State
}
switch state {
case "done":
slog.Info("paddleocr: job done", "jobId", jobID)
// Return full data so caller can extract resultJsonUrl
var fullData map[string]any
json.Unmarshal(bodyBytes, &fullData)
return fullData, nil
case "failed":
errMsg := pollResp.Data.ErrorMsg
if errMsg == "" {
errMsg = pollResp.ErrorMsg
}
return nil, fmt.Errorf("job %s failed: %s", jobID, errMsg)
}
// Exponential backoff
sleepTime := interval
if remaining < sleepTime {
sleepTime = remaining
}
time.Sleep(sleepTime)
interval = time.Duration(float64(interval) * paddleOCRPollMultiplier)
if interval > paddleOCRPollMaxInterval {
interval = paddleOCRPollMaxInterval
}
}
}
// fetchResult downloads and parses the JSONL result from resultJsonUrl.
func (c *PaddleOCRClient) fetchResult(pollData map[string]any, deadline time.Time) ([]map[string]any, error) {
data, _ := pollData["data"].(map[string]any)
if data == nil {
data = pollData
}
resultJSONURL, _ := data["resultJsonUrl"].(string)
if resultJSONURL == "" {
if resultURL, ok := data["resultUrl"].(map[string]any); ok {
resultJSONURL, _ = resultURL["jsonUrl"].(string)
}
}
if resultJSONURL == "" {
return nil, fmt.Errorf("no resultJsonUrl in completion response")
}
req, err := http.NewRequest(http.MethodGet, resultJSONURL, nil)
if err != nil {
return nil, err
}
if c.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
}
req = req.WithContext(withDeadline(req.Context(), deadline))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch result: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("fetch result HTTP %d: %s", resp.StatusCode, string(body))
}
content, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read result: %w", err)
}
// Parse JSONL: one JSON object per line
var lines []map[string]any
for _, line := range strings.Split(strings.TrimSpace(string(content)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var obj map[string]any
if err := json.Unmarshal([]byte(line), &obj); err != nil {
return nil, fmt.Errorf("parse JSONL line: %w", err)
}
lines = append(lines, obj)
}
return lines, nil
}
// extractImageText extracts text from PaddleOCR JSONL results.
// Mirrors Python's parse_image() text extraction:
//
// layoutParsingResults[].prunedResult.parsing_res_list[].block_content
// Fallback: ocrResults[].prunedResult.rec_texts[]
func (c *PaddleOCRClient) extractImageText(resultLines []map[string]any) string {
var texts []string
for _, line := range resultLines {
result, _ := line["result"].(map[string]any)
if result == nil {
continue
}
// Primary: layoutParsingResults
if lpr, ok := result["layoutParsingResults"].([]any); ok {
for _, lr := range lpr {
lrMap, _ := lr.(map[string]any)
if lrMap == nil {
continue
}
pruned, _ := lrMap["prunedResult"].(map[string]any)
if pruned == nil {
continue
}
if prl, ok := pruned["parsing_res_list"].([]any); ok {
for _, block := range prl {
blockMap, _ := block.(map[string]any)
if blockMap == nil {
continue
}
content, _ := blockMap["block_content"].(string)
content = strings.TrimSpace(content)
// Remove markdown image blocks
content = removeMarkdownImages(content)
if content != "" {
texts = append(texts, content)
}
}
}
}
}
}
// Fallback: ocrResults for text-only models (e.g. PP-OCRv6)
if len(texts) == 0 {
for _, line := range resultLines {
result, _ := line["result"].(map[string]any)
if result == nil {
continue
}
if ocr, ok := result["ocrResults"].([]any); ok {
for _, o := range ocr {
ocrMap, _ := o.(map[string]any)
if ocrMap == nil {
continue
}
pruned, _ := ocrMap["prunedResult"].(map[string]any)
if pruned == nil {
continue
}
if recTexts, ok := pruned["rec_texts"].([]any); ok {
for _, t := range recTexts {
if s, ok := t.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
texts = append(texts, s)
}
}
}
}
}
}
}
}
if len(texts) == 0 {
return ""
}
return strings.Join(texts, "\n")
}
// removeMarkdownImages strips markdown image syntax () and HTML
// img/div blocks, mirroring Python's _remove_images_from_markdown.
func removeMarkdownImages(md string) string {
// Strip HTML
![]()
and
blocks
md = imgTagPattern.ReplaceAllString(md, "")
return strings.TrimSpace(md)
}
// withDeadline applies a deadline to a context, respecting the existing
// deadline if it is sooner.
func withDeadline(ctx context.Context, deadline time.Time) context.Context {
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
return ctx
}
newCtx, _ := context.WithDeadline(ctx, deadline)
return newCtx
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}