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

ducks-project / encoding-repair / 21231385783

22 Jan 2026 12:40AM UTC coverage: 79.793% (+3.8%) from 75.976%
21231385783

push

github

donaldinou
feat : coverage

3 of 5 new or added lines in 2 files covered. (60.0%)

18 existing lines in 2 files now uncovered.

308 of 386 relevant lines covered (79.79%)

7.36 hits per line

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

90.11
/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
     */
UNCOV
92
    private function __construct()
×
93
    {
UNCOV
94
    }
×
95

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

110
        return self::$transcoderChain;
19✔
111
    }
112

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

126
        return self::$detectorChain;
2✔
127
    }
128

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

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

160
        throw new InvalidArgumentException(
1✔
161
            'Transcoder must be an instance of TranscoderInterface or a callable'
1✔
162
        );
1✔
163
    }
164

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

189
        if (\is_callable($detector)) {
2✔
190
            /** @var callable(string, array<string, mixed>|null): (string|null) $detector */
191
            $wrapper = new CallableDetector($detector, $priority ?? 0);
1✔
192
            self::getDetectorChain()->register($wrapper, $priority);
1✔
193
            return;
1✔
194
        }
195

196
        throw new InvalidArgumentException(
1✔
197
            'Detector must be an instance of DetectorInterface or a callable'
1✔
198
        );
1✔
199
    }
200

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

217
        $detected = self::getDetectorChain()->detect($string, $options);
2✔
218

219
        return $detected ?? self::ENCODING_ISO;
2✔
220
    }
221

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

246
        $options = self::configureOptions($options);
24✔
247

248
        // We define the callback logic for a single string
249
        /**
250
         * @psalm-suppress MissingClosureParamType
251
         * @psalm-suppress MissingClosureReturnType
252
         */
253
        $callback = static fn ($value) => self::convertValue($value, $to, $from, $options);
24✔
254

255
        return self::applyRecursive($data, $callback);
24✔
256
    }
257

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

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

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

343
        /**
344
         * @psalm-suppress MissingClosureParamType
345
         * @psalm-suppress MissingClosureReturnType
346
         */
347
        $callback = static fn ($value) => self::repairValue($value, $to, $from, $options);
11✔
348

349
        return self::applyRecursive($data, $callback);
11✔
350
    }
351

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

375
        if (false === $json) {
3✔
UNCOV
376
            throw new RuntimeException(
×
UNCOV
377
                'JSON Encode Error: ' . \json_last_error_msg()
×
UNCOV
378
            );
×
379
        }
380

381
        return $json;
3✔
382
    }
383

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

412
        if (null === $result && \JSON_ERROR_NONE !== \json_last_error()) {
4✔
413
            throw new RuntimeException(
1✔
414
                'JSON Decode Error: ' . \json_last_error_msg()
1✔
415
            );
1✔
416
        }
417

418
        return self::toCharset($result, $to, self::ENCODING_UTF8);
3✔
419
    }
420

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

442
        if (\is_object($data)) {
23✔
443
            return self::applyToObject($data, $callback);
4✔
444
        }
445

446
        // Apply the transformation on scalar value
447
        return $callback($data);
23✔
448
    }
449

450
    /**
451
     * Applies callback to object properties recursively.
452
     *
453
     * @param object $data Object to process
454
     * @param callable $callback Processing function
455
     *
456
     * @return object Cloned object with processed properties
457
     */
458
    private static function applyToObject(object $data, callable $callback): object
4✔
459
    {
460
        $copy = clone $data;
4✔
461

462
        $properties = \get_object_vars($copy);
4✔
463
        /** @var mixed $value */
464
        foreach ($properties as $key => $value) {
4✔
465
            $copy->$key = self::applyRecursive($value, $callback);
4✔
466
        }
467

468
        return $copy;
4✔
469
    }
470

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

491
        // Special handling when converting FROM UTF-8
492
        // Do not trust mbstring when return utf-8 but we want another encoding,
493
        // because it will return true even if it's not really valid.
494
        if (self::ENCODING_UTF8 !== $to && self::isValidUtf8($value)) {
22✔
495
            return self::convertString($value, $to, self::ENCODING_UTF8, $options);
1✔
496
        }
497

498
        // Check if already in target encoding
499
        if (\mb_check_encoding($value, $to)) {
21✔
500
            return self::normalize($value, $to, $options);
15✔
501
        }
502

503
        return self::convertString($value, $to, $from, $options);
7✔
504
    }
505

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

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

545
        $result = self::getTranscoderChain()->transcode(
18✔
546
            $data,
18✔
547
            $targetEncoding,
18✔
548
            $sourceEncoding,
18✔
549
            $options
18✔
550
        );
18✔
551

552
        if (null !== $result && self::ENCODING_UTF8 === $targetEncoding) {
18✔
553
            return self::normalize($result, $targetEncoding, $options);
7✔
554
        }
555

556
        return $result;
11✔
557
    }
558

559
    /**
560
     * Repairs a double-encoded value.
561
     *
562
     * @param mixed $value Value to repair
563
     * @param string $to Target encoding
564
     * @param string $from Glitch encoding
565
     * @param array<string, mixed> $options Configuration
566
     *
567
     * @return mixed
568
     */
569
    private static function repairValue(
10✔
570
        $value,
571
        string $to,
572
        string $from,
573
        array $options
574
    ) {
575
        if (!\is_string($value)) {
10✔
UNCOV
576
            return $value;
×
577
        }
578

579
        /** @var mixed $maxDepth */
580
        $maxDepth = $options['maxDepth'] ?? self::MAX_REPAIR_DEPTH;
10✔
581
        if (!\is_int($maxDepth)) {
10✔
UNCOV
582
            $maxDepth = self::MAX_REPAIR_DEPTH;
×
583
        }
584

585
        $fixed = self::peelEncodingLayers($value, $from, $maxDepth);
10✔
586
        $detectedEncoding = self::isValidUtf8($fixed) ? self::ENCODING_UTF8 : $from;
10✔
587

588
        return self::toCharset($fixed, $to, $detectedEncoding, $options);
10✔
589
    }
590

591
    /**
592
     * Attempts to remove multiple encoding layers.
593
     *
594
     * @param string $value String to repair
595
     * @param string $from Encoding to reverse
596
     * @param int $maxDepth Maximum iterations
597
     *
598
     * @return string Repaired string
599
     */
600
    private static function peelEncodingLayers(
10✔
601
        string $value,
602
        string $from,
603
        int $maxDepth
604
    ): string {
605
        $fixed = $value;
10✔
606
        $iterations = 0;
10✔
607
        $options = [
10✔
608
            'normalize' => false,
10✔
609
            'translit' => false,
10✔
610
            'ignore' => false,
10✔
611
        ];
10✔
612

613
        // Loop while it looks like valid UTF-8
614
        while ($iterations < $maxDepth && self::isValidUtf8($fixed)) {
10✔
615
            // Attempt to reverse convert (UTF-8 -> $from)
616
            $test = self::transcodeString($fixed, $from, self::ENCODING_UTF8, $options);
10✔
617

618
            if (null === $test || $test === $fixed || !self::isValidUtf8($test)) {
10✔
619
                break;
10✔
620
            }
621

622
            // If conversion worked AND result is still valid UTF-8 AND result is different
UNCOV
623
            $fixed = $test;
×
UNCOV
624
            $iterations++;
×
625
        }
626

627
        return $fixed;
10✔
628
    }
629

630
    /**
631
     * Resolves AUTO encoding to actual encoding.
632
     *
633
     * @param string $encoding Encoding constant
634
     * @param string $data String for detection
635
     * @param array<string, mixed> $options Detection options
636
     *
637
     * @return string Resolved encoding
638
     */
639
    private static function resolveEncoding(
18✔
640
        string $encoding,
641
        string $data,
642
        array $options
643
    ): string {
644
        return self::AUTO === $encoding
18✔
UNCOV
645
            ? self::detect($data, $options)
×
646
            : $encoding;
18✔
647
    }
648

649
    /**
650
     * Normalizes UTF-8 string if needed.
651
     *
652
     * @param string $value String to normalize
653
     * @param string $to Target encoding
654
     * @param array<string, mixed> $options Configuration
655
     *
656
     * @return string Normalized or original string
657
     */
658
    private static function normalize(
21✔
659
        string $value,
660
        string $to,
661
        array $options
662
    ): string {
663
        if (self::ENCODING_UTF8 !== $to || false !== ($options['normalize'] ?? true)) {
21✔
664
            return $value;
21✔
665
        }
666

UNCOV
667
        if (!\class_exists(Normalizer::class)) {
×
668
            return $value;
×
669
        }
670

UNCOV
671
        $normalized = Normalizer::normalize($value);
×
672

UNCOV
673
        return false !== $normalized ? $normalized : $value;
×
674
    }
675

676
    /**
677
     * Checks if string is valid UTF-8.
678
     *
679
     * Please not that it will use mb_check_encoding internally,
680
     * and could return true also if it's not really a full utf8 string.
681
     *
682
     * @param string $string String to check
683
     *
684
     * @return bool True if valid UTF-8
685
     */
686
    private static function isValidUtf8(string $string): bool
15✔
687
    {
688
        return \mb_check_encoding($string, self::ENCODING_UTF8);
15✔
689
    }
690

691
    /**
692
     * Validates encoding name against whitelist.
693
     *
694
     * @param string $encoding Encoding to validate
695
     * @param string $type Type for error message (e.g., 'source', 'target')
696
     *
697
     * @throws InvalidArgumentException If encoding is not allowed
698
     */
699
    private static function validateEncoding(string $encoding, string $type): void
26✔
700
    {
701
        $normalized = \strtoupper($encoding);
26✔
702

703
        if (
704
            !\in_array($encoding, self::ALLOWED_ENCODINGS, true)
26✔
705
            && !\in_array($normalized, self::ALLOWED_ENCODINGS, true)
26✔
706
        ) {
707
            throw new InvalidArgumentException(
2✔
708
                \sprintf(
2✔
709
                    'Invalid %s encoding: "%s". Allowed: %s',
2✔
710
                    $type,
2✔
711
                    $encoding,
2✔
712
                    \implode(', ', self::ALLOWED_ENCODINGS)
2✔
713
                )
2✔
714
            );
2✔
715
        }
716
    }
717

718
    /**
719
     * Builds conversion configuration with defaults.
720
     *
721
     * Merges user options with default values, allowing multiple override layers.
722
     *
723
     * @param array<string, mixed> $options User-provided options
724
     * @param array<string, mixed> ...$replacements Additional override layers
725
     *
726
     * @return array<string, mixed> Merged configuration
727
     *
728
     * @example
729
     * // Basic usage
730
     * $config = self::configureOptions(['normalize' => false]);
731
     *
732
     * // With additional defaults
733
     * $config = self::configureOptions(
734
     *     ['normalize' => false],
735
     *     ['maxDepth' => 10]
736
     * );
737
     */
738
    private static function configureOptions(array $options, array ...$replacements): array
25✔
739
    {
740
        $replacements[] = $options;
25✔
741

742
        return \array_replace(
25✔
743
            [
25✔
744
                'normalize' => true,
25✔
745
                'translit' => true,
25✔
746
                'ignore' => true,
25✔
747
                'encodings' => self::DEFAULT_ENCODINGS,
25✔
748
            ],
25✔
749
            ...$replacements
25✔
750
        );
25✔
751
    }
752
}
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