diff --git a/src/numeric-values.js b/src/numeric-values.js index aaf251c..9da3e12 100644 --- a/src/numeric-values.js +++ b/src/numeric-values.js @@ -1,9 +1,27 @@ +import { + CommaToken, + DelimToken, + DimensionToken, + FunctionToken, IdentToken, + LeftCurlyBracketToken, + LeftParenthesisToken, + LeftSquareBracketToken, + NumberToken, + PercentageToken, RightCurlyBracketToken, + RightParenthesisToken, RightSquareBracketToken, + Token, + tokenizeString, + WhitespaceToken +} from './tokenizer'; +import {simplifyCalculation} from './simplify-calculation'; + /** * @typedef {{[string]: integer}} UnitMap * @typedef {[number, UnitMap]} SumValueItem * @typedef {SumValueItem[]} SumValue * @typedef {null} Failure * @typedef {{[string]: integer} & {percentHint: string | undefined}} Type + * @typedef {{type: 'ADDITION'}|{type: 'MULTIPLICATION'}|{type: 'NEGATE'}|{type: 'INVERT'}} ASTNode */ const failure = null; @@ -405,4 +423,422 @@ export function multiplyTypes(type1, type2) { finalType[baseType] += type2[baseType]; } return finalType; +} + +class CSSFunction { + name; + values; + constructor(name, values) { + this.name = name; + this.values = values; + } +} + +class CSSSimpleBlock { + value; + associatedToken; + constructor(value, associatedToken) { + this.value = value; + this.associatedToken = associatedToken; + } +} + +/** + * Normalize into a token stream + * https://www.w3.org/TR/css-syntax-3/#normalize-into-a-token-stream + */ +function normalizeIntoTokenStream(input) { + // If input is a list of CSS tokens, return input. + // If input is a list of CSS component values, return input. + if (Array.isArray(input)) { + return input; + } + // If input is a string, then filter code points from input, tokenize the result, and return the final result. + if (typeof input === 'string') { + return tokenizeString(input); + } + // Assert: Only the preceding types should be passed as input. + throw new TypeError(`Invalid input type git${typeof input}`) +} + +/** + * Consume a function + * https://www.w3.org/TR/css-syntax-3/#consume-a-function + * @param {FunctionToken} token + * @param {Token[]} tokens + */ +function consumeFunction(token, tokens) { + // Create a function with its name equal to the value of the current input token and with its value initially set to an empty list. + const func = new CSSFunction(token.value, []); + + // Repeatedly consume the next input token and process it as follows: + while(true) { + const nextToken = tokens.shift(); + if (nextToken instanceof RightParenthesisToken) { + // <)-token> + // Return the function. + return func; + } else if (typeof nextToken === 'undefined') { + // + // This is a parse error. Return the function. + return func; + } else { + // anything else + // Reconsume the current input token. Consume a component value and append the returned value to the function’s value. + tokens.unshift(nextToken); + func.values.push(consumeComponentValue(tokens)); + } + } +} + +/** + * Consume a simple block + * https://www.w3.org/TR/css-syntax-3/#consume-simple-block + * @param {Token[]} tokens + * @param {LeftCurlyBracketToken | LeftParenthesisToken | LeftSquareBracketToken} currentInputToken + */ +function consumeSimpleBlock(tokens, currentInputToken) { + // The ending token is the mirror variant of the current input token. (E.g. if it was called with <[-token>, the ending token is <]-token>.) + let endingTokenConstructor ; + if (currentInputToken instanceof LeftCurlyBracketToken) { + endingTokenConstructor = RightCurlyBracketToken; + } else if (currentInputToken instanceof LeftParenthesisToken) { + endingTokenConstructor = RightParenthesisToken; + } else if (currentInputToken instanceof LeftSquareBracketToken) { + endingTokenConstructor = RightSquareBracketToken; + } else { + return undefined; + } + + + // Create a simple block with its associated token set to the current input token and with its value initially set to an empty list. + const simpleBlock = new CSSSimpleBlock([], currentInputToken); + + // Repeatedly consume the next input token and process it as follows: + while (true) { + const token = tokens.shift(); + if (token instanceof endingTokenConstructor) { + // ending token + // Return the block. + return simpleBlock; + } else if (typeof token === 'undefined') { + // + // This is a parse error. Return the block. + return simpleBlock; + } else { + // anything else + // Reconsume the current input token. Consume a component value and append it to the value of the block. + tokens.unshift(token); + simpleBlock.value.push(consumeComponentValue(tokens)); + } + } +} + +/** + * Consume a component value + * https://www.w3.org/TR/css-syntax-3/#consume-a-component-value + * @param {Token[]} tokens + */ +function consumeComponentValue(tokens) { + const syntaxError = null; + // Consume the next input token. + const token = tokens.shift(); + + if (token instanceof LeftCurlyBracketToken || token instanceof LeftSquareBracketToken || token instanceof LeftParenthesisToken) { + // If the current input token is a <{-token>, <[-token>, or <(-token>, consume a simple block and return it. + return consumeSimpleBlock(tokens, token); + } else if (token instanceof FunctionToken) { + // Otherwise, if the current input token is a , consume a function and return it. + return consumeFunction(token, tokens); + } else { + // Otherwise, return the current input token. + return token; + } +} + +/** + * Parse a component value + * https://www.w3.org/TR/css-syntax-3/#parse-component-value + * @param {string} input + */ +function parseComponentValue(input) { + const syntaxError = null; + // To parse a component value from input: + // 1. Normalize input, and set input to the result. + const tokens = normalizeIntoTokenStream(input); + + // 2. While the next input token from input is a , consume the next input token from input. + while (tokens[0] instanceof WhitespaceToken) { + tokens.shift(); + } + // 3. If the next input token from input is an , return a syntax error. + if (typeof tokens[0] === 'undefined') { + return syntaxError; + } + // 4. Consume a component value from input and let value be the return value. + const returnValue = consumeComponentValue(tokens); + // 5. While the next input token from input is a , consume the next input token. + while (tokens[0] instanceof WhitespaceToken) { + tokens.shift(); + } + // 6. If the next input token from input is an , return value. Otherwise, return a syntax error. + if (typeof tokens[0] === 'undefined') { + return returnValue; + } else { + return syntaxError; + } +} + +function precedence(token) { + if (token instanceof LeftParenthesisToken || token instanceof RightParenthesisToken) { + return 6; + } else if (token instanceof DelimToken) { + const value = token.value; + switch (value) { + case '*': + return 4; + case '/': + return 4; + case '+': + return 2; + case '-': + return 2; + } + } +} + + +function last(items) { + return items[items.length - 1]; +} + +function toNAryAstNode(operatorToken, first, second) { + // Treat subtraction as instead being addition, with the RHS argument instead wrapped in a special "negate" node. + // Treat division as instead being multiplication, with the RHS argument instead wrapped in a special "invert" node. + + const type = ['+','-'].includes(operatorToken.value) ? 'ADDITION' : 'MULTIPLICATION'; + const firstValues = first.type === type ? first.values : [first]; + const secondValues = second.type === type ? second.values : [second]; + + if (operatorToken.value === '-') { + secondValues[0] = {type: 'NEGATE', value: secondValues[0]}; + } else if (operatorToken.value === '/') { + secondValues[0] = {type: 'INVERT', value: secondValues[0]}; + } + return {type, values: [...firstValues, ...secondValues]}; +} + +/** + * Convert expression to AST using the Shunting Yard Algorithm + * https://en.wikipedia.org/wiki/Shunting_yard_algorithm + * @param {(Token | CSSFunction)[]} tokens + * @return {null} + */ +function convertTokensToAST(tokens) { + const operatorStack = []; + const tree = []; + while (tokens.length) { + const token = tokens.shift(); + if (token instanceof NumberToken || token instanceof DimensionToken || token instanceof PercentageToken || + token instanceof CSSFunction || token instanceof CSSSimpleBlock || token instanceof IdentToken) { + tree.push(token); + } else if (token instanceof DelimToken && ['*', '/', '+', '-'].includes(token.value)) { + while (operatorStack.length && + !(last(operatorStack) instanceof LeftParenthesisToken) && + precedence(last(operatorStack)) > precedence(token)) { + const o2 = operatorStack.pop(); + const second = tree.pop(); + const first = tree.pop(); + tree.push(toNAryAstNode(o2, first, second)); + } + operatorStack.push(token); + } else if (token instanceof LeftParenthesisToken) { + operatorStack.push(token); + } else if (token instanceof RightParenthesisToken) { + if (!operatorStack.length) { + return null; + } + while (!(last(operatorStack) instanceof LeftParenthesisToken) ) { + const o2 = operatorStack.pop(); + const second = tree.pop(); + const first = tree.pop(); + tree.push(toNAryAstNode(o2, first, second)); + } + if (!(last(operatorStack) instanceof LeftParenthesisToken)) { + return null; + } + operatorStack.pop(); + } else if (token instanceof WhitespaceToken) { + // Consume token + } else { + return null; + } + } + while(operatorStack.length) { + if (last(operatorStack) instanceof LeftParenthesisToken) { + return null; + } + const o2 = operatorStack.pop() + const second = tree.pop(); + const first = tree.pop(); + tree.push(toNAryAstNode(o2, first, second)); + } + return tree[0]; +} + +/** + * Step 4 of `reify a math expression` + * https://drafts.css-houdini.org/css-typed-om/#reify-a-math-expression + * + * 4. Recursively transform the expression tree into objects, as follows: + * + * @param {ASTNode} node + * @return {CSSMathNegate|CSSMathProduct|CSSMathMin|CSSMathMax|CSSMathSum|CSSNumericValue|CSSUnitValue|CSSMathInvert} + */ +function transformToCSSNumericValue(node) { + if (node.type === 'ADDITION') { + // addition node + // becomes a new CSSMathSum object, with its values internal slot set to its list of arguments + return new CSSMathSum(...node.values.map(value => transformToCSSNumericValue(value))); + } else if (node.type === 'MULTIPLICATION') { + // multiplication node + // becomes a new CSSMathProduct object, with its values internal slot set to its list of arguments + return new CSSMathProduct(...node.values.map(value => transformToCSSNumericValue(value))); + } else if (node.type === 'NEGATE') { + // negate node + // becomes a new CSSMathNegate object, with its value internal slot set to its argument + return new CSSMathNegate(transformToCSSNumericValue(node.value)); + } else if (node.type === 'INVERT') { + // invert node + // becomes a new CSSMathInvert object, with its value internal slot set to its argument + return new CSSMathInvert(transformToCSSNumericValue(node.value)); + } else { + // leaf node + // reified as appropriate + if (node instanceof CSSSimpleBlock) { + return reifyMathExpression(new CSSFunction('calc', node.value)); + } else if (node instanceof IdentToken) { + if (node.value === 'e') { + return new CSSUnitValue(Math.E, 'number'); + } else if (node.value === 'pi') { + return new CSSUnitValue(Math.PI, 'number'); + } else { + throw new SyntaxError('Invalid math expression') + } + } else { + return reifyNumericValue(node); + } + } +} + +/** + * Reify a math expression + * https://drafts.css-houdini.org/css-typed-om/#reify-a-math-expression + * @param {CSSFunction} num + */ +function reifyMathExpression(num) { + // TODO: handle `clamp()` and possibly other math functions + // 1. If num is a min() or max() expression: + if (num.name === 'min' || num.name === 'max') + { + // Let values be the result of reifying the arguments to the expression, treating each argument as if it were the contents of a calc() expression. + const values = num.values + .filter(value => !(value instanceof WhitespaceToken || value instanceof CommaToken)) + // TODO: Update when we have clarification on where simplify a calculation should be run: + // https://github.com/w3c/csswg-drafts/issues/9870 + .map(value => simplifyCalculation(reifyMathExpression(new CSSFunction('calc', value)))); + // Return a new CSSMathMin or CSSMathMax object, respectively, with its values internal slot set to values. + return num.name === 'min' ? new CSSMathMin(...values) : new CSSMathMax(...values); + } + + // 2. Assert: Otherwise, num is a calc(). + if (num.name !== 'calc') { + return null; + } + + // 3. Turn num’s argument into an expression tree using standard PEMDAS precedence rules, with the following exceptions/clarification: + // + // Treat subtraction as instead being addition, with the RHS argument instead wrapped in a special "negate" node. + // Treat division as instead being multiplication, with the RHS argument instead wrapped in a special "invert" node. + // Addition and multiplication are N-ary; each node can have any number of arguments. + // If an expression has only a single value in it, and no operation, treat it as an addition node with the single argument. + const root = convertTokensToAST([...num.values]); + + // 4. Recursively transform the expression tree into objects + const numericValue = transformToCSSNumericValue(root); + let simplifiedValue; + try { + // TODO: Update when we have clarification on where simplify a calculation should be run: + // https://github.com/w3c/csswg-drafts/issues/9870 + simplifiedValue = simplifyCalculation(numericValue); + } catch (e) { + // Use insertRule to trigger native SyntaxError on TypeError + (new CSSStyleSheet()).insertRule('error', 0); + } + if (simplifiedValue instanceof CSSUnitValue) { + return new CSSMathSum(simplifiedValue); + } else { + return simplifiedValue; + } +} + +/** + * Reify a numeric value + * https://drafts.css-houdini.org/css-typed-om/#reify-a-numeric-value + * @param num + */ +function reifyNumericValue(num) { + // If an internal representation contains a var() reference, then it is reified by reifying a list of component values, + // regardless of what property it is for. + // TODO: handle `var()` function + + // If num is a math function, reify a math expression from num and return the result. + if (num instanceof CSSFunction && ['calc', 'min', 'max', 'clamp'].includes(num.name)) { + return reifyMathExpression(num); + } + // If num is the unitless value 0 and num is a , + // return a new CSSUnitValue with its value internal slot set to 0, and its unit internal slot set to "px". + if (num instanceof NumberToken && num.value === 0 && !num.unit) { + return new CSSUnitValue(0, 'px'); + } + // Return a new CSSUnitValue with its value internal slot set to the numeric value of num, and its unit internal slot + // set to "number" if num is a , "percent" if num is a , and num’s unit if num is a . + if (num instanceof NumberToken) { + return new CSSUnitValue(num.value, 'number'); + } else if (num instanceof PercentageToken) { + return new CSSUnitValue(num.value, 'percent'); + } else if (num instanceof DimensionToken) { + return new CSSUnitValue(num.value, num.unit); + } +} + +/** + * Implementation of the parse(cssText) method. + * https://drafts.css-houdini.org/css-typed-om-1/#dom-cssnumericvalue-parse + * @param {string} cssText + * @return {CSSMathMin|CSSMathMax|CSSMathSum|CSSMathProduct|CSSMathNegate|CSSMathInvert|CSSUnitValue} + */ +export function parseCSSNumericValue(cssText) { + // Parse a component value from cssText and let result be the result. + // If result is a syntax error, throw a SyntaxError and abort this algorithm. + const result = parseComponentValue(cssText); + if (result === null) { + // Use insertRule to trigger native SyntaxError + (new CSSStyleSheet()).insertRule('error', 0); + } + // If result is not a , , , or a math function, throw a SyntaxError and abort this algorithm. + if (!(result instanceof NumberToken || result instanceof PercentageToken || result instanceof DimensionToken || result instanceof CSSFunction)) { + // Use insertRule to trigger native SyntaxError + (new CSSStyleSheet()).insertRule('error', 0); + } + // If result is a and creating a type from result’s unit returns failure, throw a SyntaxError and abort this algorithm. + if (result instanceof DimensionToken) { + const type = createAType(result.unit); + if (type === null) { + // Use insertRule to trigger native SyntaxError + (new CSSStyleSheet()).insertRule('error', 0); + } + } + // Reify a numeric value result, and return the result. + return reifyNumericValue(result); } \ No newline at end of file diff --git a/src/proxy-cssom.js b/src/proxy-cssom.js index 04c39d4..ffcf5db 100644 --- a/src/proxy-cssom.js +++ b/src/proxy-cssom.js @@ -11,8 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { createAType, invertType, multiplyTypes, to, toSum } from "./numeric-values"; -import { simplifyCalculation } from "./simplify-calculation"; +import {createAType, invertType, multiplyTypes, parseCSSNumericValue, to, toSum} from './numeric-values'; +import {simplifyCalculation} from './simplify-calculation'; +import './tokenizer' export function installCSSOM() { // Object for storing details associated with an object which are to be kept @@ -69,124 +70,10 @@ export function installCSSOM() { } } - /** - * Parse a CSSUnitValue from the passed string - * @param {string} str - * @return {CSSUnitValue} - */ - function parseCSSUnitValue(str) { - const UNIT_VALUE_REGEXP = /^(-?\d*[.]?\d+)(r?em|r?ex|r?cap|r?ch|r?ic|r?lh|[sld]?v(w|h|i|b|min|max)|cm|mm|Q|in|pt|pc|px|%)?$/; - const match = str.match(UNIT_VALUE_REGEXP); - if (match) { - let [_, v, unit] = match; - if (typeof unit === 'undefined') { - unit = 'number'; - } else if (unit === '%') { - unit = 'percent'; - } - return new CSSUnitValue(parseFloat(v), unit); - } else { - throw new SyntaxError(`Unsupported syntax ${str}`); - } - } - - /** - * Parse the string as a CSSMathProduct - * @param {string} str - * @return {CSSMathProduct} - */ - function parseCSSMultiplication(str) { - let values = []; - const tokens = str.split(/(? simplifyCalculation(value, info)); } @@ -84,7 +84,15 @@ export function simplifyCalculation(root, info) { if (root instanceof CSSUnitValue && root.unit === 'em' && info.fontSize) { root = new CSSUnitValue(root.value * info.fontSize.value, info.fontSize.unit); } - // 3. If root is a , return its numeric value. + // 3. If root is a that can be resolved, return what it resolves to, simplified. + if (root instanceof CSSKeywordValue) { + //https://www.w3.org/TR/css-values-4/#calc-constants + if (root.value === 'e') { + return new CSSUnitValue(Math.E, 'number'); + } else if (root.value === 'pi') { + return new CSSUnitValue(Math.PI, 'number'); + } + } // 4. Otherwise, return root. return root; } @@ -169,8 +177,14 @@ export function simplifyCalculation(root, info) { } } - // 2. Return root. - return root; + // 2. If root has only one child, return the child. + // + // Otherwise, return root. + if (children.length === 1) { + return children[0]; + } else { + return root; + } } // If root is a Negate node: diff --git a/src/tokenizer.js b/src/tokenizer.js new file mode 100644 index 0000000..eecb16d --- /dev/null +++ b/src/tokenizer.js @@ -0,0 +1,801 @@ + +export class Token {} + +// The output of tokenization step is a stream of zero or more of the following tokens: , , +// , , , , , , , +// , , , , , , , +// , , <[-token>, <]-token>, <(-token>, <)-token>, <{-token>, and <}-token>. +export class IdentToken extends Token { + value; + constructor(value) { + super(); + this.value = value; + } +} + +export class FunctionToken extends Token { + value; + constructor(value) { + super(); + this.value = value; + } +} + +export class AtKeywordToken extends Token { + value; + constructor(value) { + super(); + this.value = value; + } +} + +export class HashToken extends Token { + type; + value; + constructor(value, type = 'unrestricted') { + super(); + this.value = value; + this.type = type; + } +} + +export class StringToken extends Token { + value; + constructor(value) { + super(); + this.value = value; + } +} + +export class BadStringToken extends Token {} + +export class UrlToken extends Token { + value; + constructor(value) { + super(); + this.value = value; + } +} + +export class BadUrlToken extends Token {} + +export class DelimToken extends Token { + value; + constructor(value) { + super(); + this.value = value; + } +} + +export class NumberToken extends Token { + value; + type; + constructor(value, type = "integer") { + super(); + this.value = value; + this.type = type; + } +} + +export class PercentageToken extends Token { + value; + constructor(value) { + super(); + this.value = value; + } +} + +export class DimensionToken extends Token { + value; + type; + unit; + constructor(value, type, unit) { + super(); + this.value = value; + this.type = type; + this.unit = unit; + } +} + +export class WhitespaceToken extends Token {} + +export class CDOToken extends Token {} + +export class CDCToken extends Token {} + +export class ColonToken extends Token {} + +export class SemicolonToken extends Token {} + +export class CommaToken extends Token {} + +export class LeftSquareBracketToken extends Token {} + +export class RightSquareBracketToken extends Token {} + +export class LeftParenthesisToken extends Token {} + +export class RightParenthesisToken extends Token {} + +export class LeftCurlyBracketToken extends Token {} + +export class RightCurlyBracketToken extends Token {} + +class InputStream { + input + index = 0; + constructor(input) { + this.input = input; + } + + consume() { + const codePoint = this.input.codePointAt(this.index); + if (typeof codePoint !== 'undefined') { + this.index += String.fromCodePoint(codePoint).length; + } + return codePoint; + } + + reconsume(codePoint) { + if (typeof codePoint !== 'undefined') { + this.index -= String.fromCodePoint(codePoint).length + } + } + + peek() { + const codePoints = [] + let position = this.index + for (let i = 0; i < 3; i++) { + const nextCodePoint = this.input.codePointAt(position); + if (typeof nextCodePoint !== 'undefined') { + codePoints.push(nextCodePoint); + position += String.fromCodePoint(nextCodePoint).length; + } + } + return codePoints; + } +} + +function isNewline(codePoint) { + // U+000A LINE FEED. + return codePoint === 0x000A; +} +function isWhitespace(codePoint) { + // A newline, U+0009 CHARACTER TABULATION, or U+0020 SPACE. + return isNewline(codePoint) || codePoint === 0x2000 || codePoint === 0x0020; +} + +function isDigit(codePoint) { + // A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. + return codePoint >= 0x0030 && codePoint <=0x0039; +} + +function isHexDigit(codePoint) { + // A digit, or a code point between U+0041 LATIN CAPITAL LETTER A (A) and U+0046 LATIN CAPITAL LETTER F (F) inclusive, + // or a code point between U+0061 LATIN SMALL LETTER A (a) and U+0066 LATIN SMALL LETTER F (f) inclusive. + return isDigit(codePoint) || + (codePoint >= 0x0041 && codePoint <= 0x0046) || + (codePoint >= 0x0061 && codePoint <= 0x0066); +} + +function isUppercaseLetter(codePoint) { + // A code point between U+0041 LATIN CAPITAL LETTER A (A) and U+005A LATIN CAPITAL LETTER Z (Z) inclusive. + return codePoint >= 0x0041 && codePoint <= 0x005A; +} + +function isLowercaseLetter(codePoint) { + // A code point between U+0061 LATIN SMALL LETTER A (a) and U+007A LATIN SMALL LETTER Z (z) inclusive. + return codePoint >= 0x0061 && codePoint <= 0x007A; +} + +function isLetter(codePoint) { + // An uppercase letter or a lowercase letter. + return isUppercaseLetter(codePoint) || isLowercaseLetter(codePoint); +} + +function nonASCIICodePoint(codePoint) { + // A code point with a value equal to or greater than U+0080 . + return codePoint >= 0x0080; +} +function isIdentStartCodePoint(codePoint) { + // A letter, a non-ASCII code point, or U+005F LOW LINE (_). + return isLetter(codePoint) || nonASCIICodePoint(codePoint) || codePoint === 0x005F; +} + +function isIdentCodePoint(codePoint) { + // An ident-start code point, a digit, or U+002D HYPHEN-MINUS (-). + return isIdentStartCodePoint(codePoint) || isDigit(codePoint) || codePoint === 0x002D; +} + +function isNonPrintableCodePoint(codePoint) { + // A code point between U+0000 NULL and U+0008 BACKSPACE inclusive, or U+000B LINE TABULATION, + // or a code point between U+000E SHIFT OUT and U+001F INFORMATION SEPARATOR ONE inclusive, or U+007F DELETE. + return (codePoint >= 0x0000 && codePoint <= 0x0008) || codePoint === 0x000B || + (codePoint >= 0x000E && codePoint <= 0x001F) || codePoint === 0x007F; +} + +function validEscape(firstCodePoint, secondCodePoint) { + // If the first code point is not U+005C REVERSE SOLIDUS (\), return false. + // Otherwise, if the second code point is a newline, return false. + // Otherwise, return true. + return firstCodePoint === 0x005C && !isNewline(secondCodePoint); +} + +function startsIdentSequence(firstCodePoint, secondCodePoint, thirdCodePoint) { + // Look at the first code point: + if (firstCodePoint === 0x002D) { + // U+002D HYPHEN-MINUS + // If the second code point is an ident-start code point or a U+002D HYPHEN-MINUS, + // or the second and third code points are a valid escape, return true. Otherwise, return false. + return isIdentStartCodePoint(secondCodePoint) || secondCodePoint === 0x002D || + validEscape(secondCodePoint, thirdCodePoint); + } else if (isIdentStartCodePoint(firstCodePoint)) { + // ident-start code point + // Return true. + return true; + } else if (firstCodePoint === 0x005C) { + // U+005C REVERSE SOLIDUS (\) + // If the first and second code points are a valid escape, return true. Otherwise, return false. + return validEscape(firstCodePoint, secondCodePoint); + } else { + // anything else + // Return false. + return false; + } +} + +function startsNumber(firstCodePoint, secondCodePoint, thirdCodePoint) { + // https://www.w3.org/TR/css-syntax-3/#check-if-three-code-points-would-start-a-number + // Look at the first code point: + + if (firstCodePoint === 0x002B || firstCodePoint === 0x002D) { + // U+002B PLUS SIGN (+) + // U+002D HYPHEN-MINUS (-) + // If the second code point is a digit, return true. + // Otherwise, if the second code point is a U+002E FULL STOP (.) and the third code point is a digit, return true. + // + // Otherwise, return false. + return isDigit(secondCodePoint) || (secondCodePoint === 0x002E && isDigit(thirdCodePoint)); + } else if (firstCodePoint === 0x002E) { + // U+002E FULL STOP (.) + // If the second code point is a digit, return true. Otherwise, return false. + return isDigit(secondCodePoint); + } else { + // digit + // Return true. + // anything else + // Return false. + return isDigit(firstCodePoint); + } +} + +/** + * Consume an escaped code point + * https://www.w3.org/TR/css-syntax-3/#consume-an-escaped-code-point + * + * @param {InputStream} input + * @return number + */ +function consumeEscapedCodePoint(input) { + // Consume the next input code point. + const codePoint = input.consume(); + if (isHexDigit(codePoint)) { + let digits = [codePoint]; + // hex digit + // Consume as many hex digits as possible, but no more than 5. Note that this means 1-6 hex digits have been + // consumed in total. + while(isHexDigit(...input.peek()) && digits.length < 5) { + digits.push(input.consume()); + } + + // If the next input code point is whitespace, consume it as well. + if (isWhitespace(...input.peek())) { + input.consume(); + } + + // Interpret the hex digits as a hexadecimal number. If this number is zero, or is for a surrogate, or is greater + // than the maximum allowed code point, return U+FFFD REPLACEMENT CHARACTER (�). Otherwise, return the code point + // with that value. + const number = parseInt(String.fromCodePoint(...digits), 16); + if (number === 0 || number > 0x10FFFF) { + return 0xFFFD; + } else { + return number; + } + } else if (typeof codePoint === 'undefined') { + // EOF + // This is a parse error. Return U+FFFD REPLACEMENT CHARACTER (�). + return 0xFFFD; + } else { + // anything else + // Return the current input code point. + return codePoint; + } +} + + +/** + * Consume a string token + * https://www.w3.org/TR/css-syntax-3/#consume-a-string-token + * + * @param {InputStream} input + * @param {number} endingCodePoint + */ +function consumeStringToken(input, endingCodePoint) { + const stringToken = new StringToken(''); + + while (true) { + // Repeatedly consume the next input code point from the stream: + const codePoint = input.consume(); + if (codePoint === endingCodePoint) { + // ending code point + // Return the . + return stringToken; + } else if (typeof codePoint === 'undefined') { + // EOF + // This is a parse error. Return the . + return stringToken + } else if (codePoint === 0x00A) { + // newline + // This is a parse error. Reconsume the current input code point, create a , and return it. + input.reconsume(codePoint); + return new BadStringToken(); + } else if (codePoint === 0x005C) { + // U+005C REVERSE SOLIDUS (\) + const nextCodePoint = input.peek()[0]; + if (typeof nextCodePoint === 'undefined') { + // If the next input code point is EOF, do nothing. + } else if (isNewline(nextCodePoint)) { + // Otherwise, if the next input code point is a newline, consume it. + input.consume(); + } else { + // Otherwise, (the stream starts with a valid escape) consume an escaped code point and + // append the returned code point to the ’s value. + stringToken.value += String.fromCodePoint(consumeEscapedCodePoint(input)); + } + } else { + // anything else + // Append the current input code point to the ’s value. + stringToken.value += String.fromCodePoint(codePoint); + } + } +} + +/** + * Consume ident sequence + * https://www.w3.org/TR/css-syntax-3/#consume-name + * + * @param {InputStream} input + */ +function consumeIdentSequence(input) { + // Let result initially be an empty string. + let result = ''; + + // Repeatedly consume the next input code point from the stream: + while (true) { + const codePoint = input.consume(); + if (isIdentCodePoint(codePoint)) { + // ident code point + // Append the code point to result. + result += String.fromCodePoint(codePoint); + } else if (validEscape(...input.peek())) { + // the stream starts with a valid escape + // Consume an escaped code point. Append the returned code point to result. + result += String.fromCodePoint(consumeEscapedCodePoint(input)); + } else { + // anything else + // Reconsume the current input code point. Return result. + input.reconsume(codePoint); + return result; + } + } +} + +/** + * Consume a number + * https://www.w3.org/TR/css-syntax-3/#consume-a-number + * + * @param {InputStream} input + */ +function consumeNumber(input) { + // Execute the following steps in order: + // + // Initially set type to "integer". Let repr be the empty string. + let type = 'integer'; + let repr = ''; + + // If the next input code point is U+002B PLUS SIGN (+) or U+002D HYPHEN-MINUS (-), consume it and append it to repr. + if ([0x002B, 0x002D].includes(input.peek()[0])) { + repr += String.fromCodePoint(input.consume()); + } + + // While the next input code point is a digit, consume it and append it to repr. + while(isDigit(...input.peek())) { + repr += String.fromCodePoint(input.consume()); + } + + // If the next 2 input code points are U+002E FULL STOP (.) followed by a digit, then: + // Consume them. + // Append them to repr. + // Set type to "number". + // While the next input code point is a digit, consume it and append it to repr. + if (input.peek()[0] === 0x002E && isDigit(input.peek()[1])) { + repr += String.fromCodePoint(input.consume(), input.consume()); + type = 'number'; + while(isDigit(...input.peek())) { + repr += String.fromCodePoint(input.consume()); + } + } + + // If the next 2 or 3 input code points are U+0045 LATIN CAPITAL LETTER E (E) or U+0065 LATIN SMALL LETTER E (e), + // optionally followed by U+002D HYPHEN-MINUS (-) or U+002B PLUS SIGN (+), + // followed by a digit, then: + // Consume them. + // Append them to repr. + // Set type to "number". + // While the next input code point is a digit, consume it and append it to repr. + if ([0x0045, 0x0065].includes(input.peek()[0])) { + if ([0x002D, 0x002B].includes(input.peek()[1]) && isDigit(input.peek()[2])) { + repr += String.fromCodePoint(input.consume(), input.consume(), input.consume()); + type = 'number'; + } else if (isDigit(input.peek()[1])) { + repr += String.fromCodePoint(input.consume(), input.consume()); + type = 'number'; + } + } + + // Convert repr to a number, and set the value to the returned value. + const value = parseFloat(repr); + // Return value and type. + return { value, type }; +} + +/** + * Consume a numeric token + * https://www.w3.org/TR/css-syntax-3/#consume-a-numeric-token + * + * @param {InputStream} input + */ +function consumeNumericToken(input) { + // Consume a number and let number be the result. + let number = consumeNumber(input); + // If the next 3 input code points would start an ident sequence, then: + if (startsIdentSequence(...input.peek())) { + // Create a with the same value and type flag as number, and a unit set initially to the empty string. + // Consume an ident sequence. Set the ’s unit to the returned value. + // Return the . + return new DimensionToken(number.value, number.type, consumeIdentSequence(input)); + } else if (input.peek()[0] === 0x0025) { + // Otherwise, if the next input code point is U+0025 PERCENTAGE SIGN (%), consume it. + // Create a with the same value as number, and return it. + input.consume(); + return new PercentageToken(number.value); + } else { + // Otherwise, create a with the same value and type flag as number, and return it. + return new NumberToken(number.value, number.type); + } +} + +/** + * Consume remnants of a bad url + * https://www.w3.org/TR/css-syntax-3/#consume-the-remnants-of-a-bad-url + * @param {InputStream} input + */ +function consumeRemnantsOfBadUrl(input) { + // Repeatedly consume the next input code point from the stream: + while (true) { + const codePoint = input.consume(); + if (codePoint === 0x0029 || typeof codePoint === 'undefined') { + // U+0029 RIGHT PARENTHESIS ()) + // EOF + // Return. + return; + } else if (validEscape(...input.peek())) { + // the input stream starts with a valid escape + // Consume an escaped code point. This allows an escaped right parenthesis ("\)") to be encountered without + // ending the . This is otherwise identical to the "anything else" clause. + consumeEscapedCodePoint(input); + } + // anything else + // Do nothing. + } +} + +/** + * Consume URL token + * https://www.w3.org/TR/css-syntax-3/#consume-a-url-token + * @param {InputStream} input + */ +function consumeUrlToken(input) { + // Initially create a with its value set to the empty string. + const urlToken = new UrlToken(''); + + // Consume as much whitespace as possible. + while(isWhitespace(...input.peek())) { + input.consume(); + } + + // Repeatedly consume the next input code point from the stream: + while (true) { + const codePoint = input.consume(); + if (codePoint === 0x0029) { + + // U+0029 RIGHT PARENTHESIS ()) + // Return the . + return urlToken; + } else if (typeof codePoint === 'undefined') { + // EOF + // This is a parse error. Return the . + return urlToken; + } else if (isWhitespace(codePoint)) { + // whitespace + // Consume as much whitespace as possible. + while(isWhitespace(...input.peek())) { + input.consume(); + } + if (input.peek()[0] === 0x0029 || typeof input.peek()[0] === 'undefined') { + // If the next input code point is U+0029 RIGHT PARENTHESIS ()) or EOF, + // consume it and return the (if EOF was encountered, this is a parse error); + input.consume(); + return urlToken; + } else { + // otherwise, consume the remnants of a bad url, create a , and return it. + consumeRemnantsOfBadUrl(input); + return new BadUrlToken(); + } + } else if ([0x0022, 0x0027, 0x0028].includes(codePoint) || isNonPrintableCodePoint(codePoint)) { + // U+0022 QUOTATION MARK (") + // U+0027 APOSTROPHE (') + // U+0028 LEFT PARENTHESIS (() + // non-printable code point + // This is a parse error. Consume the remnants of a bad url, create a , and return it. + consumeRemnantsOfBadUrl(input); + return new BadUrlToken(); + } else if (codePoint === 0x005C) { + // U+005C REVERSE SOLIDUS (\) + if (validEscape(...input.peek())) { + // If the stream starts with a valid escape, + // consume an escaped code point and append the returned code point to the ’s value. + urlToken.value += consumeEscapedCodePoint(input); + } else { + // Otherwise, this is a parse error. Consume the remnants of a bad url, create a , and return it. + consumeRemnantsOfBadUrl(input); + return new BadUrlToken(); + } + } else { + // anything else + // Append the current input code point to the ’s value. + urlToken.value += String.fromCodePoint(codePoint); + } + } +} + +/** + * Consume ident like token + * https://www.w3.org/TR/css-syntax-3/#consume-an-ident-like-token + * + * @param {InputStream} input + */ +function consumeIdentLikeToken(input) { + // Consume an ident sequence, and let string be the result. + const str = consumeIdentSequence(input); + if (str.match(/url/i) && input.peek()[0] === 0x0028) { + // If string’s value is an ASCII case-insensitive match for "url", + // and the next input code point is U+0028 LEFT PARENTHESIS ((), consume it. + input.consume(); + // While the next two input code points are whitespace, consume the next input code point. + while(isWhitespace(input.peek()[0]) && isWhitespace(input.peek()[1])) { + input.consume(); + } + + if ([0x0022, 0x0027].includes(input.peek()[0]) || + (isWhitespace(input.peek()[0]) && [0x0022, 0x0027].includes(input.peek()[1]))) { + // If the next one or two input code points are U+0022 QUOTATION MARK ("), U+0027 APOSTROPHE ('), + // or whitespace followed by U+0022 QUOTATION MARK (") or U+0027 APOSTROPHE ('), + // then create a with its value set to string and return it. + return new FunctionToken(str); + } else { + // Otherwise, consume a url token, and return it. + return consumeUrlToken(input); + } + } else if (input.peek()[0] === 0x0028) { + // Otherwise, if the next input code point is U+0028 LEFT PARENTHESIS ((), consume it. + // Create a with its value set to string and return it. + input.consume(); + return new FunctionToken(str); + } else { + // Otherwise, create an with its value set to string and return it. + return new IdentToken(str); + } +} +/** + * Consume a token. + * + * https://www.w3.org/TR/css-syntax-3/#consume-a-token + * + * @param {InputStream} input + */ +function consumeToken(input) { + // Consume the next input code point + const codePoint = input.consume() + const lookahead = input.peek() + if (isWhitespace(codePoint)) { + // whitespace + // Consume as much whitespace as possible. Return a . + while(isWhitespace(...input.peek())) { + input.consume(); + } + return new WhitespaceToken(); + } else if (codePoint === 0x0022) { + // U+0022 QUOTATION MARK (") + // Consume a string token and return it. + return consumeStringToken(input, codePoint); + } else if (codePoint === 0x0023) { + // U+0023 NUMBER SIGN (#) + // If the next input code point is an ident code point or the next two input code points are a valid escape, then: + // Create a . + // If the next 3 input code points would start an ident sequence, set the ’s type flag to "id". + // Consume an ident sequence, and set the ’s value to the returned string. + // Return the . + // Otherwise, return a with its value set to the current input code point. + if (isIdentCodePoint(lookahead[0]) || validEscape(...lookahead)) { + const hashToken = new HashToken(); + if (startsIdentSequence(...lookahead)) { + hashToken.type = 'id'; + } + hashToken.value = consumeIdentSequence(input); + return hashToken; + } else { + return new DelimToken(String.fromCodePoint(codePoint)); + } + } else if (codePoint === 0x0027) { + // U+0027 APOSTROPHE (') + // Consume a string token and return it. + return consumeStringToken(input, codePoint); + } else if (codePoint === 0x0028) { + // U+0028 LEFT PARENTHESIS (() + // Return a <(-token>. + return new LeftParenthesisToken(); + } else if (codePoint === 0x0029) { + // U+0029 RIGHT PARENTHESIS ()) + // Return a <)-token>. + return new RightParenthesisToken(); + } else if (codePoint === 0x002B) { + // U+002B PLUS SIGN (+) + // If the input stream starts with a number, reconsume the current input code point, consume a numeric token, + // and return it. + // Otherwise, return a with its value set to the current input code point. + if (startsNumber(...lookahead)) { + input.reconsume(codePoint); + return consumeNumericToken(input); + } else { + return new DelimToken(String.fromCodePoint(codePoint)); + } + } else if (codePoint === 0x002C) { + // U+002C COMMA (,) + // Return a . + return new CommaToken(); + } else if (codePoint === 0x002D) { + // U+002D HYPHEN-MINUS (-) + if (startsNumber(...input.peek())) { + // If the input stream starts with a number, reconsume the current input code point, consume a numeric token, and return it. + input.reconsume(codePoint); + return consumeNumericToken(input); + } else if (input.peek()[0] === 0x002D && input.peek()[1] === 0x003E) { + // Otherwise, if the next 2 input code points are U+002D HYPHEN-MINUS U+003E GREATER-THAN SIGN (->), consume them and return a . + input.consume(); + input.consume(); + return new CDCToken(); + } else if (startsIdentSequence(...input.peek())) { + // Otherwise, if the input stream starts with an ident sequence, reconsume the current input code point, consume an ident-like token, and return it. + input.reconsume(codePoint); + return consumeIdentLikeToken(input); + } else { + // Otherwise, return a with its value set to the current input code point. + return new DelimToken(String.fromCodePoint(codePoint)); + } + } else if (codePoint === 0x002E) { + // U+002E FULL STOP (.) + if (startsNumber(...input.peek())) { + // If the input stream starts with a number, reconsume the current input code point, consume a numeric token, and return it. + input.reconsume(codePoint); + return consumeNumericToken(input); + } else { + // Otherwise, return a with its value set to the current input code point. + return new DelimToken(String.fromCodePoint(codePoint)); + } + } else if (codePoint === 0x003A) { + // U+003A COLON (:) + // Return a . + return new ColonToken(); + } else if (codePoint === 0x003B) { + // U+003B SEMICOLON (;) + // Return a . + return new SemicolonToken(); + } else if (codePoint === 0x003C) { + // U+003C LESS-THAN SIGN (<) + if (lookahead[0] === 0x0021 && lookahead[1] === 0x002D && lookahead[2] === 0x002D) { + // If the next 3 input code points are U+0021 EXCLAMATION MARK U+002D HYPHEN-MINUS U+002D HYPHEN-MINUS (!--), consume them and return a . + input.consume(); + input.consume(); + input.consume(); + return new CDOToken(); + } else { + // Otherwise, return a with its value set to the current input code point. + return new DelimToken(String.fromCodePoint(codePoint)); + } + } else if (codePoint === 0x0040) { + // U+0040 COMMERCIAL AT (@) + if (startsIdentSequence(...lookahead)) { + // If the next 3 input code points would start an ident sequence, consume an ident sequence, + // create an with its value set to the returned value, and return it. + return new AtKeywordToken(consumeIdentSequence(input)); + } else { + // Otherwise, return a with its value set to the current input code point. + return new DelimToken(String.fromCodePoint(codePoint)); + } + } else if (codePoint === 0x005B) { + // U+005B LEFT SQUARE BRACKET ([) + // Return a <[-token>. + return new LeftSquareBracketToken(); + } else if (codePoint === 0x005C) { + // U+005C REVERSE SOLIDUS (\) + if (validEscape(...lookahead)) { + // If the input stream starts with a valid escape, reconsume the current input code point, consume an ident-like token, and return it. + input.reconsume(codePoint); + return consumeIdentLikeToken(input); + } else { + // Otherwise, this is a parse error. Return a with its value set to the current input code point. + return new DelimToken(String.fromCodePoint(codePoint)); + } + } else if (codePoint === 0x005D) { + // U+005D RIGHT SQUARE BRACKET (]) + // Return a <]-token>. + return new RightSquareBracketToken(); + } else if (codePoint === 0x007B) { + // U+007B LEFT CURLY BRACKET ({) + // Return a <{-token>. + return new LeftCurlyBracketToken(); + } else if (codePoint === 0x007D) { + // U+007D RIGHT CURLY BRACKET (}) + // Return a <}-token>. + return new RightCurlyBracketToken(); + } else if (isDigit(codePoint)) { + // digit + // Reconsume the current input code point, consume a numeric token, and return it. + input.reconsume(codePoint); + return consumeNumericToken(input); + } else if (isIdentStartCodePoint(codePoint)) { + // ident-start code point + // Reconsume the current input code point, consume an ident-like token, and return it. + input.reconsume(codePoint); + return consumeIdentLikeToken(input); + } else if (typeof codePoint === 'undefined') { + // EOF + // Return an . + return undefined; + } else { + // anything else + // Return a with its value set to the current input code point. + return new DelimToken(String.fromCodePoint(codePoint)); + } +} + +/** + * Tokenize a string into an array of CSS tokens. + * @param {string} str + */ +export function tokenizeString(str) { + const input = new InputStream(str); + // To tokenize a stream of code points into a stream of CSS tokens input, repeatedly consume a token from input + // until an is reached, pushing each of the returned tokens into a stream. + const tokens = []; + while (true) { + const token = consumeToken(input); + if (typeof token === 'undefined') { + return tokens; + } else { + tokens.push(token); + } + } +} \ No newline at end of file diff --git a/test/expected.txt b/test/expected.txt index 9630d16..804fcb0 100644 --- a/test/expected.txt +++ b/test/expected.txt @@ -923,13 +923,13 @@ PASS /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timel FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with px range [JavaScript API] FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with calculated range [JavaScript API] FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with EM range [JavaScript API] -TIMEOUT /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with percentage range [CSS] -NOTRUN /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with px range [CSS] -NOTRUN /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with calculated range [CSS] -NOTRUN /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with EM range [CSS] +FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with percentage range [CSS] +FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with px range [CSS] +FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with calculated range [CSS] +FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with EM range [CSS] PASS /scroll-animations/scroll-timelines/scroll-timeline-snapshotting.html ScrollTimeline current time is updated after programmatic animated scroll. PASS /scroll-animations/scroll-timelines/setting-current-time.html Setting animation current time to null throws TypeError. -FAIL /scroll-animations/scroll-timelines/setting-current-time.html Setting the current time to an absolute time value throws exception +PASS /scroll-animations/scroll-timelines/setting-current-time.html Setting the current time to an absolute time value throws exception PASS /scroll-animations/scroll-timelines/setting-current-time.html Set animation current time to a valid value without playing. PASS /scroll-animations/scroll-timelines/setting-current-time.html Set animation current time to a valid value while playing. PASS /scroll-animations/scroll-timelines/setting-current-time.html Set animation current time to a value beyond effect end. @@ -953,7 +953,7 @@ PASS /scroll-animations/scroll-timelines/setting-playback-rate.html Reversing th PASS /scroll-animations/scroll-timelines/setting-playback-rate.html Zero initial playback rate should correctly modify initial current time. FAIL /scroll-animations/scroll-timelines/setting-playback-rate.html Setting a zero playback rate while running preserves the start time FAIL /scroll-animations/scroll-timelines/setting-playback-rate.html Reversing an animation with non-boundary aligned start time symmetrically adjusts the start time -FAIL /scroll-animations/scroll-timelines/setting-start-time.html Setting the start time to an absolute time value throws exception +PASS /scroll-animations/scroll-timelines/setting-start-time.html Setting the start time to an absolute time value throws exception PASS /scroll-animations/scroll-timelines/setting-start-time.html Setting the start time clears the hold time PASS /scroll-animations/scroll-timelines/setting-start-time.html Setting the start time clears the hold time when the timeline is inactive PASS /scroll-animations/scroll-timelines/setting-start-time.html Setting an unresolved start time sets the hold time