baidu_ocr_client.dart 4.41 KB
import 'dart:convert';
import 'dart:typed_data';

import 'package:dio/dio.dart';

/// 百度智能云通用文字识别(标准版)客户端。
///
/// 免费额度:个人实名认证 1000 次/月,企业实名认证 2000 次/月。
/// 文档:https://cloud.baidu.com/doc/OCR/s/dk3iqnq51
class BaiduOcrClient {
  BaiduOcrClient({
    required this.apiKey,
    required this.secretKey,
  });

  final String apiKey;
  final String secretKey;

  static const String _tokenHost = 'aip.baidubce.com';
  static const String _ocrHost = 'aip.baidubce.com';
  static const String _ocrPath = '/rest/2.0/ocr/v1/general_basic';

  final Dio _dio = Dio(
    BaseOptions(
      connectTimeout: const Duration(seconds: 15),
      receiveTimeout: const Duration(seconds: 30),
      responseType: ResponseType.json,
    ),
  );

  String? _accessToken;
  DateTime? _tokenExpiresAt;

  /// 识别图片中的文字并返回多行文本。
  Future<String> recognize(Uint8List imageBytes) async {
    if (imageBytes.isEmpty) {
      throw const BaiduOcrException(0, '图片内容为空');
    }

    final String accessToken = await _fetchAccessToken();
    final String imageBase64 = base64Encode(imageBytes);

    final Response<Map<String, dynamic>> response;
    try {
      response = await _dio.post<Map<String, dynamic>>(
        'https://$_ocrHost$_ocrPath?access_token=$accessToken',
        data: <String, String>{
          'image': imageBase64,
          'language_type': 'CHN_ENG',
          'detect_direction': 'true',
        },
        options: Options(
          contentType: Headers.formUrlEncodedContentType,
        ),
      );
    } on DioException catch (error) {
      throw BaiduOcrException(
        error.response?.statusCode ?? 0,
        '图片识别网络请求失败,请检查网络',
      );
    }

    final Map<String, dynamic>? data = response.data;
    if (data == null) {
      throw BaiduOcrException(response.statusCode ?? 0, '图片识别返回数据异常');
    }

    final int errorCode = data['error_code'] as int? ?? 0;
    if (errorCode != 0) {
      throw BaiduOcrException(
        errorCode,
        _friendlyMessage(errorCode, data['error_msg']?.toString()),
      );
    }

    final List<dynamic>? wordsResult = data['words_result'] as List<dynamic>?;
    if (wordsResult == null) {
      return '';
    }

    final List<String> lines = <String>[];
    for (final dynamic item in wordsResult) {
      if (item is Map) {
        final String text = item['words']?.toString() ?? '';
        if (text.trim().isNotEmpty) {
          lines.add(text.trim());
        }
      }
    }
    return lines.join('\n');
  }

  String _friendlyMessage(int errorCode, String? serverMessage) {
    switch (errorCode) {
      case 17:
        return '今日免费识别额度已用完,请明天再试';
      case 18:
        return '识别请求太频繁,请稍后重试';
      case 19:
        return '本月免费识别额度已用完';
      default:
        return serverMessage ?? '图片识别失败';
    }
  }

  Future<String> _fetchAccessToken() async {
    final DateTime now = DateTime.now();
    if (_accessToken != null &&
        _tokenExpiresAt != null &&
        now.isBefore(_tokenExpiresAt!)) {
      return _accessToken!;
    }

    final Response<Map<String, dynamic>> response;
    try {
      response = await _dio.post<Map<String, dynamic>>(
        'https://$_tokenHost/oauth/2.0/token',
        queryParameters: <String, String>{
          'grant_type': 'client_credentials',
          'client_id': apiKey,
          'client_secret': secretKey,
        },
      );
    } on DioException catch (error) {
      throw BaiduOcrException(
        error.response?.statusCode ?? 0,
        '获取图片识别凭证失败,请检查网络',
      );
    }

    final Map<String, dynamic>? data = response.data;
    final String? token = data?['access_token']?.toString();
    if (token == null || token.isEmpty) {
      throw BaiduOcrException(
        0,
        data?['error_description']?.toString() ?? '图片识别凭证获取失败',
      );
    }

    final int expiresIn = data?['expires_in'] as int? ?? 2592000;
    _accessToken = token;
    _tokenExpiresAt = now.add(Duration(seconds: expiresIn - 60));
    return token;
  }
}

class BaiduOcrException implements Exception {
  const BaiduOcrException(this.code, this.message);

  final int code;
  final String message;

  @override
  String toString() => 'BaiduOcrException($code, $message)';
}