Overview
  • Namespace
  • Class

Namespaces

  • apemsel
    • AttributedString

Classes

  • apemsel\AttributedString\AttributedString
  • apemsel\AttributedString\Bitmap
  • apemsel\AttributedString\BooleanArray
  • apemsel\AttributedString\MutableAttributedString
  • apemsel\AttributedString\TokenizedAttributedString

Interfaces

  • apemsel\AttributedString\Attribute
  • apemsel\AttributedString\MutableAttribute
  1 <?php
  2 namespace apemsel\AttributedString;
  3 
  4 /**
  5  * Basic class to work with attributed strings.
  6  *
  7  * Attributed strings are strings that can have multiple attributes per character of the string
  8  *
  9  * @author Adrian Pemsel <apemsel@gmail.com>
 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    * @param string|AttributedString $string Either a simple string or another AttributedString to init the AttributedString
 21    * @param string $attributeClass Class to use for attributes
 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    * Returns the native string
 43    *
 44    * @return string The native string representation of the AttributedString without attributes
 45    */
 46   public function __toString() {
 47     return $this->string;
 48   }
 49   
 50   /**
 51    * Creates a new attribute layer
 52    *
 53    * @param string $attribute The name of the new attribute
 54    * @throws InvalidArgumentException if the attribute already exists
 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    * Check if the given attribute exists
 66    *
 67    * @param string $attribute The name of the attribute to check
 68    * @return bool
 69    */
 70   public function hasAttribute($attribute) {
 71     return isset($this->attributes[$attribute]);
 72   }
 73   
 74   /**
 75    * Delete an attribute
 76    *
 77    * @param string $attribute The name of the attribute to delete
 78    */
 79   public function deleteAttribute($attribute) {
 80     if (isset($this->attributes[$attribute])) {
 81       unset($this->attributes[$attribute]);
 82     }
 83   }
 84   
 85   /**
 86    * Set given range of the string to an attribute and state
 87    *
 88    * @param int $from start offset
 89    * @param int $to end offset
 90    * @param string $attribute name of the attribute to be set
 91    * @param bool $state set state to true (default) or false
 92    */
 93   public function setRange($from, $to, $attribute, $state = true) {
 94     // Ensure correct range
 95     $from = min($from, $this->length);
 96     $from = max($from, 0);
 97     $to = min($to, $this->length);
 98     $to = max($to, 0);
 99     
100     // Be kind and swap from and to if mixed up
101     if ($from>$to) {
102       list($from, $to) = [$to, $from];
103     }
104     
105     // Create attribute if it does not exist
106     if (!$this->hasAttribute($attribute)) {
107       $this->createAttribute($attribute);
108     }
109 
110     // Set attribute state for given range
111     $this->attributes[$attribute]->setRange($from, $to, $state);
112   }
113   
114   /**
115    * Set given length of the string to an attribute and state
116    *
117    * @param int $from start offset
118    * @param int $length length to be set
119    * @param string $attribute name of the attribute to be set
120    * @param bool $state set state to true (default) or false
121    */
122   public function setLength($from, $length, $attribute, $state = true) {
123     return $this->setRange($from, $from + $length - 1, $attribute, $state);
124   }
125   
126   /**
127    * Set parts of the string matching a given regex to an attribute and state
128    *
129    * @param string $pattern regex pattern
130    * @param string $attribute name of the attribute to be set
131    * @param bool $state set state to true (default) or false
132    * @return int number of matches
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    * Set given substring to an attribute and state
148    *
149    * @param string $substring the substring to search
150    * @param string $attribute name of the attribute to be set
151    * @param bool $all set first or all occurences of the substring
152    * @param bool $matchCase match or ignore case
153    * @param bool $state set state to true (default) or false
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    * Search inside the string for ranges with the given attribute
171    *
172    * @param string $attribute name of the attribute to search
173    * @param int $offset start offset
174    * @param bool $returnLength if true (default is false), return an array with position and length of the found range
175    * @param bool $state the state to look for (default is true)
176    * @param bool $strict perform strict comparison during search
177    * @return int|int[] either position or position and lenght in an array
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    * Check for given attribute at a offset
189    *
190    * @param string $attribute name of the attribute to check
191    * @param int $pos offset to check
192    * @return bool true if string has the attribute at the given position
193    */
194   public function is($attribute, $pos) {
195     return (isset($this->attributes[$attribute][$pos]) and $this->attributes[$attribute][$pos]);
196   }
197   
198   /**
199    * Return an array of substrings that have a given attribute
200    *
201    * @param string $attribute name of the attribute
202    * @param int $pos offset
203    * @param bool $state the state to look for (default is true)
204    * @param bool $strict perform strict comparison during search
205    * @return string[] array of strings with given attribute
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       //var_dump($pl);
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    * Return all parts of the string that have a given attribute as new string
223    *
224    * @param string $attribute name of the attribute
225    * @param int $pos offset
226    * @param bool $state the state to look for (default is true)
227    * @param bool $strict perform strict comparison during search
228    * @param string $glue glue that is inserted between the parts, default is nothing ("")
229    * @return string combined filtered string
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    * Return all attributes at a given offset
238    *
239    * @param int $pos offset
240    * @return string[] attributes at the given offset
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    * Convert to HTML, using a given class to mark attribute spans
256    *
257    * @param string $tag HTML tag to use for the spans (defaults is "<span>")
258    * @param string $classPrefix Optional prefix used to convert the attribute names to class names
259    * @return string HTML
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             // Close attribute span. If the top of the stack does not equal the attribute to be closed
287             // close, pop and stash it. This happens when span a ends in span b.
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             // Now repopen the stashed spans and put them back on the stack.
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     // Close all spans that remained open
311     $html .= str_repeat("</$tag>", count($stack));
312 
313     return $html;
314   }
315   
316   /**
317    * Combine attributes with the given boolean operation
318    *
319    * @param string $op one of or|xor|and|not
320    * @param string $attribute1 name of the first attribute
321    * @param string $attribute2 Name of the second attribute. Ignored for "not" operation.
322    * @param string $to optional name of the attribute to copy the result to
323    * @throws InvalidArgumentException if one of the attributes does not exist or an unkown operation is given
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     // Switch outside the loops for speed
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    * Convert attribute map to a visual string representation (e.g. for debugging)
375    *
376    * @param string $attribute name of the attribute
377    * @param string $true char to use for true state of attribute
378    * @param string $false char to use for false state of attribute
379    */
380   public function attributeToString($attribute, $true = "-", $false = " ") {
381     return $this->attributes[$attribute]->toString($true, $false);
382   }
383   
384   /**
385    * Return attribute instance
386    *
387    * @param string $attribute name of the attribute
388    * @return object an object implementing the attribute interface
389    */
390   public function getAttribute($attribute) {
391     return $this->attributes[$attribute];
392   }
393   
394   /**
395    * Enable and fill cache for byte to char offset conversion
396    *
397    * May improve performance if setPattern is used extensively
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   // Countable interface
452   
453   /**
454    * Return string length (number of UTF-8 chars, not strlen())
455    *
456    * @return int string length
457    */
458   public function count() {
459     return $this->length;
460   }
461   
462   // ArrayAccess interface
463   
464   /**
465    * Check if the given offset exists in the string
466    *
467    * @param int $offset offset
468    * @return bool does the offset exist
469    */
470   public function offsetExists($offset) {
471     return $offest < $this->length;
472   }
473   
474   /**
475    * Get char at given offset
476    *
477    * Note: Since AttributedString is using UTF-8, the returned char may be longer than 1 byte!
478    *
479    * @param int $offset offset
480    * @return string character
481    */
482   public function offsetGet($offset) {
483     return mb_substr($this->string, $offset, 1, "utf-8");
484   }
485   
486   /**
487    * Not implemented since AttributedString is immutable
488    *
489    * @throws RuntimeException always
490    */
491   public function offsetSet($offset, $value) {
492     throw new \RuntimeException("AttributedString is immutable");
493   }
494   
495   /**
496    * Not implemented since AttributedString is immutable
497    *
498    * @throws RuntimeException always
499    */
500   public function offsetUnset($offset) {
501     throw new \RuntimeException("AttributedString is immutable");
502   }
503 }
504 
API documentation generated by ApiGen