1 <?php
2 namespace apemsel\AttributedString;
3
4 5 6 7 8 9 10
11 class AttributedString implements \Countable, \ArrayAccess
12 {
13 protected $string;
14 protected $attributes = [];
15 protected $length;
16 protected $byteToChar;
17 protected $attributeClass;
18
19 20 21 22
23 public function __construct($string, $attributeClass = "apemsel\AttributedString\BooleanArray") {
24 if (is_string($string)) {
25 $this->string = $string;
26 $this->length = mb_strlen($string, "utf-8");
27 }
28 elseif ($string instanceof AttributedString) {
29 $this->string = $string->string;
30 $this->attributes = $string->attributes;
31 $this->lenght = $string->length;
32 $this->byteToChar = $string->byteToChar;
33 }
34 else {
35 throw new \InvalidArgumentException();
36 }
37
38 $this->attributeClass = $attributeClass;
39 }
40
41 42 43 44 45
46 public function __toString() {
47 return $this->string;
48 }
49
50 51 52 53 54 55
56 public function createAttribute($attribute) {
57 if ($this->hasAttribute($attribute)) {
58 throw new \InvalidArgumentException();
59 }
60
61 $this->attributes[$attribute] = new $this->attributeClass($this->length);
62 }
63
64 65 66 67 68 69
70 public function hasAttribute($attribute) {
71 return isset($this->attributes[$attribute]);
72 }
73
74 75 76 77 78
79 public function deleteAttribute($attribute) {
80 if (isset($this->attributes[$attribute])) {
81 unset($this->attributes[$attribute]);
82 }
83 }
84
85 86 87 88 89 90 91 92
93 public function setRange($from, $to, $attribute, $state = true) {
94
95 $from = min($from, $this->length);
96 $from = max($from, 0);
97 $to = min($to, $this->length);
98 $to = max($to, 0);
99
100
101 if ($from>$to) {
102 list($from, $to) = [$to, $from];
103 }
104
105
106 if (!$this->hasAttribute($attribute)) {
107 $this->createAttribute($attribute);
108 }
109
110
111 $this->attributes[$attribute]->setRange($from, $to, $state);
112 }
113
114 115 116 117 118 119 120 121
122 public function setLength($from, $length, $attribute, $state = true) {
123 return $this->setRange($from, $from + $length - 1, $attribute, $state);
124 }
125
126 127 128 129 130 131 132 133
134 public function setPattern($pattern, $attribute, $state = true) {
135 if ($ret = preg_match_all($pattern, $this->string, $matches, PREG_OFFSET_CAPTURE)) {
136 foreach($matches[0] as $match)
137 {
138 $match[1] = $this->byteToCharOffset($match[1]);
139 $this->setRange($match[1], $match[1]+mb_strlen($match[0], "utf-8")-1, $attribute, $state);
140 }
141
142 return $ret;
143 }
144 }
145
146 147 148 149 150 151 152 153 154
155 public function setSubstring($substring, $attribute, $all = true, $matchCase = true, $state = true) {
156 $offset = 0;
157 $length = mb_strlen($substring, "utf-8");
158 $func = $matchCase ? "mb_strpos" : "mb_stripos";
159
160 while (false !== $pos = $func($this->string, $substring, $offset, "utf-8")) {
161 $this->setRange($pos, $pos + $length - 1, $attribute, $state);
162 if (!$all) {
163 return;
164 }
165 $offset = $pos + $length;
166 }
167 }
168
169 170 171 172 173 174 175 176 177 178
179 public function searchAttribute($attribute, $offset = 0, $returnLength = false, $state = true, $strict = true) {
180 if (!$this->hasAttribute($attribute)) {
181 return false;
182 }
183
184 return $this->attributes[$attribute]->search($offset, $returnLength, $state, $strict);
185 }
186
187 188 189 190 191 192 193
194 public function is($attribute, $pos) {
195 return (isset($this->attributes[$attribute][$pos]) and $this->attributes[$attribute][$pos]);
196 }
197
198 199 200 201 202 203 204 205 206
207 public function substrings($attribute, $offset = 0, $state = true, $strict = true)
208 {
209 $substrings = [];
210 while (false !== $pl = $this->searchAttribute($attribute, $offset, true, $state, $strict))
211 {
212
213 $substring = mb_substr($this->string, $pl[0], $pl[1], "UTF-8");
214 $substrings[] = $substring;
215 $offset = $pl[0] + $pl[1];
216 }
217
218 return $substrings;
219 }
220
221 222 223 224 225 226 227 228 229 230
231 public function filter($attribute, $offset = 0, $state = true, $strict = true, $glue = "")
232 {
233 return implode($glue, $this->substrings($attribute, $offset, $state, $strict));
234 }
235
236 237 238 239 240 241
242 public function attributesAt($pos) {
243 $attributes = [];
244
245 foreach ($this->attributes as $attribute => &$map) {
246 if ($map[$pos]) {
247 $attributes[] = $attribute;
248 }
249 }
250
251 return $attributes;
252 }
253
254 255 256 257 258 259 260
261 public function toHtml($tag = "span", $classPrefix = "") {
262 foreach($this->attributes as $attribute => $map) $state[$attribute] = false;
263
264 $html = "";
265 $stack = [];
266 $lastPos = 0;
267
268 for ($i=0; $i<$this->length; $i++)
269 {
270 foreach($this->attributes as $attribute => &$map)
271 {
272 if ($this->attributes[$attribute][$i] != $state[$attribute])
273 {
274 $state[$attribute] = $this->attributes[$attribute][$i];
275
276 $html .= mb_substr($this->string, $lastPos, $i-$lastPos, "utf-8");
277 $lastPos = $i;
278
279 if ($state[$attribute])
280 {
281 $html .= "<$tag class=\"$classPrefix$attribute\">";
282 $stack[] = $attribute;
283 }
284 else
285 {
286
287
288 $stashed = [];
289 while($open = array_pop($stack))
290 {
291 $html .= "</$tag>";
292 if ($attribute == $open) {
293 break;
294 }
295 $stashed[] = $open;
296 }
297
298
299 foreach($stashed as $a) {
300 $stack[] = $a;
301 $html .= "<$tag class=\"$classPrefix$a\">";
302 }
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 (!$this->hasAttribute($to)) {
339 $this->createAttribute($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 379
380 public function attributeToString($attribute, $true = "-", $false = " ") {
381 return $this->attributes[$attribute]->toString($true, $false);
382 }
383
384 385 386 387 388 389
390 public function getAttribute($attribute) {
391 return $this->attributes[$attribute];
392 }
393
394 395 396 397 398
399 public function enableByteToCharCache() {
400 $this->byteToChar = [];
401 $char = 0;
402 for ($i = 0; $i < strlen($this->string); ) {
403 $char++;
404 $byte = $this->string[$i];
405 $cl = self::utf8CharLen($byte);
406 $i += $cl;
407
408 $this->byteToChar[$i] = $char;
409 }
410 }
411
412 protected function byteToCharOffset($boff) {
413 if (isset($this->byteToChar[$boff])) return $this->byteToChar[$boff];
414
415 return $this->byteToChar[$boff] = self::byteToCharOffsetString($this->string, $boff);
416 }
417
418 protected function charToByteOffset($char) {
419 $byte = strlen(mb_substr($this->string, 0, $char, "utf-8"));
420 if (!isset($this->byteToChar[$byte])) $this->byteToChar[$byte] = $char;
421
422 return $byte;
423 }
424
425 protected static function byteToCharOffsetString($string, $boff) {
426 $result = 0;
427
428 for ($i = 0; $i < $boff; ) {
429 $result++;
430 $byte = $string[$i];
431 $cl = self::utf8CharLen($byte);
432 $i += $cl;
433 }
434
435 return $result;
436 }
437
438 protected static function utf8CharLen($byte) {
439 $base2 = str_pad(base_convert((string) ord($byte), 10, 2), 8, "0", STR_PAD_LEFT);
440 $p = strpos($base2, "0");
441
442 if ($p == 0) {
443 return 1;
444 } elseif ($p <= 4) {
445 return $p;
446 } else {
447 throw new \InvalidArgumentException();
448 }
449 }
450
451
452
453 454 455 456 457
458 public function count() {
459 return $this->length;
460 }
461
462
463
464 465 466 467 468 469
470 public function offsetExists($offset) {
471 return $offest < $this->length;
472 }
473
474 475 476 477 478 479 480 481
482 public function offsetGet($offset) {
483 return mb_substr($this->string, $offset, 1, "utf-8");
484 }
485
486 487 488 489 490
491 public function offsetSet($offset, $value) {
492 throw new \RuntimeException("AttributedString is immutable");
493 }
494
495 496 497 498 499
500 public function offsetUnset($offset) {
501 throw new \RuntimeException("AttributedString is immutable");
502 }
503 }
504