• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ducks-project / encoding-repair / 21242368865

22 Jan 2026 09:03AM UTC coverage: 97.994% (+2.5%) from 95.531%
21242368865

push

github

donaldinou
feat : improve coverage

342 of 349 relevant lines covered (97.99%)

11.15 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.09
/CharsetHelper.php
1
<?php
2

3
/**
4
 * Part of EncodingRepair package.
5
 *
6
 * (c) Adrien Loyant <donald_duck@team-df.org>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
declare(strict_types=1);
13

14
namespace Ducks\Component\EncodingRepair;
15

16
use Ducks\Component\EncodingRepair\Transcoder\TranscoderChain;
17
use Ducks\Component\EncodingRepair\Transcoder\TranscoderInterface;
18
use Ducks\Component\EncodingRepair\Transcoder\UConverterTranscoder;
19
use Ducks\Component\EncodingRepair\Transcoder\IconvTranscoder;
20
use Ducks\Component\EncodingRepair\Transcoder\MbStringTranscoder;
21
use Ducks\Component\EncodingRepair\Transcoder\CallableTranscoder;
22
use Ducks\Component\EncodingRepair\Detector\DetectorChain;
23
use Ducks\Component\EncodingRepair\Detector\DetectorInterface;
24
use Ducks\Component\EncodingRepair\Detector\MbStringDetector;
25
use Ducks\Component\EncodingRepair\Detector\FileInfoDetector;
26
use Ducks\Component\EncodingRepair\Detector\CallableDetector;
27
use InvalidArgumentException;
28
use Normalizer;
29
use RuntimeException;
30

31
/**
32
 * Helper class for encoding and detect charset.
33
 *
34
 * Designed to handle legacy ISO-8859-1 <-> UTF-8 interoperability issues.
35
 * Implements Chain of Responsibility pattern for extensibility.
36
 *
37
 * @psalm-api
38
 *
39
 * @psalm-immutable This class has no mutable state
40
 *
41
 * @final
42
 */
43
final class CharsetHelper
44
{
45
    public const AUTO = 'AUTO';
46
    public const WINDOWS_1252 = 'CP1252';
47
    public const ENCODING_ISO = 'ISO-8859-1';
48
    public const ENCODING_UTF8 = 'UTF-8';
49
    public const ENCODING_UTF16 = 'UTF-16';
50
    public const ENCODING_UTF32 = 'UTF-32';
51
    public const ENCODING_ASCII = 'ASCII';
52

53
    private const DEFAULT_ENCODINGS = [
54
        self::ENCODING_UTF8,
55
        self::WINDOWS_1252,
56
        self::ENCODING_ISO,
57
        self::ENCODING_ASCII,
58
    ];
59

60
    private const ALLOWED_ENCODINGS = [
61
        self::AUTO,
62
        self::ENCODING_UTF8,
63
        self::WINDOWS_1252,
64
        self::ENCODING_ISO,
65
        self::ENCODING_ASCII,
66
        self::ENCODING_UTF16,
67
        self::ENCODING_UTF32,
68
    ];
69

70
    private const MAX_REPAIR_DEPTH = 5;
71
    private const JSON_DEFAULT_DEPTH = 512;
72

73
    /**
74
     * Transcoder chain instance.
75
     *
76
     * @var TranscoderChain|null
77
     */
78
    private static $transcoderChain = null;
79

80
    /**
81
     * Detector chain instance.
82
     *
83
     * @var DetectorChain|null
84
     */
85
    private static $detectorChain = null;
86

87
    /**
88
     * Private constructor to prevent instantiation of static utility class.
89
     *
90
     * @psalm-api
91
     *
92
     * @codeCoverageIgnore
93
     */
94
    private function __construct()
95
    {
96
    }
97

98
    /**
99
     * Get or initialize transcoder chain.
100
     *
101
     * @return TranscoderChain
102
     */
103
    private static function getTranscoderChain(): TranscoderChain
25✔
104
    {
105
        if (null === self::$transcoderChain) {
25✔
106
            self::$transcoderChain = new TranscoderChain();
25✔
107
            self::$transcoderChain->register(new UConverterTranscoder());
25✔
108
            self::$transcoderChain->register(new IconvTranscoder());
25✔
109
            self::$transcoderChain->register(new MbStringTranscoder());
25✔
110
        }
111

112
        return self::$transcoderChain;
25✔
113
    }
114

115
    /**
116
     * Get or initialize detector chain.
117
     *
118
     * @return DetectorChain
119
     */
120
    private static function getDetectorChain(): DetectorChain
3✔
121
    {
122
        if (null === self::$detectorChain) {
3✔
123
            self::$detectorChain = new DetectorChain();
3✔
124
            self::$detectorChain->register(new MbStringDetector());
3✔
125
            self::$detectorChain->register(new FileInfoDetector());
3✔
126
        }
127

128
        return self::$detectorChain;
3✔
129
    }
130

131
    /**
132
     * Register a transcoder with optional priority.
133
     *
134
     * @phpcs:disable Generic.Files.LineLength.TooLong
135
     *
136
     * @param TranscoderInterface|callable(string, string, string, null|array<string, mixed>): (string|null) $transcoder Transcoder instance or callable
137
     * @param int|null $priority Priority override (null = use transcoder's default)
138
     *
139
     * @return void
140
     *
141
     * @throws InvalidArgumentException If transcoder is invalid
142
     *
143
     * @phpcs:enable Generic.Files.LineLength.TooLong
144
     */
145
    public static function registerTranscoder(
2✔
146
        $transcoder,
147
        ?int $priority = null
148
    ): void {
149
        /** @var mixed $transcoder */
150
        if ($transcoder instanceof TranscoderInterface) {
2✔
151
            // @codeCoverageIgnoreStart
152
            self::getTranscoderChain()->register($transcoder, $priority);
153
            return;
154
            // @codeCoverageIgnoreEnd
155
        }
156

157
        if (\is_callable($transcoder)) {
2✔
158
            /** @var callable(string, string, string, null|array<string, mixed>): (string|null) $transcoder */
159
            $wrapper = new CallableTranscoder($transcoder, $priority ?? 0);
1✔
160
            self::getTranscoderChain()->register($wrapper, $priority);
1✔
161
            return;
1✔
162
        }
163

164
        throw new InvalidArgumentException(
1✔
165
            'Transcoder must be an instance of TranscoderInterface or a callable'
1✔
166
        );
1✔
167
    }
168

169
    /**
170
     * Register a detector with optional priority.
171
     *
172
     * @phpcs:disable Generic.Files.LineLength.TooLong
173
     *
174
     * @param DetectorInterface|callable(string, array<string, mixed>|null): (string|null) $detector Detector instance or callable
175
     * @param int|null $priority Priority override (null = use detector's default)
176
     *
177
     * @return void
178
     *
179
     * @throws InvalidArgumentException If detector is invalid
180
     *
181
     * @phpcs:enable Generic.Files.LineLength.TooLong
182
     */
183
    public static function registerDetector(
2✔
184
        $detector,
185
        ?int $priority = null
186
    ): void {
187
        /** @var mixed $detector */
188
        if ($detector instanceof DetectorInterface) {
2✔
189
            // @codeCoverageIgnoreStart
190
            self::getDetectorChain()->register($detector, $priority);
191
            return;
192
            // @codeCoverageIgnoreEnd
193
        }
194

195
        if (\is_callable($detector)) {
2✔
196
            /** @var callable(string, array<string, mixed>|null): (string|null) $detector */
197
            $wrapper = new CallableDetector($detector, $priority ?? 0);
1✔
198
            self::getDetectorChain()->register($wrapper, $priority);
1✔
199
            return;
1✔
200
        }
201

202
        throw new InvalidArgumentException(
1✔
203
            'Detector must be an instance of DetectorInterface or a callable'
1✔
204
        );
1✔
205
    }
206

207
    /**
208
     * Detects the charset encoding of a string.
209
     *
210
     * @param string $string String to analyze
211
     * @param array<string, mixed> $options Conversion options
212
     *                                      - 'encodings': array of encodings to test
213
     *
214
     * @return string Detected encoding (uppercase)
215
     */
216
    public static function detect(string $string, array $options = []): string
5✔
217
    {
218
        // Fast common return.
219
        if (self::isValidUtf8($string)) {
5✔
220
            return self::ENCODING_UTF8;
2✔
221
        }
222

223
        $detected = self::getDetectorChain()->detect($string, $options);
3✔
224

225
        return $detected ?? self::ENCODING_ISO;
3✔
226
    }
227

228
    /**
229
     * Convert $data string from one encoding to another.
230
     *
231
     * @param mixed $data Data to convert
232
     * @param string $to Target encoding
233
     * @param string $from Source encoding (use AUTO for detection)
234
     * @param array<string, mixed> $options Conversion options
235
     *                                      - 'normalize': bool (default: true)
236
     *                                      - 'translit': bool (default: true)
237
     *                                      - 'ignore': bool (default: true)
238
     *
239
     * @return mixed The data transcoded in the target encoding
240
     *
241
     * @throws InvalidArgumentException If encoding is invalid
242
     */
243
    public static function toCharset(
34✔
244
        $data,
245
        string $to = self::ENCODING_UTF8,
246
        string $from = self::ENCODING_ISO,
247
        array $options = []
248
    ) {
249
        self::validateEncoding($to, 'target');
34✔
250
        self::validateEncoding($from, 'source');
33✔
251

252
        $options = self::configureOptions($options);
32✔
253

254
        // We define the callback logic for a single string
255
        /**
256
         * @psalm-suppress MissingClosureParamType
257
         * @psalm-suppress MissingClosureReturnType
258
         */
259
        $callback = static fn ($value) => self::convertValue($value, $to, $from, $options);
32✔
260

261
        return self::applyRecursive($data, $callback);
32✔
262
    }
263

264
    /**
265
     * Converts anything (string, array, object) to UTF-8.
266
     *
267
     * @param mixed $data Data to convert
268
     * @param string $from Source encoding
269
     * @param array<string, mixed> $options Conversion options
270
     *                                      - 'normalize': bool (default: true)
271
     *                                      - 'translit': bool (default: true)
272
     *                                      - 'ignore': bool (default: true)
273
     *
274
     * @return mixed
275
     *
276
     * @throws InvalidArgumentException If encoding is invalid
277
     */
278
    public static function toUtf8(
8✔
279
        $data,
280
        string $from = self::WINDOWS_1252,
281
        array $options = []
282
    ) {
283
        return self::toCharset(
8✔
284
            $data,
8✔
285
            self::ENCODING_UTF8,
8✔
286
            $from,
8✔
287
            $options
8✔
288
        );
8✔
289
    }
290

291
    /**
292
     * Converts anything to ISO-8859-1 (Windows-1252).
293
     *
294
     * @param mixed $data Data to convert
295
     * @param string $from Source encoding
296
     * @param array<string, mixed> $options Conversion options
297
     *                                      - 'normalize': bool (default: true)
298
     *                                      - 'translit': bool (default: true)
299
     *                                      - 'ignore': bool (default: true)
300
     *
301
     * @return mixed
302
     *
303
     * @throws InvalidArgumentException If encoding is invalid
304
     */
305
    public static function toIso(
1✔
306
        $data,
307
        string $from = self::ENCODING_UTF8,
308
        array $options = []
309
    ) {
310
        return self::toCharset(
1✔
311
            $data,
1✔
312
            self::WINDOWS_1252,
1✔
313
            $from,
1✔
314
            $options
1✔
315
        );
1✔
316
    }
317

318
    /**
319
     * Repairs double-encoded strings.
320
     *
321
     * Attempts to fix strings that have been encoded multiple times
322
     * by detecting and reversing the encoding layers.
323
     * Pay attention that it will first repair within UTF-8, then converts to $to.
324
     *
325
     * @param mixed $data Data to repair
326
     * @param string $to Target encoding (UTF-8, ISO, etc.)
327
     * @param string $from The "glitch" encoding (usually ISO/Windows-1252) that caused the double encoding.
328
     * @param array<string,mixed> $options Conversion options
329
     *                                     - 'normalize': bool (default: true)
330
     *                                     - 'translit': bool (default: true)
331
     *                                     - 'ignore': bool (default: true)
332
     *                                     - 'maxDepth' : int (default: 5)
333
     *
334
     * @return mixed
335
     *
336
     * @throws InvalidArgumentException If encoding is invalid
337
     */
338
    public static function repair(
16✔
339
        $data,
340
        string $to = self::ENCODING_UTF8,
341
        string $from = self::ENCODING_ISO,
342
        array $options = []
343
    ) {
344
        $options = self::configureOptions(
16✔
345
            $options,
16✔
346
            ['maxDepth' => self::MAX_REPAIR_DEPTH]
16✔
347
        );
16✔
348

349
        /**
350
         * @psalm-suppress MissingClosureParamType
351
         * @psalm-suppress MissingClosureReturnType
352
         */
353
        $callback = static fn ($value) => self::repairValue($value, $to, $from, $options);
16✔
354

355
        return self::applyRecursive($data, $callback);
16✔
356
    }
357

358
    /**
359
     * Safe JSON encoding to ensure UTF-8 compliance.
360
     *
361
     * @param mixed $data
362
     * @param int $flags JSON encode flags
363
     * @param int<1, 2147483647> $depth Maximum depth
364
     * @param string $from Source encoding for repair
365
     *
366
     * @return string JSON UTF-8 string
367
     *
368
     * @throws RuntimeException if error occured.
369
     */
370
    public static function safeJsonEncode(
4✔
371
        $data,
372
        int $flags = 0,
373
        int $depth = self::JSON_DEFAULT_DEPTH,
374
        string $from = self::WINDOWS_1252
375
    ): string {
376
        /** @var mixed $data */
377
        $data = self::repair($data, self::ENCODING_UTF8, $from);
4✔
378
        /** @var string|false $json */
379
        $json = \json_encode($data, $flags, $depth);
4✔
380

381
        if (false === $json) {
4✔
382
            throw new RuntimeException(
×
383
                'JSON Encode Error: ' . \json_last_error_msg()
×
384
            );
×
385
        }
386

387
        return $json;
4✔
388
    }
389

390
    /**
391
     * Safe JSON decoding with charset conversion.
392
     *
393
     * @param string $json JSON string
394
     * @param bool|null $associative Return associative array
395
     * @param int<1, 2147483647> $depth Maximum depth
396
     * @param int $flags JSON decode flags
397
     * @param string $to Target encoding
398
     * @param string $from Source encoding for repair
399
     *
400
     * @return mixed Decoded data
401
     *
402
     * @throws RuntimeException If decoding fails
403
     */
404
    public static function safeJsonDecode(
5✔
405
        string $json,
406
        ?bool $associative = null,
407
        int $depth = self::JSON_DEFAULT_DEPTH,
408
        int $flags = 0,
409
        string $to = self::ENCODING_UTF8,
410
        string $from = self::WINDOWS_1252
411
    ) {
412
        // Repair string to a valid UTF-8 for decoding
413
        /** @var string $data */
414
        $data = self::repair($json, self::ENCODING_UTF8, $from);
5✔
415
        /** @var mixed $result */
416
        $result = \json_decode($data, $associative, $depth, $flags);
5✔
417

418
        if (null === $result && \JSON_ERROR_NONE !== \json_last_error()) {
5✔
419
            throw new RuntimeException(
1✔
420
                'JSON Decode Error: ' . \json_last_error_msg()
1✔
421
            );
1✔
422
        }
423

424
        return self::toCharset($result, $to, self::ENCODING_UTF8);
4✔
425
    }
426

427
    /**
428
     * Applies a callback recursively to arrays, objects, and scalar values.
429
     *
430
     * @param mixed $data Data to process
431
     * @param callable $callback Processing callback function
432
     *
433
     * @return mixed
434
     */
435
    private static function applyRecursive($data, callable $callback)
33✔
436
    {
437
        if (\is_array($data)) {
33✔
438
            return \array_map(
13✔
439
                /**
440
                 * @psalm-suppress MissingClosureReturnType
441
                 * @psalm-suppress MissingClosureParamType
442
                 */
443
                static fn ($item) => self::applyRecursive($item, $callback),
13✔
444
                $data
13✔
445
            );
13✔
446
        }
447

448
        if (\is_object($data)) {
31✔
449
            return self::applyToObject($data, $callback);
5✔
450
        }
451

452
        // Apply the transformation on scalar value
453
        return $callback($data);
31✔
454
    }
455

456
    /**
457
     * Applies callback to object properties recursively.
458
     *
459
     * @param object $data Object to process
460
     * @param callable $callback Processing function
461
     *
462
     * @return object Cloned object with processed properties
463
     */
464
    private static function applyToObject(object $data, callable $callback): object
5✔
465
    {
466
        $copy = clone $data;
5✔
467

468
        $properties = \get_object_vars($copy);
5✔
469
        /** @var mixed $value */
470
        foreach ($properties as $key => $value) {
5✔
471
            $copy->$key = self::applyRecursive($value, $callback);
5✔
472
        }
473

474
        return $copy;
5✔
475
    }
476

477
    /**
478
     * Converts a single value to target encoding.
479
     *
480
     * @param mixed $value Value to convert
481
     * @param string $to Target encoding
482
     * @param string $from Source encoding
483
     * @param array<string, mixed> $options Conversion configuration
484
     *
485
     * @return mixed
486
     */
487
    private static function convertValue(
31✔
488
        $value,
489
        string $to,
490
        string $from,
491
        array $options
492
    ) {
493
        if (!\is_string($value)) {
31✔
494
            return $value;
2✔
495
        }
496

497
        // Special handling when converting FROM UTF-8
498
        // Do not trust mbstring when return utf-8 but we want another encoding,
499
        // because it will return true even if it's not really valid.
500
        if (self::ENCODING_UTF8 !== $to && self::isValidUtf8($value)) {
30✔
501
            return self::convertString($value, $to, self::ENCODING_UTF8, $options);
2✔
502
        }
503

504
        // Check if already in target encoding
505
        if (\mb_check_encoding($value, $to)) {
28✔
506
            return self::normalize($value, $to, $options);
22✔
507
        }
508

509
        return self::convertString($value, $to, $from, $options);
7✔
510
    }
511

512
    /**
513
     * Low-level string conversion logic.
514
     *
515
     * @param string $data String to convert
516
     * @param string $to Target encoding
517
     * @param string $from Source encoding
518
     * @param array<string, mixed> $options Conversion options
519
     *
520
     * @return string Converted string or $data if convertion failed
521
     */
522
    private static function convertString(
9✔
523
        string $data,
524
        string $to,
525
        string $from,
526
        array $options
527
    ): string {
528
        // Return original if everything failed
529
        return self::transcodeString($data, $to, $from, $options) ?? $data;
9✔
530
    }
531

532
    /**
533
     * Low-level string transcode logic with fallback strategies.
534
     *
535
     * @param string $data String to transcode
536
     * @param string $to Target encoding
537
     * @param string $from Source encoding
538
     * @param array<string, mixed> $options Conversion options
539
     *
540
     * @return ?string Converted string or null if failed.
541
     */
542
    private static function transcodeString(
24✔
543
        string $data,
544
        string $to,
545
        string $from,
546
        array $options
547
    ): ?string {
548
        $targetEncoding = self::resolveEncoding($to, $data, $options);
24✔
549
        $sourceEncoding = self::resolveEncoding($from, $data, $options);
24✔
550

551
        $result = self::getTranscoderChain()->transcode(
24✔
552
            $data,
24✔
553
            $targetEncoding,
24✔
554
            $sourceEncoding,
24✔
555
            $options
24✔
556
        );
24✔
557

558
        if (null !== $result && self::ENCODING_UTF8 === $targetEncoding) {
24✔
559
            return self::normalize($result, $targetEncoding, $options);
7✔
560
        }
561

562
        return $result;
17✔
563
    }
564

565
    /**
566
     * Repairs a double-encoded value.
567
     *
568
     * @param mixed $value Value to repair
569
     * @param string $to Target encoding
570
     * @param string $from Glitch encoding
571
     * @param array<string, mixed> $options Configuration
572
     *
573
     * @return mixed
574
     */
575
    private static function repairValue(
15✔
576
        $value,
577
        string $to,
578
        string $from,
579
        array $options
580
    ) {
581
        if (!\is_string($value)) {
15✔
582
            // @codeCoverageIgnoreStart
583
            return $value;
584
            // @codeCoverageIgnoreEnd
585
        }
586

587
        /** @var mixed $maxDepth */
588
        $maxDepth = $options['maxDepth'] ?? self::MAX_REPAIR_DEPTH;
15✔
589
        if (!\is_int($maxDepth)) {
15✔
590
            $maxDepth = self::MAX_REPAIR_DEPTH;
1✔
591
        }
592

593
        $fixed = self::peelEncodingLayers($value, $from, $maxDepth);
15✔
594
        $detectedEncoding = self::isValidUtf8($fixed) ? self::ENCODING_UTF8 : $from;
15✔
595

596
        return self::toCharset($fixed, $to, $detectedEncoding, $options);
15✔
597
    }
598

599
    /**
600
     * Attempts to remove multiple encoding layers.
601
     *
602
     * @param string $value String to repair
603
     * @param string $from Encoding to reverse
604
     * @param int $maxDepth Maximum iterations
605
     *
606
     * @return string Repaired string
607
     */
608
    private static function peelEncodingLayers(
15✔
609
        string $value,
610
        string $from,
611
        int $maxDepth
612
    ): string {
613
        $fixed = $value;
15✔
614
        $iterations = 0;
15✔
615
        $options = [
15✔
616
            'normalize' => false,
15✔
617
            'translit' => false,
15✔
618
            'ignore' => false,
15✔
619
        ];
15✔
620

621
        // Loop while it looks like valid UTF-8
622
        while ($iterations < $maxDepth && self::isValidUtf8($fixed)) {
15✔
623
            // Attempt to reverse convert (UTF-8 -> $from)
624
            $test = self::transcodeString($fixed, $from, self::ENCODING_UTF8, $options);
15✔
625

626
            if (null === $test || $test === $fixed || !self::isValidUtf8($test)) {
15✔
627
                break;
15✔
628
            }
629

630
            // If conversion worked AND result is still valid UTF-8 AND result is different
631
            $fixed = $test;
1✔
632
            $iterations++;
1✔
633
        }
634

635
        return $fixed;
15✔
636
    }
637

638
    /**
639
     * Resolves AUTO encoding to actual encoding.
640
     *
641
     * @param string $encoding Encoding constant
642
     * @param string $data String for detection
643
     * @param array<string, mixed> $options Detection options
644
     *
645
     * @return string Resolved encoding
646
     */
647
    private static function resolveEncoding(
24✔
648
        string $encoding,
649
        string $data,
650
        array $options
651
    ): string {
652
        return self::AUTO === $encoding
24✔
653
            // @codeCoverageIgnoreStart
654
            ? self::detect($data, $options)
655
            // @codeCoverageIgnoreEnd
656
            : $encoding;
24✔
657
    }
658

659
    /**
660
     * Normalizes UTF-8 string if needed.
661
     *
662
     * @param string $value String to normalize
663
     * @param string $to Target encoding
664
     * @param array<string, mixed> $options Configuration
665
     *
666
     * @return string Normalized or original string
667
     */
668
    private static function normalize(
28✔
669
        string $value,
670
        string $to,
671
        array $options
672
    ): string {
673
        if (self::ENCODING_UTF8 !== $to || false !== ($options['normalize'] ?? true)) {
28✔
674
            return $value;
28✔
675
        }
676

677
        // @codeCoverageIgnoreStart
678
        if (!\class_exists(Normalizer::class)) {
679
            return $value;
680
        }
681
        // @codeCoverageIgnoreEnd
682

683
        $normalized = Normalizer::normalize($value);
×
684

685
        return false !== $normalized ? $normalized : $value;
×
686
    }
687

688
    /**
689
     * Checks if string is valid UTF-8.
690
     *
691
     * Please not that it will use mb_check_encoding internally,
692
     * and could return true also if it's not really a full utf8 string.
693
     *
694
     * @param string $string String to check
695
     *
696
     * @return bool True if valid UTF-8
697
     */
698
    private static function isValidUtf8(string $string): bool
22✔
699
    {
700
        return \mb_check_encoding($string, self::ENCODING_UTF8);
22✔
701
    }
702

703
    /**
704
     * Validates encoding name against whitelist.
705
     *
706
     * @param string $encoding Encoding to validate
707
     * @param string $type Type for error message (e.g., 'source', 'target')
708
     *
709
     * @throws InvalidArgumentException If encoding is not allowed
710
     */
711
    private static function validateEncoding(string $encoding, string $type): void
34✔
712
    {
713
        $normalized = \strtoupper($encoding);
34✔
714

715
        if (
716
            !\in_array($encoding, self::ALLOWED_ENCODINGS, true)
34✔
717
            && !\in_array($normalized, self::ALLOWED_ENCODINGS, true)
34✔
718
        ) {
719
            throw new InvalidArgumentException(
2✔
720
                \sprintf(
2✔
721
                    'Invalid %s encoding: "%s". Allowed: %s',
2✔
722
                    $type,
2✔
723
                    $encoding,
2✔
724
                    \implode(', ', self::ALLOWED_ENCODINGS)
2✔
725
                )
2✔
726
            );
2✔
727
        }
728
    }
729

730
    /**
731
     * Builds conversion configuration with defaults.
732
     *
733
     * Merges user options with default values, allowing multiple override layers.
734
     *
735
     * @param array<string, mixed> $options User-provided options
736
     * @param array<string, mixed> ...$replacements Additional override layers
737
     *
738
     * @return array<string, mixed> Merged configuration
739
     *
740
     * @example
741
     * // Basic usage
742
     * $config = self::configureOptions(['normalize' => false]);
743
     *
744
     * // With additional defaults
745
     * $config = self::configureOptions(
746
     *     ['normalize' => false],
747
     *     ['maxDepth' => 10]
748
     * );
749
     */
750
    private static function configureOptions(array $options, array ...$replacements): array
33✔
751
    {
752
        $replacements[] = $options;
33✔
753

754
        return \array_replace(
33✔
755
            [
33✔
756
                'normalize' => true,
33✔
757
                'translit' => true,
33✔
758
                'ignore' => true,
33✔
759
                'encodings' => self::DEFAULT_ENCODINGS,
33✔
760
            ],
33✔
761
            ...$replacements
33✔
762
        );
33✔
763
    }
764
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc