e904b667c6
Build/Publish Develop Docs / deploy (push) Failing after 1s
PaddleOCR Code Style Check / check-code-style (push) Failing after 1s
PaddleOCR PR Tests GPU / detect-changes (push) Failing after 1s
PaddleOCR PR Tests / detect-changes (push) Failing after 1s
PaddleOCR PR Tests GPU / test-pr-gpu (push) Has been cancelled
PaddleOCR PR Tests / test-pr (push) Has been cancelled
PaddleOCR PR Tests GPU / test-pr-gpu-impl (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.13) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.8) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.9) (push) Has been cancelled
330 lines
10 KiB
Go
330 lines
10 KiB
Go
// Copyright (c) 2026 PaddlePaddle 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.
|
|
|
|
package paddleocr
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"mime/multipart"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
type apiResponse struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
|
|
type submitResponse struct {
|
|
JobID string `json:"jobId"`
|
|
}
|
|
|
|
type jobStatusResponse struct {
|
|
JobID string `json:"jobId"`
|
|
State string `json:"state"`
|
|
ExtractProgress json.RawMessage `json:"extractProgress"`
|
|
ResultURL map[string]string `json:"resultUrl"`
|
|
ErrorMsg string `json:"errorMsg"`
|
|
}
|
|
|
|
type extractProgress struct {
|
|
TotalPages int `json:"totalPages"`
|
|
ExtractedPages int `json:"extractedPages"`
|
|
StartTime string `json:"startTime"`
|
|
EndTime string `json:"endTime"`
|
|
}
|
|
|
|
func (c *Client) submitURL(ctx context.Context, model, fileURL string, payload interface{}, pageRanges, batchID string) (string, error) {
|
|
body := map[string]interface{}{
|
|
"fileUrl": fileURL,
|
|
"model": model,
|
|
"optionalPayload": payload,
|
|
}
|
|
if pageRanges != "" {
|
|
body["pageRanges"] = pageRanges
|
|
}
|
|
if batchID != "" {
|
|
body["batchId"] = batchID
|
|
}
|
|
jsonBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "failed to encode submit request", Cause: err}}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.jobsURL, bytes.NewReader(jsonBody))
|
|
if err != nil {
|
|
return "", &NetworkError{PaddleOCRAPIError{Message: err.Error()}}
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
c.setClientPlatformHeader(req)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", classifyHTTPError(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if err := raiseForResponse(resp); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
apiResp, err := decodeAPIResponse(resp)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var sr submitResponse
|
|
if err := json.Unmarshal(apiResp.Data, &sr); err != nil {
|
|
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "submit response is malformed", Cause: err}}
|
|
}
|
|
if sr.JobID == "" {
|
|
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "submit response is missing jobId"}}
|
|
}
|
|
return sr.JobID, nil
|
|
}
|
|
|
|
func (c *Client) submitFile(ctx context.Context, model, filePath string, payload interface{}, pageRanges, batchID string) (string, error) {
|
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
|
return "", &FileNotFoundError{Path: filePath, PaddleOCRAPIError: PaddleOCRAPIError{Message: "File not found: " + filePath}}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
w := multipart.NewWriter(&buf)
|
|
|
|
_ = w.WriteField("model", model)
|
|
payloadJSON, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "failed to encode optionalPayload", Cause: err}}
|
|
}
|
|
if err := w.WriteField("optionalPayload", string(payloadJSON)); err != nil {
|
|
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "failed to prepare multipart body", Cause: err}}
|
|
}
|
|
if pageRanges != "" {
|
|
_ = w.WriteField("pageRanges", pageRanges)
|
|
}
|
|
if batchID != "" {
|
|
_ = w.WriteField("batchId", batchID)
|
|
}
|
|
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return "", &FileNotFoundError{Path: filePath, PaddleOCRAPIError: PaddleOCRAPIError{Message: "Failed to open file: " + filePath, Cause: err}}
|
|
}
|
|
defer file.Close()
|
|
|
|
fw, err := w.CreateFormFile("file", filepath.Base(filePath))
|
|
if err != nil {
|
|
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "failed to prepare multipart body", Cause: err}}
|
|
}
|
|
if _, err := io.Copy(fw, file); err != nil {
|
|
return "", &NetworkError{PaddleOCRAPIError{Message: "failed to read file content", Cause: err}}
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "failed to finalize multipart body", Cause: err}}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.jobsURL, &buf)
|
|
if err != nil {
|
|
return "", &NetworkError{PaddleOCRAPIError{Message: err.Error()}}
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
|
c.setClientPlatformHeader(req)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", classifyHTTPError(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if err := raiseForResponse(resp); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
apiResp, err := decodeAPIResponse(resp)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var sr submitResponse
|
|
if err := json.Unmarshal(apiResp.Data, &sr); err != nil {
|
|
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "submit response is malformed", Cause: err}}
|
|
}
|
|
if sr.JobID == "" {
|
|
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "submit response is missing jobId"}}
|
|
}
|
|
return sr.JobID, nil
|
|
}
|
|
|
|
func (c *Client) getJobStatus(ctx context.Context, jobID string) (*jobStatusResponse, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", c.jobsURL+"/"+jobID, nil)
|
|
if err != nil {
|
|
return nil, &NetworkError{PaddleOCRAPIError{Message: err.Error()}}
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
c.setClientPlatformHeader(req)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, classifyHTTPError(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if err := raiseForResponse(resp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
apiResp, err := decodeAPIResponse(resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var status jobStatusResponse
|
|
if err := json.Unmarshal(apiResp.Data, &status); err != nil {
|
|
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "status response is malformed", Cause: err}}
|
|
}
|
|
if status.State == "" {
|
|
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "status response is missing state"}}
|
|
}
|
|
return &status, nil
|
|
}
|
|
|
|
func (c *Client) getBatchStatus(ctx context.Context, batchID string) (*BatchStatus, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", c.jobsURL+"/batch/"+batchID, nil)
|
|
if err != nil {
|
|
return nil, &NetworkError{PaddleOCRAPIError{Message: err.Error(), Cause: err}}
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
c.setClientPlatformHeader(req)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, classifyHTTPError(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if err := raiseForResponse(resp); err != nil {
|
|
return nil, err
|
|
}
|
|
apiResp, err := decodeAPIResponse(resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var payload struct {
|
|
ExtractResult []jobStatusResponse `json:"extractResult"`
|
|
}
|
|
if err := json.Unmarshal(apiResp.Data, &payload); err != nil {
|
|
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "batch response is malformed", Cause: err}}
|
|
}
|
|
result := &BatchStatus{BatchID: batchID}
|
|
for _, item := range payload.ExtractResult {
|
|
jobID := item.JobID
|
|
if jobID == "" {
|
|
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "batch response item is missing jobId"}}
|
|
}
|
|
status, err := normalizeStatus(jobID, &item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.Jobs = append(result.Jobs, status)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (c *Client) fetchJSONL(ctx context.Context, url string) ([]map[string]interface{}, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return nil, &NetworkError{PaddleOCRAPIError{Message: err.Error()}}
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, classifyHTTPError(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if err := raiseForResponse(resp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
|
var results []map[string]interface{}
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var obj map[string]interface{}
|
|
if err := json.Unmarshal([]byte(line), &obj); err != nil {
|
|
return nil, &ResultParseError{PaddleOCRAPIError{Message: "failed to parse JSONL result payload", Cause: err}}
|
|
}
|
|
results = append(results, obj)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func raiseForResponse(resp *http.Response) error {
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
return nil
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
msg := string(body)
|
|
|
|
switch {
|
|
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
|
return &AuthError{PaddleOCRAPIError{Message: "Authentication failed: " + msg}}
|
|
case resp.StatusCode == 400:
|
|
return &InvalidRequestError{PaddleOCRAPIError{Message: "Bad request: " + msg}}
|
|
case resp.StatusCode == 429:
|
|
return &RateLimitError{APIError{StatusCode: 429, PaddleOCRAPIError: PaddleOCRAPIError{Message: "Rate limit exceeded: " + msg}}}
|
|
case resp.StatusCode == 503 || resp.StatusCode == 504:
|
|
return &ServiceUnavailableError{APIError{StatusCode: resp.StatusCode, PaddleOCRAPIError: PaddleOCRAPIError{Message: "Service unavailable: " + msg}}}
|
|
default:
|
|
return &APIError{StatusCode: resp.StatusCode, PaddleOCRAPIError: PaddleOCRAPIError{Message: msg}}
|
|
}
|
|
}
|
|
|
|
func decodeAPIResponse(resp *http.Response) (*apiResponse, error) {
|
|
var apiResp apiResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
|
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "expected a JSON response body", Cause: err}}
|
|
}
|
|
if apiResp.Code != 0 {
|
|
msg := apiResp.Msg
|
|
if msg == "" {
|
|
msg = "PaddleOCR official API request failed"
|
|
}
|
|
return nil, &APIError{StatusCode: resp.StatusCode, PaddleOCRAPIError: PaddleOCRAPIError{Message: msg}}
|
|
}
|
|
if len(apiResp.Data) == 0 || string(apiResp.Data) == "null" {
|
|
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "response body is missing data"}}
|
|
}
|
|
return &apiResp, nil
|
|
}
|
|
|
|
func classifyHTTPError(err error) error {
|
|
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
|
return &RequestTimeoutError{PaddleOCRAPIError: PaddleOCRAPIError{Message: err.Error(), Cause: err}}
|
|
}
|
|
return &NetworkError{PaddleOCRAPIError{Message: err.Error(), Cause: err}}
|
|
}
|