Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.48% covered (success)
99.48%
191 / 192
95.24% covered (success)
95.24%
20 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
MarcReader
99.48% covered (success)
99.48%
191 / 192
95.24% covered (success)
95.24%
20 / 21
84
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setData
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
9
 toFormat
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getLeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getField
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFields
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
9
 getAllFields
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
5
 getSubfield
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getSubfields
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 getFieldsSubfields
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
9
 getLinkedField
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getLinkedFields
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
8
 getLinkedFieldsSubfields
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getFieldLink
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseLinkageField
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getFilteredRecord
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 getFilteringRulesForTag
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 filterSubfields
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 getWarnings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getInternalFields
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getInternalSubfield
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * MARC record reader class.
5 *
6 * PHP version 7
7 *
8 * Copyright (C) The National Library of Finland 2020-2022.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  MARC
25 * @author   Ere Maijala <ere.maijala@helsinki.fi>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development Wiki
28 */
29
30namespace VuFind\Marc;
31
32use function in_array;
33use function intval;
34use function is_array;
35use function is_string;
36
37/**
38 * MARC record reader class.
39 *
40 * @category VuFind
41 * @package  MARC
42 * @author   Ere Maijala <ere.maijala@helsinki.fi>
43 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
44 * @link     https://vufind.org/wiki/development Wiki
45 */
46class MarcReader
47{
48    /**
49     * Supported serialization formats
50     *
51     * @var array
52     */
53    protected $serializations = [
54        'ISO2709' => Serialization\Iso2709::class,
55        'JSON' => Serialization\MarcInJson::class,
56        'MARCXML' => Serialization\MarcXml::class,
57    ];
58
59    /**
60     * MARC leader
61     *
62     * @var string
63     */
64    protected $leader;
65
66    /**
67     * MARC is stored in a multidimensional array resembling MARC-in-JSON
68     * specification by Ross Singer:
69     * [
70     *     'leader' => '...',
71     *     'fields' => [
72     *         [
73     *             '001' => '12345'
74     *         ],
75     *         [
76     *             '245' => [
77     *                 'ind1' => '0',
78     *                 'ind2' => '1',
79     *                 'subfields' => [
80     *                      ['a' => 'Title'],
81     *                      ['k' => 'Form'],
82     *                      ['k' => 'Another'],
83     *                      ['p' => 'Part'],
84     *                 ]
85     *             ]
86     *         ]
87     *     ]
88     * ]
89     *
90     * @var array
91     * @see https://web.archive.org/web/20151112001548/http://dilettantes.code4lib.org/blog/2010/09/a-proposal-to-serialize-marc-in-json/
92     */
93    protected $data;
94
95    /**
96     * Any warnings encountered when parsing a record
97     *
98     * @var array
99     */
100    protected $warnings;
101
102    /**
103     * Constructor
104     *
105     * @param string|array $data MARC record in one of the supported formats, or an
106     * associative array with 'leader' and 'fields' in the internal format
107     */
108    public function __construct($data)
109    {
110        $this->setData($data);
111    }
112
113    /**
114     * Set MARC record data
115     *
116     * @param string|array $data MARC record in one of the supported formats, or an
117     * associative array with 'leader' and 'fields' in the internal format
118     *
119     * @throws \Exception
120     * @return void
121     */
122    public function setData($data): void
123    {
124        $this->warnings = [];
125        if (is_array($data)) {
126            if (
127                !is_string($data['leader'] ?? null)
128                || !is_array($data['fields'] ?? null)
129            ) {
130                throw new \Exception('Invalid data array format provided');
131            }
132            $this->data = $data;
133            return;
134        }
135        $valid = false;
136        foreach ($this->serializations as $serialization) {
137            if ($serialization::canParse($data)) {
138                $this->data = $serialization::fromString($data);
139                if (isset($this->data['warnings'])) {
140                    $this->warnings = $this->data['warnings'];
141                    unset($this->data['warnings']);
142                }
143                $valid = true;
144                break;
145            }
146        }
147        if (!$valid) {
148            throw new \Exception('MARC record format not recognized');
149        }
150        // Make sure leader is 24 characters, and reset meaningless offsets:
151        if ($this->data['leader']) {
152            $leader = str_pad(substr($this->data['leader'] ?? '', 0, 24), 24);
153            $this->data['leader'] = '00000' . substr($leader, 5, 7) . '00000'
154                . substr($leader, 17);
155        }
156    }
157
158    /**
159     * Serialize the record
160     *
161     * @param string $format Format to return (e.g. 'ISO2709' or 'MARCXML')
162     *
163     * @throws \Exception
164     * @return string
165     */
166    public function toFormat(string $format): string
167    {
168        $serialization = $this->serializations[$format] ?? null;
169        if (null === $serialization) {
170            throw new \Exception("Unknown MARC format '$format' requested");
171        }
172        return $serialization::toString($this->data);
173    }
174
175    /**
176     * Return leader
177     *
178     * @return string
179     */
180    public function getLeader(): string
181    {
182        return $this->data['leader'];
183    }
184
185    /**
186     * Return an associative array for a data field, a string for a control field or
187     * an empty array if field does not exist
188     *
189     * @param string $fieldTag      The MARC field tag to get
190     * @param array  $subfieldCodes The MARC subfield codes to get, or empty for all
191     *
192     * @return array|string
193     */
194    public function getField(string $fieldTag, ?array $subfieldCodes = null)
195    {
196        $results = $this->getFields($fieldTag, $subfieldCodes);
197        return $results[0] ?? [];
198    }
199
200    /**
201     * Return an associative array of fields for data fields or an array of values
202     * for control fields
203     *
204     * @param string $fieldTag      The MARC field tag to get
205     * @param array  $subfieldCodes The MARC subfield codes to get, or empty for all.
206     * Ignored for control fields.
207     *
208     * @return array
209     */
210    public function getFields(string $fieldTag, ?array $subfieldCodes = null): array
211    {
212        $result = [];
213
214        foreach ($this->data['fields'] as $fieldData) {
215            if ($fieldTag && $fieldTag !== (string)key($fieldData)) {
216                continue;
217            }
218            $field = current($fieldData);
219            if (!is_array($field)) {
220                // Control field
221                $result[] = $field;
222                continue;
223            }
224            $subfields = [];
225            foreach ($field['subfields'] ?? [] as $subfield) {
226                if (
227                    $subfieldCodes
228                    && !in_array((string)key($subfield), $subfieldCodes)
229                ) {
230                    continue;
231                }
232                $subfields[] = [
233                    'code' => (string)key($subfield),
234                    'data' => current($subfield),
235                ];
236            }
237            if ($subfields) {
238                $result[] = [
239                    'tag' => $fieldTag,
240                    'i1' => $field['ind1'],
241                    'i2' => $field['ind2'],
242                    'subfields' => $subfields,
243                ];
244            }
245        }
246
247        return $result;
248    }
249
250    /**
251     * Return all fields as an array.
252     *
253     * Control fields have the following elements:
254     * - tag
255     * - data
256     *
257     * Data fields have the following elements:
258     * - tag
259     * - i1
260     * - i2
261     * - subfields
262     *
263     * @return array
264     */
265    public function getAllFields()
266    {
267        $result = [];
268
269        foreach ($this->data['fields'] as $fieldData) {
270            $tag = (string)key($fieldData);
271            $field = current($fieldData);
272            if (is_string($field)) {
273                // Control field
274                $result[] = [
275                    'tag' => $tag,
276                    'data' => $field,
277                ];
278                continue;
279            }
280            $subfields = [];
281            foreach ($field['subfields'] ?? [] as $subfield) {
282                $subfields[] = [
283                    'code' => (string)key($subfield),
284                    'data' => current($subfield),
285                ];
286            }
287            if ($subfields) {
288                $result[] = [
289                    'tag' => $tag,
290                    'i1' => $field['ind1'],
291                    'i2' => $field['ind2'],
292                    'subfields' => $subfields,
293                ];
294            }
295        }
296
297        return $result;
298    }
299
300    /**
301     * Return first subfield with the given code in the MARC field provided by
302     * getField or getFields
303     *
304     * @param array  $field        Result from MarcReader::getFields
305     * @param string $subfieldCode The MARC subfield code to get
306     *
307     * @return string
308     */
309    public function getSubfield(array $field, string $subfieldCode): string
310    {
311        foreach ($field['subfields'] ?? [] as $current) {
312            if ($current['code'] == $subfieldCode) {
313                return trim($current['data']);
314            }
315        }
316
317        return '';
318    }
319
320    /**
321     * Return all subfields with the given code in the MARC field provided by
322     * getField or getFields. Returns all subfields if subfieldCode is empty.
323     *
324     * @param array  $field        Result from MarcReader::getFields
325     * @param string $subfieldCode The MARC subfield code to get
326     *
327     * @return array
328     */
329    public function getSubfields(array $field, string $subfieldCode = ''): array
330    {
331        $result = [];
332        foreach ($field['subfields'] ?? [] as $current) {
333            if ('' === $subfieldCode || $current['code'] == $subfieldCode) {
334                $result[] = trim($current['data']);
335            }
336        }
337
338        return $result;
339    }
340
341    /**
342     * Return an array of all values extracted from the specified field/subfield
343     * combination.  If multiple subfields and a separator are specified, the
344     * subfields will be concatenated together in the order listed -- each entry in
345     * the array will correspond with a single MARC field.  If $separator is null,
346     * the return array will contain separate entries for all subfields.
347     *
348     * @param string $fieldTag      The MARC field tag to get
349     * @param array  $subfieldCodes The MARC subfield codes to get
350     * @param string $separator     Subfield separator string. Set to null to disable
351     * concatenation of subfields.
352     *
353     * @return array
354     */
355    public function getFieldsSubfields(
356        string $fieldTag,
357        array $subfieldCodes,
358        ?string $separator = ' '
359    ): array {
360        $result = [];
361
362        foreach ($this->getInternalFields($fieldTag) as $field) {
363            if (!isset($field['subfields'])) {
364                continue;
365            }
366            $subfields = [];
367            foreach ($field['subfields'] ?? [] as $subfield) {
368                if (
369                    $subfieldCodes
370                    && !in_array((string)key($subfield), $subfieldCodes)
371                ) {
372                    continue;
373                }
374                if (null !== $separator) {
375                    $subfields[] = current($subfield);
376                } else {
377                    $result[] = current($subfield);
378                }
379            }
380            if (null !== $separator && $subfields) {
381                $result[] = implode($separator, $subfields);
382            }
383        }
384
385        return $result;
386    }
387
388    /**
389     * Return an associative array for a linked field such as 880 (Alternate Graphic
390     * Representation) or an empty array if field does not exist
391     *
392     * @param string $fieldTag       The MARC field that contains the linked fields
393     * @param string $linkedFieldTag The linked MARC field tag to get
394     * @param string $occurrence     The occurrence number to get; empty string for
395     * whatever comes first
396     * @param array  $subfieldCodes  The MARC subfield codes to get, or empty for all
397     *
398     * @return array
399     */
400    public function getLinkedField(
401        string $fieldTag,
402        string $linkedFieldTag,
403        string $occurrence = '',
404        ?array $subfieldCodes = null
405    ): array {
406        $results
407            = $this->getLinkedFields($fieldTag, $linkedFieldTag, $subfieldCodes);
408        foreach ($results as $field) {
409            if (empty($occurrence) || $occurrence === $field['link']['occurrence']) {
410                return $field;
411            }
412        }
413        return [];
414    }
415
416    /**
417     * Return an array of associative arrays for a linked field such as 880
418     * (Alternate Graphic Representation)
419     *
420     * @param string $fieldTag       The MARC field that contains the linked fields
421     * @param string $linkedFieldTag The linked MARC field tag to get
422     * @param array  $subfieldCodes  The MARC subfield codes to get, or empty for all
423     *
424     * @return array
425     */
426    public function getLinkedFields(
427        string $fieldTag,
428        string $linkedFieldTag,
429        ?array $subfieldCodes = null
430    ): array {
431        $result = [];
432
433        foreach ($this->getInternalFields($fieldTag) as $field) {
434            if (is_string($field)) {
435                // Control field
436                continue;
437            }
438            $link
439                = $this->parseLinkageField($this->getInternalSubfield($field, '6'));
440            if ($link['field'] !== $linkedFieldTag) {
441                continue;
442            }
443            $subfields = [];
444            foreach ($field['subfields'] ?? [] as $subfield) {
445                if (
446                    $subfieldCodes
447                    && !in_array((string)key($subfield), $subfieldCodes)
448                ) {
449                    continue;
450                }
451                $subfields[] = [
452                    'code' => (string)key($subfield),
453                    'data' => current($subfield),
454                ];
455            }
456            if ($subfields) {
457                $result[] = [
458                    'tag' => $fieldTag,
459                    'i1' => $field['ind1'],
460                    'i2' => $field['ind2'],
461                    'subfields' => $subfields,
462                    'link' => $link,
463                ];
464            }
465        }
466
467        return $result;
468    }
469
470    /**
471     * Return an array of all values extracted from the specified linked
472     * field/subfield combination.  If multiple subfields and a separator are
473     * specified, the subfields will be concatenated together in the order listed
474     * -- each entry in the array will correspond with a single MARC field.  If
475     * $separator is null, the return array will contain separate entries for all
476     * subfields.
477     *
478     * @param string $fieldTag       The MARC field that contains the linked fields
479     * @param string $linkedFieldTag The linked MARC field tag to get
480     * @param array  $subfieldCodes  The MARC subfield codes to get
481     * @param string $separator      Subfield separator string. Set to null to
482     * disable concatenation of subfields.
483     *
484     * @return array
485     */
486    public function getLinkedFieldsSubfields(
487        string $fieldTag,
488        string $linkedFieldTag,
489        array $subfieldCodes,
490        ?string $separator = ' '
491    ): array {
492        $result = [];
493        foreach ($this->getLinkedFields($fieldTag, $linkedFieldTag, $subfieldCodes) as $field) {
494            $subfields = $this->getSubfields($field);
495            if (null !== $separator) {
496                $result[] = implode($separator, $subfields);
497            } else {
498                $result = array_merge($result, $subfields);
499            }
500        }
501        return $result;
502    }
503
504    /**
505     * Get linked field data from subfield 6
506     *
507     * @param array $field Field
508     *
509     * @return array
510     */
511    public function getFieldLink(array $field): array
512    {
513        return $this->parseLinkageField($this->getSubfield($field, '6'));
514    }
515
516    /**
517     * Parse a linkage field
518     *
519     * @param string $link Linkage field
520     *
521     * @return array
522     */
523    public function parseLinkageField(string $link): array
524    {
525        $linkParts = explode('/', $link, 3);
526        $targetParts = explode('-', $linkParts[0]);
527        return [
528            'field' => $targetParts[0],
529            'occurrence' => $targetParts[1] ?? '',
530            'script' => $linkParts[1] ?? '',
531            'orientation' => $linkParts[2] ?? '',
532        ];
533    }
534
535    /**
536     * Return a copy of the record with the specified fields and/or subfields
537     * removed.
538     *
539     * Each rule can have the following elements:
540     *
541     * tag       - Tag the rule applies to (a regular expression).
542     * subfields - Subfields codes to remove (a regular expression, optional).
543     *             Default is to remove all subfields (and the field).
544     *
545     * Examples:
546     *
547     * $result = $reader->getFilteredRecord(
548     *   [
549     *     [
550     *       'tag' => '9..'
551     *     ]
552     *   ]
553     * );
554     *
555     * $result = $reader->getFilteredRecord(
556     *   [
557     *     [
558     *       'tag' => '...',
559     *       'subfields' => '0'
560     *     ]
561     *   ]
562     * );
563     *
564     * @param array $rules Array of filtering rules
565     *
566     * @return MarcReader
567     */
568    public function getFilteredRecord(array $rules): MarcReader
569    {
570        $resultFields = [];
571        foreach ($this->data['fields'] as $fieldData) {
572            $tag = (string)key($fieldData);
573            $field = current($fieldData);
574            $fieldRules = $this->getFilteringRulesForTag($rules, $tag);
575            if ($fieldRules) {
576                if (is_string($field)) {
577                    // Control field, filter out completely
578                    continue;
579                }
580                $field['subfields'] = $this->filterSubfields(
581                    $fieldRules,
582                    $field['subfields']
583                );
584                if (!$field['subfields']) {
585                    // No subfields left, drop the field
586                    continue;
587                }
588                $resultFields[] = [$tag => $field];
589            } else {
590                $resultFields[] = [$tag => $field];
591            }
592        }
593        return new MarcReader(
594            [
595                'leader' => $this->data['leader'],
596                'fields' => $resultFields,
597            ]
598        );
599    }
600
601    /**
602     * Get filtering rules matching a field tag
603     *
604     * @param array  $rules Filtering rules
605     * @param string $tag   Field tag
606     *
607     * @return array
608     */
609    protected function getFilteringRulesForTag(array $rules, string $tag): array
610    {
611        $result = [];
612        foreach ($rules as $rule) {
613            if (
614                preg_match('/' . $rule['tag'] . '/', $tag)
615                && (!isset($rule['subfields']) || intval($tag) >= 10)
616            ) {
617                $result[] = $rule;
618            }
619        }
620        return $result;
621    }
622
623    /**
624     * Filter subfields
625     *
626     * @param array $rules     Filtering rules
627     * @param array $subfields Subfields
628     *
629     * @return array
630     */
631    protected function filterSubfields(array $rules, array $subfields): array
632    {
633        foreach ($rules as $rule) {
634            if (!isset($rule['subfields'])) {
635                // No subfields specified, filter out all of them
636                return [];
637            }
638            $remaining = [];
639            foreach ($subfields as $subfield) {
640                $code = (string)key($subfield);
641                if (!preg_match('/' . $rule['subfields'] . '/', $code)) {
642                    $remaining[] = $subfield;
643                }
644            }
645            if (!$remaining) {
646                return [];
647            }
648            $subfields = $remaining;
649        }
650
651        return $subfields;
652    }
653
654    /**
655     * Get any warnings encountered when parsing a record
656     *
657     * @return array
658     */
659    public function getWarnings(): array
660    {
661        return $this->warnings;
662    }
663
664    /**
665     * Return fields by tag in internal format
666     *
667     * @param string $tag Field tag
668     *
669     * @return array
670     */
671    protected function getInternalFields(string $tag): array
672    {
673        $result = [];
674        foreach ($this->data['fields'] as $field) {
675            $fieldTag = (string)key($field);
676            if ($fieldTag === $tag) {
677                $result[] = current($field);
678            }
679        }
680        return $result;
681    }
682
683    /**
684     * Return first subfield with the given code in the internal MARC field
685     *
686     * @param array  $field        Internal MARC field
687     * @param string $subfieldCode The MARC subfield code to get
688     *
689     * @return string
690     */
691    protected function getInternalSubfield(
692        array $field,
693        string $subfieldCode
694    ): string {
695        foreach ($field['subfields'] ?? [] as $subfield) {
696            if ((string)key($subfield) === $subfieldCode) {
697                return trim(current($subfield));
698            }
699        }
700        return '';
701    }
702}