1 <?php
2 namespace apemsel\AttributedString;
3
4 5 6 7 8 9 10
11 class AttributedString implements \Countable
12 {
13 protected $string;
14 protected $attributes;
15 protected $length;
16 protected $byteToChar;
17
18 19 20
21 public function __construct($string) {
22 if (is_string($string)) {
23 $this->string = $string;
24 $this->length = mb_strlen($string, "utf-8");
25 }
26 elseif ($string instanceof AttributedString) {
27 $this->string = $string->string;
28 $this->attributes = $string->attributes;
29 $this->lenght = $string->length;
30 $this->byteToChar = $string->byteToChar;
31 }
32 else {
33 throw new \InvalidArgumentException();
34 }
35 }
36
37 38 39 40 41
42 public function __toString() {
43 return $this->string;
44 }
45
46 47 48 49 50 51
52 public function createAttribute($attribute) {
53 if ($this->hasAttribute($attribute)) {
54 throw new \InvalidArgumentException();
55 }
56
57 $this->attributes[$attribute] = array_fill(0, $this->length, false);
58 }
59
60 61 62 63 64 65
66 public function hasAttribute($attribute) {
67 return isset($this->attributes[$attribute]);
68 }
69
70 public function deleteAttribute($attribute) {
71 if (isset($this->attributes[$attribute])) {
72 unset($this->attributes[$attribute]);
73 }
74 }
75
76 77 78 79 80 81 82 83
84 public function setRange($from, $to, $attribute, $state = true) {
85
86 $from = min($from, $this->length);
87 $from = max($from, 0);
88 $to = min($to, $this->length);
89 $to = max($to, 0);
90
91
92 if ($from>$to) {
93 list($from, $to) = [$to, $from];
94 }
95
96
97 if (!$this->hasAttribute($attribute)) {
98 $this->createAttribute($attribute);
99 }
100
101
102 $this->attributes[$attribute] = array_replace($this->attributes[$attribute], array_fill($from, $to-$from+1, $state));
103 }
104
105 106 107 108 109 110 111 112
113 public function setLength($from, $length, $attribute, $state = true) {
114 return $this->setRange($from, $from + $length - 1, $attribute, $state);
115 }
116
117 118 119 120 121 122 123 124
125 public function setPattern($pattern, $attribute, $state = true) {
126 if ($ret = preg_match_all($pattern, $this->string, $matches, PREG_OFFSET_CAPTURE)) {
127 foreach($matches[0] as $match)
128 {
129 $match[1] = $this->byteToCharOffset($match[1]);
130 $this->setRange($match[1], $match[1]+mb_strlen($match[0], "utf-8")-1, $attribute, $state);
131 }
132
133 return $ret;
134 }
135 }
136
137 138 139 140 141 142 143 144 145
146 public function setSubstring($substring, $attribute, $all = true, $matchCase = true, $state = true) {
147 $offset = 0;
148 $length = mb_strlen($substring, "utf-8");
149 $func = $matchCase ? "mb_strpos" : "mb_stripos";
150
151 while (false !== $pos = $func($this->string, $substring, $offset, "utf-8")) {
152 $this->setRange($pos, $pos + $length - 1, $attribute, $state);
153 if (!$all) {
154 return;
155 }
156 $offset = $pos + $length;
157 }
158 }
159
160 161 162 163 164 165 166 167 168 169
170 public function searchAttribute($attribute, $offset = 0, $returnLength = false, $state = true, $strict = true) {
171 if (!$this->hasAttribute($attribute)) {
172 return false;
173 }
174
175 $a = $this->attributes[$attribute];
176
177 if ($offset) {
178 $a = array_slice($a, $offset, NULL, true);
179 }
180
181 $pos = array_search($state, $a, $strict);
182
183 if ($returnLength) {
184 if (false === $pos) {
185 return false;
186 }
187
188 $a = array_slice($a, $pos - $offset);
189 $length = array_search(!$state, $a, $strict);
190 $length = $length ? $length : $this->length - $pos;
191
192 return [$pos, $length];
193 } else {
194 return $pos;
195 }
196 }
197
198 199 200 201 202 203 204
205 public function is($attribute, $pos) {
206 return (isset($this->attributes[$attribute][$pos]) and $this->attributes[$attribute][$pos]);
207 }
208
209 210 211 212 213 214 215 216 217
218 public function substrings($attribute, $offset = 0, $state = true, $strict = true)
219 {
220 $substrings = [];
221 while (false !== $pl = $this->searchAttribute($attribute, $offset, true, $state, $strict))
222 {
223
224 $substring = mb_substr($this->string, $pl[0], $pl[1], "UTF-8");
225 $substrings[] = $substring;
226 $offset = $pl[0] + $pl[1];
227 }
228
229 return $substrings;
230 }
231
232 233 234 235 236 237 238 239 240 241
242 public function filter($attribute, $offset = 0, $state = true, $strict = true, $glue = "")
243 {
244 return implode($glue, $this->substrings($attribute, $offset, $state, $strict));
245 }
246
247 248 249 250 251 252
253 public function attributesAt($pos) {
254 $attributes = [];
255
256 foreach ($this->attributes as $attribute => &$map) {
257 if ($map[$pos]) {
258 $attributes[] = $attribute;
259 }
260 }
261
262 return $attributes;
263 }
264
265 266 267 268 269 270 271 272
273 public function toHtml($tag = "span", $classPrefix = "") {
274 foreach($this->attributes as $attribute => $map) $state[$attribute] = false;
275
276 $html = "";
277 $stack = [];
278 $lastPos = 0;
279
280 for ($i=0; $i<$this->length; $i++)
281 {
282 foreach($this->attributes as $attribute => &$map)
283 {
284 if ($this->attributes[$attribute][$i] != $state[$attribute])
285 {
286 $state[$attribute] = $this->attributes[$attribute][$i];
287
288 $html .= mb_substr($this->string, $lastPos, $i-$lastPos, "utf-8");
289 $lastPos = $i;
290
291 if ($state[$attribute])
292 {
293 $html .= "<$tag class=\"$classPrefix$attribute\">";
294 $stack[] = $attribute;
295 }
296 else
297 {
298 if ($attribute != array_pop($stack))
299 {
300 throw new Exception("Attributes are not properly nested for HTML conversion");
301 }
302 $html .= "</$tag>";
303 }
304 }
305 }
306 }
307
308 $html .= mb_substr($this->string, $lastPos, $this->length-$lastPos, 'utf-8');
309
310
311 $html .= str_repeat("</$tag>", count($stack));
312
313 return $html;
314 }
315
316 317 318 319 320 321 322 323 324
325 public function combineAttributes($op, $attribute1, $attribute2 = false, $to = false)
326 {
327 $to = isset($to) ? $to : $attribute1;
328 $op = strtolower($op);
329
330 if ($op == "not") {
331 $attribute2 = $attribute1;
332 }
333
334 if (!$this->hasAttribute($attribute1) or !$this->hasAttribute($attribute2)) {
335 throw new \InvalidArgumentException("Attribute does not exist");
336 }
337
338 if (!isset($this->attributes[$to])) {
339 $this->attributes[$to] = [];
340 }
341
342
343 switch ($op) {
344 case 'or':
345 for($i = 0; $i < $this->length; $i++) {
346 $this->attributes[$to][$i] = $this->attributes[$attribute1][$i] || $this->attributes[$attribute2][$i];
347 }
348 break;
349
350 case 'xor':
351 for($i = 0; $i < $this->length; $i++) {
352 $this->attributes[$to][$i] = ($this->attributes[$attribute1][$i] xor $this->attributes[$attribute2][$i]);
353 }
354 break;
355
356 case 'and':
357 for($i = 0; $i < $this->length; $i++) {
358 $this->attributes[$to][$i] = $this->attributes[$attribute1][$i] && $this->attributes[$attribute2][$i];
359 }
360 break;
361
362 case 'not':
363 for($i = 0; $i < $this->length; $i++) {
364 $this->attributes[$to][$i] = !$this->attributes[$attribute1][$i];
365 }
366 break;
367
368 default:
369 throw new \InvalidArgumentException("Unknown operation");
370 }
371 }
372
373 374 375 376 377
378 public function enablebyteToCharCache() {
379 $this->byteToChar = [];
380 $char = 0;
381 for ($i = 0; $i < strlen($this->string); ) {
382 $char++;
383 $byte = $this->string[$i];
384 $cl = self::utf8CharLen($byte);
385 $i += $cl;
386
387 $this->byteToChar[$i] = $char;
388 }
389 }
390
391 protected function byteToCharOffset($boff) {
392 if (isset($this->byteToChar[$boff])) return $this->byteToChar[$boff];
393
394 return $this->byteToChar[$boff] = self::byteToCharOffsetString($this->string, $boff);
395 }
396
397 protected function charToByteOffset($char) {
398 $byte = strlen(mb_substr($this->string, 0, $char, "utf-8"));
399 if (!isset($this->byteToChar[$byte])) $this->byteToChar[$byte] = $char;
400
401 return $byte;
402 }
403
404 protected static function byteToCharOffsetString($string, $boff) {
405 $result = 0;
406
407 for ($i = 0; $i < $boff; ) {
408 $result++;
409 $byte = $string[$i];
410 $cl = self::utf8CharLen($byte);
411 $i += $cl;
412 }
413
414 return $result;
415 }
416
417 protected static function utf8CharLen($byte) {
418 $base2 = str_pad(base_convert((string) ord($byte), 10, 2), 8, "0", STR_PAD_LEFT);
419 $p = strpos($base2, "0");
420
421 if ($p == 0) {
422 return 1;
423 } elseif ($p <= 4) {
424 return $p;
425 } else {
426 throw new \InvalidArgumentException();
427 }
428 }
429
430 431 432 433 434
435 public function count() {
436 return $this->length;
437 }
438 }
439