wesmiler 5 mesi fa
parent
commit
f92f93dc14
85 ha cambiato i file con 2638 aggiunte e 108 eliminazioni
  1. 62 1
      app/Helpers/common.php
  2. 1 0
      app/Http/Controllers/Api/UploadController.php
  3. 90 6
      app/Http/Controllers/Api/v1/ExamController.php
  4. 3 4
      app/Http/Controllers/Api/v1/PaperController.php
  5. 34 3
      app/Http/Controllers/Api/v1/TestController.php
  6. 0 1
      app/Models/ExamAnswerModel.php
  7. 237 59
      app/Services/Api/ExamService.php
  8. 150 31
      app/Services/Api/PaperService.php
  9. 154 0
      app/Services/DeepSeekService.php
  10. 1 0
      composer.json
  11. 56 1
      composer.lock
  12. 2 2
      config/platform.php
  13. BIN
      public/uploads/temp/20250922/answer.png
  14. BIN
      public/uploads/temp/20250922/answer1.png
  15. BIN
      public/uploads/temp/20250922/t1.png
  16. BIN
      public/uploads/temp/20250922/topic.png
  17. BIN
      public/uploads/temp/20250922/topic1.png
  18. BIN
      public/uploads/temp/20250922/topic2.jpeg
  19. BIN
      public/uploads/temp/20250922/topic2.png
  20. BIN
      public/uploads/temp/20251020/M0pMcGhBQ3VkcnYyN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  21. BIN
      public/uploads/temp/20251020/MGFlOW5nbVNDU1F2N2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  22. BIN
      public/uploads/temp/20251020/MUJ4NjA5ZEFKNUhvN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  23. BIN
      public/uploads/temp/20251020/NlBJUFRMZHlXelA2N2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  24. BIN
      public/uploads/temp/20251020/RU84WlZXMkkwb2ZuN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  25. BIN
      public/uploads/temp/20251020/RVVtWVJXZGJyamw1N2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  26. BIN
      public/uploads/temp/20251020/RjNQTTlkRmNSQUoxN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  27. BIN
      public/uploads/temp/20251020/RlQ2T3NYVkl0ODgyN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  28. BIN
      public/uploads/temp/20251020/RlppdnlRbVlqNkNmN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  29. BIN
      public/uploads/temp/20251020/TXg2VW1DRUFGZ2p1N2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  30. BIN
      public/uploads/temp/20251020/UXlENTRQb1psYVY1N2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  31. BIN
      public/uploads/temp/20251020/VU9EZWRBSm5UcTRyN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  32. BIN
      public/uploads/temp/20251020/Vlh5R0lmeVIwa2k2N2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  33. BIN
      public/uploads/temp/20251020/WGVCQkdkUzFuSndlN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  34. BIN
      public/uploads/temp/20251020/Y2FzcXZSRktlTnlrN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  35. BIN
      public/uploads/temp/20251020/Z0ROMkVCN1hadDFyZWQwODlhYjRlOWRiMWQ4OTE2MDkyNzAxZTkxNTg2MjkuanBn.jpg
  36. BIN
      public/uploads/temp/20251020/aDlLMWVsdVlkd3JXN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  37. BIN
      public/uploads/temp/20251020/aXZuM3p2OXNYcFUxN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  38. BIN
      public/uploads/temp/20251020/b09GMVlMYmlGZUluN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  39. BIN
      public/uploads/temp/20251020/bVlMYUtUTnZhZWc4N2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  40. BIN
      public/uploads/temp/20251020/dk83R0t0Qm0zakhxN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  41. BIN
      public/uploads/temp/20251020/dmNwTU4zM3R1dE9RMWRjMjhkMWZmODEwZGUxNTlmYWUwODhmNjc4ODRhZTMuanBn.jpg
  42. BIN
      public/uploads/temp/20251020/dzZpSWNwdWVpdjJHN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png
  43. BIN
      public/uploads/temp/20251021/MHhrREhrZmJ3NnpIYzdiZDdhZTVkNWZmNDcwZTgzMTJiNzVjYjAxMWQ1OTAucG5n.png
  44. 7 0
      routes/api.php
  45. 25 0
      vendor/autoload.php
  46. 21 0
      vendor/thiagoalessio/tesseract_ocr/.appveyor.yml
  47. 113 0
      vendor/thiagoalessio/tesseract_ocr/.github/workflows/ci.yml
  48. 13 0
      vendor/thiagoalessio/tesseract_ocr/.github/workflows/container-images/tesseract-3.02.Dockerfile
  49. 13 0
      vendor/thiagoalessio/tesseract_ocr/.github/workflows/container-images/tesseract-3.03.Dockerfile
  50. 13 0
      vendor/thiagoalessio/tesseract_ocr/.github/workflows/container-images/tesseract-3.04.Dockerfile
  51. 13 0
      vendor/thiagoalessio/tesseract_ocr/.github/workflows/container-images/tesseract-3.05.Dockerfile
  52. 21 0
      vendor/thiagoalessio/tesseract_ocr/.github/workflows/container-images/tesseract-4.00.Dockerfile
  53. 13 0
      vendor/thiagoalessio/tesseract_ocr/.github/workflows/container-images/tesseract-4.1.0.Dockerfile
  54. 13 0
      vendor/thiagoalessio/tesseract_ocr/.github/workflows/container-images/tesseract-4.1.1.Dockerfile
  55. 4 0
      vendor/thiagoalessio/tesseract_ocr/.gitignore
  56. 19 0
      vendor/thiagoalessio/tesseract_ocr/MIT-LICENSE
  57. 4 0
      vendor/thiagoalessio/tesseract_ocr/codecov.yml
  58. 35 0
      vendor/thiagoalessio/tesseract_ocr/composer.json
  59. 11 0
      vendor/thiagoalessio/tesseract_ocr/phpcs.xml
  60. 80 0
      vendor/thiagoalessio/tesseract_ocr/src/Command.php
  61. 7 0
      vendor/thiagoalessio/tesseract_ocr/src/FeatureNotAvailableException.php
  62. 120 0
      vendor/thiagoalessio/tesseract_ocr/src/FriendlyErrors.php
  63. 7 0
      vendor/thiagoalessio/tesseract_ocr/src/ImageNotFoundException.php
  64. 7 0
      vendor/thiagoalessio/tesseract_ocr/src/NoWritePermissionsForOutputFile.php
  65. 79 0
      vendor/thiagoalessio/tesseract_ocr/src/Option.php
  66. 80 0
      vendor/thiagoalessio/tesseract_ocr/src/Process.php
  67. 7 0
      vendor/thiagoalessio/tesseract_ocr/src/TesseractNotFoundException.php
  68. 181 0
      vendor/thiagoalessio/tesseract_ocr/src/TesseractOCR.php
  69. 7 0
      vendor/thiagoalessio/tesseract_ocr/src/TesseractOcrException.php
  70. 7 0
      vendor/thiagoalessio/tesseract_ocr/src/UnsuccessfulCommandException.php
  71. 47 0
      vendor/thiagoalessio/tesseract_ocr/tests/Common/TestCase.php
  72. 148 0
      vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/FriendlyErrors.php
  73. 186 0
      vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/ReadmeExamples.php
  74. BIN
      vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/images/8055.png
  75. BIN
      vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/images/file`with`backtick.png
  76. BIN
      vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/images/german.png
  77. BIN
      vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/images/mixed-languages.png
  78. 1 0
      vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/images/not-an-image.txt
  79. BIN
      vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/images/text.png
  80. 92 0
      vendor/thiagoalessio/tesseract_ocr/tests/Unit/CommandTest.php
  81. 108 0
      vendor/thiagoalessio/tesseract_ocr/tests/Unit/OptionTest.php
  82. 42 0
      vendor/thiagoalessio/tesseract_ocr/tests/Unit/OutputFileTest.php
  83. 211 0
      vendor/thiagoalessio/tesseract_ocr/tests/Unit/TesseractOCRTest.php
  84. 14 0
      vendor/thiagoalessio/tesseract_ocr/tests/Unit/TestableCommand.php
  85. 79 0
      vendor/thiagoalessio/tesseract_ocr/tests/run.php

+ 62 - 1
app/Helpers/common.php

@@ -565,6 +565,29 @@ if (!function_exists('format_time')) {
     }
 }
 
+if (!function_exists('format_times')) {
+
+    /**
+     * 格式化时间
+     * @param int $time 时间戳
+     * @author laravel开发员
+     * @date 2019/5/23
+     */
+    function format_times($time, $type=0)
+    {
+        if($type == 1){
+            $hour = $time>=3600? intval($time/3600) : 0;
+            $minute = $time>=60? intval($time%3600/60) : 0;
+            $second = intval($time%60);
+            return ($hour<10?'0'.$hour:$hour).':'.($minute<10?'0'.$minute:$minute).':'.($second<10?'0'.$second:$second);
+        }else{
+            $minute = $time>=60? intval($time/60) : 0;
+            $second = intval($time%60);
+            return ($minute<10?'0'.$minute:$minute).':'.($second<10?'0'.$second:$second);
+        }
+    }
+}
+
 if (!function_exists('format_bytes')) {
 
     /**
@@ -1397,7 +1420,7 @@ if (!function_exists('message')) {
      */
     function message($msg = "操作成功", $success = true, $data = [], $code = 0, $type = 'json')
     {
-        $result = ['success' => $success, 'msg' => lang($msg), 'data' => $data, 'stime' => time()];
+        $result = ['success' => $success, 'msg' => lang($msg), 'data' => $success==true || env('APP_DEBUG')?$data:[], 'stime' => time()];
         if ($success) {
             $result['code'] = 0;
         } else {
@@ -1829,6 +1852,8 @@ if (!function_exists('upload_image')) {
     }
 }
 
+
+
 if (!function_exists('upload_file')) {
 
     /**
@@ -2142,6 +2167,7 @@ if (!function_exists('httpRequest')) {
             curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
             $ret = curl_exec($ch);
             $ret = trim($ret);
+            var_dump($ret);
             curl_close($ch);
             if (!preg_match("/^{/", $ret)) {
                 return ['code' => 'err', 'msg' => $ret];
@@ -2160,6 +2186,41 @@ if (!function_exists('httpRequest')) {
     }
 }
 
+if (!function_exists('aiRequest')) {
+    /**
+     *
+     * 接口请求
+     * @param $url 接口地址
+     * @param $data
+     * @param $type
+     * @param int $timeout
+     * @return mixed
+     * @author wesmiler
+     */
+    function aiRequest($url, $data = '', $timeout = 60, $header = [])
+    {
+
+            set_time_limit($timeout);
+            $ch = curl_init($url);
+            if ($header) {
+                curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
+            }
+            curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
+            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
+            curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);    //禁止 cURL 验证对等证书
+            curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);    //是否检测服务器的域名与证书上的是否一致
+            curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
+            curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
+            $ret = curl_exec($ch);
+            curl_close($ch);
+            if (preg_match("/^{/", $ret)) {
+                return json_decode($ret, true);
+            }
+
+            return $ret;
+    }
+}
+
 if (!function_exists('getVersion')) {
     /**
      * 获取版本号

+ 1 - 0
app/Http/Controllers/Api/UploadController.php

@@ -52,6 +52,7 @@ class UploadController extends webApp
         return showJson(MESSAGE_OK, true, ['url'=> $file_path, 'path'=> $result['data']['img_path']]);
     }
 
+
     /**
      * 上传文件(单个上传)
      * @param Request $request

+ 90 - 6
app/Http/Controllers/Api/v1/ExamController.php

@@ -19,10 +19,16 @@ class ExamController extends webApp
      */
     public function index()
     {
-        $params = request()->post();
-        $pageSize = request()->post('pageSize', 15);
-        $datas = ExamService::make()->getDataList($params, $pageSize);
-        return message(1010, true, $datas);
+        try {
+            $params = request()->post();
+            $pageSize = request()->post('pageSize', 10);
+            $params['user_id'] = $this->userId;
+            $datas = ExamService::make()->getDataList($params, $pageSize);
+            return message(1010, true, $datas);
+        } catch (\Exception $exception) {
+            $error = ['error' => $exception->getMessage(), 'trace' => $exception->getTrace()];
+            return message(1009, false, $error);
+        }
     }
 
     /**
@@ -33,15 +39,93 @@ class ExamController extends webApp
     {
         try {
             $params = request()->post();
-            $pageSize = request()->post('pageSize', 15);
+            $pageSize = request()->post('pageSize', 10);
+            $params['user_id'] = $this->userId;
             $datas = ExamService::make()->getHistoryList($params, $pageSize);
             return message(1010, true, $datas);
         } catch (\Exception $exception) {
-            return message(1009, false);
+            $error = ['error' => $exception->getMessage(), 'trace' => $exception->getTrace()];
+            return message(1009, false, $error);
+        }
+    }
+
+    /**
+     * 每日一练
+     * @return array
+     */
+    public function practice()
+    {
+        try {
+            $params = request()->post();
+            $pageSize = request()->post('pageSize', 10);
+            $datas = ExamService::make()->getPracticeList($this->userId, $params, $pageSize);
+            return message(1010, true, $datas);
+        } catch (\Exception $exception) {
+            $error = ['error' => $exception->getMessage(), 'trace' => $exception->getTrace()];
+            return message(1009, false, $error);
+        }
+    }
+
+    /**
+     * 答题
+     * @return array
+     */
+    public function answer()
+    {
+        try {
+            $params = request()->all();
+            if(ExamService::make()->answer($this->userId, $params)){
+                return showJson(1002, true);
+            }else{
+                return showJson(ExamService::make()->getError(), false);
+            }
+        } catch (\Exception $exception) {
+            $error = ['error' => $exception->getMessage(), 'trace' => $exception->getTrace()];
+            return message(1009, false, $error);
         }
     }
 
     /**
+     * 交卷
+     * @return array
+     */
+    public function submit()
+    {
+        try {
+            $params = request()->all();
+            if(ExamService::make()->submit($this->userId, $params)){
+                return showJson(1002, true);
+            }else{
+                return showJson(ExamService::make()->getError(), false);
+            }
+        } catch (\Exception $exception) {
+            $error = ['error' => $exception->getMessage(), 'trace' => $exception->getTrace()];
+            return message(1009, false, $error);
+        }
+    }
+
+    /**
+     * 再次答题
+     * @return array
+     */
+    public function reset()
+    {
+        try {
+            $id = request()->post('id');
+            if(ExamService::make()->reset($id)){
+                return showJson(1002, true);
+            }else{
+                return showJson(ExamService::make()->getError(), false);
+            }
+        } catch (\Exception $exception) {
+            $error = ['error' => $exception->getMessage(), 'trace' => $exception->getTrace()];
+            return message(1009, false, $error);
+        }
+    }
+    
+
+
+    /**
      * 详情
      */
     public function info()

+ 3 - 4
app/Http/Controllers/Api/v1/PaperController.php

@@ -32,19 +32,18 @@ class PaperController extends webApp
         try {
             $params = request()->all();
             $id = isset($params['id'])? intval($params['id']) : 0;
-            $tid = isset($params['tid'])? intval($params['tid']) : 0;
-            $rid = isset($params['rid'])? intval($params['rid']) : 0;
             if(empty($id)){
                 return message(1036, false);
             }
 
-            if($info = PaperService::make()->getInfo($this->userId, $id, $tid, $rid)){
+            if($info = PaperService::make()->getInfo($this->userId, $id, $params)){
                 return message(1010, true, $info);
             }else{
                 return message(1009, false);
             }
         }catch (\Exception $exception){
-            return message(1009, false, $exception->getMessage());
+            $error = ['error' => $exception->getMessage(), 'trace' => $exception->getTrace()];
+            return message(1009, false, $error);
         }
     }
 

+ 34 - 3
app/Http/Controllers/Api/v1/TestController.php

@@ -3,9 +3,8 @@
 namespace App\Http\Controllers\Api\v1;
 
 use App\Http\Controllers\Api\webApp;
-use App\Services\Api\MessageService;
-use App\Services\MapService;
-use App\Services\RedisService;
+use App\Services\DeepSeekService;
+use thiagoalessio\TesseractOCR\TesseractOCR;
 
 /**
  * 测试
@@ -18,6 +17,38 @@ class TestController extends webApp
 
     public function check()
     {
+        $result = (new TesseractOCR(ATTACHMENT_PATH.'/temp/20250922/answer1.jpeg'))->lang('chi_sim')->run();
+        dump($result);
+        return 66;
 
+
+        $data = [
+//            'answer'=> '表达了诗人孤独、愁苦的心境和对时光流逝的感慨。',
+            'answer'=> 'https://shuati.derkj.com/uploads/temp/20250922/answer1.jpeg',
+            'score'=>10,
+            'topic'=> 'https://shuati.derkj.com/uploads/temp/20250922/topic2.png',
+//            'topic'=> '2.请简述一下杜甫的诗《登高》中“无边落木萧萧下,不尽长江滚滚来”这两句诗描绘了怎样的景象?表达了诗人怎样的情感?',
+            'type'=> 2
+        ];
+
+
+        $result = DeepSeekService::make()->upload('/temp/20250922/topic2.jpeg');
+//        $result = DeepSeekService::make()->apiRequest($data,'deepseek-chat');
+        dump($result);
+//        $papers = [28,29];
+//        $datas = [];
+//        $topics = ExamTopicModel::where(['paper_id'=>27])->orderBy('id')->get();
+//        $topics = $topics? $topics->toArray() : [];
+//        foreach ($papers as $paperId){
+//            foreach($topics as &$item){
+//                unset($item['id']);
+//                $item['paper_id'] = $paperId;
+//                $datas[] = $item;
+//            }
+//        }
+//
+//        ExamTopicModel::insert($datas);
+
+//        var_dump($topics);
     }
 }

+ 0 - 1
app/Models/ExamAnswerModel.php

@@ -10,5 +10,4 @@ class ExamAnswerModel extends BaseModel
 {
     protected $table = 'exam_answers';
 
-
 }

+ 237 - 59
app/Services/Api/ExamService.php

@@ -11,7 +11,10 @@
 
 namespace App\Services\Api;
 
+use App\Models\ExamAccessLogModel;
 use App\Models\ExamAnswerModel;
+use App\Models\ExamPaperModel;
+use App\Models\ExamTopicModel;
 use App\Models\MemberAnswerRankModel;
 use App\Services\BaseService;
 use App\Services\ConfigService;
@@ -57,31 +60,31 @@ class ExamService extends BaseService
      */
     public function getDataList($params, $pageSize = 15)
     {
-        $page = isset($params['page'])? $params['page'] : 1;
-        $cacheKey = "caches:exams:list_{$page}_{$pageSize}:".md5(json_encode($params));
+        $page = isset($params['page']) ? $params['page'] : 1;
+        $cacheKey = "caches:exams:list_{$page}_{$pageSize}:" . md5(json_encode($params));
         $datas = RedisService::get($cacheKey);
-        if($datas){
+        if ($datas) {
             return $datas;
         }
 
         $query = $this->getQuery($params);
-        $list = $query->select(['a.id','a.user_id','a.paper_id','a.score','a.accurate_count','b.name','b.type','b.topic_count','b.score_total','b.is_charge','a.create_time','a.answer_times','a.status'])
-            ->orderBy('a.create_time','desc')
+        $list = $query->select(['a.id', 'a.user_id', 'a.paper_id', 'a.score', 'a.accurate_count', 'b.name', 'b.type', 'b.topic_count', 'b.score_total', 'b.is_charge', 'a.create_time', 'a.answer_times', 'a.status'])
+            ->orderBy('a.create_time', 'desc')
             ->paginate($pageSize > 0 ? $pageSize : 9999999);
-        $list = $list? $list->toArray() :[];
-        if($list){
-            foreach($list['data'] as &$item){
-                $item['create_time'] = $item['create_time']? datetime($item['create_time'],'Y-m-d H.i.s') : '';
+        $list = $list ? $list->toArray() : [];
+        if ($list) {
+            foreach ($list['data'] as &$item) {
+                $item['create_time'] = $item['create_time'] ? datetime($item['create_time'], 'Y-m-d H.i.s') : '';
             }
         }
 
-        $rows = isset($list['data'])? $list['data'] : [];
+        $rows = isset($list['data']) ? $list['data'] : [];
         $datas = [
-            'pageSize'=> $pageSize,
-            'total'=> isset($list['total'])? $list['total'] : 0,
-            'list'=> $rows
+            'pageSize' => $pageSize,
+            'total' => isset($list['total']) ? $list['total'] : 0,
+            'list' => $rows
         ];
-        if($rows){
+        if ($rows) {
             RedisService::set($cacheKey, $datas, rand(300, 600));
         }
 
@@ -95,33 +98,37 @@ class ExamService extends BaseService
      */
     public function getQuery($params)
     {
-        $where = ['b.status'=>1,'b.mark'=>1,'a.mark' => 1];
-        $status = isset($params['status'])? $params['status'] : 0;
-        $type = isset($params['type'])? $params['type'] : 0;
-        $sceneType = isset($params['scene_type'])? $params['scene_type'] : 0;
-        $subjectId = isset($params['subject_id'])? $params['subject_id'] : 0;
-        if($status>0){
+        $where = ['b.status' => 1, 'b.mark' => 1, 'a.mark' => 1];
+        $status = isset($params['status']) ? $params['status'] : 0;
+        $type = isset($params['type']) ? $params['type'] : 0;
+        $sceneType = isset($params['scene_type']) ? $params['scene_type'] : 0;
+        $userId = isset($params['user_id']) ? $params['user_id'] : 0;
+        $subjectId = isset($params['subject_id']) ? $params['subject_id'] : 0;
+        if ($userId > 0) {
+            $where['a.user_id'] = $userId;
+        }
+        if ($status > 0) {
             $where['a.status'] = $status;
         }
-        if($type>0){
+        if ($type > 0) {
             $where['b.type'] = $type;
         }
 
-        if($sceneType>0){
+        if ($sceneType > 0) {
             $where['b.scene_type'] = $sceneType;
         }
 
-        if($subjectId>0){
+        if ($subjectId > 0) {
             $where['b.subject_id'] = $subjectId;
         }
 
         return $this->model->from('exam_answers as a')
-            ->leftJoin('exam_papers as b','b.id','=','a.paper_id')
+            ->leftJoin('exam_papers as b', 'b.id', '=', 'a.paper_id')
             ->where($where)
-            ->where(function ($query) use($params){
-                $keyword = isset($params['keyword'])? $params['keyword'] : '';
-                if($keyword){
-                    $query->where('b.name','like',"%{$keyword}%");
+            ->where(function ($query) use ($params) {
+                $keyword = isset($params['keyword']) ? $params['keyword'] : '';
+                if ($keyword) {
+                    $query->where('b.name', 'like', "%{$keyword}%");
                 }
             });
     }
@@ -132,33 +139,98 @@ class ExamService extends BaseService
      * @param int $pageSize
      * @return array|mixed
      */
-    public function getHistoryList($params, $pageSize=10)
+    public function getHistoryList($params, $pageSize = 10)
     {
-        $page = isset($params['page'])? $params['page'] : 1;
-        $cacheKey = "caches:exams:history_{$page}_{$pageSize}:".md5(json_encode($params));
+        $page = isset($params['page']) ? $params['page'] : 1;
+        $cacheKey = "caches:exams:history_{$page}_{$pageSize}:" . md5(json_encode($params));
         $datas = RedisService::get($cacheKey);
-        if($datas){
+        if ($datas) {
             return $datas;
         }
 
         $query = $this->getQuery($params);
-        $list = $query->select(['a.id','a.user_id','a.paper_id','a.score','a.accurate_count','b.name','b.type','b.topic_count','b.score_total','b.is_charge','a.create_time','a.answer_times','a.status'])
-            ->orderBy('a.create_time','desc')
+        $list = $query->select(['a.id', 'a.user_id', 'a.paper_id', 'a.score', 'a.accurate_count', 'b.name', 'b.type', 'b.topic_count', 'b.score_total', 'b.is_charge', 'a.create_time', 'a.answer_times', 'a.status'])
+            ->orderBy('a.create_time', 'desc')
+            ->paginate($pageSize > 0 ? $pageSize : 9999999);
+        $list = $list ? $list->toArray() : [];
+        if ($list) {
+            foreach ($list['data'] as &$item) {
+                $item['create_time'] = $item['create_time'] ? datetime($item['create_time'], 'Y-m-d H.i.s') : '';
+            }
+        }
+
+        $rows = isset($list['data']) ? $list['data'] : [];
+        $datas = [
+            'pageSize' => $pageSize,
+            'total' => isset($list['total']) ? $list['total'] : 0,
+            'list' => $rows
+        ];
+        if ($rows) {
+            RedisService::set($cacheKey, $datas, rand(10, 20));
+        }
+
+        return $datas;
+    }
+
+    /**
+     * 每日一练目录数据
+     * @param $userId 用户ID
+     * @param $params
+     * @param $pageSize
+     * @return array|mixed
+     */
+    public function getPracticeList($userId, $params, $pageSize = 10)
+    {
+        $page = isset($params['page']) ? $params['page'] : 1;
+        $type = isset($params['type']) ? $params['type'] : 1;
+        $cacheKey = "caches:exams:practice_{$userId}:{$page}_" . md5(json_encode($params));
+        $datas = RedisService::get($cacheKey);
+        // 每日一练访问次数统计
+        if(empty($sc)){
+            ExamAccessLogModel::saveLog(date('Y-m-d'), $type, 1);
+        }
+
+        if ($datas) {
+            return $datas;
+        }
+
+        $list = $this->model->from('exam_answers as a')
+            ->leftJoin('exam_papers as b', 'b.id', '=', 'a.paper_id')
+            ->where(['b.scene_type' => 1, 'b.status' => 1, 'b.mark' => 1, 'a.mark' => 1])
+            ->where(function ($query) use ($params) {
+                $type = isset($params['type']) && $params['type'] ? intval($params['type']) : 1;
+                if ($type > 0) {
+                    $query->where('b.type', $type);
+                }
+            })
+            ->select(['a.*', 'b.name', 'b.scene_type', 'b.type', 'b.score_total', 'b.topic_count'])
+            ->orderBy('a.create_time', 'desc')
             ->paginate($pageSize > 0 ? $pageSize : 9999999);
-        $list = $list? $list->toArray() :[];
-        if($list){
-            foreach($list['data'] as &$item){
-                $item['create_time'] = $item['create_time']? datetime($item['create_time'],'Y-m-d H.i.s') : '';
+        $list = $list ? $list->toArray() : [];
+        if ($list) {
+            foreach ($list['data'] as &$item) {
+                $item['date'] = $item['create_time'] ? datetime($item['create_time'], 'Y-m-d') : '';
+            }
+        }
+
+        // 今日是否练习过
+        $rows = isset($list['data']) ? $list['data'] : [];
+        $first = isset($rows[0]) ? $rows[0] : [];
+        $firstTime = isset($first['date']) ? $first['date'] : 0;
+        if ($page == 1 && strtotime($firstTime) < strtotime(date('Y-m-d'))) {
+            $type = isset($params['type']) && $params['type'] ? intval($params['type']) : 1;
+            $data = PaperService::make()->getRandomPaper($userId, $type, 1);
+            if($data){
+                $rows = array_merge([$data], $rows);
             }
         }
 
-        $rows = isset($list['data'])? $list['data'] : [];
         $datas = [
-            'pageSize'=> $pageSize,
-            'total'=> isset($list['total'])? $list['total'] : 0,
-            'list'=> $rows
+            'pageSize' => $pageSize,
+            'total' => isset($list['total']) ? $list['total'] : 0,
+            'list' => $rows
         ];
-        if($rows){
+        if ($rows) {
             RedisService::set($cacheKey, $datas, rand(10, 20));
         }
 
@@ -173,38 +245,38 @@ class ExamService extends BaseService
      */
     public function getRankByType($type, $num = 0)
     {
-        $num = $num? $num : ConfigService::make()->getConfigByCode('rank_num', 10);
+        $num = $num ? $num : ConfigService::make()->getConfigByCode('rank_num', 10);
         $cacheKey = "caches:exams:ranks:{$type}_{$num}";
         $datas = RedisService::get($cacheKey);
-        if($datas){
+        if ($datas) {
             return $datas;
         }
 
-        $prefix = env('DB_PREFIX','lev_');
+        $prefix = env('DB_PREFIX', 'lev_');
         $datas = MemberAnswerRankModel::from('member_answer_ranks as a')
-            ->leftJoin('member as b','b.id','=','a.user_id')
-            ->where(['a.status'=>1,'a.mark'=>1])
-            ->where(function($query) use($type){
-                if($type==1){
+            ->leftJoin('member as b', 'b.id', '=', 'a.user_id')
+            ->where(['a.status' => 1, 'a.mark' => 1])
+            ->where(function ($query) use ($type) {
+                if ($type == 1) {
                     // 日
                     $query->where('a.date', date('Y-m-d'));
-                }else if($type == 2){
+                } else if ($type == 2) {
                     // 周
-                    $query->where('a.date','>=', date('Y-m-d', time() - 7 * 86400));
-                }else if($type == 3){
+                    $query->where('a.date', '>=', date('Y-m-d', time() - 7 * 86400));
+                } else if ($type == 3) {
                     // 月
-                    $query->where('a.date','>=', date('Y-m-01'));
+                    $query->where('a.date', '>=', date('Y-m-01'));
                 }
             })
-            ->select(['a.id','a.user_id','b.avatar','b.nickname','a.answer_time','a.answer_count',DB::raw("ROUND(sum({$prefix}a.answer_time)/3600,0) as answer_hour"),DB::raw("sum({$prefix}a.answer_count) as count")])
+            ->select(['a.id', 'a.user_id', 'b.avatar', 'b.nickname', 'a.answer_time', 'a.answer_count', DB::raw("ROUND(sum({$prefix}a.answer_time)/3600,0) as answer_hour"), DB::raw("sum({$prefix}a.answer_count) as count")])
             ->groupBy('a.user_id')
             ->orderByRaw("sum({$prefix}a.answer_time)/3600 desc")
             ->take($num)
             ->get();
-        $datas = $datas? $datas->toArray() : [];
-        if($datas){
-            foreach ($datas as &$item){
-                $item['avatar'] = $item['avatar']? get_image_url($item['avatar']) : '';
+        $datas = $datas ? $datas->toArray() : [];
+        if ($datas) {
+            foreach ($datas as &$item) {
+                $item['avatar'] = $item['avatar'] ? get_image_url($item['avatar']) : '';
             }
 
             RedisService::set($cacheKey, $datas, rand(20, 30));
@@ -212,4 +284,110 @@ class ExamService extends BaseService
         return $datas;
     }
 
+    /**
+     * 重新答题,清除答题记录数据
+     * @param $id
+     * @return bool
+     */
+    public function reset($id)
+    {
+        $log = $this->model->where(['id'=> $id,'mark'=>1])->first();
+        if(empty($log)){
+            return true;
+        }
+
+        $updateData = ['score'=>0,'accurate_count'=>0,'answer_count'=>0,'answer_times'=>0,'answer_last_id'=>0,'is_submit'=>0,'status'=>1];
+        $this->model->where(['id'=> $id,'mark'=>1])->update($updateData);
+        return true;
+    }
+
+    public function answer($userId, $params)
+    {
+        $paperId = isset($params['id'])? $params['id'] : 0;
+        $rid = isset($params['rid'])? $params['rid'] : 0;
+        $tid = isset($params['tid'])? $params['tid'] : 0;
+        $type = isset($params['type'])? $params['type'] : 1;
+        $isSubmit = isset($params['is_submit'])? $params['is_submit'] : 1;
+        $answer = isset($params['answer'])? $params['answer'] : '';
+        $answerImage = isset($params['answer_image'])? $params['answer_image'] : '';
+        $answerType = isset($params['answer_type']) && $params['answer_type']? $params['answer_type'] : 2;
+        if($isSubmit<=0 && empty($answer)){
+            $this->error = '请先提交答案';
+            return false;
+        }
+
+        if($answerType==1 && empty($answerImage)){
+            $this->error = '请先上传图片答案';
+            return false;
+        }
+
+        // 试卷数据
+        $paperInfo = ExamPaperModel::where(['id'=> $paperId,'type'=>$type,'status'=>1,'mark'=>1])
+            ->first();
+        $correctAnswer = isset($paperInfo['correct_answer'])? $paperInfo['correct_answer'] : '';
+        $sceneType = isset($paperInfo['scene_type']) && $paperInfo['scene_type']? $paperInfo['scene_type'] : 1;
+        if(empty($paperInfo)){
+            $this->error = '试题数据错误,请返回刷新重试';
+            return false;
+        }
+
+        // 题目数据
+        $topicInfo = ExamTopicModel::where(['id'=> $tid,'paper_id'=>$paperId,'status'=>1,'mark'=>1])->first();
+        $topicType = isset($topicInfo['topic_type'])? trim($topicInfo['topic_type']) : '';
+        if(empty($topicInfo) || empty($topicType)){
+            $this->error = '题库已更新,请返回刷新重试';
+            return false;
+        }
+
+        // 答题记录
+        $submit = 0;
+        if($rid){
+            $answerInfo = ExamAnswerModel::where(['id'=>$rid,'status'=>1,'mark'=>1])->first();
+            $submit = isset($answerInfo['is_submit'])? $answerInfo['is_submit'] : 0;
+            if(empty($answerInfo)){
+                $rid = 0;
+            }
+        }
+
+        // 验证答案内容类型和数据
+        // 每日一练
+        if($sceneType == 1 && $rid<=0){
+            // 今日记录
+            $answerInfo = ExamAnswerModel::where(['paper_id'=>$paperId,'status'=>1,'mark'=>1])->where('create_time','>=', strtotime(date('Y-m-d')))->first();
+            $rid = isset($answerInfo['id'])? $answerInfo['id'] : 0;
+            $submit = isset($answerInfo['is_submit'])? $answerInfo['is_submit'] : 0;
+        }
+
+        // 是否已交卷
+        if($submit == 1){
+            $this->error = '您已交卷';
+            return false;
+        }
+
+        /* TODO 验证答案 */
+        if(in_array($topicType,['选择题','单选题'])){
+
+        } else if (in_array($topicType, ['简答题','计算题','阅读理解'])){
+            // 图片答案AI验证
+            if($answerType == 1){
+
+
+            }else {
+
+
+            }
+        }
+
+
+
+
+        if($rid){
+            $data = [
+                ''
+            ];
+        }
+
+
+    }
+
 }

+ 150 - 31
app/Services/Api/PaperService.php

@@ -12,6 +12,7 @@
 namespace App\Services\Api;
 
 use App\Models\ExamAnswerModel;
+use App\Models\ExamAnswerTopicModel;
 use App\Models\ExamPaperModel;
 use App\Models\ExamTopicModel;
 use App\Services\BaseService;
@@ -152,7 +153,7 @@ class PaperService extends BaseService
                     $query->where('is_submit', $submit)->orWhere('answer_last_at','<', time() - $lastTime * 60);
                 }
             })
-            ->select(['id','paper_id','score','answer_last_at'])
+            ->select(['id','paper_id','score','answer_last_at','answer_last_id'])
             ->first();
         $data = $data? $data->toArray() : [];
         if($data){
@@ -166,97 +167,181 @@ class PaperService extends BaseService
      * @param $id
      * @return array|mixed
      */
-    public function getInfo($userId, $paperId, $tid=0, $rid=0)
+    public function getInfo($userId, $paperId, $params=[])
     {
-        $cacheKey = "caches:paper:info_{$userId}:p{$paperId}_t{$tid}_r{$rid}";
+        $lid = isset($params['lid'])? intval($params['lid']) : 0;
+        $tid = isset($params['tid'])? intval($params['tid']) : 0;
+        $rid = isset($params['rid'])? intval($params['rid']) : 0;
+        $type = isset($params['type'])? intval($params['type']) : 1;
+        $cacheKey = "caches:paper:info_{$userId}:p{$paperId}_t{$tid}_r{$rid}_l{$lid}";
         $info = RedisService::get($cacheKey);
         if($info){
             return $info;
         }
 
         // 若进行答题
-        if($rid<=0){
+        if($rid<=0 && $type != 1) {
             // 判断N分钟内是否有未交卷的答题
             $lastAnswerInfo = $this->getLastAnswer($userId, $paperId, 0);
-            $rid = isset($lastAnswerInfo['id'])? $lastAnswerInfo['id'] : 0;
+            $rid = isset($lastAnswerInfo['id']) ? $lastAnswerInfo['id'] : 0;
         }
-
         $where = ['a.id'=> $paperId,'a.status'=>1,'a.mark'=>1];
         $info = $this->model->from('exam_papers as a')
-            ->leftJoin('exam_answers as b','b.paper_id','=','a.id')
-            ->where($where)
-            ->where(function($query) use($rid){
-                if($rid>0){
-                    $query->where(['b.id'=>$rid]);
-                }
+            ->leftJoin('exam_answers as b',function($join) use($rid){
+                $join->on('b.paper_id','=','a.id')->where(['b.id'=>$rid]);
             })
-            ->select(['a.id as paper_id','b.id as rid','b.score','b.accurate_count','b.answer_times','a.name','a.type','a.scene_type','a.subject_id','a.score_total','a.topic_count','a.is_charge','a.create_time','a.status'])
+            ->where($where)
+            ->select(['a.id as paper_id','b.id as rid','b.score','b.accurate_count','b.answer_count','b.answer_last_id','b.answer_times','a.name','a.type','a.scene_type','a.subject_id','a.score_total','a.topic_count','a.total_time','a.is_charge','a.create_time','a.status'])
             ->first();
         $info = $info? $info->toArray() : [];
+
         if($info){
             $info['create_time'] = $info['create_time']? datetime($info['create_time'],'Y-m-d') : '';
+            $lastId = isset($info['answer_last_id'])? $info['answer_last_id'] : 0;
+            $info['answer_count'] = isset($info['answer_count'])? intval($info['answer_count']) : 0;
+            $info['accurate_count'] = isset($info['accurate_count'])? intval($info['accurate_count']) : 0;
+            $info['score'] = isset($info['score'])? intval($info['score']) : 0;
+            $info['rid'] = isset($info['rid'])? intval($info['rid']) : 0;
+
+            // 剩余时间
+            $totalTime = isset($info['total_time'])? intval($info['total_time']) : 0;
+            $totalTime = $totalTime>0? $totalTime : ConfigService::make()->getConfigByCode('answer_total_time', 1800);
+            $info['answer_times'] = isset($info['answer_times'])? intval($info['answer_times']) : 0;
+            $info['remain_time'] = $totalTime>$info['answer_times']? $totalTime-$info['answer_times'] : 0;
+            $info['remain_time_text'] = $info['remain_time']? format_times($info['remain_time']) : '00:00';
 
             // 当前题目
             //$prefix = env('DB_PREFIX','_lev');
-            $info['topic'] = ExamTopicModel::from('exam_topics as a')
+            $model = ExamTopicModel::from('exam_topics as a')
                 ->leftJoin('exam_answers_topics as b', function($join) use($rid){
                     // 是否有最近答题记录
-                    $join->on('b.topic_id','=',"a.id")->where("b.answer_log_id",'=', $rid);
+                    $join->on('b.topic_id','=',"a.id")->where("b.answer_log_id",'=', $rid)->where("b.mark",'=', 1);
                 })
                 ->where(['a.paper_id'=> $paperId,'a.status'=>1,'a.mark'=>1])
-                ->where(function($query) use($tid){
+                ->where(function($query) use($tid, $lastId){
                     // 答题卡选择的题目,否则默认按题目排序返回第一题
                     if($tid>0){
                       $query->where('a.id', $tid);
+                    }else if($lastId){
+                        $query->where('a.id', '>', $lastId);
                     }
-                })
-                ->select(['a.*','b.id as answer_topic_id','b.answer_log_id','b.answer as submit_answer','b.answer_type as submit_answer_type','b.score as submit_score','b.accurate'])
+                });
+            $model1 = clone  $model;
+            if($model->value('a.id')<=0 && $lastId){
+                $model = $model1->where('a.id', $lastId);
+            }
+            $info['topic'] = $model->select(['a.*','b.id as answer_topic_id','b.answer_log_id','b.answer as submit_answer','b.answer_type as submit_answer_type','b.score as submit_score','b.accurate'])
                 ->orderBy('a.sort','desc')
                 ->orderBy('a.id','asc')
                 ->first();
-            if($info['topic']) {
+            $info['topic'] = $info['topic']? $info['topic']->toArray() : [];
+            $topicId = isset($info['topic']['id'])? $info['topic']['id'] : 0;
+            if($info['topic'] && $topicId) {
+                $info['topic']['accurate'] = isset($info['topic']['accurate'])?$info['topic']['accurate'] : -1;
+                $info['topic']['submit_answer'] = isset($info['topic']['submit_answer'])?$info['topic']['submit_answer'] : '';
+                $info['topic']['submit_answer_type'] = isset($info['topic']['submit_answer_type'])?$info['topic']['submit_answer_type'] : 1;
+                $info['topic']['submit_answers'] = '';
+                $info['topic']['collect_answers'] = [];
+                if($info['topic']['submit_answer_type'] == 3){
+                    $info['topic']['submit_answer'] = get_image_url($info['topic']['submit_answer']);
+                }else if($info['topic']['submit_answer_type'] == 4){
+                    $info['topic']['submit_answer'] = $info['topic']['submit_answer']?json_decode($info['topic']['submit_answer'], true):[];
+                }
+
                 if($info['topic']['show_type'] == 1){
                     $info['topic']['topic_name'] = format_content($info['topic']['topic_name']);
-                    if($rid<=0){
-                        $info['topic']['correct_answer'] = crypt_answer($info['topic']['id'], $info['topic']['correct_answer']);
-                        $info['topic']['topic_analysis'] = '';
-                    }
                 }else if($info['topic']['show_type'] == 2){
                     $info['topic']['topic_name'] = get_image_url($info['topic']['topic_name']);
 
                     // 已经答题,返回答案
-                    if($rid>0){
+                    if($rid>0 || $info['topic']['submit_answer']){
                         $info['topic']['topic_analysis'] = get_image_url($info['topic']['topic_analysis']);
-                        if(preg_match("/images/", $info['topic']['correct_answer'])){
+                        if(preg_match("/(images|temp)/", $info['topic']['correct_answer'])){
                             $info['topic']['correct_answer'] = get_image_url($info['topic']['correct_answer']);
                         }
 
-                        if(preg_match("/images/", $info['topic']['answer_A'])){
+                        if(preg_match("/(images|temp)/", $info['topic']['answer_A'])){
                             $info['topic']['answer_A'] = get_image_url($info['topic']['answer_A']);
                         }
 
-                        if(preg_match("/images/", $info['topic']['answer_B'])){
+                        if(preg_match("/(images|temp)/", $info['topic']['answer_B'])){
                             $info['topic']['answer_B'] = get_image_url($info['topic']['answer_B']);
                         }
 
-                        if(preg_match("/images/", $info['topic']['answer_C'])){
+                        if(preg_match("/(images|temp)/", $info['topic']['answer_C'])){
                             $info['topic']['answer_C'] = get_image_url($info['topic']['answer_C']);
                         }
 
-                        if(preg_match("/images/", $info['topic']['answer_D'])){
+                        if(preg_match("/(images|temp)/", $info['topic']['answer_D'])){
                             $info['topic']['answer_D'] = get_image_url($info['topic']['answer_D']);
                         }
 
-                        if(preg_match("/images/", $info['topic']['answer_E'])){
+                        if(preg_match("/(images|temp)/", $info['topic']['answer_E'])){
                             $info['topic']['answer_E'] = get_image_url($info['topic']['answer_E']);
                         }
 
-                        if(preg_match("/images/", $info['topic']['answer_F'])){
+                        if(preg_match("/(images|temp)/", $info['topic']['answer_F'])){
                             $info['topic']['answer_F'] = get_image_url($info['topic']['answer_F']);
                         }
                     }
                 }
 
+                // 未答题隐藏答案
+                if(empty($info['topic']['submit_answer'])){
+                    $info['topic']['correct_answer'] = '未答题';
+                    $info['topic']['topic_analysis'] = '';
+                }
+
+                // 多选题
+                $topicType = isset($info['topic']['topic_type'])? $info['topic']['topic_type'] : '';
+                if($topicType == '多选题'){
+                    $info['topic']['submit_answers'] = $info['topic']['submit_answer']?explode(',', $info['topic']['submit_answer']) : '';
+                    $info['topic']['correct_answers'] = $info['topic']['correct_answer']?explode(',', $info['topic']['correct_answer']) : [];
+                }
+
+                $info['topic']['answers'] = [];
+                if($info['topic']['answer_A']){
+                    $info['topic']['answers'][] = ['code'=>'A','value'=> $info['topic']['answer_A']];
+                }
+
+                if($info['topic']['answer_B']){
+                    $info['topic']['answers'][] = ['code'=>'B','value'=> $info['topic']['answer_B']];
+                }
+
+                if($info['topic']['answer_C']){
+                    $info['topic']['answers'][] = ['code'=>'C','value'=> $info['topic']['answer_C']];
+                }
+
+                if($info['topic']['answer_D']){
+                    $info['topic']['answers'][] = ['code'=>'D','value'=> $info['topic']['answer_D']];
+                }
+
+                if($info['topic']['answer_E']){
+                    $info['topic']['answers'][] = ['code'=>'E','value'=> $info['topic']['answer_E']];
+                }
+
+                if($info['topic']['answer_F']){
+                    $info['topic']['answers'][] = ['code'=>'F','value'=> $info['topic']['answer_F']];
+                }
+
+                // 上一题
+                $info['last'] = ExamTopicModel::where(['paper_id'=> $paperId,'status'=>1,'mark'=>1])
+                    ->where('id','<', $topicId)
+                    ->select(['id','topic_name'])
+                    ->orderBy('sort','asc')
+                    ->orderBy('id','desc')
+                    ->first();
+                $info['last'] = $info['last']? $info['last']->toArray() :['id'=>0];
+
+                // 下一题
+                $info['next'] = ExamTopicModel::where(['paper_id'=> $paperId,'status'=>1,'mark'=>1])
+                    ->where('id','>', $topicId)
+                    ->select(['id','topic_name'])
+                    ->orderBy('sort','desc')
+                    ->orderBy('id','asc')
+                    ->first();
+                $info['next'] = $info['next']? $info['next']->toArray() :['id'=>0];
+
             }
 
             RedisService::set($cacheKey, $info, rand(10, 20));
@@ -265,4 +350,38 @@ class PaperService extends BaseService
         return $info;
     }
 
+
+    /**
+     * 获取随机试卷
+     * @param $userId 用户
+     * @param $type 试卷类型
+     * @param $sceneType 场景
+     * @param false $refresh
+     * @return array|mixed
+     */
+    public function getRandomPaper($userId, $type, $sceneType, $refresh=false)
+    {
+        $cacheKey = "caches:papers:random_{$userId}:{$type}_{$sceneType}";
+        $data = RedisService::get($cacheKey);
+        if($data && !$refresh){
+            return $data;
+        }
+
+        $data = $this->model->where(['type'=>$type,'scene_type'=>$sceneType,'status'=>1,'mark'=>1])
+            ->orderByRaw('RAND()')
+            ->first();
+        $data = $data? $data->toArray() : [];
+        if($data){
+            $data['score'] = 0;
+            $data['paper_id'] = $data['id'];
+            $data['accurate_count'] = 0;
+            $data['date'] = date('Y-m-d');
+            $data['answer_count'] = 0;
+            $data['answer_times'] = 0;
+
+            RedisService::set($cacheKey, $data, rand(10, 20));
+        }
+
+        return $data;
+    }
 }

+ 154 - 0
app/Services/DeepSeekService.php

@@ -0,0 +1,154 @@
+<?php
+
+namespace App\Services;
+
+/**
+ * DeepSeek服务管理-服务类
+ * @author laravel开发员
+ * @since 2020/11/11
+ * @package App\Services
+ */
+class DeepSeekService extends BaseService
+{
+    // 静态对象
+    protected static $instance = null;
+
+    protected $debug = true;
+    protected $expireTime = 7200; // 缓存日志时长
+    protected $apiKey = '';
+    protected $apiName = '';
+    protected $apiurl = '';
+
+    // 接口地址
+    protected $apiUrls = [
+        // 授权登录
+        'deepseek-chat' => '/chat/completions',
+        'deepseek-reasoner' => '/chat/completions',
+        'upload' => '/v1/ocr/invoice',
+    ];
+
+
+    public function __construct()
+    {
+        $this->apiUrl = ConfigService::make()->getConfigByCode('dk_base_url');
+        $this->apiKey = ConfigService::make()->getConfigByCode('dk_api_key');
+        $this->apiName = ConfigService::make()->getConfigByCode('dk_api_name');
+    }
+
+    /**
+     * 静态入口
+     * @return static|null
+     */
+    public static function make()
+    {
+        if (!self::$instance) {
+            self::$instance = new static();
+        }
+
+        return self::$instance;
+    }
+
+
+    /**
+     * AI 分析接口
+     * @param $params
+     * @param string $model
+     * @return array|false|mixed
+     */
+    public function apiRequest($params, $model='deepseek-chat')
+    {
+        if(empty($this->apiUrl) || empty($this->apiKey) || empty($this->apiName)){
+            $this->error = 'AI接口参数未配置';
+            return false;
+        }
+
+        $headers = [
+            'Content-Type: application/json',
+            'Accept: application/json',
+            'Authorization: Bearer ' . $this->apiKey
+        ];
+        $answer = isset($params['answer'])? $params['answer'] : '';
+        $topic = isset($params['topic'])? $params['topic'] : '';
+        $score = isset($params['score'])? $params['score'] : 0;
+        $type = isset($params['type'])? $params['type'] : 1; // 1-文字答案图片题目,2-文字答案文字题目,3-图片答案图片题目,4-图片答案文字题目
+        $content = "你是一个答题高手";
+        if($type==1){
+            $message = "请判断答案【{$answer}】内容并针对图片[image]{$topic}[/image]中题目共{$score}分给出评分,请返回针对该题目的包含score字段评分、topic字段题目内容以及analyze题目解析字段的结果";
+        }else if ($type == 2){
+            $message = "题目为【{$topic}】的答案【{$answer}】总分共{$score}分能得多少分?请返回针对该题目的包含score字段评分、topic字段题目内容以及analyze题目解析字段的结果";
+        }else if ($type == 3){
+            $file = file_get_contents(ATTACHMENT_PATH.get_image_path($answer));
+            $file = "data:image/jpeg;base64".base64_encode($file);
+            $file1 = file_get_contents(ATTACHMENT_PATH.get_image_path($topic));
+            $file1 = "data:image/jpeg;base64".base64_encode($file1);
+            $message = "请判断图片内容{$file}中答案内容并针对图片{$file1}中题目内容总分共{$score}分给出评分,请返回针对该题目的包含score字段评分、topic字段题目内容以及analyze题目解析字段的结果";
+        }else{
+            $message = "请判断图片[image]{$answer}[/image]中答文字案内容并针对【{$topic}】题目内容总分共{$score}分给出评分,请返回针对该题目的包含score字段评分、topic字段题目内容以及analyze题目解析字段的结果";
+        }
+
+        $file = file_get_contents(ATTACHMENT_PATH.get_image_path($answer));
+//        var_dump($file);
+        $file = "data:image/jpeg;base64,".base64_encode($file);
+        $message = "请上传图片[image]{$answer}[/image],返回分子分析数据";
+        //$message = "请分析图片[image]{$image}[/image]中答案与".($type==1?'题目'.$topic:"图片[image]{$topic}[/image]中题目内容")."的正确度,共{$score}分,请返回包含score字段评分、topic字段题目内容、analyze题目解析字段的结果";
+        dump($message);
+        $data = [
+            'model'=> $model,
+            'messages'=> [
+                [
+                    'role' => 'system',
+                    'content' => $content
+                ],
+                [
+                    'role' => 'user',
+                    'content' => $message
+                ],
+            ],
+            //'stream' => false, //false 非流  true//流返回,需要前端追加和保持长连接
+//            'response_format'=>[
+//                'type'=>'text' //返回格式(text,json_object)
+//            ],
+//            "max_tokens"=>2048, //最大返回token数
+        ];
+
+
+        $url = $this->apiUrl.$this->apiUrls[$model];
+        $result = aiRequest($url, json_encode($data), 20, $headers);
+        dump($result);
+        $choices = isset($result['choices'])? $result['choices'] : [];
+        $choiceData = isset($choices[0]['message'])? $choices[0]['message'] : [];
+        $choiceContent = isset($choiceData['content'])? $choiceData['content'] : '';
+        $content = $choiceContent? str_replace('\n','', $choiceContent) : '';
+        $content = $content? json_decode($content, true) : [];
+        return $content;
+    }
+
+    public function upload($image)
+    {
+        if(empty($this->apiUrl) || empty($this->apiKey) || empty($this->apiName)){
+            $this->error = 'AI接口参数未配置';
+            return false;
+        }
+
+        $headers = [
+            'Content-Type: application/json',
+            'Accept: application/json',
+            'Authorization: Bearer ' . $this->apiKey
+        ];
+
+
+        $filePath = ATTACHMENT_PATH.get_image_path($image);
+        var_dump($filePath);
+        $obj = new \CURLFile($filePath);
+        $data = [
+            'image'=> $obj,
+        ];
+        $url = $this->apiUrl.$this->apiUrls['upload'];
+        var_dump($url);
+//        $url = 'http://127.0.5.12/api/v1/upload/image';
+//        var_dump($options);
+//        $result = file_get_contents($url, false, $context);
+        $result = aiRequest($url, $data, 10, $headers);
+        dump($result);
+    }
+}

+ 1 - 0
composer.json

@@ -20,6 +20,7 @@
         "laravel/socialite": "^5.6",
         "laravel/tinker": "^2.5",
         "maatwebsite/excel": "^3.1",
+        "thiagoalessio/tesseract_ocr": "^2.12",
         "wxkxklmyt/pscws4": "^0.0.2",
         "yansongda/pay": "~3.4.0"
     },

+ 56 - 1
composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "a694da65a01e777afed5c1da9e22bc05",
+    "content-hash": "cc7f1e0988ee9e528e57044730cb6eec",
     "packages": [
         {
             "name": "asm89/stack-cors",
@@ -7544,6 +7544,61 @@
             "time": "2024-11-08T15:21:10+00:00"
         },
         {
+            "name": "thiagoalessio/tesseract_ocr",
+            "version": "2.12.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/thiagoalessio/tesseract-ocr-for-php.git",
+                "reference": "0f10bd7b02bdcba59c4fbd98fbd93a56f93b09b7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/thiagoalessio/tesseract-ocr-for-php/zipball/0f10bd7b02bdcba59c4fbd98fbd93a56f93b09b7",
+                "reference": "0f10bd7b02bdcba59c4fbd98fbd93a56f93b09b7",
+                "shasum": "",
+                "mirrors": [
+                    {
+                        "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%",
+                        "preferred": true
+                    }
+                ]
+            },
+            "require": {
+                "php": "^5.3 || ^7.0 || ^8.0"
+            },
+            "require-dev": {
+                "phpunit/php-code-coverage": "^2.2.4 || ^9.0.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "thiagoalessio\\TesseractOCR\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "thiagoalessio",
+                    "email": "thiagoalessio@me.com"
+                }
+            ],
+            "description": "A wrapper to work with Tesseract OCR inside PHP.",
+            "keywords": [
+                "OCR",
+                "Tesseract",
+                "text recognition"
+            ],
+            "support": {
+                "irc": "irc://irc.freenode.net/tesseract-ocr-for-php",
+                "issues": "https://github.com/thiagoalessio/tesseract-ocr-for-php/issues",
+                "source": "https://github.com/thiagoalessio/tesseract-ocr-for-php"
+            },
+            "time": "2021-06-04T21:21:33+00:00"
+        },
+        {
             "name": "tijsverkoyen/css-to-inline-styles",
             "version": "v2.3.0",
             "source": {

+ 2 - 2
config/platform.php

@@ -4,8 +4,8 @@ return [
         [
             "id" => 1,
             "name" => '每日一练',
-            "code" => 'day-training',
-            'page' => '/pages/exam/training'
+            "code" => 'day-practice',
+            'page' => '/pages/exam/practice'
         ],
         [
             "id" => 2,

BIN
public/uploads/temp/20250922/answer.png


BIN
public/uploads/temp/20250922/answer1.png


BIN
public/uploads/temp/20250922/t1.png


BIN
public/uploads/temp/20250922/topic.png


BIN
public/uploads/temp/20250922/topic1.png


BIN
public/uploads/temp/20250922/topic2.jpeg


BIN
public/uploads/temp/20250922/topic2.png


BIN
public/uploads/temp/20251020/M0pMcGhBQ3VkcnYyN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/MGFlOW5nbVNDU1F2N2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/MUJ4NjA5ZEFKNUhvN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/NlBJUFRMZHlXelA2N2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/RU84WlZXMkkwb2ZuN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/RVVtWVJXZGJyamw1N2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/RjNQTTlkRmNSQUoxN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/RlQ2T3NYVkl0ODgyN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/RlppdnlRbVlqNkNmN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/TXg2VW1DRUFGZ2p1N2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/UXlENTRQb1psYVY1N2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/VU9EZWRBSm5UcTRyN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/Vlh5R0lmeVIwa2k2N2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/WGVCQkdkUzFuSndlN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/Y2FzcXZSRktlTnlrN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/Z0ROMkVCN1hadDFyZWQwODlhYjRlOWRiMWQ4OTE2MDkyNzAxZTkxNTg2MjkuanBn.jpg


BIN
public/uploads/temp/20251020/aDlLMWVsdVlkd3JXN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/aXZuM3p2OXNYcFUxN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/b09GMVlMYmlGZUluN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/bVlMYUtUTnZhZWc4N2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/dk83R0t0Qm0zakhxN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251020/dmNwTU4zM3R1dE9RMWRjMjhkMWZmODEwZGUxNTlmYWUwODhmNjc4ODRhZTMuanBn.jpg


BIN
public/uploads/temp/20251020/dzZpSWNwdWVpdjJHN2Y4YTg4YzYxMjQ1MjQzNThjOWQwNTRjNDBiODQyMGQucG5n.png


BIN
public/uploads/temp/20251021/MHhrREhrZmJ3NnpIYzdiZDdhZTVkNWZmNDcwZTgzMTJiNzVjYjAxMWQ1OTAucG5n.png


+ 7 - 0
routes/api.php

@@ -72,6 +72,13 @@ Route::prefix('v1')->middleware('web.login')->group(function() {
     Route::post('/exam/index', [\App\Http\Controllers\Api\v1\ExamController::class, 'index']);
     Route::post('/exam/history', [\App\Http\Controllers\Api\v1\ExamController::class, 'history']);
     Route::post('/exam/ranks', [\App\Http\Controllers\Api\v1\ExamController::class, 'ranks']);
+    Route::post('/exam/reset', [\App\Http\Controllers\Api\v1\ExamController::class, 'reset']);
+    Route::post('/exam/updateTime', [\App\Http\Controllers\Api\v1\ExamController::class, 'updateTime']);
+    Route::post('/exam/answer', [\App\Http\Controllers\Api\v1\ExamController::class, 'answer']);
+    Route::post('/exam/submit', [\App\Http\Controllers\Api\v1\ExamController::class, 'submit']);
+
+    // 每日一练
+    Route::post('/practice/index', [\App\Http\Controllers\Api\v1\ExamController::class, 'practice']);
 
     // 试卷
     Route::post('/paper/index', [\App\Http\Controllers\Api\v1\PaperController::class, 'index']);

+ 25 - 0
vendor/autoload.php

@@ -0,0 +1,25 @@
+<?php
+
+// autoload.php @generated by Composer
+
+if (PHP_VERSION_ID < 50600) {
+    if (!headers_sent()) {
+        header('HTTP/1.1 500 Internal Server Error');
+    }
+    $err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
+    if (!ini_get('display_errors')) {
+        if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
+            fwrite(STDERR, $err);
+        } elseif (!headers_sent()) {
+            echo $err;
+        }
+    }
+    trigger_error(
+        $err,
+        E_USER_ERROR
+    );
+}
+
+require_once __DIR__ . '/composer/autoload_real.php';
+
+return ComposerAutoloaderInita694da65a01e777afed5c1da9e22bc05::getLoader();

+ 21 - 0
vendor/thiagoalessio/tesseract_ocr/.appveyor.yml

@@ -0,0 +1,21 @@
+---
+build: false
+
+install:
+  - ps: Set-Service wuauserv -StartupType Manual
+  - choco install php
+  - choco install capture2text --version 3.9
+  - choco install composer
+  - refreshenv
+  - cd %APPVEYOR_BUILD_FOLDER%
+  - composer install
+
+test_script:
+  - php tests\run.php unit e2e
+
+notifications:
+  - provider: Webhook
+    url: https://webhooks.gitter.im/e/b48c69af70a9047dba30
+    on_build_success: true
+    on_build_failure: true
+    on_build_status_changed: true

+ 113 - 0
vendor/thiagoalessio/tesseract_ocr/.github/workflows/ci.yml

@@ -0,0 +1,113 @@
+name: CI
+
+on:
+- pull_request
+- push
+
+jobs:
+  unit-tests:
+    strategy:
+      matrix:
+        operating-system:
+        - ubuntu-latest
+        - windows-latest
+        - macos-latest
+        php-version:
+        - '5.3'
+        - '5.4'
+        - '5.5'
+        - '5.6'
+        - '7.0'
+        - '7.1'
+        - '7.2'
+        - '7.3'
+        - '7.4'
+        - '8.0'
+        - '8.1'
+
+    name: unit-tests on php-${{ matrix.php-version }} - ${{ matrix.operating-system }}
+    runs-on: ${{ matrix.operating-system }}
+
+    steps:
+    - name: Checkout code
+      uses: actions/checkout@v2
+
+    - name: Setup PHP
+      uses: shivammathur/setup-php@v2
+      with:
+        php-version: ${{ matrix.php-version }}
+        extensions: dom, libxml, xmlwriter
+        coverage: xdebug
+
+    - name: Install composer dependencies
+      run: composer update --no-ansi --no-interaction --no-progress
+
+    - name: Run unit tests
+      run: php tests/run.php unit
+
+  e2e-tests:
+    strategy:
+      matrix:
+        tesseract-version:
+        - '3.02'
+        - '3.03'
+        - '3.04'
+        - '3.05'
+        - '4.00'
+        - '4.1.0'
+        - '4.1.1'
+
+    name: e2e-tests on tesseract-${{ matrix.tesseract-version }}
+    runs-on: ubuntu-latest
+    container: quay.io/thiagoalessio/tesseract-ocr-for-php-ci:${{ matrix.tesseract-version }}
+
+    steps:
+    - name: Checkout code
+      uses: actions/checkout@v1
+
+    - name: Install dependencies
+      run: composer update --no-ansi --no-interaction --no-progress
+
+    - name: Run end-to-end tests
+      run: php tests/run.php e2e
+
+  code-coverage:
+    strategy:
+      matrix:
+        operating-system:
+        - ubuntu-latest
+        - macos-latest
+
+    name: code-coverage on ${{ matrix.operating-system }}
+    runs-on: ${{ matrix.operating-system }}
+
+    steps:
+    - name: Checkout code
+      uses: actions/checkout@v2
+
+    - name: Setup PHP
+      uses: shivammathur/setup-php@v2
+      with:
+        php-version: '8.1'
+        extensions: dom, libxml, xmlwriter
+        coverage: xdebug
+
+    - name: Install system dependencies (linux)
+      run: >
+        sudo apt-get -y update &&
+        DEBIAN_FRONTEND=noninteractive sudo apt-get -y install
+        tesseract-ocr tesseract-ocr-deu tesseract-ocr-jpn tesseract-ocr-spa
+      if: ${{ matrix.operating-system == 'ubuntu-latest' }}
+
+    - name: Install system dependencies (macos)
+      run: brew install tesseract tesseract-lang
+      if: ${{ matrix.operating-system == 'macos-latest' }}
+
+    - name: Install composer dependencies
+      run: composer update --no-ansi --no-interaction --no-progress
+
+    - name: Run unit + e2e tests
+      run: php tests/run.php unit e2e
+
+    - name: Report code coverage
+      run: bash <(curl -s https://codecov.io/bash)

+ 13 - 0
vendor/thiagoalessio/tesseract_ocr/.github/workflows/container-images/tesseract-3.02.Dockerfile

@@ -0,0 +1,13 @@
+FROM docker.io/library/fedora:20
+
+RUN yum install -y curl git php-cli php-pecl-xdebug unzip \
+	tesseract-3.02.02-3.fc20 \
+	tesseract-langpack-deu \
+	tesseract-langpack-jpn \
+	tesseract-langpack-spa &&\
+	yum clean all && rm -rf /var/cache/yum && rm -rf /var/tmp/yum-*
+
+RUN curl -sko- https://getcomposer.org/installer |\
+	php -- --quiet --filename=composer --install-dir=/usr/local/bin
+
+ENTRYPOINT ["/bin/bash"]

+ 13 - 0
vendor/thiagoalessio/tesseract_ocr/.github/workflows/container-images/tesseract-3.03.Dockerfile

@@ -0,0 +1,13 @@
+FROM docker.io/library/fedora:22
+
+RUN dnf install -y curl git php-cli php-pecl-xdebug unzip \
+	tesseract-3.03-0.4.rc1.fc22 \
+	tesseract-langpack-deu \
+	tesseract-langpack-jpn \
+	tesseract-langpack-spa &&\
+	dnf clean all && rm -rf /var/cache/yum && rm -rf /var/tmp/yum-*
+
+RUN curl -sko- https://getcomposer.org/installer |\
+	php -- --quiet --filename=composer --install-dir=/usr/local/bin
+
+ENTRYPOINT ["/bin/bash"]

+ 13 - 0
vendor/thiagoalessio/tesseract_ocr/.github/workflows/container-images/tesseract-3.04.Dockerfile

@@ -0,0 +1,13 @@
+FROM docker.io/library/fedora:25
+
+RUN dnf install -y curl git php-cli php-pecl-xdebug php-xml unzip \
+	tesseract-3.04.01-2.fc25 \
+	tesseract-langpack-deu \
+	tesseract-langpack-jpn \
+	tesseract-langpack-spa &&\
+	dnf clean all && rm -rf /var/cache/yum && rm -rf /var/tmp/yum-*
+
+RUN curl -sko- https://getcomposer.org/installer |\
+	php -- --quiet --filename=composer --install-dir=/usr/local/bin
+
+ENTRYPOINT ["/bin/bash"]

+ 13 - 0
vendor/thiagoalessio/tesseract_ocr/.github/workflows/container-images/tesseract-3.05.Dockerfile

@@ -0,0 +1,13 @@
+FROM docker.io/library/fedora:29
+
+RUN dnf install -y curl git php-cli php-json php-pecl-xdebug php-xml unzip \
+	tesseract-3.05.02-1.fc29 \
+	tesseract-langpack-deu \
+	tesseract-langpack-jpn \
+	tesseract-langpack-spa &&\
+	dnf clean all && rm -rf /var/cache/yum && rm -rf /var/tmp/yum-*
+
+RUN curl -sko- https://getcomposer.org/installer |\
+	php -- --quiet --filename=composer --install-dir=/usr/local/bin
+
+ENTRYPOINT ["/bin/bash"]

+ 21 - 0
vendor/thiagoalessio/tesseract_ocr/.github/workflows/container-images/tesseract-4.00.Dockerfile

@@ -0,0 +1,21 @@
+FROM ubuntu:18.04
+
+RUN export TZ=Europe/Berlin \
+	&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
+	&& echo $TZ > /etc/timezone
+
+RUN apt-get -y update && \
+	DEBIAN_FRONTEND=noninteractive apt-get -y install \
+	curl git-core unzip php-cli php-xdebug php-xml php-curl \
+	tesseract-ocr=4.00~git2288-10f4998a-2 \
+	tesseract-ocr-deu \
+	tesseract-ocr-jpn \
+	tesseract-ocr-spa \
+	--no-install-recommends &&\
+	apt-get clean &&\
+	rm -rf /var/lib/apt/lists/*
+
+RUN curl -sko- https://getcomposer.org/installer | \
+	php -- --quiet --filename=composer --install-dir=/usr/local/bin
+
+ENTRYPOINT ["/bin/bash"]

+ 13 - 0
vendor/thiagoalessio/tesseract_ocr/.github/workflows/container-images/tesseract-4.1.0.Dockerfile

@@ -0,0 +1,13 @@
+FROM docker.io/library/fedora:31
+
+RUN dnf install -y curl git php-cli php-json php-pecl-xdebug php-xml unzip \
+	tesseract-4.1.0-1.fc31 \
+	tesseract-langpack-deu \
+	tesseract-langpack-jpn \
+	tesseract-langpack-spa &&\
+	dnf clean all && rm -rf /var/cache/yum && rm -rf /var/tmp/yum-*
+
+RUN curl -sko- https://getcomposer.org/installer |\
+	php -- --quiet --filename=composer --install-dir=/usr/local/bin
+
+ENTRYPOINT ["/bin/bash"]

+ 13 - 0
vendor/thiagoalessio/tesseract_ocr/.github/workflows/container-images/tesseract-4.1.1.Dockerfile

@@ -0,0 +1,13 @@
+FROM docker.io/library/fedora:33
+
+RUN dnf install -y curl git php-cli php-json php-pecl-xdebug php-xml unzip \
+	tesseract-4.1.1-4.fc33 \
+	tesseract-langpack-deu \
+	tesseract-langpack-jpn \
+	tesseract-langpack-spa &&\
+	dnf clean all && rm -rf /var/cache/yum && rm -rf /var/tmp/yum-*
+
+RUN curl -sko- https://getcomposer.org/installer |\
+	php -- --quiet --filename=composer --install-dir=/usr/local/bin
+
+ENTRYPOINT ["/bin/bash"]

+ 4 - 0
vendor/thiagoalessio/tesseract_ocr/.gitignore

@@ -0,0 +1,4 @@
+composer.lock
+coverage-report
+coverage.xml
+vendor

+ 19 - 0
vendor/thiagoalessio/tesseract_ocr/MIT-LICENSE

@@ -0,0 +1,19 @@
+Copyright (c) 2012-2018 Thiago Alessio Pereira
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 4 - 0
vendor/thiagoalessio/tesseract_ocr/codecov.yml

@@ -0,0 +1,4 @@
+fixes:
+- "/home/runner/work/tesseract-ocr-for-php/tesseract-ocr-for-php/::"
+- "/Users/runner/work/tesseract-ocr-for-php/tesseract-ocr-for-php/::"
+- "C:\\projects\\tesseract-ocr-for-php\\::"

+ 35 - 0
vendor/thiagoalessio/tesseract_ocr/composer.json

@@ -0,0 +1,35 @@
+{
+	"name": "thiagoalessio/tesseract_ocr",
+	"description": "A wrapper to work with Tesseract OCR inside PHP.",
+	"version": "2.12.0",
+	"type": "library",
+	"keywords": ["Tesseract", "OCR", "text recognition"],
+	"license": "MIT",
+	"authors": [
+		{
+			"name": "thiagoalessio",
+			"email": "thiagoalessio@me.com"
+		}
+	],
+	"support": {
+		"issues": "https://github.com/thiagoalessio/tesseract-ocr-for-php/issues",
+		"irc": "irc://irc.freenode.net/tesseract-ocr-for-php",
+		"source": "https://github.com/thiagoalessio/tesseract-ocr-for-php"
+	},
+	"require": {
+		"php": "^5.3 || ^7.0 || ^8.0"
+	},
+	"require-dev": {
+		"phpunit/php-code-coverage": "^2.2.4 || ^9.0.0"
+	},
+	"autoload": {
+		"psr-4": {
+			"thiagoalessio\\TesseractOCR\\": "src/"
+		}
+	},
+	"autoload-dev": {
+		"psr-4": {
+			"thiagoalessio\\TesseractOCR\\Tests\\": "tests/"
+		}
+	}
+}

+ 11 - 0
vendor/thiagoalessio/tesseract_ocr/phpcs.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<ruleset name="MyStandard">
+	<rule ref="PSR2">
+		<exclude name="Generic.ControlStructures.InlineControlStructure.NotAllowed"/>
+		<exclude name="Generic.Functions.OpeningFunctionBraceBsdAllman.BraceOnSameLine"/>
+		<exclude name="Generic.WhiteSpace.DisallowTabIndent"/>
+		<exclude name="PSR1.Classes.ClassDeclaration.MultipleClasses"/>
+		<exclude name="PSR2.Classes.ClassDeclaration.OpenBraceNewLine"/>
+		<exclude name="Squiz"/>
+	</rule>
+</ruleset>

+ 80 - 0
vendor/thiagoalessio/tesseract_ocr/src/Command.php

@@ -0,0 +1,80 @@
+<?php namespace thiagoalessio\TesseractOCR;
+
+class Command
+{
+	public $executable = 'tesseract';
+	public $useFileAsInput = true;
+	public $useFileAsOutput = true;
+	public $options = array();
+	public $configFile;
+	public $tempDir;
+	public $threadLimit;
+	public $image;
+	public $imageSize;
+	private $outputFile;
+
+	public function __construct($image=null, $outputFile=null)
+	{
+		$this->image = $image;
+		$this->outputFile = $outputFile;
+	}
+
+	public function build() { return "$this"; }
+
+	public function __toString()
+	{
+		$cmd = array();
+		if ($this->threadLimit) $cmd[] = "OMP_THREAD_LIMIT={$this->threadLimit}";
+		$cmd[] = self::escape($this->executable);
+		$cmd[] = $this->useFileAsInput ? self::escape($this->image) : "-";
+		$cmd[] = $this->useFileAsOutput ? self::escape($this->getOutputFile(false)) : "-";
+
+		$version = $this->getTesseractVersion();
+
+		foreach ($this->options as $option) {
+			$cmd[] = is_callable($option) ? $option($version) : "$option";
+		}
+		if ($this->configFile) $cmd[] = $this->configFile;
+
+		return join(' ', $cmd);
+	}
+
+	public function getOutputFile($withExt=true)
+	{
+		if (!$this->outputFile)
+			$this->outputFile = $this->getTempDir()
+				.DIRECTORY_SEPARATOR
+				.basename(tempnam($this->getTempDir(), 'ocr'));
+		if (!$withExt) return $this->outputFile;
+
+		$hasCustomExt = array('hocr', 'tsv', 'pdf');
+		$ext = in_array($this->configFile, $hasCustomExt) ? $this->configFile : 'txt';
+		return "{$this->outputFile}.{$ext}";
+	}
+
+	public function getTempDir()
+	{
+		return $this->tempDir ?: sys_get_temp_dir();
+	}
+
+	public function getTesseractVersion()
+	{
+		exec(self::escape($this->executable).' --version 2>&1', $output);
+		$outputParts = explode(' ', $output[0]);
+		return $outputParts[1];
+	}
+
+	public function getAvailableLanguages()
+	{
+		exec(self::escape($this->executable) . ' --list-langs 2>&1', $output);
+		array_shift($output);
+		sort($output);
+		return $output;
+	}
+
+	public static function escape($str)
+	{
+		$charlist = strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' ? '$"`' : '$"\\`';
+		return '"'.addcslashes($str, $charlist).'"';
+	}
+}

+ 7 - 0
vendor/thiagoalessio/tesseract_ocr/src/FeatureNotAvailableException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace thiagoalessio\TesseractOCR;
+
+class FeatureNotAvailableException extends TesseractOcrException
+{
+}

+ 120 - 0
vendor/thiagoalessio/tesseract_ocr/src/FriendlyErrors.php

@@ -0,0 +1,120 @@
+<?php namespace thiagoalessio\TesseractOCR;
+
+class FriendlyErrors
+{
+	public static function checkImagePath($image)
+	{
+		if (file_exists($image)) return;
+
+		$currentDir = __DIR__;
+		$msg = array();
+		$msg[] = "Error! The image \"$image\" was not found.";
+		$msg[] = '';
+		$msg[] = "The current __DIR__ is $currentDir";
+		$msg = join(PHP_EOL, $msg);
+
+		throw new ImageNotFoundException($msg);
+	}
+
+	public static function checkTesseractPresence($executable)
+	{
+		if (file_exists($executable)) return;
+
+		$cmd = stripos(PHP_OS, 'win') === 0
+			? 'where.exe '.Command::escape($executable).' > NUL 2>&1'
+			: 'type '.Command::escape($executable).' > /dev/null 2>&1';
+		system($cmd, $exitCode);
+
+		if ($exitCode == 0) return;
+
+		$currentPath = getenv('PATH');
+		$msg = array();
+		$msg[] = "Error! The command \"$executable\" was not found.";
+		$msg[] = '';
+		$msg[] = 'Make sure you have Tesseract OCR installed on your system:';
+		$msg[] = 'https://github.com/tesseract-ocr/tesseract';
+		$msg[] = '';
+		$msg[] = "The current \$PATH is $currentPath";
+		$msg = join(PHP_EOL, $msg);
+
+		throw new TesseractNotFoundException($msg);
+	}
+
+	public static function checkCommandExecution($command, $stdout, $stderr)
+	{
+		if ($command->useFileAsOutput) {
+		    $file = $command->getOutputFile();
+		    if (file_exists($file) && filesize($file) > 0)  return;
+		}
+
+		if (!$command->useFileAsOutput && $stdout) {
+			return;
+		}
+
+		$msg = array();
+		$msg[] = 'Error! The command did not produce any output.';
+		$msg[] = '';
+		$msg[] = 'Generated command:';
+		$msg[] = "$command";
+		$msg[] = '';
+		$msg[] = 'Returned message:';
+		$arrayStderr = explode(PHP_EOL, $stderr);
+		array_pop($arrayStderr);
+		$msg = array_merge($msg, $arrayStderr);
+		$msg = join(PHP_EOL, $msg);
+
+		throw new UnsuccessfulCommandException($msg);
+	}
+
+	public static function checkProcessCreation($processHandle, $command)
+	{
+		if ($processHandle !== FALSE) return;
+
+		$msg = array();
+		$msg[] = 'Error! The command could not be launched.';
+		$msg[] = '';
+		$msg[] = 'Generated command:';
+		$msg[] = "$command";
+		$msg = join(PHP_EOL, $msg);
+
+		throw new UnsuccessfulCommandException($msg);
+	}
+
+	public static function checkTesseractVersion($expected, $action, $command)
+	{
+		$actual = $command->getTesseractVersion();
+
+		if ($actual[0] === 'v')
+			$actual = substr($actual, 1);
+
+		if (version_compare($actual, $expected, ">=")) return;
+
+		$msg = array();
+		$msg[] = "Error! $action is not available this tesseract version";
+		$msg[] = "Required version is $expected, actual version is $actual";
+		$msg[] = '';
+		$msg[] = 'Generated command:';
+		$msg[] = "$command";
+		$msg = join(PHP_EOL, $msg);
+
+		throw new FeatureNotAvailableException($msg);
+	}
+
+	public static function checkWritePermissions($path)
+	{
+		if (!is_dir(dirname($path))) mkdir(dirname($path));
+		$writableDirectory = is_writable(dirname($path));
+		$writableFile = true;
+		if (file_exists($path)) $writableFile = is_writable($path);
+		if ($writableFile && $writableDirectory) return;
+
+		$msg = array();
+		$msg[] = "Error! No permission to write to $path";
+		$msg[] = "Make sure you have the right outputFile and permissions "
+			."to write to the folder";
+		$msg[] = '';
+		$msg = join(PHP_EOL, $msg);
+
+		throw new NoWritePermissionsForOutputFile($msg);
+	}
+}

+ 7 - 0
vendor/thiagoalessio/tesseract_ocr/src/ImageNotFoundException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace thiagoalessio\TesseractOCR;
+
+class ImageNotFoundException extends TesseractOcrException
+{
+}

+ 7 - 0
vendor/thiagoalessio/tesseract_ocr/src/NoWritePermissionsForOutputFile.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace thiagoalessio\TesseractOCR;
+
+class NoWritePermissionsForOutputFile extends TesseractOcrException
+{
+}

+ 79 - 0
vendor/thiagoalessio/tesseract_ocr/src/Option.php

@@ -0,0 +1,79 @@
+<?php namespace thiagoalessio\TesseractOCR;
+
+class Option
+{
+	public static function psm($psm)
+	{
+		return function($version) use ($psm) {
+			$version = preg_replace('/^v/', '', $version);
+			return (version_compare($version, 4, '>=') ? '-' : '')."-psm $psm";
+		};
+	}
+
+	public static function oem($oem)
+	{
+		return function($version) use ($oem) {
+			Option::checkMinVersion('3.05', $version, 'oem');
+			return "--oem $oem";
+		};
+	}
+
+	public static function dpi($dpi)
+	{
+		return function() use ($dpi) {
+			return "--dpi $dpi";
+		};
+	}
+
+	public static function userWords($path)
+	{
+		return function($version) use ($path) {
+			Option::checkMinVersion('3.04', $version, 'user-words');
+			return '--user-words "'.addcslashes($path, '\\"').'"';
+		};
+	}
+
+	public static function userPatterns($path)
+	{
+		return function($version) use ($path) {
+			Option::checkMinVersion('3.04', $version, 'user-patterns');
+			return '--user-patterns "'.addcslashes($path, '\\"').'"';
+		};
+	}
+
+	public static function tessdataDir($path)
+	{
+		return function() use ($path) {
+			return '--tessdata-dir "'.addcslashes($path, '\\"').'"';
+		};
+	}
+
+	public static function lang()
+	{
+		$languages = func_get_args();
+		return function() use ($languages) {
+			return '-l '.join('+', $languages);
+		};
+	}
+
+	public static function config($var, $value)
+	{
+		return function() use($var, $value) {
+			$snakeCase = function($str) {
+				return strtolower(preg_replace('/([A-Z])+/', '_$1', $str));
+			};
+			$pair = $snakeCase($var).'='.$value;
+			return '-c "'.addcslashes($pair, '\\"').'"';
+		};
+	}
+
+	public static function checkMinVersion($minVersion, $currVersion, $option)
+	{
+		$minVersion = preg_replace('/^v/', '', $minVersion);
+		$currVersion = preg_replace('/^v/', '', $currVersion);
+		if (!version_compare($currVersion, $minVersion, '<')) return;
+		$msg = "$option option is only available on Tesseract $minVersion or later.";
+		$msg.= PHP_EOL."Your version of Tesseract is $currVersion";
+		throw new \Exception($msg);
+	}
+}

+ 80 - 0
vendor/thiagoalessio/tesseract_ocr/src/Process.php

@@ -0,0 +1,80 @@
+<?php namespace thiagoalessio\TesseractOCR;
+
+class Process {
+
+    private $stdin;
+    private $stdout;
+    private $stderr;
+    private $handle;
+    private $startTime;
+
+    public function __construct($command)
+    {
+        $this->startTime = microtime(true);
+        $streamDescriptors = [
+            array("pipe", "r"),
+            array("pipe", "w"),
+            array("pipe", "w")
+        ];
+        $this->handle = proc_open($command, $streamDescriptors, $pipes, NULL, NULL, ["bypass_shell" => true]);
+        list($this->stdin, $this->stdout, $this->stderr) = $pipes;
+
+        FriendlyErrors::checkProcessCreation($this->handle, $command);
+
+        //This is can avoid deadlock on some cases (when stderr buffer is filled up before writing to stdout and vice-versa)
+        stream_set_blocking($this->stdout, 0);
+        stream_set_blocking($this->stderr, 0);
+    }
+
+    public function write($data, $len)
+    {
+        $total = 0;
+        do
+        {
+            $res = fwrite($this->stdin, substr($data, $total));
+        } while($res && $total += $res < $len);
+        return $total === $len;
+    }
+
+
+    public function wait($timeout = 0)
+    {
+        $running = true;
+        $data = ["out" => "", "err" => ""];
+        while (($running === true) && !$this->hasTimedOut($timeout))
+        {
+            $data["out"] .= fread($this->stdout, 8192);
+            $data["err"] .= fread($this->stderr, 8192);
+            $procInfo = proc_get_status($this->handle);
+            $running = $procInfo["running"];
+        }
+        return $data;
+    }
+
+    public function close()
+    {
+        $this->closeStream($this->stdin);
+        $this->closeStream($this->stdout);
+        $this->closeStream($this->stderr);
+        return proc_close($this->handle);
+    }
+
+    public function closeStdin()
+    {
+        $this->closeStream($this->stdin);
+    }
+
+    private function hasTimedOut($timeout)
+    {
+        return (($timeout > 0) &&  ($this->startTime + $timeout < microtime(true)));    
+    }
+    
+    private function closeStream(&$stream)
+    {
+        if ($stream !== NULL)
+        {
+            fclose($stream);
+            $stream = NULL;
+        }
+    }
+}

+ 7 - 0
vendor/thiagoalessio/tesseract_ocr/src/TesseractNotFoundException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace thiagoalessio\TesseractOCR;
+
+class TesseractNotFoundException extends TesseractOcrException
+{
+}

+ 181 - 0
vendor/thiagoalessio/tesseract_ocr/src/TesseractOCR.php

@@ -0,0 +1,181 @@
+<?php namespace thiagoalessio\TesseractOCR;
+
+use thiagoalessio\TesseractOCR\Command;
+use thiagoalessio\TesseractOCR\Option;
+use thiagoalessio\TesseractOCR\FriendlyErrors;
+
+class TesseractOCR
+{
+	public $command;
+	private $outputFile = null;
+
+	public function __construct($image=null, $command=null)
+	{
+		$this->command = $command ?: new Command;
+		$this->image("$image");
+	}
+
+	public function run($timeout = 0)
+	{
+		try {
+			if ($this->outputFile !== null) {
+				FriendlyErrors::checkWritePermissions($this->outputFile);
+				$this->command->useFileAsOutput = true;
+			}
+
+			FriendlyErrors::checkTesseractPresence($this->command->executable);
+			if ($this->command->useFileAsInput) {
+				FriendlyErrors::checkImagePath($this->command->image);
+			}
+
+			$process = new Process("{$this->command}");
+
+			if (!$this->command->useFileAsInput) {
+				$process->write($this->command->image, $this->command->imageSize);
+				$process->closeStdin();
+			}
+			$output = $process->wait($timeout);
+
+			FriendlyErrors::checkCommandExecution($this->command, $output["out"], $output["err"]);
+		}
+		catch (TesseractOcrException $e) {
+			if ($this->command->useFileAsOutput) $this->cleanTempFiles();
+			throw $e;
+		}
+
+		if ($this->command->useFileAsOutput) {
+			$text = file_get_contents($this->command->getOutputFile());
+
+			if ($this->outputFile !== null) {
+				rename($this->command->getOutputFile(), $this->outputFile);
+			}
+
+			$this->cleanTempFiles();
+		}
+		else
+			$text = $output["out"];
+
+		return trim($text, " \t\n\r\0\x0A\x0B\x0C");
+	}
+
+	public function imageData($image, $size)
+	{
+		FriendlyErrors::checkTesseractVersion("3.03-rc1", "Reading image data from stdin", $this->command);
+		$this->command->useFileAsInput = false;
+		$this->command->image = $image;
+		$this->command->imageSize = $size;
+		return $this;
+	}
+
+	public function withoutTempFiles()
+	{
+		FriendlyErrors::checkTesseractVersion("3.03-rc1", "Writing to stdout (without using temp files)", $this->command);
+		$this->command->useFileAsOutput = false;
+		return $this;
+	}
+
+	public function image($image)
+	{
+		$this->command->image = $image;
+		return $this;
+	}
+
+	public function executable($executable)
+	{
+		FriendlyErrors::checkTesseractPresence($executable);
+		$this->command->executable = $executable;
+		return $this;
+	}
+
+	public function configFile($configFile)
+	{
+		$this->command->configFile = $configFile;
+		return $this;
+	}
+
+	public function tempDir($tempDir)
+	{
+		$this->command->tempDir = $tempDir;
+		return $this;
+	}
+
+	public function threadLimit($limit)
+	{
+		$this->command->threadLimit = $limit;
+		return $this;
+	}
+
+	// @deprecated
+	public function format($fmt) { return $this->configFile($fmt); }
+
+	public function setOutputFile($path) {
+		$this->outputFile = $path;
+		return $this;
+	}
+
+	public function allowlist()
+	{
+		$concat = function ($arg) { return is_array($arg) ? join('', $arg) : $arg; };
+		$allowlist = join('', array_map($concat, func_get_args()));
+		$this->command->options[] = Option::config('tessedit_char_whitelist', $allowlist);
+		return $this;
+	}
+
+	public function whitelist()
+	{
+		$warningMsg = 'Notice: whitelist is deprecated, use allowlist instead.';
+		trigger_error($warningMsg, E_USER_NOTICE);
+
+		$concat = function ($arg) { return is_array($arg) ? join('', $arg) : $arg; };
+		$allowlist = join('', array_map($concat, func_get_args()));
+		return $this->allowlist($allowlist);
+	}
+
+	public function version()
+	{
+		return $this->command->getTesseractVersion();
+	}
+
+	public function availableLanguages()
+	{
+		return $this->command->getAvailableLanguages();
+	}
+
+	public function __call($method, $args)
+	{
+		if ($this->isConfigFile($method)) return $this->configFile($method);
+		if ($this->isOption($method)) {
+			$option = $this->getOptionClassName().'::'.$method;
+			$this->command->options[] = call_user_func_array($option, $args);
+			return $this;
+		}
+		$arg = empty($args) ? null : $args[0];
+		$this->command->options[] = Option::config($method, $arg);
+		return $this;
+	}
+
+	private function isConfigFile($name)
+	{
+		return in_array($name, array('digits', 'hocr', 'pdf', 'quiet', 'tsv', 'txt'));
+	}
+
+	private function isOption($name)
+	{
+		return in_array($name, get_class_methods($this->getOptionClassName()));
+	}
+
+	private function getOptionClassName()
+	{
+		return __NAMESPACE__.'\\Option';
+	}
+
+	private function cleanTempFiles()
+	{
+		if (file_exists($this->command->getOutputFile(false))) {
+			unlink($this->command->getOutputFile(false));
+		}
+		if (file_exists($this->command->getOutputFile(true))) {
+			unlink($this->command->getOutputFile(true));
+		}
+	}
+}

+ 7 - 0
vendor/thiagoalessio/tesseract_ocr/src/TesseractOcrException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace thiagoalessio\TesseractOCR;
+
+abstract class TesseractOcrException extends \Exception
+{
+}

+ 7 - 0
vendor/thiagoalessio/tesseract_ocr/src/UnsuccessfulCommandException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace thiagoalessio\TesseractOCR;
+
+class UnsuccessfulCommandException extends TesseractOcrException
+{
+}

+ 47 - 0
vendor/thiagoalessio/tesseract_ocr/tests/Common/TestCase.php

@@ -0,0 +1,47 @@
+<?php namespace thiagoalessio\TesseractOCR\Tests\Common;
+
+class TestCase
+{
+	public function run()
+	{
+		$results = array();
+
+		if (method_exists($this, 'setUp')) $this->setUp();
+		foreach ($this->getTests() as $test) {
+			if (method_exists($this, 'beforeEach')) $this->beforeEach();
+			try {
+				$this->$test();
+				$results[$test] = array('status' => 'pass');
+			} catch (SkipException $e) {
+				$results[$test] = array('status' => 'skip');
+			} catch (\Exception $e) {
+				$results[$test] = array('status' => 'fail', 'msg' => $e->getMessage());
+			}
+			if (method_exists($this, 'afterEach')) $this->afterEach();
+		}
+		if (method_exists($this, 'tearDown')) $this->tearDown();
+
+		return $results;
+	}
+
+	protected function getTests()
+	{
+		$isTest = function ($name) { return preg_match('/^test/', $name); };
+		$methods = get_class_methods(get_class($this));
+		return array_filter($methods, $isTest);
+	}
+
+	protected function assertEquals($expected, $actual)
+	{
+		if ($expected != $actual) {
+			throw new \Exception("\t\tExpected: $expected\n\t\t  Actual: $actual");
+		}
+	}
+
+	protected function skip()
+	{
+		throw new SkipException();
+	}
+}
+
+class SkipException extends \Exception {}

+ 148 - 0
vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/FriendlyErrors.php

@@ -0,0 +1,148 @@
+<?php namespace thiagoalessio\TesseractOCR\Tests\EndToEnd;
+
+use thiagoalessio\TesseractOCR\Tests\Common\TestCase;
+use thiagoalessio\TesseractOCR\TesseractOCR;
+use thiagoalessio\TesseractOCR\Tests\Unit\TestableCommand;
+
+use thiagoalessio\TesseractOCR\ImageNotFoundException;
+use thiagoalessio\TesseractOCR\TesseractNotFoundException;
+use thiagoalessio\TesseractOCR\UnsuccessfulCommandException;
+
+class FriendlyErrors extends TestCase
+{
+	public function testImageNotFound()
+	{
+		$currentDir = realpath(
+			join(DIRECTORY_SEPARATOR, [__DIR__, '..', '..', 'src'])
+		);
+
+		$expected = array();
+		$expected[] = 'Error! The image "/invalid/image.png" was not found.';
+		$expected[] = '';
+		$expected[] = "The current __DIR__ is $currentDir";
+		$expected = join(PHP_EOL, $expected);
+
+		try {
+			(new TesseractOCR('/invalid/image.png'))->run();
+			throw new \Exception('ImageNotFoundException not thrown');
+		} catch (\Exception $e) {
+			$this->assertEquals($expected, $e->getMessage());
+		}
+	}
+
+	public function testExecutableNotFound()
+	{
+		$currentPath = getenv('PATH');
+
+		$expected = array();
+		$expected[] = 'Error! The command "/nowhere/tesseract" was not found.';
+		$expected[] = '';
+		$expected[] = 'Make sure you have Tesseract OCR installed on your system:';
+		$expected[] = 'https://github.com/tesseract-ocr/tesseract';
+		$expected[] = '';
+		$expected[] = "The current \$PATH is $currentPath";
+		$expected = join(PHP_EOL, $expected);
+
+		try {
+			(new TesseractOCR('./tests/EndToEnd/images/text.png'))
+				->executable('/nowhere/tesseract')
+				->run();
+			throw new \Exception('TesseractNotFoundException not thrown');
+		} catch (TesseractNotFoundException $e) {
+			$this->assertEquals($expected, $e->getMessage());
+		}
+	}
+
+	public function testExecutableNotFoundWithVersionCheckingOptions()
+	{
+		# Issue #210, reported by @samwilson
+
+		$currentPath = getenv('PATH');
+
+		$expected = array();
+		$expected[] = 'Error! The command "/nowhere/tesseract" was not found.';
+		$expected[] = '';
+		$expected[] = 'Make sure you have Tesseract OCR installed on your system:';
+		$expected[] = 'https://github.com/tesseract-ocr/tesseract';
+		$expected[] = '';
+		$expected[] = "The current \$PATH is $currentPath";
+		$expected = join(PHP_EOL, $expected);
+
+		try {
+			(new TesseractOCR())
+				->executable('/nowhere/tesseract')
+				->imageData('irrelevant', 1234)
+				->withoutTempFiles()
+				->run();
+			throw new \Exception('TesseractNotFoundException not thrown');
+		} catch (TesseractNotFoundException $e) {
+			$this->assertEquals($expected, $e->getMessage());
+		}
+	}
+
+	public function testUnsuccessfulCommand()
+	{
+		$expected = array();
+		$expected[] = 'Error! The command did not produce any output.';
+		$expected[] = '';
+		$expected[] = 'Generated command:';
+		$expected[] = '"tesseract" "./tests/EndToEnd/images/not-an-image.txt" "tmpfile" quiet';
+		$expected[] = '';
+		$expected[] = 'Returned message:';
+
+		switch (true) {
+
+			case ($this->isVersion('3.02')):
+				$expected[] = 'Error in pixReadStream: Unknown format: no pix returned';
+				$expected[] = 'Error in pixRead: pix not read';
+				$expected[] = 'Unsupported image type.';
+				break;
+
+			case ($this->isVersion('3.03')):
+				$expected[] = 'Tesseract Open Source OCR Engine v3.03 with Leptonica';
+				$expected[] = 'Error in pixReadStream: Unknown format: no pix returned';
+				$expected[] = 'Error in pixRead: pix not read';
+				$expected[] = 'Error in pixGetInputFormat: pix not defined';
+				$expected[] = 'Error in fopenReadStream: file not found';
+				$expected[] = 'Error in pixRead: image file not found: not an image';
+				$expected[] = 'Error during processing.';
+				break;
+
+			case ($this->isVersion('3.04.01')):
+				$expected[] = 'Tesseract Open Source OCR Engine v3.04.01 with Leptonica';
+				$expected[] = 'Error in fopenReadStream: file not found';
+				$expected[] = 'Error in pixRead: image file not found: not an image';
+				$expected[] = 'Error during processing.';
+				break;
+
+			case ($this->isVersion('3.05.00dev')):
+				$expected[] = 'Tesseract Open Source OCR Engine v3.05.00dev with Leptonica';
+				$expected[] = 'read_params_file: Can\'t open quiet';
+				$expected[] = 'Image file not an image cannot be read!';
+				$expected[] = 'Error during processing.';
+				break;
+
+			default:
+				$expected[] = 'Error in fopenReadStream: file not found';
+				$expected[] = 'Error in pixRead: image file not found: not an image';
+				$expected[] = 'Error during processing.';
+		}
+		$expected = join(PHP_EOL, $expected);
+
+		$cmd = new TestableCommand();
+		try {
+			(new TesseractOCR('./tests/EndToEnd/images/not-an-image.txt', $cmd))
+				->quiet()
+				->run();
+			throw new \Exception('UnsuccessfulCommandException not thrown');
+		} catch (UnsuccessfulCommandException $e) {
+			$this->assertEquals($expected, $e->getMessage());
+		}
+	}
+
+	protected function isVersion($version)
+	{
+		exec('tesseract --version 2>&1', $output);
+		return strpos($output[0], "tesseract $version") !== false;
+	}
+}

+ 186 - 0
vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/ReadmeExamples.php

@@ -0,0 +1,186 @@
+<?php namespace thiagoalessio\TesseractOCR\Tests\EndToEnd;
+
+use thiagoalessio\TesseractOCR\TesseractOcrException;
+use thiagoalessio\TesseractOCR\Tests\Common\TestCase;
+use thiagoalessio\TesseractOCR\TesseractOCR;
+use ReflectionObject;
+
+class ReadmeExamples extends TestCase
+{
+	private $executable = 'tesseract';
+	private $imagesDir  = './tests/EndToEnd/images';
+
+	public function testBasicUsage()
+	{
+		$expected = "The quick brown fox\njumps over\nthe lazy dog.";
+		$actual = (new TesseractOCR("{$this->imagesDir}/text.png"))
+			->executable($this->executable)
+			->run();
+		$this->assertEquals($expected, str_replace(PHP_EOL, "\n", $actual));
+	}
+
+	public function testOtherLanguages()
+	{
+		$expected = 'Bülowstraße';
+		$actual = (new TesseractOCR("{$this->imagesDir}/german.png"))
+			->executable($this->executable)
+			->lang('deu')
+			->run();
+		$this->assertEquals($expected, $actual);
+	}
+
+	public function testMultipleLanguages()
+	{
+		// training data for these versions return different output
+		if ($this->isVersion302() || $this->isVersion305()) $this->skip();
+
+		$expected = 'I eat すし y Pollo';
+		$actual = (new TesseractOCR("{$this->imagesDir}/mixed-languages.png"))
+			->executable($this->executable)
+			->lang('eng', 'jpn', 'spa')
+			->run();
+		$this->assertEquals($expected, $actual);
+	}
+
+	public function testInducingRecognition()
+	{
+		// https://github.com/tesseract-ocr/tesseract/issues/751
+		if ($this->isVersion302() || $this->isVersion4()) $this->skip();
+
+		$expected = 'BOSS';
+		$actual = (new TesseractOCR("{$this->imagesDir}/8055.png"))
+			->executable($this->executable)
+			->allowlist(range('A', 'Z'))
+			->run();
+		$this->assertEquals($expected, $actual);
+	}
+
+	public function testListAvailableLanguages()
+	{
+		// feature not available in this version of tesseract
+		if ($this->isVersion302()) $this->skip();
+
+		$actual = (new TesseractOCR())->availableLanguages();
+		$this->assertEquals(true, in_array('deu', $actual));
+		$this->assertEquals(true, in_array('eng', $actual));
+		$this->assertEquals(true, in_array('jpn', $actual));
+		$this->assertEquals(true, in_array('spa', $actual));
+	}
+
+	public function testTemporaryFilesAreDeleted()
+	{
+		// https://github.com/thiagoalessio/tesseract-ocr-for-php/issues/169
+		$ocr = new TesseractOCR("{$this->imagesDir}/text.png");
+		$ocr->run();
+
+		$this->assertEquals(false, file_exists($ocr->command->getOutputFile(false)));
+		$this->assertEquals(false, file_exists($ocr->command->getOutputFile(true)));
+	}
+
+	public function testTemporaryFilesAreNotCreated()
+	{
+		// Cannot read from stdin in version 3.02
+		if ($this->isVersion302()) $this->skip();
+
+		$ocr = new TesseractOCR("{$this->imagesDir}/text.png");
+		$ocr->withoutTempFiles();
+		$ocr->run();
+
+		$reflectionProperty = (new ReflectionObject($ocr->command))->getProperty('outputFile');
+		$reflectionProperty->setAccessible(true);
+		$outputFileValue = $reflectionProperty->getValue($ocr->command);
+
+		$this->assertEquals(null, $outputFileValue);
+	}
+
+	public function testTemporaryFilesAreDeletedInCaseOfException()
+	{
+
+		try {
+			$ocr = new TesseractOCR("{$this->imagesDir}/not-an-image.txt");
+			$ocr->run();
+		}
+		catch (TesseractOcrException $e) {
+
+		}
+
+		$this->assertEquals(false, file_exists($ocr->command->getOutputFile(false)));
+		$this->assertEquals(false, file_exists($ocr->command->getOutputFile(true)));
+	}
+
+	public function testWithoutInputFile()
+	{
+		// Cannot read from stdin in version 3.02
+		if ($this->isVersion302()) $this->skip();
+
+		$expected = "The quick brown fox\njumps over\nthe lazy dog.";
+		$actual = (new TesseractOCR)
+			->imageData(file_get_contents("{$this->imagesDir}/text.png"), filesize("{$this->imagesDir}/text.png"))
+			->executable($this->executable)
+			->run();
+		$this->assertEquals($expected, $actual);
+		$this->assertEquals($expected, str_replace(PHP_EOL, "\n", $actual));
+	}
+
+	public function testWithoutOutputFile()
+	{
+		// Cannot write to stdout in version 3.02
+		if ($this->isVersion302()) $this->skip();
+
+		$expected = "The quick brown fox\njumps over\nthe lazy dog.";
+		$actual = (new TesseractOCR("{$this->imagesDir}/text.png"))
+			->executable($this->executable)
+			->withoutTempFiles()
+			->run();
+		$this->assertEquals($expected, str_replace(PHP_EOL, "\n", $actual));
+	}
+
+	public function testWithoutFiles()
+	{
+		// Cannot read from stdin and write to stdout in version 3.02
+		if ($this->isVersion302()) $this->skip();
+
+		$expected = "The quick brown fox\njumps over\nthe lazy dog.";
+		$actual = (new TesseractOCR)
+			->imageData(file_get_contents("{$this->imagesDir}/text.png"), filesize("{$this->imagesDir}/text.png"))
+			->executable($this->executable)
+			->withoutTempFiles()
+			->run();
+		$this->assertEquals($expected, str_replace(PHP_EOL, "\n", $actual));
+	}
+
+	public function testBacktickOnFilenames()
+	{
+		// skipping for now until I take the time to properly fix it
+		if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN') $this->skip();
+
+		$expected = "The quick brown fox\njumps over\nthe lazy dog.";
+		$actual = (new TesseractOCR("{$this->imagesDir}/file`with`backtick.png"))
+			->executable($this->executable)
+			->run();
+		$this->assertEquals($expected, str_replace(PHP_EOL, "\n", $actual));
+	}
+
+	protected function isVersion302()
+	{
+		exec('tesseract --version 2>&1', $output);
+		$version = explode(' ', $output[0])[1];
+		return version_compare($version, '3.02', '>=')
+			&& version_compare($version, '3.03', '<');
+	}
+
+	protected function isVersion305()
+	{
+		exec('tesseract --version 2>&1', $output);
+		$version = explode(' ', $output[0])[1];
+		return version_compare($version, '3.05', '>=')
+			&& version_compare($version, '3.06', '<');
+	}
+
+	protected function isVersion4()
+	{
+		exec('tesseract --version 2>&1', $output);
+		$version = explode(' ', $output[0])[1];
+		return version_compare($version, '4.00', '>=');
+	}
+}

BIN
vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/images/8055.png


BIN
vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/images/file`with`backtick.png


BIN
vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/images/german.png


BIN
vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/images/mixed-languages.png


+ 1 - 0
vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/images/not-an-image.txt

@@ -0,0 +1 @@
+not an image

BIN
vendor/thiagoalessio/tesseract_ocr/tests/EndToEnd/images/text.png


+ 92 - 0
vendor/thiagoalessio/tesseract_ocr/tests/Unit/CommandTest.php

@@ -0,0 +1,92 @@
+<?php namespace thiagoalessio\TesseractOCR\Tests\Unit;
+
+use thiagoalessio\TesseractOCR\Tests\Common\TestCase;
+use thiagoalessio\TesseractOCR\Tests\Unit\TestableCommand;
+use thiagoalessio\TesseractOCR\Command;
+use thiagoalessio\TesseractOCR\Option;
+
+class CommandTest extends TestCase
+{
+	public function setUp()
+	{
+		$this->customTempDir = __DIR__.DIRECTORY_SEPARATOR.'custom-temp-dir';
+		mkdir($this->customTempDir);
+	}
+
+	public function tearDown()
+	{
+		$files = glob(join(DIRECTORY_SEPARATOR, array($this->customTempDir, '*')));
+		array_map('unlink', $files);
+		rmdir($this->customTempDir);
+	}
+
+	public function testSimplestCommand()
+	{
+		$cmd = new TestableCommand('image.png');
+
+		$expected = '"tesseract" "image.png" "tmpfile"';
+		$this->assertEquals("$expected", "$cmd");
+	}
+
+	public function testCommandWithOption()
+	{
+		$cmd = new TestableCommand('image.png');
+		$cmd->options[] = Option::lang('eng');
+
+		$expected = '"tesseract" "image.png" "tmpfile" -l eng';
+		$this->assertEquals("$expected", "$cmd");
+	}
+
+	public function testWithConfigFile()
+	{
+		$cmd = new TestableCommand('image.png');
+		$cmd->configFile = 'hocr';
+
+		$expected = '"tesseract" "image.png" "tmpfile" hocr';
+		$this->assertEquals("$expected", "$cmd");
+	}
+
+	public function testCustomTempDir()
+	{
+		if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN') $this->skip();
+
+		$cmd = new Command('image.png');
+		$cmd->tempDir = $this->customTempDir;
+
+		$expected = "\"tesseract\" \"image.png\" \"{$this->customTempDir}";
+		$actual = substr("$cmd", 0, strlen($expected));
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testCustomTempDirWindows()
+	{
+		if (strtoupper(substr(PHP_OS, 0, 3)) != 'WIN') $this->skip();
+
+		$customTempDir = 'C:\Users\Foo Bar\Temp\Dir';
+		if (!file_exists($customTempDir)) mkdir($customTempDir, null, true);
+
+		$cmd = new Command('image.png');
+		$cmd->tempDir = $customTempDir;
+
+		$expected = '"tesseract" "image.png" "C:\Users\Foo Bar\Temp\Dir';
+		$actual = substr("$cmd", 0, strlen($expected));
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testCommandWithThreadLimit()
+	{
+		$cmd = new TestableCommand('image.png');
+		$cmd->threadLimit = 2;
+
+		$expected = 'OMP_THREAD_LIMIT=2 "tesseract" "image.png" "tmpfile"';
+		$this->assertEquals("$expected", "$cmd");
+	}
+
+	public function testEscapeSpecialCharactersOnFilename()
+	{
+		$cmd = new TestableCommand('$@ ! ? "#\'_`foo.png');
+
+		$expected = '"tesseract" "\$@ ! ? \\"#\'_\`foo.png" "tmpfile"';
+		$this->assertEquals("$expected", "$cmd");
+	}
+}

+ 108 - 0
vendor/thiagoalessio/tesseract_ocr/tests/Unit/OptionTest.php

@@ -0,0 +1,108 @@
+<?php namespace thiagoalessio\TesseractOCR\Tests\Unit;
+
+use thiagoalessio\TesseractOCR\Tests\Common\TestCase;
+use thiagoalessio\TesseractOCR\Option;
+
+class OptionTest extends TestCase
+{
+	public function testPsm()
+	{
+		$psm = Option::psm(8);
+		$this->assertEquals('-psm 8', $psm('3.05.01'));
+		$this->assertEquals('--psm 8', $psm('4.0.0-beta.1'));
+		$this->assertEquals('--psm 8', $psm('v4.0.0-beta.4.20180912'));
+	}
+
+	public function testOem()
+	{
+		$oem = Option::oem(2);
+		$this->assertEquals('--oem 2', $oem('3.05.01'));
+		try {
+			$oem('3.04.01');
+			throw new \Exception('Expected Exception to be thrown');
+		} catch (\Exception $e) {
+			$expected = 'oem option is only available on Tesseract 3.05 or later.';
+			$expected.= PHP_EOL."Your version of Tesseract is 3.04.01";
+			$this->assertEquals($expected, $e->getMessage());
+		}
+	}
+
+	public function testDpi()
+	{
+		$dpi = Option::dpi(300);
+
+		$this->assertEquals('--dpi 300', $dpi());
+	}
+
+	public function testUserWords()
+	{
+		$userWords = Option::userWords('/path/to/words');
+		$this->assertEquals('--user-words "/path/to/words"', $userWords('3.04'));
+
+		$userWords = Option::userWords('c:\path\to\words');
+		$this->assertEquals('--user-words "c:\\\\path\\\\to\\\\words"', $userWords('3.04'));
+
+		try {
+			$userWords('3.03');
+			throw new \Exception('Expected Exception to be thrown');
+		} catch (\Exception $e) {
+			$expected = 'user-words option is only available on Tesseract 3.04 or later.';
+			$expected.= PHP_EOL."Your version of Tesseract is 3.03";
+			$this->assertEquals($expected, $e->getMessage());
+		}
+	}
+
+	public function testUserPatterns()
+	{
+		$userPatterns = Option::userPatterns('/path/to/patterns');
+		$this->assertEquals('--user-patterns "/path/to/patterns"', $userPatterns('3.04'));
+
+		$userPatterns = Option::userPatterns('c:\path\to\patterns');
+		$this->assertEquals('--user-patterns "c:\\\\path\\\\to\\\\patterns"', $userPatterns('3.04'));
+
+		try {
+			$userPatterns('3.03');
+			throw new \Exception('Expected Exception to be thrown');
+		} catch (\Exception $e) {
+			$expected = 'user-patterns option is only available on Tesseract 3.04 or later.';
+			$expected.= PHP_EOL."Your version of Tesseract is 3.03";
+			$this->assertEquals($expected, $e->getMessage());
+		}
+	}
+
+	public function testTessdataDir()
+	{
+		$tessdataDir = Option::tessdataDir('/path/to/tessdata');
+		$this->assertEquals('--tessdata-dir "/path/to/tessdata"', $tessdataDir());
+
+		$tessdataDir = Option::tessdataDir('c:\path\to\tessdata');
+		$this->assertEquals('--tessdata-dir "c:\\\\path\\\\to\\\\tessdata"', $tessdataDir());
+	}
+
+	public function testLang()
+	{
+		$lang = Option::lang('eng');
+		$this->assertEquals('-l eng', $lang());
+
+		$lang = Option::lang('eng', 'deu', 'jpn');
+		$this->assertEquals('-l eng+deu+jpn', $lang());
+	}
+
+	public function testConfig()
+	{
+		$config = Option::config('var', 'value');
+		$this->assertEquals('-c "var=value"', $config());
+
+		$config = Option::config('chars', '\'"!$@%&?`');
+		$this->assertEquals('-c "chars=\'\\"!$@%&?`"', $config());
+
+		$config = Option::config('fooBarBazChunkyBacon', 'value');
+		$this->assertEquals('-c "foo_bar_baz_chunky_bacon=value"', $config());
+	}
+
+	public function testCheckMinVersion()
+	{
+		Option::checkMinVersion('3.05', '4.0.0.20190314', 'option');
+		Option::checkMinVersion('3.05', 'v4.0.0.20190314', 'option');
+	}
+}

+ 42 - 0
vendor/thiagoalessio/tesseract_ocr/tests/Unit/OutputFileTest.php

@@ -0,0 +1,42 @@
+<?php namespace thiagoalessio\TesseractOCR\Tests\Unit;
+
+use thiagoalessio\TesseractOCR\Tests\Common\TestCase;
+use thiagoalessio\TesseractOCR\Command;
+
+class OutputFileTest extends TestCase
+{
+	public function beforeEach()
+	{
+		$this->cmd = new Command('image', '/path/to/output/file');
+	}
+
+	public function testTxt()
+	{
+		foreach (array('digits', 'quiet', 'txt', 'anything', 'else') as $ext) {
+			$this->cmd->configFile = $ext;
+			$expected = "/path/to/output/file.txt";
+			$this->assertEquals($expected, $this->cmd->getOutputFile());
+		}
+	}
+
+	public function testHocr()
+	{
+		$this->cmd->configFile = 'hocr';
+		$expected = '/path/to/output/file.hocr';
+		$this->assertEquals($expected, $this->cmd->getOutputFile());
+	}
+
+	public function testTsv()
+	{
+		$this->cmd->configFile = 'tsv';
+		$expected = '/path/to/output/file.tsv';
+		$this->assertEquals($expected, $this->cmd->getOutputFile());
+	}
+
+	public function testPdf()
+	{
+		$this->cmd->configFile = 'pdf';
+		$expected = '/path/to/output/file.pdf';
+		$this->assertEquals($expected, $this->cmd->getOutputFile());
+	}
+}

+ 211 - 0
vendor/thiagoalessio/tesseract_ocr/tests/Unit/TesseractOCRTest.php

@@ -0,0 +1,211 @@
+<?php namespace thiagoalessio\TesseractOCR\Tests\Unit;
+
+use thiagoalessio\TesseractOCR\Tests\Common\TestCase;
+use thiagoalessio\TesseractOCR\TesseractOCR;
+use thiagoalessio\TesseractOCR\Command;
+use thiagoalessio\TesseractOCR\Tests\Unit\TestableCommand;
+
+class TesseractOCRTest extends TestCase
+{
+	public function setUp()
+	{
+		$this->customTempDir = __DIR__.DIRECTORY_SEPARATOR.'custom-temp-dir';
+		mkdir($this->customTempDir);
+	}
+
+	public function tearDown()
+	{
+		$files = glob(join(DIRECTORY_SEPARATOR, array($this->customTempDir, '*')));
+		array_map('unlink', $files);
+		rmdir($this->customTempDir);
+	}
+
+	public function beforeEach()
+	{
+		$this->tess = new TesseractOCR('image.png', new TestableCommand());
+	}
+
+	public function testSimplestUsage()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile"';
+		$actual = $this->tess->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testDelayedSettingOfImagePath()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile"';
+
+		$ocr = new TesseractOCR(null, new TestableCommand());
+		$ocr->image('image.png');
+		$actual = $ocr->command;
+
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testCustomExecutablePath()
+	{
+		// skipping for now until I take the time to properly fix it
+		if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN') $this->skip();
+
+		$expected = '"/bin/ls" "image.png" "tmpfile"';
+		$actual = $this->tess->executable('/bin/ls')->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testDefiningOptions()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" -l eng hocr';
+		$actual = $this->tess->lang('eng')->format('hocr')->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testAllowlistSingleStringArgument()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" -c "tessedit_char_whitelist=abcdefghij"';
+		$actual = $this->tess->allowlist('abcdefghij')->command;
+		$this->assertEquals("$expected", $actual);
+	}
+
+	public function testAllowlistMultipleStringArguments()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" -c "tessedit_char_whitelist=abcdefghij"';
+		$actual = $this->tess->allowlist('ab', 'cd', 'ef', 'gh', 'ij')->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testAllowlistSingleArrayArgument()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" -c "tessedit_char_whitelist=abcdefghij"';
+		$actual = $this->tess->allowlist(range('a', 'j'))->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testAllowlistMultipleArrayArguments()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" -c "tessedit_char_whitelist=abcdefghij"';
+		$actual = $this->tess->allowlist(range('a', 'e'), range('f', 'j'))->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testAllowlistMixedArguments()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" -c "tessedit_char_whitelist=0123456789abcdefghij"';
+		$actual = $this->tess->allowlist(range(0, 9), 'abcd', range('e', 'j'))->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testDefiningConfigPairs()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" '
+			.'-c "load_system_dawg=F" '
+			.'-c "tessedit_create_pdf=1"';
+		$actual = $this->tess->loadSystemDawg('F')->tesseditCreatePdf(1)->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testDefiningConfigFile()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" tsv';
+		$actual = $this->tess->configFile('tsv')->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	// @deprecated
+	public function testDefiningFormat()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" tsv';
+		$actual = $this->tess->format('tsv')->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testDigits()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" digits';
+		$actual = $this->tess->digits()->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testHocr()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" hocr';
+		$actual = $this->tess->hocr()->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testPdf()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" pdf';
+		$actual = $this->tess->pdf()->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testQuiet()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" quiet';
+		$actual = $this->tess->quiet()->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testTsv()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" tsv';
+		$actual = $this->tess->tsv()->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testTxt()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" txt';
+		$actual = $this->tess->txt()->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testCustomTempDir()
+	{
+		if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN') $this->skip();
+
+		$tess = new TesseractOCR('image.png');
+		$cmd = $tess->tempDir($this->customTempDir)->command;
+
+		$expected = "\"tesseract\" \"image.png\" \"{$this->customTempDir}";
+		$actual = substr("$cmd", 0, strlen($expected));
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testCustomTempDirWindows()
+	{
+		if (strtoupper(substr(PHP_OS, 0, 3)) != 'WIN') $this->skip();
+
+		$customTempDir = 'C:\Users\Foo Bar\Temp\Dir';
+		if (!file_exists($customTempDir)) mkdir($customTempDir, null, true);
+
+		$cmd = new Command('image.png');
+		$cmd->tempDir = $customTempDir;
+
+		$expected = '"tesseract" "image.png" "C:\Users\Foo Bar\Temp\Dir';
+		$actual = substr("$cmd", 0, strlen($expected));
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testThreadLimit()
+	{
+		$expected = 'OMP_THREAD_LIMIT=4 "tesseract" "image.png" "tmpfile"';
+		$actual = $this->tess->threadLimit(4)->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testVersion()
+	{
+		$expected = '3.05';
+		$actual = $this->tess->version();
+		$this->assertEquals("$expected", "$actual");
+	}
+
+	public function testSetOutputFile()
+	{
+		$expected = '"tesseract" "image.png" "tmpfile" pdf';
+		$actual = $this->tess->configFile('pdf')->setOutputFile('/foo/bar.pdf')->command;
+		$this->assertEquals("$expected", "$actual");
+	}
+}

+ 14 - 0
vendor/thiagoalessio/tesseract_ocr/tests/Unit/TestableCommand.php

@@ -0,0 +1,14 @@
+<?php namespace thiagoalessio\TesseractOCR\Tests\Unit;
+
+use thiagoalessio\TesseractOCR\Command;
+
+class TestableCommand extends Command
+{
+	public function __construct($image=null, $version='3.05')
+	{
+		parent::__construct($image, 'tmpfile');
+		$this->version = $version;
+	}
+
+	public function getTesseractVersion() { return $this->version; }
+}

+ 79 - 0
vendor/thiagoalessio/tesseract_ocr/tests/run.php

@@ -0,0 +1,79 @@
+<?php namespace thiagoalessio\TesseractOCR\Tests;
+
+require_once __DIR__.'/../vendor/autoload.php';
+
+if (in_array('unit', $argv)) {
+	foreach(glob(__DIR__.'/Unit/*Test.php') as $file) require_once $file;
+	foreach(glob(__DIR__.'/Unit/**/*Test.php') as $file) require_once $file;
+}
+
+if (in_array('e2e', $argv))
+	foreach(glob(__DIR__.'/EndToEnd/*.php') as $file) require_once $file;
+
+// setting up code coverage
+if (extension_loaded('xdebug')) {
+	if (class_exists('\PHP_CodeCoverage')) {
+		$coverage = new \PHP_CodeCoverage;
+		$coverage->filter()->addDirectoryToWhitelist('./src');
+	} else {
+		$filter = new \SebastianBergmann\CodeCoverage\Filter;
+		$filter->includeDirectory('./src');
+		$selector = new \SebastianBergmann\CodeCoverage\Driver\Selector;
+		$coverage = new \SebastianBergmann\CodeCoverage\CodeCoverage(
+			$selector->forLineCoverage($filter),
+			$filter
+		);
+	}
+	$coverage->start('tests');
+}
+
+// running tests
+$isTest = function($class) {
+	return strstr($class, __NAMESPACE__) && !strstr($class, 'Common');
+};
+$tests = array_filter(get_declared_classes(), $isTest);
+$rc = 0;
+foreach ($tests as $test) {
+	echo str_replace(__NAMESPACE__.'\\', '', $test), PHP_EOL;
+
+	$testInstance = new $test;
+	$results = $testInstance->run();
+	foreach ($results as $name => $result) {
+		switch ($result['status']) {
+			case 'fail':
+				$status = "\033[31m✕";
+				break;
+			case 'pass':
+				$status = "\033[32m✓";
+				break;
+			case 'skip':
+				$status = "\033[33m‖";
+				break;
+		}
+		echo "\t{$status} {$name}\033[0m", PHP_EOL;
+
+		if ($result['status'] == 'fail') {
+			$rc++;
+			echo "\033[35m{$result['msg']}\033[0m", PHP_EOL;
+		}
+	}
+	echo PHP_EOL;
+}
+
+// saving coverage results
+if (isset($coverage)) {
+	$coverage->stop();
+	$reportClass = class_exists('\PHP_CodeCoverage_Report_Clover')
+		? '\PHP_CodeCoverage_Report_Clover'
+		: '\SebastianBergmann\CodeCoverage\Report\Clover';
+	$writer = new $reportClass;
+	$writer->process($coverage, 'coverage.xml');
+
+	// dev
+	//$reportClass = class_exists('\PHP_CodeCoverage_Report_HTML')
+	//	? '\PHP_CodeCoverage_Report_HTML'
+	//	: '\SebastianBergmann\CodeCoverage\Report\Html\Facade';
+	//$writer = new $reportClass;
+	//@$writer->process($coverage, 'coverage-report');
+}
+exit($rc);