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

ducks-project / encoding-repair / 21247849814

22 Jan 2026 12:07PM UTC coverage: 90.223% (-9.8%) from 100.0%
21247849814

push

github

donaldinou
feat : add new Processor Service

147 of 182 new or added lines in 2 files covered. (80.77%)

323 of 358 relevant lines covered (90.22%)

13.96 hits per line

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

78.79
/CharsetProcessor.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\Detector\DetectorChain;
17
use Ducks\Component\EncodingRepair\Detector\DetectorInterface;
18
use Ducks\Component\EncodingRepair\Detector\FileInfoDetector;
19
use Ducks\Component\EncodingRepair\Detector\MbStringDetector;
20
use Ducks\Component\EncodingRepair\Transcoder\IconvTranscoder;
21
use Ducks\Component\EncodingRepair\Transcoder\MbStringTranscoder;
22
use Ducks\Component\EncodingRepair\Transcoder\TranscoderChain;
23
use Ducks\Component\EncodingRepair\Transcoder\TranscoderInterface;
24
use Ducks\Component\EncodingRepair\Transcoder\UConverterTranscoder;
25
use InvalidArgumentException;
26
use Normalizer;
27
use RuntimeException;
28

29
/**
30
 * Charset processing service.
31
 *
32
 * @final
33
 */
34
final class CharsetProcessor implements CharsetProcessorInterface
35
{
36
    private const DEFAULT_ENCODINGS = [
37
        self::ENCODING_UTF8,
38
        self::WINDOWS_1252,
39
        self::ENCODING_ISO,
40
        self::ENCODING_ASCII,
41
    ];
42

43
    private const MAX_REPAIR_DEPTH = 5;
44
    private const JSON_DEFAULT_DEPTH = 512;
45

46
    /**
47
     * @var TranscoderChain
48
     */
49
    private TranscoderChain $transcoderChain;
50

51
    /**
52
     * @var DetectorChain
53
     */
54
    private DetectorChain $detectorChain;
55

56
    /**
57
     * @var list<string>
58
     */
59
    private $allowedEncodings;
60

61
    public function __construct()
41✔
62
    {
63
        $this->transcoderChain = new TranscoderChain();
41✔
64
        $this->detectorChain = new DetectorChain();
41✔
65
        $this->allowedEncodings = [
41✔
66
            self::AUTO,
41✔
67
            self::ENCODING_UTF8,
41✔
68
            self::WINDOWS_1252,
41✔
69
            self::ENCODING_ISO,
41✔
70
            self::ENCODING_ASCII,
41✔
71
            self::ENCODING_UTF16,
41✔
72
            self::ENCODING_UTF32,
41✔
73
        ];
41✔
74

75
        $this->resetTranscoders();
41✔
76
        $this->resetDetectors();
41✔
77
    }
78

79
    /**
80
     * @inheritDoc
81
     */
82
    public function registerTranscoder(TranscoderInterface $transcoder, ?int $priority = null): self
1✔
83
    {
84
        $this->transcoderChain->register($transcoder, $priority);
1✔
85

86
        return $this;
1✔
87
    }
88

89
    /**
90
     * @inheritDoc
91
     */
NEW
92
    public function unregisterTranscoder(TranscoderInterface $transcoder): self
×
93
    {
94
        // Note: Unregister not implemented in chain yet
95
        // This would require tracking registered transcoders
NEW
96
        return $this;
×
97
    }
98

99
    /**
100
     * @inheritDoc
101
     */
NEW
102
    public function queueTranscoders(TranscoderInterface ...$transcoders): self
×
103
    {
NEW
104
        foreach ($transcoders as $transcoder) {
×
NEW
105
            $this->registerTranscoder($transcoder);
×
106
        }
107

NEW
108
        return $this;
×
109
    }
110

111
    /**
112
     * @inheritDoc
113
     */
114
    public function resetTranscoders(): self
41✔
115
    {
116
        $this->transcoderChain = new TranscoderChain();
41✔
117
        $this->transcoderChain->register(new UConverterTranscoder());
41✔
118
        $this->transcoderChain->register(new IconvTranscoder());
41✔
119
        $this->transcoderChain->register(new MbStringTranscoder());
41✔
120

121
        return $this;
41✔
122
    }
123

124
    /**
125
     * @inheritDoc
126
     */
127
    public function registerDetector(DetectorInterface $detector, ?int $priority = null): self
1✔
128
    {
129
        $this->detectorChain->register($detector, $priority);
1✔
130

131
        return $this;
1✔
132
    }
133

134
    /**
135
     * @inheritDoc
136
     */
NEW
137
    public function unregisterDetector(DetectorInterface $detector): self
×
138
    {
139
        // Note: Unregister not implemented in chain yet
NEW
140
        return $this;
×
141
    }
142

143
    /**
144
     * @inheritDoc
145
     */
NEW
146
    public function queueDetectors(DetectorInterface ...$detectors): self
×
147
    {
NEW
148
        foreach ($detectors as $detector) {
×
NEW
149
            $this->registerDetector($detector);
×
150
        }
151

NEW
152
        return $this;
×
153
    }
154

155
    /**
156
     * @inheritDoc
157
     */
158
    public function resetDetectors(): self
41✔
159
    {
160
        $this->detectorChain = new DetectorChain();
41✔
161
        $this->detectorChain->register(new MbStringDetector());
41✔
162
        $this->detectorChain->register(new FileInfoDetector());
41✔
163

164
        return $this;
41✔
165
    }
166

167
    /**
168
     * @inheritDoc
169
     */
NEW
170
    public function addEncodings(string ...$encodings): self
×
171
    {
NEW
172
        foreach ($encodings as $encoding) {
×
NEW
173
            if (!\in_array($encoding, $this->allowedEncodings, true)) {
×
NEW
174
                $this->allowedEncodings[] = $encoding;
×
175
            }
176
        }
177

NEW
178
        return $this;
×
179
    }
180

181
    /**
182
     * @inheritDoc
183
     */
NEW
184
    public function removeEncodings(string ...$encodings): self
×
185
    {
NEW
186
        $this->allowedEncodings = \array_values(
×
NEW
187
            \array_diff($this->allowedEncodings, $encodings)
×
NEW
188
        );
×
189

NEW
190
        return $this;
×
191
    }
192

193
    /**
194
     * @inheritDoc
195
     */
NEW
196
    public function getEncodings(): array
×
197
    {
NEW
198
        return $this->allowedEncodings;
×
199
    }
200

201
    /**
202
     * @inheritDoc
203
     */
NEW
204
    public function resetEncodings(): self
×
205
    {
NEW
206
        $this->allowedEncodings = [
×
NEW
207
            self::AUTO,
×
NEW
208
            self::ENCODING_UTF8,
×
NEW
209
            self::WINDOWS_1252,
×
NEW
210
            self::ENCODING_ISO,
×
NEW
211
            self::ENCODING_ASCII,
×
NEW
212
            self::ENCODING_UTF16,
×
NEW
213
            self::ENCODING_UTF32,
×
NEW
214
        ];
×
215

NEW
216
        return $this;
×
217
    }
218

219
    /**
220
     * @inheritDoc
221
     */
222
    public function detect(string $string, array $options = []): string
5✔
223
    {
224
        if ($this->isValidUtf8($string)) {
5✔
225
            return self::ENCODING_UTF8;
2✔
226
        }
227

228
        $detected = $this->detectorChain->detect($string, $options);
3✔
229

230
        return $detected ?? self::ENCODING_ISO;
3✔
231
    }
232

233
    /**
234
     * @inheritDoc
235
     */
236
    public function toCharset(
34✔
237
        $data,
238
        string $to = self::ENCODING_UTF8,
239
        string $from = self::ENCODING_ISO,
240
        array $options = []
241
    ) {
242
        $this->validateEncoding($to, 'target');
34✔
243
        $this->validateEncoding($from, 'source');
33✔
244

245
        $options = $this->configureOptions($options);
32✔
246

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

254
        return $this->applyRecursive($data, $callback);
32✔
255
    }
256

257
    /**
258
     * @inheritDoc
259
     */
260
    public function toUtf8($data, string $from = self::WINDOWS_1252, array $options = [])
8✔
261
    {
262
        return $this->toCharset($data, self::ENCODING_UTF8, $from, $options);
8✔
263
    }
264

265
    /**
266
     * @inheritDoc
267
     */
268
    public function toIso($data, string $from = self::ENCODING_UTF8, array $options = [])
1✔
269
    {
270
        return $this->toCharset($data, self::WINDOWS_1252, $from, $options);
1✔
271
    }
272

273
    /**
274
     * @inheritDoc
275
     */
276
    public function repair(
17✔
277
        $data,
278
        string $to = self::ENCODING_UTF8,
279
        string $from = self::ENCODING_ISO,
280
        array $options = []
281
    ) {
282
        $options = $this->configureOptions($options, ['maxDepth' => self::MAX_REPAIR_DEPTH]);
17✔
283

284
        /**
285
         * @psalm-suppress MissingClosureParamType
286
         * @psalm-suppress MissingClosureReturnType
287
         */
288
        $callback = fn ($value) => $this->repairValue($value, $to, $from, $options);
17✔
289

290
        return $this->applyRecursive($data, $callback);
17✔
291
    }
292

293
    /**
294
     * @inheritDoc
295
     */
296
    public function safeJsonEncode(
5✔
297
        $data,
298
        int $flags = 0,
299
        int $depth = self::JSON_DEFAULT_DEPTH,
300
        string $from = self::WINDOWS_1252
301
    ): string {
302
        /** @var mixed $data */
303
        $data = $this->repair($data, self::ENCODING_UTF8, $from);
5✔
304
        /** @var string|false $json */
305
        $json = \json_encode($data, $flags, $depth);
5✔
306

307
        if (false === $json) {
5✔
308
            throw new RuntimeException('JSON Encode Error: ' . \json_last_error_msg());
1✔
309
        }
310

311
        return $json;
4✔
312
    }
313

314
    /**
315
     * @inheritDoc
316
     */
317
    public function safeJsonDecode(
5✔
318
        string $json,
319
        ?bool $associative = null,
320
        int $depth = self::JSON_DEFAULT_DEPTH,
321
        int $flags = 0,
322
        string $to = self::ENCODING_UTF8,
323
        string $from = self::WINDOWS_1252
324
    ) {
325
        // Repair string to a valid UTF-8 for decoding
326
        /** @var string $data */
327
        $data = $this->repair($json, self::ENCODING_UTF8, $from);
5✔
328
        /** @var mixed $result */
329
        $result = \json_decode($data, $associative, $depth, $flags);
5✔
330

331
        if (null === $result && \JSON_ERROR_NONE !== \json_last_error()) {
5✔
332
            throw new RuntimeException('JSON Decode Error: ' . \json_last_error_msg());
1✔
333
        }
334

335
        return $this->toCharset($result, $to, self::ENCODING_UTF8);
4✔
336
    }
337

338
    /**
339
     * Applies a callback recursively to arrays, objects, and scalar values.
340
     *
341
     * @param mixed $data Data to process
342
     * @param callable $callback Processing callback function
343
     *
344
     * @return mixed
345
     */
346
    private function applyRecursive($data, callable $callback)
34✔
347
    {
348
        if (\is_array($data)) {
34✔
349
            /**
350
             * @psalm-suppress MissingClosureReturnType
351
             * @psalm-suppress MissingClosureParamType
352
             */
353
            return \array_map(fn ($item) => $this->applyRecursive($item, $callback), $data);
14✔
354
        }
355

356
        if (\is_object($data)) {
32✔
357
            return $this->applyToObject($data, $callback);
5✔
358
        }
359

360
        return $callback($data);
32✔
361
    }
362

363
    /**
364
     * Applies callback to object properties recursively.
365
     *
366
     * @param object $data Object to process
367
     * @param callable $callback Processing function
368
     *
369
     * @return object Cloned object with processed properties
370
     */
371
    private function applyToObject(object $data, callable $callback): object
5✔
372
    {
373
        $copy = clone $data;
5✔
374
        $properties = \get_object_vars($copy);
5✔
375

376
        /** @var mixed $value */
377
        foreach ($properties as $key => $value) {
5✔
378
            $copy->$key = $this->applyRecursive($value, $callback);
5✔
379
        }
380

381
        return $copy;
5✔
382
    }
383

384
    /**
385
     * Converts a single value to target encoding.
386
     *
387
     * @param mixed $value Value to convert
388
     * @param string $to Target encoding
389
     * @param string $from Source encoding
390
     * @param array<string, mixed> $options Conversion configuration
391
     *
392
     * @return mixed
393
     */
394
    private function convertValue($value, string $to, string $from, array $options)
31✔
395
    {
396
        if (!\is_string($value)) {
31✔
397
            return $value;
2✔
398
        }
399

400
        if (self::ENCODING_UTF8 !== $to && $this->isValidUtf8($value)) {
30✔
401
            return $this->convertString($value, $to, self::ENCODING_UTF8, $options);
2✔
402
        }
403

404
        if (\mb_check_encoding($value, $to)) {
28✔
405
            return $this->normalize($value, $to, $options);
22✔
406
        }
407

408
        return $this->convertString($value, $to, $from, $options);
7✔
409
    }
410

411
    /**
412
     * Low-level string conversion logic.
413
     *
414
     * @param string $data String to convert
415
     * @param string $to Target encoding
416
     * @param string $from Source encoding
417
     * @param array<string, mixed> $options Conversion options
418
     *
419
     * @return string Converted string or $data if convertion failed
420
     */
421
    private function convertString(string $data, string $to, string $from, array $options): string
9✔
422
    {
423
        return $this->transcodeString($data, $to, $from, $options) ?? $data;
9✔
424
    }
425

426
    /**
427
     * Low-level string transcode logic with fallback strategies.
428
     *
429
     * @param string $data String to transcode
430
     * @param string $to Target encoding
431
     * @param string $from Source encoding
432
     * @param array<string, mixed> $options Conversion options
433
     *
434
     * @return ?string Converted string or null if failed.
435
     */
436
    private function transcodeString(string $data, string $to, string $from, array $options): ?string
24✔
437
    {
438
        $targetEncoding = $this->resolveEncoding($to, $data, $options);
24✔
439
        $sourceEncoding = $this->resolveEncoding($from, $data, $options);
24✔
440

441
        $result = $this->transcoderChain->transcode($data, $targetEncoding, $sourceEncoding, $options);
24✔
442

443
        if (null !== $result && self::ENCODING_UTF8 === $targetEncoding) {
24✔
444
            return $this->normalize($result, $targetEncoding, $options);
7✔
445
        }
446

447
        return $result;
17✔
448
    }
449

450
    /**
451
     * Repairs a double-encoded value.
452
     *
453
     * @param mixed $value Value to repair
454
     * @param string $to Target encoding
455
     * @param string $from Glitch encoding
456
     * @param array<string, mixed> $options Configuration
457
     *
458
     * @return mixed
459
     */
460
    private function repairValue($value, string $to, string $from, array $options)
16✔
461
    {
462
        if (!\is_string($value)) {
16✔
463
            // @codeCoverageIgnoreStart
464
            return $value;
465
            // @codeCoverageIgnoreEnd
466
        }
467

468
        /** @var mixed $maxDepth */
469
        $maxDepth = $options['maxDepth'] ?? self::MAX_REPAIR_DEPTH;
15✔
470
        if (!\is_int($maxDepth)) {
15✔
471
            $maxDepth = self::MAX_REPAIR_DEPTH;
1✔
472
        }
473

474
        $fixed = $this->peelEncodingLayers($value, $from, $maxDepth);
15✔
475
        $detectedEncoding = $this->isValidUtf8($fixed) ? self::ENCODING_UTF8 : $from;
15✔
476

477
        return $this->toCharset($fixed, $to, $detectedEncoding, $options);
15✔
478
    }
479

480
    /**
481
     * Attempts to remove multiple encoding layers.
482
     *
483
     * @param string $value String to repair
484
     * @param string $from Encoding to reverse
485
     * @param int $maxDepth Maximum iterations
486
     *
487
     * @return string Repaired string
488
     */
489
    private function peelEncodingLayers(string $value, string $from, int $maxDepth): string
15✔
490
    {
491
        $fixed = $value;
15✔
492
        $iterations = 0;
15✔
493
        $options = ['normalize' => false, 'translit' => false, 'ignore' => false];
15✔
494

495
        // Loop while it looks like valid UTF-8
496
        while ($iterations < $maxDepth && $this->isValidUtf8($fixed)) {
15✔
497
            // Attempt to reverse convert (UTF-8 -> $from)
498
            $test = $this->transcodeString($fixed, $from, self::ENCODING_UTF8, $options);
15✔
499

500
            if (null === $test || $test === $fixed || !$this->isValidUtf8($test)) {
15✔
501
                break;
15✔
502
            }
503

504
            // If conversion worked AND result is still valid UTF-8 AND result is different
505
            $fixed = $test;
1✔
506
            $iterations++;
1✔
507
        }
508

509
        return $fixed;
15✔
510
    }
511

512
    /**
513
     * Resolves AUTO encoding to actual encoding.
514
     *
515
     * @param string $encoding Encoding constant
516
     * @param string $data String for detection
517
     * @param array<string, mixed> $options Detection options
518
     *
519
     * @return string Resolved encoding
520
     *
521
     * @codeCoverageIgnore
522
     */
523
    private function resolveEncoding(string $encoding, string $data, array $options): string
524
    {
525
        return self::AUTO === $encoding ? $this->detect($data, $options) : $encoding;
526
    }
527

528
    /**
529
     * Normalizes UTF-8 string if needed.
530
     *
531
     * @param string $value String to normalize
532
     * @param string $to Target encoding
533
     * @param array<string, mixed> $options Configuration
534
     *
535
     * @return string Normalized or original string
536
     *
537
     * @codeCoverageIgnore
538
     */
539
    private function normalize(string $value, string $to, array $options): string
540
    {
541
        if (self::ENCODING_UTF8 !== $to || false !== ($options['normalize'] ?? true)) {
542
            return $value;
543
        }
544

545
        if (!\class_exists(Normalizer::class)) {
546
            return $value;
547
        }
548

549
        $normalized = Normalizer::normalize($value);
550

551
        return false !== $normalized ? $normalized : $value;
552
    }
553

554
    /**
555
     * Checks if string is valid UTF-8.
556
     *
557
     * Please not that it will use mb_check_encoding internally,
558
     * and could return true also if it's not really a full utf8 string.
559
     *
560
     * @param string $string String to check
561
     *
562
     * @return bool True if valid UTF-8
563
     */
564
    private function isValidUtf8(string $string): bool
22✔
565
    {
566
        return \mb_check_encoding($string, self::ENCODING_UTF8);
22✔
567
    }
568

569
    /**
570
     * Validates encoding name against whitelist.
571
     *
572
     * @param string $encoding Encoding to validate
573
     * @param string $type Type for error message (e.g., 'source', 'target')
574
     *
575
     * @throws InvalidArgumentException If encoding is not allowed
576
     */
577
    private function validateEncoding(string $encoding, string $type): void
34✔
578
    {
579
        $normalized = \strtoupper($encoding);
34✔
580

581
        if (
582
            !\in_array($encoding, $this->allowedEncodings, true)
34✔
583
            && !\in_array($normalized, $this->allowedEncodings, true)
34✔
584
        ) {
585
            throw new InvalidArgumentException(
2✔
586
                \sprintf(
2✔
587
                    'Invalid %s encoding: "%s". Allowed: %s',
2✔
588
                    $type,
2✔
589
                    $encoding,
2✔
590
                    \implode(', ', $this->allowedEncodings)
2✔
591
                )
2✔
592
            );
2✔
593
        }
594
    }
595

596
    /**
597
     * Builds conversion configuration with defaults.
598
     *
599
     * Merges user options with default values, allowing multiple override layers.
600
     *
601
     * @param array<string, mixed> $options User-provided options
602
     * @param array<string, mixed> ...$replacements Additional override layers
603
     *
604
     * @return array<string, mixed> Merged configuration
605
     *
606
     * @example
607
     * // Basic usage
608
     * $config = self::configureOptions(['normalize' => false]);
609
     *
610
     * // With additional defaults
611
     * $config = self::configureOptions(
612
     *     ['normalize' => false],
613
     *     ['maxDepth' => 10]
614
     * );
615
     */
616
    private function configureOptions(array $options, array ...$replacements): array
34✔
617
    {
618
        $replacements[] = $options;
34✔
619

620
        return \array_replace(
34✔
621
            ['normalize' => true, 'translit' => true, 'ignore' => true, 'encodings' => self::DEFAULT_ENCODINGS],
34✔
622
            ...$replacements
34✔
623
        );
34✔
624
    }
625
}
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