diff --git a/application/api/controller/Article.php b/application/api/controller/Article.php index f14695e1..70371bb7 100644 --- a/application/api/controller/Article.php +++ b/application/api/controller/Article.php @@ -3476,6 +3476,17 @@ class Article extends Base return jsonError("Submission failed"); } $article_id = $data['article_id']; + + //查询标题是否存在 + $oArticle = new \app\common\Article; + $aCheckTitle = $oArticle->checkTitle(['title' => $data['title'],'article_id' => $article_id,'user_id' => $data['user_id']]); + $iStatus = empty($aCheckTitle['status']) ? -1 : $aCheckTitle['status']; + $sMsg = empty($aCheckTitle['msg']) ? '': $aCheckTitle['msg']; + $iDraftId = empty($aCheckTitle['draft_id']) ? 0 : $aCheckTitle['draft_id']; + if($iStatus != 1){ + return json(['code' => $iStatus, 'msg' => $sMsg,'draft_id' => $iDraftId]); + } + //添加文章基础信息 if ($data['article_id'] == 0) { $checkArticle = $this->article_obj->where("title", trim($data['title']))->select(); @@ -5489,6 +5500,17 @@ class Article extends Base } } + //查询标题是否存在 + $oArticle = new \app\common\Article; + $aCheckTitle = $oArticle->checkTitle(['title' => $aArticle['title'],'article_id' => $iArticleId,'user_id' => $iUserId]); + $iStatus = empty($aCheckTitle['status']) ? -1 : $aCheckTitle['status']; + $sMsg = empty($aCheckTitle['msg']) ? '': $aCheckTitle['msg']; + $iDraftId = empty($aCheckTitle['draft_id']) ? 0 : $aCheckTitle['draft_id']; + if($iStatus != 1){ + $iFirstStatus = 2; + $sFirstMsg = 'Step 1: '.$sMsg; + } + //判断伦理 if(!empty($aArticle['approval']) && $aArticle['approval'] == 1 && empty($aArticle['approval_file'])){ $iFirstStatus = 2; diff --git a/application/api/controller/Cronreview.php b/application/api/controller/Cronreview.php new file mode 100644 index 00000000..2dd05989 --- /dev/null +++ b/application/api/controller/Cronreview.php @@ -0,0 +1,407 @@ + [ + 'email_subject' => 'Invitation to Review Manuscript for [{accept_sn}]-Reminder', + 'email_content' => ' + Dear Dr. {realname},

+ I hope this email finds you well.

+ On {invite_time}, we sent you the following review request for {journal_title},
+ Manuscript ID:{accept_sn}
+ Title:{article_title}

+ We have not yet received a response from you, and we understand that the original invitation may not have reached you. We would greatly appreciate it if you could kindly inform us whether you are available to undertake this review.

+ For your convenience, please find the relevant links below:
+ Accept the review invitation
+ Reject the review invitation
+ Your username: {account}
+ Your original password:123456qwe, if you have reset the password, please sign in with the new one or click the "forgot password".

+ Thank you once again for considering our invitation. Your input is invaluable to us, and we truly appreciate your time and effort.

+ Please feel free to reply to this email or contact me directly with any questions.

+ Sincerely,
+ Editorial Office
+ Subscribe to this journal
{journal_title}
+ Email: {journal_email}
+ Website: {website}' + ], + 'five' => [ + 'email_subject' => 'Gentle Reminder: Review Invitation for Manuscript {accept_sn}', + 'email_content' => ' + Dear Dr. {realname},

+ This is a brief follow-up regarding our review invitation sent on {invite_time} for the following manuscript submitted to {journal_title}:

+ Manuscript ID:{accept_sn}
+ Title:{article_title}

+ We would appreciate it if you could inform us whether you are able to undertake this review. If you are unavailable or require additional time, please feel free to let us know so that we may make appropriate arrangements.

+ For your convenience, please find the relevant links below:
+ Accept the review invitation
+ Reject the review invitation
+ Your username: {account}
+ Your original password:123456qwe, if you have reset the password, please sign in with the new one or click the "forgot password".

+ Thank you very much for your time and consideration.

+ Sincerely,
+ Editorial Office
+ Subscribe to this journal
{journal_title}
+ Email: {journal_email}
+ Website: {website}' + ], + + 'ten' => [ + 'email_subject' => 'Reminder: Review Report for Manuscript [{accept_sn}]', + 'email_content' => ' + Dear Dr. {realname},

+ I hope this message finds you well.

+ I am writing to kindly follow up regarding the review report for the following manuscript, for which you were so kind to agree to serve as a reviewer for {journal_title}.
+ Manuscript ID:{accept_sn}
+ Title:{article_title}

+ We sincerely appreciate the time and expertise you are dedicating to this review. We fully understand that academic and professional commitments can be demanding, and should you require additional time or experience any difficulty in accessing the manuscript, please do not hesitate to let us know. We would be more than happy to accommodate your schedule or provide any assistance needed.

+ We would greatly appreciate your expert feedback at your earliest convenience, as it will help us proceed smoothly with the editorial process. For your convenience, please find the relevant link below:
+ Click here to submit the review report
+ Your username: {account}
+ Your original password:123456qwe, if you have reset the password, please sign in with the new one or click the "forgot password".

+ Thank you once again for your valued contribution to {journal_title} and for your continued support of our peer-review process.

+ Sincerely,
+ Editorial Office
+ Subscribe to this journal
{journal_title}
+ Email: {journal_email}
+ Website: {website}' + ], + 'twelve' => [ + 'email_subject' => 'Gentle Reminder: Review Report for Manuscript [{accept_sn}]', + 'email_content' => ' + Dear Dr. {realname},

+ This is a gentle reminder regarding the review report for the manuscript listed below, for which you kindly agreed to serve as a reviewer:

+ Manuscript ID:{accept_sn}
+ Title:{article_title}

+ For your convenience, please find the relevant links below:
+ Click here to submit review report
+ Your username: {account}
+ Your original password:123456qwe, if you have reset the password, please login with the new one or click the "forgot password".

+ We would greatly appreciate it if you could submit your review at your convenience before {agree_deadline}. If you require additional time or encounter any difficulties, please feel free to let us know.

+ Thank you very much for your valuable time and contribution to the peer-review process.

+ Sincerely,
+ Editorial Office
+ Subscribe to this journal
{journal_title}
+ Email: {journal_email}
+ Website: {website}' + ], + ]; + private $iDayTime = 86400;//一天秒数 + /** + * 文章审稿阶段-邀请审稿超过三天/五天发送提醒邮件 + * @return void + */ + public function inviteReminder(){ + + //获取当前时间 + $sCurrentDate = date('Y-m-d', time()); + $iTime = $this->iDayTime; + //获取文章信息 + $aResult = $this->getArticle(); + $iStatus = empty($aResult['status']) ? 0 : $aResult['status']; + $sMsg = empty($aResult['msg']) ? 'No data obtained' : $aResult['msg']; + if($iStatus != 1){ + $this->showMessage($sMsg,2); + exit; + } + + //数据处理 + $aArticle = empty($aResult['data']) ? [] : $aResult['data']; + if(empty($aArticle)){ + $this->showMessage('Article or journal information is empty',2); + exit; + } + + //查询文章邀请的审稿人 + $aArticleId = array_column($aArticle, 'article_id'); + $aWhere = ['article_id' => ['in',$aArticleId],'state' => 5]; + $aReviewer = Db::name('article_reviewer')->field('art_rev_id,reviewer_id,article_id,ctime')->where($aWhere)->order('article_id desc')->select(); + if(empty($aReviewer)){ + $this->showMessage('No qualified reviewers were found',2); + exit; + } + + //数据处理 + foreach ($aReviewer as $key => $value) { + if(empty($value['ctime'])){ + continue; + } + //时间处理 + $sTargetDate = date('Y-m-d', $value['ctime']); + //日期转时间戳 + $iTargetTime = strtotime($sTargetDate);//邀请时间戳 + $iCurrentTime = strtotime($sCurrentDate);//当前时间戳 + $iThreeCtime = intval($iTargetTime + (3 * $iTime));//三天 + $iFiveCtime = intval($iTargetTime + (5 * $iTime));//五天 + //对比 + if($iCurrentTime != $iThreeCtime && $iCurrentTime != $iFiveCtime){ + continue; + } + if($iThreeCtime == $iCurrentTime){ //超过三天 + $value['email_type'] = 'three'; + } + if($iFiveCtime == $iCurrentTime){ //超过五天 + $value['email_type'] = 'five'; + } + \think\Queue::push('app\api\job\ReminderEmailToReviewer@fire',$value, 'ReminderEmailToReviewer'); + } + $this->showMessage('邀请审稿超过三天/五天发送提醒邮件处理完成',1); + } + /** + * 文章审稿阶段-同意审稿超过十天/十二天发送提醒邮件 + * @return void + */ + public function agreeReminder(){ + + //获取当前时间 + $sCurrentDate = date('Y-m-d', time()); + $iTime = $this->iDayTime; + //获取文章信息 + $aResult = $this->getArticle(); + $iStatus = empty($aResult['status']) ? 0 : $aResult['status']; + $sMsg = empty($aResult['msg']) ? 'No data obtained' : $aResult['msg']; + if($iStatus != 1){ + $this->showMessage($sMsg,2); + exit; + } + + //数据处理 + $aArticle = empty($aResult['data']) ? [] : $aResult['data']; + if(empty($aArticle)){ + $this->showMessage('Article or journal information is empty',2); + exit; + } + + //查询文章邀请的审稿人 + $aArticleId = array_column($aArticle, 'article_id'); + $aWhere = ['article_id' => ['in',$aArticleId],'state' => 0]; + $aReviewer = Db::name('article_reviewer')->field('art_rev_id,reviewer_id,article_id,agree_review_time')->where($aWhere)->order('article_id desc')->select(); + if(empty($aReviewer)){ + $this->showMessage('No qualified reviewers were found',2); + exit; + } + + //数据处理 + foreach ($aReviewer as $key => $value) { + if(empty($value['agree_review_time'])){ + continue; + } + //时间处理 + $sTargetDate = date('Y-m-d', $value['agree_review_time']); + //日期转时间戳 + $iTargetTime = strtotime($sTargetDate);//邀请时间戳 + $iCurrentTime = strtotime($sCurrentDate);//当前时间戳 + $iTenCtime = intval($iTargetTime + (10 * $iTime));//十天 + $iTwelveCtime = intval($iTargetTime + (12 * $iTime));//十二天 + //对比 + if($iCurrentTime != $iTenCtime && $iCurrentTime != $iTwelveCtime){ + continue; + } + if($iTenCtime == $iCurrentTime){ //超过十天 + $value['email_type'] = 'ten'; + } + if($iTwelveCtime == $iCurrentTime){ //超过十二天 + $value['email_type'] = 'twelve'; + } + \think\Queue::push('app\api\job\ReminderEmailToReviewer@fire',$value, 'ReminderEmailToReviewer'); + } + $this->showMessage('同意审稿超过十天/十二天发送提醒邮件处理完成',1); + } + /** + * 发送邮件提醒 + * @return void + */ + public function reminder($aParam = []){ + //文章ID + $iArticleId = empty($aParam['article_id']) ? 0 : $aParam['article_id']; + if(empty($iArticleId)){ + return json_encode(['status' => 2,'msg' => 'Please select the article to query']); + } + //审稿人ID + $iReviewerId = empty($aParam['reviewer_id']) ? 0 : $aParam['reviewer_id']; + if(empty($iReviewerId)){ + return json_encode(['status' => 2,'msg' => 'Reviewers who meet the criteria of the article were not selected']); + } + //邮件类型 + $sEmailType = empty($aParam['email_type']) ? '' : $aParam['email_type']; + if(empty($sEmailType)){ + return json_encode(['status' => 2,'msg' => 'Email type cannot be empty']); + } + //判断文章是否送审 + $aWhere = ['state' => 2,'article_id' => $iArticleId]; + $aArticle = Db::name('article')->field('article_id,user_id,journal_id,accept_sn,title,abstrart')->where($aWhere)->find(); + if(empty($aArticle)){ + return json_encode(array('status' => 3,'msg' => 'No articles requiring review were found' )); + } + //查询是否存在审稿记录 + $aState = ['three' => 5,'five' => 5,'ten' => 0,'twelve' => 0]; + $iState = isset($aState[$sEmailType]) ? $aState[$sEmailType] : -1 ; + $aWhere = ['reviewer_id' => $iReviewerId,'article_id' => $iArticleId,'state' => $iState]; + $aReviewer = Db::name('article_reviewer')->field('art_rev_id,reviewer_id,ctime,agree_review_time,state')->where($aWhere)->find(); + if(empty($aReviewer)){ + return json_encode(['status' => 4,'msg' => 'No qualified reviewers were found']); + } + + //查询期刊信息 + if(empty($aArticle['journal_id'])){ + return json_encode(array('status' => 5,'msg' => 'The article is not associated with a journal' )); + } + $aWhere = ['state' => 0,'journal_id' => $aArticle['journal_id']]; + $aJournal = Db::name('journal')->where($aWhere)->find(); + if(empty($aJournal)){ + return json_encode(array('status' => 6,'msg' => 'No journal information found' )); + } + + //查询审稿人邮箱 + $aWhere = ['user_id' => $iReviewerId,'state' => 0,'email' => ['<>','']]; + $aUser = Db::name('user')->field('user_id,email,realname,account')->where($aWhere)->find(); + //收件人 + $sEmail = empty($aUser['email']) ? '' : $aUser['email'];//'1172937051@qq.com';//'publisher@tmrjournals.com';//'1172937051@qq.com';// + if(empty($sEmail)){ + return json_encode(['status' => 7,'msg' => "Reviewer's email information not found"]); + } + + //处理发邮件 + //获取邮件模版 + $aEmailConfig= empty($this->aEmailConfig[$sEmailType]) ? [] : $this->aEmailConfig[$sEmailType]; + if(empty($aEmailConfig)){ + return json_encode(['status' => 8,'msg' => "Email template not obtained"]); + } + //邮件内容 + $aSearch = [ + '{accept_sn}' => empty($aArticle['accept_sn']) ? '' : $aArticle['accept_sn'],//accept_sn + '{article_title}' => empty($aArticle['title']) ? '' : $aArticle['title'],//文章标题 + '{abstrart}' => empty($aArticle['abstrart']) ? '' : $aArticle['abstrart'],//文章摘要 + '{journal_title}' => empty($aJournal['title']) ? '' : $aJournal['title'],//期刊名 + '{journal_issn}' => empty($aJournal['issn']) ? '' : $aJournal['issn'], + '{journal_email}' => empty($aJournal['email']) ? '' : $aJournal['email'], + '{website}' => empty($aJournal['website']) ? '' : $aJournal['website'], + ]; + //用户名 + $realname = empty($aUser['account']) ? '' : $aUser['account']; + $realname = empty($aUser['realname']) ? $realname : $aUser['realname']; + $aSearch['{realname}'] = $realname; + //用户账号 + $aSearch['{account}'] = empty($aUser['account']) ? '' : $aUser['account']; + //审稿链接 + $oArticle = new \app\api\controller\Article; + $aSearch['{creatLoginUrlForreviewer}'] = $oArticle->creatLoginUrlForreviewer(['user_id' => $iReviewerId],$aReviewer['art_rev_id']); + if($aReviewer['state'] == 5){ + $aSearch['{creatRejectUrlForReviewer}'] = $oArticle->creatRejectUrlForReviewer(['user_id' => $iReviewerId],$aReviewer['art_rev_id']); + + } + //邀请时间 + $aSearch['{invite_time}'] = empty($aReviewer['ctime']) ? '' : $this->timestampToEnglishDate($aReviewer['ctime']); + //同意审稿截止时间 + $iAgreeTime = empty($aReviewer['agree_review_time']) ? 0 : $aReviewer['agree_review_time']; + $iAgreeTime = empty($iAgreeTime) ? '' : intval($iAgreeTime + (14 * $this->iDayTime));//十四天 + $aSearch['{agree_deadline}'] = empty($iAgreeTime) ? '' : $this->timestampToEnglishDate($iAgreeTime); + //邮件标题 + $title = str_replace(array_keys($aSearch), array_values($aSearch),$aEmailConfig['email_subject']); + //邮件内容变量替换 + $content = str_replace(array_keys($aSearch), array_values($aSearch), $aEmailConfig['email_content']); + //判断标题和内容是否为空 + if(empty($title) || empty($content)){ + return json_encode(['status' => 9,'msg' => "The email content and title are empty"]); + } + //拼接样式 + $pre = \think\Env::get('emailtemplete.pre'); + $net = \think\Env::get('emailtemplete.net'); + $net1 = str_replace("{{email}}",trim($sEmail),$net); + $content=$pre.$content.$net1; + + //发送邮件 + $memail = empty($aJournal['email']) ? '' : $aJournal['email']; + $mpassword = empty($aJournal['epassword']) ? '' : $aJournal['epassword']; + //期刊标题 + $from_name = empty($aJournal['title']) ? '' : $aJournal['title']; + + //查询是否发送过邮件 + $aStateType = ['three' => 10,'five' => 11,'ten' => 12,'twelve' => 13]; + //邮件日志类型 + $iLogType = empty($aStateType[$sEmailType]) ? 0 : $aStateType[$sEmailType]; + $oReviewer = new \app\common\Reviewer; + $aEmailLog = ['article_id' => $iArticleId,'art_rev_id' => $aReviewer['art_rev_id'],'reviewer_id' => $iReviewerId,'type' => $iLogType,'is_success' => 1]; + $aLog = DB::name('email_reviewer')->field('id')->where($aEmailLog)->find(); + $sMsg = '邮件已发送'; + if(empty($aLog)){ + $aResult = sendEmail($sEmail,$title,$from_name,$content,$memail,$mpassword); + $iStatus = empty($aResult['status']) ? 1 : $aResult['status']; + $iIsSuccess = 2; + $sMsg = empty($aResult['data']) ? '失败' : $aResult['data']; + if($iStatus == 1){ + $iIsSuccess = 1; + $sMsg = '成功'; + } + //记录邮件发送日志 + $aEmailLog['email'] = $sEmail; + $aEmailLog['content'] = $content; + $aEmailLog['create_time'] = time(); + $aEmailLog['is_success'] = $iIsSuccess; + $aEmailLog['msg'] = $sMsg; + //添加邮件发送日志 + $iId = $oReviewer->addLog($aEmailLog); + } + return json_encode(['status' => 1,'msg' => $sMsg]); + } + /** + * 获取审稿状态的文章 + * @return void + */ + private function getArticle(){ + + //查询送审中的文章 + $aWhere = ['state' => 2]; + $aArticle = Db::name('article')->field('article_id')->where($aWhere)->select(); + if(empty($aArticle)){ + return ['status' => 2,'msg' => 'No articles requiring review were found']; + } + + //数据返回 + return ['status' => 1,'msg' => 'Successfully obtained information','data' => $aArticle]; + } + private function timestampToEnglishDate($timestamp) { + //验证时间戳有效性 + if (!is_numeric($timestamp) || $timestamp < 0) { + return ''; + } + + //设置本地化(确保月份为英文全称,兼容Linux/Windows) + $locale = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' + ? 'English_United States.1252' + : 'en_US.UTF-8'; + setlocale(LC_TIME, $locale); + + //格式化:d=日(无前导零)、F=英文月份全称、Y=四位年 strftime的%e在Linux下是无前导零的日,Windows用%#d + if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { + $dateStr = strftime('%#d %B %Y', $timestamp); // Windows + } else { + $dateStr = strftime('%e %B %Y', $timestamp); // Linux/Mac + } + return trim($dateStr); + } + /** + * + * 格式化信息输出 + * + * @access public + * @return void + * @date 2018.09.28 + * @param $[message] [<显示信息>] + * @param $[status] [<输出信息1成功,2失败>] + */ + private function showMessage($message, $status = 1) { + if ($status == 1) { + echo "[SUCCESS]"; + } else { + echo "[ERROR]"; + } + echo date("Y-m-d H:i:s") . " " . $message . "\n"; + } + + +} diff --git a/application/api/controller/EmailClient.php b/application/api/controller/EmailClient.php index b243f43b..5d379ad6 100644 --- a/application/api/controller/EmailClient.php +++ b/application/api/controller/EmailClient.php @@ -1385,7 +1385,7 @@ class EmailClient extends Base $factoryId = intval($this->request->param('promotion_factory_id', 0)); $sendDate = trim($this->request->param('send_date', date('Y-m-d', strtotime('+1 day')))); $taskName = trim($this->request->param('task_name', '')); - $noRepeatDays = intval($this->request->param('no_repeat_days', 30)); + $noRepeatDays = intval($this->request->param('no_repeat_days', 15)); $minInterval = intval($this->request->param('min_interval', 30)); $maxInterval = intval($this->request->param('max_interval', 60)); $maxBounceRate = intval($this->request->param('max_bounce_rate', 5)); @@ -2282,7 +2282,7 @@ class EmailClient extends Base set_time_limit(120); $sendDate = date('Y-m-d', strtotime('+1 day')); - $noRepeatDaysDefault = 30; + $noRepeatDaysDefault = 15; $factories = Db::name('promotion_factory') ->alias('f') @@ -2354,7 +2354,7 @@ class EmailClient extends Base $expertType = intval($factory['expert_type']); // 内部受众(type∈{1..4}):默认 60 天频次(约稿场景);外部 expert 库(type=5)沿用 30 天 - $noRepeatDays = $expertType === 5 ? $noRepeatDaysDefault : 60; + $noRepeatDays = $expertType === 5 ? $noRepeatDaysDefault : 20; if ($expertType === 5) { $fields = $this->resolveFieldsByFetchIds($factory['fetch_ids']); @@ -2624,8 +2624,8 @@ class EmailClient extends Base $expertType = intval($factory['expert_type']); $dailyLimit = max(1, intval($factory['send_count'])); - // 默认频次:expert=30天,内部=60天 - $noRepeatDaysDefault = $expertType === 5 ? 30 : 60; + // 默认频次:expert=15天,内部=20天 + $noRepeatDaysDefault = $expertType === 5 ? 15 : 20; $noRepeatDays = intval($this->request->param('no_repeat_days', $noRepeatDaysDefault)); if ($expertType === 5) { @@ -2734,7 +2734,7 @@ class EmailClient extends Base $query = Db::name('expert')->alias('e') ->join('t_expert_field ef', 'e.expert_id = ef.expert_id', 'inner') - ->where('e.state', 0) +// ->where('e.state', 0) ->where('e.unsubscribed', 0) ->where('ef.state', 0); @@ -2776,11 +2776,16 @@ class EmailClient extends Base $query->where('e.country_id', 'in', $countryIds); } - return $query + $res1 = $query ->field('e.*') ->group('e.expert_id') ->limit($limit) ->select(); + + +// echo $query->getLastSql(); + + return $res1; } /** diff --git a/application/api/controller/ExpertFinder.php b/application/api/controller/ExpertFinder.php index 19984547..ee8e5aac 100644 --- a/application/api/controller/ExpertFinder.php +++ b/application/api/controller/ExpertFinder.php @@ -298,7 +298,7 @@ class ExpertFinder extends Base */ public function dailyFetchAll() { - $perPage = max(10, intval($this->request->param('per_page', 10))); + $perPage = max(10, intval($this->request->param('per_page', 50))); $source = $this->request->param('source', 'pubmed'); $minYear = intval($this->request->param('min_year', date('Y') - 3)); diff --git a/application/api/controller/Journal.php b/application/api/controller/Journal.php index f785b0bd..e7f5f7fd 100644 --- a/application/api/controller/Journal.php +++ b/application/api/controller/Journal.php @@ -126,6 +126,24 @@ class Journal extends Base { return jsonSuccess($program); } + public function citeMate(){ + $data = $this->request->post(); + $rule = new Validate([ + "journal_id"=>"require", + "year"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $journal_info = $this->journal_obj->where("journal_id",$data['journal_id'])->find(); + $url = "http://journalapi.tmrjournals.com/public/index.php/api/Main/citeMate"; + $program['journal_issn'] = $journal_info['issn']; + $program['year'] = $data['year']; + $res = object_to_array(json_decode(myPost($url,$program))); + + return json($res); + } + public function delJournalStage(){ $data = $this->request->post(); $rule = new Validate([ diff --git a/application/api/controller/Preaccept.php b/application/api/controller/Preaccept.php index 9f89b31d..0ec24534 100644 --- a/application/api/controller/Preaccept.php +++ b/application/api/controller/Preaccept.php @@ -782,7 +782,8 @@ class Preaccept extends Base $title = trim((string)($summary['title'] ?? '')); $jouraRaw = trim((string)($summary['joura'] ?? '')); - $authorStr = trim((string)($summary['author_str'] ?? '')); + // 姓全写 + 名首字母,超过 3 个作者取前 3 个 + et al + $authorCitation = $svc->getAuthorsCitation($summary['raw'] ?? [], 3); $dateno = trim((string)($summary['dateno'] ?? '')); $doilink = trim((string)($summary['doilink'] ?? '')); if ($doilink === '') { @@ -790,7 +791,7 @@ class Preaccept extends Base } $f = [ - 'author' => $authorStr !== '' ? prgeAuthor($authorStr) : '', + 'author' => $authorCitation !== '' ? $authorCitation . '.' : '', 'title' => $title, 'joura' => $jouraRaw !== '' ? formateJournal($jouraRaw) : '', 'dateno' => str_replace(' ', '', str_replace('-', '–', $dateno)), diff --git a/application/api/controller/Production.php b/application/api/controller/Production.php index 6d7bdf54..dbaf23de 100644 --- a/application/api/controller/Production.php +++ b/application/api/controller/Production.php @@ -3208,7 +3208,7 @@ class Production extends Base $z = count($list); $m = 0; foreach ($list as $v) { - if ($v['refer_frag'] != '' || $v['author'] != '') { + if ($v['refer_frag'] != '' || $v['author'] != ''|| $v['title'] != '') { $m++; } } @@ -3361,7 +3361,7 @@ class Production extends Base $tt .= "Please carefully check the proof, including the text, figures, tables, references, author information, affiliations, spelling, and formatting. If any corrections are needed, please mark them clearly on the proof or submit comments through the system.
"; $tt .= "If we do not receive your confirmation by ".date("Y-m-d", strtotime("+3 days")).", the proof will be considered approved in its current form. Please note that no further revisions will be accepted after online confirmation.

"; $tt .= "Thank you for your time and cooperation. Should you have any questions, please feel free to contact us.

"; - $tt .= "Best regards,
Biomedical Engineering Communications
Email: bmec@tmrjournals.com
Website: https://www.tmrjournals.com/bmec/"; + $tt .= "Best regards,
".$journal_info['title']."
Email: ".$journal_info['email']."
Website: ".$journal_info['website']; // $maidata['email'] = '751475802@qq.com'; $maidata['email'] = $user_info['email']; diff --git a/application/api/job/AiCheckRefer.php b/application/api/job/AiCheckRefer.php new file mode 100644 index 00000000..d63ebb20 --- /dev/null +++ b/application/api/job/AiCheckRefer.php @@ -0,0 +1,78 @@ +oQueueJob = new QueueJob; + $this->QueueRedis = QueueRedis::getInstance(); + } + + public function fire(Job $job, $data) + { + //任务开始判断 + $this->oQueueJob->init($job); + + // 获取 Redis 任务的原始数据 + $rawBody = empty($job->getRawBody()) ? '' : $job->getRawBody(); + $jobData = empty($rawBody) ? [] : json_decode($rawBody, true); + $jobId = empty($jobData['id']) ? 'unknown' : $jobData['id']; + + $this->oQueueJob->log("-----------队列任务开始-----------"); + $this->oQueueJob->log("当前任务ID: {$jobId}, 尝试次数: {$job->attempts()}"); + + + // 获取生产文章ID + $iPArticleId = empty($data['p_article_id']) ? 0 : $data['p_article_id']; + if (empty($iPArticleId)) { + $this->oQueueJob->log("无效的p_article_id,删除任务"); + $job->delete(); + return; + } + try { + + // 生成Redis键并尝试获取锁 + $sClassName = get_class($this); + $sRedisKey = "queue_job:{$sClassName}:{$iPArticleId}"; + $sRedisValue = uniqid() . '_' . getmypid(); + if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) { + return; // 未获取到锁,已处理 + } + + //生成内容 + $oProductionArticleRefer = new \app\api\controller\References; + $response = $oProductionArticleRefer->checkByAi($data); + // 验证API响应 + if (empty($response)) { + throw new \RuntimeException("OpenAI API返回空结果"); + } + // 检查JSON解析错误 + $aResult = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException("解析OpenAI响应失败: " . json_last_error_msg() . " | 原始响应: {$response}"); + } + $sMsg = empty($aResult['msg']) ? 'success' : $aResult['msg']; + //更新完成标识 + $this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie,$sRedisValue); + $job->delete(); + $this->oQueueJob->log("任务执行成功 | 日志ID: {$sRedisKey} | 执行日志:{$sMsg}"); + + } catch (\RuntimeException $e) { + $this->oQueueJob->handleRetryableException($e,$sRedisKey,$sRedisValue, $job); + } catch (\LogicException $e) { + $this->oQueueJob->handleNonRetryableException($e,$sRedisKey,$sRedisValue, $job); + } catch (\Exception $e) { + $this->oQueueJob->handleRetryableException($e,$sRedisKey,$sRedisValue, $job); + } finally { + $this->oQueueJob->finnal(); + } + } +} \ No newline at end of file diff --git a/application/api/job/ProofReadQueue.php b/application/api/job/ProofReadQueue.php new file mode 100644 index 00000000..393f35ac --- /dev/null +++ b/application/api/job/ProofReadQueue.php @@ -0,0 +1,82 @@ +oQueueJob = new QueueJob; + $this->QueueRedis = QueueRedis::getInstance(); + } + + public function fire(Job $job, $data) + { + //任务开始判断 + $this->oQueueJob->init($job); + + // 获取 Redis 任务的原始数据 + $rawBody = empty($job->getRawBody()) ? '' : $job->getRawBody(); + $jobData = empty($rawBody) ? [] : json_decode($rawBody, true); + $jobId = empty($jobData['id']) ? 'unknown' : $jobData['id']; + + $this->oQueueJob->log("-----------队列任务开始-----------"); + $this->oQueueJob->log("当前任务ID: {$jobId}, 尝试次数: {$job->attempts()}"); + + // 获取文章ID + $iArticleId = empty($data['article_id']) ? 0 : $data['article_id']; + $sChunkIndex = empty($data['chunkIndex']) ? 0 : $data['chunkIndex']; + $sPrompt = empty($data['prompt']) ? '' : $data['prompt']; + if (empty($iArticleId)) { + $this->oQueueJob->log("无效的article_id,删除任务"); + $job->delete(); + return; + } + try { + + // 生成Redis键并尝试获取锁 + $sClassName = get_class($this); + $sRedisKey = "queue_job:{$sClassName}:{$iArticleId}:{$sPrompt}_{$sChunkIndex}"; + $sRedisValue = uniqid() . '_' . getmypid(); + if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) { + return; // 未获取到锁,已处理 + } + + //生成内容 + $oAireview = new ProofRead; + $response = $oAireview->proofReadQueue($data); + + // 验证API响应 + if (empty($response)) { + throw new \RuntimeException("OpenAI API返回空结果"); + } + // 检查JSON解析错误 + $aResult = json_decode($response, true); + echo '
';var_dump($aResult);
+            if (json_last_error() !== JSON_ERROR_NONE) {
+                throw new \RuntimeException("解析OpenAI响应失败: " . json_last_error_msg() . " | 原始响应: {$response}");
+            }
+            $sMsg = empty($aResult['msg']) ? 'success' : $aResult['msg'];
+            //更新完成标识
+            $this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie,$sRedisValue);
+            $job->delete();
+            $this->oQueueJob->log("任务执行成功 | 日志ID: {$sRedisKey} | 执行日志:{$sMsg}");
+
+        } catch (\RuntimeException $e) {
+            $this->oQueueJob->handleRetryableException($e,$sRedisKey,$sRedisValue, $job);
+        } catch (\LogicException $e) {
+            $this->oQueueJob->handleNonRetryableException($e,$sRedisKey,$sRedisValue, $job);
+        } catch (\Exception $e) {
+            $this->oQueueJob->handleRetryableException($e,$sRedisKey,$sRedisValue, $job);
+        } finally {
+            $this->oQueueJob->finnal();
+        }
+    }
+}
\ No newline at end of file
diff --git a/application/api/job/SendAuthorEmail.php b/application/api/job/SendAuthorEmail.php
new file mode 100644
index 00000000..a1d7c0c3
--- /dev/null
+++ b/application/api/job/SendAuthorEmail.php
@@ -0,0 +1,89 @@
+oQueueJob = new QueueJob;
+        $this->QueueRedis = QueueRedis::getInstance();
+    }
+
+    public function fire(Job $job, $data)
+    {
+        //任务开始判断
+        $this->oQueueJob->init($job);
+
+        // 获取 Redis 任务的原始数据
+        $rawBody = empty($job->getRawBody()) ? '' : $job->getRawBody();
+        $jobData = empty($rawBody) ? [] : json_decode($rawBody, true);
+        $jobId = empty($jobData['id']) ? 'unknown' : $jobData['id'];
+
+        $this->oQueueJob->log("-----------队列任务开始-----------");
+        $this->oQueueJob->log("当前任务ID: {$jobId}, 尝试次数: {$job->attempts()}");
+
+        try {
+           
+            // 验证任务数据完整性
+           // 获取文章ID
+            $iArticleId = empty($data['article_id']) ? 0 : $data['article_id'];
+            //作者邮箱
+            $email = empty($data['email']) ? '' : $data['email'];
+            //邮件主题
+            $subject = empty($data['subject']) ? '' : $data['subject'];
+            //发送来源
+            $title = empty($data['title']) ? '' : $data['title'];
+            $subject = empty($subject) ? $title : $subject;
+            //邮件内容
+            $content = empty($data['content']) ? '' : $data['content'];
+            //邮箱
+            $temail = empty($data['temail']) ? '' : $data['temail'];
+            //密码
+            $tpassword = empty($data['tpassword']) ? '' : $data['tpassword'];
+            //邮件类型
+            $type = empty($data['type']) ? 1 : $data['type'];
+            if (empty($iArticleId) || empty($email)) {
+                $this->oQueueJob->log("无效的article_id/email,删除任务");
+                $job->delete();
+                return;
+            }
+
+            // 生成唯一任务标识
+            $sClassName = get_class($this);
+            $sRedisKey = "queue_job:{$sClassName}:{$iArticleId}:{$email}";
+            $sRedisValue = uniqid() . '_' . getmypid();
+            if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) {
+                return; // 未获取到锁,已处理
+            }
+
+            // 执行核心任务-发送邮件
+            $aResult = sendEmail($email,$subject,$title,$content,$temail,$tpassword);
+            $iStatus = empty($aResult['status']) ? 1 : $aResult['status'];
+            $iIsSuccess = 2;
+            $sMsg = empty($aResult['data']) ? '失败' : $aResult['data'];
+            if($iStatus == 1){
+                $iIsSuccess = 1;
+                $sMsg = '成功';
+            }
+            // 更新完成标识
+            $this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie, $sRedisValue);
+            $job->delete();
+            $this->oQueueJob->log("任务执行成功 | 日志ID: {$sRedisKey} | 执行日志:{$sMsg}");
+
+        } catch (RuntimeException $e) {
+            $this->oQueueJob->handleRetryableException($e,$sRedisKey,$sRedisValue, $job);
+        } catch (LogicException $e) {
+            $this->oQueueJob->handleNonRetryableException($e,$sRedisKey,$sRedisValue, $job);
+        } catch (Exception $e) {
+            $this->oQueueJob->handleRetryableException($e,$sRedisKey,$sRedisValue, $job);
+        } finally {
+            $this->oQueueJob->finnal();
+        }
+    }
+}
\ No newline at end of file
diff --git a/application/command/agreeReviewReminder.sh b/application/command/agreeReviewReminder.sh
new file mode 100755
index 00000000..346d4f76
--- /dev/null
+++ b/application/command/agreeReviewReminder.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+# agreeReviewReminder.sh
+# 批量处理文章审稿阶段-同意审稿超过十天/十二天发送提醒邮件
+# 调用接口获取需要提醒的审稿人记录
+# 此文件需要在crontab中配置每天【凌晨0:30】运行一次
+# @author chengxiaoling
+# @date 2025-12-29
+
+# 基础配置
+DOMAIN="http://api.tmrjournals.com/public/index.php/"  # 项目域名
+# DOMAIN="http://zmzm.tougao.dev.com"  # 项目域名
+ROUTE="/api/Cronreview/agreeReminder"  # 控制器路由
+BASE_PATH=$(cd `dirname $0`; pwd)
+# 如果日志目录不存在则创建
+logDir=${BASE_PATH}/log/$(date "+%Y")/$(date "+%m")
+if [ ! -d $logDir  ];then
+    mkdir -p $logDir
+fi
+
+# 执行请求并记录日志
+curl "${DOMAIN}${ROUTE}" >> ${logDir}/agreeReminder_$(date "+%Y%m%d").log 2>&1
+# 添加时间戳
+echo "[$(date '+%Y-%m-%d %H:%M:%S')] 定时任务已执行" >> ${logDir}/agreeReminder_$(date "+%Y%m%d").log
\ No newline at end of file
diff --git a/application/command/inviteReviewReminder.sh b/application/command/inviteReviewReminder.sh
new file mode 100755
index 00000000..b4ac4715
--- /dev/null
+++ b/application/command/inviteReviewReminder.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+# inviteReviewReminder.sh
+# 批量处理文章审稿阶段-邀请审稿超过三天/五天发送提醒邮件
+# 调用接口获取需要提醒的审稿人记录
+# 此文件需要在crontab中配置每天【凌晨0:30】运行一次
+# @author chengxiaoling
+# @date 2025-12-29
+
+# 基础配置
+DOMAIN="http://api.tmrjournals.com/public/index.php/"  # 项目域名
+# DOMAIN="http://zmzm.tougao.dev.com"  # 项目域名
+ROUTE="/api/Cronreview/inviteReminder"  # 控制器路由
+BASE_PATH=$(cd `dirname $0`; pwd)
+# 如果日志目录不存在则创建
+logDir=${BASE_PATH}/log/$(date "+%Y")/$(date "+%m")
+if [ ! -d $logDir  ];then
+    mkdir -p $logDir
+fi
+
+# 执行请求并记录日志
+curl "${DOMAIN}${ROUTE}" >> ${logDir}/inviteReminder_$(date "+%Y%m%d").log 2>&1
+# 添加时间戳
+echo "[$(date '+%Y-%m-%d %H:%M:%S')] 定时任务已执行" >> ${logDir}/inviteReminder_$(date "+%Y%m%d").log
\ No newline at end of file
diff --git a/application/common/CrossrefService.php b/application/common/CrossrefService.php
index 699768fc..e5babfb9 100644
--- a/application/common/CrossrefService.php
+++ b/application/common/CrossrefService.php
@@ -16,6 +16,10 @@ class CrossrefService
     private $timeout = 15; // 请求超时(秒)
     private $maxRetry = 2; // 单个DOI最大重试次数
     private $crossrefUrl = "https://api.crossref.org/works/"; // 接口地址
+    private $pubmedAbbr = true; // CrossRef 无期刊缩写时,是否回退到 PubMed/NLM 规范缩写
+
+    /** @var PubmedService|null 懒加载 */
+    private $pubmedService = null;
 
     public function __construct($config = [])
     {
@@ -24,6 +28,7 @@ class CrossrefService
             if (isset($config['timeout'])) $this->timeout = intval($config['timeout']);
             if (isset($config['maxRetry'])) $this->maxRetry = intval($config['maxRetry']);
             if (isset($config['crossrefUrl'])) $this->crossrefUrl = (string)$config['crossrefUrl'];
+            if (isset($config['pubmed_abbr'])) $this->pubmedAbbr = (bool)$config['pubmed_abbr'];
         }
     }
 
@@ -191,7 +196,15 @@ class CrossrefService
 
         $title = $this->getTitle($msg);
         $publisher = $this->getPublisher($msg);
-        $joura = !empty($publisher['title']) ? $publisher['title'] : ($publisher['short_title'] ?? '');
+        $validDoi = $this->filterValidDoi($doi);
+        // 期刊缩写优先级:CrossRef short-container-title → PubMed/NLM 规范缩写 → CrossRef 全称
+        $shortTitle = trim((string)($publisher['short_title'] ?? ''));
+        $fullTitle  = trim((string)($publisher['title'] ?? ''));
+        $joura = $shortTitle;
+        if ($joura === '') {
+            $pubmedAbbr = $this->lookupPubmedJournalAbbr($validDoi);
+            $joura = $pubmedAbbr !== '' ? $pubmedAbbr : $fullTitle;
+        }
         $authors = $this->getAuthors($msg);
         $dateno = $this->getVolumeIssuePages($msg);
         $retractInfo = $this->checkRetracted($msg);
@@ -280,6 +293,34 @@ class CrossrefService
         ];
     }
 
+    /**
+     * 用 PubMed/NLM 反查期刊规范缩写(CrossRef 无缩写时的兜底)。
+     * 任何异常都吞掉并返回空串,保证不影响主流程。
+     *
+     * @param string $doi 已规整的裸 DOI
+     * @return string 缩写或空串
+     */
+    private function lookupPubmedJournalAbbr($doi)
+    {
+        $doi = trim((string)$doi);
+        if (!$this->pubmedAbbr || $doi === '') {
+            return '';
+        }
+
+        try {
+            if ($this->pubmedService === null) {
+                $this->pubmedService = new PubmedService([
+                    'email'   => $this->mailto,
+                    'timeout' => $this->timeout,
+                ]);
+            }
+            $abbr = $this->pubmedService->journalAbbrByDoi($doi);
+            return is_string($abbr) ? trim($abbr) : '';
+        } catch (\Throwable $e) {
+            return '';
+        }
+    }
+
     /**
      * 提取作者列表
      */
@@ -300,6 +341,73 @@ class CrossrefService
         return $authors;
     }
 
+    /**
+     * 引用格式作者串:姓全写 + 名首字母,超过 $maxAuthors 个取前 N 个 + et al
+     * 例:Smith JA, Jones B, Lee C, et al
+     *
+     * @param array $aDoiInfo Crossref message
+     * @param int   $maxAuthors 最多展示作者数,超过则截断加 et al
+     * @return string
+     */
+    public function getAuthorsCitation($aDoiInfo = [], $maxAuthors = 3)
+    {
+        $list = [];
+        if (!empty($aDoiInfo['author'])) {
+            foreach ($aDoiInfo['author'] as $author) {
+                $family = trim((string)($author['family'] ?? ''));
+                $given = trim((string)($author['given'] ?? ''));
+
+                if ($family === '' && $given === '') {
+                    // 机构作者等无姓名结构的情况
+                    $orgName = trim((string)($author['name'] ?? ''));
+                    if ($orgName !== '') {
+                        $list[] = $orgName;
+                    }
+                    continue;
+                }
+
+                $initials = $this->givenToInitials($given);
+                $name = $initials !== '' ? trim($family . ' ' . $initials) : $family;
+                if ($name !== '') {
+                    $list[] = $name;
+                }
+            }
+        }
+
+        if (empty($list)) {
+            return '';
+        }
+
+        $maxAuthors = max(1, (int)$maxAuthors);
+        if (count($list) > $maxAuthors) {
+            $list = array_slice($list, 0, $maxAuthors);
+            return implode(', ', $list) . ', et al';
+        }
+
+        return implode(', ', $list);
+    }
+
+    /**
+     * 名转首字母:取每个组成部分(空格/连字符/点分隔)首字母大写并拼接。
+     * 例:"John A." -> "JA","Mary-Jane" -> "MJ"
+     */
+    private function givenToInitials($given)
+    {
+        $given = trim((string)$given);
+        if ($given === '') {
+            return '';
+        }
+        $parts = preg_split('/[\s\-\.]+/u', $given, -1, PREG_SPLIT_NO_EMPTY);
+        $initials = '';
+        foreach ($parts as $p) {
+            $first = mb_substr($p, 0, 1);
+            if ($first !== '') {
+                $initials .= mb_strtoupper($first);
+            }
+        }
+        return $initials;
+    }
+
     /**
      * 提取发表年份
      */
diff --git a/application/common/ExpertFieldAiService.php b/application/common/ExpertFieldAiService.php
index 55cbc6ab..34637a6b 100644
--- a/application/common/ExpertFieldAiService.php
+++ b/application/common/ExpertFieldAiService.php
@@ -2,6 +2,7 @@
 
 namespace app\common;
 
+use app\common\service\LocalModelService;
 use think\Db;
 use think\Env;
 use think\Exception;
@@ -27,9 +28,17 @@ class ExpertFieldAiService
 
     private $logFile;
 
+    /** @var bool|null */
+    private static $schemaReady = null;
+
     public function __construct()
     {
         $this->logFile = ROOT_PATH . 'runtime' . DS . 'expert_field_ai.log';
+        try {
+            $this->ensureSchema();
+        } catch (\Throwable $e) {
+            $this->log('[ExpertFieldAi] ensureSchema fail: ' . $e->getMessage());
+        }
     }
 
     // ===================== 链式队列 =====================
@@ -366,10 +375,16 @@ class ExpertFieldAiService
         $papers = array_slice($papers, 0, $maxPapers);
         $searchKeywords = array_values(array_unique(array_filter($searchKeywords)));
 
-        $countryName = '';
-        $countryId = intval($expert['country_id'] ?? 0);
-        if ($countryId > 0) {
-            $countryName = (string)Db::name('country')->where('country_id', $countryId)->value('title');
+        // t_expert.country 已存国家英文名,无需再查 country 表
+        $countryName = trim((string)($expert['country'] ?? ''));
+        if ($countryName === '') {
+            $countryId = intval($expert['country_id'] ?? 0);
+            if ($countryId > 0) {
+                $row = Db::name('country')->where('country_id', $countryId)->find();
+                if ($row) {
+                    $countryName = (string)($row['en_name'] ?? ($row['zh_name'] ?? ''));
+                }
+            }
         }
 
         return [
@@ -453,69 +468,27 @@ class ExpertFieldAiService
 
     private function summarizeWithLlm(array $context)
     {
-        $url = $this->resolveLlmChatUrl();
-        $model = $this->resolveLlmModel();
-        $apiKey = trim((string)Env::get(
-            'expert_field_ai.chat_api_key',
-            Env::get('user_field_ai.chat_api_key', Env::get('expert_country_chat_api_key', Env::get('citation_chat_api_key', '')))
-        ));
-
-        if ($url === '' || $model === '') {
-            throw new Exception('LLM not configured (set base.model_url / expert_field_ai.chat_model)');
-        }
-
         $payloadJson = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
-        $messages = [
-            [
-                'role'    => 'system',
-                'content' => '你是学术领域分类助手。根据专家的单位、论文标题与 PubMed 检索上下文,用简体中文总结该专家最主要的研究领域。'
-                    . '注意:search_keywords 只是检索词,不可直接当作领域结论,应结合 paper 标题与 affiliation 判断。'
-                    . '要求:精确、简洁,1~3 个中文领域词或短短语,用顿号分隔;不要解释、不要英文。'
-                    . '只输出 JSON:{"field_ai":"..."}。',
-            ],
-            [
-                'role'    => 'user',
-                'content' => "请根据以下 JSON 资料总结该专家的主要研究领域:\n" . $payloadJson,
-            ],
-        ];
+        $systemPrompt = '你是学术领域分类助手。根据专家的单位、论文标题与 PubMed 检索上下文,用简体中文总结该专家最主要的研究领域。'
+            . '注意:search_keywords 只是检索词,不可直接当作领域结论,应结合 paper 标题与 affiliation 判断。'
+            . '要求:精确、简洁,1~3 个中文领域词或短短语,用顿号分隔;不要解释、不要英文。'
+            . '只输出 JSON:{"field_ai":"..."}。';
+        $userPrompt = "请根据以下 JSON 资料总结该专家的主要研究领域:\n" . $payloadJson;
 
-        $body = [
-            'model'       => $model,
-            'temperature' => 0.2,
-            'messages'    => $messages,
-        ];
+        // 按上下文长度动态选模型(小: base.model_url1 / 大: base.model_url)
+        $svc = new LocalModelService();
+        $res = $svc->chat([
+            ['role' => 'system', 'content' => $systemPrompt],
+            ['role' => 'user',   'content' => $userPrompt],
+        ], ['temperature' => 0.2]);
 
-        $ch = curl_init();
-        curl_setopt_array($ch, [
-            CURLOPT_URL            => $url,
-            CURLOPT_POST           => true,
-            CURLOPT_POSTFIELDS     => json_encode($body, JSON_UNESCAPED_UNICODE),
-            CURLOPT_RETURNTRANSFER => true,
-            CURLOPT_CONNECTTIMEOUT => 15,
-            CURLOPT_TIMEOUT        => max(30, (int)Env::get('expert_field_ai.timeout', Env::get('user_field_ai.timeout', 90))),
-            CURLOPT_HTTPHEADER     => array_filter([
-                'Content-Type: application/json',
-                $apiKey !== '' ? 'Authorization: Bearer ' . $apiKey : null,
-            ]),
-        ]);
-        $raw = curl_exec($ch);
-        $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
-        $err = curl_error($ch);
-        curl_close($ch);
-
-        if ($raw === false) {
-            throw new Exception('LLM curl error: ' . $err);
-        }
-        if ($code < 200 || $code >= 300) {
-            throw new Exception('LLM HTTP ' . $code . ': ' . mb_substr((string)$raw, 0, 400));
+        if (empty($res['ok'])) {
+            throw new Exception('LLM error: ' . (string)($res['error'] ?? 'unknown'));
         }
 
-        $data = json_decode($raw, true);
-        $content = '';
-        if (is_array($data) && isset($data['choices'][0]['message']['content'])) {
-            $content = trim((string)$data['choices'][0]['message']['content']);
-        }
+        $this->log('[ExpertFieldAi] llm tier=' . ($res['tier'] ?? '') . ' ctx_len=' . ($res['context_len'] ?? 0) . ' url=' . ($res['url'] ?? ''));
 
+        $content = trim((string)($res['content'] ?? ''));
         $fieldAi = $this->parseFieldAiFromContent($content);
         if ($fieldAi === '' && $content !== '') {
             $fieldAi = $this->cleanFieldAiText($content);
@@ -523,44 +496,6 @@ class ExpertFieldAiService
         return $fieldAi;
     }
 
-    private function resolveLlmChatUrl()
-    {
-        $candidates = [
-//            Env::get('expert_field_ai.chat_url', ''),
-//            Env::get('user_field_ai.chat_url', ''),
-            Env::get('base.model_url1', ''),
-        ];
-        foreach ($candidates as $u) {
-            $u = trim((string)$u);
-            if ($u === '') {
-                continue;
-            }
-            if (stripos($u, 'chat/completions') !== false) {
-                return $u;
-            }
-            return rtrim($u, '/') . '/v1/chat/completions';
-        }
-        return '';
-    }
-
-    private function resolveLlmModel()
-    {
-        $candidates = [
-            Env::get('expert_field_ai.chat_model', ''),
-            Env::get('user_field_ai.chat_model', ''),
-            Env::get('base.model', ''),
-            Env::get('expert_country_chat_model', ''),
-            'gpt-4.1',
-        ];
-        foreach ($candidates as $m) {
-            $m = trim((string)$m);
-            if ($m !== '' && strtolower($m) !== 'your-model-name') {
-                return $m;
-            }
-        }
-        return '';
-    }
-
     private function parseFieldAiFromContent($content)
     {
         $content = trim((string)$content);
@@ -637,18 +572,73 @@ class ExpertFieldAiService
 
     private function updateFieldAi($expertId, $fieldAi, $status, $source, $note)
     {
+        $this->ensureSchema();
+
         $data = [
             'field_ai'        => mb_substr(trim((string)$fieldAi), 0, 512),
             'field_ai_status' => intval($status),
             'field_ai_utime'  => time(),
-            'field_ai_source' => mb_substr(trim((string)$source), 0, 32),
         ];
+        if ($this->hasColumn('field_ai_source')) {
+            $data['field_ai_source'] = mb_substr(trim((string)$source), 0, 32);
+        }
+
         Db::name('expert')->where('expert_id', intval($expertId))->update($data);
         if ($note !== '') {
             $this->log('[ExpertFieldAi] expert_id=' . $expertId . ' status=' . $status . ' note=' . $note);
         }
     }
 
+    /**
+     * 自动补全 t_expert 上缺失的 field_ai 字段(可重复执行)。
+     */
+    public function ensureSchema()
+    {
+        if (self::$schemaReady === true) {
+            return;
+        }
+
+        $table = config('database.prefix') . 'expert';
+        $columns = Db::query('SHOW COLUMNS FROM `' . $table . '`');
+        $existing = [];
+        foreach ($columns as $col) {
+            $existing[$col['Field']] = true;
+        }
+
+        $alters = [];
+        if (!isset($existing['field_ai'])) {
+            $alters[] = "ADD COLUMN `field_ai` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'AI总结的主要研究领域(中文)' AFTER `affiliation`";
+            $existing['field_ai'] = true;
+        }
+        if (!isset($existing['field_ai_status'])) {
+            $alters[] = "ADD COLUMN `field_ai_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待处理 1已生成 2资料不足 3失败 4无user待AI' AFTER `field_ai`";
+            $existing['field_ai_status'] = true;
+        }
+        if (!isset($existing['field_ai_utime'])) {
+            $alters[] = "ADD COLUMN `field_ai_utime` INT NOT NULL DEFAULT 0 COMMENT 'field_ai更新时间' AFTER `field_ai_status`";
+            $existing['field_ai_utime'] = true;
+        }
+        if (!isset($existing['field_ai_source'])) {
+            $alters[] = "ADD COLUMN `field_ai_source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源: user_link / ai' AFTER `field_ai_utime`";
+            $existing['field_ai_source'] = true;
+        }
+
+        if (!empty($alters)) {
+            Db::execute('ALTER TABLE `' . $table . '` ' . implode(', ', $alters));
+            $this->log('[ExpertFieldAi] schema patched: ' . implode('; ', $alters));
+        }
+
+        self::$schemaReady = true;
+    }
+
+    private function hasColumn($column)
+    {
+        $this->ensureSchema();
+        $table = config('database.prefix') . 'expert';
+        $columns = Db::query('SHOW COLUMNS FROM `' . $table . '` LIKE \'' . addslashes($column) . '\'');
+        return !empty($columns);
+    }
+
     public function statusLabel($status)
     {
         $map = [
diff --git a/application/common/ExpertFinderService.php b/application/common/ExpertFinderService.php
index bd5a67e7..e92cb5c5 100644
--- a/application/common/ExpertFinderService.php
+++ b/application/common/ExpertFinderService.php
@@ -13,6 +13,9 @@ class ExpertFinderService
     private $ncbiBaseUrl = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/';
     private $logFile;
 
+    /** @var bool|null */
+    private static $schemaReady = null;
+
     public function __construct()
     {
         $this->httpClient = new Client([
@@ -21,6 +24,54 @@ class ExpertFinderService
             'verify'          => false,
         ]);
         $this->logFile = ROOT_PATH . 'runtime' . DS . 'expert_finder.log';
+
+        try {
+            $this->ensureSchema();
+        } catch (\Throwable $e) {
+            $this->log('[ExpertFinder] ensureSchema fail: ' . $e->getMessage());
+        }
+    }
+
+    /**
+     * 历史遗留数据迁移用:旧版每天按页抓取时使用的 per_page。
+     * 用于把旧的 last_page 换算成新的 last_offset(last_offset = last_page × 此值)。
+     */
+    const MIGRATE_LEGACY_PER_PAGE = 10;
+
+    /**
+     * 自动补全 expert_fetch 上缺失的 last_offset 列,并一次性回填历史进度(可重复执行)。
+     * last_offset 为累计抓取偏移量(已扫到第几篇),与 per_page 解耦,
+     * 改 per_page 不会再导致翻页错位。
+     */
+    public function ensureSchema()
+    {
+        if (self::$schemaReady === true) {
+            return;
+        }
+
+        $table = config('database.prefix') . 'expert_fetch';
+        $columns = Db::query('SHOW COLUMNS FROM `' . $table . '`');
+        $existing = [];
+        foreach ($columns as $col) {
+            $existing[$col['Field']] = true;
+        }
+
+        if (!isset($existing['last_offset'])) {
+            Db::execute('ALTER TABLE `' . $table . '` ADD COLUMN `last_offset` INT NOT NULL DEFAULT 0 COMMENT \'累计抓取偏移量(与per_page解耦)\' AFTER `last_page`');
+            $this->log('[ExpertFinder] schema patched: add last_offset');
+        }
+
+        // 一次性迁移:把旧 last_page 按历史 per_page 换算成 last_offset。
+        // 只命中"未迁移"的遗留行(last_offset=0 且 last_page>0),幂等,不会重复执行。
+        $affected = Db::execute(
+            'UPDATE `' . $table . '` SET `last_offset` = `last_page` * ' . intval(self::MIGRATE_LEGACY_PER_PAGE)
+            . ' WHERE `last_offset` = 0 AND `last_page` > 0'
+        );
+        if ($affected > 0) {
+            $this->log('[ExpertFinder] migrated last_offset from last_page for ' . $affected . ' rows (×' . self::MIGRATE_LEGACY_PER_PAGE . ')');
+        }
+
+        self::$schemaReady = true;
     }
 
     public function doFetchForField($field, $source = 'pubmed', $perPage = 100, $minYear = null)
@@ -30,12 +81,13 @@ class ExpertFinderService
         }
 
         $fetchLog = $this->getFetchLog($field, $source);
-        $page     = $fetchLog['last_page'] + 1;
+        // 基于累计偏移量(offset)的游标:改 per_page 也不会错位
+        $offset   = intval($fetchLog['last_offset'] ?? 0);
 
         if ($source === 'pmc') {
-            $result = $this->searchViaPMC($field, $perPage, $minYear, $page);
+            $result = $this->searchViaPMC($field, $perPage, $minYear, $offset);
         } else {
-            $result = $this->searchViaPubMed($field, $perPage, $minYear, $page);
+            $result = $this->searchViaPubMed($field, $perPage, $minYear, $offset);
         }
 
         if(!isset($result['total'])){
@@ -45,13 +97,15 @@ class ExpertFinderService
         }
         $saveResult = $this->saveExperts($result['experts'], $field, $source);
 
-        $nextPage   = $result['has_more'] ? $page : $fetchLog['last_page'];
-        $totalPages = $result['total_pages'] ?? $fetchLog['total_pages'];
-        $this->updateFetchLog($field, $source, $nextPage, $totalPages);
+        // 抓到下一篇则前移一个窗口;抓完则保持当前 offset
+        $nextOffset = $result['has_more'] ? ($offset + $perPage) : $offset;
+        $totalPages = $result['total_pages'] ?? ($fetchLog['total_pages'] ?? 0);
+        $this->updateFetchLog($field, $source, $nextOffset, $totalPages, $perPage);
 
         return [
             'keyword'        => $field,
-            'page'           => $page,
+            'page'           => $result['page'] ?? 1,
+            'offset'         => $offset,
             'experts_found'  => $result['total'],
             'saved_new'      => $saveResult['inserted'],
             'saved_exist'    => $saveResult['existing'],
@@ -63,10 +117,12 @@ class ExpertFinderService
 
     public function searchExperts($keyword, $perPage, $minYear, $page, $source)
     {
+        // 交互式按页搜索:把页码换算成偏移量后走统一的 offset 逻辑
+        $retstart = max(0, (intval($page) - 1) * intval($perPage));
         if ($source === 'pmc') {
-            return $this->searchViaPMC($keyword, $perPage, $minYear, $page);
+            return $this->searchViaPMC($keyword, $perPage, $minYear, $retstart);
         }
-        return $this->searchViaPubMed($keyword, $perPage, $minYear, $page);
+        return $this->searchViaPubMed($keyword, $perPage, $minYear, $retstart);
     }
 
     public function saveExperts($experts, $field, $source)
@@ -184,14 +240,25 @@ class ExpertFinderService
             ->find();
 
         if (!$log) {
-            return ['last_page' => 0, 'total_pages' => 0, 'last_time' => 0];
+            return ['last_page' => 0, 'last_offset' => 0, 'total_pages' => 0, 'last_time' => 0];
         }
 
         return $log;
     }
 
-    public function updateFetchLog($field, $source, $lastPage, $totalPages)
+    /**
+     * 回写抓取进度。
+     * @param int $lastOffset 累计偏移量(权威游标)
+     * @param int $totalPages 总页数(仅展示)
+     * @param int $perPage    本次窗口大小,用于换算展示用 last_page
+     */
+    public function updateFetchLog($field, $source, $lastOffset, $totalPages, $perPage = 0)
     {
+        $lastOffset = max(0, intval($lastOffset));
+        $perPage    = intval($perPage);
+        // last_page 仅作展示:由偏移量换算(per_page 未知时退化为偏移量本身)
+        $lastPage   = $perPage > 0 ? intval(floor($lastOffset / $perPage)) : $lastOffset;
+
         $exists = Db::name('expert_fetch')
             ->where('field', $field)
             ->where('source', $source)
@@ -201,6 +268,7 @@ class ExpertFinderService
             Db::name('expert_fetch')
                 ->where('expert_fetch_id', $exists['expert_fetch_id'])
                 ->update([
+                    'last_offset' => $lastOffset,
                     'last_page'   => $lastPage,
                     'total_pages' => $totalPages,
                     'last_time'   => time(),
@@ -209,6 +277,7 @@ class ExpertFinderService
             Db::name('expert_fetch')->insert([
                 'field'       => mb_substr($field, 0, 128),
                 'source'      => mb_substr($source, 0, 128),
+                'last_offset' => $lastOffset,
                 'last_page'   => $lastPage,
                 'total_pages' => $totalPages,
                 'last_time'   => time(),
@@ -218,16 +287,16 @@ class ExpertFinderService
 
     // ==================== PubMed Search ====================
 
-    private function searchViaPubMed($keyword, $perPage, $minYear, $page = 1)
+    private function searchViaPubMed($keyword, $perPage, $minYear, $retstart = 0)
     {
         set_time_limit(600);
 
-        $searchResult  = $this->esearch('pubmed', $keyword, $perPage, $minYear, $page);
+        $searchResult  = $this->esearch('pubmed', $keyword, $perPage, $minYear, $retstart);
         $ids           = $searchResult['ids'];
         $totalArticles = $searchResult['total'];
 
         if (empty($ids)) {
-            return $this->buildPagedResult([], 0, 0, $totalArticles, $page, $perPage, 'pubmed');
+            return $this->buildPagedResult([], 0, 0, $totalArticles, $retstart, $perPage, 'pubmed');
         }
 
         $allAuthors = [];
@@ -243,21 +312,21 @@ class ExpertFinderService
 
         $experts = $this->aggregateExperts($allAuthors);
 
-        return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $page, $perPage, 'pubmed');
+        return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $retstart, $perPage, 'pubmed');
     }
 
     // ==================== PMC Search ====================
 
-    private function searchViaPMC($keyword, $perPage, $minYear, $page = 1)
+    private function searchViaPMC($keyword, $perPage, $minYear, $retstart = 0)
     {
         set_time_limit(600);
 
-        $searchResult  = $this->esearch('pmc', $keyword, $perPage, $minYear, $page);
+        $searchResult  = $this->esearch('pmc', $keyword, $perPage, $minYear, $retstart);
         $ids           = $searchResult['ids'];
         $totalArticles = $searchResult['total'];
 
         if (empty($ids)) {
-            return $this->buildPagedResult([], 0, 0, $totalArticles, $page, $perPage, 'pmc');
+            return $this->buildPagedResult([], 0, 0, $totalArticles, $retstart, $perPage, 'pmc');
         }
 
         $allAuthors = [];
@@ -273,15 +342,15 @@ class ExpertFinderService
 
         $experts = $this->aggregateExperts($allAuthors);
 
-        return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $page, $perPage, 'pmc');
+        return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $retstart, $perPage, 'pmc');
     }
 
     // ==================== NCBI API ====================
 
-    private function esearch($db, $keyword, $perPage, $minYear, $page = 1)
+    private function esearch($db, $keyword, $perPage, $minYear, $retstart = 0)
     {
         $term     = $keyword . ' AND ' . $minYear . ':' . date('Y') . '[pdat]';
-        $retstart = ($page - 1) * $perPage;
+        $retstart = max(0, intval($retstart));
 
         $response = $this->httpClient->get($this->ncbiBaseUrl . 'esearch.fcgi', [
             'query' => [
@@ -563,18 +632,23 @@ class ExpertFinderService
         return $experts;
     }
 
-    private function buildPagedResult($experts, $expertCount, $articlesScanned, $totalArticles, $page, $perPage, $source)
+    private function buildPagedResult($experts, $expertCount, $articlesScanned, $totalArticles, $retstart, $perPage, $source)
     {
+        $perPage    = max(1, intval($perPage));
+        $retstart   = max(0, intval($retstart));
         $totalPages = $totalArticles > 0 ? ceil($totalArticles / $perPage) : 0;
+        $page       = intval(floor($retstart / $perPage)) + 1;
         return [
             'experts'          => $experts,
             'total'            => $expertCount,
             'articles_scanned' => $articlesScanned,
             'total_articles'   => $totalArticles,
             'page'             => $page,
+            'offset'           => $retstart,
             'per_page'         => $perPage,
             'total_pages'      => $totalPages,
-            'has_more'         => $page < $totalPages,
+            // 偏移量驱动:下一个窗口还在范围内才有更多
+            'has_more'         => ($retstart + $perPage) < $totalArticles,
             'source'           => $source,
         ];
     }
diff --git a/application/common/ProductionArticleRefer.php b/application/common/ProductionArticleRefer.php
index 706ec8ee..b61b42d1 100644
--- a/application/common/ProductionArticleRefer.php
+++ b/application/common/ProductionArticleRefer.php
@@ -2,6 +2,7 @@
 namespace app\common;
 use think\Db;
 use think\Env;
+use app\common\CrossrefService;
 class ProductionArticleRefer
 {
 
@@ -78,6 +79,41 @@ class ProductionArticleRefer
             return json_encode(['status' => 4,'msg' => 'Reference DOI is empty'.json_encode($aParam)]);
         }
 
+
+        //开始用crossref接口的方式处理数据
+        $doiNorm = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', $aRefer['refer_doi']);
+        $doiNorm = trim($doiNorm, " \t\n\r\0\x0B/");
+
+        $svc = new CrossrefService([
+            'mailto' => trim((string)Env::get('crossref_mailto', '')),
+        ]);
+        $summary = $svc->fetchWorkSummary($doiNorm);
+        if ($summary !== null && !empty($summary['doi'])) {
+            $update_a = [];
+            $title = trim((string)($summary['title'] ?? ''));
+            $jouraRaw = trim((string)($summary['joura'] ?? ''));
+            // 姓全写 + 名首字母,超过 3 个作者取前 3 个 + et al
+            $authorCitation = $svc->getAuthorsCitation($summary['raw'] ?? [], 3);
+            $dateno = trim((string)($summary['dateno'] ?? ''));
+            $doilink = trim((string)($summary['doilink'] ?? ''));
+            $update_a['title'] = $title;
+            $update_a['author'] = $authorCitation !== '' ? $authorCitation . '.' : '';
+            $update_a['joura'] = $jouraRaw;
+            $update_a['dateno'] = $dateno;
+            $update_a['refer_type'] = "journal";
+            $update_a['is_ja'] = 1;
+            $update_a['doilink'] = $doilink;
+            $update_a['cs'] = 1;
+            $update_a['update_time'] = time();
+            $update_a['is_deal'] = 1;
+            Db::name('production_article_refer')->where(['p_refer_id' => $iPReferId])->limit(1)->update($update_a);
+            return json_encode(['status' => 1,'msg' => 'Update successful']);
+        }
+
+        //结束---用crossref接口的方式处理数据
+
+
+
         //数据处理
         $doi = str_replace('/', '%2F', $aRefer['refer_doi']);
         $url = "https://citation.doi.org/format?doi=$doi&style=cancer-translational-medicine&lang=en-US";
diff --git a/application/common/PromotionService.php b/application/common/PromotionService.php
index c5b1ad2e..9e0b3bb0 100644
--- a/application/common/PromotionService.php
+++ b/application/common/PromotionService.php
@@ -253,9 +253,13 @@ class PromotionService
                 'send_time'  => $now,
             ]);
             Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->setInc('today_sent');
-            // 仅外部 expert 库回写最近一次推广时间;内部 user 用 promotion_email_log.send_time 计频次
+            // 仅外部 expert 库回写最近一次推广时间与累计推广次数;内部 user 用 promotion_email_log.send_time 计频次
             if ($audienceKind === 'expert' && intval($expert['expert_id']) > 0) {
-                Db::name('expert')->where('expert_id', $expert['expert_id'])->update(['state' => 1, 'ltime' => $now]);
+                Db::name('expert')->where('expert_id', $expert['expert_id'])->update([
+                    'state' => 1,
+                    'ltime' => $now,
+                    'times' => Db::raw('times+1'),
+                ]);
             }
             Db::name('promotion_task')->where('task_id', $taskId)->setInc('sent_count');
         } else {
diff --git a/application/common/PubmedService.php b/application/common/PubmedService.php
index 50f565ec..49aec572 100644
--- a/application/common/PubmedService.php
+++ b/application/common/PubmedService.php
@@ -60,7 +60,8 @@ class PubmedService
         $pmid = trim($pmid);
         if ($pmid === '') return null;
 
-        $cacheKey = 'pmid_' . $pmid;
+        // v2:解析结果新增 journal_iso_abbr / journal_medline_ta,换 key 避免命中旧缓存
+        $cacheKey = 'pmid_v2_' . $pmid;
         $cached = $this->cacheGet($cacheKey, 30 * 86400);
         if (is_array($cached)) return $cached;
 
@@ -96,6 +97,22 @@ class PubmedService
         return $info;
     }
 
+    /**
+     * DOI -> 期刊规范缩写(NLM/ISO 形式,如 "J Clin Oncol")
+     * 优先 ISOAbbreviation,回退 MedlineTA;查不到返回 null。
+     */
+    public function journalAbbrByDoi(string $doi): ?string
+    {
+        $info = $this->fetchByDoi($doi);
+        if (!is_array($info)) return null;
+
+        $abbr = trim((string)($info['journal_iso_abbr'] ?? ''));
+        if ($abbr === '') {
+            $abbr = trim((string)($info['journal_medline_ta'] ?? ''));
+        }
+        return $abbr !== '' ? $abbr : null;
+    }
+
     /**
      * 按书目信息检索 PubMed(标题 + 第一作者 + 年份)
      */
@@ -224,6 +241,9 @@ class PubmedService
         $pubTypes = array_values(array_unique($pubTypes));
 
         $journal = $this->xpText($xp, '//PubmedArticle//Journal//Title');
+        // 期刊规范缩写:ISOAbbreviation(Journal 下)与 MedlineTA(MedlineJournalInfo 下)
+        $journalIsoAbbr = $this->xpText($xp, '//PubmedArticle//Journal//ISOAbbreviation');
+        $journalMedlineTa = $this->xpText($xp, '//PubmedArticle//MedlineJournalInfo//MedlineTA');
 
         $year = '';
         $year = $this->xpText($xp, '//PubmedArticle//JournalIssue//PubDate//Year');
@@ -244,6 +264,8 @@ class PubmedService
             'mesh_terms' => $mesh,
             'publication_types' => $pubTypes,
             'journal' => $journal,
+            'journal_iso_abbr' => $journalIsoAbbr,
+            'journal_medline_ta' => $journalMedlineTa,
             'year' => $year,
         ];
     }
diff --git a/application/common/service/LocalModelService.php b/application/common/service/LocalModelService.php
new file mode 100644
index 00000000..0f24cabe
--- /dev/null
+++ b/application/common/service/LocalModelService.php
@@ -0,0 +1,219 @@
+ 小模型(显存为大模型一半),对应 base.model_url1
+ * - 长上下文  -> 大模型,对应 base.model_url
+ *
+ * 选择规则:上下文字符数 <= 阈值 用小模型;超过阈值 用大模型。
+ * 两个端点模型名相同(base.model)。
+ *
+ * 用法:
+ *   $svc = new LocalModelService();
+ *   $res = $svc->chat([
+ *       ['role' => 'system', 'content' => '...'],
+ *       ['role' => 'user',   'content' => '...'],
+ *   ]);
+ *   // $res['ok'], $res['content'], $res['tier'](small|large), $res['context_len']
+ *
+ *   // 只要文本结果:
+ *   $text = $svc->complete($systemPrompt, $userPrompt);
+ */
+class LocalModelService
+{
+    /** 上下文长度阈值(字符数):<= 用小模型,> 用大模型 */
+    const CONTEXT_THRESHOLD = 1000;
+
+    /** 请求超时(秒) */
+    const TIMEOUT = 120;
+
+    /** 小模型端点(短上下文,显存一半) */
+    private $smallUrl;
+
+    /** 大模型端点(长上下文) */
+    private $largeUrl;
+
+    /** 模型名(两端点相同) */
+    private $model;
+
+    /** 上下文长度阈值(字符数) */
+    private $threshold;
+
+    public function __construct()
+    {
+        // 小模型 -> base.model_url1,大模型 -> base.model_url,模型名同为 base.model
+        $this->smallUrl = $this->normalizeChatUrl((string)Env::get('base.model_url1', ''));
+        $this->largeUrl = $this->normalizeChatUrl((string)Env::get('base.model_url', ''));
+        $this->model    = trim((string)Env::get('base.model', ''));
+        $this->threshold = self::CONTEXT_THRESHOLD;
+    }
+
+    /**
+     * 发起一次对话,按上下文长度自动选模型。
+     *
+     * @param array $messages OpenAI 格式 messages
+     * @param array $options  可选:
+     *                        - temperature (float, 默认 0.2)
+     *                        - max_tokens  (int, 可选)
+     *                        - force_tier  ('small'|'large') 强制指定模型,跳过长度判断
+     *                        - extra       (array) 透传到请求体的额外字段
+     * @return array{ok:bool, content:string, tier:string, model:string, url:string, context_len:int, error:string}
+     */
+    public function chat(array $messages, array $options = [])
+    {
+        $contextLen = $this->measureMessages($messages);
+
+        $tier = isset($options['force_tier']) && in_array($options['force_tier'], ['small', 'large'], true)
+            ? $options['force_tier']
+            : $this->pickTier($contextLen);
+
+        $endpoint = $this->resolveEndpoint($tier);
+
+        $result = [
+            'ok'          => false,
+            'content'     => '',
+            'tier'        => $tier,
+            'model'       => $endpoint['model'],
+            'url'         => $endpoint['url'],
+            'context_len' => $contextLen,
+            'error'       => '',
+        ];
+
+        if ($endpoint['url'] === '' || $endpoint['model'] === '') {
+            $result['error'] = $tier . ' 模型未配置(检查 .env [base] model_url / model_url1 / model)';
+            return $result;
+        }
+
+        $payload = [
+            'model'       => $endpoint['model'],
+            'temperature' => isset($options['temperature']) ? (float)$options['temperature'] : 0.2,
+            'messages'    => $messages,
+        ];
+        if (isset($options['max_tokens']) && intval($options['max_tokens']) > 0) {
+            $payload['max_tokens'] = intval($options['max_tokens']);
+        }
+        if (isset($options['extra']) && is_array($options['extra'])) {
+            $payload = array_merge($payload, $options['extra']);
+        }
+
+        $content = $this->postChat($endpoint['url'], $payload, $err);
+        if ($content === null) {
+            $result['error'] = $err !== '' ? $err : 'LLM 请求失败';
+            return $result;
+        }
+
+        $result['ok'] = true;
+        $result['content'] = $content;
+        return $result;
+    }
+
+    /**
+     * 便捷方法:传 system + user,返回纯文本内容(失败返回空字符串)。
+     */
+    public function complete($systemPrompt, $userPrompt, array $options = [])
+    {
+        $messages = [];
+        if (trim((string)$systemPrompt) !== '') {
+            $messages[] = ['role' => 'system', 'content' => (string)$systemPrompt];
+        }
+        $messages[] = ['role' => 'user', 'content' => (string)$userPrompt];
+
+        $res = $this->chat($messages, $options);
+        return $res['ok'] ? $res['content'] : '';
+    }
+
+    /**
+     * 根据上下文长度选择 tier。
+     */
+    public function pickTier($contextLen)
+    {
+        return $contextLen > $this->threshold ? 'large' : 'small';
+    }
+
+    /**
+     * 统计 messages 的上下文长度(所有 content 字符数之和)。
+     */
+    public function measureMessages(array $messages)
+    {
+        $len = 0;
+        foreach ($messages as $m) {
+            if (isset($m['content']) && is_string($m['content'])) {
+                $len += mb_strlen($m['content']);
+            }
+        }
+        return $len;
+    }
+
+    /**
+     * 返回某 tier 的端点配置(模型名两端点相同)。
+     */
+    private function resolveEndpoint($tier)
+    {
+        $url = $tier === 'large' ? $this->largeUrl : $this->smallUrl;
+        return ['url' => $url, 'model' => $this->model];
+    }
+
+    private function postChat($url, array $payload, &$err = '')
+    {
+        $err = '';
+        $ch = curl_init();
+        curl_setopt($ch, CURLOPT_URL, $url);
+        curl_setopt($ch, CURLOPT_POST, true);
+        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
+        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
+        curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT);
+
+        $headers = ['Content-Type: application/json'];
+        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+
+        $raw = curl_exec($ch);
+        if ($raw === false) {
+            $err = 'curl error: ' . curl_error($ch);
+            curl_close($ch);
+            return null;
+        }
+        $httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE));
+        curl_close($ch);
+
+        if ($httpCode < 200 || $httpCode >= 300) {
+            $err = 'http ' . $httpCode . ': ' . mb_substr((string)$raw, 0, 300);
+            return null;
+        }
+
+        $data = json_decode($raw, true);
+        if (!is_array($data)) {
+            $err = 'invalid json response';
+            return null;
+        }
+        if (isset($data['choices'][0]['message']['content'])) {
+            return (string)$data['choices'][0]['message']['content'];
+        }
+        if (isset($data['content'])) {
+            return (string)$data['content'];
+        }
+        $err = 'no content in response: ' . mb_substr((string)$raw, 0, 300);
+        return null;
+    }
+
+    /**
+     * 根地址自动补 /v1/chat/completions。
+     */
+    private function normalizeChatUrl($url)
+    {
+        $url = trim((string)$url);
+        if ($url === '') {
+            return '';
+        }
+        if (stripos($url, 'chat/completions') !== false) {
+            return $url;
+        }
+        return rtrim($url, '/') . '/v1/chat/completions';
+    }
+}
diff --git a/sql/add_field_ai_source_to_expert.sql b/sql/add_field_ai_source_to_expert.sql
new file mode 100644
index 00000000..511beb5c
--- /dev/null
+++ b/sql/add_field_ai_source_to_expert.sql
@@ -0,0 +1,3 @@
+-- 若已执行过 add_field_ai_to_expert.sql 但缺少 field_ai_source,单独补这一列
+ALTER TABLE `t_expert`
+  ADD COLUMN `field_ai_source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源: user_link / ai' AFTER `field_ai_utime`;
diff --git a/sql/patch_expert_field_ai_columns.php b/sql/patch_expert_field_ai_columns.php
new file mode 100644
index 00000000..d13d596a
--- /dev/null
+++ b/sql/patch_expert_field_ai_columns.php
@@ -0,0 +1,46 @@
+ PDO::ERRMODE_EXCEPTION,
+]);
+
+$table = $config['prefix'] . 'expert';
+$cols = $pdo->query("SHOW COLUMNS FROM `{$table}`")->fetchAll(PDO::FETCH_COLUMN, 0);
+$colSet = array_flip($cols);
+
+$alters = [];
+if (!isset($colSet['field_ai'])) {
+    $alters[] = "ADD COLUMN `field_ai` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'AI总结的主要研究领域(中文)' AFTER `affiliation`";
+}
+if (!isset($colSet['field_ai_status'])) {
+    $after = isset($colSet['field_ai']) || !empty($alters) ? 'field_ai' : 'affiliation';
+    $alters[] = "ADD COLUMN `field_ai_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待处理 1已生成 2资料不足 3失败 4无user待AI' AFTER `{$after}`";
+}
+if (!isset($colSet['field_ai_utime'])) {
+    $alters[] = "ADD COLUMN `field_ai_utime` INT NOT NULL DEFAULT 0 COMMENT 'field_ai更新时间' AFTER `field_ai_status`";
+}
+if (!isset($colSet['field_ai_source'])) {
+    $alters[] = "ADD COLUMN `field_ai_source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源: user_link / ai' AFTER `field_ai_utime`";
+}
+
+if (empty($alters)) {
+    echo "OK: all field_ai columns exist on {$table}\n";
+    exit(0);
+}
+
+$sql = "ALTER TABLE `{$table}` " . implode(', ', $alters);
+echo "Running: {$sql}\n";
+$pdo->exec($sql);
+echo "Done.\n";
diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php
index 0fb0a2c1..730bac18 100644
--- a/vendor/composer/autoload_classmap.php
+++ b/vendor/composer/autoload_classmap.php
@@ -6,5 +6,10 @@ $vendorDir = dirname(__DIR__);
 $baseDir = dirname($vendorDir);
 
 return array(
+    'Attribute' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/Attribute.php',
     'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
+    'PhpToken' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/PhpToken.php',
+    'Stringable' => $vendorDir . '/myclabs/php-enum/stubs/Stringable.php',
+    'UnhandledMatchError' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/UnhandledMatchError.php',
+    'ValueError' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/ValueError.php',
 );
diff --git a/vendor/composer/autoload_real.php b/vendor/composer/autoload_real.php
index e9626643..b341152f 100644
--- a/vendor/composer/autoload_real.php
+++ b/vendor/composer/autoload_real.php
@@ -22,6 +22,8 @@ class ComposerAutoloaderInit2bc4f313dba415539e266f7ac2c87dcd
             return self::$loader;
         }
 
+        require __DIR__ . '/platform_check.php';
+
         spl_autoload_register(array('ComposerAutoloaderInit2bc4f313dba415539e266f7ac2c87dcd', 'loadClassLoader'), true, true);
         self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
         spl_autoload_unregister(array('ComposerAutoloaderInit2bc4f313dba415539e266f7ac2c87dcd', 'loadClassLoader'));
diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php
index 4cdddf9d..014b19ea 100644
--- a/vendor/composer/installed.php
+++ b/vendor/composer/installed.php
@@ -3,7 +3,7 @@
         'name' => 'topthink/think',
         'pretty_version' => 'dev-master',
         'version' => 'dev-master',
-        'reference' => 'bbd690ca0f68c671ece05e82edc88ee7a68b82ed',
+        'reference' => '1d54946fef97376f7c2789af83a1616dd6f7a380',
         'type' => 'project',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -11,9 +11,9 @@
     ),
     'versions' => array(
         'apimatic/core' => array(
-            'pretty_version' => '0.3.16',
-            'version' => '0.3.16.0',
-            'reference' => 'ae4ab4ca26a41be41718f33c703d67b7a767c07b',
+            'pretty_version' => '0.3.17',
+            'version' => '0.3.17.0',
+            'reference' => 'a48a583f686ee3786432b976c795a2817ec095b3',
             'type' => 'library',
             'install_path' => __DIR__ . '/../apimatic/core',
             'aliases' => array(),
@@ -55,15 +55,6 @@
             'aliases' => array(),
             'dev_requirement' => false,
         ),
-        'composer/pcre' => array(
-            'pretty_version' => '3.3.2',
-            'version' => '3.3.2.0',
-            'reference' => 'b2bed4734f0cc156ee1fe9c0da2550420d99a21e',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/./pcre',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
         'ezyang/htmlpurifier' => array(
             'pretty_version' => 'v4.19.0',
             'version' => '4.19.0.0',
@@ -92,18 +83,18 @@
             'dev_requirement' => false,
         ),
         'guzzlehttp/psr7' => array(
-            'pretty_version' => '2.8.0',
-            'version' => '2.8.0.0',
-            'reference' => '21dc724a0583619cd1652f673303492272778051',
+            'pretty_version' => '2.9.0',
+            'version' => '2.9.0.0',
+            'reference' => '7d0ed42f28e42d61352a7a79de682e5e67fec884',
             'type' => 'library',
             'install_path' => __DIR__ . '/../guzzlehttp/psr7',
             'aliases' => array(),
             'dev_requirement' => false,
         ),
         'maennchen/zipstream-php' => array(
-            'pretty_version' => '3.1.2',
-            'version' => '3.1.2.0',
-            'reference' => 'aeadcf5c412332eb426c0f9b4485f6accba2a99f',
+            'pretty_version' => '2.1.0',
+            'version' => '2.1.0.0',
+            'reference' => 'c4c5803cc1f93df3d2448478ef79394a5981cc58',
             'type' => 'library',
             'install_path' => __DIR__ . '/../maennchen/zipstream-php',
             'aliases' => array(),
@@ -127,6 +118,15 @@
             'aliases' => array(),
             'dev_requirement' => false,
         ),
+        'myclabs/php-enum' => array(
+            'pretty_version' => '1.8.5',
+            'version' => '1.8.5.0',
+            'reference' => 'e7be26966b7398204a234f8673fdad5ac6277802',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../myclabs/php-enum',
+            'aliases' => array(),
+            'dev_requirement' => false,
+        ),
         'nyholm/psr7' => array(
             'pretty_version' => '1.8.2',
             'version' => '1.8.2.0',
@@ -137,9 +137,9 @@
             'dev_requirement' => false,
         ),
         'paragonie/constant_time_encoding' => array(
-            'pretty_version' => 'v3.1.3',
-            'version' => '3.1.3.0',
-            'reference' => 'd5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77',
+            'pretty_version' => 'v2.8.2',
+            'version' => '2.8.2.0',
+            'reference' => 'e30811f7bc69e4b5b6d5783e712c06c8eabf0226',
             'type' => 'library',
             'install_path' => __DIR__ . '/../paragonie/constant_time_encoding',
             'aliases' => array(),
@@ -194,9 +194,9 @@
             'dev_requirement' => false,
         ),
         'php-http/message' => array(
-            'pretty_version' => '1.16.1',
-            'version' => '1.16.1.0',
-            'reference' => '5997f3289332c699fa2545c427826272498a2088',
+            'pretty_version' => '1.16.2',
+            'version' => '1.16.2.0',
+            'reference' => '06dd5e8562f84e641bf929bfe699ee0f5ce8080a',
             'type' => 'library',
             'install_path' => __DIR__ . '/../php-http/message',
             'aliases' => array(),
@@ -209,9 +209,9 @@
             ),
         ),
         'php-http/multipart-stream-builder' => array(
-            'pretty_version' => '1.3.1',
-            'version' => '1.3.1.0',
-            'reference' => 'ed56da23b95949ae4747378bed8a5b61a2fdae24',
+            'pretty_version' => '1.4.2',
+            'version' => '1.4.2.0',
+            'reference' => '10086e6de6f53489cca5ecc45b6f468604d3460e',
             'type' => 'library',
             'install_path' => __DIR__ . '/../php-http/multipart-stream-builder',
             'aliases' => array(),
@@ -227,18 +227,18 @@
             'dev_requirement' => false,
         ),
         'phpmailer/phpmailer' => array(
-            'pretty_version' => 'v6.11.1',
-            'version' => '6.11.1.0',
-            'reference' => 'd9e3b36b47f04b497a0164c5a20f92acb4593284',
+            'pretty_version' => 'v6.12.0',
+            'version' => '6.12.0.0',
+            'reference' => 'd1ac35d784bf9f5e61b424901d5a014967f15b12',
             'type' => 'library',
             'install_path' => __DIR__ . '/../phpmailer/phpmailer',
             'aliases' => array(),
             'dev_requirement' => false,
         ),
         'phpoffice/math' => array(
-            'pretty_version' => '0.2.0',
-            'version' => '0.2.0.0',
-            'reference' => 'fc2eb6d1a61b058d5dac77197059db30ee3c8329',
+            'pretty_version' => '0.3.0',
+            'version' => '0.3.0.0',
+            'reference' => 'fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a',
             'type' => 'library',
             'install_path' => __DIR__ . '/../phpoffice/math',
             'aliases' => array(),
@@ -254,18 +254,18 @@
             'dev_requirement' => false,
         ),
         'phpoffice/phpspreadsheet' => array(
-            'pretty_version' => '1.30.1',
-            'version' => '1.30.1.0',
-            'reference' => 'fa8257a579ec623473eabfe49731de5967306c4c',
+            'pretty_version' => '1.25.2',
+            'version' => '1.25.2.0',
+            'reference' => 'a317a09e7def49852400a4b3eca4a4b0790ceeb5',
             'type' => 'library',
             'install_path' => __DIR__ . '/../phpoffice/phpspreadsheet',
             'aliases' => array(),
             'dev_requirement' => false,
         ),
         'phpoffice/phpword' => array(
-            'pretty_version' => '1.3.0',
-            'version' => '1.3.0.0',
-            'reference' => '8392134ce4b5dba65130ba956231a1602b848b7f',
+            'pretty_version' => '1.4.0',
+            'version' => '1.4.0.0',
+            'reference' => '6d75328229bc93790b37e93741adf70646cea958',
             'type' => 'library',
             'install_path' => __DIR__ . '/../phpoffice/phpword',
             'aliases' => array(),
@@ -297,9 +297,9 @@
             ),
         ),
         'psr/http-factory' => array(
-            'pretty_version' => '1.0.2',
-            'version' => '1.0.2.0',
-            'reference' => 'e616d01114759c4c489f93b099585439f795fe35',
+            'pretty_version' => '1.1.0',
+            'version' => '1.1.0.0',
+            'reference' => '2b4765fddfe3b508ac62f829e852b1501d3f6e8a',
             'type' => 'library',
             'install_path' => __DIR__ . '/../psr/http-factory',
             'aliases' => array(),
@@ -313,9 +313,9 @@
             ),
         ),
         'psr/http-message' => array(
-            'pretty_version' => '2.0',
-            'version' => '2.0.0.0',
-            'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71',
+            'pretty_version' => '1.1',
+            'version' => '1.1.0.0',
+            'reference' => 'cb6ce4845ce34a8ad9e68117c10ee90a29919eba',
             'type' => 'library',
             'install_path' => __DIR__ . '/../psr/http-message',
             'aliases' => array(),
@@ -329,18 +329,18 @@
             ),
         ),
         'psr/log' => array(
-            'pretty_version' => '3.0.1',
-            'version' => '3.0.1.0',
-            'reference' => '79dff0b268932c640297f5208d6298f71855c03e',
+            'pretty_version' => '1.1.4',
+            'version' => '1.1.4.0',
+            'reference' => 'd49695b909c3b7628b6289db5479a1c204601f11',
             'type' => 'library',
             'install_path' => __DIR__ . '/../psr/log',
             'aliases' => array(),
             'dev_requirement' => false,
         ),
         'psr/simple-cache' => array(
-            'pretty_version' => '3.0.0',
-            'version' => '3.0.0.0',
-            'reference' => '764e0b3939f5ca87cb904f570ef9be2d78a07865',
+            'pretty_version' => '1.0.1',
+            'version' => '1.0.1.0',
+            'reference' => '408d5eafb83c57f6365a3ca330ff23aa4a5fa39b',
             'type' => 'library',
             'install_path' => __DIR__ . '/../psr/simple-cache',
             'aliases' => array(),
@@ -355,6 +355,15 @@
             'aliases' => array(),
             'dev_requirement' => false,
         ),
+        'smalot/pdfparser' => array(
+            'pretty_version' => 'v2.9.0',
+            'version' => '2.9.0.0',
+            'reference' => '6b53144fcb24af77093d4150dd7d0dd571f25761',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../smalot/pdfparser',
+            'aliases' => array(),
+            'dev_requirement' => false,
+        ),
         'spatie/data-transfer-object' => array(
             'pretty_version' => '1.14.1',
             'version' => '1.14.1.0',
@@ -365,32 +374,41 @@
             'dev_requirement' => false,
         ),
         'symfony/deprecation-contracts' => array(
-            'pretty_version' => 'v3.6.0',
-            'version' => '3.6.0.0',
-            'reference' => '63afe740e99a13ba87ec199bb07bbdee937a5b62',
+            'pretty_version' => 'v2.5.4',
+            'version' => '2.5.4.0',
+            'reference' => '605389f2a7e5625f273b53960dc46aeaf9c62918',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/deprecation-contracts',
             'aliases' => array(),
             'dev_requirement' => false,
         ),
         'symfony/http-foundation' => array(
-            'pretty_version' => 'v8.0.1',
-            'version' => '8.0.1.0',
-            'reference' => '3690740e2e8b19d877f20d4f10b7a489cddf0fe2',
+            'pretty_version' => 'v5.4.50',
+            'version' => '5.4.50.0',
+            'reference' => '1a0706e8b8041046052ea2695eb8aeee04f97609',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/http-foundation',
             'aliases' => array(),
             'dev_requirement' => false,
         ),
         'symfony/polyfill-mbstring' => array(
-            'pretty_version' => 'v1.32.0',
-            'version' => '1.32.0.0',
-            'reference' => '6d857f4d76bd4b343eac26d6b539585d2bc56493',
+            'pretty_version' => 'v1.37.0',
+            'version' => '1.37.0.0',
+            'reference' => '6a21eb99c6973357967f6ce3708cd55a6bec6315',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/polyfill-mbstring',
             'aliases' => array(),
             'dev_requirement' => false,
         ),
+        'symfony/polyfill-php80' => array(
+            'pretty_version' => 'v1.37.0',
+            'version' => '1.37.0.0',
+            'reference' => 'dfb55726c3a76ea3b6459fcfda1ec2d80a682411',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../symfony/polyfill-php80',
+            'aliases' => array(),
+            'dev_requirement' => false,
+        ),
         'tectalic/openai' => array(
             'pretty_version' => 'v1.6.0',
             'version' => '1.6.0.0',
@@ -412,7 +430,7 @@
         'topthink/think' => array(
             'pretty_version' => 'dev-master',
             'version' => 'dev-master',
-            'reference' => 'bbd690ca0f68c671ece05e82edc88ee7a68b82ed',
+            'reference' => '1d54946fef97376f7c2789af83a1616dd6f7a380',
             'type' => 'project',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
@@ -428,18 +446,18 @@
             'dev_requirement' => false,
         ),
         'topthink/think-helper' => array(
-            'pretty_version' => 'v3.1.11',
-            'version' => '3.1.11.0',
-            'reference' => '1d6ada9b9f3130046bf6922fe1bd159c8d88a33c',
+            'pretty_version' => 'v3.1.12',
+            'version' => '3.1.12.0',
+            'reference' => 'fe277121112a8f1c872e169a733ca80bb11c4acb',
             'type' => 'library',
             'install_path' => __DIR__ . '/../topthink/think-helper',
             'aliases' => array(),
             'dev_requirement' => false,
         ),
         'topthink/think-image' => array(
-            'pretty_version' => 'v1.0.7',
-            'version' => '1.0.7.0',
-            'reference' => '8586cf47f117481c6d415b20f7dedf62e79d5512',
+            'pretty_version' => 'v1.0.8',
+            'version' => '1.0.8.0',
+            'reference' => 'd1d748cbb2fe2f29fca6138cf96cb8b5113892f1',
             'type' => 'library',
             'install_path' => __DIR__ . '/../topthink/think-image',
             'aliases' => array(),
diff --git a/vendor/phpmailer/phpmailer/composer.json b/vendor/phpmailer/phpmailer/composer.json
index e762d59d..7b008b7c 100644
--- a/vendor/phpmailer/phpmailer/composer.json
+++ b/vendor/phpmailer/phpmailer/composer.json
@@ -49,15 +49,14 @@
     },
     "suggest": {
         "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
-        "ext-imap": "Needed to support advanced email address parsing according to RFC822",
         "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
         "ext-openssl": "Needed for secure SMTP sending and DKIM signing",
         "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
         "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
         "league/oauth2-google": "Needed for Google XOAUTH2 authentication",
         "psr/log": "For optional PSR-3 debug logging",
-        "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
-        "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
+        "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication",
+        "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)"
     },
     "autoload": {
         "psr-4": {
@@ -72,7 +71,6 @@
     "license": "LGPL-2.1-only",
     "scripts": {
         "check": "./vendor/bin/phpcs",
-        "style": "./vendor/bin/phpcbf",
         "test": "./vendor/bin/phpunit --no-coverage",
         "coverage": "./vendor/bin/phpunit",
         "lint": [
diff --git a/vendor/phpmailer/phpmailer/src/PHPMailer.php b/vendor/phpmailer/phpmailer/src/PHPMailer.php
index 0a8711f4..4b3f34c8 100644
--- a/vendor/phpmailer/phpmailer/src/PHPMailer.php
+++ b/vendor/phpmailer/phpmailer/src/PHPMailer.php
@@ -561,9 +561,9 @@ class PHPMailer
      *   string  $body          the email body
      *   string  $from          email address of sender
      *   string  $extra         extra information of possible use
-     *                          'smtp_transaction_id' => last smtp transaction id
+     *                          "smtp_transaction_id' => last smtp transaction id
      *
-     * @var callable|callable-string
+     * @var string
      */
     public $action_function = '';
 
@@ -711,7 +711,7 @@ class PHPMailer
      *
      * @var array
      */
-    protected static $language = [];
+    protected $language = [];
 
     /**
      * The number of errors encountered.
@@ -768,7 +768,7 @@ class PHPMailer
      *
      * @var string
      */
-    const VERSION = '6.11.1';
+    const VERSION = '6.12.0';
 
     /**
      * Error severity: message only, continue processing.
@@ -1102,7 +1102,7 @@ class PHPMailer
             //At-sign is missing.
             $error_message = sprintf(
                 '%s (%s): %s',
-                self::lang('invalid_address'),
+                $this->lang('invalid_address'),
                 $kind,
                 $address
             );
@@ -1187,7 +1187,7 @@ class PHPMailer
         if (!in_array($kind, ['to', 'cc', 'bcc', 'Reply-To'])) {
             $error_message = sprintf(
                 '%s: %s',
-                self::lang('Invalid recipient kind'),
+                $this->lang('Invalid recipient kind'),
                 $kind
             );
             $this->setError($error_message);
@@ -1201,7 +1201,7 @@ class PHPMailer
         if (!static::validateAddress($address)) {
             $error_message = sprintf(
                 '%s (%s): %s',
-                self::lang('invalid_address'),
+                $this->lang('invalid_address'),
                 $kind,
                 $address
             );
@@ -1220,16 +1220,12 @@ class PHPMailer
 
                 return true;
             }
-        } else {
-            foreach ($this->ReplyTo as $replyTo) {
-                if (0 === strcasecmp($replyTo[0], $address)) {
-                    return false;
-                }
-            }
-            $this->ReplyTo[] = [$address, $name];
+        } elseif (!array_key_exists(strtolower($address), $this->ReplyTo)) {
+            $this->ReplyTo[strtolower($address)] = [$address, $name];
 
             return true;
         }
+
         return false;
     }
 
@@ -1242,18 +1238,15 @@ class PHPMailer
      * @see https://www.andrew.cmu.edu/user/agreen1/testing/mrbs/web/Mail/RFC822.php A more careful implementation
      *
      * @param string $addrstr The address list string
-     * @param null   $useimap Deprecated argument since 6.11.0.
+     * @param bool   $useimap Whether to use the IMAP extension to parse the list
      * @param string $charset The charset to use when decoding the address list string.
      *
      * @return array
      */
-    public static function parseAddresses($addrstr, $useimap = null, $charset = self::CHARSET_ISO88591)
+    public static function parseAddresses($addrstr, $useimap = true, $charset = self::CHARSET_ISO88591)
     {
-        if ($useimap !== null) {
-            trigger_error(self::lang('deprecated_argument'), E_USER_DEPRECATED);
-        }
         $addresses = [];
-        if (function_exists('imap_rfc822_parse_adrlist')) {
+        if ($useimap && function_exists('imap_rfc822_parse_adrlist')) {
             //Use this built-in parser if it's available
             $list = imap_rfc822_parse_adrlist($addrstr, '');
             // Clear any potential IMAP errors to get rid of notices being thrown at end of script.
@@ -1263,13 +1256,20 @@ class PHPMailer
                     '.SYNTAX-ERROR.' !== $address->host &&
                     static::validateAddress($address->mailbox . '@' . $address->host)
                 ) {
-                    //Decode the name part if it's present and maybe encoded
+                    //Decode the name part if it's present and encoded
                     if (
-                        property_exists($address, 'personal')
-                        && is_string($address->personal)
-                        && $address->personal !== ''
+                        property_exists($address, 'personal') &&
+                        //Check for a Mbstring constant rather than using extension_loaded, which is sometimes disabled
+                        defined('MB_CASE_UPPER') &&
+                        preg_match('/^=\?.*\?=$/s', $address->personal)
                     ) {
-                        $address->personal = static::decodeHeader($address->personal, $charset);
+                        $origCharset = mb_internal_encoding();
+                        mb_internal_encoding($charset);
+                        //Undo any RFC2047-encoded spaces-as-underscores
+                        $address->personal = str_replace('_', '=20', $address->personal);
+                        //Decode the name
+                        $address->personal = mb_decode_mimeheader($address->personal);
+                        mb_internal_encoding($origCharset);
                     }
 
                     $addresses[] = [
@@ -1280,51 +1280,40 @@ class PHPMailer
             }
         } else {
             //Use this simpler parser
-            $addresses = static::parseSimplerAddresses($addrstr, $charset);
-        }
-
-        return $addresses;
-    }
-
-    /**
-     * Parse a string containing one or more RFC822-style comma-separated email addresses
-     * with the form "display name 
" into an array of name/address pairs. - * Uses a simpler parser that does not require the IMAP extension but doesnt support - * the full RFC822 spec. For full RFC822 support, use the PHP IMAP extension. - * - * @param string $addrstr The address list string - * @param string $charset The charset to use when decoding the address list string. - * - * @return array - */ - protected static function parseSimplerAddresses($addrstr, $charset) - { - // Emit a runtime notice to recommend using the IMAP extension for full RFC822 parsing - trigger_error(self::lang('imap_recommended'), E_USER_NOTICE); - - $addresses = []; - $list = explode(',', $addrstr); - foreach ($list as $address) { - $address = trim($address); - //Is there a separate name part? - if (strpos($address, '<') === false) { - //No separate name, just use the whole thing - if (static::validateAddress($address)) { - $addresses[] = [ - 'name' => '', - 'address' => $address, - ]; - } - } else { - $parsed = static::parseEmailString($address); - $email = $parsed['email']; - if (static::validateAddress($email)) { - $name = static::decodeHeader($parsed['name'], $charset); - $addresses[] = [ - //Remove any surrounding quotes and spaces from the name - 'name' => trim($name, '\'" '), - 'address' => $email, - ]; + $list = explode(',', $addrstr); + foreach ($list as $address) { + $address = trim($address); + //Is there a separate name part? + if (strpos($address, '<') === false) { + //No separate name, just use the whole thing + if (static::validateAddress($address)) { + $addresses[] = [ + 'name' => '', + 'address' => $address, + ]; + } + } else { + list($name, $email) = explode('<', $address); + $email = trim(str_replace('>', '', $email)); + $name = trim($name); + if (static::validateAddress($email)) { + //Check for a Mbstring constant rather than using extension_loaded, which is sometimes disabled + //If this name is encoded, decode it + if (defined('MB_CASE_UPPER') && preg_match('/^=\?.*\?=$/s', $name)) { + $origCharset = mb_internal_encoding(); + mb_internal_encoding($charset); + //Undo any RFC2047-encoded spaces-as-underscores + $name = str_replace('_', '=20', $name); + //Decode the name + $name = mb_decode_mimeheader($name); + mb_internal_encoding($origCharset); + } + $addresses[] = [ + //Remove any surrounding quotes and spaces from the name + 'name' => trim($name, '\'" '), + 'address' => $email, + ]; + } } } } @@ -1332,42 +1321,6 @@ class PHPMailer return $addresses; } - /** - * Parse a string containing an email address with an optional name - * and divide it into a name and email address. - * - * @param string $input The email with name. - * - * @return array{name: string, email: string} - */ - private static function parseEmailString($input) - { - $input = trim((string)$input); - - if ($input === '') { - return ['name' => '', 'email' => '']; - } - - $pattern = '/^\s*(?:(?:"([^"]*)"|\'([^\']*)\'|([^<]*?))\s*)?<\s*([^>]+)\s*>\s*$/'; - if (preg_match($pattern, $input, $matches)) { - $name = ''; - // Double quotes including special scenarios. - if (isset($matches[1]) && $matches[1] !== '') { - $name = $matches[1]; - // Single quotes including special scenarios. - } elseif (isset($matches[2]) && $matches[2] !== '') { - $name = $matches[2]; - // Simplest scenario, name and email are in the format "Name ". - } elseif (isset($matches[3])) { - $name = trim($matches[3]); - } - - return ['name' => $name, 'email' => trim($matches[4])]; - } - - return ['name' => '', 'email' => $input]; - } - /** * Set the From and FromName properties. * @@ -1381,10 +1334,6 @@ class PHPMailer */ public function setFrom($address, $name = '', $auto = true) { - if (is_null($name)) { - //Helps avoid a deprecation warning in the preg_replace() below - $name = ''; - } $address = trim((string)$address); $name = trim(preg_replace('/[\r\n]+/', '', $name)); //Strip breaks and trim //Don't validate now addresses with IDN. Will be done in send(). @@ -1396,7 +1345,7 @@ class PHPMailer ) { $error_message = sprintf( '%s (From): %s', - self::lang('invalid_address'), + $this->lang('invalid_address'), $address ); $this->setError($error_message); @@ -1652,7 +1601,7 @@ class PHPMailer && ini_get('mail.add_x_header') === '1' && stripos(PHP_OS, 'WIN') === 0 ) { - trigger_error(self::lang('buggy_php'), E_USER_WARNING); + trigger_error($this->lang('buggy_php'), E_USER_WARNING); } try { @@ -1682,7 +1631,7 @@ class PHPMailer call_user_func_array([$this, 'addAnAddress'], $params); } if (count($this->to) + count($this->cc) + count($this->bcc) < 1) { - throw new Exception(self::lang('provide_address'), self::STOP_CRITICAL); + throw new Exception($this->lang('provide_address'), self::STOP_CRITICAL); } //Validate From, Sender, and ConfirmReadingTo addresses @@ -1699,7 +1648,7 @@ class PHPMailer if (!static::validateAddress($this->{$address_kind})) { $error_message = sprintf( '%s (%s): %s', - self::lang('invalid_address'), + $this->lang('invalid_address'), $address_kind, $this->{$address_kind} ); @@ -1721,7 +1670,7 @@ class PHPMailer $this->setMessageType(); //Refuse to send an empty message unless we are specifically allowing it if (!$this->AllowEmpty && empty($this->Body)) { - throw new Exception(self::lang('empty_message'), self::STOP_CRITICAL); + throw new Exception($this->lang('empty_message'), self::STOP_CRITICAL); } //Trim subject consistently @@ -1860,10 +1809,8 @@ class PHPMailer } else { $sendmailFmt = '%s -oi -f%s -t'; } - } elseif ($this->Mailer === 'qmail') { - $sendmailFmt = '%s'; } else { - //Allow sendmail to choose a default envelope sender. It may + //allow sendmail to choose a default envelope sender. It may //seem preferable to force it to use the From header as with //SMTP, but that introduces new problems (see //), and @@ -1881,35 +1828,33 @@ class PHPMailer foreach ($this->SingleToArray as $toAddr) { $mail = @popen($sendmail, 'w'); if (!$mail) { - throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL); + throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } $this->edebug("To: {$toAddr}"); fwrite($mail, 'To: ' . $toAddr . "\n"); fwrite($mail, $header); fwrite($mail, $body); $result = pclose($mail); - $addrinfo = static::parseAddresses($toAddr, null, $this->CharSet); - foreach ($addrinfo as $addr) { - $this->doCallback( - ($result === 0), - [[$addr['address'], $addr['name']]], - $this->cc, - $this->bcc, - $this->Subject, - $body, - $this->From, - [] - ); - } + $addrinfo = static::parseAddresses($toAddr, true, $this->CharSet); + $this->doCallback( + ($result === 0), + [[$addrinfo['address'], $addrinfo['name']]], + $this->cc, + $this->bcc, + $this->Subject, + $body, + $this->From, + [] + ); $this->edebug("Result: " . ($result === 0 ? 'true' : 'false')); if (0 !== $result) { - throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL); + throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } } } else { $mail = @popen($sendmail, 'w'); if (!$mail) { - throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL); + throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } fwrite($mail, $header); fwrite($mail, $body); @@ -1926,7 +1871,7 @@ class PHPMailer ); $this->edebug("Result: " . ($result === 0 ? 'true' : 'false')); if (0 !== $result) { - throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL); + throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } } @@ -2065,19 +2010,17 @@ class PHPMailer if ($this->SingleTo && count($toArr) > 1) { foreach ($toArr as $toAddr) { $result = $this->mailPassthru($toAddr, $this->Subject, $body, $header, $params); - $addrinfo = static::parseAddresses($toAddr, null, $this->CharSet); - foreach ($addrinfo as $addr) { - $this->doCallback( - $result, - [[$addr['address'], $addr['name']]], - $this->cc, - $this->bcc, - $this->Subject, - $body, - $this->From, - [] - ); - } + $addrinfo = static::parseAddresses($toAddr, true, $this->CharSet); + $this->doCallback( + $result, + [[$addrinfo['address'], $addrinfo['name']]], + $this->cc, + $this->bcc, + $this->Subject, + $body, + $this->From, + [] + ); } } else { $result = $this->mailPassthru($to, $this->Subject, $body, $header, $params); @@ -2087,7 +2030,7 @@ class PHPMailer ini_set('sendmail_from', $old_from); } if (!$result) { - throw new Exception(self::lang('instantiate'), self::STOP_CRITICAL); + throw new Exception($this->lang('instantiate'), self::STOP_CRITICAL); } return true; @@ -2173,12 +2116,12 @@ class PHPMailer $header = static::stripTrailingWSP($header) . static::$LE . static::$LE; $bad_rcpt = []; if (!$this->smtpConnect($this->SMTPOptions)) { - throw new Exception(self::lang('smtp_connect_failed'), self::STOP_CRITICAL); + throw new Exception($this->lang('smtp_connect_failed'), self::STOP_CRITICAL); } //If we have recipient addresses that need Unicode support, //but the server doesn't support it, stop here if ($this->UseSMTPUTF8 && !$this->smtp->getServerExt('SMTPUTF8')) { - throw new Exception(self::lang('no_smtputf8'), self::STOP_CRITICAL); + throw new Exception($this->lang('no_smtputf8'), self::STOP_CRITICAL); } //Sender already validated in preSend() if ('' === $this->Sender) { @@ -2190,7 +2133,7 @@ class PHPMailer $this->smtp->xclient($this->SMTPXClient); } if (!$this->smtp->mail($smtp_from)) { - $this->setError(self::lang('from_failed') . $smtp_from . ' : ' . implode(',', $this->smtp->getError())); + $this->setError($this->lang('from_failed') . $smtp_from . ' : ' . implode(',', $this->smtp->getError())); throw new Exception($this->ErrorInfo, self::STOP_CRITICAL); } @@ -2212,7 +2155,7 @@ class PHPMailer //Only send the DATA command if we have viable recipients if ((count($this->all_recipients) > count($bad_rcpt)) && !$this->smtp->data($header . $body)) { - throw new Exception(self::lang('data_not_accepted'), self::STOP_CRITICAL); + throw new Exception($this->lang('data_not_accepted'), self::STOP_CRITICAL); } $smtp_transaction_id = $this->smtp->getLastTransactionID(); @@ -2243,7 +2186,7 @@ class PHPMailer foreach ($bad_rcpt as $bad) { $errstr .= $bad['to'] . ': ' . $bad['error']; } - throw new Exception(self::lang('recipients_failed') . $errstr, self::STOP_CONTINUE); + throw new Exception($this->lang('recipients_failed') . $errstr, self::STOP_CONTINUE); } return true; @@ -2297,7 +2240,7 @@ class PHPMailer $hostinfo ) ) { - $this->edebug(self::lang('invalid_hostentry') . ' ' . trim($hostentry)); + $this->edebug($this->lang('invalid_hostentry') . ' ' . trim($hostentry)); //Not a valid host entry continue; } @@ -2309,7 +2252,7 @@ class PHPMailer //Check the host name is a valid name or IP address before trying to use it if (!static::isValidHost($hostinfo[2])) { - $this->edebug(self::lang('invalid_host') . ' ' . $hostinfo[2]); + $this->edebug($this->lang('invalid_host') . ' ' . $hostinfo[2]); continue; } $prefix = ''; @@ -2329,7 +2272,7 @@ class PHPMailer if (static::ENCRYPTION_STARTTLS === $secure || static::ENCRYPTION_SMTPS === $secure) { //Check for an OpenSSL constant rather than using extension_loaded, which is sometimes disabled if (!$sslext) { - throw new Exception(self::lang('extension_missing') . 'openssl', self::STOP_CRITICAL); + throw new Exception($this->lang('extension_missing') . 'openssl', self::STOP_CRITICAL); } } $host = $hostinfo[2]; @@ -2381,7 +2324,7 @@ class PHPMailer $this->oauth ) ) { - throw new Exception(self::lang('authenticate')); + throw new Exception($this->lang('authenticate')); } return true; @@ -2431,7 +2374,7 @@ class PHPMailer * * @return bool Returns true if the requested language was loaded, false otherwise. */ - public static function setLanguage($langcode = 'en', $lang_path = '') + public function setLanguage($langcode = 'en', $lang_path = '') { //Backwards compatibility for renamed language codes $renamed_langcodes = [ @@ -2480,9 +2423,6 @@ class PHPMailer 'smtp_error' => 'SMTP server error: ', 'variable_set' => 'Cannot set or reset variable: ', 'no_smtputf8' => 'Server does not support SMTPUTF8 needed to send to Unicode addresses', - 'imap_recommended' => 'Using simplified address parser is not recommended. ' . - 'Install the PHP IMAP extension for full RFC822 parsing.', - 'deprecated_argument' => 'Argument $useimap is deprecated', ]; if (empty($lang_path)) { //Calculate an absolute path so it can work if CWD is not here @@ -2549,7 +2489,7 @@ class PHPMailer } } } - self::$language = $PHPMAILER_LANG; + $this->language = $PHPMAILER_LANG; return $foundlang; //Returns false if language not found } @@ -2561,11 +2501,11 @@ class PHPMailer */ public function getTranslations() { - if (empty(self::$language)) { - self::setLanguage(); // Set the default language. + if (empty($this->language)) { + $this->setLanguage(); // Set the default language. } - return self::$language; + return $this->language; } /** @@ -2988,6 +2928,10 @@ class PHPMailer //Create unique IDs and preset boundaries $this->setBoundaries(); + if ($this->sign_key_file) { + $body .= $this->getMailMIME() . static::$LE; + } + $this->setWordWrap(); $bodyEncoding = $this->Encoding; @@ -3019,12 +2963,6 @@ class PHPMailer if (static::ENCODING_BASE64 !== $altBodyEncoding && static::hasLineLongerThanMax($this->AltBody)) { $altBodyEncoding = static::ENCODING_QUOTED_PRINTABLE; } - - if ($this->sign_key_file) { - $this->Encoding = $bodyEncoding; - $body .= $this->getMailMIME() . static::$LE; - } - //Use this as a preamble in all multipart message types $mimepre = ''; switch ($this->message_type) { @@ -3206,12 +3144,12 @@ class PHPMailer if ($this->isError()) { $body = ''; if ($this->exceptions) { - throw new Exception(self::lang('empty_message'), self::STOP_CRITICAL); + throw new Exception($this->lang('empty_message'), self::STOP_CRITICAL); } } elseif ($this->sign_key_file) { try { if (!defined('PKCS7_TEXT')) { - throw new Exception(self::lang('extension_missing') . 'openssl'); + throw new Exception($this->lang('extension_missing') . 'openssl'); } $file = tempnam(sys_get_temp_dir(), 'srcsign'); @@ -3249,7 +3187,7 @@ class PHPMailer $body = $parts[1]; } else { @unlink($signed); - throw new Exception(self::lang('signing') . openssl_error_string()); + throw new Exception($this->lang('signing') . openssl_error_string()); } } catch (Exception $exc) { $body = ''; @@ -3394,7 +3332,7 @@ class PHPMailer ) { try { if (!static::fileIsAccessible($path)) { - throw new Exception(self::lang('file_access') . $path, self::STOP_CONTINUE); + throw new Exception($this->lang('file_access') . $path, self::STOP_CONTINUE); } //If a MIME type is not specified, try to work it out from the file name @@ -3407,7 +3345,7 @@ class PHPMailer $name = $filename; } if (!$this->validateEncoding($encoding)) { - throw new Exception(self::lang('encoding') . $encoding); + throw new Exception($this->lang('encoding') . $encoding); } $this->attachment[] = [ @@ -3568,11 +3506,11 @@ class PHPMailer { try { if (!static::fileIsAccessible($path)) { - throw new Exception(self::lang('file_open') . $path, self::STOP_CONTINUE); + throw new Exception($this->lang('file_open') . $path, self::STOP_CONTINUE); } $file_buffer = file_get_contents($path); if (false === $file_buffer) { - throw new Exception(self::lang('file_open') . $path, self::STOP_CONTINUE); + throw new Exception($this->lang('file_open') . $path, self::STOP_CONTINUE); } $file_buffer = $this->encodeString($file_buffer, $encoding); @@ -3625,9 +3563,9 @@ class PHPMailer $encoded = $this->encodeQP($str); break; default: - $this->setError(self::lang('encoding') . $encoding); + $this->setError($this->lang('encoding') . $encoding); if ($this->exceptions) { - throw new Exception(self::lang('encoding') . $encoding); + throw new Exception($this->lang('encoding') . $encoding); } break; } @@ -3733,42 +3671,6 @@ class PHPMailer return trim(static::normalizeBreaks($encoded)); } - /** - * Decode an RFC2047-encoded header value - * Attempts multiple strategies so it works even when the mbstring extension is disabled. - * - * @param string $value The header value to decode - * @param string $charset The target charset to convert to, defaults to ISO-8859-1 for BC - * - * @return string The decoded header value - */ - public static function decodeHeader($value, $charset = self::CHARSET_ISO88591) - { - if (!is_string($value) || $value === '') { - return ''; - } - // Detect the presence of any RFC2047 encoded-words - $hasEncodedWord = (bool) preg_match('/=\?.*\?=/s', $value); - if ($hasEncodedWord && defined('MB_CASE_UPPER')) { - $origCharset = mb_internal_encoding(); - // Always decode to UTF-8 to provide a consistent, modern output encoding. - mb_internal_encoding($charset); - if (PHP_VERSION_ID < 80300) { - // Undo any RFC2047-encoded spaces-as-underscores. - $value = str_replace('_', '=20', $value); - } else { - // PHP 8.3+ already interprets underscores as spaces. Remove additional - // linear whitespace between adjacent encoded words to avoid double spacing. - $value = preg_replace('/(\?=)\s+(=\?)/', '$1$2', $value); - } - // Decode the header value - $value = mb_decode_mimeheader($value); - mb_internal_encoding($origCharset); - } - - return $value; - } - /** * Check if a string contains multi-byte characters. * @@ -3938,7 +3840,7 @@ class PHPMailer } if (!$this->validateEncoding($encoding)) { - throw new Exception(self::lang('encoding') . $encoding); + throw new Exception($this->lang('encoding') . $encoding); } //Append to $attachment array @@ -3997,7 +3899,7 @@ class PHPMailer ) { try { if (!static::fileIsAccessible($path)) { - throw new Exception(self::lang('file_access') . $path, self::STOP_CONTINUE); + throw new Exception($this->lang('file_access') . $path, self::STOP_CONTINUE); } //If a MIME type is not specified, try to work it out from the file name @@ -4006,7 +3908,7 @@ class PHPMailer } if (!$this->validateEncoding($encoding)) { - throw new Exception(self::lang('encoding') . $encoding); + throw new Exception($this->lang('encoding') . $encoding); } $filename = (string) static::mb_pathinfo($path, PATHINFO_BASENAME); @@ -4072,7 +3974,7 @@ class PHPMailer } if (!$this->validateEncoding($encoding)) { - throw new Exception(self::lang('encoding') . $encoding); + throw new Exception($this->lang('encoding') . $encoding); } //Append to $attachment array @@ -4329,7 +4231,7 @@ class PHPMailer } if (strpbrk($name . $value, "\r\n") !== false) { if ($this->exceptions) { - throw new Exception(self::lang('invalid_header')); + throw new Exception($this->lang('invalid_header')); } return false; @@ -4353,15 +4255,15 @@ class PHPMailer if ('smtp' === $this->Mailer && null !== $this->smtp) { $lasterror = $this->smtp->getError(); if (!empty($lasterror['error'])) { - $msg .= ' ' . self::lang('smtp_error') . $lasterror['error']; + $msg .= ' ' . $this->lang('smtp_error') . $lasterror['error']; if (!empty($lasterror['detail'])) { - $msg .= ' ' . self::lang('smtp_detail') . $lasterror['detail']; + $msg .= ' ' . $this->lang('smtp_detail') . $lasterror['detail']; } if (!empty($lasterror['smtp_code'])) { - $msg .= ' ' . self::lang('smtp_code') . $lasterror['smtp_code']; + $msg .= ' ' . $this->lang('smtp_code') . $lasterror['smtp_code']; } if (!empty($lasterror['smtp_code_ex'])) { - $msg .= ' ' . self::lang('smtp_code_ex') . $lasterror['smtp_code_ex']; + $msg .= ' ' . $this->lang('smtp_code_ex') . $lasterror['smtp_code_ex']; } } } @@ -4486,21 +4388,21 @@ class PHPMailer * * @return string */ - protected static function lang($key) + protected function lang($key) { - if (count(self::$language) < 1) { - self::setLanguage(); //Set the default language + if (count($this->language) < 1) { + $this->setLanguage(); //Set the default language } - if (array_key_exists($key, self::$language)) { + if (array_key_exists($key, $this->language)) { if ('smtp_connect_failed' === $key) { //Include a link to troubleshooting docs on SMTP connection failure. //This is by far the biggest cause of support questions //but it's usually not PHPMailer's fault. - return self::$language[$key] . ' https://github.com/PHPMailer/PHPMailer/wiki/Troubleshooting'; + return $this->language[$key] . ' https://github.com/PHPMailer/PHPMailer/wiki/Troubleshooting'; } - return self::$language[$key]; + return $this->language[$key]; } //Return the key as a fallback @@ -4515,7 +4417,7 @@ class PHPMailer */ private function getSmtpErrorMessage($base_key) { - $message = self::lang($base_key); + $message = $this->lang($base_key); $error = $this->smtp->getError(); if (!empty($error['error'])) { $message .= ' ' . $error['error']; @@ -4559,7 +4461,7 @@ class PHPMailer //Ensure name is not empty, and that neither name nor value contain line breaks if (empty($name) || strpbrk($name . $value, "\r\n") !== false) { if ($this->exceptions) { - throw new Exception(self::lang('invalid_header')); + throw new Exception($this->lang('invalid_header')); } return false; @@ -4952,7 +4854,7 @@ class PHPMailer return true; } - $this->setError(self::lang('variable_set') . $name); + $this->setError($this->lang('variable_set') . $name); return false; } @@ -5090,7 +4992,7 @@ class PHPMailer { if (!defined('PKCS7_TEXT')) { if ($this->exceptions) { - throw new Exception(self::lang('extension_missing') . 'openssl'); + throw new Exception($this->lang('extension_missing') . 'openssl'); } return ''; diff --git a/vendor/phpmailer/phpmailer/src/POP3.php b/vendor/phpmailer/phpmailer/src/POP3.php index 2c2cf789..4d3e8db2 100644 --- a/vendor/phpmailer/phpmailer/src/POP3.php +++ b/vendor/phpmailer/phpmailer/src/POP3.php @@ -46,7 +46,7 @@ class POP3 * * @var string */ - const VERSION = '6.11.1'; + const VERSION = '6.12.0'; /** * Default POP3 port number. diff --git a/vendor/phpoffice/math/mkdocs.yml b/vendor/phpoffice/math/mkdocs.yml index 972d2741..198c5fe5 100644 --- a/vendor/phpoffice/math/mkdocs.yml +++ b/vendor/phpoffice/math/mkdocs.yml @@ -57,7 +57,9 @@ nav: - Writers: 'usage/writers.md' - Credits: 'credits.md' - Releases: - - '0.1.0 (WIP)': 'changes/0.1.0.md' + - '0.3.0 (WIP)': 'changes/0.3.0.md' + - '0.2.0': 'changes/0.2.0.md' + - '0.1.0': 'changes/0.1.0.md' - Developers: - 'Coveralls': 'https://coveralls.io/github/PHPOffice/Math' - 'Code Coverage': 'coverage/index.html' diff --git a/vendor/phpoffice/math/tests/Math/Reader/MathMLTest.php b/vendor/phpoffice/math/tests/Math/Reader/MathMLTest.php index db9b174a..a17c179c 100644 --- a/vendor/phpoffice/math/tests/Math/Reader/MathMLTest.php +++ b/vendor/phpoffice/math/tests/Math/Reader/MathMLTest.php @@ -7,6 +7,7 @@ namespace Tests\PhpOffice\Math\Reader; use PhpOffice\Math\Element; use PhpOffice\Math\Exception\InvalidInputException; use PhpOffice\Math\Exception\NotImplementedException; +use PhpOffice\Math\Exception\SecurityException; use PhpOffice\Math\Math; use PhpOffice\Math\Reader\MathML; use PHPUnit\Framework\TestCase; @@ -294,4 +295,15 @@ class MathMLTest extends TestCase $reader = new MathML(); $math = $reader->read($content); } + + public function testReadSecurity(): void + { + $this->expectException(SecurityException::class); + $this->expectExceptionMessage('Detected use of ENTITY in XML, loading aborted to prevent XXE/XEE attacks'); + + $content = ' M'; + + $reader = new MathML(); + $math = $reader->read($content); + } } diff --git a/vendor/phpoffice/phpword/LICENSE b/vendor/phpoffice/phpword/LICENSE index 8a1acaea..aebd12b0 100644 --- a/vendor/phpoffice/phpword/LICENSE +++ b/vendor/phpoffice/phpword/LICENSE @@ -1,6 +1,6 @@ PHPWord, a pure PHP library for reading and writing word processing documents. -Copyright (c) 2010-2016 PHPWord. +Copyright (c) 2010-2025 PHPWord. PHPWord is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License version 3 as published by diff --git a/vendor/phpoffice/phpword/README.md b/vendor/phpoffice/phpword/README.md index 11bcd152..e080d32d 100644 --- a/vendor/phpoffice/phpword/README.md +++ b/vendor/phpoffice/phpword/README.md @@ -1,11 +1,11 @@ # ![PHPWord](https://rawgit.com/PHPOffice/PHPWord/develop/docs/images/phpword.svg "PHPWord") -[![Latest Stable Version](https://poser.pugx.org/phpoffice/phpword/v/stable.png)](https://packagist.org/packages/phpoffice/phpword) +[![Latest Stable Version](https://poser.pugx.org/phpoffice/phpword/v)](https://packagist.org/packages/phpoffice/phpword) [![Coverage Status](https://coveralls.io/repos/github/PHPOffice/PHPWord/badge.svg?branch=master)](https://coveralls.io/github/PHPOffice/PHPWord?branch=master) -[![Total Downloads](https://poser.pugx.org/phpoffice/phpword/downloads.png)](https://packagist.org/packages/phpoffice/phpword) -[![License](https://poser.pugx.org/phpoffice/phpword/license.png)](https://packagist.org/packages/phpoffice/phpword) -[![CI](https://github.com/PHPOffice/PHPWord/actions/workflows/ci.yml/badge.svg)](https://github.com/PHPOffice/PHPWord/actions/workflows/ci.yml) -[![Join the chat at https://gitter.im/PHPOffice/PHPWord](https://img.shields.io/badge/GITTER-join%20chat-green.svg)](https://gitter.im/PHPOffice/PHPWord) +[![Total Downloads](https://poser.pugx.org/phpoffice/phpword/downloads)](https://packagist.org/packages/phpoffice/phpword) +[![License](https://poser.pugx.org/phpoffice/phpword/license)](https://packagist.org/packages/phpoffice/phpword) + +Branch Master : [![PHPWord](https://github.com/PHPOffice/PHPWord/actions/workflows/php.yml/badge.svg?branch=master)](https://github.com/PHPOffice/PHPWord/actions/workflows/php.yml) PHPWord is a library written in pure PHP that provides a set of classes to write to and read from different document file formats. The current version of PHPWord supports Microsoft [Office Open XML](http://en.wikipedia.org/wiki/Office_Open_XML) (OOXML or OpenXML), OASIS [Open Document Format for Office Applications](http://en.wikipedia.org/wiki/OpenDocument) (OpenDocument or ODF), [Rich Text Format](http://en.wikipedia.org/wiki/Rich_Text_Format) (RTF), HTML, and PDF. @@ -81,7 +81,6 @@ The following is a basic usage example of the PHPWord library. ```php */ class Bookmarks extends AbstractCollection diff --git a/vendor/phpoffice/phpword/src/PhpWord/Element/Chart.php b/vendor/phpoffice/phpword/src/PhpWord/Element/Chart.php index 4f652f25..7aa49fb9 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Element/Chart.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Element/Chart.php @@ -1,4 +1,5 @@ text; } @@ -109,7 +108,7 @@ class Link extends AbstractElement /** * Get Text style. * - * @return null|\PhpOffice\PhpWord\Style\Font|string + * @return null|Font|string */ public function getFontStyle() { @@ -119,7 +118,7 @@ class Link extends AbstractElement /** * Get Paragraph style. * - * @return null|\PhpOffice\PhpWord\Style\Paragraph|string + * @return null|Paragraph|string */ public function getParagraphStyle() { diff --git a/vendor/phpoffice/phpword/src/PhpWord/Element/PageBreak.php b/vendor/phpoffice/phpword/src/PhpWord/Element/PageBreak.php index 02f5989f..950f0b7d 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Element/PageBreak.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Element/PageBreak.php @@ -1,4 +1,5 @@ $collectionArray; if (in_array($type, [Header::AUTO, Header::FIRST, Header::EVEN])) { $index = count($collection); - /** @var \PhpOffice\PhpWord\Element\AbstractContainer $container Type hint */ + /** @var AbstractContainer $container Type hint */ $container = new $containerClass($this->sectionId, ++$index, $type); $container->setPhpWord($this->phpWord); diff --git a/vendor/phpoffice/phpword/src/PhpWord/Element/Shape.php b/vendor/phpoffice/phpword/src/PhpWord/Element/Shape.php index 15161f89..9ea221db 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Element/Shape.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Element/Shape.php @@ -1,4 +1,5 @@ rows); for ($i = 0; $i < $rowCount; ++$i) { - /** @var \PhpOffice\PhpWord\Element\Row $row Type hint */ + /** @var Row $row Type hint */ $row = $this->rows[$i]; $cellCount = count($row->getCells()); if ($columnCount < $cellCount) { diff --git a/vendor/phpoffice/phpword/src/PhpWord/Element/TextBreak.php b/vendor/phpoffice/phpword/src/PhpWord/Element/TextBreak.php index c3cd087d..8bfac741 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Element/TextBreak.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Element/TextBreak.php @@ -1,4 +1,5 @@ getElements() as $element) { if ($element instanceof Text) { $outstr .= $element->getText(); + } elseif ($element instanceof Ruby) { + $outstr .= $element->getBaseTextRun()->getText() . + ' (' . $element->getRubyTextRun()->getText() . ')'; } } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Element/TrackChange.php b/vendor/phpoffice/phpword/src/PhpWord/Element/TrackChange.php index 064e6380..3539bdc6 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Element/TrackChange.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Element/TrackChange.php @@ -1,4 +1,5 @@ load($filename); @@ -100,7 +101,7 @@ abstract class IOFactory */ public static function extractVariables(string $filename, string $readerName = 'Word2007'): array { - /** @var \PhpOffice\PhpWord\Reader\ReaderInterface $reader */ + /** @var ReaderInterface $reader */ $reader = self::createReader($readerName); $document = $reader->load($filename); $extractedVariables = []; diff --git a/vendor/phpoffice/phpword/src/PhpWord/Media.php b/vendor/phpoffice/phpword/src/PhpWord/Media.php index 31487a91..0a340a0a 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Media.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Media.php @@ -1,4 +1,5 @@ setRels($relationships); $part->read($phpWord); diff --git a/vendor/phpoffice/phpword/src/PhpWord/Reader/ODText/Meta.php b/vendor/phpoffice/phpword/src/PhpWord/Reader/ODText/Meta.php index 200ee130..1321b6ce 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Reader/ODText/Meta.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Reader/ODText/Meta.php @@ -1,4 +1,5 @@ . * - * @param \PhpOffice\PhpWord\Element\AbstractContainer $element Where the parts need to be added + * @param AbstractContainer $element Where the parts need to be added * @param string $html The code to parse * @param bool $fullHTML If it's a full HTML, no need to add 'body' tag * @param bool $preserveWhiteSpace If false, the whitespaces between nodes will be removed @@ -127,21 +130,21 @@ class Html break; case 'width': // tables, cells + $val = $val === 'auto' ? '100%' : $val; if (false !== strpos($val, '%')) { // e.g. or
$styles['width'] = (int) $val * 50; $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT; } else { // e.g. , where "2" = 2px (always pixels) - $val = (int) $val . 'px'; - $styles['cellSpacing'] = Converter::cssToTwip($val); + $styles['cellSpacing'] = Converter::pixelToTwip(self::convertHtmlSize($val)); break; case 'bgcolor': @@ -185,7 +188,7 @@ class Html * Parse a node and add a corresponding element to the parent element. * * @param DOMNode $node node to parse - * @param \PhpOffice\PhpWord\Element\AbstractContainer $element object to add an element corresponding with the node + * @param AbstractContainer $element object to add an element corresponding with the node * @param array $styles Array with all styles * @param array $data Array to transport data to a next level in the DOM tree, for example level of listitems */ @@ -208,16 +211,16 @@ class Html // Node mapping table $nodes = [ - // $method $node $element $styles $data $argument1 $argument2 - 'p' => ['Paragraph', $node, $element, $styles, null, null, null], - 'h1' => ['Heading', null, $element, $styles, null, 'Heading1', null], - 'h2' => ['Heading', null, $element, $styles, null, 'Heading2', null], - 'h3' => ['Heading', null, $element, $styles, null, 'Heading3', null], - 'h4' => ['Heading', null, $element, $styles, null, 'Heading4', null], - 'h5' => ['Heading', null, $element, $styles, null, 'Heading5', null], - 'h6' => ['Heading', null, $element, $styles, null, 'Heading6', null], - '#text' => ['Text', $node, $element, $styles, null, null, null], - 'strong' => ['Property', null, null, $styles, null, 'bold', true], + // $method $node $element $styles $data $argument1 $argument2 + 'p' => ['Paragraph', $node, $element, $styles, null, null, null], + 'h1' => ['Heading', $node, $element, $styles, null, 'Heading1', null], + 'h2' => ['Heading', $node, $element, $styles, null, 'Heading2', null], + 'h3' => ['Heading', $node, $element, $styles, null, 'Heading3', null], + 'h4' => ['Heading', $node, $element, $styles, null, 'Heading4', null], + 'h5' => ['Heading', $node, $element, $styles, null, 'Heading5', null], + 'h6' => ['Heading', $node, $element, $styles, null, 'Heading6', null], + '#text' => ['Text', $node, $element, $styles, null, null, null], + 'strong' => ['Property', null, null, $styles, null, 'bold', true], 'b' => ['Property', null, null, $styles, null, 'bold', true], 'em' => ['Property', null, null, $styles, null, 'italic', true], 'i' => ['Property', null, null, $styles, null, 'italic', true], @@ -238,6 +241,7 @@ class Html 'a' => ['Link', $node, $element, $styles, null, null, null], 'input' => ['Input', $node, $element, $styles, null, null, null], 'hr' => ['HorizRule', $node, $element, $styles, null, null, null], + 'ruby' => ['Ruby', $node, $element, $styles, null, null, null], ]; $newElement = null; @@ -276,7 +280,7 @@ class Html * Parse child nodes. * * @param DOMNode $node - * @param \PhpOffice\PhpWord\Element\AbstractContainer|Row|Table $element + * @param AbstractContainer|Row|Table $element * @param array $styles * @param array $data */ @@ -298,10 +302,10 @@ class Html * Parse paragraph node. * * @param DOMNode $node - * @param \PhpOffice\PhpWord\Element\AbstractContainer $element + * @param AbstractContainer $element * @param array &$styles * - * @return \PhpOffice\PhpWord\Element\PageBreak|\PhpOffice\PhpWord\Element\TextRun + * @return \PhpOffice\PhpWord\Element\PageBreak|TextRun */ protected static function parseParagraph($node, $element, &$styles) { @@ -317,7 +321,7 @@ class Html * Parse input node. * * @param DOMNode $node - * @param \PhpOffice\PhpWord\Element\AbstractContainer $element + * @param AbstractContainer $element * @param array &$styles */ protected static function parseInput($node, $element, &$styles): void @@ -341,28 +345,25 @@ class Html /** * Parse heading node. * - * @param \PhpOffice\PhpWord\Element\AbstractContainer $element - * @param array &$styles * @param string $argument1 Name of heading style * - * @return \PhpOffice\PhpWord\Element\TextRun - * * @todo Think of a clever way of defining header styles, now it is only based on the assumption, that * Heading1 - Heading6 are already defined somewhere */ - protected static function parseHeading($element, &$styles, $argument1) + protected static function parseHeading(DOMNode $node, AbstractContainer $element, array &$styles, string $argument1): TextRun { - $styles['paragraph'] = $argument1; - $newElement = $element->addTextRun($styles['paragraph']); + $style = new Paragraph(); + $style->setStyleName($argument1); + $style->setStyleByArray(self::parseInlineStyle($node, $styles['paragraph'])); - return $newElement; + return $element->addTextRun($style); } /** * Parse text node. * * @param DOMNode $node - * @param \PhpOffice\PhpWord\Element\AbstractContainer $element + * @param AbstractContainer $element * @param array &$styles */ protected static function parseText($node, $element, &$styles): void @@ -406,7 +407,7 @@ class Html * Parse table node. * * @param DOMNode $node - * @param \PhpOffice\PhpWord\Element\AbstractContainer $element + * @param AbstractContainer $element * @param array &$styles * * @return Table $element @@ -437,7 +438,7 @@ class Html * Parse a table row. * * @param DOMNode $node - * @param \PhpOffice\PhpWord\Element\Table $element + * @param Table $element * @param array &$styles * * @return Row $element @@ -460,10 +461,10 @@ class Html * Parse table cell. * * @param DOMNode $node - * @param \PhpOffice\PhpWord\Element\Table $element + * @param Table $element * @param array &$styles * - * @return \PhpOffice\PhpWord\Element\Cell|\PhpOffice\PhpWord\Element\TextRun $element + * @return \PhpOffice\PhpWord\Element\Cell|TextRun $element */ protected static function parseCell($node, $element, &$styles) { @@ -554,7 +555,7 @@ class Html * Parse list node. * * @param DOMNode $node - * @param \PhpOffice\PhpWord\Element\AbstractContainer $element + * @param AbstractContainer $element * @param array &$styles * @param array &$data */ @@ -627,15 +628,15 @@ class Html return [ 'type' => 'hybridMultilevel', 'levels' => [ - ['format' => NumberFormat::BULLET, 'text' => '', 'alignment' => 'left', 'tabPos' => 720, 'left' => 720, 'hanging' => 360, 'font' => 'Symbol', 'hint' => 'default'], - ['format' => NumberFormat::BULLET, 'text' => 'o', 'alignment' => 'left', 'tabPos' => 1440, 'left' => 1440, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'], - ['format' => NumberFormat::BULLET, 'text' => '', 'alignment' => 'left', 'tabPos' => 2160, 'left' => 2160, 'hanging' => 360, 'font' => 'Wingdings', 'hint' => 'default'], - ['format' => NumberFormat::BULLET, 'text' => '', 'alignment' => 'left', 'tabPos' => 2880, 'left' => 2880, 'hanging' => 360, 'font' => 'Symbol', 'hint' => 'default'], - ['format' => NumberFormat::BULLET, 'text' => 'o', 'alignment' => 'left', 'tabPos' => 3600, 'left' => 3600, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'], - ['format' => NumberFormat::BULLET, 'text' => '', 'alignment' => 'left', 'tabPos' => 4320, 'left' => 4320, 'hanging' => 360, 'font' => 'Wingdings', 'hint' => 'default'], - ['format' => NumberFormat::BULLET, 'text' => '', 'alignment' => 'left', 'tabPos' => 5040, 'left' => 5040, 'hanging' => 360, 'font' => 'Symbol', 'hint' => 'default'], - ['format' => NumberFormat::BULLET, 'text' => 'o', 'alignment' => 'left', 'tabPos' => 5760, 'left' => 5760, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'], - ['format' => NumberFormat::BULLET, 'text' => '', 'alignment' => 'left', 'tabPos' => 6480, 'left' => 6480, 'hanging' => 360, 'font' => 'Wingdings', 'hint' => 'default'], + ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 720, 'left' => 720, 'hanging' => 360, 'font' => 'Symbol', 'hint' => 'default'], + ['format' => NumberFormat::BULLET, 'text' => '◦', 'alignment' => 'left', 'tabPos' => 1440, 'left' => 1440, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'], + ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 2160, 'left' => 2160, 'hanging' => 360, 'font' => 'Wingdings', 'hint' => 'default'], + ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 2880, 'left' => 2880, 'hanging' => 360, 'font' => 'Symbol', 'hint' => 'default'], + ['format' => NumberFormat::BULLET, 'text' => '◦', 'alignment' => 'left', 'tabPos' => 3600, 'left' => 3600, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'], + ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 4320, 'left' => 4320, 'hanging' => 360, 'font' => 'Wingdings', 'hint' => 'default'], + ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 5040, 'left' => 5040, 'hanging' => 360, 'font' => 'Symbol', 'hint' => 'default'], + ['format' => NumberFormat::BULLET, 'text' => '◦', 'alignment' => 'left', 'tabPos' => 5760, 'left' => 5760, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'], + ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 6480, 'left' => 6480, 'hanging' => 360, 'font' => 'Wingdings', 'hint' => 'default'], ], ]; } @@ -644,7 +645,7 @@ class Html * Parse list item node. * * @param DOMNode $node - * @param \PhpOffice\PhpWord\Element\AbstractContainer $element + * @param AbstractContainer $element * @param array &$styles * @param array $data * @@ -704,6 +705,10 @@ class Html case 'text-align': $styles['alignment'] = self::mapAlign($value, $bidi); + break; + case 'ruby-align': + $styles['rubyAlignment'] = self::mapRubyAlign($value); + break; case 'display': $styles['hidden'] = $value === 'none' || $value === 'hidden'; @@ -733,7 +738,7 @@ class Html break; case 'line-height': $matches = []; - if ($value === 'normal') { + if ($value === 'normal' || $value === 'inherit') { $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO; $spacing = 0; } elseif (preg_match('/([0-9]+\.?[0-9]*[a-z]+)/', $value, $matches)) { @@ -803,6 +808,58 @@ class Html $styles['spaceAfter'] = Converter::cssToTwip($value); break; + + case 'padding': + $valueTop = $valueRight = $valueBottom = $valueLeft = null; + $cValue = preg_replace('# +#', ' ', trim($value)); + $paddingArr = explode(' ', $cValue); + $countParams = count($paddingArr); + if ($countParams == 1) { + $valueTop = $valueRight = $valueBottom = $valueLeft = $paddingArr[0]; + } elseif ($countParams == 2) { + $valueTop = $valueBottom = $paddingArr[0]; + $valueRight = $valueLeft = $paddingArr[1]; + } elseif ($countParams == 3) { + $valueTop = $paddingArr[0]; + $valueRight = $valueLeft = $paddingArr[1]; + $valueBottom = $paddingArr[2]; + } elseif ($countParams == 4) { + $valueTop = $paddingArr[0]; + $valueRight = $paddingArr[1]; + $valueBottom = $paddingArr[2]; + $valueLeft = $paddingArr[3]; + } + if ($valueTop !== null) { + $styles['paddingTop'] = Converter::cssToTwip($valueTop); + } + if ($valueRight !== null) { + $styles['paddingRight'] = Converter::cssToTwip($valueRight); + } + if ($valueBottom !== null) { + $styles['paddingBottom'] = Converter::cssToTwip($valueBottom); + } + if ($valueLeft !== null) { + $styles['paddingLeft'] = Converter::cssToTwip($valueLeft); + } + + break; + case 'padding-top': + $styles['paddingTop'] = Converter::cssToTwip($value); + + break; + case 'padding-right': + $styles['paddingRight'] = Converter::cssToTwip($value); + + break; + case 'padding-bottom': + $styles['paddingBottom'] = Converter::cssToTwip($value); + + break; + case 'padding-left': + $styles['paddingLeft'] = Converter::cssToTwip($value); + + break; + case 'border-color': self::mapBorderColor($styles, $value); @@ -886,7 +943,7 @@ class Html * Parse image node. * * @param DOMNode $node - * @param \PhpOffice\PhpWord\Element\AbstractContainer $element + * @param AbstractContainer $element * * @return \PhpOffice\PhpWord\Element\Image */ @@ -901,36 +958,12 @@ class Html break; case 'width': - $width = $attribute->value; - - // pt - if (false !== strpos($width, 'pt')) { - $width = Converter::pointToPixel((float) str_replace('pt', '', $width)); - } - - // px - if (false !== strpos($width, 'px')) { - $width = str_replace('px', '', $width); - } - - $style['width'] = $width; + $style['width'] = self::convertHtmlSize($attribute->value); $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX; break; case 'height': - $height = $attribute->value; - - // pt - if (false !== strpos($height, 'pt')) { - $height = Converter::pointToPixel((float) str_replace('pt', '', $height)); - } - - // px - if (false !== strpos($height, 'px')) { - $height = str_replace('px', '', $height); - } - - $style['height'] = $height; + $style['height'] = self::convertHtmlSize($attribute->value); $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX; break; @@ -970,14 +1003,15 @@ class Html $match = []; preg_match('/data:image\/(\w+);base64,(.+)/', $src, $match); + if (!empty($match)) { + $src = $imgFile = $tmpDir . uniqid() . '.' . $match[1]; - $src = $imgFile = $tmpDir . uniqid() . '.' . $match[1]; + $ifp = fopen($imgFile, 'wb'); - $ifp = fopen($imgFile, 'wb'); - - if ($ifp !== false) { - fwrite($ifp, base64_decode($match[2])); - fclose($ifp); + if ($ifp !== false) { + fwrite($ifp, base64_decode($match[2])); + fclose($ifp); + } } } $src = urldecode($src); @@ -1073,6 +1107,23 @@ class Html } } + /** + * Transforms a HTML/CSS ruby alignment into a \PhpOffice\PhpWord\SimpleType\Jc. + */ + protected static function mapRubyAlign(string $cssRubyAlignment): string + { + switch ($cssRubyAlignment) { + case 'center': + return RubyProperties::ALIGNMENT_CENTER; + case 'start': + return RubyProperties::ALIGNMENT_LEFT; + case 'space-between': + return RubyProperties::ALIGNMENT_DISTRIBUTE_SPACE; + default: + return ''; + } + } + /** * Transforms a HTML/CSS vertical alignment. * @@ -1129,7 +1180,7 @@ class Html /** * Parse line break. * - * @param \PhpOffice\PhpWord\Element\AbstractContainer $element + * @param AbstractContainer $element */ protected static function parseLineBreak($element): void { @@ -1140,7 +1191,7 @@ class Html * Parse link node. * * @param DOMNode $node - * @param \PhpOffice\PhpWord\Element\AbstractContainer $element + * @param AbstractContainer $element * @param array $styles */ protected static function parseLink($node, $element, &$styles) @@ -1172,7 +1223,7 @@ class Html * Note: Word rule is not the same as HTML's
since it does not support width and thus neither alignment. * * @param DOMNode $node - * @param \PhpOffice\PhpWord\Element\AbstractContainer $element + * @param AbstractContainer $element */ protected static function parseHorizRule($node, $element): void { @@ -1201,6 +1252,59 @@ class Html // - repeated text, e.g. underline "_", because of unpredictable line wrapping } + /** + * Parse ruby node. + * + * @param DOMNode $node + * @param AbstractContainer $element + * @param array $styles + */ + protected static function parseRuby($node, $element, &$styles) + { + $rubyProperties = new RubyProperties(); + $baseTextRun = new TextRun($styles['paragraph']); + $rubyTextRun = new TextRun(null); + if ($node->hasAttributes()) { + $langAttr = $node->attributes->getNamedItem('lang'); + if ($langAttr !== null) { + $rubyProperties->setLanguageId($langAttr->textContent); + } + $styleAttr = $node->attributes->getNamedItem('style'); + if ($styleAttr !== null) { + $styles = self::parseStyle($styleAttr, $styles['paragraph']); + if (isset($styles['rubyAlignment']) && $styles['rubyAlignment'] !== '') { + $rubyProperties->setAlignment($styles['rubyAlignment']); + } + if (isset($styles['size']) && $styles['size'] !== '') { + $rubyProperties->setFontSizeForBaseText($styles['size']); + } + $baseTextRun->setParagraphStyle($styles); + } + } + foreach ($node->childNodes as $child) { + if ($child->nodeName === '#text') { + $content = trim($child->textContent); + if ($content !== '') { + $baseTextRun->addText($content); + } + } elseif ($child->nodeName === 'rt') { + $rubyTextRun->addText(trim($child->textContent)); + if ($child->hasAttributes()) { + $styleAttr = $child->attributes->getNamedItem('style'); + if ($styleAttr !== null) { + $styles = self::parseStyle($styleAttr, []); + if (isset($styles['size']) && $styles['size'] !== '') { + $rubyProperties->setFontFaceSize($styles['size']); + } + $rubyTextRun->setParagraphStyle($styles); + } + } + } + } + + return $element->addRuby($baseTextRun, $rubyTextRun, $rubyProperties); + } + private static function convertRgb(string $rgb): string { if (preg_match(self::RGB_REGEXP, $rgb, $matches) === 1) { @@ -1209,4 +1313,22 @@ class Html return trim($rgb, '# '); } + + /** + * Transform HTML sizes (pt, px) in pixels. + */ + protected static function convertHtmlSize(string $size): float + { + // pt + if (false !== strpos($size, 'pt')) { + return Converter::pointToPixel((float) str_replace('pt', '', $size)); + } + + // px + if (false !== strpos($size, 'px')) { + return (float) str_replace('px', '', $size); + } + + return (float) $size; + } } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Shared/Validate.php b/vendor/phpoffice/phpword/src/PhpWord/Shared/Validate.php index 0967b569..faa7df0d 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Shared/Validate.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Shared/Validate.php @@ -1,4 +1,5 @@ tempDir . DIRECTORY_SEPARATOR . $filenameParts['basename'], 'wb'); - fwrite($handle, $contents); - fclose($handle); + if ($handle) { + fwrite($handle, $contents); + fclose($handle); + } // Add temp file to zip $filename = $this->tempDir . DIRECTORY_SEPARATOR . $filenameParts['basename']; @@ -420,4 +423,15 @@ class ZipArchive return ($listIndex > -1) ? $listIndex : false; } + + /** + * Add an empty directory to the zip archive (emulate \ZipArchive). + * + * @param string $dirname Directory name to add to the zip archive + */ + public function addEmptyDir(string $dirname): bool + { + // Create a directory entry by adding an empty file with trailing slash + return $this->addFromString(rtrim($dirname, '/') . '/', ''); + } } diff --git a/vendor/phpoffice/phpword/src/PhpWord/SimpleType/LineSpacingRule.php b/vendor/phpoffice/phpword/src/PhpWord/SimpleType/LineSpacingRule.php index 833b6269..648b8f93 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/SimpleType/LineSpacingRule.php +++ b/vendor/phpoffice/phpword/src/PhpWord/SimpleType/LineSpacingRule.php @@ -1,4 +1,5 @@ color; } @@ -682,7 +681,7 @@ class Font extends AbstractStyle * * @param string $value * - * @return \PhpOffice\PhpWord\Style\Table + * @return Table */ public function setBgColor($value = null) { @@ -812,7 +811,7 @@ class Font extends AbstractStyle /** * Get paragraph style. * - * @return \PhpOffice\PhpWord\Style\Paragraph + * @return Paragraph */ public function getParagraph() { @@ -860,7 +859,7 @@ class Font extends AbstractStyle /** * Get shading. * - * @return \PhpOffice\PhpWord\Style\Shading + * @return Shading */ public function getShading() { @@ -884,7 +883,7 @@ class Font extends AbstractStyle /** * Get language. * - * @return null|\PhpOffice\PhpWord\Style\Language + * @return null|Language */ public function getLang() { diff --git a/vendor/phpoffice/phpword/src/PhpWord/Style/Frame.php b/vendor/phpoffice/phpword/src/PhpWord/Style/Frame.php index 45fc583e..016722f3 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Style/Frame.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Style/Frame.php @@ -1,4 +1,5 @@ left; } /** * Set left. - * - * @param float|int $value - * - * @return self */ - public function setLeft($value) + public function setLeft(?float $value): self { - $this->left = $this->setNumericVal($value, $this->left); + $this->left = $this->setNumericVal($value); return $this; } /** * Get right. - * - * @return float|int */ - public function getRight() + public function getRight(): ?float { return $this->right; } /** * Set right. - * - * @param float|int $value - * - * @return self */ - public function setRight($value) + public function setRight(?float $value): self { - $this->right = $this->setNumericVal($value, $this->right); + $this->right = $this->setNumericVal($value); return $this; } /** * Get first line. - * - * @return float|int */ - public function getFirstLine() + public function getFirstLine(): ?float { return $this->firstLine; } /** * Set first line. - * - * @param float|int $value - * - * @return self */ - public function setFirstLine($value) + public function setFirstLine(?float $value): self { - $this->firstLine = $this->setNumericVal($value, $this->firstLine); + $this->firstLine = $this->setNumericVal($value); + + return $this; + } + + /** + * Get first line chars. + */ + public function getFirstLineChars(): int + { + return $this->firstLineChars; + } + + /** + * Set first line chars. + */ + public function setFirstLineChars(int $value): self + { + $this->firstLineChars = $this->setIntVal($value, $this->firstLineChars); return $this; } /** * Get hanging. - * - * @return float|int */ - public function getHanging() + public function getHanging(): ?float { return $this->hanging; } /** * Set hanging. - * - * @param float|int $value - * - * @return self */ - public function setHanging($value = null) + public function setHanging(?float $value = null): self { - $this->hanging = $this->setNumericVal($value, $this->hanging); + $this->hanging = $this->setNumericVal($value); return $this; } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Style/LineNumbering.php b/vendor/phpoffice/phpword/src/PhpWord/Style/LineNumbering.php index 61a98dc8..95267a29 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Style/LineNumbering.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Style/LineNumbering.php @@ -1,4 +1,5 @@ numId; } /** * Set Id. - * - * @param int $value - * - * @return self */ - public function setNumId($value) + public function setNumId(int $value): self { $this->numId = $this->setIntVal($value, $this->numId); @@ -78,22 +73,16 @@ class Numbering extends AbstractStyle /** * Get multilevel type. - * - * @return string */ - public function getType() + public function getType(): ?string { return $this->type; } /** * Set multilevel type. - * - * @param string $value - * - * @return self */ - public function setType($value) + public function setType(string $value): self { $enum = ['singleLevel', 'multilevel', 'hybridMultilevel']; $this->type = $this->setEnumVal($value, $enum, $this->type); @@ -106,19 +95,15 @@ class Numbering extends AbstractStyle * * @return NumberingLevel[] */ - public function getLevels() + public function getLevels(): array { return $this->levels; } /** * Set multilevel type. - * - * @param array $values - * - * @return self */ - public function setLevels($values) + public function setLevels(array $values): self { if (is_array($values)) { foreach ($values as $key => $value) { diff --git a/vendor/phpoffice/phpword/src/PhpWord/Style/Paper.php b/vendor/phpoffice/phpword/src/PhpWord/Style/Paper.php index 3a340bda..c59ea42d 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Style/Paper.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Style/Paper.php @@ -1,4 +1,5 @@ getChildStyleValue($this->indentation, 'hanging'); + } + /** * Get indentation. * - * @return null|\PhpOffice\PhpWord\Style\Indentation + * @deprecated 1.4.0 Use getIndentLeft */ - public function getIndentation() + public function getIndent(): ?float + { + return $this->getChildStyleValue($this->indentation, 'left'); + } + + /** + * Get indentation. + */ + public function getIndentation(): ?Indentation { return $this->indentation; } /** - * Set shading. - * - * @param mixed $value - * - * @return self + * Get firstLine. */ - public function setIndentation($value = null) + public function getIndentFirstLine(): ?float { + return $this->getChildStyleValue($this->indentation, 'firstLine'); + } + + /** + * Get left indentation. + */ + public function getIndentLeft(): ?float + { + return $this->getChildStyleValue($this->indentation, 'left'); + } + + /** + * Get right indentation. + */ + public function getIndentRight(): ?float + { + return $this->getChildStyleValue($this->indentation, 'right'); + } + + /** + * Set hanging. + * + * @deprecated 1.4.0 Use setIndentHanging + */ + public function setHanging(?float $value = null): self + { + return $this->setIndentation(['hanging' => $value]); + } + + /** + * Set indentation. + * + * @deprecated 1.4.0 Use setIndentLeft + */ + public function setIndent(?float $value = null): self + { + return $this->setIndentation(['left' => $value]); + } + + /** + * Set indentation. + * + * @param array{ + * left?:null|float|int|numeric-string, + * right?:null|float|int|numeric-string, + * hanging?:null|float|int|numeric-string, + * firstLine?:null|float|int|numeric-string + * } $value + */ + public function setIndentation(array $value = []): self + { + $value = array_map(function ($indent) { + if (is_string($indent) || is_numeric($indent)) { + $indent = $this->setFloatVal($indent); + } + + return $indent; + }, $value); $this->setObjectVal($value, 'Indentation', $this->indentation); return $this; } /** - * Get indentation. - * - * @return int + * Set hanging indentation. */ - public function getIndent() - { - return $this->getChildStyleValue($this->indentation, 'left'); - } - - /** - * Set indentation. - * - * @param int $value - * - * @return self - */ - public function setIndent($value = null) - { - return $this->setIndentation(['left' => $value]); - } - - /** - * Get hanging. - * - * @return int - */ - public function getHanging() - { - return $this->getChildStyleValue($this->indentation, 'hanging'); - } - - /** - * Set hanging. - * - * @param int $value - * - * @return self - */ - public function setHanging($value = null) + public function setIndentHanging(?float $value = null): self { return $this->setIndentation(['hanging' => $value]); } + /** + * Set firstline indentation. + */ + public function setIndentFirstLine(?float $value = null): self + { + return $this->setIndentation(['firstLine' => $value]); + } + + /** + * Set firstlineChars indentation. + */ + public function setIndentFirstLineChars(int $value = 0): self + { + return $this->setIndentation(['firstLineChars' => $value]); + } + + /** + * Set left indentation. + */ + public function setIndentLeft(?float $value = null): self + { + return $this->setIndentation(['left' => $value]); + } + + /** + * Set right indentation. + */ + public function setIndentRight(?float $value = null): self + { + return $this->setIndentation(['right' => $value]); + } + /** * Get spacing. * - * @return \PhpOffice\PhpWord\Style\Spacing + * @return Spacing * * @todo Rename to getSpacing in 1.0 */ @@ -498,7 +565,7 @@ class Paragraph extends Border * * @param string $value Possible values are defined in LineSpacingRule * - * @return \PhpOffice\PhpWord\Style\Paragraph + * @return Paragraph */ public function setSpacingLineRule($value) { @@ -686,7 +753,7 @@ class Paragraph extends Border /** * Get tabs. * - * @return \PhpOffice\PhpWord\Style\Tab[] + * @return Tab[] */ public function getTabs() { @@ -712,7 +779,7 @@ class Paragraph extends Border /** * Get shading. * - * @return \PhpOffice\PhpWord\Style\Shading + * @return Shading */ public function getShading() { diff --git a/vendor/phpoffice/phpword/src/PhpWord/Style/Section.php b/vendor/phpoffice/phpword/src/PhpWord/Style/Section.php index 3b08aa5f..1f8e1f5c 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Style/Section.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Style/Section.php @@ -1,4 +1,5 @@ element; - $text = $this->parentWriter->escapeHTML($element->getText()); + $text = $this->parentWriter->escapeHTML($element->getText() ?? ''); if (!$this->withoutP && !trim($text)) { $text = ' '; } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Element/TextRun.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Element/TextRun.php index abae7d30..faeeb22e 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Element/TextRun.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/HTML/Element/TextRun.php @@ -1,4 +1,5 @@ parts) as $partName) { $partClass = static::class . '\\Part\\' . $partName; if (class_exists($partClass)) { - /** @var \PhpOffice\PhpWord\Writer\ODText\Part\AbstractPart $partObject Type hint */ + /** @var AbstractPart $partObject Type hint */ $partObject = new $partClass(); $partObject->setParentWriter($this); $this->writerParts[strtolower($partName)] = $partObject; diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Formula.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Formula.php index ddb1d81a..2c7ce3aa 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Formula.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/Formula.php @@ -1,4 +1,5 @@ writeAttribute('text:change-id', $element->getTrackChange()->getElementId()); $xmlWriter->endElement(); } else { - if (empty($fontStyle)) { - if (empty($paragraphStyle)) { - if (!$this->withoutP) { - $xmlWriter->writeAttribute('text:style-name', 'Normal'); - } - } elseif (is_string($paragraphStyle)) { - if (!$this->withoutP) { - $xmlWriter->writeAttribute('text:style-name', $paragraphStyle); - } + if (empty($paragraphStyle)) { + if (!$this->withoutP) { + $xmlWriter->writeAttribute('text:style-name', 'Normal'); } - $this->writeChangeInsertion(true, $element->getTrackChange()); - $this->replaceTabs($element->getText(), $xmlWriter); - $this->writeChangeInsertion(false, $element->getTrackChange()); - } else { - if (empty($paragraphStyle)) { - if (!$this->withoutP) { - $xmlWriter->writeAttribute('text:style-name', 'Normal'); - } - } elseif (is_string($paragraphStyle)) { - if (!$this->withoutP) { - $xmlWriter->writeAttribute('text:style-name', $paragraphStyle); - } + } elseif (is_string($paragraphStyle)) { + if (!$this->withoutP) { + $xmlWriter->writeAttribute('text:style-name', $paragraphStyle); } + } + + if (!empty($fontStyle)) { // text:span $xmlWriter->startElement('text:span'); if (is_string($fontStyle)) { $xmlWriter->writeAttribute('text:style-name', $fontStyle); } - $this->writeChangeInsertion(true, $element->getTrackChange()); - $this->replaceTabs($element->getText(), $xmlWriter); - $this->writeChangeInsertion(false, $element->getTrackChange()); + } + + $this->writeChangeInsertion(true, $element->getTrackChange()); + $this->replaceTabs($element->getText(), $xmlWriter); + $this->writeChangeInsertion(false, $element->getTrackChange()); + + if (!empty($fontStyle)) { $xmlWriter->endElement(); } } @@ -96,35 +89,6 @@ class Text extends AbstractElement } } - private function replacetabs($text, $xmlWriter): void - { - if (preg_match('/^ +/', $text, $matches)) { - $num = strlen($matches[0]); - $xmlWriter->startElement('text:s'); - $xmlWriter->writeAttributeIf($num > 1, 'text:c', "$num"); - $xmlWriter->endElement(); - $text = preg_replace('/^ +/', '', $text); - } - preg_match_all('/([\\s\\S]*?)(\\t| +| ?$)/', $text, $matches, PREG_SET_ORDER); - foreach ($matches as $match) { - $this->writeText($match[1]); - if ($match[2] === '') { - break; - } elseif ($match[2] === "\t") { - $xmlWriter->writeElement('text:tab'); - } elseif ($match[2] === ' ') { - $xmlWriter->writeElement('text:s'); - - break; - } else { - $num = strlen($match[2]); - $xmlWriter->startElement('text:s'); - $xmlWriter->writeAttributeIf($num > 1, 'text:c', "$num"); - $xmlWriter->endElement(); - } - } - } - private function writeChangeInsertion($start = true, ?TrackChange $trackChange = null): void { if ($trackChange == null || $trackChange->getChangeType() != TrackChange::INSERTED) { diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/TextBreak.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/TextBreak.php index 1bfe3988..1a697007 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/TextBreak.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Element/TextBreak.php @@ -1,4 +1,5 @@ startElement('style:text-properties'); - $xmlWriter->writeAttribute('style:use-window-font-color', 'true'); + $xmlWriter->writeAttribute('style:use-window-font-color', 'false'); $xmlWriter->writeAttribute('style:font-name', Settings::getDefaultFontName()); $xmlWriter->writeAttribute('fo:font-size', Settings::getDefaultFontSize() . 'pt'); $xmlWriter->writeAttribute('fo:language', $latinLang[0]); $xmlWriter->writeAttribute('fo:country', $latinLang[1]); + $xmlWriter->writeAttribute('fo:color', '#' . Settings::getDefaultFontColor()); $xmlWriter->writeAttribute('style:letter-kerning', 'true'); $xmlWriter->writeAttribute('style:font-name-asian', Settings::getDefaultFontName() . '2'); $xmlWriter->writeAttribute('style:font-size-asian', Settings::getDefaultFontSize() . 'pt'); diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Style/Image.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Style/Image.php index 79ddfc50..56c4f57a 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Style/Image.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/ODText/Style/Image.php @@ -1,4 +1,5 @@ element; $elementClass = str_replace('\\Writer\\RTF', '', static::class); - if (!$element instanceof $elementClass || !is_string($element->getText())) { + if (!$element instanceof $elementClass) { return ''; } + $textToWrite = $element->getText(); + if ($textToWrite instanceof \PhpOffice\PhpWord\Element\TextRun) { + $textToWrite = $textToWrite->getText(); // gets text from TextRun + } + $this->getStyles(); $content = ''; @@ -82,7 +88,7 @@ class Title extends Text $content .= '{'; $content .= $this->writeFontStyle(); - $content .= $this->writeText($element->getText()); + $content .= $this->writeText($textToWrite); $content .= '}'; $content .= $this->writeClosing(); $content .= $endout; diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Style/Border.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Style/Border.php index c674170d..8f302449 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Style/Border.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/RTF/Style/Border.php @@ -1,4 +1,5 @@ xmlWriter = $xmlWriter; $this->element = $element; @@ -76,7 +75,7 @@ abstract class AbstractElement /** * Get XML Writer. * - * @return \PhpOffice\PhpWord\Shared\XMLWriter + * @return XMLWriter */ protected function getXmlWriter() { @@ -86,7 +85,7 @@ abstract class AbstractElement /** * Get element. * - * @return \PhpOffice\PhpWord\Element\AbstractElement + * @return Element */ protected function getElement() { diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/CheckBox.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/CheckBox.php index 3d7fdab1..1adf7d6e 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/CheckBox.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/CheckBox.php @@ -1,4 +1,5 @@ + */ + protected $containerWithoutP = ['TextRun', 'Footnote', 'Endnote', 'ListItemRun']; + /** * Write element. */ @@ -46,7 +52,7 @@ class Container extends AbstractElement return; } $containerClass = substr(get_class($container), strrpos(get_class($container), '\\') + 1); - $withoutP = in_array($containerClass, ['TextRun', 'Footnote', 'Endnote', 'ListItemRun']); + $withoutP = in_array($containerClass, $this->containerWithoutP); $xmlWriter = $this->getXmlWriter(); // Loop through elements @@ -62,7 +68,7 @@ class Container extends AbstractElement $writeLastTextBreak = ($containerClass == 'Cell') && ($elementClass == '' || $elementClass == 'Table'); if ($writeLastTextBreak) { $writerClass = $this->namespace . '\\TextBreak'; - /** @var \PhpOffice\PhpWord\Writer\Word2007\Element\AbstractElement $writer Type hint */ + /** @var AbstractElement $writer Type hint */ $writer = new $writerClass($xmlWriter, new TextBreakElement(), $withoutP); $writer->write(); } @@ -70,18 +76,14 @@ class Container extends AbstractElement /** * Write individual element. - * - * @param bool $withoutP - * - * @return string */ - private function writeElement(XMLWriter $xmlWriter, Element $element, $withoutP) + private function writeElement(XMLWriter $xmlWriter, Element $element, bool $withoutP): string { $elementClass = substr(get_class($element), strrpos(get_class($element), '\\') + 1); $writerClass = $this->namespace . '\\' . $elementClass; if (class_exists($writerClass)) { - /** @var \PhpOffice\PhpWord\Writer\Word2007\Element\AbstractElement $writer Type hint */ + /** @var AbstractElement $writer Type hint */ $writer = new $writerClass($xmlWriter, $element, $withoutP); $writer->setPart($this->getPart()); $writer->write(); diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/Footnote.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/Footnote.php index 77073a23..68f998e3 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/Footnote.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/Footnote.php @@ -1,4 +1,5 @@ startElement('w:r'); $xmlWriter->startElement('w:instrText'); $xmlWriter->writeAttribute('xml:space', 'preserve'); - $xmlWriter->text("PAGEREF _Toc{$rId} \\h"); + $xmlWriter->text("PAGEREF $rId \\h"); $xmlWriter->endElement(); $xmlWriter->endElement(); diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/TableAlignment.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/TableAlignment.php index c8b48644..9c0977b8 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/TableAlignment.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Element/TableAlignment.php @@ -1,4 +1,5 @@ getSalt() == null) { - $documentProtection->setSalt(openssl_random_pseudo_bytes(16)); + $documentProtection->setSalt((string) openssl_random_pseudo_bytes(16)); } $passwordHash = PasswordEncoder::hashPassword($documentProtection->getPassword(), $documentProtection->getAlgorithm(), $documentProtection->getSalt(), $documentProtection->getSpinCount()); $this->settings['w:documentProtection'] = [ diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Part/Styles.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Part/Styles.php index 2112fd3c..edf0314c 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Part/Styles.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Part/Styles.php @@ -1,4 +1,5 @@ getParentWriter()->getPhpWord(); $fontName = $phpWord->getDefaultFontName(); + $asianFontName = $phpWord->getDefaultAsianFontName(); $fontSize = $phpWord->getDefaultFontSize(); + $fontColor = $phpWord->getDefaultFontColor(); $language = $phpWord->getSettings()->getThemeFontLang(); $latinLanguage = ($language == null || $language->getLatin() === null) ? 'en-US' : $language->getLatin(); @@ -94,9 +97,12 @@ class Styles extends AbstractPart $xmlWriter->startElement('w:rFonts'); $xmlWriter->writeAttribute('w:ascii', $fontName); $xmlWriter->writeAttribute('w:hAnsi', $fontName); - $xmlWriter->writeAttribute('w:eastAsia', $fontName); + $xmlWriter->writeAttribute('w:eastAsia', $asianFontName); $xmlWriter->writeAttribute('w:cs', $fontName); $xmlWriter->endElement(); // w:rFonts + $xmlWriter->startElement('w:color'); + $xmlWriter->writeAttribute('w:val', $fontColor); + $xmlWriter->endElement(); $xmlWriter->startElement('w:sz'); $xmlWriter->writeAttribute('w:val', $fontSize * 2); $xmlWriter->endElement(); // w:sz @@ -125,7 +131,7 @@ class Styles extends AbstractPart if (isset($styles['Normal'])) { $normalStyle = $styles['Normal']; // w:pPr - if ($normalStyle instanceof Fontstyle && $normalStyle->getParagraph() != null) { + if ($normalStyle instanceof FontStyle && $normalStyle->getParagraph() != null) { $styleWriter = new ParagraphStyleWriter($xmlWriter, $normalStyle->getParagraph()); $styleWriter->write(); } elseif ($normalStyle instanceof ParagraphStyle) { diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Part/Theme.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Part/Theme.php index ad57d664..a70c248d 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Part/Theme.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Part/Theme.php @@ -1,4 +1,5 @@ write(); } diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Font.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Font.php index 1f6db009..623e8d5e 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Font.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Font.php @@ -1,4 +1,5 @@ startElement('w:numPr'); diff --git a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Section.php b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Section.php index 3bdeafa4..cb2c9a08 100644 --- a/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Section.php +++ b/vendor/phpoffice/phpword/src/PhpWord/Writer/Word2007/Style/Section.php @@ -1,4 +1,5 @@ info['type']; + header("content-type: image/{$type}"); + switch ($type) { + case 'png': + imagepng($this->im); + break; + case 'gif': + imagegif($this->im); + break; + case 'jpeg': + imagejpeg($this->im); + break; + case 'wbmp': + imagewbmp($this->im); + break; + } + exit; + } + /** * 返回图像宽度 * @return int 图像宽度 @@ -257,16 +282,17 @@ class Image public function crop($w, $h, $x = 0, $y = 0, $width = null, $height = null) { //设置保存尺寸 + empty($width) && $width = $w; empty($height) && $height = $h; do { //创建新图像 - $img = imagecreatetruecolor($width, $height); + $img = imagecreatetruecolor((int) $width, (int) $height); // 调整默认颜色 $color = imagecolorallocate($img, 255, 255, 255); imagefill($img, 0, 0, $color); //裁剪 - imagecopyresampled($img, $this->im, 0, 0, $x, $y, $width, $height, $w, $h); + imagecopyresampled($img, $this->im, 0, 0, (int) $x, (int) $y, (int) $width, (int) $height, $w, $h); imagedestroy($this->im); //销毁原图 //设置新图像 $this->im = $img; @@ -302,18 +328,18 @@ class Image $scale = min($width / $w, $height / $h); //设置缩略图的坐标及宽度和高度 $x = $y = 0; - $width = $w * $scale; - $height = $h * $scale; + $width = (int) ($w * $scale); + $height = (int) ($h * $scale); break; /* 居中裁剪 */ case self::THUMB_CENTER: //计算缩放比例 $scale = max($width / $w, $height / $h); //设置缩略图的坐标及宽度和高度 - $w = $width / $scale; - $h = $height / $scale; - $x = ($this->info['width'] - $w) / 2; - $y = ($this->info['height'] - $h) / 2; + $w = (int) ($width / $scale); + $h = (int) ($height / $scale); + $x = (int) (($this->info['width'] - $w) / 2); + $y = (int) (($this->info['height'] - $h) / 2); break; /* 左上角裁剪 */ case self::THUMB_NORTHWEST: @@ -321,18 +347,18 @@ class Image $scale = max($width / $w, $height / $h); //设置缩略图的坐标及宽度和高度 $x = $y = 0; - $w = $width / $scale; - $h = $height / $scale; + $w = (int) ($width / $scale); + $h = (int) ($height / $scale); break; /* 右下角裁剪 */ case self::THUMB_SOUTHEAST: //计算缩放比例 $scale = max($width / $w, $height / $h); //设置缩略图的坐标及宽度和高度 - $w = $width / $scale; - $h = $height / $scale; - $x = $this->info['width'] - $w; - $y = $this->info['height'] - $h; + $w = (int) ($width / $scale); + $h = (int) ($height / $scale); + $x = (int) ($this->info['width'] - $w); + $y = (int) ($this->info['height'] - $h); break; /* 填充 */ case self::THUMB_FILLED: @@ -343,12 +369,12 @@ class Image $scale = min($width / $w, $height / $h); } //设置缩略图的坐标及宽度和高度 - $neww = $w * $scale; - $newh = $h * $scale; + $neww = (int) ($w * $scale); + $newh = (int) ($h * $scale); $x = $this->info['width'] - $w; $y = $this->info['height'] - $h; - $posx = ($width - $w * $scale) / 2; - $posy = ($height - $h * $scale) / 2; + $posx = (int) (($width - $w * $scale) / 2); + $posy = (int) (($height - $h * $scale) / 2); do { //创建新图像 $img = imagecreatetruecolor($width, $height); @@ -377,9 +403,9 @@ class Image /** * 添加水印 * - * @param string $source 水印图片路径 - * @param int $locate 水印位置 - * @param int $alpha 透明度 + * @param string $source 水印图片路径 + * @param int|array $locate 水印位置 + * @param int $alpha 透明度 * @return $this */ public function water($source, $locate = self::WATER_SOUTHEAST, $alpha = 100) @@ -457,10 +483,10 @@ class Image // 调整默认颜色 $color = imagecolorallocate($src, 255, 255, 255); imagefill($src, 0, 0, $color); - imagecopy($src, $this->im, 0, 0, $x, $y, $info[0], $info[1]); + imagecopy($src, $this->im, 0, 0, (int) $x, (int) $y, $info[0], $info[1]); imagecopy($src, $water, 0, 0, 0, 0, $info[0], $info[1]); - imagecopymerge($this->im, $src, $x, $y, 0, 0, $info[0], $info[1], $alpha); - //销毁零时图片资源 + imagecopymerge($this->im, $src, (int) $x, (int) $y, 0, 0, $info[0], $info[1], $alpha); + //销毁临时图片资源 imagedestroy($src); } while (!empty($this->gif) && $this->gifNext()); //销毁水印资源 @@ -471,13 +497,13 @@ class Image /** * 图像添加文字 * - * @param string $text 添加的文字 - * @param string $font 字体路径 - * @param integer $size 字号 - * @param string $color 文字颜色 - * @param int $locate 文字写入位置 - * @param integer $offset 文字相对当前位置的偏移量 - * @param integer $angle 文字倾斜角度 + * @param string $text 添加的文字 + * @param string $font 字体路径 + * @param integer $size 字号 + * @param string $color 文字颜色 + * @param int|array $locate 文字写入位置 + * @param integer|array $offset 文字相对当前位置的偏移量 + * @param integer $angle 文字倾斜角度 * * @return $this * @throws ImageException @@ -559,6 +585,24 @@ class Image $offset = intval($offset); $ox = $oy = $offset; } + /* 图片黑白检测 */ + if ("auto" == $color) { + //X方向采集宽度:单英文字符占据宽度约为字体大小/1.6,单中文字符占据宽度约为字体大小*4/3;Y方向采集宽度:英文字符高度约为字体大小,中文会高一些。 + //使用保守宽度,以免在纯英文情况下采集区域超出图像范围,并且精度完全可以满足本功能。 + $pickX = intval(mb_strwidth($text) * ($size / 1.6)); + $pickY = $size; + + $brightness = 0; + for ($i = $x + $ox; $i < $pickX + $x + $ox; $i++) { + //根据文字基线确定要进行遍历的像素 + for ($j = $y + $oy - $pickY; $j < $y + $oy; $j++) { + //基线修正 + $brightness += self::getBrightnessOfPixel($i, $j); + } + } + + $color = $brightness / ($pickX * $pickY) > 127 ? '#00000000' : '#ffffffff'; + } /* 设置颜色 */ if (is_string($color) && 0 === strpos($color, '#')) { $color = str_split(substr($color, 1), 2); @@ -577,6 +621,22 @@ class Image return $this; } + /** + * 获取图片指定像素点的亮度值 + */ + private function getBrightnessOfPixel($x, $y) + { + $rgb = imagecolorat($this->im, $x, $y); + $r = ($rgb >> 16) & 0xFF; + $g = ($rgb >> 8) & 0xFF; + $b = $rgb & 0xFF; + + //红绿蓝能量不同,亮度不同,对应系数也不同(参考https://www.w3.org/TR/AERT/#color-contrast) + $brightness = intval($r * 0.299 + $g * 0.587 + $b * 0.114); + + return $brightness; + } + /** * 切换到GIF的下一帧并保存当前帧 */ diff --git a/vendor/topthink/think-image/src/image/gif/Encoder.php b/vendor/topthink/think-image/src/image/gif/Encoder.php index 688f7a0a..763a1fd8 100644 --- a/vendor/topthink/think-image/src/image/gif/Encoder.php +++ b/vendor/topthink/think-image/src/image/gif/Encoder.php @@ -48,7 +48,7 @@ class Encoder for ($i = 0; $i < count($GIF_src); $i++) { if (strtolower($GIF_mod) == "url") { $this->BUF[] = fread(fopen($GIF_src[$i], "rb"), filesize($GIF_src[$i])); - } else if (strtolower($GIF_mod) == "bin") { + } elseif (strtolower($GIF_mod) == "bin") { $this->BUF[] = $GIF_src[$i]; } else { printf("%s: %s ( %s )!", $this->VER, $this->ERR['ERR02'], $GIF_mod); @@ -74,7 +74,7 @@ class Encoder } $this->addHeader(); for ($i = 0; $i < count($this->BUF); $i++) { - $this->addFrames($i, $GIF_dly[$i]); + isset($GIF_dly[$i]) && $this->addFrames($i, $GIF_dly[$i]); } $this->addFooter(); } @@ -219,4 +219,4 @@ class Encoder { return ($this->GIF); } -} \ No newline at end of file +} diff --git a/vendor/topthink/think-image/src/image/gif/Gif.php b/vendor/topthink/think-image/src/image/gif/Gif.php index b8909158..f803db0c 100644 --- a/vendor/topthink/think-image/src/image/gif/Gif.php +++ b/vendor/topthink/think-image/src/image/gif/Gif.php @@ -85,4 +85,4 @@ class Gif $gif = new Encoder($this->frames, $this->delays, 0, 2, 0, 0, 0, 'bin'); file_put_contents($pathname, $gif->getAnimation()); } -} \ No newline at end of file +} diff --git a/vendor/topthink/think-image/tests/InfoTest.php b/vendor/topthink/think-image/tests/InfoTest.php index 22132ca4..97ff4861 100644 --- a/vendor/topthink/think-image/tests/InfoTest.php +++ b/vendor/topthink/think-image/tests/InfoTest.php @@ -37,7 +37,6 @@ class InfoTest extends TestCase $this->assertEquals([800, 600], $image->size()); } - public function testPng() { $image = Image::open($this->getPng()); @@ -57,4 +56,4 @@ class InfoTest extends TestCase $this->assertEquals('image/gif', $image->mime()); $this->assertEquals([380, 216], $image->size()); } -} \ No newline at end of file +} diff --git a/vendor/topthink/think-image/tests/TextTest.php b/vendor/topthink/think-image/tests/TextTest.php index 04506a27..87f04674 100644 --- a/vendor/topthink/think-image/tests/TextTest.php +++ b/vendor/topthink/think-image/tests/TextTest.php @@ -55,4 +55,4 @@ class TextTest extends TestCase @unlink($pathname); } -} \ No newline at end of file +} diff --git a/vendor/topthink/think-image/tests/ThumbTest.php b/vendor/topthink/think-image/tests/ThumbTest.php index 07113c8e..98354c45 100644 --- a/vendor/topthink/think-image/tests/ThumbTest.php +++ b/vendor/topthink/think-image/tests/ThumbTest.php @@ -103,7 +103,6 @@ class ThumbTest extends TestCase @unlink($pathname); } - public function testPng() { $pathname = TEST_PATH . 'tmp/thumb.png'; @@ -281,4 +280,4 @@ class ThumbTest extends TestCase @unlink($pathname); } -} \ No newline at end of file +}