Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
96 / 96
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
FontFaceAtRuleSanitizer
100.00% covered (success)
100.00%
96 / 96
100.00% covered (success)
100.00%
4 / 4
10
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 fontMatchData
100.00% covered (success)
100.00%
61 / 61
100.00% covered (success)
100.00%
1 / 1
2
 handlesRule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 doSanitize
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * @file
4 * @license https://opensource.org/licenses/Apache-2.0 Apache-2.0
5 */
6
7namespace Wikimedia\CSS\Sanitizer;
8
9use Wikimedia\CSS\Grammar\Alternative;
10use Wikimedia\CSS\Grammar\FunctionMatcher;
11use Wikimedia\CSS\Grammar\Juxtaposition;
12use Wikimedia\CSS\Grammar\KeywordMatcher;
13use Wikimedia\CSS\Grammar\MatcherFactory;
14use Wikimedia\CSS\Grammar\Quantifier;
15use Wikimedia\CSS\Grammar\TokenMatcher;
16use Wikimedia\CSS\Grammar\UnorderedGroup;
17use Wikimedia\CSS\Grammar\UrangeMatcher;
18use Wikimedia\CSS\Objects\AtRule;
19use Wikimedia\CSS\Objects\CSSObject;
20use Wikimedia\CSS\Objects\Rule;
21use Wikimedia\CSS\Objects\Token;
22use Wikimedia\CSS\Util;
23
24/**
25 * Sanitizes a CSS \@font-face rule
26 * @see https://www.w3.org/TR/2018/REC-css-fonts-3-20180920/#font-resources
27 */
28class FontFaceAtRuleSanitizer extends RuleSanitizer {
29
30    /** @var PropertySanitizer */
31    protected $propertySanitizer;
32
33    /**
34     * @param MatcherFactory $matcherFactory
35     */
36    public function __construct( MatcherFactory $matcherFactory ) {
37        $matchData = self::fontMatchData( $matcherFactory );
38
39        $this->propertySanitizer = new PropertySanitizer();
40        $this->propertySanitizer->setKnownProperties( [
41            'font-family' => $matchData['familyName'],
42            'src' => Quantifier::hash( new Alternative( [
43                new Juxtaposition( [
44                    $matcherFactory->url( 'font' ),
45                    Quantifier::optional(
46                        new FunctionMatcher( 'format', Quantifier::hash( $matcherFactory->string() ) )
47                    ),
48                ] ),
49                new FunctionMatcher( 'local', $matchData['familyName'] ),
50            ] ) ),
51            'font-style' => $matchData['font-style'],
52            'font-weight' => new Alternative( [
53                new KeywordMatcher( [ 'normal', 'bold' ] ), $matchData['numWeight']
54            ] ),
55            'font-stretch' => $matchData['font-stretch'],
56            'unicode-range' => Quantifier::hash( new UrangeMatcher() ),
57            'font-feature-settings' => $matchData['font-feature-settings'],
58        ] );
59    }
60
61    /**
62     * Get some shared data for font declaration matchers
63     * @param MatcherFactory $matcherFactory
64     * @return array
65     */
66    public static function fontMatchData( MatcherFactory $matcherFactory ) {
67        $ret = [
68            'familyName' => new Alternative( [
69                $matcherFactory->string(),
70                Quantifier::plus( $matcherFactory->ident() ),
71            ] ),
72            'numWeight' => new TokenMatcher( Token::T_NUMBER, static function ( Token $t ) {
73                return $t->typeFlag() === 'integer' && preg_match( '/^[1-9]00$/', $t->representation() );
74            } ),
75            'font-style' => new KeywordMatcher( [ 'normal', 'italic', 'oblique' ] ),
76            'font-stretch' => new KeywordMatcher( [
77                'normal', 'ultra-condensed', 'extra-condensed', 'condensed', 'semi-condensed', 'semi-expanded',
78                'expanded', 'extra-expanded', 'ultra-expanded'
79            ] ),
80            'font-feature-settings' => new Alternative( [
81                new KeywordMatcher( 'normal' ),
82                Quantifier::hash( new Juxtaposition( [
83                    new TokenMatcher( Token::T_STRING, static function ( Token $t ) {
84                        return preg_match( '/^[\x20-\x7e]{4}$/', $t->value() );
85                    } ),
86                    Quantifier::optional( new Alternative( [
87                        $matcherFactory->integer(),
88                        new KeywordMatcher( [ 'on', 'off' ] ),
89                    ] ) )
90                ] ) )
91            ] ),
92            'ligatures' => [
93                new KeywordMatcher( [ 'common-ligatures', 'no-common-ligatures' ] ),
94                new KeywordMatcher( [ 'discretionary-ligatures', 'no-discretionary-ligatures' ] ),
95                new KeywordMatcher( [ 'historical-ligatures', 'no-historical-ligatures' ] ),
96                new KeywordMatcher( [ 'contextual', 'no-contextual' ] )
97            ],
98            'capsKeywords' => [
99                'small-caps', 'all-small-caps', 'petite-caps', 'all-petite-caps', 'unicase', 'titling-caps'
100            ],
101            'numeric' => [
102                new KeywordMatcher( [ 'lining-nums', 'oldstyle-nums' ] ),
103                new KeywordMatcher( [ 'proportional-nums', 'tabular-nums' ] ),
104                new KeywordMatcher( [ 'diagonal-fractions', 'stacked-fractions' ] ),
105                new KeywordMatcher( 'ordinal' ),
106                new KeywordMatcher( 'slashed-zero' ),
107            ],
108            'eastAsian' => [
109                new KeywordMatcher( [ 'jis78', 'jis83', 'jis90', 'jis04', 'simplified', 'traditional' ] ),
110                new KeywordMatcher( [ 'full-width', 'proportional-width' ] ),
111                new KeywordMatcher( 'ruby' ),
112            ],
113            'positionKeywords' => [
114                'sub', 'super',
115            ],
116        ];
117        $ret['font-variant'] = new Alternative( [
118            new KeywordMatcher( [ 'normal', 'none' ] ),
119            UnorderedGroup::someOf( array_merge(
120                $ret['ligatures'],
121                [ new KeywordMatcher( $ret['capsKeywords'] ) ],
122                $ret['numeric'],
123                $ret['eastAsian'],
124                [ new KeywordMatcher( $ret['positionKeywords'] ) ]
125            ) )
126        ] );
127        return $ret;
128    }
129
130    /** @inheritDoc */
131    public function handlesRule( Rule $rule ) {
132        return $rule instanceof AtRule && !strcasecmp( $rule->getName(), 'font-face' );
133    }
134
135    /** @inheritDoc */
136    protected function doSanitize( CSSObject $object ) {
137        if ( !$object instanceof AtRule || !$this->handlesRule( $object ) ) {
138            $this->sanitizationError( 'expected-at-rule', $object, [ 'font-face' ] );
139            return null;
140        }
141
142        if ( $object->getBlock() === null ) {
143            $this->sanitizationError( 'at-rule-block-required', $object, [ 'font-face' ] );
144            return null;
145        }
146
147        // No non-whitespace prelude allowed
148        if ( Util::findFirstNonWhitespace( $object->getPrelude() ) ) {
149            $this->sanitizationError( 'invalid-font-face-at-rule', $object );
150            return null;
151        }
152
153        $ret = clone $object;
154        $this->fixPreludeWhitespace( $ret, false );
155        $this->sanitizeDeclarationBlock( $ret->getBlock(), $this->propertySanitizer );
156
157        return $ret;
158    }
159}