参考文献作者堆叠

参考文献相关性检测
作者ai写作辅助检测工作
This commit is contained in:
wyn
2026-07-15 10:49:05 +08:00
parent da71dfc04e
commit 8785610e6d
27 changed files with 10008 additions and 204 deletions

View File

@@ -0,0 +1,834 @@
<?php
namespace app\common;
use think\Db;
/**
* 参考文献引用堆叠统计:同刊、同作者、自引(实时计算,不入库)
*/
class ReferenceStackingStatsService
{
const DETAIL_JOURNAL = 'journal';
const DETAIL_AUTHOR = 'author';
const THRESHOLD_SAME_AUTHOR = 0.15;
const THRESHOLD_SAME_JOURNAL = 0.20;
const THRESHOLD_SELF_CITATION = 0.10;
/** @var ReferenceCheckService */
private $refUtil;
/** @var CrossrefService */
private $crossref;
/** @var ReferenceAuthorIdentityService */
private $identity;
public function __construct()
{
$this->refUtil = new ReferenceCheckService();
$this->crossref = new CrossrefService([
'mailto' => trim((string)\think\Env::get('crossref_mailto', '')),
]);
$this->identity = new ReferenceAuthorIdentityService();
}
/**
* 按阈值规则实时统计:同作者(>15%)、同刊(>20%)、自引(>10%)
*
* @param int $pArticleId
* @return array
*/
public function getThresholdStackingByPArticleId($pArticleId)
{
$pArticleId = intval($pArticleId);
if ($pArticleId <= 0) {
throw new \InvalidArgumentException('p_article_id is required');
}
return $this->formatThresholdStackingReport($this->compute($pArticleId));
}
/**
* @param array $full compute/analyze 或 getStored 的完整结果
*/
public function formatThresholdStackingReport(array $full)
{
$total = max(1, intval($full['total_references'] ?? 0));
$referMap = (array)($full['refer_map'] ?? []);
if (empty($referMap) && intval($full['p_article_id'] ?? 0) > 0) {
$referMap = $this->loadReferMapByPArticleId(intval($full['p_article_id']));
}
$authorItems = [];
foreach ((array)($full['author_details'] ?? []) as $item) {
$count = intval($item['ref_count'] ?? 0);
$ratio = $count / $total;
if ($ratio <= self::THRESHOLD_SAME_AUTHOR) {
continue;
}
$pReferIds = array_values((array)($item['p_refer_ids'] ?? []));
$authorItems[] = [
'author_name' => (string)($item['group_name'] ?? ''),
'orcid' => (string)($item['orcid'] ?? ''),
'cite_count' => $count,
'cite_ratio' => round($ratio, 4),
'threshold' => self::THRESHOLD_SAME_AUTHOR,
'exceeded' => true,
'p_refer_ids' => $pReferIds,
'reference_nos' => array_values((array)($item['reference_nos'] ?? [])),
'references' => $this->buildReferBriefs($pReferIds, $referMap),
];
}
$journalItems = [];
foreach ((array)($full['journal_details'] ?? []) as $item) {
$count = intval($item['ref_count'] ?? 0);
$ratio = $count / $total;
if ($ratio <= self::THRESHOLD_SAME_JOURNAL) {
continue;
}
$pReferIds = array_values((array)($item['p_refer_ids'] ?? []));
$journalItems[] = [
'journal_name' => (string)($item['group_name'] ?? ''),
'cite_count' => $count,
'cite_ratio' => round($ratio, 4),
'threshold' => self::THRESHOLD_SAME_JOURNAL,
'exceeded' => true,
'p_refer_ids' => $pReferIds,
'reference_nos' => array_values((array)($item['reference_nos'] ?? [])),
'references' => $this->buildReferBriefs($pReferIds, $referMap),
];
}
$selfDetails = (array)($full['self_citation_details'] ?? []);
$selfCount = count($selfDetails);
$selfRatio = $selfCount / $total;
$selfItems = [];
foreach ($selfDetails as $item) {
$pReferId = intval($item['p_refer_id'] ?? 0);
$selfItems[] = [
'manuscript_author' => (string)($item['matched_manuscript_author'] ?? ''),
'manuscript_orcid' => (string)($item['matched_orcid'] ?? ''),
'matched_refer_author' => (string)($item['matched_refer_author'] ?? ''),
'reference_no' => intval($item['reference_no'] ?? 0),
'p_refer_id' => $pReferId,
'reference' => $this->buildReferBriefs([$pReferId], $referMap)[0] ?? null,
];
}
return [
'p_article_id' => intval($full['p_article_id'] ?? 0),
'article_id' => intval($full['article_id'] ?? 0),
'total_references' => intval($full['total_references'] ?? 0),
'same_author_stacking' => [
'threshold' => self::THRESHOLD_SAME_AUTHOR,
'exceeded' => !empty($authorItems),
'items' => $authorItems,
],
'same_journal_stacking' => [
'threshold' => self::THRESHOLD_SAME_JOURNAL,
'exceeded' => !empty($journalItems),
'items' => $journalItems,
],
'self_citation' => [
'threshold' => self::THRESHOLD_SELF_CITATION,
'exceeded' => $selfRatio > self::THRESHOLD_SELF_CITATION,
'cite_count' => $selfCount,
'cite_ratio' => round($selfRatio, 4),
'reference_nos' => array_values((array)($full['self_citation_reference_nos'] ?? [])),
'items' => $selfItems,
'note' => (string)($full['author_identity_note'] ?? ''),
],
'computed_at' => (string)($full['computed_at'] ?? ''),
];
}
/**
* @param int $pArticleId
* @return array
*/
public function compute($pArticleId)
{
$pArticleId = intval($pArticleId);
if ($pArticleId <= 0) {
throw new \InvalidArgumentException('p_article_id is required');
}
DbReconnectHelper::release();
$articleId = $this->resolveArticleId($pArticleId);
$refers = Db::name('production_article_refer')
->field('p_refer_id,index,author,joura,refer_type,refer_doi,doilink,refer_content,refer_frag')
->where('p_article_id', $pArticleId)
->where('state', 0)
->order('index asc')
->select();
$manuscriptAuthors = $this->identity->resolveManuscriptAuthors($pArticleId);
$ambiguousManuscriptNameKeys = $this->buildAmbiguousManuscriptNameKeys($manuscriptAuthors);
$doiCache = [];
$referMap = [];
$journalBuckets = [];
$authorBuckets = [];
$selfCitationDetails = [];
foreach ($refers as $refer) {
$refNo = intval($refer['index']) + 1;
$pReferId = intval($refer['p_refer_id']);
$referMap[$pReferId] = $refer;
$meta = $this->resolveReferMeta($refer, $doiCache);
$joura = (string)$meta['joura'];
$journalKey = $this->normalizeJournalKey($joura);
if ($journalKey !== '') {
if (!isset($journalBuckets[$journalKey])) {
$journalBuckets[$journalKey] = [
'group_key' => $journalKey,
'name' => trim(trim($joura), '.'),
'count' => 0,
'reference_nos' => [],
'p_refer_ids' => [],
];
}
$journalBuckets[$journalKey]['count']++;
$journalBuckets[$journalKey]['reference_nos'][] = $refNo;
$journalBuckets[$journalKey]['p_refer_ids'][] = $pReferId;
}
$referAuthors = $this->resolveReferAuthorsWithMeta($pReferId, $meta);
$this->accumulateAuthorBuckets($authorBuckets, $referAuthors, $refNo, $pReferId);
$matchedManuscript = $this->matchManuscriptAuthorForSelfCitation(
$referAuthors,
$manuscriptAuthors,
$ambiguousManuscriptNameKeys
);
if ($matchedManuscript !== null) {
$referAuthorNames = array_map(function ($ra) {
return (string)($ra['name'] ?? '');
}, $referAuthors);
$selfCitationDetails[] = [
'reference_no' => $refNo,
'p_refer_id' => $pReferId,
'refer_author' => implode(', ', array_filter($referAuthorNames)),
'matched_refer_author' => (string)($matchedManuscript['matched_refer_author'] ?? ''),
'matched_manuscript_author' => (string)($matchedManuscript['display_name'] ?? ''),
'matched_orcid' => (string)($matchedManuscript['orcid'] ?? ''),
'match_confidence' => ReferenceAuthorIdentityService::MATCH_FUZZY,
'meta_source' => (string)$meta['meta_source'],
];
}
}
$journalDetails = $this->formatBucketDetails($journalBuckets, self::DETAIL_JOURNAL);
$authorDetails = $this->formatAuthorBucketDetails($authorBuckets, self::DETAIL_AUTHOR);
$selfDetails = $this->formatSelfCitationDetails($selfCitationDetails);
$selfCitationRefNos = array_column($selfCitationDetails, 'reference_no');
return [
'article_id' => $articleId,
'p_article_id' => $pArticleId,
'total_references' => count($refers),
'self_citation_reference_nos' => array_values(array_unique($selfCitationRefNos)),
'journal_details' => $journalDetails,
'author_details' => $authorDetails,
'self_citation_details' => $selfDetails,
'refer_map' => $referMap,
'author_identity_note' => '同作者堆叠按 citation_name空则 display_name姓名精确匹配同名即同人本文出现重名作者如两位 Lin时跳过该姓名的自引判定。优先读 t_production_article_refer_author否则解析 refer.author。',
'computed_at' => date('Y-m-d H:i:s'),
];
}
private function formatAuthorBucketDetails(array $buckets, $detailType)
{
$list = [];
foreach ($buckets as $bucket) {
$item = [
'detail_type' => $detailType,
'group_key' => (string)($bucket['group_key'] ?? ''),
'group_name' => (string)($bucket['name'] ?? ''),
'ref_count' => intval($bucket['count'] ?? 0),
'reference_nos' => array_values((array)($bucket['reference_nos'] ?? [])),
'p_refer_ids' => array_values((array)($bucket['p_refer_ids'] ?? [])),
'match_confidence' => (string)($bucket['match_confidence'] ?? ''),
];
if (!empty($bucket['openalex_id'])) {
$item['openalex_id'] = (string)$bucket['openalex_id'];
}
if (!empty($bucket['orcid'])) {
$item['orcid'] = (string)$bucket['orcid'];
}
$list[] = $item;
}
usort($list, function ($a, $b) {
$cmp = intval($b['ref_count']) <=> intval($a['ref_count']);
if ($cmp !== 0) {
return $cmp;
}
return strcmp((string)$a['group_name'], (string)$b['group_name']);
});
return $list;
}
/**
* @return array<int, array{name:string,orcid:string}>
*/
private function resolveReferAuthorsWithMeta($pReferId, array $meta)
{
$pReferId = intval($pReferId);
$list = [];
if ($pReferId > 0) {
$rows = Db::name('production_article_refer_author')
->where('p_refer_id', $pReferId)
->order('author_seq asc, id asc')
->field('display_name,citation_name,orcid')
->select();
foreach ($rows as $row) {
$name = trim((string)($row['citation_name'] ?? ''));
if ($name === '') {
$name = trim((string)($row['display_name'] ?? ''));
}
if ($name === '') {
continue;
}
$list[] = [
'name' => $name,
'orcid' => $this->cleanOrcid($row['orcid'] ?? ''),
];
}
}
if (!empty($list)) {
return $list;
}
foreach ($this->parseAuthorStringParts((string)($meta['author'] ?? '')) as $name) {
$list[] = [
'name' => $name,
'orcid' => '',
];
}
return $list;
}
/**
* @return string[]
*/
private function parseAuthorStringParts($authorString)
{
$authorString = trim(trim((string)$authorString), '.');
if ($authorString === '') {
return [];
}
$authorString = preg_replace('/\s+et\s+al\.?\s*$/iu', '', $authorString);
$names = [];
foreach (preg_split('/,\s*/u', $authorString) as $part) {
$part = trim($part);
if ($part === '' || preg_match('/^et\s+al\.?$/iu', $part)) {
continue;
}
$names[] = $part;
}
return $names;
}
/**
* 本文多位作者姓名归一化后相同(如两位 Lin则视为歧义不参与自引匹配
*
* @return array<string,bool>
*/
private function buildAmbiguousManuscriptNameKeys(array $manuscriptAuthors)
{
$counts = [];
foreach ($manuscriptAuthors as $author) {
$key = $this->normalizeAuthorNameKey((string)($author['display_name'] ?? ''));
if ($key === '') {
continue;
}
if (!isset($counts[$key])) {
$counts[$key] = 0;
}
$counts[$key]++;
}
$ambiguous = [];
foreach ($counts as $key => $count) {
if ($count > 1) {
$ambiguous[$key] = true;
}
}
return $ambiguous;
}
/**
* @param array<int, array{name:string,orcid:string}> $referAuthors
* @param array<int, array{display_name:string,orcid:string}> $manuscriptAuthors
* @param array<string,bool> $ambiguousNameKeys
* @return array{display_name:string,orcid:string,matched_refer_author:string}|null
*/
private function matchManuscriptAuthorForSelfCitation(array $referAuthors, array $manuscriptAuthors, array $ambiguousNameKeys)
{
foreach ($referAuthors as $referAuthor) {
$referName = trim((string)($referAuthor['name'] ?? ''));
$referKey = $this->normalizeAuthorNameKey($referName);
if ($referKey === '' || !empty($ambiguousNameKeys[$referKey])) {
continue;
}
foreach ($manuscriptAuthors as $manuscriptAuthor) {
$manuscriptName = trim((string)($manuscriptAuthor['display_name'] ?? ''));
$manuscriptKey = $this->normalizeAuthorNameKey($manuscriptName);
if ($manuscriptKey !== '' && $manuscriptKey === $referKey) {
return [
'display_name' => $manuscriptName,
'orcid' => trim((string)($manuscriptAuthor['orcid'] ?? '')),
'matched_refer_author' => $referName,
];
}
}
}
return null;
}
/**
* @param array<int, array{name:string,orcid:string}> $referAuthors
*/
private function accumulateAuthorBuckets(array &$buckets, array $referAuthors, $refNo, $pReferId)
{
if (empty($referAuthors)) {
return;
}
$seenKeys = [];
foreach ($referAuthors as $author) {
$name = trim((string)($author['name'] ?? ''));
if ($name === '' || preg_match('/^et\s+al\.?$/iu', $name)) {
continue;
}
$key = $this->normalizeAuthorNameKey($name);
if ($key === '' || isset($seenKeys[$key])) {
continue;
}
$seenKeys[$key] = true;
if (!isset($buckets[$key])) {
$buckets[$key] = [
'group_key' => $key,
'name' => $name,
'orcid' => '',
'match_confidence' => ReferenceAuthorIdentityService::MATCH_FUZZY,
'count' => 0,
'reference_nos' => [],
'p_refer_ids' => [],
];
}
$orcid = trim((string)($author['orcid'] ?? ''));
if ($orcid !== '' && trim((string)($buckets[$key]['orcid'] ?? '')) === '') {
$buckets[$key]['orcid'] = $orcid;
}
$buckets[$key]['count']++;
$buckets[$key]['reference_nos'][] = $refNo;
$buckets[$key]['p_refer_ids'][] = $pReferId;
}
}
private function normalizeAuthorNameKey($name)
{
$name = trim(preg_replace('/\.+$/u', '', trim((string)$name)));
$name = preg_replace('/\s+/u', ' ', $name);
if ($name === '') {
return '';
}
return mb_strtolower($name);
}
/**
* @param int[] $pReferIds
* @param array<int,array> $referMap
* @return array<int,array{p_refer_id:int,reference_no:int,refer_text:string}>
*/
private function buildReferBriefs(array $pReferIds, array $referMap)
{
$list = [];
foreach ($pReferIds as $pReferId) {
$pReferId = intval($pReferId);
if ($pReferId <= 0 || empty($referMap[$pReferId])) {
continue;
}
$refer = $referMap[$pReferId];
$list[] = [
'p_refer_id' => $pReferId,
'reference_no' => intval($refer['index'] ?? 0) + 1,
'refer_text' => $this->referSnippet($refer),
];
}
return $list;
}
private function loadReferMapByPArticleId($pArticleId)
{
$pArticleId = intval($pArticleId);
if ($pArticleId <= 0) {
return [];
}
DbReconnectHelper::release();
$refers = Db::name('production_article_refer')
->field('p_refer_id,index,author,joura,refer_type,refer_doi,doilink,refer_content,refer_frag')
->where('p_article_id', $pArticleId)
->where('state', 0)
->order('index asc')
->select();
$map = [];
foreach ($refers as $refer) {
$map[intval($refer['p_refer_id'])] = $refer;
}
return $map;
}
private function formatBucketDetails(array $buckets, $detailType)
{
$list = [];
foreach ($buckets as $bucket) {
$list[] = [
'detail_type' => $detailType,
'group_key' => (string)($bucket['group_key'] ?? ''),
'group_name' => (string)($bucket['name'] ?? ''),
'ref_count' => intval($bucket['count'] ?? 0),
'reference_nos' => array_values((array)($bucket['reference_nos'] ?? [])),
'p_refer_ids' => array_values((array)($bucket['p_refer_ids'] ?? [])),
];
}
usort($list, function ($a, $b) {
$cmp = intval($b['ref_count']) <=> intval($a['ref_count']);
if ($cmp !== 0) {
return $cmp;
}
return strcmp((string)$a['group_name'], (string)$b['group_name']);
});
return $list;
}
private function formatSelfCitationDetails(array $items)
{
usort($items, function ($a, $b) {
return intval($a['reference_no']) <=> intval($b['reference_no']);
});
return $items;
}
/**
* 合并 refer 行已有字段、Crossref、refer_frag/refer_content 解析结果
*
* @return array{
* author:string,
* joura:string,
* author_keys:string[],
* first_author_display:string,
* meta_source:string,
* resolved:bool
* }
*/
private function resolveReferMeta(array $refer, array &$doiCache)
{
$author = trim(trim((string)($refer['author'] ?? '')), '.');
$joura = trim(trim((string)($refer['joura'] ?? '')), '.');
$sources = [];
$authorKeys = [];
if ($author !== '') {
$sources[] = 'local';
$authorKeys = $this->extractAuthorKeysFromAuthorString($author);
}
$doi = $this->refUtil->extractDoiFromRefer($refer);
$summary = null;
if ($doi !== '') {
if (!array_key_exists($doi, $doiCache)) {
try {
$doiCache[$doi] = $this->crossref->fetchWorkSummary($doi);
} catch (\Throwable $e) {
$doiCache[$doi] = null;
}
}
$summary = $doiCache[$doi];
}
if (is_array($summary)) {
$sources[] = 'crossref';
if ($joura === '' && trim((string)($summary['joura'] ?? '')) !== '') {
$joura = trim((string)$summary['joura']);
}
$crossrefKeys = $this->authorKeysFromCrossrefMessage($summary['raw'] ?? []);
if ($author === '') {
$citationAuthor = $this->crossref->getAuthorsCitation($summary['raw'] ?? [], 3);
if ($citationAuthor !== '') {
$author = $citationAuthor;
}
$authorKeys = !empty($crossrefKeys)
? $crossrefKeys
: $this->extractAuthorKeysFromAuthorString($author);
} elseif (!empty($crossrefKeys)) {
$authorKeys = array_values(array_unique(array_merge($authorKeys, $crossrefKeys)));
}
}
if ($joura === '' || $author === '') {
$fragParsed = $this->parseStructuredReferText($refer);
if (is_array($fragParsed)) {
$sources[] = 'frag';
if ($joura === '' && trim((string)($fragParsed['joura'] ?? '')) !== '') {
$joura = trim((string)$fragParsed['joura']);
}
if ($author === '' && trim((string)($fragParsed['author'] ?? '')) !== '') {
$author = trim((string)$fragParsed['author']);
$authorKeys = $this->extractAuthorKeysFromAuthorString($author);
}
}
}
$sources = array_values(array_unique($sources));
$metaSource = empty($sources) ? 'unresolved' : implode('+', $sources);
$resolved = ($joura !== '' || !empty($authorKeys));
return [
'author' => $author,
'joura' => $joura,
'author_keys' => array_values(array_unique($authorKeys)),
'first_author_display' => $this->firstAuthorDisplayFromAuthorString($author),
'meta_source' => $metaSource,
'resolved' => $resolved,
];
}
/**
* 解析 refer_frag / refer_content 中「作者.标题.期刊.年卷页」四段式结构
*
* @return array{author:string,joura:string}|null
*/
private function parseStructuredReferText(array $refer)
{
foreach (['refer_frag', 'refer_content'] as $field) {
$text = trim((string)($refer[$field] ?? ''));
if ($text === '') {
continue;
}
$text = preg_replace('/\s+Available at:.*$/is', '', $text);
$text = trim($text, " \t\n\r\0\x0B.");
if ($text === '' || mb_substr_count($text, '.') !== 3) {
continue;
}
$parts = explode('.', $text);
if (count($parts) < 4) {
continue;
}
$authorPart = trim((string)$parts[0]);
$journalPart = trim((string)$parts[2]);
if ($authorPart === '' || $journalPart === '') {
continue;
}
$bj = bekjournal($journalPart);
$joura = formateJournal(trim((string)($bj[0] ?? '')));
$author = trim(prgeAuthor($authorPart), '.');
if ($joura === '' && $author === '') {
continue;
}
return [
'author' => $author,
'joura' => $joura,
];
}
return null;
}
/**
* @return string[]
*/
private function authorKeysFromCrossrefMessage(array $message)
{
$keys = [];
if (empty($message['author']) || !is_array($message['author'])) {
return $keys;
}
foreach ($message['author'] as $author) {
if (!is_array($author)) {
continue;
}
$family = trim((string)($author['family'] ?? ''));
$given = trim((string)($author['given'] ?? ''));
if ($family === '' && $given === '') {
$org = trim((string)($author['name'] ?? ''));
if ($org !== '') {
$keys[] = $this->authorKeyFromCitationPart($org);
}
continue;
}
if ($family !== '') {
$keys[] = mb_strtoupper($family) . '|' . $this->givenToInitials($given);
}
}
return array_values(array_unique(array_filter($keys)));
}
private function referSnippet(array $refer)
{
foreach (['refer_content', 'refer_frag'] as $field) {
$text = trim((string)($refer[$field] ?? ''));
if ($text !== '') {
$text = preg_replace('/\s+/u', ' ', $text);
return mb_substr($text, 0, 240);
}
}
$doi = trim((string)($refer['refer_doi'] ?? ''));
if ($doi !== '') {
return 'DOI: ' . $doi;
}
return '';
}
private function resolveArticleId($pArticleId)
{
$row = Db::name('production_article')
->field('article_id')
->where('p_article_id', $pArticleId)
->whereIn('state', [0, 2])
->find();
return empty($row['article_id']) ? 0 : intval($row['article_id']);
}
/**
* @return string[]
*/
private function extractAuthorKeysFromAuthorString($author)
{
$author = trim(trim((string)$author), '.');
if ($author === '') {
return [];
}
$keys = [];
foreach (preg_split('/,\s*/u', $author) as $part) {
$part = trim($part);
if ($part === '' || preg_match('/^et\s+al\.?$/iu', $part)) {
continue;
}
$key = $this->authorKeyFromCitationPart($part);
if ($key !== '') {
$keys[] = $key;
}
}
return array_values(array_unique($keys));
}
private function firstAuthorDisplayFromAuthorString($author)
{
$author = trim(trim((string)$author), '.');
if ($author === '') {
return '';
}
$parts = preg_split('/,\s*/u', $author);
$first = trim((string)($parts[0] ?? ''));
if (preg_match('/^et\s+al\.?$/iu', $first)) {
return '';
}
return $first;
}
private function authorKeyFromCitationPart($part)
{
$part = trim(preg_replace('/\.+$/u', '', trim((string)$part)));
if ($part === '') {
return '';
}
$tokens = preg_split('/\s+/u', $part, -1, PREG_SPLIT_NO_EMPTY);
if (count($tokens) === 1) {
return mb_strtoupper($tokens[0]) . '|';
}
$last = array_pop($tokens);
if (preg_match('/^[A-Za-z]{1,4}$/u', $last)) {
$family = implode(' ', $tokens);
return mb_strtoupper(preg_replace('/\s+/u', ' ', trim($family))) . '|' . mb_strtoupper($last);
}
$family = $last;
$initials = '';
foreach ($tokens as $token) {
$initials .= mb_strtoupper(mb_substr($token, 0, 1));
}
return mb_strtoupper($family) . '|' . $initials;
}
private function normalizeJournalKey($joura)
{
$joura = trim(trim((string)$joura), '.');
if ($joura === '') {
return '';
}
$mapped = formateJournal($joura);
$key = mb_strtolower($mapped);
$key = preg_replace('/[^\p{L}\p{N}\s]/u', '', $key);
$key = preg_replace('/\s+/u', ' ', trim($key));
return $key;
}
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 $part) {
$first = mb_substr($part, 0, 1);
if ($first !== '') {
$initials .= mb_strtoupper($first);
}
}
return $initials;
}
private function cleanOrcid($orcid)
{
$orcid = trim((string)$orcid);
if ($orcid === '') {
return '';
}
$orcid = preg_replace('#^https?://orcid\.org/#i', '', $orcid);
return trim($orcid, " \t\n\r\0\x0B/");
}
}