Passed
Push — new-api ( 7cf340...18d26d )
by Sebastian
12:35 queued 08:17
created

Name::factory()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 29
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 22
c 0
b 0
f 0
nc 3
nop 2
dl 0
loc 29
ccs 23
cts 23
cp 1
crap 3
rs 9.568
1
<?php
2
/*
3
 * citeproc-php
4
 *
5
 * @link        http://github.com/seboettg/citeproc-php for the source repository
6
 * @copyright   Copyright (c) 2016 Sebastian Böttger.
7
 * @license     https://opensource.org/licenses/MIT
8
 */
9
10
namespace Seboettg\CiteProc\Rendering\Name;
11
12
use Seboettg\CiteProc\CiteProc;
13
use Seboettg\CiteProc\Config\RenderingMode;
14
use Seboettg\CiteProc\Exception\CiteProcException;
15
use Seboettg\CiteProc\Exception\InvalidStylesheetException;
16
use Seboettg\CiteProc\Locale\Locale;
17
use Seboettg\CiteProc\Rendering\HasParent;
18
use Seboettg\CiteProc\Rendering\Observer\RenderingObserver;
19
use Seboettg\CiteProc\Rendering\Observer\RenderingObserverTrait;
20
use Seboettg\CiteProc\Style\Options\NameOptions;
21
use Seboettg\CiteProc\Style\Options\SubsequentAuthorSubstituteRule;
22
use Seboettg\CiteProc\Styles\StylesRenderer;
23
use Seboettg\CiteProc\Util\CiteProcHelper;
24
use Seboettg\CiteProc\Util\Factory;
25
use Seboettg\CiteProc\Util\NameHelper;
26
use Seboettg\CiteProc\Util\StringHelper;
27
use SimpleXMLElement;
28
use stdClass;
29
30
/**
31
 * Class Name
32
 *
33
 * The cs:name element, an optional child element of cs:names, can be used to describe the formatting of individual
34
 * names, and the separation of names within a name variable.
35
 *
36
 * @package Seboettg\CiteProc\Rendering\Name
37
 *
38
 * @author Sebastian Böttger <[email protected]>
39
 */
40
class Name implements HasParent, RenderingObserver
41
{
42
    use RenderingObserverTrait;
43
44
    /** @var NamePart[] */
45
    protected $nameParts;
46
47
    /**
48
     * Specifies the text string used to separate names in a name variable. Default is ”, ” (e.g. “Doe, Smith”).
49
     * @var string
50
     */
51
    private $delimiter;
52
53
    /** @var Names */
54
    private $parent;
55
56
    /**
57
     * @var string
58
     */
59
    private $etAl;
60
61
    /**
62
     * @var string
63
     */
64
    private $variable;
65
66
    /** @var NameOptions[] */
67
    private $nameOptionsArray;
68
69
    /** @var NameOptions|null */
70
    private $nameOptions;
71
72
    /** @var string */
73
    private $and;
74
75
    /** @var NameOrderRenderer */
76
    private $nameOrderRenderer;
77
78
    /** @var StylesRenderer */
79
    private $stylesRenderer;
80
81
    /** @var Locale */
82
    private $locale;
83
84
    /**
85
     * @param SimpleXMLElement $node
86
     * @param Names $parent
87
     * @return Name
88
     * @throws InvalidStylesheetException
89
     */
90 118
    public static function factory(SimpleXMLElement $node, Names $parent): Name
91
    {
92 118
        $context = CiteProc::getContext();
93 118
        $nameOptionsArray[RenderingMode::CITATION] =
0 ignored issues
show
Comprehensibility Best Practice introduced by
$nameOptionsArray was never initialized. Although not strictly required by PHP, it is generally a good practice to add $nameOptionsArray = array(); before regardless.
Loading history...
94 118
            NameOptions::updateNameOptions($node, null, $parent->getNameOptions(RenderingMode::CITATION()));
95 118
        $nameOptionsArray[RenderingMode::BIBLIOGRAPHY] =
96 118
            NameOptions::updateNameOptions($node, null, $parent->getNameOptions(RenderingMode::BIBLIOGRAPHY()));
97 118
        $stylesRenderer = StylesRenderer::factory($node);
98 118
        $delimiter = (string) ($node->attributes()['delimiter'] ?? ', ');
99 118
        $name = new Name($stylesRenderer, $context->getLocale(), $nameOptionsArray, $delimiter, $parent);
100 118
        $nameParts = [];
101
102 118
        foreach ($node->children() as $child) {
103 3
            switch ($child->getName()) {
104 3
                case "name-part":
105
                    /** @var NamePart $namePart */
106 3
                    $namePart = Factory::create($child, $name);
107 3
                    $nameParts[$namePart->getName()] = $namePart;
108
            }
109
        }
110 118
        $nameOrderRenderer = new NameOrderRenderer(
111 118
            $context->getGlobalOptions(),
112 118
            $nameParts,
113 118
            $delimiter
114
        );
115 118
        $name->setNameParts($nameParts);
116 118
        $name->setNameOrderRenderer($nameOrderRenderer);
117 118
        $context->addObserver($name);
118 118
        return $name;
119
    }
120
121 118
    public function __construct(
122
        StylesRenderer $stylesRenderer,
123
        Locale $locale,
124
        array $nameOptionsArray,
125
        string $delimiter,
126
        Names $parent
127
    ) {
128 118
        $this->stylesRenderer = $stylesRenderer;
129 118
        $this->locale = $locale;
130 118
        $this->nameOptionsArray = $nameOptionsArray;
131 118
        $this->delimiter = $delimiter;
132 118
        $this->parent = $parent;
133 118
        $this->initObserver();
134 118
    }
135
136
    /**
137
     * @param stdClass $data
138
     * @param string $var
139
     * @param int|null $citationNumber
140
     * @return string
141
     * @throws CiteProcException
142
     */
143 105
    public function render(stdClass $data, string $var, ?int $citationNumber = null)
144
    {
145 105
        $this->nameOptions = $this->nameOptionsArray[(string)CiteProc::getContext()->getMode()];
146 105
        $this->nameOrderRenderer->setNameOptions($this->nameOptions);
147 105
        $this->delimiter = $this->nameOptions->getNameDelimiter() ?? $this->delimiter;
148 105
        $this->variable = $var;
149 105
        $name = $data->{$var};
150 105
        if ("text" === $this->nameOptions->getAnd()) {
151 23
            $this->and = $this->locale->filter('terms', 'and')->single;
152 83
        } elseif ('symbol' === $this->nameOptions->getAnd()) {
153 30
            $this->and = '&#38;';
154
        }
155
156 105
        $resultNames = $this->handleSubsequentAuthorSubstitution($name, $citationNumber);
157
158 105
        if (empty($resultNames)) {
159 2
            return $this->citationData->getSubsequentAuthorSubstitute();
160
        }
161
162 105
        $resultNames = $this->prepareAbbreviation($resultNames);
163
164
        /* When set to “true” (the default is “false”), name lists truncated by et-al abbreviation are followed by
165
        the name delimiter, the ellipsis character, and the last name of the original name list. This is only
166
        possible when the original name list has at least two more names than the truncated name list (for this
167
        the value of et-al-use-first/et-al-subsequent-min must be at least 2 less than the value of
168
        et-al-min/et-al-subsequent-use-first). */
169 105
        if ("symbol" !== $this->nameOptions->getAnd() && $this->nameOptions->isEtAlUseLast()) {
170 3
            $this->and = "…"; // set "and"
171 3
            $this->etAl = null; //reset $etAl;
172
        }
173
174
        /* add "and" */
175 105
        $this->addAnd($resultNames);
176
177 105
        $text = $this->renderDelimiterPrecedesLast($resultNames);
178
179 105
        if (empty($text)) {
180 65
            $text = implode($this->delimiter, $resultNames);
181
        }
182
183 105
        $text = $this->appendEtAl($name, $text, $resultNames);
184
185
        /* A third value, “count”, returns the total number of names that would otherwise be rendered by the use of the
186
        cs:names element (taking into account the effects of et-al abbreviation and editor/translator collapsing),
187
        which allows for advanced sorting. */
188 105
        if ($this->nameOptions->getForm() == 'count') {
189 5
            return (int) count($resultNames);
190
        }
191
192 100
        return $text;
193
    }
194
195
    /**
196
     * @param stdClass $nameItem
197
     * @param int $rank
198
     * @return string
199
     * @throws CiteProcException
200
     */
201 105
    private function formatName(stdClass $nameItem, int $rank): string
202
    {
203 105
        $nameObj = $this->cloneNamePOSC($nameItem);
204
205 105
        $useInitials = $this->nameOptions->isInitialize() &&
0 ignored issues
show
Bug introduced by
The method isInitialize() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

205
        $useInitials = $this->nameOptions->/** @scrutinizer ignore-call */ isInitialize() &&

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
206 105
            !is_null($this->nameOptions->getInitializeWith()) && $this->nameOptions->getInitializeWith() !== false;
207 105
        if ($useInitials && isset($nameItem->given)) {
208 34
            $nameObj->given = StringHelper::initializeBySpaceOrHyphen(
209 34
                $nameItem->given,
210 34
                $this->nameOptions->getInitializeWith()
211
            );
212
        }
213
214 105
        $renderedResult = $this->getNamesString($nameObj, $rank);
215 105
        CiteProcHelper::applyAdditionMarkupFunction($nameItem, $this->parent->getVariables()[0], $renderedResult);
216 105
        return trim($renderedResult);
217
    }
218
219
    /**
220
     * @param stdClass $name
221
     * @param int $rank
222
     * @return string
223
     * @throws CiteProcException
224
     */
225 105
    private function getNamesString(stdClass $name, int $rank): string
226
    {
227 105
        $text = "";
228
229 105
        if (!isset($name->family)) {
230
            return $text;
231
        }
232
233 105
        $text = $this->nameOrderRenderer->render($name, $rank);
234
235
        //contains nbsp prefixed by normal space or followed by normal space?
236 105
        $text = htmlentities($text);
237 105
        if (strpos($text, " &nbsp;") !== false || strpos($text, "&nbsp; ") !== false) {
238
            $text = preg_replace("/[\s]+/", "", $text); //remove normal spaces
239
            return preg_replace("/&nbsp;+/", " ", $text);
240
        }
241 105
        $text = html_entity_decode(preg_replace("/[\s]+/", " ", $text));
242 105
        return $this->stylesRenderer->renderFormatting(trim($text));
243
    }
244
245
    /**
246
     * @param stdClass $name
247
     * @return stdClass
248
     */
249 105
    private function cloneNamePOSC(stdClass $name): stdClass
250
    {
251 105
        $nameObj = new stdClass();
252 105
        if (isset($name->family)) {
253 105
            $nameObj->family = $name->family;
254
        }
255 105
        if (isset($name->given)) {
256 103
            $nameObj->given = $name->given;
257
        }
258 105
        if (isset($name->{'non-dropping-particle'})) {
259 8
            $nameObj->{'non-dropping-particle'} = $name->{'non-dropping-particle'};
260
        }
261 105
        if (isset($name->{'dropping-particle'})) {
262 8
            $nameObj->{'dropping-particle'} = $name->{'dropping-particle'};
263
        }
264 105
        if (isset($name->{'suffix'})) {
265 17
            $nameObj->{'suffix'} = $name->{'suffix'};
266
        }
267 105
        return $nameObj;
268
    }
269
270
    /**
271
     * @param array $data
272
     * @param string $text
273
     * @param array $resultNames
274
     * @return string
275
     */
276 105
    protected function appendEtAl(array $data, string $text, array $resultNames): string
277
    {
278
        //append et al abbreviation
279 105
        if (count($data) > 1
280 105
            && !empty($resultNames)
281 105
            && !empty($this->etAl)
282 105
            && !empty($this->nameOptions->getEtAlMin())
283 105
            && !empty($this->nameOptions->getEtAlUseFirst())
284 105
            && count($data) != count($resultNames)
285
        ) {
286
            /* By default, when a name list is truncated to a single name, the name and the “et-al” (or “and others”)
287
            term are separated by a space (e.g. “Doe et al.”). When a name list is truncated to two or more names, the
288
            name delimiter is used (e.g. “Doe, Smith, et al.”). This behavior can be changed with the
289
            delimiter-precedes-et-al attribute. */
290
291 13
            switch ($this->nameOptions->getDelimiterPrecedesEtAl()) {
292 13
                case 'never':
293 5
                    $text = $text . " " . $this->etAl;
294 5
                    break;
295 8
                case 'always':
296 1
                    $text = $text . $this->delimiter . $this->etAl;
297 1
                    break;
298 8
                case 'contextual':
299
                default:
300 8
                    if (count($resultNames) === 1) {
301 5
                        $text .= " " . $this->etAl;
302
                    } else {
303 3
                        $text .= $this->delimiter . $this->etAl;
304
                    }
305
            }
306
        }
307 105
        return $text;
308
    }
309
310
    /**
311
     * @param array $resultNames
312
     * @return array
313
     */
314 105
    protected function prepareAbbreviation(array $resultNames): array
315
    {
316 105
        $cnt = count($resultNames);
317
        /* Use of et-al-min and et-al-user-first enables et-al abbreviation. If the number of names in a name variable
318
        matches or exceeds the number set on et-al-min, the rendered name list is truncated after reaching the number of
319
        names set on et-al-use-first.  */
320
321 105
        if (null !== $this->nameOptions->getEtAlMin() && null !== $this->nameOptions->getEtAlUseFirst()) {
0 ignored issues
show
introduced by
The condition null !== $this->nameOptions->getEtAlUseFirst() is always true.
Loading history...
322 52
            if ($this->nameOptions->getEtAlMin() <= $cnt) {
323 16
                if ($this->nameOptions->isEtAlUseLast() &&
324 16
                    $this->nameOptions->getEtAlMin() - $this->nameOptions->getEtAlUseFirst() >= 2) {
325
                    /* et-al-use-last: When set to “true” (the default is “false”), name lists truncated by et-al
326
                    abbreviation are followed by the name delimiter, the ellipsis character, and the last name of the
327
                    original name list. This is only possible when the original name list has at least two more names
328
                    than the truncated name list (for this the value of et-al-use-first/et-al-subsequent-min must be at
329
                    least 2 less than the value of et-al-min/et-al-subsequent-use-first).*/
330
331 3
                    $lastName = array_pop($resultNames); //remove last Element and remember in $lastName
332
                }
333 16
                for ($i = $this->nameOptions->getEtAlUseFirst(); $i < $cnt; ++$i) {
334 16
                    unset($resultNames[$i]);
335
                }
336
337 16
                $resultNames = array_values($resultNames);
338
339 16
                if (!empty($lastName)) { // append $lastName if exist
340 3
                    $resultNames[] = $lastName;
341
                }
342
343 16
                if ($this->parent->hasEtAl()) {
344 1
                    $this->etAl = $this->parent->getEtAl()->render(null);
345 1
                    return $resultNames;
346
                } else {
347 15
                    $this->etAl = CiteProc::getContext()->getLocale()->filter('terms', 'et-al')->single;
348 15
                    return $resultNames;
349
                }
350
            }
351 44
            return $resultNames;
352
        }
353 54
        return $resultNames;
354
    }
355
356
    /**
357
     * @param $data
358
     * @param stdClass $preceding
359
     * @return array
360
     * @throws CiteProcException
361
     */
362 3
    protected function renderSubsequentSubstitution($data, stdClass $preceding): array
363
    {
364 3
        $resultNames = [];
365 3
        $subsequentSubstitution = $this->citationData->getSubsequentAuthorSubstitute();
366 3
        $subsequentSubstitutionRule = $this->citationData->getSubsequentAuthorSubstituteRule();
367
368
        /**
369
         * @var string $type
370
         * @var stdClass $name
371
         */
372 3
        foreach ($data as $rank => $name) {
373
            switch ($subsequentSubstitutionRule) {
374
                /*
375
                 * “partial-each” - when one or more rendered names in the name variable match those in the
376
                 * preceding bibliographic entry, the value of subsequent-author-substitute substitutes for each
377
                 * matching name. Matching starts with the first name, and continues up to the first mismatch.
378
                 */
379 3
                case SubsequentAuthorSubstituteRule::PARTIAL_EACH:
380 1
                    if (NameHelper::precedingHasAuthor($preceding, $name)) {
381 1
                        $resultNames[] = $subsequentSubstitution;
382
                    } else {
383 1
                        $resultNames[] = $this->formatName($name, $rank);
384
                    }
385 1
                    break;
386
387
                /*
388
                 * “partial-first” - as “partial-each”, but substitution is limited to the first name of the name
389
                 * variable.
390
                 */
391 2
                case SubsequentAuthorSubstituteRule::PARTIAL_FIRST:
392 1
                    if ($rank === 0) {
393 1
                        if ($preceding->author[0]->family === $name->family) {
394 1
                            $resultNames[] = $subsequentSubstitution;
395
                        } else {
396 1
                            $resultNames[] = $this->formatName($name, $rank);
397
                        }
398
                    } else {
399 1
                        $resultNames[] = $this->formatName($name, $rank);
400
                    }
401 1
                    break;
402
403
                 /*
404
                  * “complete-each” - requires a complete match like “complete-all”, but now the value of
405
                  * subsequent-author-substitute substitutes for each rendered name.
406
                  */
407 1
                case SubsequentAuthorSubstituteRule::COMPLETE_EACH:
408
                    try {
409 1
                        if (NameHelper::identicalAuthors($preceding, $data)) {
410 1
                            $resultNames[] = $subsequentSubstitution;
411
                        } else {
412 1
                            $resultNames[] = $this->formatName($name, $rank);
413
                        }
414
                    } catch (CiteProcException $e) {
415
                        $resultNames[] = $this->formatName($name, $rank);
416
                    }
417 3
                    break;
418
            }
419
        }
420 3
        return $resultNames;
421
    }
422
423
    /**
424
     * @param array $data
425
     * @param int|null $citationNumber
426
     * @return array
427
     * @throws CiteProcException
428
     */
429 105
    private function handleSubsequentAuthorSubstitution(array $data, ?int $citationNumber): array
430
    {
431 105
        $hasPreceding = $this->citationData->hasKey($citationNumber - 1);
432 105
        $subsequentSubstitution = $this->citationData->getSubsequentAuthorSubstitute();
433 105
        $subsequentSubstitutionRule = $this->citationData->getSubsequentAuthorSubstituteRule();
434 105
        $preceding = $this->citationData->get($citationNumber - 1);
435
436 105
        if ($hasPreceding && !is_null($subsequentSubstitution) && !empty($subsequentSubstitutionRule)) {
437
            /**
438
             * @var stdClass $preceding
439
             */
440 8
            if ($subsequentSubstitutionRule == SubsequentAuthorSubstituteRule::COMPLETE_ALL) {
0 ignored issues
show
introduced by
The condition $subsequentSubstitutionR...ituteRule::COMPLETE_ALL is always false.
Loading history...
441
                try {
442 5
                    if (NameHelper::identicalAuthors($preceding, $data)) {
443 2
                        return [];
444
                    } else {
445 4
                        $resultNames = $this->getFormattedNames($data);
446
                    }
447 1
                } catch (CiteProcException $e) {
448 5
                    $resultNames = $this->getFormattedNames($data);
449
                }
450
            } else {
451 8
                $resultNames = $this->renderSubsequentSubstitution($data, $preceding);
452
            }
453
        } else {
454 105
            $resultNames = $this->getFormattedNames($data);
455
        }
456 105
        return $resultNames;
457
    }
458
459
460
    /**
461
     * @param array $data
462
     * @return array
463
     * @throws CiteProcException
464
     */
465 105
    protected function getFormattedNames(array $data): array
466
    {
467 105
        $resultNames = [];
468 105
        foreach ($data as $rank => $name) {
469 105
            $formatted = $this->formatName($name, $rank);
470 105
            $resultNames[] = NameHelper::addExtendedMarkup($this->variable, $name, $formatted);
471
        }
472 105
        return $resultNames;
473
    }
474
475
    /**
476
     * @param  $resultNames
477
     * @return string
478
     */
479 15
    protected function renderDelimiterPrecedesLastNever($resultNames): string
480
    {
481 15
        $text = "";
482 15
        if (!$this->nameOptions->isEtAlUseLast()) {
483 13
            if (count($resultNames) === 1) {
484 10
                $text = $resultNames[0];
485 10
            } elseif (count($resultNames) === 2) {
486 8
                $text = implode(" ", $resultNames);
487
            } else { // >2
488 7
                $lastName = array_pop($resultNames);
489 7
                $text = implode($this->delimiter, $resultNames) . " " . $lastName;
490
            }
491
        }
492 15
        return $text;
493
    }
494
495
    /**
496
     * @param  $resultNames
497
     * @return string
498
     */
499 14
    protected function renderDelimiterPrecedesLastContextual($resultNames): string
500
    {
501 14
        if (count($resultNames) === 1) {
502 9
            $text = $resultNames[0];
503 6
        } elseif (count($resultNames) === 2) {
504 5
            $text = implode(" ", $resultNames);
505
        } else {
506 1
            $text = implode($this->delimiter, $resultNames);
507
        }
508 14
        return $text;
509
    }
510
511
    /**
512
     * @param $resultNames
513
     */
514 105
    protected function addAnd(&$resultNames)
515
    {
516 105
        $count = count($resultNames);
517 105
        if (!empty($this->and) && $count > 1 && empty($this->etAl)) {
518 30
            $new = $this->and . ' ' . end($resultNames); // add and-prefix of the last name if "and" is defined
519
            // set prefixed last name at the last position of $resultNames array
520 30
            $resultNames[count($resultNames) - 1] = $new;
521
        }
522 105
    }
523
524
    /**
525
     * @param  $resultNames
526
     * @return array|string
527
     */
528 105
    protected function renderDelimiterPrecedesLast($resultNames)
529
    {
530 105
        $text = "";
531 105
        if (!empty($this->and) && empty($this->etAl)) {
532 51
            switch ($this->nameOptions->getDelimiterPrecedesLast()) {
533 51
                case 'after-inverted-name':
534
                    //TODO: implement
535
                    break;
536 51
                case 'always':
537 25
                    $text = implode($this->delimiter, $resultNames);
538 25
                    break;
539 29
                case 'never':
540 15
                    $text = $this->renderDelimiterPrecedesLastNever($resultNames);
541 15
                    break;
542 14
                case 'contextual':
543
                default:
544 14
                    $text = $this->renderDelimiterPrecedesLastContextual($resultNames);
545
            }
546
        }
547 105
        return $text;
548
    }
549
550
    /**
551
     * @return string
552
     */
553 5
    public function getForm(): string
554
    {
555 5
        return $this->nameOptions->getForm();
556
    }
557
558
    /**
559
     * @param mixed $delimiter
560
     */
561
    public function setDelimiter($delimiter)
562
    {
563
        $this->delimiter = $delimiter;
564
    }
565
566
    /**
567
     * @return Names
568
     */
569
    public function getParent(): Names
570
    {
571
        return $this->parent;
572
    }
573
574
    public function setParent($parent)
575
    {
576
        $this->parent = $parent;
577
    }
578
579
    /**
580
     * @param NamePart[] $nameParts
581
     */
582 118
    public function setNameParts(array $nameParts): void
583
    {
584 118
        $this->nameParts = $nameParts;
585 118
    }
586
587
    /**
588
     * @param NameOrderRenderer $nameOrderRenderer
589
     */
590 118
    public function setNameOrderRenderer(NameOrderRenderer $nameOrderRenderer): void
591
    {
592 118
        $this->nameOrderRenderer = $nameOrderRenderer;
593 118
    }
594
}
595