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

ducks-project / encoding-repair / 21228444070

21 Jan 2026 10:45PM UTC coverage: 75.976% (+4.7%) from 71.26%
21228444070

push

github

donaldinou
feat : add object oriented transcoders

97 of 127 new or added lines in 6 files covered. (76.38%)

253 of 333 relevant lines covered (75.98%)

7.94 hits per line

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

77.59
/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 finfo;
23
use InvalidArgumentException;
24
use Normalizer;
25
use RuntimeException;
26

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

49
    private const DEFAULT_ENCODINGS = [
50
        self::ENCODING_UTF8,
51
        self::WINDOWS_1252,
52
        self::ENCODING_ISO,
53
        self::ENCODING_ASCII,
54
    ];
55

56
    private const ALLOWED_ENCODINGS = [
57
        self::AUTO,
58
        self::ENCODING_UTF8,
59
        self::WINDOWS_1252,
60
        self::ENCODING_ISO,
61
        self::ENCODING_ASCII,
62
        self::ENCODING_UTF16,
63
        self::ENCODING_UTF32,
64
    ];
65

66
    private const MAX_REPAIR_DEPTH = 5;
67
    private const JSON_DEFAULT_DEPTH = 512;
68

69
    /**
70
     * Transcoder chain instance.
71
     *
72
     * @var TranscoderChain|null
73
     */
74
    private static $transcoderChain = null;
75

76
    /**
77
     * List of internal detectors (Providers) by priority.
78
     *
79
     * @var list<string|callable(string, array<string, mixed>): (string|null)>
80
     */
81
    private static $detectors = [
82
        'detectWithMbString',
83
        'detectWithFileInfo',
84
    ];
85

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

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

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

112
    /**
113
     * Register a transcoder with optional priority.
114
     *
115
     * @phpcs:disable Generic.Files.LineLength.TooLong
116
     *
117
     * @param TranscoderInterface|callable(string, string, string, null|array<string, mixed>): (string|null) $transcoder Transcoder instance or callable
118
     * @param int|null $priority Priority override (null = use transcoder's default)
119
     *
120
     * @return void
121
     *
122
     * @throws InvalidArgumentException If transcoder is invalid
123
     *
124
     * @phpcs:enable Generic.Files.LineLength.TooLong
125
     */
126
    public static function registerTranscoder(
2✔
127
        $transcoder,
128
        ?int $priority = null
129
    ): void {
130
        /** @var mixed $transcoder */
131
        if ($transcoder instanceof TranscoderInterface) {
2✔
NEW
132
            self::getTranscoderChain()->register($transcoder, $priority);
×
NEW
133
            return;
×
134
        }
135

136
        if (\is_callable($transcoder)) {
2✔
137
            /** @var callable(string, string, string, null|array<string, mixed>): (string|null) $transcoder */
138
            $wrapper = new CallableTranscoder($transcoder, $priority ?? 0);
1✔
139
            self::getTranscoderChain()->register($wrapper, $priority);
1✔
140
            return;
1✔
141
        }
142

143
        throw new InvalidArgumentException(
1✔
144
            'Transcoder must be an instance of TranscoderInterface or a callable'
1✔
145
        );
1✔
146
    }
147

148
    /**
149
     * Register a custom detector provider.
150
     *
151
     * @phpcs:disable Generic.Files.LineLength.TooLong
152
     *
153
     * @param string|callable(string, array<string, mixed>): (string|null) $detector Method name or callable with signature: fn (string, string, string, array): string|null
154
     * @param bool $prepend Priority (Top of the list)
155
     *
156
     * @return void
157
     *
158
     * @throws InvalidArgumentException If detector is invalid
159
     *
160
     * @phpcs:enable Generic.Files.LineLength.TooLong
161
     */
162
    public static function registerDetector(
2✔
163
        $detector,
164
        bool $prepend = true
165
    ): void {
166
        self::validateDetector($detector);
2✔
167

168
        if ($prepend) {
1✔
169
            \array_unshift(self::$detectors, $detector);
1✔
170
        } else {
171
            self::$detectors[] = $detector;
×
172
        }
173
    }
174

175
    /**
176
     * Detects the charset encoding of a string.
177
     *
178
     * @param string $string String to analyze
179
     * @param array<string, mixed> $options Conversion options
180
     *                                      - 'encodings': array of encodings to test
181
     *
182
     * @return string Detected encoding (uppercase)
183
     */
184
    public static function detect(string $string, array $options = []): string
4✔
185
    {
186
        // Fast common return.
187
        if (self::isValidUtf8($string)) {
4✔
188
            return self::ENCODING_UTF8;
2✔
189
        }
190

191
        // Loop over registered detectors
192
        foreach (self::$detectors as $detector) {
2✔
193
            try {
194
                $args = [$string, $options];
2✔
195
                $detected = self::invokeProvider($detector, ...$args);
2✔
196
            } catch (\Throwable $th) {
×
197
                continue;
×
198
            }
199

200
            if (null !== $detected) {
2✔
201
                return $detected;
2✔
202
            }
203
        }
204

205
        return self::ENCODING_ISO;
×
206
    }
207

208
    /**
209
     * Convert $data string from one encoding to another.
210
     *
211
     * @param mixed $data Data to convert
212
     * @param string $to Target encoding
213
     * @param string $from Source encoding (use AUTO for detection)
214
     * @param array<string, mixed> $options Conversion options
215
     *                                      - 'normalize': bool (default: true)
216
     *                                      - 'translit': bool (default: true)
217
     *                                      - 'ignore': bool (default: true)
218
     *
219
     * @return mixed The data transcoded in the target encoding
220
     *
221
     * @throws InvalidArgumentException If encoding is invalid
222
     */
223
    public static function toCharset(
26✔
224
        $data,
225
        string $to = self::ENCODING_UTF8,
226
        string $from = self::ENCODING_ISO,
227
        array $options = []
228
    ) {
229
        self::validateEncoding($to, 'target');
26✔
230
        self::validateEncoding($from, 'source');
25✔
231

232
        $options = self::configureOptions($options);
24✔
233

234
        // We define the callback logic for a single string
235
        /**
236
         * @psalm-suppress MissingClosureParamType
237
         * @psalm-suppress MissingClosureReturnType
238
         */
239
        $callback = static fn ($value) => self::convertValue($value, $to, $from, $options);
24✔
240

241
        return self::applyRecursive($data, $callback);
24✔
242
    }
243

244
    /**
245
     * Converts anything (string, array, object) to UTF-8.
246
     *
247
     * @param mixed $data Data to convert
248
     * @param string $from Source encoding
249
     * @param array<string, mixed> $options Conversion options
250
     *                                      - 'normalize': bool (default: true)
251
     *                                      - 'translit': bool (default: true)
252
     *                                      - 'ignore': bool (default: true)
253
     *
254
     * @return mixed
255
     *
256
     * @throws InvalidArgumentException If encoding is invalid
257
     */
258
    public static function toUtf8(
8✔
259
        $data,
260
        string $from = self::WINDOWS_1252,
261
        array $options = []
262
    ) {
263
        return self::toCharset(
8✔
264
            $data,
8✔
265
            self::ENCODING_UTF8,
8✔
266
            $from,
8✔
267
            $options
8✔
268
        );
8✔
269
    }
270

271
    /**
272
     * Converts anything to ISO-8859-1 (Windows-1252).
273
     *
274
     * @param mixed $data Data to convert
275
     * @param string $from Source encoding
276
     * @param array<string, mixed> $options Conversion options
277
     *                                      - 'normalize': bool (default: true)
278
     *                                      - 'translit': bool (default: true)
279
     *                                      - 'ignore': bool (default: true)
280
     *
281
     * @return mixed
282
     *
283
     * @throws InvalidArgumentException If encoding is invalid
284
     */
285
    public static function toIso(
1✔
286
        $data,
287
        string $from = self::ENCODING_UTF8,
288
        array $options = []
289
    ) {
290
        return self::toCharset(
1✔
291
            $data,
1✔
292
            self::WINDOWS_1252,
1✔
293
            $from,
1✔
294
            $options
1✔
295
        );
1✔
296
    }
297

298
    /**
299
     * Repairs double-encoded strings.
300
     *
301
     * Attempts to fix strings that have been encoded multiple times
302
     * by detecting and reversing the encoding layers.
303
     * Pay attention that it will first repair within UTF-8, then converts to $to.
304
     *
305
     * @param mixed $data Data to repair
306
     * @param string $to Target encoding (UTF-8, ISO, etc.)
307
     * @param string $from The "glitch" encoding (usually ISO/Windows-1252) that caused the double encoding.
308
     * @param array<string,mixed> $options Conversion options
309
     *                                     - 'normalize': bool (default: true)
310
     *                                     - 'translit': bool (default: true)
311
     *                                     - 'ignore': bool (default: true)
312
     *                                     - 'maxDepth' : int (default: 5)
313
     *
314
     * @return mixed
315
     *
316
     * @throws InvalidArgumentException If encoding is invalid
317
     */
318
    public static function repair(
11✔
319
        $data,
320
        string $to = self::ENCODING_UTF8,
321
        string $from = self::ENCODING_ISO,
322
        array $options = []
323
    ) {
324
        $options = self::configureOptions(
11✔
325
            $options,
11✔
326
            ['maxDepth' => self::MAX_REPAIR_DEPTH]
11✔
327
        );
11✔
328

329
        /**
330
         * @psalm-suppress MissingClosureParamType
331
         * @psalm-suppress MissingClosureReturnType
332
         */
333
        $callback = static fn ($value) => self::repairValue($value, $to, $from, $options);
11✔
334

335
        return self::applyRecursive($data, $callback);
11✔
336
    }
337

338
    /**
339
     * Safe JSON encoding to ensure UTF-8 compliance.
340
     *
341
     * @param mixed $data
342
     * @param int $flags JSON encode flags
343
     * @param int<1, 2147483647> $depth Maximum depth
344
     * @param string $from Source encoding for repair
345
     *
346
     * @return string JSON UTF-8 string
347
     *
348
     * @throws RuntimeException if error occured.
349
     */
350
    public static function safeJsonEncode(
3✔
351
        $data,
352
        int $flags = 0,
353
        int $depth = self::JSON_DEFAULT_DEPTH,
354
        string $from = self::WINDOWS_1252
355
    ): string {
356
        /** @var mixed $data */
357
        $data = self::repair($data, self::ENCODING_UTF8, $from);
3✔
358
        /** @var string|false $json */
359
        $json = \json_encode($data, $flags, $depth);
3✔
360

361
        if (false === $json) {
3✔
362
            throw new RuntimeException(
×
363
                'JSON Encode Error: ' . \json_last_error_msg()
×
364
            );
×
365
        }
366

367
        return $json;
3✔
368
    }
369

370
    /**
371
     * Safe JSON decoding with charset conversion.
372
     *
373
     * @param string $json JSON string
374
     * @param bool|null $associative Return associative array
375
     * @param int<1, 2147483647> $depth Maximum depth
376
     * @param int $flags JSON decode flags
377
     * @param string $to Target encoding
378
     * @param string $from Source encoding for repair
379
     *
380
     * @return mixed Decoded data
381
     *
382
     * @throws RuntimeException If decoding fails
383
     */
384
    public static function safeJsonDecode(
4✔
385
        string $json,
386
        ?bool $associative = null,
387
        int $depth = self::JSON_DEFAULT_DEPTH,
388
        int $flags = 0,
389
        string $to = self::ENCODING_UTF8,
390
        string $from = self::WINDOWS_1252
391
    ) {
392
        // Repair string to a valid UTF-8 for decoding
393
        /** @var string $data */
394
        $data = self::repair($json, self::ENCODING_UTF8, $from);
4✔
395
        /** @var mixed $result */
396
        $result = \json_decode($data, $associative, $depth, $flags);
4✔
397

398
        if (null === $result && \JSON_ERROR_NONE !== \json_last_error()) {
4✔
399
            throw new RuntimeException(
1✔
400
                'JSON Decode Error: ' . \json_last_error_msg()
1✔
401
            );
1✔
402
        }
403

404
        return self::toCharset($result, $to, self::ENCODING_UTF8);
3✔
405
    }
406

407
    /**
408
     * Applies a callback recursively to arrays, objects, and scalar values.
409
     *
410
     * @param mixed $data Data to process
411
     * @param callable $callback Processing callback function
412
     *
413
     * @return mixed
414
     */
415
    private static function applyRecursive($data, callable $callback)
25✔
416
    {
417
        if (\is_array($data)) {
25✔
418
            return \array_map(
11✔
419
                /**
420
                 * @psalm-suppress MissingClosureReturnType
421
                 * @psalm-suppress MissingClosureParamType
422
                 */
423
                static fn ($item) => self::applyRecursive($item, $callback),
11✔
424
                $data
11✔
425
            );
11✔
426
        }
427

428
        if (\is_object($data)) {
23✔
429
            return self::applyToObject($data, $callback);
4✔
430
        }
431

432
        // Apply the transformation on scalar value
433
        return $callback($data);
23✔
434
    }
435

436
    /**
437
     * Applies callback to object properties recursively.
438
     *
439
     * @param object $data Object to process
440
     * @param callable $callback Processing function
441
     *
442
     * @return object Cloned object with processed properties
443
     */
444
    private static function applyToObject(object $data, callable $callback): object
4✔
445
    {
446
        $copy = clone $data;
4✔
447

448
        $properties = \get_object_vars($copy);
4✔
449
        /** @var mixed $value */
450
        foreach ($properties as $key => $value) {
4✔
451
            $copy->$key = self::applyRecursive($value, $callback);
4✔
452
        }
453

454
        return $copy;
4✔
455
    }
456

457
    /**
458
     * Converts a single value to target encoding.
459
     *
460
     * @param mixed $value Value to convert
461
     * @param string $to Target encoding
462
     * @param string $from Source encoding
463
     * @param array<string, mixed> $options Conversion configuration
464
     *
465
     * @return mixed
466
     */
467
    private static function convertValue(
23✔
468
        $value,
469
        string $to,
470
        string $from,
471
        array $options
472
    ) {
473
        if (!\is_string($value)) {
23✔
474
            return $value;
2✔
475
        }
476

477
        // Special handling when converting FROM UTF-8
478
        // Do not trust mbstring when return utf-8 but we want another encoding,
479
        // because it will return true even if it's not really valid.
480
        if (self::ENCODING_UTF8 !== $to && self::isValidUtf8($value)) {
22✔
481
            return self::convertString($value, $to, self::ENCODING_UTF8, $options);
1✔
482
        }
483

484
        // Check if already in target encoding
485
        if (\mb_check_encoding($value, $to)) {
21✔
486
            return self::normalize($value, $to, $options);
15✔
487
        }
488

489
        return self::convertString($value, $to, $from, $options);
7✔
490
    }
491

492
    /**
493
     * Low-level string conversion logic.
494
     *
495
     * @param string $data String to convert
496
     * @param string $to Target encoding
497
     * @param string $from Source encoding
498
     * @param array<string, mixed> $options Conversion options
499
     *
500
     * @return string Converted string or $data if convertion failed
501
     */
502
    private static function convertString(
8✔
503
        string $data,
504
        string $to,
505
        string $from,
506
        array $options
507
    ): string {
508
        // Return original if everything failed
509
        return self::transcodeString($data, $to, $from, $options) ?? $data;
8✔
510
    }
511

512
    /**
513
     * Low-level string transcode logic with fallback strategies.
514
     *
515
     * @param string $data String to transcode
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 null if failed.
521
     */
522
    private static function transcodeString(
18✔
523
        string $data,
524
        string $to,
525
        string $from,
526
        array $options
527
    ): ?string {
528
        $targetEncoding = self::resolveEncoding($to, $data, $options);
18✔
529
        $sourceEncoding = self::resolveEncoding($from, $data, $options);
18✔
530

531
        $result = self::getTranscoderChain()->transcode(
18✔
532
            $data,
18✔
533
            $targetEncoding,
18✔
534
            $sourceEncoding,
18✔
535
            $options
18✔
536
        );
18✔
537

538
        if (null !== $result && self::ENCODING_UTF8 === $targetEncoding) {
18✔
539
            return self::normalize($result, $targetEncoding, $options);
7✔
540
        }
541

542
        return $result;
11✔
543
    }
544

545
    /**
546
     * Repairs a double-encoded value.
547
     *
548
     * @param mixed $value Value to repair
549
     * @param string $to Target encoding
550
     * @param string $from Glitch encoding
551
     * @param array<string, mixed> $options Configuration
552
     *
553
     * @return mixed
554
     */
555
    private static function repairValue(
10✔
556
        $value,
557
        string $to,
558
        string $from,
559
        array $options
560
    ) {
561
        if (!\is_string($value)) {
10✔
562
            return $value;
×
563
        }
564

565
        /** @var mixed $maxDepth */
566
        $maxDepth = $options['maxDepth'] ?? self::MAX_REPAIR_DEPTH;
10✔
567
        if (!\is_int($maxDepth)) {
10✔
568
            $maxDepth = self::MAX_REPAIR_DEPTH;
×
569
        }
570

571
        $fixed = self::peelEncodingLayers($value, $from, $maxDepth);
10✔
572
        $detectedEncoding = self::isValidUtf8($fixed) ? self::ENCODING_UTF8 : $from;
10✔
573

574
        return self::toCharset($fixed, $to, $detectedEncoding, $options);
10✔
575
    }
576

577
    /**
578
     * Attempts to remove multiple encoding layers.
579
     *
580
     * @param string $value String to repair
581
     * @param string $from Encoding to reverse
582
     * @param int $maxDepth Maximum iterations
583
     *
584
     * @return string Repaired string
585
     */
586
    private static function peelEncodingLayers(
10✔
587
        string $value,
588
        string $from,
589
        int $maxDepth
590
    ): string {
591
        $fixed = $value;
10✔
592
        $iterations = 0;
10✔
593
        $options = [
10✔
594
            'normalize' => false,
10✔
595
            'translit' => false,
10✔
596
            'ignore' => false,
10✔
597
        ];
10✔
598

599
        // Loop while it looks like valid UTF-8
600
        while ($iterations < $maxDepth && self::isValidUtf8($fixed)) {
10✔
601
            // Attempt to reverse convert (UTF-8 -> $from)
602
            $test = self::transcodeString($fixed, $from, self::ENCODING_UTF8, $options);
10✔
603

604
            if (null === $test || $test === $fixed || !self::isValidUtf8($test)) {
10✔
605
                break;
10✔
606
            }
607

608
            // If conversion worked AND result is still valid UTF-8 AND result is different
609
            $fixed = $test;
×
610
            $iterations++;
×
611
        }
612

613
        return $fixed;
10✔
614
    }
615

616
    /**
617
     * Resolves AUTO encoding to actual encoding.
618
     *
619
     * @param string $encoding Encoding constant
620
     * @param string $data String for detection
621
     * @param array<string, mixed> $options Detection options
622
     *
623
     * @return string Resolved encoding
624
     */
625
    private static function resolveEncoding(
18✔
626
        string $encoding,
627
        string $data,
628
        array $options
629
    ): string {
630
        return self::AUTO === $encoding
18✔
631
            ? self::detect($data, $options)
×
632
            : $encoding;
18✔
633
    }
634

635
    /**
636
     * Invokes a provider (method name or callable) with given arguments.
637
     *
638
     * @phpcs:disable Generic.Files.LineLength.TooLong
639
     *
640
     * @param string|callable(string, string, string, array<string, mixed>): (string|null)|callable(string, array<string, mixed>): (string|null) $provider Provider to call (method name or callable)
641
     * @param array<mixed>|string $args Arguments to pass to the provider
642
     *
643
     * @return string|null Result of the provider call
644
     *
645
     * @throws InvalidArgumentException when provider is not callable.
646
     *
647
     * @psalm-param array<string, mixed>|string $args
648
     *
649
     * @phpcs:enable Generic.Files.LineLength.TooLong
650
     */
651
    private static function invokeProvider($provider, ...$args)
2✔
652
    {
653
        /** @var mixed $result */
654
        $result = null;
2✔
655

656
        if (\is_string($provider) && \method_exists(self::class, $provider)) {
2✔
657
            /** @var mixed $result */
658
            $result = self::$provider(...$args);
1✔
659
        } elseif (\is_callable($provider)) {
1✔
660
            /**
661
             * @psalm-suppress InvalidArgument
662
             * @psalm-suppress MixedArgument
663
             */
664
            $result = $provider(...$args);
1✔
665
        }
666

667
        if (null !== $result && !\is_string($result)) {
2✔
668
            throw new InvalidArgumentException('Provider is not callable');
×
669
        }
670

671
        return $result;
2✔
672
    }
673

674
    /**
675
     * Validates a provider before registration.
676
     *
677
     * @param mixed $provider Provider to validate
678
     * @param string $type Type name for error message
679
     *
680
     * @throws InvalidArgumentException If provider is invalid
681
     */
682
    private static function validateProvider($provider, string $type): void
2✔
683
    {
684
        if (!\is_string($provider) && !\is_callable($provider)) {
2✔
685
            throw new InvalidArgumentException(
1✔
686
                \sprintf(
1✔
687
                    '%s must be a string (method name) or callable',
1✔
688
                    $type
1✔
689
                )
1✔
690
            );
1✔
691
        }
692

693
        if (\is_string($provider) && !\method_exists(self::class, $provider)) {
1✔
694
            throw new InvalidArgumentException(
×
695
                \sprintf(
×
696
                    'Method "%s" does not exist in %s',
×
697
                    $provider,
×
698
                    self::class
×
699
                )
×
700
            );
×
701
        }
702
    }
703

704
    /**
705
     * Validates a detector before registration.
706
     *
707
     * @param mixed $detector Detector to validate
708
     *
709
     * @throws InvalidArgumentException If invalid
710
     */
711
    private static function validateDetector($detector): void
2✔
712
    {
713
        self::validateProvider($detector, 'Detector');
2✔
714
    }
715

716
    /**
717
     * Normalizes UTF-8 string if needed.
718
     *
719
     * @param string $value String to normalize
720
     * @param string $to Target encoding
721
     * @param array<string, mixed> $options Configuration
722
     *
723
     * @return string Normalized or original string
724
     */
725
    private static function normalize(
21✔
726
        string $value,
727
        string $to,
728
        array $options
729
    ): string {
730
        if (self::ENCODING_UTF8 !== $to || false !== ($options['normalize'] ?? true)) {
21✔
731
            return $value;
21✔
732
        }
733

734
        if (!\class_exists(Normalizer::class)) {
×
735
            return $value;
×
736
        }
737

738
        $normalized = Normalizer::normalize($value);
×
739

740
        return false !== $normalized ? $normalized : $value;
×
741
    }
742

743
    /**
744
     * Detects encoding using mbstring extension.
745
     *
746
     * @param string $string String to analyze
747
     * @param array<string, mixed> $options Options usable by mb_string
748
     *                                      - encodings : A list of character encodings to try
749
     *
750
     * @return string|null Detected encoding or null
751
     *
752
     * @psalm-api
753
     */
754
    private static function detectWithMbString(string $string, array $options = []): ?string
1✔
755
    {
756
        /** @var mixed|list<string> */
757
        $encodings = $options['encodings'] ?? self::DEFAULT_ENCODINGS;
1✔
758

759
        if (!\is_array($encodings)) {
1✔
760
            $encodings = self::DEFAULT_ENCODINGS;
×
761
        }
762

763
        $detected = \mb_detect_encoding($string, $encodings, true);
1✔
764

765
        return false !== $detected ? $detected : null;
1✔
766
    }
767

768
    /**
769
     * Detects encoding using FileInfo extension.
770
     *
771
     * @param string $string String to analyze
772
     * @param array<string, mixed> $options Options usable by finfo
773
     *
774
     * @return string|null Detected encoding or null
775
     *
776
     * @psalm-api
777
     */
778
    private static function detectWithFileInfo(string $string, array $options = []): ?string
×
779
    {
780
        if (!\class_exists(finfo::class)) {
×
781
            return null;
×
782
        }
783

784
        // use an array in order to pass args througt functions
785
        // in order to ensure several php compatibility.
786
        $args = [];
×
787

788
        /** @var mixed|string|null $magic */
789
        $magic = $options['finfo_magic'] ?? null;
×
790
        if (\is_string($magic)) {
×
791
            $args[] = $magic;
×
792
        }
793

794
        $finfo = new finfo(FILEINFO_MIME_ENCODING, ...$args);
×
795

796
        $args = [];
×
797

798
        /** @var mixed|int */
799
        $flags = $options['finfo_flags'] ?? \FILEINFO_NONE;
×
800
        if (!\is_int($flags)) {
×
801
            $flags = \FILEINFO_NONE;
×
802
        }
803
        $args[] = $flags;
×
804

805
        /** @var mixed|resource|null */
806
        $context = $options['finfo_context'] ?? null;
×
807
        if (\is_resource($context)) {
×
808
            $args[] = $context;
×
809
        }
810

811
        $detected = $finfo->buffer(
×
812
            $string,
×
813
            ...$args
×
814
        );
×
815

816
        if (false === $detected || 'binary' === $detected) {
×
817
            return null;
×
818
        }
819

820
        // Returns things like 'iso-8859-1', we uppercase it
821
        return \strtoupper($detected);
×
822
    }
823

824
    /**
825
     * Checks if string is valid UTF-8.
826
     *
827
     * Please not that it will use mb_check_encoding internally,
828
     * and could return true also if it's not really a full utf8 string.
829
     *
830
     * @param string $string String to check
831
     *
832
     * @return bool True if valid UTF-8
833
     */
834
    private static function isValidUtf8(string $string): bool
15✔
835
    {
836
        return \mb_check_encoding($string, self::ENCODING_UTF8);
15✔
837
    }
838

839
    /**
840
     * Validates encoding name against whitelist.
841
     *
842
     * @param string $encoding Encoding to validate
843
     * @param string $type Type for error message (e.g., 'source', 'target')
844
     *
845
     * @throws InvalidArgumentException If encoding is not allowed
846
     */
847
    private static function validateEncoding(string $encoding, string $type): void
26✔
848
    {
849
        $normalized = \strtoupper($encoding);
26✔
850

851
        if (
852
            !\in_array($encoding, self::ALLOWED_ENCODINGS, true)
26✔
853
            && !\in_array($normalized, self::ALLOWED_ENCODINGS, true)
26✔
854
        ) {
855
            throw new InvalidArgumentException(
2✔
856
                \sprintf(
2✔
857
                    'Invalid %s encoding: "%s". Allowed: %s',
2✔
858
                    $type,
2✔
859
                    $encoding,
2✔
860
                    \implode(', ', self::ALLOWED_ENCODINGS)
2✔
861
                )
2✔
862
            );
2✔
863
        }
864
    }
865

866
    /**
867
     * Builds conversion configuration with defaults.
868
     *
869
     * Merges user options with default values, allowing multiple override layers.
870
     *
871
     * @param array<string, mixed> $options User-provided options
872
     * @param array<string, mixed> ...$replacements Additional override layers
873
     *
874
     * @return array<string, mixed> Merged configuration
875
     *
876
     * @example
877
     * // Basic usage
878
     * $config = self::configureOptions(['normalize' => false]);
879
     *
880
     * // With additional defaults
881
     * $config = self::configureOptions(
882
     *     ['normalize' => false],
883
     *     ['maxDepth' => 10]
884
     * );
885
     */
886
    private static function configureOptions(array $options, array ...$replacements): array
25✔
887
    {
888
        $replacements[] = $options;
25✔
889

890
        return \array_replace(
25✔
891
            [
25✔
892
                'normalize' => true,
25✔
893
                'translit' => true,
25✔
894
                'ignore' => true,
25✔
895
                'encodings' => self::DEFAULT_ENCODINGS,
25✔
896
            ],
25✔
897
            ...$replacements
25✔
898
        );
25✔
899
    }
900
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc