diff --git a/src/ExCSS.Tests/PropertyTests/PageRuleTests.cs b/src/ExCSS.Tests/PropertyTests/PageRuleTests.cs new file mode 100644 index 00000000..125a4350 --- /dev/null +++ b/src/ExCSS.Tests/PropertyTests/PageRuleTests.cs @@ -0,0 +1,69 @@ +using System.Linq; +using Xunit; + +namespace ExCSS.Tests.PropertyTests +{ + public class PageRuleTests : CssConstructionFunctions + { + private static PageRule ParsePage(string source) + { + var sheet = ParseStyleSheet(source); + return (PageRule)sheet.Rules.First(); + } + + [Theory] + // CSS Paged Media 3 4.1: an @page selector is a comma-separated list of "[]? [:]?" + // entries. + [InlineData("@page :left { margin: 1cm }", ":left")] + [InlineData("@page :first { margin: 1cm }", ":first")] + [InlineData("@page chapter { margin: 1cm }", "chapter")] + [InlineData("@page chapter:left { margin: 1cm }", "chapter:left")] + [InlineData("@page chapter1:left, chapter2:left { margin: 1cm }", "chapter1:left, chapter2:left")] + [InlineData("@page a, b, c { margin: 1cm }", "a, b, c")] + public void PageSelectorForms(string source, string expectedSelector) + { + var rule = ParsePage(source); + + Assert.Equal(RuleType.Page, rule.Type); + Assert.Equal(expectedSelector, rule.SelectorText); + } + + [Fact] + public void PageSelectorListEntriesAreExposed() + { + var rule = ParsePage("@page chapter1:left, chapter2:right { margin: 1cm }"); + var selector = Assert.IsType(rule.Selector); + + Assert.Equal(2, selector.Entries.Count); + Assert.Equal("chapter1", selector.Entries[0].Name); + Assert.Equal("left", selector.Entries[0].Pseudo); + Assert.Equal("chapter2", selector.Entries[1].Name); + Assert.Equal("right", selector.Entries[1].Pseudo); + } + + [Theory] + // The @page "size" descriptor (CSS Paged Media 3 6.3). + [InlineData("size: A4", "A4")] + [InlineData("size: letter", "letter")] + [InlineData("size: landscape", "landscape")] + [InlineData("size: 8.5in 11in", "8.5in 11in")] + [InlineData("size: 210mm", "210mm")] + public void PageSizeDescriptor(string declaration, string value) + { + var style = ParseDeclarations(declaration); + + Assert.Equal(value, style.Size); + } + + [Theory] + // The "page" property (CSS Paged Media 3 3.4). + [InlineData("page: chapter", "chapter")] + [InlineData("page: auto", "auto")] + public void PageProperty(string declaration, string value) + { + var style = ParseDeclarations(declaration); + + Assert.Equal(value, style.PageName); + } + } +} diff --git a/src/ExCSS/Enumerations/PropertyNames.cs b/src/ExCSS/Enumerations/PropertyNames.cs index 854654d9..52e298b2 100644 --- a/src/ExCSS/Enumerations/PropertyNames.cs +++ b/src/ExCSS/Enumerations/PropertyNames.cs @@ -238,6 +238,9 @@ public static class PropertyNames public static readonly string UnicodeRange = "unicode-range"; public static readonly string Src = "src"; public static readonly string ObjectFit = "object-fit"; + // CSS Paged Media 3: the "page" property assigns a box to a named page; "size" is an @page descriptor. + public static readonly string PageName = "page"; + public static readonly string Size = "size"; public static readonly string ObjectPosition = "object-position"; } } \ No newline at end of file diff --git a/src/ExCSS/Factories/PropertyFactory.cs b/src/ExCSS/Factories/PropertyFactory.cs index 22316405..dc846656 100644 --- a/src/ExCSS/Factories/PropertyFactory.cs +++ b/src/ExCSS/Factories/PropertyFactory.cs @@ -334,6 +334,8 @@ private PropertyFactory() AddLonghand(PropertyNames.WordWrap, () => new OverflowWrapProperty()); AddLonghand(PropertyNames.ZIndex, () => new ZIndexProperty(), true); AddLonghand(PropertyNames.ObjectFit, () => new ObjectFitProperty()); + AddLonghand(PropertyNames.PageName, () => new PageNameProperty()); + AddLonghand(PropertyNames.Size, () => new PageSizeProperty()); AddLonghand(PropertyNames.ObjectPosition, () => new ObjectPositionProperty(), true); _fonts.Add(PropertyNames.Src, () => new SrcProperty()); diff --git a/src/ExCSS/Model/StyleDeclaration.cs b/src/ExCSS/Model/StyleDeclaration.cs index 34af1f15..ac688685 100644 --- a/src/ExCSS/Model/StyleDeclaration.cs +++ b/src/ExCSS/Model/StyleDeclaration.cs @@ -1681,5 +1681,17 @@ public string Zoom get => GetPropertyValue(PropertyNames.Zoom); set => SetPropertyValue(PropertyNames.Zoom, value); } + + public string Size + { + get => GetPropertyValue(PropertyNames.Size); + set => SetPropertyValue(PropertyNames.Size, value); + } + + public string PageName + { + get => GetPropertyValue(PropertyNames.PageName); + set => SetPropertyValue(PropertyNames.PageName, value); + } } } diff --git a/src/ExCSS/Parser/StylesheetComposer.cs b/src/ExCSS/Parser/StylesheetComposer.cs index 25c7eb38..9ad82430 100644 --- a/src/ExCSS/Parser/StylesheetComposer.cs +++ b/src/ExCSS/Parser/StylesheetComposer.cs @@ -526,21 +526,46 @@ public KeyframeSelector CreateKeyframeSelector(ref Token token) private PageSelector CreatePageSelector(ref Token token) { - PageSelector selector; - - if (token.Type == TokenType.Colon) + // The CSS Paged Media 3 selector grammar, per comma-separated entry: an optional page + // name followed by an optional : pseudo-class - "@page chapter1:left, chapter2:left" + // (name+pseudo), "@page chapter" (name only), "@page :first" (pseudo only). Page names are + // case-sensitive custom-idents; pseudo-class keywords (first/left/right) are matched + // case-insensitively by the caller. + var entries = new List(); + + while (true) { - // Add the pseudo class selector - token = NextToken(); - selector = token.Type == TokenType.Ident ? new PageSelector(token.Data) : new PageSelector(); + string name = null; + string pseudo = null; + + if (token.Type == TokenType.Ident) + { + name = token.Data; + token = NextToken(); + } + + if (token.Type == TokenType.Colon) + { + token = NextToken(); + if (token.Type == TokenType.Ident) + { + pseudo = token.Data; + token = NextToken(); + } + } + + if (name != null || pseudo != null) + entries.Add(new PageSelectorEntry(name, pseudo)); + + ParseComments(ref token); + + if (token.Type != TokenType.Comma) break; - //Skip past the pseudo class identifier token = NextToken(); + ParseComments(ref token); } - else - { - selector = new PageSelector(); - } + + var selector = new PageSelector(entries); //var start = token.Position; diff --git a/src/ExCSS/Selectors/KeyframeSelector.cs b/src/ExCSS/Selectors/KeyframeSelector.cs index ae939ea0..700a483f 100644 --- a/src/ExCSS/Selectors/KeyframeSelector.cs +++ b/src/ExCSS/Selectors/KeyframeSelector.cs @@ -28,23 +28,45 @@ public override void ToCss(TextWriter writer, IStyleFormatter formatter) public string Text => this.ToCss(); } - public sealed class PageSelector : StylesheetNode, ISelector + /// + /// One comma-separated entry of an @page selector list, e.g. the two entries of + /// @page chapter1:left, chapter2:left are ("chapter1", "left") and + /// ("chapter2", "left"). Either part may be absent: @page chapter has a null pseudo; + /// @page :first has a null name. + /// + public readonly struct PageSelectorEntry { - private readonly string _name; - - public PageSelector(string name) + public PageSelectorEntry(string name, string pseudo) { - _name = name; + Name = name; + Pseudo = pseudo; } - public PageSelector() : this(string.Empty) + public string Name { get; } + public string Pseudo { get; } + } + + public sealed class PageSelector : StylesheetNode, ISelector + { + private readonly List _entries; + + public PageSelector(IEnumerable entries) { + _entries = new List(entries); } + public IReadOnlyList Entries => _entries; + public override void ToCss(TextWriter writer, IStyleFormatter formatter) { - var pseudo = _name == string.Empty ? "" : ":"; - writer.Write($"{pseudo}{_name}"); + for (var i = 0; i < _entries.Count; i++) + { + if (i > 0) writer.Write(", "); + + var entry = _entries[i]; + if (entry.Name != null) writer.Write(entry.Name); + if (entry.Pseudo != null) writer.Write($":{entry.Pseudo}"); + } } public Priority Specificity => Priority.Inline; diff --git a/src/ExCSS/StyleProperties/PageNameProperty.cs b/src/ExCSS/StyleProperties/PageNameProperty.cs new file mode 100644 index 00000000..d32266ac --- /dev/null +++ b/src/ExCSS/StyleProperties/PageNameProperty.cs @@ -0,0 +1,18 @@ +namespace ExCSS +{ + using static Converters; + + /// + /// The page property (CSS Paged Media 3 §3.4): assigns a box to a named page defined by an + /// @page <name> rule. + /// + internal sealed class PageNameProperty : Property + { + internal PageNameProperty() + : base(PropertyNames.PageName) + { + } + + internal override IValueConverter Converter => IdentifierConverter.OrDefault(); + } +} diff --git a/src/ExCSS/StyleProperties/PageSizeProperty.cs b/src/ExCSS/StyleProperties/PageSizeProperty.cs new file mode 100644 index 00000000..d32ae660 --- /dev/null +++ b/src/ExCSS/StyleProperties/PageSizeProperty.cs @@ -0,0 +1,20 @@ +namespace ExCSS +{ + using static Converters; + + /// + /// The size descriptor of an @page rule (CSS Paged Media 3 §6.3): a page-size keyword + /// (A4, letter, …), an orientation (portrait/landscape), or one or two + /// explicit lengths. + /// + internal sealed class PageSizeProperty : Property + { + internal PageSizeProperty() + : base(PropertyNames.Size) + { + } + + internal override IValueConverter Converter => + IdentifierConverter.Or(LengthConverter).Many(1, 2).OrDefault(); + } +}