Skip to content

Commit 27a1638

Browse files
jhaygood86claude
authored andcommitted
CSS engine: sync CSS Nesting, :where() and the parser conformance fixes
From PeachPDF v0.9.6 (upstream 6f1cd92, 4f2c757, 7cf1855, 2809199, 2f27b52). - CSS Nesting: StyleRule gains a NestedRules list (deliberately separate from Children, so Selector/Style lookups and ToCss are unaffected) and the composer resolves a nested rule's selector against its parent at parse time, so nested selectors reach the cascade already absolutized - :where(): parsed as a MatchesSelector like :is(), but contributing zero specificity - SelectorConstructor: pseudo-element compound wrapping, and An+B handling that accepts the spaced `3n + 1` form while still rejecting `3n + -6` - Lexer/LexerBase: CSS Syntax 4.3.4 hash-token conformance, U+ wildcard-range fixes, and a RewindTo the nesting look-ahead needs A nested rule is not visible through Stylesheet.Rules, so CssData has to recurse into NestedRules for these to apply - that is the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c8166e1 commit 27a1638

9 files changed

Lines changed: 470 additions & 28 deletions

File tree

‎Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ internal sealed class PropertyFactory
3232
// UnknownProperty (Converters.Any) and validated later against the syntax string.
3333
private readonly Dictionary<string, LonghandCreator> _propertyDescriptors;
3434

35+
// @font-palette-values descriptors (font-family / base-palette / override-colors). Stored raw via
36+
// UnknownProperty and validated later when the palette is resolved.
37+
private readonly Dictionary<string, LonghandCreator> _fontPaletteDescriptors;
38+
3539
private readonly Dictionary<string, LonghandCreator> _longhands;
3640

3741
private readonly Dictionary<string, string[]> _mappings;
@@ -230,6 +234,7 @@ private PropertyFactory()
230234
PropertyNames.FontWeight,
231235
PropertyNames.LineHeight);
232236
AddLonghand(PropertyNames.FontFamily, () => new FontFamilyProperty(), false, true);
237+
AddLonghand(PropertyNames.FontPalette, () => new FontPaletteProperty());
233238
AddLonghand(PropertyNames.FontSize, () => new FontSizeProperty(), true);
234239
AddLonghand(PropertyNames.FontSizeAdjust, () => new FontSizeAdjustProperty(), true);
235240
AddLonghand(PropertyNames.FontStyle, () => new FontStyleProperty(), false, true);
@@ -534,6 +539,13 @@ private PropertyFactory()
534539
{ PropertyNames.Inherits, () => new UnknownProperty(PropertyNames.Inherits) },
535540
};
536541

542+
_fontPaletteDescriptors = new Dictionary<string, LonghandCreator>(StringComparer.OrdinalIgnoreCase)
543+
{
544+
{ PropertyNames.FontFamily, () => new UnknownProperty(PropertyNames.FontFamily) },
545+
{ PropertyNames.BasePalette, () => new UnknownProperty(PropertyNames.BasePalette) },
546+
{ PropertyNames.OverrideColors, () => new UnknownProperty(PropertyNames.OverrideColors) },
547+
};
548+
537549
_longhands = _longhandsBuilder;
538550
_mappings = _mappingsBuilder;
539551
_shorthands = _shorthandsBuilder;
@@ -589,6 +601,11 @@ public Property CreatePropertyDescriptor(string name)
589601
return _propertyDescriptors.TryGetValue(name, out var propertyCreator) ? propertyCreator() : null;
590602
}
591603

604+
public Property CreateFontPaletteDescriptor(string name)
605+
{
606+
return _fontPaletteDescriptors.TryGetValue(name, out var propertyCreator) ? propertyCreator() : null;
607+
}
608+
592609
public Property CreateViewport(string name)
593610
{
594611
var feature = MediaFeatureFactory.Instance.Create(name);

‎Source/HtmlRenderer/Core/CssEngine/Parser/Lexer.cs‎

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public Token Get()
2727
return Data(current);
2828
}
2929

30+
3031
internal void RaiseErrorOccurred(ParseError error, TextPosition position)
3132
{
3233
var handler = Error;
@@ -319,14 +320,44 @@ private Token StringSingleQuote()
319320
private Token ColorLiteral()
320321
{
321322
var current = GetNext();
322-
while (current.IsHex())
323+
324+
// '#' not followed by a name code point or a valid escape is a plain delimiter (CSS Syntax §4.3.4).
325+
if (!current.IsName() && !IsValidEscape(current))
323326
{
324-
StringBuffer.Append(current);
325-
current = GetNext();
327+
Back();
328+
return NewDelimiter(Symbols.Num);
326329
}
327330

328331
Back();
329-
return NewColor(FlushBuffer());
332+
333+
// A '#' always begins a <hash-token>, consuming a whole <name> (CSS Syntax §4.3.4). Classify it as
334+
// a color literal only when the name is entirely hex digits (e.g. "#f00"); otherwise keep it as an
335+
// id hash-token (e.g. "#hero" — an id-selector inside element()), instead of truncating at the
336+
// first non-hex character (which turned "#hero" into an empty color plus a stray "hero" ident).
337+
var allHex = true;
338+
339+
while (true)
340+
{
341+
current = GetNext();
342+
343+
if (current.IsName())
344+
{
345+
allHex = allHex && current.IsHex();
346+
StringBuffer.Append(current);
347+
}
348+
else if (IsValidEscape(current))
349+
{
350+
current = GetNext();
351+
StringBuffer.Append(ConsumeEscape(current));
352+
allHex = false;
353+
}
354+
else
355+
{
356+
Back();
357+
var text = FlushBuffer();
358+
return allHex ? NewColor(text) : NewHash(text);
359+
}
360+
}
330361
}
331362

332363
private Token HashStart()
@@ -910,9 +941,18 @@ private Token UnicodeRange(char current)
910941
current = GetNext();
911942
}
912943

913-
if (StringBuffer.Length != 6)
944+
// Fewer than 6 hex digits may be followed by '?' wildcards (which pad the value and are
945+
// mutually exclusive with the start-end range form) OR - like a full 6-digit start - by a
946+
// '-<hex>' range end (e.g. U+41-5A). Only the wildcard form is handled here; a '-' falls
947+
// through to the shared range handling below.
948+
if (StringBuffer.Length != 6 && current == Symbols.QuestionMark)
914949
{
915-
for (var i = 0; i < 6 - StringBuffer.Length; i++)
950+
// The wildcard budget must be captured up front: appending to StringBuffer inside the loop
951+
// would otherwise shrink a "6 - StringBuffer.Length" bound on every iteration, so only half
952+
// the available wildcards were ever consumed (U+?????? stopped after three).
953+
var wildcards = 6 - StringBuffer.Length;
954+
955+
for (var i = 0; i < wildcards; i++)
916956
{
917957
if (current != Symbols.QuestionMark)
918958
{

‎Source/HtmlRenderer/Core/CssEngine/Parser/LexerBase.cs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,22 @@ public TextPosition GetCurrentPosition()
4242
return new TextPosition(Line, Column, Position);
4343
}
4444

45+
/// <summary>
46+
/// Repositions the lexer so the next token is re-lexed from character index
47+
/// <paramref name="sourceIndex"/> (a raw <see cref="Source"/> index, as returned by
48+
/// <see cref="InsertionPoint"/>). Unlike setting <see cref="InsertionPoint"/> (whose
49+
/// <c>BackNative</c> loop is not a faithful inverse across <c>\r\n</c> normalization), this
50+
/// restores the character stream exactly — line/column tracking is not rewound (it only affects
51+
/// reported positions, never token content). Used by <see cref="StylesheetComposer"/> to rewind a
52+
/// CSS-Nesting classification look-ahead so a declaration re-lexes cleanly in value mode.
53+
/// </summary>
54+
public void RewindTo(int sourceIndex)
55+
{
56+
Source.Index = sourceIndex;
57+
// Non-EOF so the next Advance() actually reads Source[sourceIndex] rather than short-circuiting.
58+
Current = Symbols.Null;
59+
}
60+
4561
protected char SkipSpaces()
4662
{
4763
var c = GetNext();

‎Source/HtmlRenderer/Core/CssEngine/Parser/SelectorConstructor.cs‎

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ private enum State : byte
5656
{PseudoClassNames.Has, ctx => new HasFunctionState(ctx)},
5757
{PseudoClassNames.Matches, ctx => new MatchesFunctionState(ctx, PseudoClassNames.Matches)},
5858
{PseudoClassNames.Is, ctx => new MatchesFunctionState(ctx, PseudoClassNames.Is)},
59+
{PseudoClassNames.Where, ctx => new MatchesFunctionState(ctx, PseudoClassNames.Where)},
5960
{PseudoClassNames.HostContext, ctx => new HostContextFunctionState(ctx)}
6061
};
6162

@@ -375,7 +376,7 @@ private void Insert(ISelector selector)
375376

376377
var combinator = GetCombinator();
377378
_complex.AppendSelector(_temp, combinator);
378-
_temp = selector;
379+
_temp = WrapPseudoElement(selector);
379380
}
380381
}
381382
else
@@ -385,6 +386,27 @@ private void Insert(ISelector selector)
385386
}
386387
}
387388

389+
// CssData.DoesSelectorMatch(CompoundSelector, box) is the only place that synthesizes
390+
// ::before/::after (and ::marker) child boxes as a match side effect, and it only runs for a
391+
// pseudo-element that's the last member of a CompoundSelector. A pseudo-element preceded by a
392+
// combinator (e.g. "E :after", with whitespace) would otherwise become a bare, unwrapped _temp
393+
// here - which CssData's ComplexSelector subject-match dispatches straight to the plain
394+
// DoesSelectorMatch(PseudoElementSelector, box) overload (which only checks whether the flag is
395+
// already set, never sets it), so the pseudo-element box would never actually get created.
396+
// Wrapping it in a single-member CompoundSelector as soon as it becomes a standalone selector
397+
// routes it through the one, already-correct synthesis path instead of needing a second
398+
// implementation of the same logic in CssData.
399+
//
400+
// Deliberately NOT applied in the other "_temp == null" branch above (a pseudo-element with no
401+
// combinator at all, i.e. it's the entire selector - "*:before" written as bare ":before"): that
402+
// shape has no ancestor restriction, so synthesizing on every match would mean synthesizing on
403+
// literally every element in the document. The UA stylesheet's own
404+
// ":before, :after { white-space: pre-line }" rule (CssDefaults.cs) is exactly this shape and
405+
// relies on the existing non-synthesizing behavior - it exists to style already-synthesized
406+
// pseudo-element boxes, not to create one on every element.
407+
private static ISelector WrapPseudoElement(ISelector selector) =>
408+
selector is PseudoElementSelector ? new CompoundSelector { selector } : selector;
409+
388410
private Combinator GetCombinator()
389411
{
390412
while (_combinators.Count > 1 && _combinators.Peek() == Combinator.Descendent) _combinators.Pop();
@@ -478,7 +500,6 @@ private ISelector GetPseudoFunction(FunctionToken arguments)
478500
}
479501

480502
return null;
481-
482503
}
483504

484505
private SelectorConstructor CreateChild()
@@ -682,7 +703,7 @@ private sealed class ContainsFunctionState : FunctionState
682703

683704
protected override bool OnToken(Token token)
684705
{
685-
if (token.Type == TokenType.Ident || token.Type == TokenType.String)
706+
if ((token.Type == TokenType.Ident || token.Type == TokenType.String))
686707
{
687708
_value = token.Data;
688709
}
@@ -787,16 +808,12 @@ protected override bool OnToken(Token token)
787808
{
788809
switch (_state)
789810
{
790-
case ParseState.Initial:
791-
return OnInitial(token);
792-
case ParseState.AfterInitialSign:
793-
return OnAfterInitialSign(token);
794-
case ParseState.Offset:
795-
return OnOffset(token);
796-
case ParseState.BeforeOf:
797-
return OnBeforeOf(token);
798-
default:
799-
return OnAfter(token);
811+
case ParseState.Initial: return OnInitial(token);
812+
case ParseState.AfterInitialSign: return OnAfterInitialSign(token);
813+
case ParseState.Offset: return OnOffset(token);
814+
case ParseState.AfterOffsetSign: return OnAfterOffsetSign(token);
815+
case ParseState.BeforeOf: return OnBeforeOf(token);
816+
default: return OnAfter(token);
800817
}
801818
}
802819

@@ -866,6 +883,16 @@ private bool OnOffset(Token token)
866883
{
867884
case TokenType.Whitespace:
868885
return false;
886+
case TokenType.Delim when token.Data.IsOneOf("+", "-"):
887+
// The An+B offset's sign is a standalone delim token when whitespace separates it
888+
// from B - the "<n-dimension> ['+' | '-'] <signless-integer>" production of
889+
// <a-n-plus-b> (CSS Syntax 3 §6.2); §6.1 gives `3n + 1` as a valid example. The
890+
// compact form `10n+1` instead arrives as a single signed <number>, handled by the
891+
// Number case below. Without this branch a spaced sign fell through to OnBeforeOf
892+
// and invalidated the selector.
893+
_sign = token.Data == "-" ? -1 : 1;
894+
_state = ParseState.AfterOffsetSign;
895+
return false;
869896
case TokenType.Number:
870897
_valid = _valid && ((NumberToken)token).IsInteger && int.TryParse(token.Data, out _offset);
871898
_offset *= _sign;
@@ -876,6 +903,26 @@ private bool OnOffset(Token token)
876903
}
877904
}
878905

906+
private bool OnAfterOffsetSign(Token token)
907+
{
908+
switch (token.Type)
909+
{
910+
case TokenType.Whitespace:
911+
return false;
912+
case TokenType.Number when !token.Data.StartsWith("+", StringComparison.Ordinal) && !token.Data.StartsWith("-", StringComparison.Ordinal):
913+
// The production requires a <signless-integer> here, defined as "a <number-token>
914+
// with its type flag set to integer, and no sign character" (CSS Syntax 3 §6.2), so
915+
// `10n + -1` and `10n + +1` are invalid - §6.1 lists `3n + -6` as an invalid example.
916+
_valid = _valid && ((NumberToken)token).IsInteger && int.TryParse(token.Data, out _offset);
917+
_offset *= _sign;
918+
_state = ParseState.BeforeOf;
919+
return false;
920+
default:
921+
_valid = false;
922+
return token.Type == TokenType.RoundBracketClose;
923+
}
924+
}
925+
879926
private bool OnInitial(Token token)
880927
{
881928
if (token.Type == TokenType.Whitespace) return false;
@@ -910,6 +957,7 @@ private enum ParseState : byte
910957
Initial,
911958
AfterInitialSign,
912959
Offset,
960+
AfterOffsetSign,
913961
BeforeOf,
914962
AfterOf
915963
}

0 commit comments

Comments
 (0)