/* * Copyright 2012 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * 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. */ package com.google.common.css.compiler.passes; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Function; import com.google.common.base.Functions; import com.google.common.base.Predicate; import com.google.common.base.Predicates; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSortedSet; import com.google.common.collect.Iterables; import com.google.common.collect.Iterators; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.PeekingIterator; import com.google.common.collect.Sets; import com.google.common.collect.UnmodifiableIterator; import com.google.common.css.SourceCode; import com.google.common.css.SourceCodeLocation; import com.google.common.css.compiler.ast.CssCommentNode; import com.google.common.css.compiler.ast.CssCompilerPass; import com.google.common.css.compiler.ast.CssCompositeValueNode; import com.google.common.css.compiler.ast.CssDeclarationNode; import com.google.common.css.compiler.ast.CssLiteralNode; import com.google.common.css.compiler.ast.CssNode; import com.google.common.css.compiler.ast.CssNumericNode; import com.google.common.css.compiler.ast.CssPriorityNode; import com.google.common.css.compiler.ast.CssPropertyValueNode; import com.google.common.css.compiler.ast.CssStringNode; import com.google.common.css.compiler.ast.CssTree; import com.google.common.css.compiler.ast.CssValueNode; import com.google.common.css.compiler.ast.DefaultTreeVisitor; import com.google.common.css.compiler.ast.ErrorManager; import com.google.common.css.compiler.ast.GssError; import com.google.common.css.compiler.ast.MutatingVisitController; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; /** * Compiler pass that replaces font and font-family declaration subtrees * so that the tree structure resembles the (rather idiosyncratic) grammar * of the corresponding CSS properties. * */ public class FixupFontDeclarations extends DefaultTreeVisitor implements CssCompilerPass { /** * Specifies how the input tree should be interpreted. */ public enum InputMode { /** * Assume the input follows the grammar of CSS. */ CSS, /** * Perform a best-effort parse that allows unsubstituted definition uses. */ GSS; } /** * Properties whose values may be specified in a {@code Font} declaration. */ public enum FontProperty { STYLE, VARIANT, WEIGHT, SIZE, LINE_HEIGHT, FAMILY }; /** * A simple predicate on {@code CssCompositeValueNode}. */ private static class WithOperator implements Predicate<CssCompositeValueNode> { private final CssCompositeValueNode.Operator op; public WithOperator(CssCompositeValueNode.Operator op) { this.op = op; } @Override public boolean apply(CssCompositeValueNode n) { if (n.getOperator() != op) { return false; } return true; } } private static WithOperator withOperator(CssCompositeValueNode.Operator op) { return new WithOperator(op); } private static final String FONT = "font"; private static final String FONT_FAMILY = "font-family"; private static final String NORMAL = "normal"; private static final String INHERIT = "inherit"; private static final Set<String> SYSTEM_FONTS = ImmutableSet.of( "caption", "icon", "menu", "message-box", "small-caption", "status-bar"); private static final Set<String> FONT_ABSOLUTE_SIZES = ImmutableSet.of( "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large"); private static final Set<String> FONT_RELATIVE_SIZES = ImmutableSet.of( "larger", "smaller"); private static final Set<String> DEFINITELY_STYLE = ImmutableSet.of( "italic", "oblique"); private static final Set<String> DEFINITELY_VARIANT = ImmutableSet.of( "small-caps"); private static final Set<String> DEFINITELY_WEIGHT = ImmutableSet.of( "bold", "bolder", "lighter"); private static final Set<String> NUMERIC_WEIGHTS = ImmutableSet.of( "100", "200", "300", "400", "500", "600", "700", "800", "900"); /** * No point looking through lots of nodes for clues that are only allowed * in a prefix. This limit is conservative, assuming we create as many * top-level nodes as tokens. As we'll see shortly, we actually expect * the parser to structure things slightly more elaborately, resulting * in fewer top-level nodes. */ private static final int PRE_FAMILY_LIMIT = 6; /** * Recognizes a simple size value represented by a node in the * original AST of a font declaration value. */ private static final Predicate<CssValueNode> IS_PLAIN_SIZE = new Predicate<CssValueNode>() { @Override public boolean apply(CssValueNode n) { return (n instanceof CssNumericNode && (!CssNumericNode.NO_UNITS.equals( ((CssNumericNode) n).getUnit()))) || FONT_ABSOLUTE_SIZES.contains(n.getValue()) || FONT_RELATIVE_SIZES.contains(n.getValue()); } }; /** * Picks out the size from the original AST subtree representing * size/line-height in a font declaration value. */ private static final Function<CssCompositeValueNode, CssValueNode> EXTRACT_SIZE = new Function<CssCompositeValueNode, CssValueNode>() { @Override public CssValueNode apply(CssCompositeValueNode n) { return n.getValues().get(0); } }; @VisibleForTesting static final String SIZE_AND_FAMILY_REQUIRED = "Size and family are required in the absence of a system font or a " + "simple inherit"; @VisibleForTesting static final Map<FontProperty, String> TOO_MANY = ImmutableMap.<FontProperty, String>builder() .put(FontProperty.LINE_HEIGHT, "The '/' can occur at most once in a font shorthand value") .put(FontProperty.SIZE, "Font size can occur at most once in a font shorthand value") .put(FontProperty.STYLE, "Font style can occur at most once in a font shorthand value") .put(FontProperty.VARIANT, "Font variant can occur at most once in a font shorthand value") .put(FontProperty.WEIGHT, "Font weight can occur at most once in a font shorthand value") .build(); private static final ImmutableSortedSet<FontProperty> SLOTTABLE_PROPERTIES = ImmutableSortedSet.of( FontProperty.STYLE, FontProperty.VARIANT, FontProperty.WEIGHT); @VisibleForTesting static final String TOO_MANY_NORMALS = "The keyword normal can occur at most thrice in a font shorthand value"; @VisibleForTesting static final String NORMAL_TOO_LATE = "The keyword normal is only allowed in the first three tokens of a " + "font shorthand"; @VisibleForTesting static final String SIZE_AFTER_HEIGHT = "Font size must be specified before line-height"; @VisibleForTesting static final String TOO_MANY_PRE_SIZE = "Too many font shorthand tokens before size"; @VisibleForTesting static final String PRE_SIZE_INTERLOPER_SIZE = "Unrecognized tokens immediately preceding size"; /** * Should the input be parsed strictly or do we assume gss variables * might be substituted later? */ private final InputMode mode; private final ErrorManager errorManager; private final MutatingVisitController visitController; private CssTree tree; public FixupFontDeclarations( InputMode mode, ErrorManager errorManager, CssTree tree) { this.mode = mode; this.errorManager = errorManager; this.tree = tree; visitController = tree.getMutatingVisitController(); } @Override public boolean enterDeclaration(CssDeclarationNode decl) { String propertyName = decl.getPropertyName().getProperty().getName(); if (!(FONT.equals(propertyName) || FONT_FAMILY.equals(propertyName))) { return false; } CssDeclarationNode d = decl.deepCopy(); List<CssDeclarationNode> replacement = ImmutableList.of(d); if (FONT.equals(propertyName)) { d.setPropertyValue(reparseFont(decl.getPropertyValue())); } else if (FONT_FAMILY.equals(propertyName)) { d.setPropertyValue(reparseFontFamily(decl.getPropertyValue())); } visitController.replaceCurrentBlockChildWith(replacement, false); return false; } private CssPropertyValueNode reparseFont(CssPropertyValueNode n) { // Preliminary easy cases if (n.numChildren() == 0) { return n.deepCopy(); } else if (n.numChildren() == 1 && SYSTEM_FONTS.contains(n.getChildAt(0).getValue()) || INHERIT.equals(n.getChildAt(0).getValue())) { return n.deepCopy(); } else if (n.numChildren() < 2) { if (mode == InputMode.CSS) { errorManager.report( new GssError(SIZE_AND_FAMILY_REQUIRED, getSourceCodeLocation(n))); } return n.deepCopy(); } // Some clients want to be able to whitelist typefaces or otherwise // understand the font shorthand in detail. Unbound GSS variables make // this pretty hopeless: // font: CLUELESS; // but we can try: // font: italic bold SIZE/LEADING FAMILIES; // and a best-effort parse tree for that might be useful. // // The best case for us is CSS, where we can rely on clues such as // the keywords that can disambiguate some cases of style vs. variant // and the slash token that tells us that the surrounding tokens are // size and line-height and not e.g., size and a family name. // // Our strategy is to segment the property value and deal with each // segment independently. Segmentation isn't straightforward because // tokens can't always be understood independently: // font: normal italic 100 medium medium roman regular; // ^the next two tokens tell us this normal specifies a font-variant // ^this is easily understood to be a font-weight // ^obviously a font-size // ^because we're past size, this must // be a font-family name component // Try to recognize things individually, then check for conflicts // among our constraints, and finally rebuild our AST. Iterable<CssValueNode> preFamilyCandidates = Iterables.limit(n.childIterable(), PRE_FAMILY_LIMIT); Iterable<CssCompositeValueNode> sizeLineHeights = Iterables.filter( extractByType(CssCompositeValueNode.class, preFamilyCandidates), withOperator(CssCompositeValueNode.Operator.SLASH)); Iterable<CssValueNode> plainSizes = Iterables.filter( preFamilyCandidates, IS_PLAIN_SIZE); Iterable<CssValueNode> lhSizes = Iterables.transform( sizeLineHeights, EXTRACT_SIZE); final HashMap<CssNode, Integer> lexicalOrder = EnumeratingVisitor.enumerate(tree); Iterable<CssValueNode> sizes = Iterables.concat(plainSizes, lhSizes); if (!validateSplitPoint( getSourceCodeLocation(n), lexicalOrder, sizeLineHeights, sizes)) { return n.deepCopy(); } // Now we can split on the one and only size node. final CssValueNode splitPoint = Iterables.getOnlyElement(Iterables.concat(sizeLineHeights, plainSizes)); Iterable<CssValueNode> prefix = takeWhile(n.childIterable(), new Predicate<CssValueNode>() { @Override public boolean apply(CssValueNode n) { return lexicalOrder.get(n).compareTo( lexicalOrder.get(splitPoint)) < 0; } }); final CssPriorityNode priority = getPriority(n); Iterable<CssValueNode> families = dropWhile( takeUntil(n.childIterable(), priority), new Predicate<CssValueNode>() { @Override public boolean apply(CssValueNode n) { return lexicalOrder.get(splitPoint).compareTo( lexicalOrder.get(n)) < 0; } }); final Map<CssValueNode, FontProperty> properties = classifyNodes(prefix, sizes, sizeLineHeights); // Validate analysis validatePrefix(prefix); validateProperties(prefix, properties); // Build output return rebuildFont(prefix, splitPoint, families, priority, properties, n); } private CssPriorityNode getPriority(CssPropertyValueNode n) { if (n.numChildren() < 1) return null; CssNode last = n.getChildAt(n.numChildren() - 1); if (last instanceof CssPriorityNode) { return (CssPriorityNode) last; } else { return null; } } private <T> Iterable<T> takeUntil(Iterable<T> xs, final T excludedEndpoint) { return takeWhile( xs, new Predicate<T>() { @Override public boolean apply(T i) { return excludedEndpoint != i; } }); } private Map<CssValueNode, FontProperty> classifyNodes( Iterable<CssValueNode> prefix, Iterable<CssValueNode> sizes, Iterable<CssCompositeValueNode> sizeLineHeights) { final Map<CssValueNode, FontProperty> properties = Maps.newHashMap(); for (CssValueNode i : prefix) { if (DEFINITELY_STYLE.contains(i.getValue())) { properties.put(i, FontProperty.STYLE); } else if (DEFINITELY_VARIANT.contains(i.getValue())) { properties.put(i, FontProperty.VARIANT); } else if (isWeight(i)) { properties.put(i, FontProperty.WEIGHT); } } properties.put(Iterables.getOnlyElement(sizes), FontProperty.SIZE); if (!Iterables.isEmpty(sizeLineHeights)) { properties.put( Iterables.getOnlyElement(sizeLineHeights).getValues().get(1), FontProperty.LINE_HEIGHT); } return properties; } private boolean isWeight(CssValueNode n) { if (DEFINITELY_WEIGHT.contains(n.getValue())) { return true; } if (!(n instanceof CssNumericNode)) { return false; } CssNumericNode numeric = (CssNumericNode) n; if (!CssNumericNode.NO_UNITS.equals(numeric.getUnit())) { return false; } return (NUMERIC_WEIGHTS.contains(numeric.getNumericPart())); } private void validateSizeLineHeight(CssCompositeValueNode composite) { if (composite.getValues().size() != 2) { reportError(TOO_MANY.get(FontProperty.LINE_HEIGHT), getSourceCodeLocation(composite)); } } private void reportError(String message, SourceCodeLocation location) { errorManager.report(new GssError(message, location)); } private boolean validateSplitPoint( SourceCodeLocation loc, HashMap<CssNode, Integer> lexicalOrder, Iterable<CssCompositeValueNode> sizeLineHeights, Iterable<CssValueNode> sizes) { CssCompositeValueNode secondSLH = Iterables.get(sizeLineHeights, 1, null); if (secondSLH != null) { reportError(TOO_MANY.get(FontProperty.LINE_HEIGHT), getSourceCodeLocation(secondSLH)); return false; } CssCompositeValueNode slashy = Iterables.find( sizeLineHeights, new Predicate<CssCompositeValueNode>() { @Override public boolean apply(CssCompositeValueNode n) { return n.getValues().size() != 2; } }, null); if (slashy != null) { reportError(TOO_MANY.get(FontProperty.LINE_HEIGHT), getSourceCodeLocation(slashy)); } if (Iterables.isEmpty(sizes)) { if (mode == InputMode.CSS) { reportError(SIZE_AND_FAMILY_REQUIRED, loc); } return false; } if (Iterables.get(sizes, 1, null) != null) { reportError(TOO_MANY.get(FontProperty.SIZE), getSourceCodeLocation( max(sizes, Functions.forMap(lexicalOrder)))); return false; } return true; } private void validatePrefix( Iterable<CssValueNode> prefix) { CssValueNode tooMuch = Iterables.get(prefix, 3, null); if (tooMuch != null) { reportError(TOO_MANY_PRE_SIZE, getSourceCodeLocation(tooMuch)); } } private void validateProperties( Iterable<CssValueNode> prefix, final Map<CssValueNode, FontProperty> classified) { final List<CssValueNode> normals = Lists.newLinkedList(); for (CssValueNode i : prefix) { if (!classified.containsKey(i) && NORMAL.equals(i.getValue())) { normals.add(i); } } if (normals.size() > 3) { errorManager.report( new GssError( TOO_MANY_NORMALS, getSourceCodeLocation(normals.get(normals.size() - 1)))); } HashSet<FontProperty> properties = Sets.newHashSet(); for (Map.Entry<CssValueNode, FontProperty> p : classified.entrySet()) { if (!properties.add(p.getValue())) { reportError(TOO_MANY.get(p.getValue()), getSourceCodeLocation(p.getKey())); } } if (mode == InputMode.CSS) { CssValueNode interloper = Iterables.find( prefix, new Predicate<CssValueNode>() { @Override public boolean apply(CssValueNode n) { return !classified.containsKey(n) && !normals.contains(n); } }, null); if (interloper != null) { reportError(PRE_SIZE_INTERLOPER_SIZE, getSourceCodeLocation(interloper)); } } } private CssPropertyValueNode rebuildFont( Iterable<CssValueNode> prefix, CssValueNode splitPoint, Iterable<CssValueNode> families, CssPriorityNode priority, final Map<CssValueNode, FontProperty> properties, CssPropertyValueNode n) { TreeMap<FontProperty, CssValueNode> parts = Maps.newTreeMap(); for (Map.Entry<CssValueNode, FontProperty> p : properties.entrySet()) { parts.put(p.getValue(), p.getKey()); } List<CssValueNode> preFamily = Lists.newArrayList(); Iterables.addAll(preFamily, prefix); if (parts.containsKey(FontProperty.SIZE)) { preFamily.add(parts.get(FontProperty.SIZE)); } if (parts.containsKey(FontProperty.LINE_HEIGHT)) { CssValueNode lineHeight = parts.get(FontProperty.LINE_HEIGHT); preFamily.add(new CssLiteralNode("/", getSourceCodeLocation(lineHeight))); preFamily.add(lineHeight); } List<CssValueNode> tail = Iterables.isEmpty(families) ? ImmutableList.<CssValueNode>of() : ImmutableList.<CssValueNode>of(reparseFamilies( families, getSourceCodeLocation(Iterables.get(families, 0)))); ImmutableList.Builder<CssValueNode> resultNodes = ImmutableList.builder(); resultNodes.addAll(Iterables.concat(preFamily, tail)); if (priority != null) { resultNodes.add(priority); } CssPropertyValueNode result = new CssPropertyValueNode(resultNodes.build()); return result.deepCopy(); } private CssCompositeValueNode reparseFamilies( Iterable<CssValueNode> families, SourceCodeLocation loc) { // they will be a comma-delimited sequence of (strings | id-sequences) List<CssValueNode> alternatives = Lists.newArrayList(); List<CssCommentNode> commentsOnAlternatives = Lists.newArrayList(); for (CssValueNode i : families) { if (i instanceof CssCompositeValueNode) { CssCompositeValueNode segment = (CssCompositeValueNode) i; if (segment.getValues().size() == 0) { continue; } CssValueNode first = Iterables.getFirst(segment.getValues(), null); collect(alternatives, first); Iterable<CssValueNode> rest = Iterables.skip(segment.getValues(), 1); Iterables.addAll(alternatives, rest); for (CssNode j : rest) { for (CssCommentNode c : j.getComments()) { commentsOnAlternatives.add(c); } } } else { collect(alternatives, i); } } CssCompositeValueNode result = new CssCompositeValueNode( alternatives, CssCompositeValueNode.Operator.COMMA, loc); for (CssCommentNode c : commentsOnAlternatives) { result.appendComment(c); } return result; } private CssPropertyValueNode reparseFontFamily(CssPropertyValueNode n) { if (n.numChildren() == 0) { return n.deepCopy(); } if (n.numChildren() == 1 && INHERIT.equals(n.getChildAt(0).getValue())) { return n.deepCopy(); } CssPriorityNode priority = getPriority(n); // deal with alternatives CssCompositeValueNode altNode = reparseFamilies( takeUntil(n.childIterable(), priority), getSourceCodeLocation(n)); ImmutableList.Builder<CssValueNode> result = ImmutableList.builder(); result.add(altNode); if (priority != null) { result.add(priority); } return new CssPropertyValueNode(result.build()); } /** * Agglomerate each id sequence into a space-separated literal value, * leaving strings as they were and fixing up comments and * SourceCodeLocations as best we can. */ private void collect(List<CssValueNode> alternatives, CssValueNode item) { if (isString(item)) { alternatives.add(item); } else { CssValueNode stump; if (alternatives.size() > 0 && !isString(alternatives.get(alternatives.size() - 1))) { // concatenate onto previous node stump = alternatives.get(alternatives.size() - 1); stump.setValue(stump.getValue() + " "); } else { // start a new node stump = new CssLiteralNode("", getSourceCodeLocation(item)); alternatives.add(stump); } stump.setValue(stump.getValue() + item.getValue()); for (CssCommentNode c : item.getComments()) { stump.appendComment(c); } } } private boolean isString(CssValueNode n) { return n instanceof CssStringNode; } private static SourceCodeLocation getSourceCodeLocation(CssNode n) { n = Iterables.find(n.ancestors(), new Predicate<CssNode>() { @Override public boolean apply(CssNode n) { return n.getSourceCodeLocation() != null; } }, null); return n != null ? n.getSourceCodeLocation() : new SourceCodeLocation( new SourceCode(null, "x"), 1, 1, 1, 1, 1, 1); } /** * Computes the prefix of {@code xs} for which {@code p} holds. */ private <T> Iterable<T> takeWhile( final Iterable<T> xs, final Predicate<? super T> p) { return new Iterable<T>() { @Override public Iterator<T> iterator() { return new UnmodifiableIterator<T>() { Iterator<T> xsi = xs.iterator(); boolean validT = false; T t; { next(); } @Override public boolean hasNext() { return validT; } @Override public T next() { T result = t; if (xsi.hasNext()) { t = xsi.next(); validT = p.apply(t); } else { validT = false; } return result; } }; } }; } /** * Computes the suffix of {@code xs} starting at the first node for which * {@code p} fails. * * {@code Iterables.concat(takeWhile(xs, p), dropWhile(xs, p)) = xs} */ private <T> Iterable<T> dropWhile( final Iterable<T> xs, final Predicate<? super T> p) { return new Iterable<T>() { @Override public Iterator<T> iterator() { PeekingIterator<T> xsi = Iterators.peekingIterator(xs.iterator()); while (xsi.hasNext()) { if (p.apply(xsi.peek())) { break; } xsi.next(); } return xsi; } }; } /** * Returns an element of {@code xs} whose image under {@code f} is * maximal. */ private <T, U extends Comparable<U>> T max( Iterable<T> xs, Function<? super T, U> f) { T result = Iterables.getFirst(xs, null); U extreme = f.apply(result); for (T x : xs) { U u = f.apply(x); if (extreme.compareTo(u) < 0) { result = x; extreme = u; } } return result; } /** * Filter to elements of type {@code T} designated by {@code ct} * and cast the result. */ private <T> Iterable<T> extractByType( final Class<T> ct, Iterable<? super T> xs) { return Iterables.transform( Iterables.filter(xs, Predicates.instanceOf(ct)), new Function<Object, T>() { @Override public T apply(Object x) { return ct.cast(x); } }); } @Override public void runPass() { visitController.startVisit(this); } }