mirror of
https://github.com/Oreolek/kohana-less.git
synced 2024-07-08 17:34:26 +03:00
1313 lines
32 KiB
PHP
1313 lines
32 KiB
PHP
<?php
|
|
|
|
/**
|
|
* lessphp v0.2.0
|
|
* http://leafo.net/lessphp
|
|
*
|
|
* LESS Css compiler, adapted from http://lesscss.org/docs.html
|
|
*
|
|
* Copyright 2010, Leaf Corcoran <leafot@gmail.com>
|
|
* Licensed under MIT or GPLv3, see LICENSE
|
|
*/
|
|
|
|
//
|
|
// investigate trouble with ^M
|
|
// fix the alpha value with color when using a percent
|
|
//
|
|
|
|
class lessc {
|
|
private $buffer;
|
|
private $count;
|
|
private $line;
|
|
private $expandStack;
|
|
|
|
private $env = array();
|
|
|
|
public $vPrefix = '@';
|
|
public $mPrefix = '$';
|
|
public $imPrefix = '!';
|
|
public $selfSelector = '&';
|
|
|
|
static private $precedence = array(
|
|
'+' => 0,
|
|
'-' => 0,
|
|
'*' => 1,
|
|
'/' => 1,
|
|
'%' => 1,
|
|
);
|
|
static private $operatorString; // regex string to match any of the operators
|
|
|
|
static private $dtypes = array('expression', 'variable', 'function', 'negative'); // types with delayed computation
|
|
static private $units = array(
|
|
'px', '%', 'in', 'cm', 'mm', 'em', 'ex', 'pt', 'pc', 'ms', 's', 'deg');
|
|
|
|
public $importDisabled = false;
|
|
public $importDir = '';
|
|
|
|
// compile chunk off the head of buffer
|
|
function chunk() {
|
|
if (empty($this->buffer)) return false;
|
|
$s = $this->seek();
|
|
|
|
// a property
|
|
if ($this->keyword($key) && $this->assign() && $this->propertyValue($value) && $this->end()) {
|
|
// look for important prefix
|
|
if ($key{0} == $this->imPrefix && strlen($key) > 1) {
|
|
$key = substr($key, 1);
|
|
if ($value[0] == 'list' && $value[1] == ' ') {
|
|
$value[2][] = array('keyword', '!important');
|
|
} else {
|
|
$value = array('list', ' ', array($value, array('keyword', '!important')));
|
|
}
|
|
}
|
|
$this->append($key, $value);
|
|
|
|
if (count($this->env) == 1)
|
|
return $this->compileProperty($key, array($value))."\n";
|
|
else
|
|
return true;
|
|
} else {
|
|
$this->seek($s);
|
|
}
|
|
|
|
// look for special css @ directives
|
|
if (count($this->env) == 1 && $this->count < strlen($this->buffer) && $this->buffer[$this->count] == '@') {
|
|
// a font-face block
|
|
if ($this->literal('@font-face') && $this->literal('{')) {
|
|
$this->push();
|
|
$this->set('__tags', array('@font-face'));
|
|
$this->set('__dontsave', true);
|
|
return true;
|
|
} else {
|
|
$this->seek($s);
|
|
}
|
|
|
|
// charset
|
|
if ($this->literal('@charset') && $this->propertyValue($value) && $this->end()) {
|
|
return "@charset ".$this->compileValue($value).";\n";
|
|
} else {
|
|
$this->seek($s);
|
|
}
|
|
}
|
|
|
|
// opening abstract block
|
|
if ($this->tag($tag, true) && $this->argumentDef($args) && $this->literal('{')) {
|
|
$this->push();
|
|
|
|
// move out of variable scope
|
|
if ($tag{0} == $this->vPrefix) $tag[0] = $this->mPrefix;
|
|
|
|
$this->set('__tags', array($tag));
|
|
if (isset($args)) $this->set('__args', $args);
|
|
|
|
return true;
|
|
} else {
|
|
$this->seek($s);
|
|
}
|
|
|
|
// opening css block
|
|
if ($this->tags($tags) && $this->literal('{')) {
|
|
// move @ tags out of variable namespace!
|
|
foreach($tags as &$tag) {
|
|
if ($tag{0} == $this->vPrefix) $tag[0] = $this->mPrefix;
|
|
}
|
|
|
|
$this->push();
|
|
$this->set('__tags', $tags);
|
|
|
|
return true;
|
|
} else {
|
|
$this->seek($s);
|
|
}
|
|
|
|
// closing block
|
|
if ($this->literal('}')) {
|
|
$tags = $this->multiplyTags();
|
|
$env = end($this->env);
|
|
$ctags = $env['__tags'];
|
|
unset($env['__tags']);
|
|
|
|
// insert the default arguments
|
|
if (isset($env['__args'])) {
|
|
foreach ($env['__args'] as $arg) {
|
|
if (isset($arg[1])) {
|
|
$this->prepend($this->vPrefix.$arg[0], $arg[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!empty($tags))
|
|
$out = $this->compileBlock($tags, $env);
|
|
|
|
$this->pop();
|
|
|
|
// make the block(s) available in the new current scope
|
|
if (!isset($env['__dontsave'])) {
|
|
foreach ($ctags as $t) {
|
|
// if the block already exists then merge
|
|
if ($this->get($t, array(end($this->env)))) {
|
|
$this->merge($t, $env);
|
|
} else {
|
|
$this->set($t, $env);
|
|
}
|
|
}
|
|
}
|
|
|
|
return isset($out) ? $out : true;
|
|
}
|
|
|
|
// import statement
|
|
if ($this->import($url, $media)) {
|
|
if ($this->importDisabled) return "/* import is disabled */\n";
|
|
|
|
$full = $this->importDir.$url;
|
|
if (file_exists($file = $full) || file_exists($file = $full.'.less')) {
|
|
$loaded = $this->removeComments(ltrim(file_get_contents($file).";"));
|
|
$this->buffer = substr($this->buffer, 0, $this->count).$loaded.substr($this->buffer, $this->count);
|
|
return true;
|
|
}
|
|
|
|
return '@import url("'.$url.'")'.($media ? ' '.$media : '').";\n";
|
|
}
|
|
|
|
// setting variable
|
|
if ($this->variable($name) && $this->assign() && $this->propertyValue($value) && $this->end()) {
|
|
$this->append($this->vPrefix.$name, $value);
|
|
return true;
|
|
} else {
|
|
$this->seek($s);
|
|
}
|
|
|
|
// mixin/function expand
|
|
if ($this->tags($tags, true, '>') && ($this->argumentValues($argv) || true) && $this->end()) {
|
|
$env = $this->getEnv($tags);
|
|
if ($env == null) return true;
|
|
|
|
// if we have arguments then insert them
|
|
if (!empty($env['__args'])) {
|
|
foreach($env['__args'] as $arg) {
|
|
$vname = $this->vPrefix.$arg[0];
|
|
$value = is_array($argv) ? array_shift($argv) : null;
|
|
// copy default value if there isn't one supplied
|
|
if ($value == null && isset($arg[1]))
|
|
$value = $arg[1];
|
|
|
|
// if ($value == null) continue; // don't define so it can search up
|
|
|
|
// create new entry if var doesn't exist in scope
|
|
if (isset($env[$vname])) {
|
|
array_unshift($env[$vname], $value);
|
|
} else {
|
|
// new element
|
|
$env[$vname] = array($value);
|
|
}
|
|
}
|
|
}
|
|
|
|
// set all properties
|
|
ob_start();
|
|
$blocks = array();
|
|
foreach ($env as $name => $value) {
|
|
// skip the metatdata
|
|
if (preg_match('/^__/', $name)) continue;
|
|
|
|
// if it is a block, remember it to compile after everything
|
|
// is mixed in
|
|
if (!isset($value[0]))
|
|
$blocks[] = array($name, $value);
|
|
|
|
// copy the data
|
|
// don't overwrite previous value, look in current env for name
|
|
if ($this->get($name, array(end($this->env)))) {
|
|
while ($tval = array_shift($value))
|
|
$this->append($name, $tval);
|
|
} else
|
|
$this->set($name, $value);
|
|
}
|
|
|
|
// render sub blocks
|
|
foreach ($blocks as $b) {
|
|
$rtags = $this->multiplyTags(array($b[0]));
|
|
echo $this->compileBlock($rtags, $b[1]);
|
|
}
|
|
|
|
return ob_get_clean();
|
|
} else {
|
|
$this->seek($s);
|
|
}
|
|
|
|
// spare ;
|
|
if ($this->literal(';')) return true;
|
|
|
|
return false; // couldn't match anything, throw error
|
|
}
|
|
|
|
// recursively find the cartesian product of all tags in stack
|
|
function multiplyTags($tags = array(' '), $d = null) {
|
|
if ($d === null) $d = count($this->env) - 1;
|
|
|
|
$parents = $d == 0 ? $this->env[$d]['__tags']
|
|
: $this->multiplyTags($this->env[$d]['__tags'], $d - 1);
|
|
|
|
$rtags = array();
|
|
foreach ($parents as $p) {
|
|
foreach ($tags as $t) {
|
|
if ($t{0} == $this->mPrefix) continue; // skip functions
|
|
$d = ' ';
|
|
if ($t{0} == ':' || $t{0} == $this->selfSelector) {
|
|
$t = ltrim($t, $this->selfSelector);
|
|
$d = '';
|
|
}
|
|
$rtags[] = trim($p.$d.$t);
|
|
}
|
|
}
|
|
|
|
return $rtags;
|
|
}
|
|
|
|
// a list of expressions
|
|
function expressionList(&$exps) {
|
|
$values = array();
|
|
|
|
while ($this->expression($exp)) {
|
|
$values[] = $exp;
|
|
}
|
|
|
|
if (count($values) == 0) return false;
|
|
|
|
$exps = $this->compressList($values, ' ');
|
|
return true;
|
|
}
|
|
|
|
// a single expression
|
|
function expression(&$out) {
|
|
$s = $this->seek();
|
|
$needWhite = true;
|
|
if ($this->literal('(') && $this->expression($exp) && $this->literal(')')) {
|
|
$lhs = $exp;
|
|
$needWhite = false;
|
|
} elseif ($this->seek($s) && $this->value($val)) {
|
|
$lhs = $val;
|
|
} else {
|
|
return false;
|
|
}
|
|
|
|
$out = $this->expHelper($lhs, 0, $needWhite);
|
|
return true;
|
|
}
|
|
|
|
// resursively parse infix equation with $lhs at precedence $minP
|
|
function expHelper($lhs, $minP, $needWhite = true) {
|
|
$ss = $this->seek();
|
|
// try to find a valid operator
|
|
while ($this->match(self::$operatorString.($needWhite ? '\s+' : ''), $m) && self::$precedence[$m[1]] >= $minP) {
|
|
$needWhite = true;
|
|
// get rhs
|
|
$s = $this->seek();
|
|
if ($this->literal('(') && $this->expression($exp) && $this->literal(')')) {
|
|
$needWhite = false;
|
|
$rhs = $exp;
|
|
} elseif ($this->seek($s) && $this->value($val)) {
|
|
$rhs = $val;
|
|
} else break;
|
|
|
|
// peek for next operator to see what to do with rhs
|
|
if ($this->peek(self::$operatorString, $next) && self::$precedence[$next[1]] > $minP) {
|
|
$rhs = $this->expHelper($rhs, self::$precedence[$next[1]]);
|
|
}
|
|
|
|
// don't evaluate yet if it is dynamic
|
|
if (in_array($rhs[0], self::$dtypes) || in_array($lhs[0], self::$dtypes))
|
|
$lhs = array('expression', $m[1], $lhs, $rhs);
|
|
else
|
|
$lhs = $this->evaluate($m[1], $lhs, $rhs);
|
|
|
|
$ss = $this->seek();
|
|
}
|
|
$this->seek($ss);
|
|
|
|
return $lhs;
|
|
}
|
|
|
|
// consume a list of values for a property
|
|
function propertyValue(&$value) {
|
|
$values = array();
|
|
|
|
$s = null;
|
|
while ($this->expressionList($v)) {
|
|
$values[] = $v;
|
|
$s = $this->seek();
|
|
if (!$this->literal(',')) break;
|
|
}
|
|
|
|
if ($s) $this->seek($s);
|
|
|
|
if (count($values) == 0) return false;
|
|
|
|
$value = $this->compressList($values, ', ');
|
|
return true;
|
|
}
|
|
|
|
// a single value
|
|
function value(&$value) {
|
|
// try a unit
|
|
if ($this->unit($value)) return true;
|
|
|
|
// see if there is a negation
|
|
$s = $this->seek();
|
|
if ($this->literal('-', false) && $this->variable($vname)) {
|
|
$value = array('negative', array('variable', $this->vPrefix.$vname));
|
|
return true;
|
|
} else {
|
|
$this->seek($s);
|
|
}
|
|
|
|
// accessor
|
|
// must be done before color
|
|
// this needs negation too
|
|
if ($this->accessor($a)) {
|
|
$tmp = $this->getEnv($a[0]);
|
|
if ($tmp && isset($tmp[$a[1]]))
|
|
$value = end($tmp[$a[1]]);
|
|
return true;
|
|
}
|
|
|
|
// color
|
|
if ($this->color($value)) return true;
|
|
|
|
// css function
|
|
// must be done after color
|
|
if ($this->func($value)) return true;
|
|
|
|
// string
|
|
if ($this->string($tmp, $d)) {
|
|
$value = array('string', $d.$tmp.$d);
|
|
return true;
|
|
}
|
|
|
|
// try a keyword
|
|
if ($this->keyword($word)) {
|
|
$value = array('keyword', $word);
|
|
return true;
|
|
}
|
|
|
|
// try a variable
|
|
if ($this->variable($vname)) {
|
|
$value = array('variable', $this->vPrefix.$vname);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// an import statement
|
|
function import(&$url, &$media) {
|
|
$s = $this->seek();
|
|
if (!$this->literal('@import')) return false;
|
|
|
|
// @import "something.css" media;
|
|
// @import url("something.css") media;
|
|
// @import url(something.css) media;
|
|
|
|
if ($this->literal('url(')) $parens = true; else $parens = false;
|
|
|
|
if (!$this->string($url)) {
|
|
if ($parens && $this->to(')', $url)) {
|
|
$parens = false; // got em
|
|
} else {
|
|
$this->seek($s);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if ($parens && !$this->literal(')')) {
|
|
$this->seek($s);
|
|
return false;
|
|
}
|
|
|
|
// now the rest is media
|
|
return $this->to(';', $media, false, true);
|
|
}
|
|
|
|
// a scoped value accessor
|
|
// .hello > @scope1 > @scope2['value'];
|
|
function accessor(&$var) {
|
|
$s = $this->seek();
|
|
|
|
if (!$this->tags($scope, true, '>') || !$this->literal('[')) {
|
|
$this->seek($s);
|
|
return false;
|
|
}
|
|
|
|
// either it is a variable or a property
|
|
// why is a property wrapped in quotes, who knows!
|
|
if ($this->variable($name)) {
|
|
$name = $this->vPrefix.$name;
|
|
} elseif($this->literal("'") && $this->keyword($name) && $this->literal("'")) {
|
|
// .. $this->count is messed up if we wanted to test another access type
|
|
} else {
|
|
$this->seek($s);
|
|
return false;
|
|
}
|
|
|
|
if (!$this->literal(']')) {
|
|
$this->seek($s);
|
|
return false;
|
|
}
|
|
|
|
$var = array($scope, $name);
|
|
return true;
|
|
}
|
|
|
|
// a string
|
|
function string(&$string, &$d = null) {
|
|
$s = $this->seek();
|
|
if ($this->literal('"', false)) {
|
|
$delim = '"';
|
|
} else if($this->literal("'", false)) {
|
|
$delim = "'";
|
|
} else {
|
|
return false;
|
|
}
|
|
|
|
if (!$this->to($delim, $string)) {
|
|
$this->seek($s);
|
|
return false;
|
|
}
|
|
|
|
$d = $delim;
|
|
return true;
|
|
}
|
|
|
|
// a numerical unit
|
|
function unit(&$unit, $allowed = null) {
|
|
$simpleCase = $allowed == null;
|
|
if (!$allowed) $allowed = self::$units;
|
|
|
|
if ($this->match('(-?[0-9]*(\.)?[0-9]+)('.implode('|', $allowed).')?', $m, !$simpleCase)) {
|
|
if (!isset($m[3])) $m[3] = 'number';
|
|
$unit = array($m[3], $m[1]);
|
|
|
|
// check for size/height font unit.. should this even be here?
|
|
if ($simpleCase) {
|
|
$s = $this->seek();
|
|
if ($this->literal('/', false) && $this->unit($right, self::$units)) {
|
|
$unit = array('keyword', $this->compileValue($unit).'/'.$this->compileValue($right));
|
|
} else {
|
|
// get rid of whitespace
|
|
$this->seek($s);
|
|
$this->match('', $_);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// a # color
|
|
function color(&$out) {
|
|
$color = array('color');
|
|
|
|
if ($this->match('(#([0-9a-f]{6})|#([0-9a-f]{3}))', $m)) {
|
|
if (isset($m[3])) {
|
|
$num = $m[3];
|
|
$width = 16;
|
|
} else {
|
|
$num = $m[2];
|
|
$width = 256;
|
|
}
|
|
|
|
$num = hexdec($num);
|
|
foreach(array(3,2,1) as $i) {
|
|
$t = $num % $width;
|
|
$num /= $width;
|
|
|
|
$color[$i] = $t * (256/$width) + $t * floor(16/$width);
|
|
}
|
|
|
|
$out = $color;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// consume a list of property values delimited by ; and wrapped in ()
|
|
function argumentValues(&$args, $delim = ';') {
|
|
$s = $this->seek();
|
|
if (!$this->literal('(')) return false;
|
|
|
|
$values = array();
|
|
while ($this->propertyValue($value)) {
|
|
$values[] = $value;
|
|
if (!$this->literal($delim)) break;
|
|
}
|
|
|
|
if (!$this->literal(')')) {
|
|
$this->seek($s);
|
|
return false;
|
|
}
|
|
|
|
$args = $values;
|
|
return true;
|
|
}
|
|
|
|
// consume an argument definition list surrounded by (), each argument is a variable name with optional value
|
|
function argumentDef(&$args, $delim = ';') {
|
|
$s = $this->seek();
|
|
if (!$this->literal('(')) return false;
|
|
|
|
$values = array();
|
|
while ($this->variable($vname)) {
|
|
$arg = array($vname);
|
|
if ($this->assign() && $this->propertyValue($value)) {
|
|
$arg[] = $value;
|
|
// let the : slide if there is no value
|
|
}
|
|
|
|
$values[] = $arg;
|
|
if (!$this->literal($delim)) break;
|
|
}
|
|
|
|
if (!$this->literal(')')) {
|
|
$this->seek($s);
|
|
return false;
|
|
}
|
|
|
|
$args = $values;
|
|
return true;
|
|
}
|
|
|
|
// consume a list of tags
|
|
// this accepts a hanging delimiter
|
|
function tags(&$tags, $simple = false, $delim = ',') {
|
|
$tags = array();
|
|
while ($this->tag($tt, $simple)) {
|
|
$tags[] = $tt;
|
|
if (!$this->literal($delim)) break;
|
|
}
|
|
if (count($tags) == 0) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
// a single tag
|
|
function tag(&$tag, $simple = false) {
|
|
if ($simple)
|
|
$chars = '^,:;{}\][>\(\) ';
|
|
else
|
|
$chars = '^,;{}[';
|
|
|
|
$tag = '';
|
|
while ($this->match('(['.$chars.'0-9]['.$chars.']*)', $m)) {
|
|
$tag.= $m[1];
|
|
if ($simple) break;
|
|
|
|
$s = $this->seek();
|
|
if ($this->literal('[') && $this->to(']', $c, true) && $this->literal(']')) {
|
|
$tag .= '['.$c.'] ';
|
|
} else {
|
|
$this->seek($s);
|
|
break;
|
|
}
|
|
}
|
|
$tag = trim($tag);
|
|
if ($tag == '') return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
// a css function
|
|
function func(&$func) {
|
|
$s = $this->seek();
|
|
|
|
if ($this->match('([\w\-_][\w\-_:\.]*)', $m) && $this->literal('(')) {
|
|
$fname = $m[1];
|
|
if ($fname == 'url') {
|
|
$this->to(')', $content, true);
|
|
$args = array('string', $content);
|
|
} else {
|
|
$args = array();
|
|
while (true) {
|
|
$ss = $this->seek();
|
|
if ($this->keyword($name) && $this->literal('=') && $this->expressionList($value)) {
|
|
$args[] = array('list', '=', array(array('keyword', $name), $value));
|
|
} else {
|
|
$this->seek($ss);
|
|
if ($this->expressionList($value)) {
|
|
$args[] = $value;
|
|
}
|
|
}
|
|
|
|
if (!$this->literal(',')) break;
|
|
}
|
|
$args = array('list', ',', $args);
|
|
}
|
|
|
|
if ($this->literal(')')) {
|
|
$func = array('function', $fname, $args);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
$this->seek($s);
|
|
return false;
|
|
}
|
|
|
|
// consume a less variable
|
|
function variable(&$name) {
|
|
$s = $this->seek();
|
|
if ($this->literal($this->vPrefix, false) && $this->keyword($name)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// consume an assignment operator
|
|
function assign() {
|
|
return $this->literal(':') || $this->literal('=');
|
|
}
|
|
|
|
// consume a keyword
|
|
function keyword(&$word) {
|
|
if ($this->match('([\w_\-\*!"][\w\-_"]*)', $m)) {
|
|
$word = $m[1];
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// consume an end of statement delimiter
|
|
function end() {
|
|
if ($this->literal(';'))
|
|
return true;
|
|
elseif ($this->count == strlen($this->buffer) || $this->buffer{$this->count} == '}') {
|
|
// if there is end of file or a closing block next then we don't need a ;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function compressList($items, $delim) {
|
|
if (count($items) == 1) return $items[0];
|
|
else return array('list', $delim, $items);
|
|
}
|
|
|
|
function compileBlock($rtags, $env) {
|
|
// don't render functions
|
|
// todo: this shouldn't need to happen because multiplyTags prunes them, verify
|
|
/*
|
|
foreach ($rtags as $i => $tag) {
|
|
if (preg_match('/( |^)%/', $tag))
|
|
unset($rtags[$i]);
|
|
}
|
|
*/
|
|
if (empty($rtags)) return '';
|
|
|
|
$props = 0;
|
|
// print all the visible properties
|
|
ob_start();
|
|
foreach ($env as $name => $value) {
|
|
// todo: change this, poor hack
|
|
// make a better name storage system!!! (value types are fine)
|
|
// but.. don't render special properties (blocks, vars, metadata)
|
|
if (isset($value[0]) && $name{0} != $this->vPrefix && $name != '__args') {
|
|
echo $this->compileProperty($name, $value, 1)."\n";
|
|
$props++;
|
|
}
|
|
}
|
|
$list = ob_get_clean();
|
|
|
|
if ($props == 0) return '';
|
|
|
|
// do some formatting
|
|
if ($props == 1) $list = ' '.trim($list).' ';
|
|
return implode(", ", $rtags).' {'.($props > 1 ? "\n" : '').
|
|
$list."}\n";
|
|
|
|
}
|
|
|
|
function compileProperty($name, $value, $level = 0) {
|
|
// output all repeated properties
|
|
foreach ($value as $v)
|
|
$props[] = str_repeat(' ', $level).
|
|
$name.':'.$this->compileValue($v).';';
|
|
|
|
return implode("\n", $props);
|
|
}
|
|
|
|
function compileValue($value) {
|
|
switch($value[0]) {
|
|
case 'list':
|
|
// [1] - delimiter
|
|
// [2] - array of values
|
|
return implode($value[1], array_map(array($this, 'compileValue'), $value[2]));
|
|
case 'keyword':
|
|
// [1] - the keyword
|
|
case 'number':
|
|
// [1] - the number
|
|
return $value[1];
|
|
case 'expression':
|
|
// [1] - operator
|
|
// [2] - value of left hand side
|
|
// [3] - value of right
|
|
return $this->compileValue($this->evaluate($value[1], $value[2], $value[3]));
|
|
case 'string':
|
|
// [1] - contents of string (includes quotes)
|
|
|
|
// search for inline variables to replace
|
|
$replace = array();
|
|
if (preg_match_all('/{(@[\w-_][0-9\w-_]*)}/', $value[1], $m)) {
|
|
foreach($m[1] as $name) {
|
|
if (!isset($replace[$name]))
|
|
$replace[$name] = $this->compileValue(array('variable', $name));
|
|
}
|
|
}
|
|
foreach ($replace as $var=>$val) {
|
|
// strip quotes
|
|
if (preg_match('/^(["\']).*?(\1)$/', $val)) {
|
|
$val = substr($val, 1, -1);
|
|
}
|
|
$value[1] = str_replace('{'.$var.'}', $val, $value[1]);
|
|
}
|
|
|
|
return $value[1];
|
|
case 'color':
|
|
// [1] - red component (either number for a %)
|
|
// [2] - green component
|
|
// [3] - blue component
|
|
// [4] - optional alpha component
|
|
if (count($value) == 5) { // rgba
|
|
return 'rgba('.$value[1].','.$value[2].','.$value[3].','.$value[4].')';
|
|
}
|
|
|
|
$out = '#';
|
|
foreach (range(1,3) as $i)
|
|
$out .= ($value[$i] < 16 ? '0' : '').dechex($value[$i]);
|
|
return $out;
|
|
case 'variable':
|
|
// [1] - the name of the variable including @
|
|
$tmp = $this->compileValue(
|
|
$this->getVal($value[1], $this->pushName($value[1]))
|
|
);
|
|
$this->popName();
|
|
|
|
return $tmp;
|
|
case 'negative':
|
|
// [1] - some value that needs to become negative
|
|
return $this->compileValue($this->reduce($value));
|
|
case 'function':
|
|
// [1] - function name
|
|
// [2] - some value representing arguments
|
|
|
|
// see if there is a library function for this func
|
|
$f = array($this, 'lib_'.$value[1]);
|
|
if (is_callable($f)) {
|
|
return call_user_func($f, $value[2]);
|
|
}
|
|
|
|
return $value[1].'('.$this->compileValue($value[2]).')';
|
|
|
|
default: // assumed to be unit
|
|
return $value[1].$value[0];
|
|
}
|
|
}
|
|
|
|
function lib_quote($arg) {
|
|
return '"'.$this->compileValue($arg).'"';
|
|
}
|
|
|
|
function lib_unquote($arg) {
|
|
$out = $this->compileValue($arg);
|
|
if ($this->quoted($out)) $out = substr($out, 1, -1);
|
|
return $out;
|
|
}
|
|
|
|
// is a string surrounded in quotes? returns the quoting char if true
|
|
function quoted($s) {
|
|
if (preg_match('/^("|\').*?\1$/', $s, $m))
|
|
return $m[1];
|
|
else return false;
|
|
}
|
|
|
|
// convert rgb, rgba into color type suitable for math
|
|
// todo: add hsl
|
|
function funcToColor($func) {
|
|
$fname = $func[1];
|
|
if (!preg_match('/^(rgb|rgba)$/', $fname)) return false;
|
|
if ($func[2][0] != 'list') return false; // need a list of arguments
|
|
|
|
$components = array();
|
|
$i = 1;
|
|
foreach ($func[2][2] as $c) {
|
|
$c = $this->reduce($c);
|
|
if ($i < 4) {
|
|
if ($c[0] == '%') $components[] = 255 * ($c[1] / 100);
|
|
else $components[] = floatval($c[1]);
|
|
} elseif ($i == 4) {
|
|
if ($c[0] == '%') $components[] = 1.0 * ($c[1] / 100);
|
|
else $components[] = floatval($c[1]);
|
|
} else break;
|
|
|
|
$i++;
|
|
}
|
|
while (count($components) < 3) $components[] = 0;
|
|
|
|
array_unshift($components, 'color');
|
|
return $this->fixColor($components);
|
|
}
|
|
|
|
// reduce a delayed type to its final value
|
|
// dereference variables and solve equations
|
|
function reduce($var, $defaultValue = array('number', 0)) {
|
|
$pushed = 0; // number of variable names pushed
|
|
|
|
while (in_array($var[0], self::$dtypes)) {
|
|
if ($var[0] == 'expression') {
|
|
$var = $this->evaluate($var[1], $var[2], $var[3]);
|
|
} else if ($var[0] == 'variable') {
|
|
$var = $this->getVal($var[1], $this->pushName($var[1]), $defaultValue);
|
|
$pushed++;
|
|
} else if ($var[0] == 'function') {
|
|
$color = $this->funcToColor($var);
|
|
if ($color) $var = $color;
|
|
break; // no where to go after a function
|
|
} else if ($var[0] == 'negative') {
|
|
$value = $this->reduce($var[1]);
|
|
if (is_numeric($value[1])) {
|
|
$value[1] = -1*$value[1];
|
|
}
|
|
$var = $value;
|
|
}
|
|
}
|
|
|
|
while ($pushed != 0) { $this->popName(); $pushed--; }
|
|
return $var;
|
|
}
|
|
|
|
// evaluate an expression
|
|
function evaluate($op, $left, $right) {
|
|
$left = $this->reduce($left);
|
|
$right = $this->reduce($right);
|
|
|
|
if ($left[0] == 'color' && $right[0] == 'color') {
|
|
$out = $this->op_color_color($op, $left, $right);
|
|
return $out;
|
|
}
|
|
|
|
if ($left[0] == 'color') {
|
|
return $this->op_color_number($op, $left, $right);
|
|
}
|
|
|
|
if ($right[0] == 'color') {
|
|
return $this->op_number_color($op, $left, $right);
|
|
}
|
|
|
|
// concatenate strings
|
|
if ($op == '+' && $left[0] == 'string') {
|
|
$append = $this->compileValue($right);
|
|
if ($this->quoted($append)) $append = substr($append, 1, -1);
|
|
|
|
$lhs = $this->compileValue($left);
|
|
if ($q = $this->quoted($lhs)) $lhs = substr($lhs, 1, -1);
|
|
if (!$q) $q = '';
|
|
|
|
return array('string', $q.$lhs.$append.$q);
|
|
}
|
|
|
|
if ($left[0] == 'keyword' || $right[0] == 'keyword' ||
|
|
$left[0] == 'string' || $right[0] == 'string')
|
|
{
|
|
// look for negative op
|
|
if ($op == '-') $right[1] = '-'.$right[1];
|
|
return array('keyword', $this->compileValue($left) .' '. $this->compileValue($right));
|
|
}
|
|
|
|
// default to number operation
|
|
return $this->op_number_number($op, $left, $right);
|
|
}
|
|
|
|
// make sure a color's components don't go out of bounds
|
|
function fixColor($c) {
|
|
foreach (range(1, 3) as $i) {
|
|
if ($c[$i] < 0) $c[$i] = 0;
|
|
if ($c[$i] > 255) $c[$i] = 255;
|
|
$c[$i] = floor($c[$i]);
|
|
}
|
|
|
|
return $c;
|
|
}
|
|
|
|
function op_number_color($op, $lft, $rgt) {
|
|
if ($op == '+' || $op = '*') {
|
|
return $this->op_color_number($op, $rgt, $lft);
|
|
}
|
|
}
|
|
|
|
function op_color_number($op, $lft, $rgt) {
|
|
if ($rgt[0] == '%') $rgt[1] /= 100;
|
|
|
|
return $this->op_color_color($op, $lft,
|
|
array_fill(1, count($lft) - 1, $rgt[1]));
|
|
}
|
|
|
|
function op_color_color($op, $left, $right) {
|
|
$out = array('color');
|
|
$max = count($left) > count($right) ? count($left) : count($right);
|
|
foreach (range(1, $max - 1) as $i) {
|
|
$lval = isset($left[$i]) ? $left[$i] : 0;
|
|
$rval = isset($right[$i]) ? $right[$i] : 0;
|
|
switch ($op) {
|
|
case '+':
|
|
$out[] = $lval + $rval;
|
|
break;
|
|
case '-':
|
|
$out[] = $lval - $rval;
|
|
break;
|
|
case '*':
|
|
$out[] = $lval * $rval;
|
|
break;
|
|
case '%':
|
|
$out[] = $lval % $rval;
|
|
break;
|
|
case '/':
|
|
if ($rval == 0) throw new exception("evaluate error: can't divide by zero");
|
|
$out[] = $lval / $rval;
|
|
break;
|
|
default:
|
|
throw new exception('evaluate error: color op number failed on op '.$op);
|
|
}
|
|
}
|
|
return $this->fixColor($out);
|
|
}
|
|
|
|
// operator on two numbers
|
|
function op_number_number($op, $left, $right) {
|
|
if ($right[0] == '%') $right[1] /= 100;
|
|
|
|
// figure out type
|
|
if ($right[0] == 'number' || $right[0] == '%') $type = $left[0];
|
|
else $type = $right[0];
|
|
|
|
$value = 0;
|
|
switch($op) {
|
|
case '+':
|
|
$value = $left[1] + $right[1];
|
|
break;
|
|
case '*':
|
|
$value = $left[1] * $right[1];
|
|
break;
|
|
case '-':
|
|
$value = $left[1] - $right[1];
|
|
break;
|
|
case '%':
|
|
$value = $left[1] % $right[1];
|
|
break;
|
|
case '/':
|
|
if ($right[1] == 0) throw new exception('parse error: divide by zero');
|
|
$value = $left[1] / $right[1];
|
|
break;
|
|
default:
|
|
throw new exception('parse error: unknown number operator: '.$op);
|
|
}
|
|
|
|
return array($type, $value);
|
|
}
|
|
|
|
|
|
/* environment functions */
|
|
|
|
// push name on expand stack, and return its
|
|
// count before being pushed
|
|
function pushName($name) {
|
|
$count = array_count_values($this->expandStack);
|
|
$count = isset($count[$name]) ? $count[$name] : 0;
|
|
|
|
$this->expandStack[] = $name;
|
|
|
|
return $count;
|
|
}
|
|
|
|
// pop name off expand stack and return it
|
|
function popName() {
|
|
return array_pop($this->expandStack);
|
|
}
|
|
|
|
// push a new environment
|
|
function push() {
|
|
$this->level++;
|
|
$this->env[] = array();
|
|
}
|
|
|
|
// pop environment off the stack
|
|
function pop() {
|
|
if ($this->level == 1)
|
|
throw new exception('parse error: unexpected end of block');
|
|
|
|
$this->level--;
|
|
return array_pop($this->env);
|
|
}
|
|
|
|
// set something in the current env
|
|
function set($name, $value) {
|
|
$this->env[count($this->env) - 1][$name] = $value;
|
|
}
|
|
|
|
// append to array in the current env
|
|
function append($name, $value) {
|
|
$this->env[count($this->env) - 1][$name][] = $value;
|
|
}
|
|
|
|
// put on the front of the value
|
|
function prepend($name, $value) {
|
|
if (isset($this->env[count($this->env) - 1][$name]))
|
|
array_unshift($this->env[count($this->env) - 1][$name], $value);
|
|
else $this->append($name, $value);
|
|
}
|
|
|
|
// get the highest occurrence of value
|
|
function get($name, $env = null) {
|
|
if (empty($env)) $env = $this->env;
|
|
|
|
for ($i = count($env) - 1; $i >= 0; $i--)
|
|
if (isset($env[$i][$name])) return $env[$i][$name];
|
|
|
|
return null;
|
|
}
|
|
|
|
// get the most recent value of a variable
|
|
// return default if it isn't found
|
|
// $skip is number of vars to skip
|
|
function getVal($name, $skip = 0, $default = array('keyword', '')) {
|
|
$val = $this->get($name);
|
|
if ($val == null) return $default;
|
|
|
|
$tmp = $this->env;
|
|
while (!isset($tmp[count($tmp) - 1][$name])) array_pop($tmp);
|
|
while ($skip > 0) {
|
|
$skip--;
|
|
|
|
if (!empty($val)) {
|
|
array_pop($val);
|
|
}
|
|
|
|
if (empty($val)) {
|
|
array_pop($tmp);
|
|
$val = $this->get($name, $tmp);
|
|
}
|
|
|
|
if (empty($val)) return $default;
|
|
}
|
|
|
|
return end($val);
|
|
}
|
|
|
|
// get the environment described by path, an array of env names
|
|
function getEnv($path) {
|
|
if (!is_array($path)) $path = array($path);
|
|
|
|
// move @ tags out of variable namespace
|
|
foreach($path as &$tag)
|
|
if ($tag{0} == $this->vPrefix) $tag[0] = $this->mPrefix;
|
|
|
|
$env = $this->get(array_shift($path));
|
|
while ($sub = array_shift($path)) {
|
|
if (isset($env[$sub])) // todo add a type check for environment
|
|
$env = $env[$sub];
|
|
else {
|
|
$env = null;
|
|
break;
|
|
}
|
|
}
|
|
return $env;
|
|
}
|
|
|
|
// merge a block into the current env
|
|
function merge($name, $value) {
|
|
// if the current block isn't there then just set
|
|
$top =& $this->env[count($this->env) - 1];
|
|
if (!isset($top[$name])) return $this->set($name, $value);
|
|
|
|
// copy the block into the old one, including meta data
|
|
foreach ($value as $k=>$v) {
|
|
// todo: merge property values instead of replacing
|
|
// have to check type for this
|
|
$top[$name][$k] = $v;
|
|
}
|
|
}
|
|
|
|
function literal($what, $eatWhitespace = true) {
|
|
// this is here mainly prevent notice from { } string accessor
|
|
if ($this->count >= strlen($this->buffer)) return false;
|
|
|
|
// shortcut on single letter
|
|
if (!$eatWhitespace and strlen($what) == 1) {
|
|
if ($this->buffer{$this->count} == $what) {
|
|
$this->count++;
|
|
return true;
|
|
}
|
|
else return false;
|
|
}
|
|
|
|
return $this->match($this->preg_quote($what), $m, $eatWhitespace);
|
|
}
|
|
|
|
function preg_quote($what) {
|
|
return preg_quote($what, '/');
|
|
}
|
|
|
|
// advance counter to next occurrence of $what
|
|
// $until - don't include $what in advance
|
|
function to($what, &$out, $until = false, $allowNewline = false) {
|
|
$validChars = $allowNewline ? "[^\n]" : '.';
|
|
if (!$this->match('('.$validChars.'*?)'.$this->preg_quote($what), $m, !$until)) return false;
|
|
if ($until) $this->count -= strlen($what); // give back $what
|
|
$out = $m[1];
|
|
return true;
|
|
}
|
|
|
|
// try to match something on head of buffer
|
|
function match($regex, &$out, $eatWhitespace = true) {
|
|
$r = '/'.$regex.($eatWhitespace ? '\s*' : '').'/Ais';
|
|
if (preg_match($r, $this->buffer, $out, null, $this->count)) {
|
|
$this->count += strlen($out[0]);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
|
|
// match something without consuming it
|
|
function peek($regex, &$out = null) {
|
|
$r = '/'.$regex.'/Ais';
|
|
$result = preg_match($r, $this->buffer, $out, null, $this->count);
|
|
|
|
return $result;
|
|
}
|
|
|
|
// seek to a spot in the buffer or return where we are on no argument
|
|
function seek($where = null) {
|
|
if ($where === null) return $this->count;
|
|
else $this->count = $where;
|
|
return true;
|
|
}
|
|
|
|
// parse and compile buffer
|
|
function parse($str = null) {
|
|
if ($str) $this->buffer = $str;
|
|
|
|
$this->env = array();
|
|
$this->expandStack = array();
|
|
$this->count = 0;
|
|
$this->line = 1;
|
|
|
|
$this->buffer = $this->removeComments($this->buffer);
|
|
$this->push(); // set up global scope
|
|
$this->set('__tags', array('')); // equivalent to 1 in tag multiplication
|
|
|
|
// trim whitespace on head
|
|
if (preg_match('/^\s+/', $this->buffer, $m)) {
|
|
$this->line += substr_count($m[0], "\n");
|
|
$this->buffer = ltrim($this->buffer);
|
|
}
|
|
|
|
$out = '';
|
|
while (false !== ($compiled = $this->chunk())) {
|
|
if (is_string($compiled)) $out .= $compiled;
|
|
}
|
|
|
|
if ($this->count != strlen($this->buffer)) $this->throwParseError();
|
|
|
|
if (count($this->env) > 1)
|
|
throw new exception('parse error: unclosed block');
|
|
|
|
// print_r($this->env);
|
|
return $out;
|
|
}
|
|
|
|
function throwParseError($msg = 'parse error') {
|
|
$line = $this->line + substr_count(substr($this->buffer, 0, $this->count), "\n");
|
|
if ($this->peek("(.*?)\n", $m))
|
|
throw new exception($msg.': failed at `'.$m[1].'` line: '.$line);
|
|
}
|
|
|
|
function __construct($fname = null) {
|
|
if (!self::$operatorString) {
|
|
self::$operatorString =
|
|
'('.implode('|', array_map(array($this, 'preg_quote'), array_keys(self::$precedence))).')';
|
|
}
|
|
|
|
if ($fname) {
|
|
if (!is_file($fname)) {
|
|
throw new Exception('load error: failed to find '.$fname);
|
|
}
|
|
$pi = pathinfo($fname);
|
|
|
|
$this->fileName = $fname;
|
|
$this->importDir = $pi['dirname'].'/';
|
|
$this->buffer = file_get_contents($fname);
|
|
}
|
|
}
|
|
|
|
// remove comments from $text
|
|
// todo: make it work for all functions, not just url
|
|
// todo: make it not mess up line counter with block comments
|
|
function removeComments($text) {
|
|
$out = '';
|
|
|
|
while (!empty($text) &&
|
|
preg_match('/^(.*?)("|\'|\/\/|\/\*|url\(|$)/is', $text, $m))
|
|
{
|
|
if (!trim($text)) break;
|
|
|
|
$out .= $m[1];
|
|
$text = substr($text, strlen($m[0]));
|
|
|
|
switch ($m[2]) {
|
|
case 'url(':
|
|
preg_match('/^(.*?)(\)|$)/is', $text, $inner);
|
|
$text = substr($text, strlen($inner[0]));
|
|
$out .= $m[2].$inner[1].$inner[2];
|
|
break;
|
|
case '//':
|
|
preg_match("/^(.*?)(\n|$)/is", $text, $inner);
|
|
// give back the newline
|
|
$text = substr($text, strlen($inner[0]) - 1);
|
|
break;
|
|
case '/*';
|
|
preg_match("/^(.*?)(\*\/|$)/is", $text, $inner);
|
|
$text = substr($text, strlen($inner[0]));
|
|
break;
|
|
case '"':
|
|
case "'":
|
|
preg_match("/^(.*?)(".$m[2]."|$)/is", $text, $inner);
|
|
$text = substr($text, strlen($inner[0]));
|
|
$out .= $m[2].$inner[1].$inner[2];
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
|
|
// compile to $in to $out if $in is newer than $out
|
|
// returns true when it compiles, false otherwise
|
|
public static function ccompile($in, $out) {
|
|
if (!is_file($out) || filemtime($in) > filemtime($out)) {
|
|
$less = new lessc($in);
|
|
file_put_contents($out, $less->parse());
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
?>
|