| 1 |
<?php |
| 2 |
/** |
| 3 |
* Copyright (C) 2018-2023 Graham Breach |
| 4 |
* |
| 5 |
* This program is free software: you can redistribute it and/or modify |
| 6 |
* it under the terms of the GNU Lesser General Public License as published by |
| 7 |
* the Free Software Foundation, either version 3 of the License, or |
| 8 |
* (at your option) any later version. |
| 9 |
* |
| 10 |
* This program is distributed in the hope that it will be useful, |
| 11 |
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 12 |
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 13 |
* GNU Lesser General Public License for more details. |
| 14 |
* |
| 15 |
* You should have received a copy of the GNU Lesser General Public License |
| 16 |
* along with this program. If not, see <http://www.gnu.org/licenses/>. |
| 17 |
*/ |
| 18 |
/** |
| 19 |
* For more information, please contact <graham@goat1000.com> |
| 20 |
*/ |
| 21 |
|
| 22 |
namespace Goat1000\SVGGraph; |
| 23 |
|
| 24 |
/** |
| 25 |
* Text functions using calculated font metrics |
| 26 |
*/ |
| 27 |
class Text { |
| 28 |
|
| 29 |
private $graph; |
| 30 |
private $font; |
| 31 |
private $metrics_file; |
| 32 |
private $adjust; |
| 33 |
private $encoding; |
| 34 |
private $no_tspan; |
| 35 |
private $no_metrics; |
| 36 |
private static $metrics; |
| 37 |
private static $measure_cache; |
| 38 |
private static $use_iconv = null; |
| 39 |
private static $use_mbstring = null; |
| 40 |
|
| 41 |
public function __construct(&$graph, $font = null, $adjust = 0) |
| 42 |
{ |
| 43 |
// iconv can be troublesome |
| 44 |
if(Text::$use_iconv === null) { |
| 45 |
Text::$use_iconv = false; |
| 46 |
if($graph->getOption('use_iconv', true) && extension_loaded('iconv')) { |
| 47 |
// test the iconv function |
| 48 |
$test_euro = "Test:\u{20ac}"; |
| 49 |
$out = iconv('UTF-8', 'ASCII//TRANSLIT', $test_euro); |
| 50 |
Text::$use_iconv = (strlen($out) > 0); |
| 51 |
} |
| 52 |
} |
| 53 |
|
| 54 |
// mbstring should be OK, but allow disabling it |
| 55 |
if(Text::$use_mbstring === null) { |
| 56 |
Text::$use_mbstring = $graph->getOption('use_mbstring', true) && |
| 57 |
extension_loaded('mbstring'); |
| 58 |
} |
| 59 |
|
| 60 |
$this->graph =& $graph; |
| 61 |
$this->font = $font; |
| 62 |
if($font !== null) |
| 63 |
$this->metrics_file = $this->metricsFilename($font); |
| 64 |
$this->encoding = strtoupper($graph->encoding); |
| 65 |
$this->no_tspan = $graph->getOption('no_tspan'); |
| 66 |
$this->no_metrics = $graph->getOption('no_font_metrics'); |
| 67 |
$this->adjust = $adjust ? $adjust : 0.6; |
| 68 |
} |
| 69 |
|
| 70 |
/** |
| 71 |
* Measures a block of text |
| 72 |
* returns array($width, $height) |
| 73 |
*/ |
| 74 |
public function measure($text, $font_size, $angle = 0, $line_spacing = 0) |
| 75 |
{ |
| 76 |
if(!is_numeric($font_size)) |
| 77 |
throw new \InvalidArgumentException("Font size is not numeric"); |
| 78 |
|
| 79 |
if(!is_string($text)) { |
| 80 |
if(is_numeric($text)) { |
| 81 |
$num = new Number($text); |
| 82 |
$text = $num->format(); |
| 83 |
} else { |
| 84 |
$text = (string)$text; |
| 85 |
} |
| 86 |
} |
| 87 |
|
| 88 |
// convert to UTF-8 |
| 89 |
$text = $this->convert($text); |
| 90 |
$cached = $this->measureCached($text, $font_size, $angle, $line_spacing); |
| 91 |
if($cached !== null) |
| 92 |
return $cached; |
| 93 |
|
| 94 |
if($line_spacing !== 0 && !is_numeric($line_spacing)) |
| 95 |
throw new \InvalidArgumentException("Line spacing is not numeric"); |
| 96 |
|
| 97 |
$lines = $line_spacing > 0 ? $this->splitLines($text) : [$text]; |
| 98 |
$width = $height = 0; |
| 99 |
foreach($lines as $l) { |
| 100 |
list($lw, $lh) = $this->measureLine($l, $font_size); |
| 101 |
if($lw > $width) |
| 102 |
$width = $lw; |
| 103 |
$height = $lh; |
| 104 |
} |
| 105 |
// height is height of first line + (line spacing) x (other lines) |
| 106 |
$height += $line_spacing * (count($lines) - 1); |
| 107 |
|
| 108 |
if($angle % 180 != 0) { |
| 109 |
$w = $height; |
| 110 |
$h = $width; |
| 111 |
if($angle % 90 != 0) { |
| 112 |
$a = deg2rad($angle); |
| 113 |
$sa = abs(sin($a)); |
| 114 |
$ca = abs(cos($a)); |
| 115 |
$w = $ca * $width + $sa * $height; |
| 116 |
$h = $sa * $width + $ca * $height; |
| 117 |
} |
| 118 |
$width = $w; |
| 119 |
$height = $h; |
| 120 |
} |
| 121 |
|
| 122 |
$this->cacheMeasurement($text, $font_size, $angle, $line_spacing, $width, |
| 123 |
$height); |
| 124 |
return [$width, $height]; |
| 125 |
} |
| 126 |
|
| 127 |
/** |
| 128 |
* Measures a block of text and finds its position |
| 129 |
* returns array($x, $y, $width, $height) |
| 130 |
*/ |
| 131 |
public function measurePosition($text, $font_size, $line_spacing, $x, $y, |
| 132 |
$anchor, $angle = 0, $rcx = null, $rcy = null) |
| 133 |
{ |
| 134 |
$baseline = $this->baseline($font_size); |
| 135 |
$y -= $baseline; |
| 136 |
list($width, $height) = $this->measure($text, $font_size, 0, |
| 137 |
$line_spacing); |
| 138 |
if($anchor == 'end') |
| 139 |
$x -= $width; |
| 140 |
elseif($anchor == 'middle') |
| 141 |
$x -= $width / 2; |
| 142 |
|
| 143 |
if($angle) { |
| 144 |
// the four corners of the bounding box |
| 145 |
$points = [ |
| 146 |
[$x, $y], [$x + $width, $y], |
| 147 |
[$x, $y + $height], [$x + $width, $y + $height] |
| 148 |
]; |
| 149 |
$arad = deg2rad($angle); |
| 150 |
$s = sin($arad); |
| 151 |
$c = cos($arad); |
| 152 |
$xp = []; |
| 153 |
$yp = []; |
| 154 |
foreach($points as $point) { |
| 155 |
// translate to origin |
| 156 |
$point[0] -= $rcx; |
| 157 |
$point[1] -= $rcy; |
| 158 |
// rotate |
| 159 |
$x1 = $point[0] * $c - $point[1] * $s; |
| 160 |
$y1 = $point[0] * $s + $point[1] * $c; |
| 161 |
// translate back |
| 162 |
$xp[] = $x1 + $rcx; |
| 163 |
$yp[] = $y1 + $rcy; |
| 164 |
} |
| 165 |
// find extents |
| 166 |
$x = min($xp); |
| 167 |
$max_x = max($xp); |
| 168 |
$y = min($yp); |
| 169 |
$max_y = max($yp); |
| 170 |
$width = $max_x - $x; |
| 171 |
$height = $max_y - $y; |
| 172 |
} |
| 173 |
return [$x, $y, $width, $height]; |
| 174 |
} |
| 175 |
|
| 176 |
/** |
| 177 |
* Measures a single line of UTF-8 text |
| 178 |
*/ |
| 179 |
private function measureLine($text, $font_size) |
| 180 |
{ |
| 181 |
$metrics = $this->no_metrics ? false : $this->loadMetrics($this->font); |
| 182 |
$width = $height = 0; |
| 183 |
|
| 184 |
// want to measure characters, not entities |
| 185 |
$text = $this->replaceEntities($text); |
| 186 |
|
| 187 |
$chars = $this->processLineArray($text); |
| 188 |
if(!$metrics) { |
| 189 |
// no metrics, use adjust value based on length of string |
| 190 |
$length = 0; |
| 191 |
foreach($chars as $char) { |
| 192 |
$code = Text::charCode($char); |
| 193 |
$length += Text::charWidth($code); |
| 194 |
} |
| 195 |
$width = $length * $font_size * $this->adjust; |
| 196 |
// height is font size |
| 197 |
return [$width, $font_size]; |
| 198 |
} |
| 199 |
|
| 200 |
// metrics file found, so use char widths and kerning |
| 201 |
$metrics =& Text::$metrics[$this->metrics_file]; |
| 202 |
|
| 203 |
$char_widths = []; |
| 204 |
foreach($chars as $char) { |
| 205 |
$code = Text::charCode($char); |
| 206 |
if(isset($metrics['widths'][$code])) { |
| 207 |
$char_widths[] = $metrics['widths'][$code]; |
| 208 |
continue; |
| 209 |
} |
| 210 |
|
| 211 |
// use mean * relative width |
| 212 |
$char_size = Text::charWidth($code); |
| 213 |
$char_width = $metrics['mean'] * $char_size; |
| 214 |
$char_widths[] = $char_width; |
| 215 |
} |
| 216 |
$len = count($chars); |
| 217 |
for($i = 0; $i < $len - 1; ++$i) { |
| 218 |
$j = $i + 1; |
| 219 |
$sub = $chars[$i] . $chars[$j]; |
| 220 |
if(isset($metrics['kern'][$sub])) { |
| 221 |
$kerning = $char_widths[$i] + $char_widths[$j] - $metrics['kern'][$sub]; |
| 222 |
$char_widths[$j] -= $kerning; |
| 223 |
} |
| 224 |
} |
| 225 |
for($i = 0; $i < $len; ++$i) { |
| 226 |
$width += $char_widths[$i]; |
| 227 |
} |
| 228 |
$width = $width * $font_size / $metrics['size']; |
| 229 |
$height = $metrics['height'] * $font_size / $metrics['size']; |
| 230 |
return [$width, $height]; |
| 231 |
} |
| 232 |
|
| 233 |
/** |
| 234 |
* Returns the cached measurement, or NULL if not in cache |
| 235 |
*/ |
| 236 |
private function measureCached($text, $font_size, $angle, $line_spacing) |
| 237 |
{ |
| 238 |
$key = $this->getCacheKey($text, $font_size, $angle, $line_spacing); |
| 239 |
return isset(Text::$measure_cache[$key]) ? |
| 240 |
Text::$measure_cache[$key] : null; |
| 241 |
} |
| 242 |
|
| 243 |
/** |
| 244 |
* Caches measurement |
| 245 |
*/ |
| 246 |
private function cacheMeasurement($text, $font_size, $angle, $line_spacing, |
| 247 |
$width, $height) |
| 248 |
{ |
| 249 |
$key = $this->getCacheKey($text, $font_size, $angle, $line_spacing); |
| 250 |
Text::$measure_cache[$key] = [$width, $height]; |
| 251 |
} |
| 252 |
|
| 253 |
/** |
| 254 |
* Returns the cache key for a string |
| 255 |
*/ |
| 256 |
private function getCacheKey($text, $font_size, $angle, $line_spacing) |
| 257 |
{ |
| 258 |
$key = $text . '-' . $this->font . '-' . $this->adjust . '-' . $font_size . |
| 259 |
'-' . $angle . '-' . $line_spacing; |
| 260 |
return $key; |
| 261 |
} |
| 262 |
|
| 263 |
/** |
| 264 |
* Returns the baseline offset for the current font |
| 265 |
*/ |
| 266 |
public function baseline($font_size) |
| 267 |
{ |
| 268 |
if(!is_numeric($font_size)) |
| 269 |
throw new \InvalidArgumentException("Font size is not numeric"); |
| 270 |
|
| 271 |
$metrics = $this->no_metrics ? false : $this->loadMetrics($this->font); |
| 272 |
|
| 273 |
if($metrics) { |
| 274 |
$metrics =& Text::$metrics[$this->metrics_file]; |
| 275 |
return $metrics['baseline'] * $font_size / $metrics['size']; |
| 276 |
} |
| 277 |
|
| 278 |
// this approximation has been good enough until now |
| 279 |
return $font_size * 0.85; |
| 280 |
} |
| 281 |
|
| 282 |
/** |
| 283 |
* Displays text |
| 284 |
*/ |
| 285 |
public function text($text, $line_spacing, $attribs, $styles = null) |
| 286 |
{ |
| 287 |
// convert to UTF-8 for processing |
| 288 |
$text = $this->convert($text); |
| 289 |
|
| 290 |
// strip special characters |
| 291 |
$text = htmlspecialchars($text, ENT_COMPAT, 'UTF-8'); |
| 292 |
|
| 293 |
// put entities back in (XML entity names only) |
| 294 |
$text = preg_replace('/&(amp|apos|gt|lt|quot|#x[a-fA-F0-9]+|#\d+);/u', |
| 295 |
'&$1;', $text); |
| 296 |
|
| 297 |
// empty string |
| 298 |
if($this->strlen($text, 'UTF-8') == 0) { |
| 299 |
$content = $this->unconvert(' '); |
| 300 |
return $this->graph->element('text', $attribs, $styles, $content); |
| 301 |
} |
| 302 |
|
| 303 |
// single line |
| 304 |
if($this->strpos($text, "\n", 0, 'UTF-8') === false) { |
| 305 |
$content = $this->processLine($text); |
| 306 |
$content = $this->unconvert($content); |
| 307 |
return $this->graph->element('text', $attribs, $styles, $content); |
| 308 |
} |
| 309 |
|
| 310 |
if($line_spacing !== 0 && !is_numeric($line_spacing)) |
| 311 |
throw new \InvalidArgumentException("Line spacing is not numeric"); |
| 312 |
|
| 313 |
$lines = $this->splitLines($text); |
| 314 |
$content = ''; |
| 315 |
$group = 'text'; |
| 316 |
$line_element = 'tspan'; |
| 317 |
$line_attr = ['x' => $attribs['x']]; |
| 318 |
if($this->no_tspan) { |
| 319 |
$line_attr['y'] = $attribs['y']; |
| 320 |
$line_element = 'text'; |
| 321 |
$group = 'g'; |
| 322 |
} else { |
| 323 |
$line_attr['dy'] = 0; |
| 324 |
} |
| 325 |
$count = 1; |
| 326 |
foreach($lines as $line) { |
| 327 |
// blank tspan elements collapse to nothing, so insert a space |
| 328 |
if($this->strlen($line, 'UTF-8') == 0) |
| 329 |
$line = ' '; |
| 330 |
else |
| 331 |
$line = $this->processLine($line); |
| 332 |
$line = $this->unconvert($line); |
| 333 |
|
| 334 |
// the TRUE is for no whitespace |
| 335 |
$content .= $this->graph->element($line_element, $line_attr, null, |
| 336 |
$line, true); |
| 337 |
if($this->no_tspan) { |
| 338 |
$line_attr['y'] = $attribs['y'] + $line_spacing * $count; |
| 339 |
} else { |
| 340 |
$line_attr['dy'] = $line_spacing; |
| 341 |
} |
| 342 |
++$count; |
| 343 |
} |
| 344 |
if($this->no_tspan) |
| 345 |
unset($attribs['x'], $attribs['y']); |
| 346 |
|
| 347 |
return $this->graph->element($group, $attribs, $styles, $content); |
| 348 |
} |
| 349 |
|
| 350 |
/** |
| 351 |
* Trim spaces at ends, collapse multiple spaces, return chars as array |
| 352 |
*/ |
| 353 |
public function processLineArray($str) |
| 354 |
{ |
| 355 |
$chars = $this->splitChars($str); |
| 356 |
$start = 0; |
| 357 |
$end = count($chars) - 1; |
| 358 |
|
| 359 |
if($end >= $start) { |
| 360 |
// find first non-space |
| 361 |
do { |
| 362 |
$code = $this->charCode($chars[$start]); |
| 363 |
} while($this->isSpace($code) && ++$start <= $end); |
| 364 |
} |
| 365 |
|
| 366 |
if($start > $end) { |
| 367 |
// all spaces |
| 368 |
return [' ']; |
| 369 |
} |
| 370 |
|
| 371 |
// find last non-space |
| 372 |
do { |
| 373 |
$code = $this->charCode($chars[$end]); |
| 374 |
} while($this->isSpace($code) && $start < --$end); |
| 375 |
|
| 376 |
$last_space = false; |
| 377 |
$processed = []; |
| 378 |
for($i = $start; $i <= $end; ++$i) { |
| 379 |
|
| 380 |
// reduce multiple normal spaces to one |
| 381 |
if($chars[$i] == ' ') { |
| 382 |
$last_space = true; |
| 383 |
continue; |
| 384 |
} |
| 385 |
|
| 386 |
if($last_space) |
| 387 |
$processed[] = ' '; |
| 388 |
$processed[] = $chars[$i]; |
| 389 |
$last_space = false; |
| 390 |
} |
| 391 |
return $processed; |
| 392 |
} |
| 393 |
|
| 394 |
/** |
| 395 |
* Trim spaces at ends, collapse multiple spaces, return string |
| 396 |
*/ |
| 397 |
public function processLine($str) |
| 398 |
{ |
| 399 |
$processed = $this->processLineArray($str); |
| 400 |
return implode($processed); |
| 401 |
} |
| 402 |
|
| 403 |
/** |
| 404 |
* Returns string length |
| 405 |
*/ |
| 406 |
public function strlen($text, $enc = null) |
| 407 |
{ |
| 408 |
if($text === null) |
| 409 |
return 0; |
| 410 |
if($enc === null) |
| 411 |
$enc = $this->encoding; |
| 412 |
if(Text::$use_iconv) |
| 413 |
return iconv_strlen($text, $enc); |
| 414 |
if(Text::$use_mbstring) |
| 415 |
return mb_strlen($text, $enc); |
| 416 |
return strlen($text); |
| 417 |
} |
| 418 |
|
| 419 |
/** |
| 420 |
* Returns position of needle in haystack |
| 421 |
*/ |
| 422 |
public function strpos($text, $needle, $offset = 0, $enc = null) |
| 423 |
{ |
| 424 |
if($enc === null) |
| 425 |
$enc = $this->encoding; |
| 426 |
if(Text::$use_iconv) |
| 427 |
return iconv_strpos($text, $needle, $offset, $enc); |
| 428 |
if(Text::$use_mbstring) |
| 429 |
return mb_strpos($text, $needle, $offset, $enc); |
| 430 |
return strpos($text, $needle, $offset); |
| 431 |
} |
| 432 |
|
| 433 |
/** |
| 434 |
* Splits UTF-8 string into lines |
| 435 |
*/ |
| 436 |
public function splitLines($text) |
| 437 |
{ |
| 438 |
return preg_split("/\\n/u", $text); |
| 439 |
} |
| 440 |
|
| 441 |
/** |
| 442 |
* Splits UTF-8 string into chars |
| 443 |
*/ |
| 444 |
private function splitChars($text) |
| 445 |
{ |
| 446 |
return preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY); |
| 447 |
} |
| 448 |
|
| 449 |
/** |
| 450 |
* Returns the code for a UTF-8 character |
| 451 |
*/ |
| 452 |
public static function charCode($char) |
| 453 |
{ |
| 454 |
$code = ord(substr($char, 0, 1)); |
| 455 |
if($code < 0x80) { |
| 456 |
return $code; |
| 457 |
} elseif(($code & 0xe0) == 0xc0) { |
| 458 |
$n = 1; |
| 459 |
$code = $code & 0x1f; |
| 460 |
} elseif(($code & 0xf0) == 0xe0) { |
| 461 |
$n = 2; |
| 462 |
$code = $code & 0x0f; |
| 463 |
} elseif(($code & 0xf8) == 0xf0) { |
| 464 |
$n = 3; |
| 465 |
$code = $code & 0x07; |
| 466 |
} else { |
| 467 |
// replacement character for unknown code point |
| 468 |
return 0xfffd; |
| 469 |
} |
| 470 |
|
| 471 |
for($i = 1; $i <= $n; ++$i) { |
| 472 |
$byte = ord(substr($char, $i, 1)); |
| 473 |
$code = ($code * 0x40) + ($byte & 0x3f); |
| 474 |
} |
| 475 |
// should be some validation here |
| 476 |
|
| 477 |
return $code; |
| 478 |
} |
| 479 |
|
| 480 |
/** |
| 481 |
* Returns the UTF-8 character for a code point |
| 482 |
*/ |
| 483 |
public static function charFromCode($code) |
| 484 |
{ |
| 485 |
if($code < 0x80) |
| 486 |
return chr($code); |
| 487 |
|
| 488 |
$u = ($code >> 16) & 0x1f; |
| 489 |
$z = ($code >> 12) & 0x0f; |
| 490 |
$y = ($code >> 6) & 0x3f; |
| 491 |
$x = ($code & 0x3f); |
| 492 |
|
| 493 |
if($u) { |
| 494 |
$b1 = 0xf0 + ($u >> 2); |
| 495 |
$b2 = 0x80 + (($u & 0x03) << 4) + $z; |
| 496 |
$b3 = 0x80 + $y; |
| 497 |
$b4 = 0x80 + $x; |
| 498 |
return chr($b1) . chr($b2) . chr($b3) . chr($b4); |
| 499 |
} |
| 500 |
|
| 501 |
if($z) { |
| 502 |
$b1 = 0xe0 + $z; |
| 503 |
$b2 = 0x80 + $y; |
| 504 |
$b3 = 0x80 + $x; |
| 505 |
return chr($b1) . chr($b2) . chr($b3); |
| 506 |
} |
| 507 |
|
| 508 |
$b1 = 0xc0 + $y; |
| 509 |
$b2 = 0x80 + $x; |
| 510 |
return chr($b1) . chr($b2); |
| 511 |
} |
| 512 |
|
| 513 |
/** |
| 514 |
* Returns the width of a character, relative to a plain letter |
| 515 |
*/ |
| 516 |
public static function charWidth($char) |
| 517 |
{ |
| 518 |
if(Text::isCombiner($char)) |
| 519 |
return 0; |
| 520 |
|
| 521 |
if(Text::isDoubleWidth($char)) |
| 522 |
return 2; |
| 523 |
return 1; |
| 524 |
} |
| 525 |
|
| 526 |
/** |
| 527 |
* Returns TRUE if the character is some kind of space |
| 528 |
*/ |
| 529 |
public static function isSpace($char, $type = false) |
| 530 |
{ |
| 531 |
$spaces = [ |
| 532 |
0x20 => 'space', |
| 533 |
0xa0 => 'no-break space', |
| 534 |
// 0x1680 => 'ogham space mark', // more dash than space |
| 535 |
0x2000 => 'en quad', |
| 536 |
0x2001 => 'em quad', |
| 537 |
0x2002 => 'en space', |
| 538 |
0x2003 => 'em space', |
| 539 |
0x2004 => 'three-per-em space', |
| 540 |
0x2005 => 'four-per-em space', |
| 541 |
0x2006 => 'six-per-em space', |
| 542 |
0x2007 => 'figure space', |
| 543 |
0x2008 => 'punctuation space', |
| 544 |
0x2009 => 'thin space', |
| 545 |
0x200a => 'hair space', |
| 546 |
0x202f => 'narrow no-break space', |
| 547 |
0x205f => 'medium mathematical space', |
| 548 |
0x3000 => 'ideographic space', |
| 549 |
]; |
| 550 |
if(isset($spaces[$char])) |
| 551 |
return $type ? $spaces[$char] : true; |
| 552 |
return false; |
| 553 |
} |
| 554 |
|
| 555 |
/** |
| 556 |
* Returns TRUE if the character is a combining mark |
| 557 |
*/ |
| 558 |
public static function isCombiner($char) |
| 559 |
{ |
| 560 |
$ranges = [ |
| 561 |
[0x300,0x36f], // combining diacritical marks |
| 562 |
[0x1ab0,0x1aff], // combining diacritical marks extended |
| 563 |
[0x1dc0,0x1dff], // combining diacritical marks supplement |
| 564 |
[0x20d0,0x20ff], // combining diacritical marks for symbols |
| 565 |
[0xfe20,0xfe2f], // combining half marks |
| 566 |
]; |
| 567 |
foreach($ranges as $range) { |
| 568 |
if($char >= $range[0] && $char <= $range[1]) |
| 569 |
return true; |
| 570 |
} |
| 571 |
return false; |
| 572 |
} |
| 573 |
|
| 574 |
/** |
| 575 |
* Returns TRUE for a double-width character |
| 576 |
*/ |
| 577 |
public static function isDoubleWidth($char) |
| 578 |
{ |
| 579 |
$ranges = [ |
| 580 |
[0x1100,0x115F], // hangul jamo |
| 581 |
[0x11A3,0x11A7], |
| 582 |
[0x11FA,0x11FF], |
| 583 |
[0x2329,0x232A], // tech |
| 584 |
[0x2E80,0x2E99], // cjk radicals |
| 585 |
[0x2E9B,0x2EF3], |
| 586 |
[0x2F00,0x2FD5], // kangxi radicals |
| 587 |
[0x2FF0,0x2FFB], // ideographic description characters |
| 588 |
[0x3000,0x303E], // cjk symbols |
| 589 |
[0x3041,0x3096], // hiragana |
| 590 |
[0x3099,0x30FF], // katakana |
| 591 |
[0x3105,0x312D], // bopomofo |
| 592 |
[0x3131,0x318E], // hangul compat jamo |
| 593 |
[0x3190,0x31BA], // kanbun |
| 594 |
[0x31C0,0x31E3], // cjk strokes |
| 595 |
[0x31F0,0x321E], // katakana phonetic extensions |
| 596 |
[0x3220,0x3247], // enclosed cjk |
| 597 |
[0x3250,0x32FE], |
| 598 |
[0x3300,0x4DBF], // cjk |
| 599 |
[0x4E00,0xA48C], |
| 600 |
[0xA490,0xA4C6], // yi radicals |
| 601 |
[0xA960,0xA97C], // hangul jamo extended a |
| 602 |
[0xAC00,0xD7A3], // hangul syllables |
| 603 |
[0xD7B0,0xD7C6], // hangul jamo extended b |
| 604 |
[0xD7CB,0xD7FB], |
| 605 |
[0xF900,0xFAFF], // cjk compat ideographs |
| 606 |
[0xFE10,0xFE19], // vertical forms |
| 607 |
[0xFE30,0xFE52], // cjk compat forms |
| 608 |
[0xFE54,0xFE66], // small form variants |
| 609 |
[0xFE68,0xFE6B], |
| 610 |
[0xFF01,0xFF60], // halfwidth and fullwidth forms |
| 611 |
[0xFFE0,0xFFE6], |
| 612 |
[0x1B000,0x1B001], // kana supplement |
| 613 |
[0x1F200,0x1F202], // enclosed ideographic supplement |
| 614 |
[0x1F210,0x1F23A], |
| 615 |
[0x1F240,0x1F248], |
| 616 |
[0x1F250,0x1F251], |
| 617 |
[0x1F600,0x1F64F], // emoticons |
| 618 |
[0x1F900,0x1F9FF], // emoticons |
| 619 |
[0x20000,0x2FFFD], // cjk ideographs |
| 620 |
]; |
| 621 |
foreach($ranges as $range) { |
| 622 |
if($char >= $range[0] && $char <= $range[1]) |
| 623 |
return true; |
| 624 |
} |
| 625 |
return false; |
| 626 |
} |
| 627 |
|
| 628 |
/** |
| 629 |
* Returns a substring |
| 630 |
*/ |
| 631 |
public function substr($text, $begin, $length = null) |
| 632 |
{ |
| 633 |
if(Text::$use_iconv) { |
| 634 |
if($length === null) |
| 635 |
$length = iconv_strlen($text, $this->encoding); |
| 636 |
return iconv_substr($text, $begin, $length, $this->encoding); |
| 637 |
} |
| 638 |
|
| 639 |
if(Text::$use_mbstring) |
| 640 |
return mb_substr($text, $begin, $length, $this->encoding); |
| 641 |
|
| 642 |
return $length === null ? substr($text, $begin) : |
| 643 |
substr($text, $begin, $length); |
| 644 |
} |
| 645 |
|
| 646 |
/** |
| 647 |
* Returns the number of lines in a string |
| 648 |
*/ |
| 649 |
public function lines($text) |
| 650 |
{ |
| 651 |
$c = 1; |
| 652 |
$pos = 0; |
| 653 |
while(($pos = $this->strpos($text, "\n", $pos)) !== false) { |
| 654 |
++$c; |
| 655 |
++$pos; |
| 656 |
} |
| 657 |
return $c; |
| 658 |
} |
| 659 |
|
| 660 |
/** |
| 661 |
* Replaces the entities in a string |
| 662 |
*/ |
| 663 |
public function replaceEntities($text) |
| 664 |
{ |
| 665 |
$patterns = ['&', ''', '<', '>', '"']; |
| 666 |
$replacements = ['&', "'", '<', '>', '"']; |
| 667 |
$text = str_replace($patterns, $replacements, $text); |
| 668 |
|
| 669 |
// numeric entities a bit trickier |
| 670 |
$text = preg_replace_callback('/&#(x)?([a-fA-F0-9]+);/ui', function($ent) { |
| 671 |
$code = ($ent[1] == 'x' ? base_convert($ent[2], 16, 10) : $ent[2]); |
| 672 |
return Text::charFromCode($code); |
| 673 |
}, $text); |
| 674 |
return $text; |
| 675 |
} |
| 676 |
|
| 677 |
/** |
| 678 |
* Converts a string to UTF-8, if possible |
| 679 |
*/ |
| 680 |
private function convert($text) |
| 681 |
{ |
| 682 |
if($this->encoding != 'UTF-8') { |
| 683 |
$converted = false; |
| 684 |
if(Text::$use_iconv) { |
| 685 |
$converted = @iconv($this->encoding, 'UTF-8', $text); |
| 686 |
if($converted !== false) { |
| 687 |
$text = $converted; |
| 688 |
$converted = true; |
| 689 |
} |
| 690 |
} |
| 691 |
if(!$converted && Text::$use_mbstring) { |
| 692 |
$order = mb_detect_order(); |
| 693 |
array_unshift($order, $this->encoding); |
| 694 |
$enc = mb_detect_encoding($text, $order); |
| 695 |
if($enc !== false) |
| 696 |
$text = @mb_convert_encoding($text, 'UTF-8', $enc); |
| 697 |
} |
| 698 |
} |
| 699 |
return $text; |
| 700 |
} |
| 701 |
|
| 702 |
/** |
| 703 |
* Converts back from UTF-8 to chosen encoding |
| 704 |
*/ |
| 705 |
private function unconvert($text) |
| 706 |
{ |
| 707 |
if($this->encoding != 'UTF-8') { |
| 708 |
$converted = false; |
| 709 |
if(Text::$use_iconv) { |
| 710 |
$converted = @iconv('UTF-8', $this->encoding, $text); |
| 711 |
if($converted !== false) { |
| 712 |
$text = $converted; |
| 713 |
$converted = true; |
| 714 |
} |
| 715 |
} |
| 716 |
if(!$converted && Text::$use_mbstring) { |
| 717 |
$text = @mb_convert_encoding($text, $this->encoding, 'UTF-8'); |
| 718 |
} |
| 719 |
} |
| 720 |
return $text; |
| 721 |
} |
| 722 |
|
| 723 |
/** |
| 724 |
* Returns the filename for a font metrics file |
| 725 |
*/ |
| 726 |
private static function metricsFilename($font) |
| 727 |
{ |
| 728 |
return preg_replace('/[^a-z0-9]+/ui', '_', strtolower($font)); |
| 729 |
} |
| 730 |
|
| 731 |
/** |
| 732 |
* Loads metrics for a font |
| 733 |
*/ |
| 734 |
private static function loadMetrics($font) |
| 735 |
{ |
| 736 |
// metrics use JSON, so we need support |
| 737 |
if(!extension_loaded('json')) |
| 738 |
return false; |
| 739 |
|
| 740 |
$filename = Text::metricsFilename($font); |
| 741 |
if(isset(Text::$metrics[$filename])) { |
| 742 |
return (Text::$metrics[$filename] !== false); |
| 743 |
} |
| 744 |
|
| 745 |
$metrics_path = __DIR__ . '/fonts/' . $filename . '.json'; |
| 746 |
if(file_exists($metrics_path)) { |
| 747 |
$metrics = file_get_contents($metrics_path); |
| 748 |
$metrics = @json_decode($metrics, true); |
| 749 |
// validate the metrics in case the file is corrupt |
| 750 |
if(is_array($metrics)) { |
| 751 |
if(isset($metrics['map'])) { |
| 752 |
$map_to = $metrics['map']; |
| 753 |
if(isset(Text::$metrics[$map_to])) { |
| 754 |
$metrics = Text::$metrics[$map_to]; |
| 755 |
} else { |
| 756 |
$metrics_path = __DIR__ . '/fonts/' . $map_to . '.json'; |
| 757 |
if(file_exists($metrics_path)) { |
| 758 |
$metrics = file_get_contents($metrics_path); |
| 759 |
$metrics = @json_decode($metrics, true); |
| 760 |
} |
| 761 |
} |
| 762 |
} |
| 763 |
|
| 764 |
if(isset($metrics['mean']) && $metrics['mean'] > 1 |
| 765 |
&& isset($metrics['size']) && $metrics['size'] > 1 |
| 766 |
&& isset($metrics['height']) && $metrics['height'] > 1 |
| 767 |
&& isset($metrics['baseline']) && $metrics['baseline'] > 1) { |
| 768 |
Text::$metrics[$filename] = $metrics; |
| 769 |
return true; |
| 770 |
} |
| 771 |
} |
| 772 |
} |
| 773 |
Text::$metrics[$filename] = false; |
| 774 |
return false; |
| 775 |
} |
| 776 |
} |
| 777 |
|
| 778 |
|