Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.51% covered (success)
94.51%
86 / 91
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Normalizer
94.51% covered (success)
94.51%
86 / 91
66.67% covered (warning)
66.67%
6 / 9
50.41
0.00% covered (danger)
0.00%
0 / 1
 content
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 normalize
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 children
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
 node
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 text
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 marks
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
7.08
 attrs
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
 mergeRuns
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 isEmpty
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Richtext;
6
7/**
8 * Canonical form for richtext documents: fixed key order (type,
9 * attrs, text, marks, content), sorted attribute keys, marks sorted
10 * by type, attributes equal to their spec default omitted, empty
11 * text runs dropped, adjacent text runs with identical marks merged.
12 * Byte-stable storage keeps history diffs small and normalization
13 * idempotent.
14 */
15final class Normalizer
16{
17    /**
18     * Canonicalize every envelope-marked richtext value inside a node
19     * content array (fields and blocks alike); empty documents become
20     * null locale entries.
21     */
22    public function content(array $content): array
23    {
24        foreach ($content as $key => $value) {
25            if (!is_array($value)) {
26                continue;
27            }
28
29            if (($value['format'] ?? null) === Envelope::FORMAT && is_array($value['value'] ?? null)) {
30                foreach ($value['value'] as $locale => $doc) {
31                    $value['value'][$locale] = $this->normalize($doc);
32                }
33
34                $content[$key] = $value;
35
36                continue;
37            }
38
39            $content[$key] = $this->content($value);
40        }
41
42        return $content;
43    }
44
45    /** Canonical form of a document; null when it is empty. */
46    public function normalize(mixed $doc): ?array
47    {
48        if (!is_array($doc) || ($doc['type'] ?? null) !== 'doc') {
49            return null;
50        }
51
52        $content = $this->children($doc['content'] ?? []);
53
54        if ($this->isEmpty($content)) {
55            return null;
56        }
57
58        return ['type' => 'doc', 'content' => $content];
59    }
60
61    /** @return list<array<string, mixed>> */
62    private function children(mixed $content): array
63    {
64        if (!is_array($content)) {
65            return [];
66        }
67
68        $result = [];
69
70        foreach ($content as $child) {
71            if (!is_array($child) || !is_string($child['type'] ?? null)) {
72                continue;
73            }
74
75            $node = $this->node($child);
76
77            if ($node !== null) {
78                $result[] = $node;
79            }
80        }
81
82        return $this->mergeRuns($result);
83    }
84
85    /** @return null|array<string, mixed> */
86    private function node(array $node): ?array
87    {
88        $type = $node['type'];
89
90        if ($type === 'text') {
91            return $this->text($node);
92        }
93
94        $result = ['type' => $type];
95        $attrs = $this->attrs(
96            is_array($node['attrs'] ?? null) ? $node['attrs'] : [],
97            Spec::nodeDefaults($type),
98        );
99
100        if ($attrs !== []) {
101            $result['attrs'] = $attrs;
102        }
103
104        if (!Spec::isLeaf($type)) {
105            $content = $this->children($node['content'] ?? []);
106
107            if ($content !== []) {
108                $result['content'] = $content;
109            }
110        }
111
112        return $result;
113    }
114
115    /** @return null|array<string, mixed> */
116    private function text(array $node): ?array
117    {
118        $text = $node['text'] ?? null;
119
120        if (!is_string($text) || $text === '') {
121            return null;
122        }
123
124        $result = ['type' => 'text', 'text' => $text];
125        $marks = $this->marks($node['marks'] ?? []);
126
127        if ($marks !== []) {
128            $result['marks'] = $marks;
129        }
130
131        return $result;
132    }
133
134    /** @return list<array<string, mixed>> */
135    private function marks(mixed $marks): array
136    {
137        if (!is_array($marks)) {
138            return [];
139        }
140
141        $result = [];
142
143        foreach ($marks as $mark) {
144            if (!is_array($mark) || !is_string($mark['type'] ?? null)) {
145                continue;
146            }
147
148            $type = $mark['type'];
149            $entry = ['type' => $type];
150            $attrs = $this->attrs(
151                is_array($mark['attrs'] ?? null) ? $mark['attrs'] : [],
152                Spec::markDefaults($type),
153            );
154
155            if ($attrs !== []) {
156                $entry['attrs'] = $attrs;
157            }
158
159            $result[$type] = $entry;
160        }
161
162        ksort($result);
163
164        return array_values($result);
165    }
166
167    /** @return array<string, mixed> */
168    private function attrs(array $attrs, array $defaults): array
169    {
170        $result = [];
171
172        foreach ($attrs as $key => $value) {
173            $hasDefault = array_key_exists($key, $defaults);
174
175            if ($hasDefault && $value === $defaults[$key]) {
176                continue;
177            }
178
179            if (!$hasDefault && $value === null) {
180                continue;
181            }
182
183            $result[$key] = $value;
184        }
185
186        ksort($result);
187
188        return $result;
189    }
190
191    /**
192     * Merge adjacent text runs carrying identical mark sets.
193     *
194     * @param list<array<string, mixed>> $nodes
195     * @return list<array<string, mixed>>
196     */
197    private function mergeRuns(array $nodes): array
198    {
199        $result = [];
200
201        foreach ($nodes as $node) {
202            $last = $result === [] ? null : $result[count($result) - 1];
203
204            if (
205                $last !== null
206                && $node['type'] === 'text'
207                && $last['type'] === 'text'
208                && ($node['marks'] ?? []) === ($last['marks'] ?? [])
209            ) {
210                $result[count($result) - 1]['text'] = $last['text'] . $node['text'];
211
212                continue;
213            }
214
215            $result[] = $node;
216        }
217
218        return $result;
219    }
220
221    /** @param list<array<string, mixed>> $content */
222    private function isEmpty(array $content): bool
223    {
224        foreach ($content as $node) {
225            if ($node['type'] !== 'paragraph' || ($node['content'] ?? []) !== []) {
226                return false;
227            }
228        }
229
230        return true;
231    }
232}