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, $this->length, 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);
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 public function attributesAt($pos) {
216 $attributes = [];
217
218 foreach ($this->attributes as $attribute => &$map) {
219 if ($map[$pos]) {
220 $attributes[] = $attribute;
221 }
222 }
223
224 return $attributes;
225 }
226
227 228 229 230 231 232 233 234
235 public function toHtml($tag = "span", $classPrefix = "") {
236 foreach($this->attributes as $attribute => $map) $state[$attribute] = false;
237
238 $html = "";
239 $stack = [];
240 $lastPos = 0;
241
242 for ($i=0; $i<$this->length; $i++)
243 {
244 foreach($this->attributes as $attribute => &$map)
245 {
246 if ($this->attributes[$attribute][$i] != $state[$attribute])
247 {
248 $state[$attribute] = $this->attributes[$attribute][$i];
249
250 $html .= mb_substr($this->string, $lastPos, $i-$lastPos, "utf-8");
251 $lastPos = $i;
252
253 if ($state[$attribute])
254 {
255 $html .= "<$tag class=\"$classPrefix$attribute\">";
256 $stack[] = $attribute;
257 }
258 else
259 {
260 if ($attribute != array_pop($stack))
261 {
262 throw new Exception("Attributes are not properly nested for HTML conversion");
263 }
264 $html .= "</$tag>";
265 }
266 }
267 }
268 }
269
270 $html .= mb_substr($this->string, $lastPos, $this->length-$lastPos, 'utf-8');
271
272
273 $html .= str_repeat("</$tag>", count($stack));
274
275 return $html;
276 }
277
278 279 280 281 282 283 284 285 286
287 public function combineAttributes($op, $attribute1, $attribute2 = false, $to = false)
288 {
289 $to = isset($to) ? $to : $attribute1;
290 $op = strtolower($op);
291
292 if ($op == "not") {
293 $attribute2 = $attribute1;
294 }
295
296 if (!$this->hasAttribute($attribute1) or !$this->hasAttribute($attribute2)) {
297 throw new \InvalidArgumentException("Attribute does not exist");
298 }
299
300 if (!isset($this->attributes[$to])) {
301 $this->attributes[$to] = [];
302 }
303
304
305 switch ($op) {
306 case 'or':
307 for($i = 0; $i < $this->length; $i++) {
308 $this->attributes[$to][$i] = $this->attributes[$attribute1][$i] || $this->attributes[$attribute2][$i];
309 }
310 break;
311
312 case 'xor':
313 for($i = 0; $i < $this->length; $i++) {
314 $this->attributes[$to][$i] = ($this->attributes[$attribute1][$i] xor $this->attributes[$attribute2][$i]);
315 }
316 break;
317
318 case 'and':
319 for($i = 0; $i < $this->length; $i++) {
320 $this->attributes[$to][$i] = $this->attributes[$attribute1][$i] && $this->attributes[$attribute2][$i];
321 }
322 break;
323
324 case 'not':
325 for($i = 0; $i < $this->length; $i++) {
326 $this->attributes[$to][$i] = !$this->attributes[$attribute1][$i];
327 }
328 break;
329
330 default:
331 throw new \InvalidArgumentException("Unknown operation");
332 }
333 }
334
335 336 337 338 339
340 public function enablebyteToCharCache() {
341 $this->byteToChar = [];
342 $char = 0;
343 for ($i = 0; $i < strlen($this->string); ) {
344 $char++;
345 $byte = $this->string[$i];
346 $cl = self::utf8CharLen($byte);
347 $i += $cl;
348
349 $this->byteToChar[$i] = $char;
350 }
351 }
352
353 protected function byteToCharOffset($boff) {
354 if (isset($this->byteToChar[$boff])) return $this->byteToChar[$boff];
355
356 return $this->byteToChar[$boff] = self::byteToCharOffsetString($this->string, $boff);
357 }
358
359 protected function charToByteOffset($char) {
360 $byte = strlen(mb_substr($this->string, 0, $char, "utf-8"));
361 if (!isset($this->byteToChar[$byte])) $this->byteToChar[$byte] = $char;
362
363 return $byte;
364 }
365
366 protected static function byteToCharOffsetString($string, $boff) {
367 $result = 0;
368
369 for ($i = 0; $i < $boff; ) {
370 $result++;
371 $byte = $string[$i];
372 $cl = self::utf8CharLen($byte);
373 $i += $cl;
374 }
375
376 return $result;
377 }
378
379 protected static function utf8CharLen($byte) {
380 $base2 = str_pad(base_convert((string) ord($byte), 10, 2), 8, "0", STR_PAD_LEFT);
381 $p = strpos($base2, "0");
382
383 if ($p == 0) {
384 return 1;
385 } elseif ($p <= 4) {
386 return $p;
387 } else {
388 throw new \InvalidArgumentException();
389 }
390 }
391
392 393 394 395 396
397 public function count() {
398 return $this->length;
399 }
400 }
401