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