Skip to content

Commit c8166e1

Browse files
jhaygood86claude
authored andcommitted
CSS engine: sync font-palette, @font-palette-values and palette-mix()
From PeachPDF v0.9.6 (upstream 2bd95e9). - FontPaletteValuesRule / IFontPaletteValuesRule and their descriptor table (font-family / base-palette / override-colors, stored raw) - The font-palette property and its value converter - PaletteMixGrammar: the palette-mix() grammar, sharing colour-space and hue-direction classification with ColorInterpolationMethodGrammar so the two functions cannot disagree about which spaces are valid Parse-only: there is no palette resolver, and the font backend has no concept of a CPAL palette, so these declarations cascade and are then ignored. The two records in PaletteMixGrammar become hand-written classes for C# 7.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 71e7f09 commit c8166e1

5 files changed

Lines changed: 330 additions & 0 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
using System.Collections.Generic;
2+
3+
namespace TheArtOfDev.HtmlRenderer.Core.CssEngine
4+
{
5+
/// <summary>One operand of a <c>palette-mix()</c>: a palette reference and an optional mixing percentage.</summary>
6+
internal sealed class PaletteMixOperand
7+
{
8+
public PaletteMixOperand(string palette, double? percentage)
9+
{
10+
Palette = palette;
11+
Percentage = percentage;
12+
}
13+
14+
public string Palette { get; }
15+
public double? Percentage { get; }
16+
}
17+
18+
/// <summary>
19+
/// A parsed <c>palette-mix()</c> value (CSS Fonts Module Level 4). The two operands are blended per CPAL
20+
/// entry in <see cref="ColorSpace"/> (with an optional polar <see cref="HueMethod"/>), weighted by the
21+
/// operand percentages.
22+
/// </summary>
23+
internal sealed class ParsedPaletteMix
24+
{
25+
public ParsedPaletteMix(string colorSpace, string hueMethod, PaletteMixOperand first, PaletteMixOperand second)
26+
{
27+
ColorSpace = colorSpace;
28+
HueMethod = hueMethod;
29+
First = first;
30+
Second = second;
31+
}
32+
33+
public string ColorSpace { get; }
34+
public string HueMethod { get; }
35+
public PaletteMixOperand First { get; }
36+
public PaletteMixOperand Second { get; }
37+
}
38+
39+
/// <summary>
40+
/// The single, shared grammar for the CSS <c>palette-mix()</c> function
41+
/// (<a href="https://www.w3.org/TR/css-fonts-4/#font-palette-values">CSS Fonts 4</a>):
42+
/// <code>palette-mix( &lt;color-interpolation-method&gt; , [ [ normal | light | dark | &lt;palette-identifier&gt; ] &lt;percentage&gt;? ]#{2} )</code>.
43+
/// Used by both Layer A (the <c>font-palette</c> value converter, which drops a malformed value at parse
44+
/// time) and Layer B (<c>ActualFontPalette</c> resolution), so the two layers cannot disagree. Color-space
45+
/// membership is owned by <see cref="ColorInterpolationMethodGrammar"/> (its predicates classify the space
46+
/// and hue direction here), keeping a single source of truth for which spaces are valid.
47+
/// </summary>
48+
internal static class PaletteMixGrammar
49+
{
50+
private const string FunctionName = "palette-mix";
51+
private const string In = "in";
52+
private const string Hue = "hue";
53+
54+
public static ParsedPaletteMix TryParse(IReadOnlyList<Token> value)
55+
{
56+
var fn = SinglePaletteMixFunction(value);
57+
if (fn == null)
58+
return null;
59+
60+
var groups = SplitTopLevelComma(new List<Token>(fn.ArgumentTokens));
61+
if (groups.Count != 3)
62+
return null;
63+
64+
if (!TryParseMethod(groups[0], out var space, out var hue) ||
65+
!TryParseOperand(groups[1], out var first) ||
66+
!TryParseOperand(groups[2], out var second))
67+
return null;
68+
69+
// Both percentages explicitly zero has no valid interpretation (mirrors color-mix()).
70+
if (first.Percentage == 0 && second.Percentage == 0)
71+
return null;
72+
73+
return new ParsedPaletteMix(space, hue, first, second);
74+
}
75+
76+
private static FunctionToken SinglePaletteMixFunction(IReadOnlyList<Token> value)
77+
{
78+
FunctionToken found = null;
79+
foreach (var token in value)
80+
{
81+
if (token.Type == TokenType.Whitespace)
82+
continue;
83+
var fn = token as FunctionToken;
84+
if (found != null || fn == null || !fn.Data.Isi(FunctionName))
85+
return null;
86+
found = fn;
87+
}
88+
89+
return found;
90+
}
91+
92+
// in <color-space> [ <hue-interpolation-method> hue ]? (the hue method only after a polar space)
93+
private static bool TryParseMethod(List<Token> tokens, out string space, out string hue)
94+
{
95+
space = null;
96+
hue = null;
97+
98+
int i = 0;
99+
100+
if (i >= tokens.Count || !IsIdent(tokens[i], out var inWord) || !inWord.Isi(In))
101+
return false;
102+
i++;
103+
104+
if (i >= tokens.Count || !IsIdent(tokens[i], out var spaceName) ||
105+
!ColorInterpolationMethodGrammar.IsColorSpace(spaceName))
106+
return false;
107+
space = spaceName;
108+
i++;
109+
110+
if (i < tokens.Count)
111+
{
112+
// A trailing "<dir> hue" is valid only for a polar color space.
113+
if (!ColorInterpolationMethodGrammar.IsPolarColorSpace(spaceName))
114+
return false;
115+
if (!IsIdent(tokens[i], out var dir) || !ColorInterpolationMethodGrammar.IsHueDirection(dir))
116+
return false;
117+
i++;
118+
if (i >= tokens.Count || !IsIdent(tokens[i], out var hueWord) || !hueWord.Isi(Hue))
119+
return false;
120+
hue = dir;
121+
i++;
122+
}
123+
124+
return i == tokens.Count;
125+
}
126+
127+
// [ normal | light | dark | <dashed-ident> ] <percentage>?
128+
private static bool TryParseOperand(List<Token> tokens, out PaletteMixOperand operand)
129+
{
130+
operand = null;
131+
132+
if (tokens.Count == 0 || tokens.Count > 2)
133+
return false;
134+
135+
if (!IsIdent(tokens[0], out var palette) || !IsPaletteIdentifier(palette))
136+
return false;
137+
138+
double? percentage = null;
139+
if (tokens.Count == 2)
140+
{
141+
var pct = tokens[1] as UnitToken;
142+
if (pct == null || pct.Type != TokenType.Percentage)
143+
return false;
144+
percentage = pct.Value;
145+
}
146+
147+
operand = new PaletteMixOperand(palette, percentage);
148+
return true;
149+
}
150+
151+
private static bool IsPaletteIdentifier(string ident) =>
152+
ident.Isi(Keywords.Normal) || ident.Isi("light") || ident.Isi("dark") ||
153+
ident.StartsWith("--", System.StringComparison.Ordinal);
154+
155+
// Splits the argument tokens on top-level commas, dropping whitespace (insignificant in this grammar).
156+
private static List<List<Token>> SplitTopLevelComma(List<Token> tokens)
157+
{
158+
var groups = new List<List<Token>> { new List<Token>() };
159+
foreach (var token in tokens)
160+
{
161+
if (token.Type == TokenType.Whitespace)
162+
continue;
163+
if (token.Type == TokenType.Comma)
164+
groups.Add(new List<Token>());
165+
else
166+
groups[groups.Count - 1].Add(token);
167+
}
168+
169+
return groups;
170+
}
171+
172+
private static bool IsIdent(Token token, out string data)
173+
{
174+
if (token.Type == TokenType.Ident)
175+
{
176+
data = token.Data;
177+
return true;
178+
}
179+
180+
data = string.Empty;
181+
return false;
182+
}
183+
}
184+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System.IO;
2+
using System.Linq;
3+
4+
namespace TheArtOfDev.HtmlRenderer.Core.CssEngine
5+
{
6+
/// <summary>
7+
/// A <c>@font-palette-values</c> at-rule (CSS Fonts Module Level 4): associates a named palette override
8+
/// (a dashed-ident such as <c>--my-palette</c>) with a font family, selecting a <c>base-palette</c> and
9+
/// optionally overriding individual colors (<c>override-colors</c>). The descriptor block is stored like
10+
/// <see cref="PropertyRule"/>; the name is captured from the prelude like <see cref="KeyframesRule.Name"/>.
11+
/// </summary>
12+
internal sealed class FontPaletteValuesRule : DeclarationRule, IFontPaletteValuesRule
13+
{
14+
internal FontPaletteValuesRule(StylesheetParser parser)
15+
: base(RuleType.FontPaletteValues, RuleNames.FontPaletteValues, parser)
16+
{
17+
}
18+
19+
protected override Property CreateNewProperty(string name)
20+
{
21+
return PropertyFactory.Instance.CreateFontPaletteDescriptor(name);
22+
}
23+
24+
protected override void ReplaceWith(IRule rule)
25+
{
26+
if (rule is FontPaletteValuesRule other) Name = other.Name;
27+
base.ReplaceWith(rule);
28+
}
29+
30+
public string Name { get; set; }
31+
32+
public string Family
33+
{
34+
get => GetValue(PropertyNames.FontFamily);
35+
set => SetValue(PropertyNames.FontFamily, value);
36+
}
37+
38+
public string BasePalette
39+
{
40+
get => GetValue(PropertyNames.BasePalette);
41+
set => SetValue(PropertyNames.BasePalette, value);
42+
}
43+
44+
public string OverrideColors
45+
{
46+
get => GetValue(PropertyNames.OverrideColors);
47+
set => SetValue(PropertyNames.OverrideColors, value);
48+
}
49+
50+
public override void ToCss(TextWriter writer, IStyleFormatter formatter)
51+
{
52+
var declarations = formatter.Declarations(Declarations.Where(d => d.HasValue).Select(d => d.ToCss(formatter)));
53+
writer.Write(string.Concat("@font-palette-values ", Name, " { ", declarations, " }"));
54+
}
55+
}
56+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
namespace TheArtOfDev.HtmlRenderer.Core.CssEngine
2+
{
3+
/// <summary>
4+
/// A palette override declared with a <c>@font-palette-values</c> at-rule (CSS Fonts Module Level 4).
5+
/// Exposes the prelude name and the three descriptors.
6+
/// </summary>
7+
internal interface IFontPaletteValuesRule : IRule, IProperties
8+
{
9+
/// <summary>The palette name, a dashed-ident referenced by <c>font-palette: &lt;dashed-ident&gt;</c>, e.g. <c>--my-palette</c>.</summary>
10+
string Name { get; set; }
11+
12+
/// <summary>The raw <c>font-family</c> descriptor value (the family this palette applies to).</summary>
13+
string Family { get; set; }
14+
15+
/// <summary>The raw <c>base-palette</c> descriptor value (<c>&lt;integer&gt; | light | dark</c>).</summary>
16+
string BasePalette { get; set; }
17+
18+
/// <summary>The raw <c>override-colors</c> descriptor value (a comma list of <c>&lt;index&gt; &lt;color&gt;</c> pairs).</summary>
19+
string OverrideColors { get; set; }
20+
}
21+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace TheArtOfDev.HtmlRenderer.Core.CssEngine
2+
{
3+
/// <summary>
4+
/// The <c>font-palette</c> property (CSS Fonts Module Level 4): selects a color palette for a COLR/CPAL
5+
/// color font. Inherited. The value grammar (<c>normal | light | dark | &lt;dashed-ident&gt; | palette-mix()</c>)
6+
/// is validated by <see cref="FontPaletteValueConverter"/>; the authored text is preserved for the paint-time
7+
/// resolver (<c>CssBoxProperties.ActualFontPalette</c>).
8+
/// </summary>
9+
internal sealed class FontPaletteProperty : Property
10+
{
11+
private static readonly IValueConverter StyleConverter = new FontPaletteValueConverter().OrDefault();
12+
13+
internal FontPaletteProperty()
14+
: base(PropertyNames.FontPalette, PropertyFlags.Inherited)
15+
{
16+
}
17+
18+
internal override IValueConverter Converter => StyleConverter;
19+
}
20+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
4+
namespace TheArtOfDev.HtmlRenderer.Core.CssEngine
5+
{
6+
/// <summary>
7+
/// Validates a <c>font-palette</c> value (CSS Fonts Module Level 4): the keywords <c>normal</c>/
8+
/// <c>light</c>/<c>dark</c>, a <c>&lt;dashed-ident&gt;</c> naming an <c>@font-palette-values</c> rule, or a
9+
/// <c>palette-mix()</c> function (validated through the shared <see cref="PaletteMixGrammar"/>). Anything
10+
/// else is dropped at parse time. The authored value text is preserved verbatim (Layer B re-tokenizes and
11+
/// resolves it in <c>ActualFontPalette</c>), mirroring <see cref="ClipPathValueConverter"/>.
12+
/// </summary>
13+
internal sealed class FontPaletteValueConverter : IValueConverter
14+
{
15+
public IPropertyValue Convert(IEnumerable<Token> value)
16+
{
17+
var tokens = value.Where(t => t.Type != TokenType.Whitespace).ToArray();
18+
19+
bool valid = tokens.Length == 1 && tokens[0].Type == TokenType.Ident
20+
? IsKeywordOrDashedIdent(tokens[0].Data)
21+
: PaletteMixGrammar.TryParse(tokens) != null;
22+
23+
return valid ? new FontPaletteValue(value) : null;
24+
}
25+
26+
public IPropertyValue Construct(Property[] properties)
27+
{
28+
return properties.Guard<FontPaletteValue>();
29+
}
30+
31+
private static bool IsKeywordOrDashedIdent(string ident) =>
32+
ident.Isi(Keywords.Normal) || ident.Isi("light") || ident.Isi("dark") ||
33+
ident.StartsWith("--", System.StringComparison.Ordinal);
34+
35+
private sealed class FontPaletteValue : IPropertyValue
36+
{
37+
public FontPaletteValue(IEnumerable<Token> tokens)
38+
{
39+
Original = new TokenValue(tokens);
40+
}
41+
42+
public string CssText => Original.Text;
43+
44+
public TokenValue Original { get; }
45+
46+
public TokenValue ExtractFor(string name) => Original;
47+
}
48+
}
49+
}

0 commit comments

Comments
 (0)