Files
tougao/application/common/ReferenceStackingStatsService.php
wyn 8785610e6d 参考文献作者堆叠
参考文献相关性检测
作者ai写作辅助检测工作
2026-07-15 10:49:05 +08:00

835 lines
29 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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/");
}
}