From 133e52c47baa8e22679b71c965df04264e54df31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 17:07:04 +0100 Subject: [PATCH 001/150] Add spec compliance tracking for CSS 2.1 --- .github/workflows/build.yml | 4 + Source/HtmlRenderer.CrossPlatform.slnf | 3 +- Source/HtmlRenderer.sln | 17 + Source/Test/Directory.Build.props | 8 + .../Compliance/SpecComplianceTests.cs | 27 + .../HtmlRenderer.Test.csproj | 1 + Source/Test/Shared/SpecAttribute.cs | 13 + .../HtmlRenderer.SpecCompliance/Compliance.cs | 154 ++++ .../HtmlRenderer.SpecCompliance.csproj | 16 + .../HtmlRenderer.SpecCompliance/Manifest.cs | 60 ++ .../HtmlRenderer.SpecCompliance/Program.cs | 51 ++ .../HtmlRenderer.SpecCompliance/Report.cs | 77 ++ .../SpecSection.cs | 18 + .../TestReferences.cs | 73 ++ Spec/README.md | 40 + Spec/css21.yml | 805 ++++++++++++++++++ 16 files changed, 1366 insertions(+), 1 deletion(-) create mode 100644 Source/Test/Directory.Build.props create mode 100644 Source/Test/HtmlRenderer.Test/Compliance/SpecComplianceTests.cs create mode 100644 Source/Test/Shared/SpecAttribute.cs create mode 100644 Source/Tools/HtmlRenderer.SpecCompliance/Compliance.cs create mode 100644 Source/Tools/HtmlRenderer.SpecCompliance/HtmlRenderer.SpecCompliance.csproj create mode 100644 Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs create mode 100644 Source/Tools/HtmlRenderer.SpecCompliance/Program.cs create mode 100644 Source/Tools/HtmlRenderer.SpecCompliance/Report.cs create mode 100644 Source/Tools/HtmlRenderer.SpecCompliance/SpecSection.cs create mode 100644 Source/Tools/HtmlRenderer.SpecCompliance/TestReferences.cs create mode 100644 Spec/README.md create mode 100644 Spec/css21.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9335d30d9..4c8391985 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -82,6 +82,10 @@ jobs: env: PDF_OUTPUT_DIRECTORY: ${{ github.workspace }}/pdf-artifacts/${{ matrix.platform.name }}-${{ matrix.dotnet.name }} + - name: Spec compliance report + if: ${{ !cancelled() && runner.os == 'Windows' && matrix.dotnet.name == '.NET 8' }} + run: dotnet run --project Source/Tools/HtmlRenderer.SpecCompliance --configuration Release --no-build -- report --output $env:GITHUB_STEP_SUMMARY + - name: Create HtmlRenderer.Core NuGet package run: dotnet pack Source/HtmlRenderer/HtmlRenderer.csproj --configuration Release --include-symbols -p:SymbolPackageFormat=snupkg --no-build --verbosity normal --output ${{ env.NuGetDirectory }} diff --git a/Source/HtmlRenderer.CrossPlatform.slnf b/Source/HtmlRenderer.CrossPlatform.slnf index 7b78f8ba8..f756150bb 100644 --- a/Source/HtmlRenderer.CrossPlatform.slnf +++ b/Source/HtmlRenderer.CrossPlatform.slnf @@ -11,7 +11,8 @@ "HtmlRenderer.WPF\\HtmlRenderer.WPF.csproj", "Test\\HtmlRenderer.IntegrationTest\\HtmlRenderer.IntegrationTest.csproj", "Test\\HtmlRenderer.PdfSharp.Test\\HtmlRenderer.PdfSharp.Test.csproj", - "Test\\HtmlRenderer.Test\\HtmlRenderer.Test.csproj" + "Test\\HtmlRenderer.Test\\HtmlRenderer.Test.csproj", + "Tools\\HtmlRenderer.SpecCompliance\\HtmlRenderer.SpecCompliance.csproj" ] } } diff --git a/Source/HtmlRenderer.sln b/Source/HtmlRenderer.sln index 521afb251..0d7e1e03b 100644 --- a/Source/HtmlRenderer.sln +++ b/Source/HtmlRenderer.sln @@ -31,6 +31,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HtmlRenderer.IntegrationTes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HtmlRenderer.Test", "Test\HtmlRenderer.Test\HtmlRenderer.Test.csproj", "{7427E12F-1524-4405-AAD1-5AF8ACB3BBBC}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HtmlRenderer.SpecCompliance", "Tools\HtmlRenderer.SpecCompliance\HtmlRenderer.SpecCompliance.csproj", "{8E0BFCAC-F676-4EE1-B639-7D8F2F24656D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -167,6 +171,18 @@ Global {7427E12F-1524-4405-AAD1-5AF8ACB3BBBC}.Release|Mixed Platforms.Build.0 = Release|Any CPU {7427E12F-1524-4405-AAD1-5AF8ACB3BBBC}.Release|x86.ActiveCfg = Release|Any CPU {7427E12F-1524-4405-AAD1-5AF8ACB3BBBC}.Release|x86.Build.0 = Release|Any CPU + {8E0BFCAC-F676-4EE1-B639-7D8F2F24656D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E0BFCAC-F676-4EE1-B639-7D8F2F24656D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E0BFCAC-F676-4EE1-B639-7D8F2F24656D}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {8E0BFCAC-F676-4EE1-B639-7D8F2F24656D}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {8E0BFCAC-F676-4EE1-B639-7D8F2F24656D}.Debug|x86.ActiveCfg = Debug|Any CPU + {8E0BFCAC-F676-4EE1-B639-7D8F2F24656D}.Debug|x86.Build.0 = Debug|Any CPU + {8E0BFCAC-F676-4EE1-B639-7D8F2F24656D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E0BFCAC-F676-4EE1-B639-7D8F2F24656D}.Release|Any CPU.Build.0 = Release|Any CPU + {8E0BFCAC-F676-4EE1-B639-7D8F2F24656D}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {8E0BFCAC-F676-4EE1-B639-7D8F2F24656D}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {8E0BFCAC-F676-4EE1-B639-7D8F2F24656D}.Release|x86.ActiveCfg = Release|Any CPU + {8E0BFCAC-F676-4EE1-B639-7D8F2F24656D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -179,6 +195,7 @@ Global {AF39851E-37B9-409E-A34D-CE091A098C86} = {E508FA78-D57E-492F-9BF7-23640001848A} {A12F4432-337B-4030-B834-5D312A9FB16C} = {E508FA78-D57E-492F-9BF7-23640001848A} {7427E12F-1524-4405-AAD1-5AF8ACB3BBBC} = {E508FA78-D57E-492F-9BF7-23640001848A} + {8E0BFCAC-F676-4EE1-B639-7D8F2F24656D} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {902788D6-3165-491D-B860-DF2F74E66C1D} diff --git a/Source/Test/Directory.Build.props b/Source/Test/Directory.Build.props new file mode 100644 index 000000000..1b9119802 --- /dev/null +++ b/Source/Test/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Source/Test/HtmlRenderer.Test/Compliance/SpecComplianceTests.cs b/Source/Test/HtmlRenderer.Test/Compliance/SpecComplianceTests.cs new file mode 100644 index 000000000..a30efa169 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Compliance/SpecComplianceTests.cs @@ -0,0 +1,27 @@ +using ComplianceData = HtmlRenderer.SpecCompliance.Compliance; + +namespace HtmlRenderer.Test.Compliance; + +/// +/// Keeps Spec/*.yml honest: every section has a numbered title, a known status and no n/a entry its parent +/// already covers, every [Spec] in the tests points to a listed section, and every section claimed as +/// supported is backed by a test. +/// +[TestClass] +public sealed class SpecComplianceTests +{ + private static readonly Lazy Data = new(() => + ComplianceData.Load(ComplianceData.FindRepositoryRoot(AppContext.BaseDirectory))); + + [TestMethod] + public void Manifests_AreWellFormed() => AssertNone(Data.Value.CheckManifests()); + + [TestMethod] + public void SpecReferences_PointToExistingAnchors() => AssertNone(Data.Value.CheckReferences()); + + [TestMethod] + public void SupportedSections_AreBackedByTests() => AssertNone(Data.Value.CheckEvidence()); + + private static void AssertNone(List problems) => + Assert.IsEmpty(problems, Environment.NewLine + string.Join(Environment.NewLine, problems)); +} diff --git a/Source/Test/HtmlRenderer.Test/HtmlRenderer.Test.csproj b/Source/Test/HtmlRenderer.Test/HtmlRenderer.Test.csproj index 23c1219cf..24cb3faac 100644 --- a/Source/Test/HtmlRenderer.Test/HtmlRenderer.Test.csproj +++ b/Source/Test/HtmlRenderer.Test/HtmlRenderer.Test.csproj @@ -14,6 +14,7 @@ + diff --git a/Source/Test/Shared/SpecAttribute.cs b/Source/Test/Shared/SpecAttribute.cs new file mode 100644 index 000000000..342606f73 --- /dev/null +++ b/Source/Test/Shared/SpecAttribute.cs @@ -0,0 +1,13 @@ +namespace HtmlRenderer.TestSupport; + +/// +/// Links a test to the spec section it verifies, e.g. [Spec("css21", "box.html#collapsing-margins")]. +/// The anchor is a section key in Spec/<spec>.yml, the test also counts for its parent sections. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +internal sealed class SpecAttribute(string spec, string anchor) : Attribute +{ + public string Spec { get; } = spec; + + public string Anchor { get; } = anchor; +} diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/Compliance.cs b/Source/Tools/HtmlRenderer.SpecCompliance/Compliance.cs new file mode 100644 index 000000000..a12622f6c --- /dev/null +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Compliance.cs @@ -0,0 +1,154 @@ +using System.Text.RegularExpressions; + +namespace HtmlRenderer.SpecCompliance; + +/// One spec with its manifest and the tests credited to each section. +public sealed class SpecResult +{ + public SpecResult(Manifest manifest) + { + Manifest = manifest; + Sections = manifest.Sections.Select(s => SpecSection.FromManifest(s.Key, s.Value.Title)).ToList(); + } + + public Manifest Manifest { get; } + + /// The manifest's sections in spec order. + public List Sections { get; } + + /// Tests per section anchor. A test counts for the section it references and all its ancestors. + public Dictionary> Tests { get; } = new(StringComparer.Ordinal); + + public IReadOnlyList TestsOf(string sectionAnchor) => + Tests.TryGetValue(sectionAnchor, out var tests) ? tests : []; + + internal void Credit(TestReference reference) + { + var section = Sections.FirstOrDefault(s => s.Anchor == reference.Anchor); + if (section == null || reference.Ignored) + return; + + foreach (var credited in Sections.Where(s => s == section || s.IsAncestorOf(section))) + { + if (!Tests.TryGetValue(credited.Anchor, out var tests)) + Tests[credited.Anchor] = tests = []; + if (!tests.Any(t => t.Test == reference.Test)) + tests.Add(reference); + } + } +} + +/// Everything under Spec/ plus the [Spec] references in Source/Test. +public sealed partial class Compliance +{ + // "8.3.1", or an appendix like "E" or "G.2" + [GeneratedRegex(@"^(\d+|[A-Z])(\.\d+)*$")] + private static partial Regex SectionNumber(); + + public required string RepositoryRoot { get; init; } + + public required List Specs { get; init; } + + public required List References { get; init; } + + public static string SpecDirectory(string root) => Path.Combine(root, "Spec"); + + public static string ManifestPath(string root, string spec) => Path.Combine(SpecDirectory(root), spec + ".yml"); + + /// Walks up from to the folder holding Source/HtmlRenderer.sln. + public static string FindRepositoryRoot(string? start = null) + { + for (var dir = new DirectoryInfo(start ?? Directory.GetCurrentDirectory()); dir != null; dir = dir.Parent) + { + if (File.Exists(Path.Combine(dir.FullName, "Source", "HtmlRenderer.sln"))) + return dir.FullName; + } + + throw new DirectoryNotFoundException("Couldn't find the repository root, run this from within the HTML-Renderer checkout."); + } + + public static Compliance Load(string root) + { + var specs = Directory.EnumerateFiles(SpecDirectory(root), "*.yml") + .Order(StringComparer.Ordinal) + .Select(path => new SpecResult(Manifest.Load(path))) + .ToList(); + + var references = TestReferences.Scan(Path.Combine(root, "Source", "Test")); + foreach (var reference in references) + specs.FirstOrDefault(s => s.Manifest.Spec == reference.Spec)?.Credit(reference); + + return new Compliance { RepositoryRoot = root, Specs = specs, References = references }; + } + + /// Every section has a title starting with its section number, a known status, and isn't an n/a entry its parent already covers. + public List CheckManifests() + { + var problems = new List(); + foreach (var spec in Specs) + { + var name = spec.Manifest.Spec; + foreach (var section in spec.Sections) + { + if (!SectionNumber().IsMatch(section.Number)) + problems.Add($"{name}: {section.Anchor} needs a title starting with its section number"); + + var status = spec.Manifest.Sections[section.Anchor].Status; + if (!SectionStatus.All.Contains(status)) + problems.Add($"{name}: {section.Anchor} has the unknown status \"{status}\", use one of {string.Join(", ", SectionStatus.All)}"); + + var parent = spec.Sections.LastOrDefault(s => s.IsAncestorOf(section)); + if (status == SectionStatus.NotApplicable && spec.Manifest.Sections[section.Anchor].Gaps is not { Count: > 0 } + && parent != null && spec.Manifest.Sections[parent.Anchor].Status == SectionStatus.NotApplicable) + problems.Add($"{name}: {section.Anchor} is n/a like {parent.Anchor}, which already covers it, leave it out"); + } + } + + return problems; + } + + /// Every [Spec] in the tests points to a section of a known spec. + public List CheckReferences() + { + var problems = new List(); + foreach (var reference in References) + { + var location = $"{reference.Test} ({Path.GetRelativePath(RepositoryRoot, reference.File)}:{reference.Line})"; + var spec = Specs.FirstOrDefault(s => s.Manifest.Spec == reference.Spec); + if (spec == null) + problems.Add($"{location}: unknown spec \"{reference.Spec}\""); + else if (!spec.Manifest.Sections.ContainsKey(reference.Anchor)) + problems.Add($"{location}: \"{reference.Anchor}\" isn't a section of {reference.Spec}"); + } + + return problems; + } + + /// A section claimed as (partially) supported has tests, and a partial one lists its gaps. + public List CheckEvidence() + { + var problems = new List(); + foreach (var spec in Specs) + { + foreach (var (anchor, section) in spec.Manifest.Sections) + { + var claimed = section.Status is SectionStatus.Supported or SectionStatus.Partial; + if (claimed && spec.TestsOf(anchor).Count == 0) + problems.Add($"{spec.Manifest.Spec}: {section.Title} is marked {section.Status}, but no test references it"); + + if (section.Status == SectionStatus.Partial && !HasGaps(spec, anchor)) + problems.Add($"{spec.Manifest.Spec}: {section.Title} is marked partial, but neither it nor its subsections list gaps"); + } + } + + return problems; + } + + private static bool HasGaps(SpecResult spec, string anchor) + { + var section = spec.Sections.First(s => s.Anchor == anchor); + return spec.Sections + .Where(s => s == section || section.IsAncestorOf(s)) + .Any(s => spec.Manifest.Sections[s.Anchor].Gaps is { Count: > 0 }); + } +} diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/HtmlRenderer.SpecCompliance.csproj b/Source/Tools/HtmlRenderer.SpecCompliance/HtmlRenderer.SpecCompliance.csproj new file mode 100644 index 000000000..e5ecc8488 --- /dev/null +++ b/Source/Tools/HtmlRenderer.SpecCompliance/HtmlRenderer.SpecCompliance.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + latest + enable + enable + false + + + + + + + diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs b/Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs new file mode 100644 index 000000000..4221f1f2c --- /dev/null +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs @@ -0,0 +1,60 @@ +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace HtmlRenderer.SpecCompliance; + +public static class SectionStatus +{ + public const string Unknown = "unknown"; + public const string Supported = "supported"; + public const string Partial = "partial"; + public const string ParsedOnly = "parsed-only"; + public const string Unsupported = "unsupported"; + public const string NotApplicable = "n/a"; + + public static readonly string[] All = [Supported, Partial, ParsedOnly, Unsupported, NotApplicable, Unknown]; +} + +public sealed class ManifestSection +{ + public string Title { get; set; } = ""; + + public string Status { get; set; } = SectionStatus.Unknown; + + public List? Gaps { get; set; } +} + +/// +/// The implementation status of one spec, Spec/<spec>.yml. Sections are keyed by their anchor +/// and kept in spec order. +/// +public sealed class Manifest +{ + public string Spec { get; set; } = ""; + + public string Title { get; set; } = ""; + + /// Published URL, anchors are appended to it for links in the report. + public string Url { get; set; } = ""; + + public Dictionary Sections { get; set; } = new(StringComparer.Ordinal); + + private static readonly IDeserializer Deserializer = new DeserializerBuilder() + .WithNamingConvention(HyphenatedNamingConvention.Instance) + .Build(); + + private static readonly ISerializer Serializer = new SerializerBuilder() + .WithNamingConvention(HyphenatedNamingConvention.Instance) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections) + .WithIndentedSequences() + .Build(); + + public static Manifest Load(string path) + { + var manifest = Deserializer.Deserialize(File.ReadAllText(path)); + manifest.Sections ??= new Dictionary(StringComparer.Ordinal); + return manifest; + } + + public void Save(string path) => File.WriteAllText(path, Serializer.Serialize(this).ReplaceLineEndings("\n")); +} diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/Program.cs b/Source/Tools/HtmlRenderer.SpecCompliance/Program.cs new file mode 100644 index 000000000..48c2de48f --- /dev/null +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Program.cs @@ -0,0 +1,51 @@ +using System.Text; +using HtmlRenderer.SpecCompliance; + +const string Usage = """ + Usage: + check Validate the manifests and the [Spec] references in the tests + report [--output ] Write the compliance report as Markdown, to stdout by default + """; + +Console.OutputEncoding = new UTF8Encoding(false); + +if (args.Length == 0) +{ + Console.Error.WriteLine(Usage); + return 1; +} + +var root = Compliance.FindRepositoryRoot(); +string? Option(string name) +{ + var i = Array.IndexOf(args, name); + return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; +} + +switch (args[0]) +{ + case "check": + { + var compliance = Compliance.Load(root); + var problems = compliance.CheckManifests().Concat(compliance.CheckReferences()).Concat(compliance.CheckEvidence()).ToList(); + foreach (var problem in problems) + Console.WriteLine(problem); + Console.WriteLine(problems.Count == 0 ? "No problems found." : $"{problems.Count} problem(s) found."); + return problems.Count == 0 ? 0 : 1; + } + + case "report": + { + var markdown = Report.Render(Compliance.Load(root)); + var output = Option("--output"); + if (output == null) + Console.Write(markdown); + else + File.WriteAllText(output, markdown); + return 0; + } + + default: + Console.Error.WriteLine(Usage); + return 1; +} diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs b/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs new file mode 100644 index 000000000..848857dee --- /dev/null +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs @@ -0,0 +1,77 @@ +using System.Text; + +namespace HtmlRenderer.SpecCompliance; + +/// Renders the compliance as Markdown, for the GitHub Actions job summary or a file. +public static class Report +{ + private static readonly string[] Columns = [SectionStatus.Supported, SectionStatus.Partial, SectionStatus.ParsedOnly, SectionStatus.Unsupported, SectionStatus.NotApplicable, SectionStatus.Unknown]; + + public static string Render(Compliance compliance) + { + var sb = new StringBuilder(); + sb.AppendLine("## Spec compliance").AppendLine(); + sb.AppendLine("| Spec | Sections | Supported | Partial | Parsed only | Unsupported | N/A | Unknown | Tested |"); + sb.AppendLine("|---|--:|--:|--:|--:|--:|--:|--:|--:|"); + foreach (var spec in compliance.Specs) + { + var sections = spec.Manifest.Sections; + var counts = Columns.Select(c => sections.Values.Count(s => s.Status == c).ToString()); + var tested = sections.Keys.Count(a => spec.TestsOf(a).Count > 0); + sb.AppendLine($"| [{spec.Manifest.Title}]({spec.Manifest.Url}) | {sections.Count} | {string.Join(" | ", counts)} | {tested} |"); + } + + foreach (var spec in compliance.Specs) + { + sb.AppendLine().AppendLine($"### {spec.Manifest.Title}").AppendLine(); + foreach (var chapter in Chapters(spec)) + RenderChapter(sb, spec, chapter); + } + + return sb.ToString(); + } + + private static void RenderChapter(StringBuilder sb, SpecResult spec, List sections) + { + var entries = sections.Select(s => spec.Manifest.Sections.GetValueOrDefault(s.Anchor) ?? new ManifestSection()).ToList(); + var summary = Columns + .Select(c => (Status: c, Count: entries.Count(e => e.Status == c))) + .Where(c => c.Count > 0) + .Select(c => $"{c.Count} {c.Status}"); + + sb.AppendLine($"
{Escape(sections[0].DisplayTitle)}: {string.Join(", ", summary)}").AppendLine(); + sb.AppendLine("| Section | Status | Tests | Gaps |"); + sb.AppendLine("|---|---|--:|---|"); + foreach (var section in sections) + { + var entry = spec.Manifest.Sections.GetValueOrDefault(section.Anchor) ?? new ManifestSection(); + var gaps = entry.Gaps == null ? "" : string.Join("
", entry.Gaps.Select(Escape)); + var link = spec.Manifest.Url.TrimEnd('/') + "/" + section.Anchor; + sb.AppendLine($"| [{Escape(section.DisplayTitle)}]({link}) | {entry.Status} | {spec.TestsOf(section.Anchor).Count} | {gaps} |"); + } + + sb.AppendLine().AppendLine("
").AppendLine(); + } + + /// Groups the sections by their top-level number, i.e. chapter or appendix. + private static IEnumerable> Chapters(SpecResult spec) + { + List? chapter = null; + foreach (var section in spec.Sections) + { + if (!section.Number.Contains('.') || chapter == null) + { + if (chapter != null) + yield return chapter; + chapter = []; + } + + chapter.Add(section); + } + + if (chapter != null) + yield return chapter; + } + + private static string Escape(string text) => text.Replace("|", "\\|").Replace("<", "<").Replace(">", ">"); +} diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/SpecSection.cs b/Source/Tools/HtmlRenderer.SpecCompliance/SpecSection.cs new file mode 100644 index 000000000..efd815923 --- /dev/null +++ b/Source/Tools/HtmlRenderer.SpecCompliance/SpecSection.cs @@ -0,0 +1,18 @@ +namespace HtmlRenderer.SpecCompliance; + +/// A numbered section of a spec, e.g. "8.3.1 Collapsing margins" at box.html#collapsing-margins. +public sealed record SpecSection(string Anchor, string Number, string Title) +{ + public string DisplayTitle => $"{Number} {Title}"; + + public bool IsAncestorOf(SpecSection other) => other.Number.StartsWith(Number + ".", StringComparison.Ordinal); + + /// Splits a manifest title like "8.3.1 Collapsing margins" into number and title. + public static SpecSection FromManifest(string anchor, string displayTitle) + { + var space = displayTitle.IndexOf(' '); + return space < 0 + ? new SpecSection(anchor, displayTitle, "") + : new SpecSection(anchor, displayTitle[..space], displayTitle[(space + 1)..]); + } +} diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/TestReferences.cs b/Source/Tools/HtmlRenderer.SpecCompliance/TestReferences.cs new file mode 100644 index 000000000..95733bda4 --- /dev/null +++ b/Source/Tools/HtmlRenderer.SpecCompliance/TestReferences.cs @@ -0,0 +1,73 @@ +using System.Text.RegularExpressions; + +namespace HtmlRenderer.SpecCompliance; + +/// +/// A [Spec("css21", "box.html#collapsing-margins")] on a test method. An ignored test documents a gap +/// and doesn't count as evidence. +/// +public sealed record TestReference(string Spec, string Anchor, string Test, string File, int Line, bool Ignored); + +/// +/// Finds the [Spec] attributes in the test sources. Reading the sources instead of the built test +/// assemblies keeps the check independent of which test projects were built for which platform. +/// +public static partial class TestReferences +{ + [GeneratedRegex("""\bSpec\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\)""")] + private static partial Regex SpecAttribute(); + + [GeneratedRegex(@"\b(?:void|Task)\s+(\w+)\s*\(")] + private static partial Regex MethodName(); + + // A declaration at the start of a line, so "class" inside a string literal doesn't count + [GeneratedRegex(@"^\s*(?:(?:public|internal|private|protected|sealed|static|abstract|partial|file)\s+)*class\s+(\w+)", RegexOptions.Multiline)] + private static partial Regex ClassName(); + + public static List Scan(string testDirectory) + { + var references = new List(); + foreach (var file in Directory.EnumerateFiles(testDirectory, "*.cs", SearchOption.AllDirectories)) + { + var separator = Path.DirectorySeparatorChar; + if (file.Contains($"{separator}obj{separator}") || file.Contains($"{separator}bin{separator}")) + continue; + + var source = File.ReadAllText(file); + foreach (Match match in SpecAttribute().Matches(source)) + { + // The attribute class itself and its usage examples aren't references + var before = source.LastIndexOf('\n', match.Index) + 1; + var linePrefix = source[before..match.Index].TrimStart(); + if (linePrefix.StartsWith("//", StringComparison.Ordinal) || linePrefix.StartsWith("///", StringComparison.Ordinal)) + continue; + + var method = MethodName().Match(source, match.Index); + var attributes = AttributeBlock(source, match.Index, method.Success ? method.Index : match.Index); + var ignored = Regex.IsMatch(attributes, @"[\[,]\s*Ignore\b"); + var type = ClassName().Matches(source[..match.Index]).LastOrDefault(); + var test = $"{type?.Groups[1].Value}.{(method.Success ? method.Groups[1].Value : "?")}"; + var line = source.AsSpan(0, match.Index).Count('\n') + 1; + references.Add(new TestReference(match.Groups[1].Value, match.Groups[2].Value, test, file, line, ignored)); + } + } + + return references; + } + + /// The attribute lines above a method, up to the previous blank line or brace. + private static string AttributeBlock(string source, int attributeIndex, int methodIndex) + { + var start = source.LastIndexOf('\n', attributeIndex) + 1; + while (start > 1) + { + var previousStart = source.LastIndexOf('\n', start - 2) + 1; + var previous = source[previousStart..(start - 1)].Trim(); + if (previous.Length == 0 || previous == "}" || previous.EndsWith('{')) + break; + start = previousStart; + } + + return source[start..methodIndex]; + } +} diff --git a/Spec/README.md b/Spec/README.md new file mode 100644 index 000000000..c0999d737 --- /dev/null +++ b/Spec/README.md @@ -0,0 +1,40 @@ +# Spec compliance + +**tl;dr:** `.yml` says per spec section how well HTML Renderer implements it, `[Spec]` on a test says which section it verifies, and `SpecComplianceTests` keeps the two in sync. The report shows up in the summary of the Windows .NET 8 CI job. + +## Manifest + +`.yml` has the spec's `title` and `url`, and one entry per section, keyed by its anchor, with the numbered title, the `status` and, if anything is missing, the `gaps`. Every section of the spec is listed, except the subsections of an `n/a` section, so the report also shows what hasn't been reviewed yet. + +Statuses: + +- `supported`: implemented as specified, needs at least one test +- `partial`: implemented with known gaps, needs at least one test and the gaps listed on it or its subsections +- `parsed-only`: the CSS engine accepts it, but layout and painting ignore it +- `unsupported`: not implemented +- `n/a`: nothing to implement, e.g. informative examples, it covers the subsections, they're only listed with a status of their own +- `unknown`: not reviewed yet + +## Linking tests + +```csharp +[TestMethod] +[Spec("css21", "box.html#collapsing-margins")] +public void NegativeSiblingMargins_CollapseToTheMostNegative() +``` + +The anchor is a section key from the manifest, the test counts for that section and all its parent sections. An `[Ignore]`d test doesn't count, use one to describe a gap with `[Ignore("Gap, see Spec/css21.yml")]`. + +Keep the spec out of code comments, the manifest says what's supported, the tests show how, and the spec explains why. A comment cites the section ("CSS 2.1 §8.3.1") and explains what the code can't. + +## Commands + +Run from the repository root: + +```sh +# Validate manifests and [Spec] references, same as SpecComplianceTests +dotnet run --project Source/Tools/HtmlRenderer.SpecCompliance -- check + +# Write the report +dotnet run --project Source/Tools/HtmlRenderer.SpecCompliance -- report --output report.md +``` diff --git a/Spec/css21.yml b/Spec/css21.yml new file mode 100644 index 000000000..44699dd03 --- /dev/null +++ b/Spec/css21.yml @@ -0,0 +1,805 @@ +spec: css21 +title: CSS 2.1 +url: https://www.w3.org/TR/CSS21/ +sections: + intro.html#q2.0: + title: 2 Introduction to CSS 2.1 + status: unknown + intro.html#html-tutorial: + title: 2.1 A brief CSS 2.1 tutorial for HTML + status: unknown + intro.html#xml-tutorial: + title: 2.2 A brief CSS 2.1 tutorial for XML + status: unknown + intro.html#processing-model: + title: 2.3 The CSS 2.1 processing model + status: unknown + intro.html#the-canvas: + title: 2.3.1 The canvas + status: unknown + intro.html#addressing: + title: 2.3.2 CSS 2.1 addressing model + status: unknown + intro.html#design-principles: + title: 2.4 CSS design principles + status: unknown + conform.html#q3.0: + title: 3 Conformance + status: unknown + conform.html#defs: + title: 3.1 Definitions + status: unknown + conform.html#conformance: + title: 3.2 UA Conformance + status: unknown + conform.html#errors: + title: 3.3 Error conditions + status: unknown + conform.html#text-css: + title: 3.4 The text/css content type + status: unknown + syndata.html#q4.0: + title: 4 Syntax and basic data types + status: unknown + syndata.html#syntax: + title: 4.1 Syntax + status: unknown + syndata.html#tokenization: + title: 4.1.1 Tokenization + status: unknown + syndata.html#keywords: + title: 4.1.2 Keywords + status: unknown + syndata.html#vendor-keywords: + title: 4.1.2.1 Vendor-specific extensions + status: unknown + syndata.html#vendor-keyword-history: + title: 4.1.2.2 Informative Historical Notes + status: unknown + syndata.html#characters: + title: 4.1.3 Characters and case + status: unknown + syndata.html#statements: + title: 4.1.4 Statements + status: unknown + syndata.html#at-rules: + title: 4.1.5 At-rules + status: unknown + syndata.html#block: + title: 4.1.6 Blocks + status: unknown + syndata.html#rule-sets: + title: 4.1.7 Rule sets, declaration blocks, and selectors + status: unknown + syndata.html#declaration: + title: 4.1.8 Declarations and properties + status: unknown + syndata.html#comments: + title: 4.1.9 Comments + status: unknown + syndata.html#parsing-errors: + title: 4.2 Rules for handling parsing errors + status: unknown + syndata.html#values: + title: 4.3 Values + status: unknown + syndata.html#numbers: + title: 4.3.1 Integers and real numbers + status: unknown + syndata.html#length-units: + title: 4.3.2 Lengths + status: unknown + syndata.html#percentage-units: + title: 4.3.3 Percentages + status: unknown + syndata.html#uri: + title: 4.3.4 URLs and URIs + status: unknown + syndata.html#counter: + title: 4.3.5 Counters + status: unknown + syndata.html#color-units: + title: 4.3.6 Colors + status: unknown + syndata.html#strings: + title: 4.3.7 Strings + status: unknown + syndata.html#unsupported-values: + title: 4.3.8 Unsupported Values + status: unknown + syndata.html#charset: + title: 4.4 CSS style sheet representation + status: unknown + syndata.html#escaping: + title: 4.4.1 Referring to characters not represented in a character encoding + status: unknown + selector.html#q5.0: + title: 5 Selectors + status: unknown + selector.html#pattern-matching: + title: 5.1 Pattern matching + status: unknown + selector.html#selector-syntax: + title: 5.2 Selector syntax + status: unknown + selector.html#grouping: + title: 5.2.1 Grouping + status: unknown + selector.html#universal-selector: + title: 5.3 Universal selector + status: unknown + selector.html#type-selectors: + title: 5.4 Type selectors + status: unknown + selector.html#descendant-selectors: + title: 5.5 Descendant selectors + status: unknown + selector.html#child-selectors: + title: 5.6 Child selectors + status: unknown + selector.html#adjacent-selectors: + title: 5.7 Adjacent sibling selectors + status: unknown + selector.html#attribute-selectors: + title: 5.8 Attribute selectors + status: unknown + selector.html#matching-attrs: + title: 5.8.1 Matching attributes and attribute values + status: unknown + selector.html#default-attrs: + title: 5.8.2 Default attribute values in DTDs + status: unknown + selector.html#class-html: + title: 5.8.3 Class selectors + status: unknown + selector.html#id-selectors: + title: 5.9 ID selectors + status: unknown + selector.html#pseudo-elements: + title: 5.10 Pseudo-elements and pseudo-classes + status: unknown + selector.html#pseudo-class-selectors: + title: 5.11 Pseudo-classes + status: unknown + selector.html#link-pseudo-classes: + title: 5.11.2 The link pseudo-classes + status: unknown + selector.html#dynamic-pseudo-classes: + title: 5.11.3 The dynamic pseudo-classes + status: unknown + selector.html#lang: + title: 5.11.4 The language pseudo-class + status: unknown + selector.html#pseudo-element-selectors: + title: 5.12 Pseudo-elements + status: unknown + selector.html#first-line-pseudo: + title: 5.12.1 The + status: unknown + selector.html#first-letter: + title: 5.12.2 The + status: unknown + selector.html#before-and-after: + title: 5.12.3 The + status: unknown + cascade.html#q6.0: + title: 6 Assigning property values, Cascading, and Inheritance + status: unknown + cascade.html#value-stages: + title: 6.1 Specified, computed, and actual values + status: unknown + cascade.html#specified-value: + title: 6.1.1 Specified values + status: unknown + cascade.html#computed-value: + title: 6.1.2 Computed values + status: unknown + cascade.html#used-value: + title: 6.1.3 Used values + status: unknown + cascade.html#actual-value: + title: 6.1.4 Actual values + status: unknown + cascade.html#inheritance: + title: 6.2 Inheritance + status: unknown + cascade.html#value-def-inherit: + title: 6.2.1 The 'inherit' value + status: unknown + cascade.html#at-import: + title: 6.3 The @import rule + status: unknown + cascade.html#cascade: + title: 6.4 The cascade + status: unknown + cascade.html#cascading-order: + title: 6.4.1 Cascading order + status: unknown + cascade.html#important-rules: + title: 6.4.2 !important rules + status: unknown + cascade.html#specificity: + title: 6.4.3 Calculating a selector's specificity + status: unknown + cascade.html#preshint: + title: 6.4.4 Precedence of non-CSS presentational hints + status: unknown + media.html#q7.0: + title: 7 Media types + status: unknown + media.html#media-intro: + title: 7.1 Introduction to media types + status: unknown + media.html#media-sheets: + title: 7.2 Specifying media-dependent style sheets + status: unknown + media.html#at-media-rule: + title: 7.2.1 The @media rule + status: unknown + media.html#media-types: + title: 7.3 Recognized media types + status: unknown + media.html#media-groups: + title: 7.3.1 Media groups + status: unknown + box.html#box-model: + title: 8 Box model + status: unknown + box.html#box-dimensions: + title: 8.1 Box dimensions + status: unknown + box.html#mpb-examples: + title: 8.2 Example of margins, padding, and borders + status: unknown + box.html#margin-properties: + title: 8.3 Margin properties + status: unknown + box.html#collapsing-margins: + title: 8.3.1 Collapsing margins + status: unknown + box.html#padding-properties: + title: 8.4 Padding properties + status: unknown + box.html#border-properties: + title: 8.5 Border properties + status: unknown + box.html#border-width-properties: + title: 8.5.1 Border width + status: unknown + box.html#border-color-properties: + title: 8.5.2 Border color + status: unknown + box.html#border-style-properties: + title: 8.5.3 Border style + status: unknown + box.html#border-shorthand-properties: + title: 8.5.4 Border shorthand properties + status: unknown + box.html#bidi-box-model: + title: 8.6 The box model for inline elements in bidirectional context + status: unknown + visuren.html#q9.0: + title: 9 Visual formatting model + status: unknown + visuren.html#visual-model-intro: + title: 9.1 Introduction to the visual formatting model + status: unknown + visuren.html#viewport: + title: 9.1.1 The viewport + status: unknown + visuren.html#containing-block: + title: 9.1.2 Containing blocks + status: unknown + visuren.html#box-gen: + title: 9.2 Controlling box generation + status: unknown + visuren.html#block-boxes: + title: 9.2.1 Block-level elements and block boxes + status: unknown + visuren.html#anonymous-block-level: + title: 9.2.1.1 Anonymous block boxes + status: unknown + visuren.html#inline-boxes: + title: 9.2.2 Inline-level elements and inline boxes + status: unknown + visuren.html#anonymous: + title: 9.2.2.1 Anonymous inline boxes + status: unknown + visuren.html#run-in: + title: 9.2.3 Run-in boxes + status: unknown + visuren.html#display-prop: + title: 9.2.4 The 'display' property + status: unknown + visuren.html#positioning-scheme: + title: 9.3 Positioning schemes + status: unknown + visuren.html#choose-position: + title: 9.3.1 Choosing a positioning scheme + status: unknown + visuren.html#position-props: + title: 9.3.2 Box offsets + status: unknown + visuren.html#normal-flow: + title: 9.4 Normal flow + status: unknown + visuren.html#block-formatting: + title: 9.4.1 Block formatting contexts + status: unknown + visuren.html#inline-formatting: + title: 9.4.2 Inline formatting contexts + status: unknown + visuren.html#relative-positioning: + title: 9.4.3 Relative positioning + status: unknown + visuren.html#floats: + title: 9.5 Floats + status: unknown + visuren.html#float-position: + title: 9.5.1 Positioning the float + status: unknown + visuren.html#flow-control: + title: 9.5.2 Controlling flow next to floats + status: unknown + visuren.html#absolute-positioning: + title: 9.6 Absolute positioning + status: unknown + visuren.html#fixed-positioning: + title: 9.6.1 Fixed positioning + status: unknown + visuren.html#dis-pos-flo: + title: 9.7 Relationships between 'display', 'position', and 'float' + status: unknown + visuren.html#comparison: + title: 9.8 Comparison of normal flow, floats, and absolute positioning + status: unknown + visuren.html#comp-normal-flow: + title: 9.8.1 Normal flow + status: unknown + visuren.html#comp-relpos: + title: 9.8.2 Relative positioning + status: unknown + visuren.html#comp-float: + title: 9.8.3 Floating a box + status: unknown + visuren.html#comp-abspos: + title: 9.8.4 Absolute positioning + status: unknown + visuren.html#layers: + title: 9.9 Layered presentation + status: unknown + visuren.html#z-index: + title: 9.9.1 Specifying the stack level + status: unknown + visuren.html#direction: + title: 9.10 Text direction + status: unknown + visudet.html#q10.0: + title: 10 Visual formatting model details + status: unknown + visudet.html#containing-block-details: + title: 10.1 Definition of "containing block" + status: unknown + visudet.html#the-width-property: + title: 10.2 Content width + status: unknown + visudet.html#Computing_widths_and_margins: + title: 10.3 Calculating widths and margins + status: unknown + visudet.html#inline-width: + title: 10.3.1 Inline, non-replaced elements + status: unknown + visudet.html#inline-replaced-width: + title: 10.3.2 Inline, replaced elements + status: unknown + visudet.html#blockwidth: + title: 10.3.3 Block-level, non-replaced elements in normal flow + status: unknown + visudet.html#block-replaced-width: + title: 10.3.4 Block-level, replaced elements in normal flow + status: unknown + visudet.html#float-width: + title: 10.3.5 Floating, non-replaced elements + status: unknown + visudet.html#float-replaced-width: + title: 10.3.6 Floating, replaced elements + status: unknown + visudet.html#abs-non-replaced-width: + title: 10.3.7 Absolutely positioned, non-replaced elements + status: unknown + visudet.html#abs-replaced-width: + title: 10.3.8 Absolutely positioned, replaced elements + status: unknown + visudet.html#inlineblock-width: + title: 10.3.9 'Inline-block', non-replaced elements in normal flow + status: unknown + visudet.html#inlineblock-replaced-width: + title: 10.3.10 'Inline-block', replaced elements in normal flow + status: unknown + visudet.html#min-max-widths: + title: 10.4 Minimum and maximum widths + status: unknown + visudet.html#the-height-property: + title: 10.5 Content height + status: unknown + visudet.html#Computing_heights_and_margins: + title: 10.6 Calculating heights and margins + status: unknown + visudet.html#inline-non-replaced: + title: 10.6.1 Inline, non-replaced elements + status: unknown + visudet.html#inline-replaced-height: + title: 10.6.2 Inline replaced elements, block-level replaced elements in normal flow, 'inline-block' replaced elements in normal flow and floating replaced elements + status: unknown + visudet.html#normal-block: + title: 10.6.3 Block-level non-replaced elements in normal flow when 'overflow' computes to 'visible' + status: unknown + visudet.html#abs-non-replaced-height: + title: 10.6.4 Absolutely positioned, non-replaced elements + status: unknown + visudet.html#abs-replaced-height: + title: 10.6.5 Absolutely positioned, replaced elements + status: unknown + visudet.html#block-root-margin: + title: 10.6.6 Complicated cases + status: unknown + visudet.html#root-height: + title: 10.6.7 'Auto' heights for block formatting context roots + status: unknown + visudet.html#min-max-heights: + title: 10.7 Minimum and maximum heights + status: unknown + visudet.html#line-height: + title: 10.8 Line height calculations + status: unknown + visudet.html#leading: + title: 10.8.1 Leading and half-leading + status: unknown + visufx.html#q11.0: + title: 11 Visual effects + status: unknown + visufx.html#overflow-clipping: + title: 11.1 Overflow and clipping + status: unknown + visufx.html#overflow: + title: 11.1.1 Overflow + status: unknown + visufx.html#clipping: + title: 11.1.2 Clipping + status: unknown + visufx.html#visibility: + title: 11.2 Visibility + status: unknown + generate.html#generated-text: + title: 12 Generated content, automatic numbering, and lists + status: unknown + generate.html#before-after-content: + title: 12.1 The + status: unknown + generate.html#content: + title: 12.2 The 'content' property + status: unknown + generate.html#quotes: + title: 12.3 Quotation marks + status: unknown + generate.html#quotes-specify: + title: 12.3.1 Specifying quotes with the 'quotes' property + status: unknown + generate.html#quotes-insert: + title: 12.3.2 Inserting quotes with the 'content' property + status: unknown + generate.html#counters: + title: 12.4 Automatic counters and numbering + status: unknown + generate.html#scope: + title: 12.4.1 Nested counters and scope + status: unknown + generate.html#counter-styles: + title: 12.4.2 Counter styles + status: unknown + generate.html#undisplayed-counters: + title: 12.4.3 Counters in elements with 'display + status: unknown + generate.html#lists: + title: 12.5 Lists + status: unknown + generate.html#list-style: + title: 12.5.1 Lists + status: unknown + page.html#the-page: + title: 13 Paged media + status: unknown + page.html#page-intro: + title: 13.1 Introduction to paged media + status: unknown + page.html#page-box: + title: 13.2 Page boxes + status: unknown + page.html#page-margins: + title: 13.2.1 Page margins + status: unknown + page.html#page-selectors: + title: 13.2.2 Page selectors + status: unknown + page.html#outside-page-box: + title: 13.2.3 Content outside the page box + status: unknown + page.html#page-breaks: + title: 13.3 Page breaks + status: unknown + page.html#page-break-props: + title: 13.3.1 Page break properties + status: unknown + page.html#break-inside: + title: 13.3.2 Breaks inside elements + status: unknown + page.html#allowed-page-breaks: + title: 13.3.3 Allowed page breaks + status: unknown + page.html#forced: + title: 13.3.4 Forced page breaks + status: unknown + page.html#best-page-breaks: + title: 13.3.5 "Best" page breaks + status: unknown + page.html#page-cascade: + title: 13.4 Cascading in the page context + status: unknown + colors.html#q14.0: + title: 14 Colors and Backgrounds + status: unknown + colors.html#colors: + title: 14.1 Foreground color + status: unknown + colors.html#background: + title: 14.2 The background + status: unknown + colors.html#background-properties: + title: 14.2.1 Background properties + status: unknown + fonts.html#q15.0: + title: 15 Fonts + status: unknown + fonts.html#fonts-intro: + title: 15.1 Introduction + status: unknown + fonts.html#algorithm: + title: 15.2 Font matching algorithm + status: unknown + fonts.html#font-family-prop: + title: 15.3 Font family + status: unknown + fonts.html#generic-font-families: + title: 15.3.1 Generic font families + status: unknown + fonts.html#serif-def: + title: 15.3.1.1 serif + status: unknown + fonts.html#sans-serif-def: + title: 15.3.1.2 sans-serif + status: unknown + fonts.html#cursive-def: + title: 15.3.1.3 cursive + status: unknown + fonts.html#fantasy-def: + title: 15.3.1.4 fantasy + status: unknown + fonts.html#monospace-def: + title: 15.3.1.5 monospace + status: unknown + fonts.html#font-styling: + title: 15.4 Font styling + status: unknown + fonts.html#small-caps: + title: 15.5 Small-caps + status: unknown + fonts.html#font-boldness: + title: 15.6 Font boldness + status: unknown + fonts.html#font-size-props: + title: 15.7 Font size + status: unknown + fonts.html#font-shorthand: + title: 15.8 Shorthand font property + status: unknown + text.html#q16.0: + title: 16 Text + status: unknown + text.html#indentation-prop: + title: 16.1 Indentation + status: unknown + text.html#alignment-prop: + title: 16.2 Alignment + status: unknown + text.html#decoration: + title: 16.3 Decoration + status: unknown + text.html#lining-striking-props: + title: 16.3.1 Underlining, overlining, striking, and blinking + status: unknown + text.html#spacing-props: + title: 16.4 Letter and word spacing + status: unknown + text.html#caps-prop: + title: 16.5 Capitalization + status: unknown + text.html#white-space-prop: + title: 16.6 White space + status: unknown + text.html#white-space-model: + title: 16.6.1 The 'white-space' processing model + status: unknown + text.html#egbidiwscollapse: + title: 16.6.2 Example of bidirectionality with white space collapsing + status: unknown + text.html#ctrlchars: + title: 16.6.3 Control and combining characters' details + status: unknown + tables.html#q17.0: + title: 17 Tables + status: unknown + tables.html#tables-intro: + title: 17.1 Introduction to tables + status: unknown + tables.html#table-display: + title: 17.2 The CSS table model + status: unknown + tables.html#anonymous-boxes: + title: 17.2.1 Anonymous table objects + status: unknown + tables.html#columns: + title: 17.3 Columns + status: unknown + tables.html#model: + title: 17.4 Tables in the visual formatting model + status: unknown + tables.html#caption-position: + title: 17.4.1 Caption position and alignment + status: unknown + tables.html#table-layout: + title: 17.5 Visual layout of table contents + status: unknown + tables.html#table-layers: + title: 17.5.1 Table layers and transparency + status: unknown + tables.html#width-layout: + title: 17.5.2 Table width algorithms + status: unknown + tables.html#fixed-table-layout: + title: 17.5.2.1 Fixed table layout + status: unknown + tables.html#auto-table-layout: + title: 17.5.2.2 Automatic table layout + status: unknown + tables.html#height-layout: + title: 17.5.3 Table height algorithms + status: unknown + tables.html#column-alignment: + title: 17.5.4 Horizontal alignment in a column + status: unknown + tables.html#dynamic-effects: + title: 17.5.5 Dynamic row and column effects + status: unknown + tables.html#borders: + title: 17.6 Borders + status: unknown + tables.html#separated-borders: + title: 17.6.1 The separated borders model + status: unknown + tables.html#empty-cells: + title: 17.6.1.1 Borders and Backgrounds around empty cells + status: unknown + tables.html#collapsing-borders: + title: 17.6.2 The collapsing border model + status: unknown + tables.html#border-conflict-resolution: + title: 17.6.2.1 Border conflict resolution + status: unknown + tables.html#table-border-styles: + title: 17.6.3 Border styles + status: unknown + ui.html#q18.0: + title: 18 User interface + status: unknown + ui.html#cursor-props: + title: 18.1 Cursors + status: unknown + ui.html#system-colors: + title: 18.2 System Colors + status: unknown + ui.html#system-fonts: + title: 18.3 User preferences for fonts + status: unknown + ui.html#dynamic-outlines: + title: 18.4 Dynamic outlines + status: unknown + ui.html#outline-focus: + title: 18.4.1 Outlines and the focus + status: unknown + ui.html#magnification: + title: 18.5 Magnification + status: unknown + aural.html#q19.0: + title: A Aural style sheets + status: unknown + aural.html#aural-media-group: + title: A.1 The media types 'aural' and 'speech' + status: unknown + aural.html#aural-intro: + title: A.2 Introduction to aural style sheets + status: unknown + aural.html#angles: + title: A.2.1 Angles + status: unknown + aural.html#times: + title: A.2.2 Times + status: unknown + aural.html#frequencies: + title: A.2.3 Frequencies + status: unknown + aural.html#volume-props: + title: A.3 Volume properties + status: unknown + aural.html#speaking-props: + title: A.4 Speaking properties + status: unknown + aural.html#pause-props: + title: A.5 Pause properties + status: unknown + aural.html#cue-props: + title: A.6 Cue properties + status: unknown + aural.html#mixing-props: + title: A.7 Mixing properties + status: unknown + aural.html#spatial-props: + title: A.8 Spatial properties + status: unknown + aural.html#voice-char-props: + title: A.9 Voice characteristic properties + status: unknown + aural.html#speech-props: + title: A.10 Speech properties + status: unknown + aural.html#aural-tables: + title: A.11 Audio rendering of tables + status: unknown + aural.html#speak-headers: + title: A.11.1 Speaking headers + status: unknown + aural.html#sample: + title: A.12 Sample style sheet for HTML + status: unknown + aural.html#Emacspeak: + title: A.13 Emacspeak + status: unknown + sample.html#q22.0: + title: D Default style sheet for HTML 4 + status: unknown + zindex.html#q23.0: + title: E Elaborate description of Stacking Contexts + status: unknown + zindex.html#stacking-defs: + title: E.1 Definitions + status: unknown + zindex.html#painting-order: + title: E.2 Painting order + status: unknown + zindex.html#stacking-notes: + title: E.3 Notes + status: unknown + grammar.html#q25.0: + title: G Grammar of CSS 2.1 + status: unknown + grammar.html#grammar: + title: G.1 Grammar + status: unknown + grammar.html#scanner: + title: G.2 Lexical scanner + status: unknown + grammar.html#tokenizer-diffs: + title: G.3 Comparison of tokenization in CSS 2.1 and CSS1 + status: unknown + grammar.html#q25.4: + title: G.4 Implementation note + status: unknown From 9954ae589dc889fc6cef596fc74698ecf53396fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 17:07:41 +0100 Subject: [PATCH 002/150] Verify and document CSS 2.1 box model compliance --- Source/HtmlRenderer/Core/Dom/CssBox.cs | 127 +++--------------- .../BoxModel/BorderIntegrationTests.cs | 126 +++++++++++++++++ .../BoxModel/BoxDimensionsIntegrationTests.cs | 35 +++++ .../BoxModel/CollapsedMarginBeforeTests.cs | 58 +++++--- .../BoxModel/MarginIntegrationTests.cs | 76 +++++++++++ .../BoxModel/PaddingIntegrationTests.cs | 34 +++++ .../BorderStylePaintIntegrationTests.cs | 126 ++++++----------- Spec/css21.yml | 37 +++-- 8 files changed, 392 insertions(+), 227 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BoxDimensionsIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/BoxModel/PaddingIntegrationTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index 30b435ee7..e0441b5c7 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -1611,14 +1611,6 @@ internal bool HasJustInlineSiblings() return ParentBox != null && DomUtils.ContainsInlinesOnly(ParentBox); } - /// - /// Gets the rectangles where inline box will be drawn. See Remarks for more info. - /// - /// Rectangles where content should be placed - /// - /// Inline boxes can be split across different LineBoxes, that's why this method - /// Delivers a rectangle for each LineBox related to this box, if inline. - /// /// /// Inherits inheritable values from parent. /// @@ -1628,31 +1620,15 @@ internal bool HasJustInlineSiblings() } /// - /// Gets the result of collapsing the vertical margins of the two boxes, per CSS 2.1 §8.3.1's real - /// "adjoining margin set" rule (collapse to max(positives) + min(negatives) over the WHOLE - /// set of margins that adjoin - not a pairwise Math.Max between exactly two, which only - /// happens to match when both margins are positive). The set can span: - /// - /// this box's own top margin, extended forward through an unblocked chain of first-in-flow - /// children () - collapsing "through" the box so a nested - /// child sits flush with it rather than being pushed down twice; - /// 's own bottom-margin chain, and - if - /// is itself self-collapsing (empty, zero border/padding/height) - reaching further back through - /// however many more self-collapsing siblings (and, if the chain runs out of siblings, the - /// parent's own top-margin chain) precede it, since a self-collapsing box is transparent to margin - /// collapsing entirely (its own top and bottom margins merge into the same set too). - /// - /// A floated box's own margin never participates in collapsing at all (CSS 2.1 §8.3.1: floats are - /// out of flow) - the predecessor's trailing margin is still real space, but this box's own margin - /// adds on top of it rather than merging. + /// The space above this box after collapsing every margin that adjoins its top edge (CSS 2.1 §8.3.1). /// /// the previous in-flow box under the same parent, or null if this is the first - /// Resulting top margin internal double MarginTopCollapse(CssBox prevSibling) { double value; if (IsFloated) { + // A float's margin never collapses, but the previous sibling's still takes up space value = (prevSibling?.ActualMarginBottom ?? 0) + ActualMarginTop; CollapsedMarginTop = value; } @@ -1663,35 +1639,22 @@ internal double MarginTopCollapse(CssBox prevSibling) CollectAdjoiningMarginsBeforeSibling(prevSibling, margins); value = CollapseMarginSet(margins); - // A real sibling gap is always genuinely, geometrically realized right here (this box's - // own position is baseTopWithoutMargin + value) - so it's a fresh anchor for whatever - // comes next to build on, independent of anything before prevSibling. CollapsedMarginTop = value; } else if (_parentBox != null && !BlocksParentChildTopJoin(_parentBox, this)) { - // This box's own semantic top margin, extended forward into its own subtree (its first - // in-flow child's chain, if unblocked, and so on). var forwardMargins = new List(); CollapsedMarginTopChain(this, forwardMargins); var forwardValue = CollapseMarginSet(forwardMargins); - // Only the excess beyond what the parent's OWN placement already geometrically realized is - // left for this box to apply - parent.CollapsedMarginTop is the CUMULATIVE amount actually - // applied so far along this unblocked first-in-flow-child chain (not merely this box's own - // semantic total): critically, that's 0 at the true document root, which cannot itself - // move no matter how large a margin collapses into it - see - // FirstChildMarginCollapsingToTheRoot_IsStillTruncated_UnlikePeachPDF, where the excess - // must still surface as real displacement (later truncated by page-break logic) rather - // than being silently absorbed by a root that only ever contributes its own 0 margin. + // The parent's placement already applied part of the chain. The root applies none, as it + // can't move, so the remainder has to show up here. value = Math.Max(0, forwardValue - _parentBox.CollapsedMarginTop); CollapsedMarginTop = _parentBox.CollapsedMarginTop + value; } else { value = ActualMarginTop; - // Blocked (or no parent at all): a fresh realized-origin, unrelated to whatever the parent - // chain was carrying - matches every other "stop the chain here" case in this method. CollapsedMarginTop = value; } @@ -1705,12 +1668,8 @@ internal double MarginTopCollapse(CssBox prevSibling) } /// - /// Adds 's own top margin, and - if unblocked - its first in-flow child's - /// own chain (recursively), to . This is the "forward" half of §8.3.1's - /// adjoining set: it's what lets a parent's own top-margin placement already account for a nested, - /// unblocked child's top margin too, so the child can sit flush with the parent instead of being - /// pushed down a second time once the child is itself placed (see 's - /// no-prevSibling branch). + /// Adds the top margin of and, while nothing separates them, of its first + /// in-flow descendants, so a nested child isn't pushed down a second time when it's placed. /// private static void CollapsedMarginTopChain(CssBox box, List margins) { @@ -1723,11 +1682,7 @@ private static void CollapsedMarginTopChain(CssBox box, List margins) } } - /// - /// Adds 's own bottom margin, and - if unblocked - its last in-flow child's - /// own chain (recursively), to . The "forward" half for the bottom edge, - /// mirroring . - /// + /// The bottom edge counterpart of . private static void CollapsedMarginBottomChain(CssBox box, List margins) { margins.Add(ResolveMarginBottomForChain(box)); @@ -1740,18 +1695,9 @@ private static void CollapsedMarginBottomChain(CssBox box, List margins) } /// - /// resolves a percentage margin against this box's - /// OWN .Width, not its containing block's (a pre-existing quirk elsewhere in - /// this engine, left as-is here to avoid a wider blast radius) - fine once a box's own width has - /// actually been computed, but deliberately reads a - /// not-yet-laid-out descendant's margin (that's the whole point of the forward lookahock - see its - /// own remarks), so Size.Width is still the zero default there. Resolves against - /// 's width instead when this box's own margin is a percentage - /// and its containing block is one level up (already sized, since this is always called from that - /// ancestor's own placement, after its own width computation ran) - correct AND safe. A percentage - /// margin more than one unblocked level deep still can't be resolved this way (the ancestor two - /// levels up hasn't been sized either at this point) and falls back to the same 0 the pre-existing - /// bug already produced. + /// resolves percentages against the box's own width, which + /// is still 0 for a descendant that isn't laid out yet. Its containing block is sized by now, as long as + /// it's the direct parent, deeper percentages still resolve to 0. /// private static double ResolveMarginTopForChain(CssBox box) { @@ -1773,11 +1719,8 @@ private static double ResolveMarginBottomForChain(CssBox box) } /// - /// The "backward" half of §8.3.1's adjoining set: adds 's own bottom- - /// margin chain to , and - if is itself - /// self-collapsing (so its own top and bottom margins merge into the very same set, per spec) - - /// reaches further back through however many more self-collapsing predecessors adjoin before it, - /// including (once siblings run out) the parent's own forward chain. + /// Adds the bottom margins adjoining the top of the box after . An empty + /// sibling lets margins through, so the search continues before it, up to the parent's top margin. /// private static void CollectAdjoiningMarginsBeforeSibling(CssBox sibling, List margins) { @@ -1794,22 +1737,13 @@ private static void CollectAdjoiningMarginsBeforeSibling(CssBox sibling, List - /// Walks back from through any run of self-collapsing (empty) - /// siblings to the first real one - the box whose own border-box bottom edge is a genuine position - /// baseline, since a self-collapsing box's own position already reflects part of the shared - /// adjoining-margin set rather than occupying real space of its own. Returns null if - /// itself is null, or the ENTIRE preceding run turns out to be - /// self-collapsing all the way back (meaning there's no real sibling to anchor against - falls - /// through to the parent's own content-top instead, same as having no previous sibling at all). + /// The last sibling before that isn't empty and as such marks a real + /// position, or null if there's none and the parent's content top is the anchor. /// internal static CssBox FindMarginAnchorSibling(CssBox prevSibling) { @@ -1822,11 +1756,8 @@ internal static CssBox FindMarginAnchorSibling(CssBox prevSibling) } /// - /// CSS 2.1 §8.3.1: an empty box (no border/padding on either the top or bottom edge, and zero - /// height) is "self-collapsing" - transparent to margin collapsing, its own top and bottom margins - /// merge into whatever set adjoins either edge, rather than keeping them apart. Relies on - /// already being fully laid out (true for a previous sibling, which is - /// always placed - including its own subtree - before this box is). + /// A box without height, borders and padding, whose top and bottom margins collapse together + /// (CSS 2.1 §8.3.1). Needs to be laid out already. /// private static bool IsSelfCollapsingEmpty(CssBox box) { @@ -1839,9 +1770,7 @@ private static bool BlocksParentChildTopJoin(CssBox parent, CssBox child) { return parent.ActualBorderTopWidth > 0.1 || parent.ActualPaddingTop > 0.1 || (!string.IsNullOrEmpty(child.Clear) && child.Clear != CssConstants.None) - // CSS 2.1 §8.3.1 margin collapsing is a block-formatting-context concept - an inline - // parent (e.g. a block-level child anonymously boxed inside a ) is not a block - // container to collapse "through" in the first place. + // Only block containers collapse margins with their children, e.g. not a around a
|| parent.Display == CssConstants.Inline || parent.Display == CssConstants.InlineBlock || EstablishesBlockFormattingContext(parent); } @@ -1871,11 +1800,8 @@ private static bool EstablishesBlockFormattingContext(CssBox box) } /// - /// The first in-flow child that's itself a block container - i.e. one margin collapsing could - /// possibly apply to (CSS 2.1 §8.3.1 is a block-formatting-context concept). Skips not just - /// out-of-flow/display:none children but inline/inline-block ones too: an anonymous inline/text - /// run preceding a real block child (e.g. "text<p>...</p>") is not itself a candidate - /// to collapse through, and must not stop the search before reaching the real block child. + /// The first in-flow block-level child, the only kind that collapses margins with its parent. Inline + /// content before it, as in "text<p>...</p>", doesn't count. /// private static CssBox FirstInFlowChild(CssBox box) { @@ -1894,11 +1820,7 @@ private static bool IsBlockLevelInFlowChild(CssBox b) && b.Display != CssConstants.Inline && b.Display != CssConstants.InlineBlock; } - /// - /// CSS 2.1 §8.3.1: an adjoining margin set collapses to the largest positive margin plus the most - /// negative (smallest) margin in the set - equal to a plain max() only when every member shares the - /// same sign. - /// + /// The largest positive plus the most negative margin of the set (CSS 2.1 §8.3.1). private static double CollapseMarginSet(List margins) { double maxPositive = 0; @@ -1940,11 +1862,7 @@ private double CalculateActualRight() /// Resulting bottom margin private double MarginBottomCollapse() { - // Only called when at least one in-flow child exists (see the caller's IsOutOfFlow gate), - // so this is guaranteed to find one - an out-of-flow (floated/absolutely-positioned) child - // doesn't contribute to this box's own auto-height (CSS 2.1 10.6.3/10.6.7) and must not be - // used as "the last child" here, matching PeachPDF's own MarginBottomCollapse - // (`Boxes.Last(b => !b.IsOutOfFlow)`). + // Out-of-flow children don't add to the auto height (CSS 2.1 §10.6.3), the caller ensures there's an in-flow one var lastInFlowBox = _boxes.FindLast(b => !b.IsOutOfFlow); double margin = 0; @@ -1953,8 +1871,7 @@ private double MarginBottomCollapse() var lastChildBottomMargin = lastInFlowBox.ActualMarginBottom; margin = Height == "auto" ? Math.Max(ActualMarginBottom, lastChildBottomMargin) : lastChildBottomMargin; } - // StaticBottom (not ActualBottom): a relatively-positioned last child's own visual offset must - // not widen this box's auto height (CSS 2.1 9.4.3). Ported from PeachPDF's MarginBottomCollapse. + // A relative offset doesn't change the parent's height (CSS 2.1 §9.4.3) return Math.Max(ActualBottom, lastInFlowBox.StaticBottom + margin + ActualPaddingBottom + ActualBorderBottomWidth); } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs new file mode 100644 index 000000000..6b7db4e1c --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs @@ -0,0 +1,126 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.BoxModel; + +[DoNotParallelize] +[TestClass] +public sealed class BorderIntegrationTests +{ + [TestMethod] + [Spec("css21", "box.html#border-width-properties")] + public void WidthKeywords_GrowFromThinOverMediumToThick() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
")); + + var thin = LayoutHarness.FindById(root, "thin")!.ActualBorderTopWidth; + var medium = LayoutHarness.FindById(root, "medium")!.ActualBorderTopWidth; + var thick = LayoutHarness.FindById(root, "thick")!.ActualBorderTopWidth; + + Assert.IsGreaterThan(0, thin); + Assert.IsGreaterThan(thin, medium); + Assert.IsGreaterThan(medium, thick); + } + + [TestMethod] + [Spec("css21", "box.html#border-width-properties")] + public void StyleNone_ComputesTheWidthToZero() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
")); + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(0, b.ActualBorderTopWidth); + Assert.AreEqual(0, b.Size.Height); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "box.html#border-width-properties")] + public void StyleHidden_ComputesTheWidthToZero() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
")); + + Assert.AreEqual(0, LayoutHarness.FindById(root, "b")!.ActualBorderTopWidth); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "box.html#border-width-properties")] + public void InitialWidth_IsMedium() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
")); + + Assert.AreEqual(LayoutHarness.FindById(root, "medium")!.ActualBorderTopWidth, LayoutHarness.FindById(root, "initial")!.ActualBorderTopWidth); + } + + [TestMethod] + [Spec("css21", "box.html#border-color-properties")] + public void TransparentColor_PaintsNothingVisible() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
")); + + Assert.AreEqual(0, LayoutHarness.FindById(root, "b")!.ActualBorderTopColor.A); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "box.html#border-color-properties")] + public void InitialColor_IsTheElementsColor() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
")); + + Assert.AreEqual(RColor.FromArgb(10, 20, 30), LayoutHarness.FindById(root, "b")!.ActualBorderTopColor); + } + + [TestMethod] + [Spec("css21", "box.html#border-shorthand-properties")] + public void BorderShorthand_SetsAllFourSides() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
")); + var b = LayoutHarness.FindById(root, "b")!; + + foreach (var (width, style, color) in new[] + { + (b.ActualBorderTopWidth, b.BorderTopStyle, b.ActualBorderTopColor), + (b.ActualBorderRightWidth, b.BorderRightStyle, b.ActualBorderRightColor), + (b.ActualBorderBottomWidth, b.BorderBottomStyle, b.ActualBorderBottomColor), + (b.ActualBorderLeftWidth, b.BorderLeftStyle, b.ActualBorderLeftColor), + }) + { + Assert.AreEqual(3, width); + Assert.AreEqual("dashed", style); + Assert.AreEqual(RColor.FromArgb(255, 0, 0), color); + } + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "box.html#bidi-box-model")] + public void SplitRtlInline_PutsTheRightBorderOnTheFirstLine() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
aaa bbb ccc ddd eee
")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "s")!); + + var lines = g.Log.OfType().OrderBy(l => l.Y1).ToList(); + Assert.AreEqual(RColor.FromArgb(0, 0, 255), lines.First().Color); + Assert.AreEqual(RColor.FromArgb(255, 0, 0), lines.Last().Color); + } + + [TestMethod] + [Spec("css21", "box.html#border-shorthand-properties")] + public void SideShorthand_OnlySetsThatSide() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
")); + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(4, b.ActualBorderTopWidth); + Assert.AreEqual(RColor.FromArgb(0, 0, 255), b.ActualBorderTopColor); + Assert.AreEqual(0, b.ActualBorderRightWidth); + Assert.AreEqual(0, b.ActualBorderBottomWidth); + Assert.AreEqual(0, b.ActualBorderLeftWidth); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BoxDimensionsIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BoxDimensionsIntegrationTests.cs new file mode 100644 index 000000000..bfbbfb2f0 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BoxDimensionsIntegrationTests.cs @@ -0,0 +1,35 @@ +using HtmlRenderer.IntegrationTest.TestSupport; + +namespace HtmlRenderer.IntegrationTest.BoxModel; + +[DoNotParallelize] +[TestClass] +public sealed class BoxDimensionsIntegrationTests +{ + private const string Box = "
"; + + [TestMethod] + [Spec("css21", "box.html#box-dimensions")] + public void BorderBox_IsContentPlusPaddingPlusBorder_AndTheMarginSurroundsIt() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(Box)); + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(7, b.Location.X); + Assert.AreEqual(7, b.Location.Y); + Assert.AreEqual(130, b.Size.Width); + Assert.AreEqual(80, b.Size.Height); + } + + [TestMethod] + [Spec("css21", "box.html#box-dimensions")] + public void Background_CoversThePaddingAndBorderArea() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap(Box)); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + + var background = g.Log.OfType().Single(); + Assert.AreEqual((7d, 7d, 130d, 80d), (background.X, background.Y, background.Width, background.Height)); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/CollapsedMarginBeforeTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/CollapsedMarginBeforeTests.cs index 459f48f99..b3f5fc8fa 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/CollapsedMarginBeforeTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/CollapsedMarginBeforeTests.cs @@ -2,21 +2,6 @@ namespace HtmlRenderer.IntegrationTest.BoxModel; -/// -/// CSS 2.1 §8.3.1's adjoining-margin -/// set, resolved at one break point by the frame the boxes are children of. -/// -/// -/// CssBox.MarginTopCollapse (Dom/CssBox.cs) implements the real adjoining-margin-set algorithm: -/// CollapsedMarginTopChain/CollapsedMarginBottomChain walk forward through an unblocked run of -/// first/last-in-flow children (so a nested child sits flush with its parent instead of being pushed down a -/// second time), CollectAdjoiningMarginsBeforeSibling walks backward through however many self-collapsing -/// (empty) siblings precede a box - reaching all the way to the parent's own top margin if the whole run turns -/// out self-collapsing - and CollapseMarginSet collapses the resulting set to -/// max(positives) + min(negatives), not a plain Math.Max between just two values (which only -/// happens to match when both margins share a sign). A floated box's own margin never participates in -/// collapsing at all (CSS 2.1 §8.3.1: floats are out of flow). -/// [DoNotParallelize] [TestClass] public sealed class CollapsedMarginBeforeTests @@ -24,10 +9,9 @@ public sealed class CollapsedMarginBeforeTests private const double Delta = 0.5; [TestMethod] + [Spec("css21", "box.html#collapsing-margins")] public void AdjoiningSiblingMargins_CollapseToTheLargerPositive() { - // Same-sign case: Math.Max(30, 10) = 30 happens to equal the spec result (max of the positives), so this - // one genuinely passes on this fork. var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( "
" + "
")); @@ -39,6 +23,7 @@ public void AdjoiningSiblingMargins_CollapseToTheLargerPositive() } [TestMethod] + [Spec("css21", "box.html#collapsing-margins")] public void MixedSignSiblingMargins_Sum() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -52,6 +37,21 @@ public void MixedSignSiblingMargins_Sum() } [TestMethod] + [Spec("css21", "box.html#collapsing-margins")] + public void NegativeSiblingMargins_CollapseToTheMostNegative() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
" + + "
")); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(-30, b.Location.Y - a.ActualBottom, Delta); + } + + [TestMethod] + [Spec("css21", "box.html#collapsing-margins")] public void ASelfCollapsingSiblingBetweenTwoBoxes_JoinsTheSet() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -62,11 +62,11 @@ public void ASelfCollapsingSiblingBetweenTwoBoxes_JoinsTheSet() var a = LayoutHarness.FindById(root, "a")!; var b = LayoutHarness.FindById(root, "b")!; - // The whole run should collapse to one value - the largest member, not a sum of the pairs. Assert.AreEqual(40, b.Location.Y - a.ActualBottom, Delta); } [TestMethod] + [Spec("css21", "box.html#collapsing-margins")] public void AFirstInFlowChildsTopMargin_JoinsItsParentsOwnSet() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -78,13 +78,26 @@ public void AFirstInFlowChildsTopMargin_JoinsItsParentsOwnSet() var outer = LayoutHarness.FindById(root, "outer")!; var inner = LayoutHarness.FindById(root, "inner")!; - // One collapsed value for the whole chain, taken once: the outer box should move down by it and the - // inner box should sit at its parent's content top rather than being pushed down again. Assert.AreEqual(30, outer.Location.Y - a.ActualBottom, Delta); Assert.AreEqual(outer.Location.Y, inner.Location.Y, Delta); } [TestMethod] + [Spec("css21", "box.html#collapsing-margins")] + public void AutoHeightParent_PassesItsLastChildsBottomMarginOn() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
" + + "
")); + + var p = LayoutHarness.FindById(root, "p")!; + var n = LayoutHarness.FindById(root, "n")!; + + Assert.AreEqual(30, n.Location.Y - p.ActualBottom, Delta); + } + + [TestMethod] + [Spec("css21", "box.html#collapsing-margins")] public void ABorderOnTheParent_BlocksTheChainAndLeavesBothMarginsStanding() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -101,6 +114,7 @@ public void ABorderOnTheParent_BlocksTheChainAndLeavesBothMarginsStanding() } [TestMethod] + [Spec("css21", "box.html#collapsing-margins")] public void AFloatsOwnMarginDoesNotMerge_ButThePredecessorsStillOccupiesSpace() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -110,8 +124,6 @@ public void AFloatsOwnMarginDoesNotMerge_ButThePredecessorsStillOccupiesSpace() var a = LayoutHarness.FindById(root, "a")!; var f = LayoutHarness.FindById(root, "f")!; - // Summed, not merged: a float is out of flow so its margin should never adjoin anything, but the - // predecessor's trailing margin is real space it must sit after. Assert.AreEqual(15, f.Location.Y - a.ActualBottom, Delta); } @@ -119,6 +131,7 @@ public void AFloatsOwnMarginDoesNotMerge_ButThePredecessorsStillOccupiesSpace() [DataRow("overflow:hidden")] [DataRow("float:left;width:100px")] [DataRow("position:absolute;width:100px")] + [Spec("css21", "box.html#collapsing-margins")] public void NewBlockFormattingContext_KeepsItsFirstChildsTopMarginInside(string parentStyle) { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -131,6 +144,7 @@ public void NewBlockFormattingContext_KeepsItsFirstChildsTopMarginInside(string } [TestMethod] + [Spec("css21", "box.html#collapsing-margins")] public void FixedHeightParent_DoesNotPassItsLastChildsBottomMarginOn() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs new file mode 100644 index 000000000..1d3e4b900 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs @@ -0,0 +1,76 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.BoxModel; + +[DoNotParallelize] +[TestClass] +public sealed class MarginIntegrationTests +{ + private const double Delta = 0.5; + + [TestMethod] + [Spec("css21", "box.html#margin-properties")] + public void NegativeMargin_PullsTheBoxOutOfTheContentArea() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
")); + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(30, b.Location.X, Delta); + Assert.AreEqual(220, b.Size.Width, Delta); + } + + [TestMethod] + [Spec("css21", "box.html#margin-properties")] + public void VerticalPercentageMargin_ResolvesAgainstTheContainingBlockWidth() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
")); + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(20, b.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "box.html#margin-properties")] + public void HorizontalPercentageMargin_ResolvesAgainstTheContainingBlockWidth() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
")); + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(20, b.Location.X, Delta); + } + + [TestMethod] + [Spec("css21", "box.html#margin-properties")] + public void InlineHorizontalMargin_MovesTheText() + { + var (plainRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
xy
")); + var (marginRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
xy
")); + + var plain = FirstWord(LayoutHarness.FindById(plainRoot, "s")!)!; + var withMargin = FirstWord(LayoutHarness.FindById(marginRoot, "s")!)!; + + Assert.AreEqual(30, withMargin.Left - plain.Left, Delta); + } + + [TestMethod] + [Spec("css21", "box.html#margin-properties")] + public void InlineVerticalMargins_DontChangeTheLineHeight() + { + var (plainRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
x y z
")); + var (marginRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
x y z
")); + + var plain = LayoutHarness.FindById(plainRoot, "p")!; + var withMargins = LayoutHarness.FindById(marginRoot, "p")!; + + Assert.AreEqual(plain.Size.Height, withMargins.Size.Height, Delta); + } + + private static CssRect? FirstWord(CssBox box) => + box.Words.Count > 0 ? box.Words[0] : box.Boxes.Select(FirstWord).FirstOrDefault(w => w != null); +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/PaddingIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/PaddingIntegrationTests.cs new file mode 100644 index 000000000..98ad7d8f3 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/PaddingIntegrationTests.cs @@ -0,0 +1,34 @@ +using HtmlRenderer.IntegrationTest.TestSupport; + +namespace HtmlRenderer.IntegrationTest.BoxModel; + +[DoNotParallelize] +[TestClass] +public sealed class PaddingIntegrationTests +{ + private const double Delta = 0.5; + + [TestMethod] + [Spec("css21", "box.html#padding-properties")] + public void PercentagePadding_ResolvesAgainstTheContainingBlockWidth() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
")); + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(20, b.ActualPaddingTop, Delta); + Assert.AreEqual(10, b.ActualPaddingLeft, Delta); + Assert.AreEqual(30, b.Size.Height, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "box.html#padding-properties")] + public void NegativePadding_IsIgnored() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
")); + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(0, b.ActualPaddingLeft); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs index f855215f6..92e44303f 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs @@ -5,37 +5,6 @@ namespace HtmlRenderer.IntegrationTest.Painting; -/// -/// Verifies border-style painting actually produces the right geometry/color, not just that it doesn't crash - -/// port of PeachPDF.Tests' BorderStylePaintIntegrationTests, adapted to this fork's real (and more -/// limited) BordersDrawHandler. -/// -/// -/// Confirmed by direct source read of Core/Handlers/BordersDrawHandler.cs: the private -/// DrawBorder(Border, CssBox, RGraphics, RRect, bool, bool) that DrawBoxBorders calls for every -/// ordinary (non-rounded) box only special-cases style == Inset || style == Outset - that branch goes -/// through SetInOutsetRectanglePoints + g.DrawPolygon (a real, distinct, beveled-quad path that -/// darkens the color via Darken on specific sides - see GetColor). Every OTHER style -/// (solid/dotted/dashed, and the CSS-recognized-but-undispatched double/ -/// groove/ridge) falls into the shared "else" branch and draws as a SINGLE straight line via -/// g.DrawLine - i.e. double/groove/ridge are indistinguishable from solid on -/// this fork; they do not throw (unlike the old PeachPDF bug this file's source documents), they just silently -/// render wrong. RecordingGraphics.DrawLineCall does not capture pen dash style (only color/position), so -/// unlike PeachPDF's TestRecordingGraphics.DrawLineCall (which also carries a stroke Width), those -/// fields are not available to assert on here. -/// -/// Also confirmed by direct source read of Core/Parse/DomParser.cs (~436-449): the deprecated -/// presentational border HTML attribute forces solid on all four sides for a plain (non-table) element, -/// same as PeachPDF. -/// -/// The old hand-rolled CssParser.SplitMultiDirectionValues/SplitValues (which split strictly on -/// spaces, with no parenthesis-awareness, and so used to mis-split a space-containing rgb(r, g, b) -/// token inside a multi-value shorthand like a 4-value border-color) no longer exists - the CSS engine -/// port's real tokenizer handles parenthesized functions correctly regardless of internal spaces. The 4-value -/// color test below still uses comma-only rgb(r,g,b) input for historical parity with that old -/// constraint, but the resolved BorderTopColor/etc. values it asserts on are the engine's own -/// normalized "rgb(r, g, b)" (spaced) serialization, not the literal input text. -/// [DoNotParallelize] [TestClass] public sealed class BorderStylePaintIntegrationTests @@ -55,16 +24,28 @@ public void BorderStyle_AllCss1Keywords_DoNotThrowWhenPainted(string style) $"
x
")); var div = PaintHarness.FindById(root, "b")!; - // Should not throw for any CSS1 border-style keyword - including double/groove/ridge, which this fork - // doesn't specially render (they fall back to a single solid-colored line, see this class's remarks). PaintHarness.PaintBox(container, div); } - [Ignore("This fork's BordersDrawHandler.DrawBorder has no 'double' case for the non-rounded path - the style " + - "falls into the shared 'else' branch (same as solid/dotted/dashed) and draws ONE line via " + - "g.DrawLine, not two same-color stripes with a gap between them. Confirmed by direct source read of " + - "BordersDrawHandler.cs's private DrawBorder overload.")] [TestMethod] + [DataRow("inset", 50, 100)] + [DataRow("outset", 100, 50)] + [Spec("css21", "box.html#border-style-properties")] + public void InsetAndOutset_DarkenOppositeSides(string style, int topLeft, int bottomRight) + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + $"
x
")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + + // Painted in the order top, left, bottom, right + var colors = g.Log.OfType().Select(p => p.Color.R).ToList(); + CollectionAssert.AreEqual(new[] { topLeft, topLeft, bottomRight, bottomRight }, colors.Select(c => (int)c).ToList()); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "box.html#border-style-properties")] public void BorderStyleDouble_DrawsTwoEqualWidthStripesWithGap() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -78,10 +59,9 @@ public void BorderStyleDouble_DrawsTwoEqualWidthStripesWithGap() Assert.AreNotEqual(lines[0].Y1, lines[1].Y1, "expected two visually distinct stripes, not one merged line"); } - [Ignore("This fork's GetColor only darkens for Inset/Outset styles (see BordersDrawHandler.GetColor) - " + - "'groove' isn't dispatched at all, so it draws a single line in the base color (51,51,51), not a " + - "darker-outer / base-color-inner stripe pair. Confirmed by direct source read.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "box.html#border-style-properties")] public void BorderStyleGroove_OuterStripeIsDarker_InnerStripeIsBaseColor() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -96,9 +76,9 @@ public void BorderStyleGroove_OuterStripeIsDarker_InnerStripeIsBaseColor() Assert.AreEqual(RColor.FromArgb(51, 51, 51), lines[1].Color); } - [Ignore("Same gap as BorderStyleDouble_DrawsTwoEqualWidthStripesWithGap, mirrored onto the right edge - " + - "'double' falls back to a single g.DrawLine call instead of two vertical stripes with a gap.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "box.html#border-style-properties")] public void BorderRightStyleDouble_DrawsTwoEqualWidthVerticalStripesWithGap() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -113,9 +93,9 @@ public void BorderRightStyleDouble_DrawsTwoEqualWidthVerticalStripesWithGap() Assert.AreNotEqual(lines[0].X1, lines[1].X1, "expected two visually distinct vertical stripes"); } - [Ignore("Same gap as BorderStyleGroove_OuterStripeIsDarker_InnerStripeIsBaseColor, mirrored onto the left " + - "edge - 'groove' isn't dispatched, so no darkened outer stripe is produced.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "box.html#border-style-properties")] public void BorderLeftStyleGroove_OuterStripeIsDarker_InnerStripeIsBaseColor() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -131,23 +111,13 @@ public void BorderLeftStyleGroove_OuterStripeIsDarker_InnerStripeIsBaseColor() } [TestMethod] + [Spec("css21", "box.html#border-color-properties")] public void BorderColorPerSide_ResolvesDistinctColorPerEdge() { - // Adapted from PeachPDF's version, which also covers 'currentcolor' on the left edge and asserts via - // DrawPolygonCall (this fork draws solid borders as g.DrawLine, not a mitered polygon quad - see this - // class's remarks) - the currentcolor sub-case is split out into - // BorderColorPerSide_CurrentColor_DoesNotResolveToElementColor below since it fails for an unrelated - // reason (currentcolor isn't a recognized color keyword on this fork at all). - // NOTE: colors deliberately use a 2-digit component (e.g. "rgb(10,0,0)" not "rgb(1,0,0)") to avoid a - // separate, confirmed real bug: CssValueParser.TryGetColor (Core/Parse/CssValueParser.cs ~313) requires - // "length > 10" for an rgb(...) token to be dispatched to GetColorByRgb at all - any all-single-digit - // triple like "rgb(1,0,0)" or "rgb(9,9,9)" is exactly 10 characters and silently fails IsColorValid, so - // the color is dropped entirely (paints as RColor.Empty) for a reason wholly unrelated to what this test - // is actually verifying (per-edge border-color resolution). Confirmed by direct instrumentation. var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( "
x
")); + "border-top-color: rgb(1,0,0); border-right-color: rgb(0,1,0); " + + "border-bottom-color: rgb(0,0,1); border-left-color: rgb(90,90,90)'>x
")); var div = PaintHarness.FindById(root, "b")!; var g = PaintHarness.PaintBox(container, div); @@ -165,19 +135,15 @@ public void BorderColorPerSide_ResolvesDistinctColorPerEdge() var left = vertical.OrderBy(l => l.X1).First(); var right = vertical.OrderByDescending(l => l.X1).First(); - Assert.AreEqual(RColor.FromArgb(10, 0, 0), top.Color); - Assert.AreEqual(RColor.FromArgb(0, 10, 0), right.Color); - Assert.AreEqual(RColor.FromArgb(0, 0, 10), bottom.Color); + Assert.AreEqual(RColor.FromArgb(1, 0, 0), top.Color); + Assert.AreEqual(RColor.FromArgb(0, 1, 0), right.Color); + Assert.AreEqual(RColor.FromArgb(0, 0, 1), bottom.Color); Assert.AreEqual(RColor.FromArgb(90, 90, 90), left.Color); } - [Ignore("'currentcolor' is not a recognized color keyword anywhere on this fork: CssValueParser.TryGetColor " + - "(Core/Parse/CssValueParser.cs ~303-331) only special-cases '#...', 'rgb(...)' and 'rgba(...)' - " + - "anything else (including 'currentcolor') falls through to GetColorByName, which asks the adapter " + - "for a named System.Drawing color, finds none, and yields a fully-transparent RColor(0,0,0,0) " + - "instead of resolving to the element's own 'color'. Confirmed by direct source read.")] + [Ignore("Gap: currentColor isn't supported in border-color")] [TestMethod] - public void BorderColorPerSide_CurrentColor_DoesNotResolveToElementColor() + public void BorderColorPerSide_CurrentColor_ResolvesToElementColor() { var (root, _) = PaintHarness.Layout(PaintHarness.Wrap( "
g.DrawPath), which - like the non-rounded path - - // has no double/groove/ridge case either (GetPen's DashStyle switch has no 'double' case), so it - // silently falls back to a single solid-colored stroke. var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( "
x
")); var div = PaintHarness.FindById(root, "b")!; @@ -231,15 +188,13 @@ public void BorderStyleDoubleWithRoundedCorners_FallsBackToASingleUnstripedStrok var g = PaintHarness.PaintBox(container, div); - // RecordingGraphics.DrawPath is a no-op override (nothing gets logged for it), so instead of asserting a - // DrawPathCall exists (as PeachPDF's TestRecordingGraphics does), verify the negative space: no crash, - // and no DrawLine/DrawPolygon calls - proving it took the rounded DrawPath branch, not either - // non-rounded one. + // RecordingGraphics doesn't log DrawPath, the absence of lines and polygons shows the rounded path was taken Assert.IsFalse(g.Log.OfType().Any()); Assert.IsFalse(g.Log.OfType().Any()); } [TestMethod] + [Spec("css21", "box.html#border-style-properties")] public void BorderStyleTwoValueShorthand_OnlyPaintsTheSolidSides() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -253,9 +208,6 @@ public void BorderStyleTwoValueShorthand_OnlyPaintsTheSolidSides() var g = PaintHarness.PaintBox(container, div); - // This fork draws solid borders as a single straight line (BordersDrawHandler's shared non-rounded - // "else" branch), not a mitered polygon quad like PeachPDF - see this class's remarks. Only left/right - // (solid) should draw anything; top/bottom (none) draw nothing. var lines = g.Log.OfType().ToList(); Assert.AreEqual(2, lines.Count); Assert.IsTrue(lines.All(l => Math.Abs(l.X1 - l.X2) < 0.01), "expected only vertical (left/right) border lines"); @@ -263,14 +215,13 @@ public void BorderStyleTwoValueShorthand_OnlyPaintsTheSolidSides() } [TestMethod] + [Spec("css21", "box.html#border-color-properties")] public void BorderColorFourValueShorthand_ResolvesTopRightBottomLeftPerSide() { var (root, _) = PaintHarness.Layout(PaintHarness.Wrap( "
x
")); var div = PaintHarness.FindById(root, "b")!; - // The resolved longhand values are the engine's own normalized "rgb(r, g, b)" (spaced) text, not - // the literal comma-only input - see this class's remarks. Assert.AreEqual("rgb(1, 0, 0)", div.BorderTopColor); Assert.AreEqual("rgb(0, 1, 0)", div.BorderRightColor); Assert.AreEqual("rgb(0, 0, 1)", div.BorderBottomColor); @@ -278,10 +229,9 @@ public void BorderColorFourValueShorthand_ResolvesTopRightBottomLeftPerSide() } [TestMethod] + [Spec("css21", "box.html#border-width-properties")] public void BorderWidthTwoValueShorthand_ThenLaterOneValue_OverridesAllSidesPerSpecificity() { - // Mirrors PeachPDF's own "border-width: 0 2em" (2-value: top/bottom=0, left/right=2em) followed later by - // a same-specificity "border-width: 1em" (all sides) - the later rule must win outright on every side. var (root, _) = PaintHarness.Layout( "" + "
"); diff --git a/Spec/css21.yml b/Spec/css21.yml index 44699dd03..e32d4d78e 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -244,40 +244,53 @@ sections: status: unknown box.html#box-model: title: 8 Box model - status: unknown + status: partial box.html#box-dimensions: title: 8.1 Box dimensions - status: unknown + status: supported box.html#mpb-examples: title: 8.2 Example of margins, padding, and borders - status: unknown + status: n/a box.html#margin-properties: title: 8.3 Margin properties - status: unknown + status: partial + gaps: + - Horizontal percentage margins resolve against the box's own width instead of the containing block's width box.html#collapsing-margins: title: 8.3.1 Collapsing margins - status: unknown + status: supported box.html#padding-properties: title: 8.4 Padding properties - status: unknown + status: partial + gaps: + - Negative padding is applied instead of being rejected box.html#border-properties: title: 8.5 Border properties - status: unknown + status: partial box.html#border-width-properties: title: 8.5.1 Border width - status: unknown + status: partial + gaps: + - "'border-style: hidden' keeps the border width instead of computing it to 0" + - The initial width is 2px, while 'medium' is 3px box.html#border-color-properties: title: 8.5.2 Border color - status: unknown + status: partial + gaps: + - The initial color is black instead of the element's 'color' box.html#border-style-properties: title: 8.5.3 Border style - status: unknown + status: partial + gaps: + - double, groove and ridge are painted as solid box.html#border-shorthand-properties: title: 8.5.4 Border shorthand properties - status: unknown + status: supported box.html#bidi-box-model: title: 8.6 The box model for inline elements in bidirectional context - status: unknown + status: unsupported + gaps: + - A split inline box in RTL text gets its left border on the first line and its right border on the last, like in LTR visuren.html#q9.0: title: 9 Visual formatting model status: unknown From 82f1562e4f4f19e64ab08cb469d1a2c61b725f3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 17:18:37 +0100 Subject: [PATCH 003/150] Add spec compliance tracking for HTML --- .../HtmlRenderer.SpecCompliance/Report.cs | 22 +- Spec/README.md | 12 +- Spec/html.yml | 2074 +++++++++++++++++ 3 files changed, 2084 insertions(+), 24 deletions(-) create mode 100644 Spec/html.yml diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs b/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs index 848857dee..8829f7f61 100644 --- a/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs @@ -53,25 +53,9 @@ private static void RenderChapter(StringBuilder sb, SpecResult spec, List").AppendLine(); } - /// Groups the sections by their top-level number, i.e. chapter or appendix. - private static IEnumerable> Chapters(SpecResult spec) - { - List? chapter = null; - foreach (var section in spec.Sections) - { - if (!section.Number.Contains('.') || chapter == null) - { - if (chapter != null) - yield return chapter; - chapter = []; - } - - chapter.Add(section); - } - - if (chapter != null) - yield return chapter; - } + /// Groups the sections by the spec page they're on, a chapter in CSS 2.1, a group of elements in HTML. + private static IEnumerable> Chapters(SpecResult spec) => + spec.Sections.GroupBy(s => s.Anchor.Split('#')[0]).Select(g => g.ToList()); private static string Escape(string text) => text.Replace("|", "\\|").Replace("<", "<").Replace(">", ">"); } diff --git a/Spec/README.md b/Spec/README.md index c0999d737..07a3a93ef 100644 --- a/Spec/README.md +++ b/Spec/README.md @@ -4,17 +4,19 @@ ## Manifest -`.yml` has the spec's `title` and `url`, and one entry per section, keyed by its anchor, with the numbered title, the `status` and, if anything is missing, the `gaps`. Every section of the spec is listed, except the subsections of an `n/a` section, so the report also shows what hasn't been reviewed yet. +`.yml` has the spec's `title` and `url`, and one entry per section, keyed by its anchor, with the numbered title, the `status` and, if anything is missing, the `gaps`. Every section of the spec is listed, except the subsections of an `n/a` section, so the report also shows what hasn't been reviewed yet. For HTML that's limited to the chapters that matter for rendering a static document: 2.3 microsyntaxes, 3.2 elements, 4 the elements of HTML, 13 the HTML syntax, 15 rendering and 16 obsolete features. Statuses: - `supported`: implemented as specified, needs at least one test - `partial`: implemented with known gaps, needs at least one test and the gaps listed on it or its subsections -- `parsed-only`: the CSS engine accepts it, but layout and painting ignore it +- `parsed-only`: accepted, e.g. a CSS property or an HTML attribute, but layout and painting ignore it - `unsupported`: not implemented -- `n/a`: nothing to implement, e.g. informative examples, it covers the subsections, they're only listed with a status of their own +- `n/a`: nothing for a renderer to implement, e.g. informative examples, authoring requirements or DOM interfaces, it covers the subsections, they're only listed with a status of their own - `unknown`: not reviewed yet +HTML spreads a feature over several sections: the element in chapter 4, how it's parsed in 13.2 and how it's rendered by default in 15. A gap goes to the section that states the requirement, e.g. a missing default style to 15. The element's own section describes the element as a whole, if it doesn't work at all, its gap points to where the cause is tracked ("Unknown element, rendered inline, see 15.3.3"). + ## Linking tests ```csharp @@ -23,9 +25,9 @@ Statuses: public void NegativeSiblingMargins_CollapseToTheMostNegative() ``` -The anchor is a section key from the manifest, the test counts for that section and all its parent sections. An `[Ignore]`d test doesn't count, use one to describe a gap with `[Ignore("Gap, see Spec/css21.yml")]`. +The anchor is a section key from the manifest, the test counts for that section and all its parent sections. An `[Ignore]`d test doesn't count, use one to describe a gap with `[Ignore("Gap, see Spec/css21.yml")]`. A test can carry several `[Spec]`, e.g. when its `[DataRow]`s cover different elements. -Keep the spec out of code comments, the manifest says what's supported, the tests show how, and the spec explains why. A comment cites the section ("CSS 2.1 §8.3.1") and explains what the code can't. +Keep the spec out of code comments, the manifest says what's supported, the tests show how, and the spec explains why. A comment cites the section ("CSS 2.1 §8.3.1", "HTML §4.4.8") and explains what the code can't. ## Commands diff --git a/Spec/html.yml b/Spec/html.yml new file mode 100644 index 000000000..244c064a1 --- /dev/null +++ b/Spec/html.yml @@ -0,0 +1,2074 @@ +spec: html +title: HTML Living Standard +url: https://html.spec.whatwg.org/multipage/ +sections: + common-microsyntaxes.html#common-microsyntaxes: + title: 2.3 Common microsyntaxes + status: unknown + common-microsyntaxes.html#common-parser-idioms: + title: 2.3.1 Common parser idioms + status: unknown + common-microsyntaxes.html#boolean-attributes: + title: 2.3.2 Boolean attributes + status: unknown + common-microsyntaxes.html#keywords-and-enumerated-attributes: + title: 2.3.3 Keywords and enumerated attributes + status: unknown + common-microsyntaxes.html#numbers: + title: 2.3.4 Numbers + status: unknown + common-microsyntaxes.html#signed-integers: + title: 2.3.4.1 Signed integers + status: unknown + common-microsyntaxes.html#non-negative-integers: + title: 2.3.4.2 Non-negative integers + status: unknown + common-microsyntaxes.html#floating-point-numbers: + title: 2.3.4.3 Floating-point numbers + status: unknown + common-microsyntaxes.html#percentages-and-dimensions: + title: 2.3.4.4 Percentages and lengths + status: unknown + common-microsyntaxes.html#nonzero-percentages-and-lengths: + title: 2.3.4.5 Nonzero percentages and lengths + status: unknown + common-microsyntaxes.html#lists-of-floating-point-numbers: + title: 2.3.4.6 Lists of floating-point numbers + status: unknown + common-microsyntaxes.html#lists-of-dimensions: + title: 2.3.4.7 Lists of dimensions + status: unknown + common-microsyntaxes.html#dates-and-times: + title: 2.3.5 Dates and times + status: unknown + common-microsyntaxes.html#months: + title: 2.3.5.1 Months + status: unknown + common-microsyntaxes.html#dates: + title: 2.3.5.2 Dates + status: unknown + common-microsyntaxes.html#yearless-dates: + title: 2.3.5.3 Yearless dates + status: unknown + common-microsyntaxes.html#times: + title: 2.3.5.4 Times + status: unknown + common-microsyntaxes.html#local-dates-and-times: + title: 2.3.5.5 Local dates and times + status: unknown + common-microsyntaxes.html#time-zones: + title: 2.3.5.6 Time zones + status: unknown + common-microsyntaxes.html#global-dates-and-times: + title: 2.3.5.7 Global dates and times + status: unknown + common-microsyntaxes.html#weeks: + title: 2.3.5.8 Weeks + status: unknown + common-microsyntaxes.html#durations: + title: 2.3.5.9 Durations + status: unknown + common-microsyntaxes.html#vaguer-moments-in-time: + title: 2.3.5.10 Vaguer moments in time + status: unknown + common-microsyntaxes.html#colours: + title: 2.3.6 Legacy colors + status: unknown + common-microsyntaxes.html#space-separated-tokens: + title: 2.3.7 Space-separated tokens + status: unknown + common-microsyntaxes.html#comma-separated-tokens: + title: 2.3.8 Comma-separated tokens + status: unknown + common-microsyntaxes.html#syntax-references: + title: 2.3.9 References + status: unknown + common-microsyntaxes.html#mq: + title: 2.3.10 Media queries + status: unknown + common-microsyntaxes.html#unique-values: + title: 2.3.11 Unique internal values + status: unknown + dom.html#elements: + title: 3.2 Elements + status: unknown + dom.html#semantics-2: + title: 3.2.1 Semantics + status: unknown + dom.html#elements-in-the-dom: + title: 3.2.2 Elements in the DOM + status: unknown + dom.html#html-element-constructors: + title: 3.2.3 HTML element constructors + status: unknown + dom.html#element-definitions: + title: 3.2.4 Element definitions + status: unknown + dom.html#attributes: + title: 3.2.4.1 Attributes + status: unknown + dom.html#content-models: + title: 3.2.5 Content models + status: unknown + dom.html#the-nothing-content-model: + title: 3.2.5.1 The "nothing" content model + status: unknown + dom.html#kinds-of-content: + title: 3.2.5.2 Kinds of content + status: unknown + dom.html#metadata-content: + title: 3.2.5.2.1 Metadata content + status: unknown + dom.html#flow-content: + title: 3.2.5.2.2 Flow content + status: unknown + dom.html#sectioning-content: + title: 3.2.5.2.3 Sectioning content + status: unknown + dom.html#heading-content: + title: 3.2.5.2.4 Heading content + status: unknown + dom.html#phrasing-content: + title: 3.2.5.2.5 Phrasing content + status: unknown + dom.html#embedded-content-2: + title: 3.2.5.2.6 Embedded content + status: unknown + dom.html#interactive-content: + title: 3.2.5.2.7 Interactive content + status: unknown + dom.html#palpable-content: + title: 3.2.5.2.8 Palpable content + status: unknown + dom.html#script-supporting-elements: + title: 3.2.5.2.9 Script-supporting elements + status: unknown + dom.html#transparent-content-models: + title: 3.2.5.3 Transparent content models + status: unknown + dom.html#paragraphs: + title: 3.2.5.4 Paragraphs + status: unknown + dom.html#global-attributes: + title: 3.2.6 Global attributes + status: unknown + dom.html#the-title-attribute: + title: 3.2.6.1 The title attribute + status: unknown + dom.html#the-lang-and-xml:lang-attributes: + title: 3.2.6.2 The lang and xml:lang attributes + status: unknown + dom.html#the-translate-attribute: + title: 3.2.6.3 The translate attribute + status: unknown + dom.html#the-dir-attribute: + title: 3.2.6.4 The dir attribute + status: unknown + dom.html#the-style-attribute: + title: 3.2.6.5 The style attribute + status: unknown + dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes: + title: 3.2.6.6 Embedding custom non-visible data with the data-* attributes + status: unknown + dom.html#the-innertext-idl-attribute: + title: 3.2.7 The innerText and outerText properties + status: unknown + dom.html#requirements-relating-to-the-bidirectional-algorithm: + title: 3.2.8 Requirements relating to the bidirectional algorithm + status: unknown + dom.html#authoring-conformance-criteria-for-bidirectional-algorithm-formatting-characters: + title: 3.2.8.1 Authoring conformance criteria for bidirectional-algorithm formatting characters + status: unknown + dom.html#user-agent-conformance-criteria: + title: 3.2.8.2 User agent conformance criteria + status: unknown + dom.html#wai-aria: + title: 3.2.9 Requirements related to ARIA and to platform accessibility APIs + status: unknown + semantics.html#semantics: + title: 4 The elements of HTML + status: unknown + semantics.html#the-root-element: + title: 4.1 The document element + status: unknown + semantics.html#the-html-element: + title: 4.1.1 The html element + status: unknown + semantics.html#document-metadata: + title: 4.2 Document metadata + status: unknown + semantics.html#the-head-element: + title: 4.2.1 The head element + status: unknown + semantics.html#the-title-element: + title: 4.2.2 The title element + status: unknown + semantics.html#the-base-element: + title: 4.2.3 The base element + status: unknown + semantics.html#the-link-element: + title: 4.2.4 The link element + status: unknown + semantics.html#processing-the-media-attribute: + title: 4.2.4.1 Processing the media attribute + status: unknown + semantics.html#processing-the-type-attribute: + title: 4.2.4.2 Processing the type attribute + status: unknown + semantics.html#fetching-and-processing-a-resource-from-a-link-element: + title: 4.2.4.3 Fetching and processing a resource from a link element + status: unknown + semantics.html#processing-link-headers: + title: 4.2.4.4 Processing `Link` headers + status: unknown + semantics.html#early-hints: + title: 4.2.4.5 Early hints + status: unknown + semantics.html#providing-users-with-a-means-to-follow-hyperlinks-created-using-the-link-element: + title: 4.2.4.6 Providing users with a means to follow hyperlinks created using the link element + status: unknown + semantics.html#the-meta-element: + title: 4.2.5 The meta element + status: unknown + semantics.html#standard-metadata-names: + title: 4.2.5.1 Standard metadata names + status: unknown + semantics.html#other-metadata-names: + title: 4.2.5.2 Other metadata names + status: unknown + semantics.html#pragma-directives: + title: 4.2.5.3 Pragma directives + status: unknown + semantics.html#charset: + title: 4.2.5.4 Specifying the document's character encoding + status: unknown + semantics.html#the-style-element: + title: 4.2.6 The style element + status: unknown + semantics.html#interactions-of-styling-and-scripting: + title: 4.2.7 Interactions of styling and scripting + status: unknown + sections.html#sections: + title: 4.3 Sections + status: unknown + sections.html#the-body-element: + title: 4.3.1 The body element + status: unknown + sections.html#the-article-element: + title: 4.3.2 The article element + status: unknown + sections.html#the-section-element: + title: 4.3.3 The section element + status: unknown + sections.html#the-nav-element: + title: 4.3.4 The nav element + status: unknown + sections.html#the-aside-element: + title: 4.3.5 The aside element + status: unknown + sections.html#the-h1,-h2,-h3,-h4,-h5,-and-h6-elements: + title: 4.3.6 The h1, h2, h3, h4, h5, and h6 elements + status: unknown + sections.html#the-hgroup-element: + title: 4.3.7 The hgroup element + status: unknown + sections.html#the-header-element: + title: 4.3.8 The header element + status: unknown + sections.html#the-footer-element: + title: 4.3.9 The footer element + status: unknown + sections.html#the-address-element: + title: 4.3.10 The address element + status: unknown + sections.html#headings-and-outlines-2: + title: 4.3.11 Headings and outlines + status: unknown + sections.html#heading-levels-&-offsets: + title: 4.3.11.1 Heading levels & offsets + status: unknown + sections.html#sample-outlines: + title: 4.3.11.2 Sample outlines + status: unknown + sections.html#exposing-outlines-to-users: + title: 4.3.11.3 Exposing outlines to users + status: unknown + sections.html#usage-summary-2: + title: 4.3.12 Usage summary + status: unknown + sections.html#article-or-section: + title: 4.3.12.1 Article or section? + status: unknown + grouping-content.html#grouping-content: + title: 4.4 Grouping content + status: unknown + grouping-content.html#the-p-element: + title: 4.4.1 The p element + status: unknown + grouping-content.html#the-hr-element: + title: 4.4.2 The hr element + status: unknown + grouping-content.html#the-pre-element: + title: 4.4.3 The pre element + status: unknown + grouping-content.html#the-blockquote-element: + title: 4.4.4 The blockquote element + status: unknown + grouping-content.html#the-ol-element: + title: 4.4.5 The ol element + status: unknown + grouping-content.html#the-ul-element: + title: 4.4.6 The ul element + status: unknown + grouping-content.html#the-menu-element: + title: 4.4.7 The menu element + status: unknown + grouping-content.html#the-li-element: + title: 4.4.8 The li element + status: unknown + grouping-content.html#the-dl-element: + title: 4.4.9 The dl element + status: unknown + grouping-content.html#the-dt-element: + title: 4.4.10 The dt element + status: unknown + grouping-content.html#the-dd-element: + title: 4.4.11 The dd element + status: unknown + grouping-content.html#the-figure-element: + title: 4.4.12 The figure element + status: unknown + grouping-content.html#the-figcaption-element: + title: 4.4.13 The figcaption element + status: unknown + grouping-content.html#the-main-element: + title: 4.4.14 The main element + status: unknown + grouping-content.html#the-search-element: + title: 4.4.15 The search element + status: unknown + grouping-content.html#the-div-element: + title: 4.4.16 The div element + status: unknown + text-level-semantics.html#text-level-semantics: + title: 4.5 Text-level semantics + status: unknown + text-level-semantics.html#the-a-element: + title: 4.5.1 The a element + status: unknown + text-level-semantics.html#the-em-element: + title: 4.5.2 The em element + status: unknown + text-level-semantics.html#the-strong-element: + title: 4.5.3 The strong element + status: unknown + text-level-semantics.html#the-small-element: + title: 4.5.4 The small element + status: unknown + text-level-semantics.html#the-s-element: + title: 4.5.5 The s element + status: unknown + text-level-semantics.html#the-cite-element: + title: 4.5.6 The cite element + status: unknown + text-level-semantics.html#the-q-element: + title: 4.5.7 The q element + status: unknown + text-level-semantics.html#the-dfn-element: + title: 4.5.8 The dfn element + status: unknown + text-level-semantics.html#the-abbr-element: + title: 4.5.9 The abbr element + status: unknown + text-level-semantics.html#the-ruby-element: + title: 4.5.10 The ruby element + status: unknown + text-level-semantics.html#the-rt-element: + title: 4.5.11 The rt element + status: unknown + text-level-semantics.html#the-rp-element: + title: 4.5.12 The rp element + status: unknown + text-level-semantics.html#the-data-element: + title: 4.5.13 The data element + status: unknown + text-level-semantics.html#the-time-element: + title: 4.5.14 The time element + status: unknown + text-level-semantics.html#the-code-element: + title: 4.5.15 The code element + status: unknown + text-level-semantics.html#the-var-element: + title: 4.5.16 The var element + status: unknown + text-level-semantics.html#the-samp-element: + title: 4.5.17 The samp element + status: unknown + text-level-semantics.html#the-kbd-element: + title: 4.5.18 The kbd element + status: unknown + text-level-semantics.html#the-sub-and-sup-elements: + title: 4.5.19 The sub and sup elements + status: unknown + text-level-semantics.html#the-i-element: + title: 4.5.20 The i element + status: unknown + text-level-semantics.html#the-b-element: + title: 4.5.21 The b element + status: unknown + text-level-semantics.html#the-u-element: + title: 4.5.22 The u element + status: unknown + text-level-semantics.html#the-mark-element: + title: 4.5.23 The mark element + status: unknown + text-level-semantics.html#the-bdi-element: + title: 4.5.24 The bdi element + status: unknown + text-level-semantics.html#the-bdo-element: + title: 4.5.25 The bdo element + status: unknown + text-level-semantics.html#the-span-element: + title: 4.5.26 The span element + status: unknown + text-level-semantics.html#the-br-element: + title: 4.5.27 The br element + status: unknown + text-level-semantics.html#the-wbr-element: + title: 4.5.28 The wbr element + status: unknown + text-level-semantics.html#usage-summary: + title: 4.5.29 Usage summary + status: unknown + links.html#links: + title: 4.6 Links + status: unknown + links.html#introduction-2: + title: 4.6.1 Introduction + status: unknown + links.html#links-created-by-a-and-area-elements: + title: 4.6.2 Links created by a and area elements + status: unknown + links.html#api-for-hyperlink-elements: + title: 4.6.3 API for hyperlink elements + status: unknown + links.html#api-for-a-and-area-elements: + title: 4.6.4 API for a and area elements + status: unknown + links.html#following-hyperlinks: + title: 4.6.5 Following hyperlinks + status: unknown + links.html#downloading-resources: + title: 4.6.6 Downloading resources + status: unknown + links.html#hyperlink-auditing: + title: 4.6.7 Hyperlink auditing + status: unknown + links.html#the-ping-headers: + title: 4.6.7.1 The `Ping-From` and `Ping-To` headers + status: unknown + links.html#linkTypes: + title: 4.6.8 Link types + status: unknown + links.html#rel-alternate: + title: 4.6.8.1 Link type "alternate" + status: unknown + links.html#link-type-author: + title: 4.6.8.2 Link type "author" + status: unknown + links.html#link-type-bookmark: + title: 4.6.8.3 Link type "bookmark" + status: unknown + links.html#link-type-canonical: + title: 4.6.8.4 Link type "canonical" + status: unknown + links.html#link-type-dns-prefetch: + title: 4.6.8.5 Link type "dns-prefetch" + status: unknown + links.html#link-type-expect: + title: 4.6.8.6 Link type "expect" + status: unknown + links.html#link-type-external: + title: 4.6.8.7 Link type "external" + status: unknown + links.html#link-type-help: + title: 4.6.8.8 Link type "help" + status: unknown + links.html#rel-icon: + title: 4.6.8.9 Link type "icon" + status: unknown + links.html#link-type-license: + title: 4.6.8.10 Link type "license" + status: unknown + links.html#link-type-manifest: + title: 4.6.8.11 Link type "manifest" + status: unknown + links.html#link-type-modulepreload: + title: 4.6.8.12 Link type "modulepreload" + status: unknown + links.html#link-type-nofollow: + title: 4.6.8.13 Link type "nofollow" + status: unknown + links.html#link-type-noopener: + title: 4.6.8.14 Link type "noopener" + status: unknown + links.html#link-type-noreferrer: + title: 4.6.8.15 Link type "noreferrer" + status: unknown + links.html#link-type-opener: + title: 4.6.8.16 Link type "opener" + status: unknown + links.html#link-type-pingback: + title: 4.6.8.17 Link type "pingback" + status: unknown + links.html#link-type-preconnect: + title: 4.6.8.18 Link type "preconnect" + status: unknown + links.html#link-type-prefetch: + title: 4.6.8.19 Link type "prefetch" + status: unknown + links.html#link-type-preload: + title: 4.6.8.20 Link type "preload" + status: unknown + links.html#link-type-privacy-policy: + title: 4.6.8.21 Link type "privacy-policy" + status: unknown + links.html#link-type-search: + title: 4.6.8.22 Link type "search" + status: unknown + links.html#link-type-stylesheet: + title: 4.6.8.23 Link type "stylesheet" + status: unknown + links.html#link-type-tag: + title: 4.6.8.24 Link type "tag" + status: unknown + links.html#link-type-terms-of-service: + title: 4.6.8.25 Link Type "terms-of-service" + status: unknown + links.html#sequential-link-types: + title: 4.6.8.26 Sequential link types + status: unknown + links.html#link-type-next: + title: 4.6.8.26.1 Link type "next" + status: unknown + links.html#link-type-prev: + title: 4.6.8.26.2 Link type "prev" + status: unknown + links.html#other-link-types: + title: 4.6.8.27 Other link types + status: unknown + edits.html#edits: + title: 4.7 Edits + status: unknown + edits.html#the-ins-element: + title: 4.7.1 The ins element + status: unknown + edits.html#the-del-element: + title: 4.7.2 The del element + status: unknown + edits.html#attributes-common-to-ins-and-del-elements: + title: 4.7.3 Attributes common to ins and del elements + status: unknown + edits.html#edits-and-paragraphs: + title: 4.7.4 Edits and paragraphs + status: unknown + edits.html#edits-and-lists: + title: 4.7.5 Edits and lists + status: unknown + edits.html#edits-and-tables: + title: 4.7.6 Edits and tables + status: unknown + embedded-content.html#embedded-content: + title: 4.8 Embedded content + status: unknown + embedded-content.html#the-picture-element: + title: 4.8.1 The picture element + status: unknown + embedded-content.html#the-source-element: + title: 4.8.2 The source element + status: unknown + embedded-content.html#the-img-element: + title: 4.8.3 The img element + status: unknown + images.html#images: + title: 4.8.4 Images + status: unknown + images.html#introduction-3: + title: 4.8.4.1 Introduction + status: unknown + images.html#adaptive-images: + title: 4.8.4.1.1 Adaptive images + status: unknown + images.html#attributes-common-to-source-and-img-elements: + title: 4.8.4.2 Attributes common to source, img, and link elements + status: unknown + images.html#srcset-attributes: + title: 4.8.4.2.1 Srcset attributes + status: unknown + images.html#sizes-attributes: + title: 4.8.4.2.2 Sizes attributes + status: unknown + images.html#images-processing-model: + title: 4.8.4.3 Processing model + status: unknown + images.html#when-to-obtain-images: + title: 4.8.4.3.1 When to obtain images + status: unknown + images.html#reacting-to-dom-mutations: + title: 4.8.4.3.2 Reacting to DOM mutations + status: unknown + images.html#the-list-of-available-images: + title: 4.8.4.3.3 The list of available images + status: unknown + images.html#decoding-images: + title: 4.8.4.3.4 Decoding images + status: unknown + images.html#updating-the-image-data: + title: 4.8.4.3.5 Updating the image data + status: unknown + images.html#preparing-an-image-for-presentation: + title: 4.8.4.3.6 Preparing an image for presentation + status: unknown + images.html#selecting-an-image-source: + title: 4.8.4.3.7 Selecting an image source + status: unknown + images.html#creating-a-source-set-from-attributes: + title: 4.8.4.3.8 Creating a source set from attributes + status: unknown + images.html#updating-the-source-set: + title: 4.8.4.3.9 Updating the source set + status: unknown + images.html#parsing-a-srcset-attribute: + title: 4.8.4.3.10 Parsing a srcset attribute + status: unknown + images.html#parsing-a-sizes-attribute: + title: 4.8.4.3.11 Parsing a sizes attribute + status: unknown + images.html#normalizing-the-source-densities: + title: 4.8.4.3.12 Normalizing the source densities + status: unknown + images.html#reacting-to-environment-changes: + title: 4.8.4.3.13 Reacting to environment changes + status: unknown + images.html#alt: + title: 4.8.4.4 Requirements for providing text to act as an alternative for images + status: unknown + images.html#general-guidelines: + title: 4.8.4.4.1 General guidelines + status: unknown + images.html#a-link-or-button-containing-nothing-but-the-image: + title: 4.8.4.4.2 A link or button containing nothing but the image + status: unknown + images.html#a-phrase-or-paragraph-with-an-alternative-graphical-representation:-charts,-diagrams,-graphs,-maps,-illustrations: + title: '4.8.4.4.3 A phrase or paragraph with an alternative graphical representation: charts, diagrams, graphs, maps, illustrations' + status: unknown + images.html#a-short-phrase-or-label-with-an-alternative-graphical-representation:-icons,-logos: + title: '4.8.4.4.4 A short phrase or label with an alternative graphical representation: icons, logos' + status: unknown + images.html#text-that-has-been-rendered-to-a-graphic-for-typographical-effect: + title: 4.8.4.4.5 Text that has been rendered to a graphic for typographical effect + status: unknown + images.html#a-graphical-representation-of-some-of-the-surrounding-text: + title: 4.8.4.4.6 A graphical representation of some of the surrounding text + status: unknown + images.html#ancillary-images: + title: 4.8.4.4.7 Ancillary images + status: unknown + images.html#a-purely-decorative-image-that-doesn't-add-any-information: + title: 4.8.4.4.8 A purely decorative image that doesn't add any information + status: unknown + images.html#a-group-of-images-that-form-a-single-larger-picture-with-no-links: + title: 4.8.4.4.9 A group of images that form a single larger picture with no links + status: unknown + images.html#a-group-of-images-that-form-a-single-larger-picture-with-links: + title: 4.8.4.4.10 A group of images that form a single larger picture with links + status: unknown + images.html#a-key-part-of-the-content: + title: 4.8.4.4.11 A key part of the content + status: unknown + images.html#an-image-not-intended-for-the-user: + title: 4.8.4.4.12 An image not intended for the user + status: unknown + images.html#an-image-in-an-e-mail-or-private-document-intended-for-a-specific-person-who-is-known-to-be-able-to-view-images: + title: 4.8.4.4.13 An image in an email or private document intended for a specific person who is known to be able to view images + status: unknown + images.html#guidance-for-markup-generators: + title: 4.8.4.4.14 Guidance for markup generators + status: unknown + images.html#guidance-for-conformance-checkers: + title: 4.8.4.4.15 Guidance for conformance checkers + status: unknown + iframe-embed-object.html#the-iframe-element: + title: 4.8.5 The iframe element + status: unknown + iframe-embed-object.html#the-embed-element: + title: 4.8.6 The embed element + status: unknown + iframe-embed-object.html#the-object-element: + title: 4.8.7 The object element + status: unknown + media.html#the-video-element: + title: 4.8.8 The video element + status: unknown + media.html#the-audio-element: + title: 4.8.9 The audio element + status: unknown + media.html#the-track-element: + title: 4.8.10 The track element + status: unknown + media.html#media-elements: + title: 4.8.11 Media elements + status: unknown + media.html#error-codes: + title: 4.8.11.1 Error codes + status: unknown + media.html#location-of-the-media-resource: + title: 4.8.11.2 Location of the media resource + status: unknown + media.html#mime-types: + title: 4.8.11.3 MIME types + status: unknown + media.html#network-states: + title: 4.8.11.4 Network states + status: unknown + media.html#loading-the-media-resource: + title: 4.8.11.5 Loading the media resource + status: unknown + media.html#offsets-into-the-media-resource: + title: 4.8.11.6 Offsets into the media resource + status: unknown + media.html#ready-states: + title: 4.8.11.7 Ready states + status: unknown + media.html#playing-the-media-resource: + title: 4.8.11.8 Playing the media resource + status: unknown + media.html#seeking: + title: 4.8.11.9 Seeking + status: unknown + media.html#media-resources-with-multiple-media-tracks: + title: 4.8.11.10 Media resources with multiple media tracks + status: unknown + media.html#audiotracklist-and-videotracklist-objects: + title: 4.8.11.10.1 AudioTrackList and VideoTrackList objects + status: unknown + media.html#selecting-specific-audio-and-video-tracks-declaratively: + title: 4.8.11.10.2 Selecting specific audio and video tracks declaratively + status: unknown + media.html#timed-text-tracks: + title: 4.8.11.11 Timed text tracks + status: unknown + media.html#text-track-model: + title: 4.8.11.11.1 Text track model + status: unknown + media.html#sourcing-in-band-text-tracks: + title: 4.8.11.11.2 Sourcing in-band text tracks + status: unknown + media.html#sourcing-out-of-band-text-tracks: + title: 4.8.11.11.3 Sourcing out-of-band text tracks + status: unknown + media.html#guidelines-for-exposing-cues-in-various-formats-as-text-track-cues: + title: 4.8.11.11.4 Guidelines for exposing cues in various formats as text track cues + status: unknown + media.html#text-track-api: + title: 4.8.11.11.5 Text track API + status: unknown + media.html#cue-events: + title: 4.8.11.11.6 Event handlers for objects of the text track APIs + status: unknown + media.html#best-practices-for-metadata-text-tracks: + title: 4.8.11.11.7 Best practices for metadata text tracks + status: unknown + media.html#identifying-a-track-kind-through-a-url: + title: 4.8.11.12 Identifying a track kind through a URL + status: unknown + media.html#user-interface: + title: 4.8.11.13 User interface + status: unknown + media.html#time-ranges: + title: 4.8.11.14 Time ranges + status: unknown + media.html#the-trackevent-interface: + title: 4.8.11.15 The TrackEvent interface + status: unknown + media.html#mediaevents: + title: 4.8.11.16 Events summary + status: unknown + media.html#security-and-privacy-considerations: + title: 4.8.11.17 Security and privacy considerations + status: unknown + media.html#best-practices-for-authors-using-media-elements: + title: 4.8.11.18 Best practices for authors using media elements + status: unknown + media.html#best-practices-for-implementers-of-media-elements: + title: 4.8.11.19 Best practices for implementers of media elements + status: unknown + image-maps.html#the-map-element: + title: 4.8.12 The map element + status: unknown + image-maps.html#the-area-element: + title: 4.8.13 The area element + status: unknown + image-maps.html#image-maps: + title: 4.8.14 Image maps + status: unknown + image-maps.html#authoring: + title: 4.8.14.1 Authoring + status: unknown + image-maps.html#image-map-processing-model: + title: 4.8.14.2 Processing model + status: unknown + embedded-content-other.html#mathml: + title: 4.8.15 MathML + status: unknown + embedded-content-other.html#svg-0: + title: 4.8.16 SVG + status: unknown + embedded-content-other.html#dimension-attributes: + title: 4.8.17 Dimension attributes + status: unknown + tables.html#tables: + title: 4.9 Tabular data + status: unknown + tables.html#the-table-element: + title: 4.9.1 The table element + status: unknown + tables.html#table-descriptions-techniques: + title: 4.9.1.1 Techniques for describing tables + status: unknown + tables.html#table-layout-techniques: + title: 4.9.1.2 Techniques for table design + status: unknown + tables.html#the-caption-element: + title: 4.9.2 The caption element + status: unknown + tables.html#the-colgroup-element: + title: 4.9.3 The colgroup element + status: unknown + tables.html#the-col-element: + title: 4.9.4 The col element + status: unknown + tables.html#the-tbody-element: + title: 4.9.5 The tbody element + status: unknown + tables.html#the-thead-element: + title: 4.9.6 The thead element + status: unknown + tables.html#the-tfoot-element: + title: 4.9.7 The tfoot element + status: unknown + tables.html#the-tr-element: + title: 4.9.8 The tr element + status: unknown + tables.html#the-td-element: + title: 4.9.9 The td element + status: unknown + tables.html#the-th-element: + title: 4.9.10 The th element + status: unknown + tables.html#attributes-common-to-td-and-th-elements: + title: 4.9.11 Attributes common to td and th elements + status: unknown + tables.html#table-processing-model: + title: 4.9.12 Processing model + status: unknown + tables.html#forming-a-table: + title: 4.9.12.1 Forming a table + status: unknown + tables.html#header-and-data-cell-semantics: + title: 4.9.12.2 Forming relationships between data cells and header cells + status: unknown + tables.html#table-examples: + title: 4.9.13 Examples + status: unknown + forms.html#forms: + title: 4.10 Forms + status: unknown + forms.html#introduction-4: + title: 4.10.1 Introduction + status: unknown + forms.html#writing-a-form's-user-interface: + title: 4.10.1.1 Writing a form's user interface + status: unknown + forms.html#implementing-the-server-side-processing-for-a-form: + title: 4.10.1.2 Implementing the server-side processing for a form + status: unknown + forms.html#configuring-a-form-to-communicate-with-a-server: + title: 4.10.1.3 Configuring a form to communicate with a server + status: unknown + forms.html#client-side-form-validation: + title: 4.10.1.4 Client-side form validation + status: unknown + forms.html#enabling-client-side-automatic-filling-of-form-controls: + title: 4.10.1.5 Enabling client-side automatic filling of form controls + status: unknown + forms.html#improving-the-user-experience-on-mobile-devices: + title: 4.10.1.6 Improving the user experience on mobile devices + status: unknown + forms.html#the-difference-between-the-field-type,-the-autofill-field-name,-and-the-input-modality: + title: 4.10.1.7 The difference between the field type, the autofill field name, and the input modality + status: unknown + forms.html#input-author-notes: + title: 4.10.1.8 Date, time, and number formats + status: unknown + forms.html#categories: + title: 4.10.2 Categories + status: unknown + forms.html#the-form-element: + title: 4.10.3 The form element + status: unknown + forms.html#the-label-element: + title: 4.10.4 The label element + status: unknown + input.html#the-input-element: + title: 4.10.5 The input element + status: unknown + input.html#states-of-the-type-attribute: + title: 4.10.5.1 States of the type attribute + status: unknown + input.html#hidden-state-(type=hidden): + title: 4.10.5.1.1 Hidden state (type=hidden) + status: unknown + input.html#text-(type=text)-state-and-search-state-(type=search): + title: 4.10.5.1.2 Text (type=text) state and Search state (type=search) + status: unknown + input.html#telephone-state-(type=tel): + title: 4.10.5.1.3 Telephone state (type=tel) + status: unknown + input.html#url-state-(type=url): + title: 4.10.5.1.4 URL state (type=url) + status: unknown + input.html#email-state-(type=email): + title: 4.10.5.1.5 Email state (type=email) + status: unknown + input.html#password-state-(type=password): + title: 4.10.5.1.6 Password state (type=password) + status: unknown + input.html#date-state-(type=date): + title: 4.10.5.1.7 Date state (type=date) + status: unknown + input.html#month-state-(type=month): + title: 4.10.5.1.8 Month state (type=month) + status: unknown + input.html#week-state-(type=week): + title: 4.10.5.1.9 Week state (type=week) + status: unknown + input.html#time-state-(type=time): + title: 4.10.5.1.10 Time state (type=time) + status: unknown + input.html#local-date-and-time-state-(type=datetime-local): + title: 4.10.5.1.11 Local Date and Time state (type=datetime-local) + status: unknown + input.html#number-state-(type=number): + title: 4.10.5.1.12 Number state (type=number) + status: unknown + input.html#range-state-(type=range): + title: 4.10.5.1.13 Range state (type=range) + status: unknown + input.html#color-state-(type=color): + title: 4.10.5.1.14 Color state (type=color) + status: unknown + input.html#checkbox-state-(type=checkbox): + title: 4.10.5.1.15 Checkbox state (type=checkbox) + status: unknown + input.html#radio-button-state-(type=radio): + title: 4.10.5.1.16 Radio Button state (type=radio) + status: unknown + input.html#file-upload-state-(type=file): + title: 4.10.5.1.17 File Upload state (type=file) + status: unknown + input.html#submit-button-state-(type=submit): + title: 4.10.5.1.18 Submit Button state (type=submit) + status: unknown + input.html#image-button-state-(type=image): + title: 4.10.5.1.19 Image Button state (type=image) + status: unknown + input.html#reset-button-state-(type=reset): + title: 4.10.5.1.20 Reset Button state (type=reset) + status: unknown + input.html#button-state-(type=button): + title: 4.10.5.1.21 Button state (type=button) + status: unknown + input.html#input-impl-notes: + title: 4.10.5.2 Implementation notes regarding localization of form controls + status: unknown + input.html#common-input-element-attributes: + title: 4.10.5.3 Common input element attributes + status: unknown + input.html#the-maxlength-and-minlength-attributes: + title: 4.10.5.3.1 The maxlength and minlength attributes + status: unknown + input.html#the-size-attribute: + title: 4.10.5.3.2 The size attribute + status: unknown + input.html#the-readonly-attribute: + title: 4.10.5.3.3 The readonly attribute + status: unknown + input.html#the-required-attribute: + title: 4.10.5.3.4 The required attribute + status: unknown + input.html#the-multiple-attribute: + title: 4.10.5.3.5 The multiple attribute + status: unknown + input.html#the-pattern-attribute: + title: 4.10.5.3.6 The pattern attribute + status: unknown + input.html#the-min-and-max-attributes: + title: 4.10.5.3.7 The min and max attributes + status: unknown + input.html#the-step-attribute: + title: 4.10.5.3.8 The step attribute + status: unknown + input.html#the-list-attribute: + title: 4.10.5.3.9 The list attribute + status: unknown + input.html#the-placeholder-attribute: + title: 4.10.5.3.10 The placeholder attribute + status: unknown + input.html#common-input-element-apis: + title: 4.10.5.4 Common input element APIs + status: unknown + input.html#common-input-element-events: + title: 4.10.5.5 Common event behaviors + status: unknown + form-elements.html#the-button-element: + title: 4.10.6 The button element + status: unknown + form-elements.html#the-select-element: + title: 4.10.7 The select element + status: unknown + form-elements.html#the-datalist-element: + title: 4.10.8 The datalist element + status: unknown + form-elements.html#the-optgroup-element: + title: 4.10.9 The optgroup element + status: unknown + form-elements.html#the-option-element: + title: 4.10.10 The option element + status: unknown + form-elements.html#the-textarea-element: + title: 4.10.11 The textarea element + status: unknown + form-elements.html#the-output-element: + title: 4.10.12 The output element + status: unknown + form-elements.html#the-progress-element: + title: 4.10.13 The progress element + status: unknown + form-elements.html#the-meter-element: + title: 4.10.14 The meter element + status: unknown + form-elements.html#the-fieldset-element: + title: 4.10.15 The fieldset element + status: unknown + form-elements.html#the-legend-element: + title: 4.10.16 The legend element + status: unknown + form-elements.html#the-selectedcontent-element: + title: 4.10.17 The selectedcontent element + status: unknown + form-control-infrastructure.html#form-control-infrastructure: + title: 4.10.18 Form control infrastructure + status: unknown + form-control-infrastructure.html#a-form-control's-value: + title: 4.10.18.1 A form control's value + status: unknown + form-control-infrastructure.html#mutability: + title: 4.10.18.2 Mutability + status: unknown + form-control-infrastructure.html#association-of-controls-and-forms: + title: 4.10.18.3 Association of controls and forms + status: unknown + form-control-infrastructure.html#attributes-common-to-form-controls: + title: 4.10.19 Attributes common to form controls + status: unknown + form-control-infrastructure.html#naming-form-controls:-the-name-attribute: + title: '4.10.19.1 Naming form controls: the name attribute' + status: unknown + form-control-infrastructure.html#submitting-element-directionality:-the-dirname-attribute: + title: '4.10.19.2 Submitting element directionality: the dirname attribute' + status: unknown + form-control-infrastructure.html#limiting-user-input-length:-the-maxlength-attribute: + title: '4.10.19.3 Limiting user input length: the maxlength attribute' + status: unknown + form-control-infrastructure.html#setting-minimum-input-length-requirements:-the-minlength-attribute: + title: '4.10.19.4 Setting minimum input length requirements: the minlength attribute' + status: unknown + form-control-infrastructure.html#enabling-and-disabling-form-controls:-the-disabled-attribute: + title: '4.10.19.5 Enabling and disabling form controls: the disabled attribute' + status: unknown + form-control-infrastructure.html#form-submission-attributes: + title: 4.10.19.6 Form submission attributes + status: unknown + form-control-infrastructure.html#autofill: + title: 4.10.19.7 Autofill + status: unknown + form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute: + title: '4.10.19.7.1 Autofilling form controls: the autocomplete attribute' + status: unknown + form-control-infrastructure.html#autofill-processing-model: + title: 4.10.19.7.2 Processing model + status: unknown + form-control-infrastructure.html#textFieldSelection: + title: 4.10.20 APIs for the text control selections + status: unknown + form-control-infrastructure.html#constraints: + title: 4.10.21 Constraints + status: unknown + form-control-infrastructure.html#definitions: + title: 4.10.21.1 Definitions + status: unknown + form-control-infrastructure.html#constraint-validation: + title: 4.10.21.2 Constraint validation + status: unknown + form-control-infrastructure.html#the-constraint-validation-api: + title: 4.10.21.3 The constraint validation API + status: unknown + form-control-infrastructure.html#security-forms: + title: 4.10.21.4 Security + status: unknown + form-control-infrastructure.html#form-submission-2: + title: 4.10.22 Form submission + status: unknown + form-control-infrastructure.html#introduction-5: + title: 4.10.22.1 Introduction + status: unknown + form-control-infrastructure.html#implicit-submission: + title: 4.10.22.2 Implicit submission + status: unknown + form-control-infrastructure.html#form-submission-algorithm: + title: 4.10.22.3 Form submission algorithm + status: unknown + form-control-infrastructure.html#constructing-form-data-set: + title: 4.10.22.4 Constructing the entry list + status: unknown + form-control-infrastructure.html#selecting-a-form-submission-encoding: + title: 4.10.22.5 Selecting a form submission encoding + status: unknown + form-control-infrastructure.html#converting-an-entry-list-to-a-list-of-name-value-pairs: + title: 4.10.22.6 Converting an entry list to a list of name-value pairs + status: unknown + form-control-infrastructure.html#url-encoded-form-data: + title: 4.10.22.7 URL-encoded form data + status: unknown + form-control-infrastructure.html#multipart-form-data: + title: 4.10.22.8 Multipart form data + status: unknown + form-control-infrastructure.html#plain-text-form-data: + title: 4.10.22.9 Plain text form data + status: unknown + form-control-infrastructure.html#the-submitevent-interface: + title: 4.10.22.10 The SubmitEvent interface + status: unknown + form-control-infrastructure.html#the-formdataevent-interface: + title: 4.10.22.11 The FormDataEvent interface + status: unknown + form-control-infrastructure.html#resetting-a-form: + title: 4.10.23 Resetting a form + status: unknown + interactive-elements.html#interactive-elements: + title: 4.11 Interactive elements + status: unknown + interactive-elements.html#the-details-element: + title: 4.11.1 The details element + status: unknown + interactive-elements.html#the-summary-element: + title: 4.11.2 The summary element + status: unknown + interactive-elements.html#commands: + title: 4.11.3 Commands + status: unknown + interactive-elements.html#facets-2: + title: 4.11.3.1 Facets + status: unknown + interactive-elements.html#using-the-a-element-to-define-a-command: + title: 4.11.3.2 Using the a element to define a command + status: unknown + interactive-elements.html#using-the-button-element-to-define-a-command: + title: 4.11.3.3 Using the button element to define a command + status: unknown + interactive-elements.html#using-the-input-element-to-define-a-command: + title: 4.11.3.4 Using the input element to define a command + status: unknown + interactive-elements.html#using-the-option-element-to-define-a-command: + title: 4.11.3.5 Using the option element to define a command + status: unknown + interactive-elements.html#using-the-accesskey-attribute-on-a-legend-element-to-define-a-command: + title: 4.11.3.6 Using the accesskey attribute on a legend element to define a command + status: unknown + interactive-elements.html#using-the-accesskey-attribute-to-define-a-command-on-other-elements: + title: 4.11.3.7 Using the accesskey attribute to define a command on other elements + status: unknown + interactive-elements.html#the-dialog-element: + title: 4.11.4 The dialog element + status: unknown + interactive-elements.html#dialog-light-dismiss: + title: 4.11.5 Dialog light dismiss + status: unknown + scripting.html#scripting-3: + title: 4.12 Scripting + status: unknown + scripting.html#the-script-element: + title: 4.12.1 The script element + status: unknown + scripting.html#script-processing-model: + title: 4.12.1.1 Processing model + status: unknown + scripting.html#scriptingLanguages: + title: 4.12.1.2 Scripting languages + status: unknown + scripting.html#restrictions-for-contents-of-script-elements: + title: 4.12.1.3 Restrictions for contents of script elements + status: unknown + scripting.html#inline-documentation-for-external-scripts: + title: 4.12.1.4 Inline documentation for external scripts + status: unknown + scripting.html#scriptTagXSLT: + title: 4.12.1.5 Interaction of script elements and XSLT + status: unknown + scripting.html#the-noscript-element: + title: 4.12.2 The noscript element + status: unknown + scripting.html#the-template-element: + title: 4.12.3 The template element + status: unknown + scripting.html#template-XSLT-XPath: + title: 4.12.3.1 Interaction of template elements with XSLT and XPath + status: unknown + scripting.html#the-slot-element: + title: 4.12.4 The slot element + status: unknown + canvas.html#the-canvas-element: + title: 4.12.5 The canvas element + status: unknown + canvas.html#2dcontext: + title: 4.12.5.1 The 2D rendering context + status: unknown + canvas.html#implementation-notes: + title: 4.12.5.1.1 Implementation notes + status: unknown + canvas.html#the-canvas-settings: + title: 4.12.5.1.2 The canvas settings + status: unknown + canvas.html#the-canvas-state: + title: 4.12.5.1.3 The canvas state + status: unknown + canvas.html#line-styles: + title: 4.12.5.1.4 Line styles + status: unknown + canvas.html#text-styles: + title: 4.12.5.1.5 Text styles + status: unknown + canvas.html#building-paths: + title: 4.12.5.1.6 Building paths + status: unknown + canvas.html#path2d-objects: + title: 4.12.5.1.7 Path2D objects + status: unknown + canvas.html#transformations: + title: 4.12.5.1.8 Transformations + status: unknown + canvas.html#image-sources-for-2d-rendering-contexts: + title: 4.12.5.1.9 Image sources for 2D rendering contexts + status: unknown + canvas.html#fill-and-stroke-styles: + title: 4.12.5.1.10 Fill and stroke styles + status: unknown + canvas.html#drawing-rectangles-to-the-bitmap: + title: 4.12.5.1.11 Drawing rectangles to the bitmap + status: unknown + canvas.html#drawing-text-to-the-bitmap: + title: 4.12.5.1.12 Drawing text to the bitmap + status: unknown + canvas.html#drawing-paths-to-the-canvas: + title: 4.12.5.1.13 Drawing paths to the canvas + status: unknown + canvas.html#drawing-focus-rings-and-scrolling-paths-into-view: + title: 4.12.5.1.14 Drawing focus rings + status: unknown + canvas.html#drawing-images: + title: 4.12.5.1.15 Drawing images + status: unknown + canvas.html#pixel-manipulation: + title: 4.12.5.1.16 Pixel manipulation + status: unknown + canvas.html#compositing: + title: 4.12.5.1.17 Compositing + status: unknown + canvas.html#image-smoothing: + title: 4.12.5.1.18 Image smoothing + status: unknown + canvas.html#shadows: + title: 4.12.5.1.19 Shadows + status: unknown + canvas.html#filters: + title: 4.12.5.1.20 Filters + status: unknown + canvas.html#working-with-externally-defined-svg-filters: + title: 4.12.5.1.21 Working with externally-defined SVG filters + status: unknown + canvas.html#drawing-model: + title: 4.12.5.1.22 Drawing model + status: unknown + canvas.html#best-practices: + title: 4.12.5.1.23 Best practices + status: unknown + canvas.html#examples: + title: 4.12.5.1.24 Examples + status: unknown + canvas.html#the-imagebitmap-rendering-context: + title: 4.12.5.2 The ImageBitmap rendering context + status: unknown + canvas.html#introduction-6: + title: 4.12.5.2.1 Introduction + status: unknown + canvas.html#the-imagebitmaprenderingcontext-interface: + title: 4.12.5.2.2 The ImageBitmapRenderingContext interface + status: unknown + canvas.html#the-offscreencanvas-interface: + title: 4.12.5.3 The OffscreenCanvas interface + status: unknown + canvas.html#the-offscreen-2d-rendering-context: + title: 4.12.5.3.1 The offscreen 2D rendering context + status: unknown + canvas.html#colour-spaces-and-colour-correction: + title: 4.12.5.4 Color spaces and color space conversion + status: unknown + canvas.html#serialising-bitmaps-to-a-file: + title: 4.12.5.5 Serializing bitmaps to a file + status: unknown + canvas.html#security-with-canvas-elements: + title: 4.12.5.6 Security with canvas elements + status: unknown + canvas.html#premultiplied-alpha-and-the-2d-rendering-context: + title: 4.12.5.7 Premultiplied alpha and the 2D rendering context + status: unknown + custom-elements.html#custom-elements: + title: 4.13 Custom elements + status: unknown + custom-elements.html#custom-elements-intro: + title: 4.13.1 Introduction + status: unknown + custom-elements.html#custom-elements-autonomous-example: + title: 4.13.1.1 Creating an autonomous custom element + status: unknown + custom-elements.html#custom-elements-face-example: + title: 4.13.1.2 Creating a form-associated custom element + status: unknown + custom-elements.html#custom-elements-accessibility-example: + title: 4.13.1.3 Creating a custom element with default accessible roles, states, and properties + status: unknown + custom-elements.html#custom-elements-customized-builtin-example: + title: 4.13.1.4 Creating a customized built-in element + status: unknown + custom-elements.html#custom-elements-autonomous-drawbacks: + title: 4.13.1.5 Drawbacks of autonomous custom elements + status: unknown + custom-elements.html#custom-elements-upgrades-examples: + title: 4.13.1.6 Upgrading elements after their creation + status: unknown + custom-elements.html#scoped-custom-element-registries: + title: 4.13.1.7 Scoped custom element registries + status: unknown + custom-elements.html#exposing-custom-element-states: + title: 4.13.1.8 Exposing custom element states + status: unknown + custom-elements.html#custom-element-conformance: + title: 4.13.2 Requirements for custom element constructors and reactions + status: unknown + custom-elements.html#preserving-custom-element-state-when-moved: + title: 4.13.2.1 Preserving custom element state when moved + status: unknown + custom-elements.html#custom-elements-core-concepts: + title: 4.13.3 Core concepts + status: unknown + custom-elements.html#custom-elements-api: + title: 4.13.4 The CustomElementRegistry interface + status: unknown + custom-elements.html#upgrades: + title: 4.13.5 Upgrades + status: unknown + custom-elements.html#custom-element-reactions: + title: 4.13.6 Custom element reactions + status: unknown + custom-elements.html#element-internals: + title: 4.13.7 Element internals + status: unknown + custom-elements.html#the-elementinternals-interface: + title: 4.13.7.1 The ElementInternals interface + status: unknown + custom-elements.html#shadow-root-access: + title: 4.13.7.2 Shadow root access + status: unknown + custom-elements.html#form-associated-custom-elements: + title: 4.13.7.3 Form-associated custom elements + status: unknown + custom-elements.html#accessibility-semantics: + title: 4.13.7.4 Accessibility semantics + status: unknown + custom-elements.html#custom-state-pseudo-class: + title: 4.13.7.5 Custom state pseudo-class + status: unknown + semantics-other.html#common-idioms: + title: 4.14 Common idioms without dedicated elements + status: unknown + semantics-other.html#rel-up: + title: 4.14.1 Breadcrumb navigation + status: unknown + semantics-other.html#tag-clouds: + title: 4.14.2 Tag clouds + status: unknown + semantics-other.html#conversations: + title: 4.14.3 Conversations + status: unknown + semantics-other.html#footnotes: + title: 4.14.4 Footnotes + status: unknown + semantics-other.html#disabled-elements: + title: 4.15 Disabled elements + status: unknown + semantics-other.html#selectors: + title: 4.16 Matching HTML elements using selectors and CSS + status: unknown + semantics-other.html#case-sensitivity-of-the-css-'attr()'-function: + title: 4.16.1 Case-sensitivity of the CSS 'attr()' function + status: unknown + semantics-other.html#case-sensitivity-of-selectors: + title: 4.16.2 Case-sensitivity of selectors + status: unknown + semantics-other.html#pseudo-classes: + title: 4.16.3 Pseudo-classes + status: unknown + syntax.html#syntax: + title: 13 The HTML syntax + status: unknown + syntax.html#writing: + title: 13.1 Writing HTML documents + status: unknown + syntax.html#the-doctype: + title: 13.1.1 The DOCTYPE + status: unknown + syntax.html#elements-2: + title: 13.1.2 Elements + status: unknown + syntax.html#start-tags: + title: 13.1.2.1 Start tags + status: unknown + syntax.html#end-tags: + title: 13.1.2.2 End tags + status: unknown + syntax.html#attributes-2: + title: 13.1.2.3 Attributes + status: unknown + syntax.html#optional-tags: + title: 13.1.2.4 Optional tags + status: unknown + syntax.html#element-restrictions: + title: 13.1.2.5 Restrictions on content models + status: unknown + syntax.html#cdata-rcdata-restrictions: + title: 13.1.2.6 Restrictions on the contents of raw text and escapable raw text elements + status: unknown + syntax.html#text-2: + title: 13.1.3 Text + status: unknown + syntax.html#newlines: + title: 13.1.3.1 Newlines + status: unknown + syntax.html#character-references: + title: 13.1.4 Character references + status: unknown + syntax.html#cdata-sections: + title: 13.1.5 CDATA sections + status: unknown + syntax.html#comments: + title: 13.1.6 Comments + status: unknown + syntax.html#processing-instructions: + title: 13.1.7 Processing instructions + status: unknown + parsing.html#parsing: + title: 13.2 Parsing HTML documents + status: unknown + parsing.html#overview-of-the-parsing-model: + title: 13.2.1 Overview of the parsing model + status: unknown + parsing.html#parse-errors: + title: 13.2.2 Parse errors + status: unknown + parsing.html#the-input-byte-stream: + title: 13.2.3 The input byte stream + status: unknown + parsing.html#parsing-with-a-known-character-encoding: + title: 13.2.3.1 Parsing with a known character encoding + status: unknown + parsing.html#determining-the-character-encoding: + title: 13.2.3.2 Determining the character encoding + status: unknown + parsing.html#character-encodings: + title: 13.2.3.3 Character encodings + status: unknown + parsing.html#changing-the-encoding-while-parsing: + title: 13.2.3.4 Changing the encoding while parsing + status: unknown + parsing.html#preprocessing-the-input-stream: + title: 13.2.3.5 Preprocessing the input stream + status: unknown + parsing.html#parse-state: + title: 13.2.4 Parse state + status: unknown + parsing.html#the-insertion-mode: + title: 13.2.4.1 The insertion mode + status: unknown + parsing.html#the-stack-of-open-elements: + title: 13.2.4.2 The stack of open elements + status: unknown + parsing.html#the-list-of-active-formatting-elements: + title: 13.2.4.3 The list of active formatting elements + status: unknown + parsing.html#the-element-pointers: + title: 13.2.4.4 The element pointers + status: unknown + parsing.html#other-parsing-state-flags: + title: 13.2.4.5 Other parsing state flags + status: unknown + parsing.html#tokenization: + title: 13.2.5 Tokenization + status: unknown + parsing.html#data-state: + title: 13.2.5.1 Data state + status: unknown + parsing.html#rcdata-state: + title: 13.2.5.2 RCDATA state + status: unknown + parsing.html#rawtext-state: + title: 13.2.5.3 RAWTEXT state + status: unknown + parsing.html#script-data-state: + title: 13.2.5.4 Script data state + status: unknown + parsing.html#plaintext-state: + title: 13.2.5.5 PLAINTEXT state + status: unknown + parsing.html#tag-open-state: + title: 13.2.5.6 Tag open state + status: unknown + parsing.html#end-tag-open-state: + title: 13.2.5.7 End tag open state + status: unknown + parsing.html#tag-name-state: + title: 13.2.5.8 Tag name state + status: unknown + parsing.html#rcdata-less-than-sign-state: + title: 13.2.5.9 RCDATA less-than sign state + status: unknown + parsing.html#rcdata-end-tag-open-state: + title: 13.2.5.10 RCDATA end tag open state + status: unknown + parsing.html#rcdata-end-tag-name-state: + title: 13.2.5.11 RCDATA end tag name state + status: unknown + parsing.html#rawtext-less-than-sign-state: + title: 13.2.5.12 RAWTEXT less-than sign state + status: unknown + parsing.html#rawtext-end-tag-open-state: + title: 13.2.5.13 RAWTEXT end tag open state + status: unknown + parsing.html#rawtext-end-tag-name-state: + title: 13.2.5.14 RAWTEXT end tag name state + status: unknown + parsing.html#script-data-less-than-sign-state: + title: 13.2.5.15 Script data less-than sign state + status: unknown + parsing.html#script-data-end-tag-open-state: + title: 13.2.5.16 Script data end tag open state + status: unknown + parsing.html#script-data-end-tag-name-state: + title: 13.2.5.17 Script data end tag name state + status: unknown + parsing.html#script-data-escape-start-state: + title: 13.2.5.18 Script data escape start state + status: unknown + parsing.html#script-data-escape-start-dash-state: + title: 13.2.5.19 Script data escape start dash state + status: unknown + parsing.html#script-data-escaped-state: + title: 13.2.5.20 Script data escaped state + status: unknown + parsing.html#script-data-escaped-dash-state: + title: 13.2.5.21 Script data escaped dash state + status: unknown + parsing.html#script-data-escaped-dash-dash-state: + title: 13.2.5.22 Script data escaped dash dash state + status: unknown + parsing.html#script-data-escaped-less-than-sign-state: + title: 13.2.5.23 Script data escaped less-than sign state + status: unknown + parsing.html#script-data-escaped-end-tag-open-state: + title: 13.2.5.24 Script data escaped end tag open state + status: unknown + parsing.html#script-data-escaped-end-tag-name-state: + title: 13.2.5.25 Script data escaped end tag name state + status: unknown + parsing.html#script-data-double-escape-start-state: + title: 13.2.5.26 Script data double escape start state + status: unknown + parsing.html#script-data-double-escaped-state: + title: 13.2.5.27 Script data double escaped state + status: unknown + parsing.html#script-data-double-escaped-dash-state: + title: 13.2.5.28 Script data double escaped dash state + status: unknown + parsing.html#script-data-double-escaped-dash-dash-state: + title: 13.2.5.29 Script data double escaped dash dash state + status: unknown + parsing.html#script-data-double-escaped-less-than-sign-state: + title: 13.2.5.30 Script data double escaped less-than sign state + status: unknown + parsing.html#script-data-double-escape-end-state: + title: 13.2.5.31 Script data double escape end state + status: unknown + parsing.html#before-attribute-name-state: + title: 13.2.5.32 Before attribute name state + status: unknown + parsing.html#attribute-name-state: + title: 13.2.5.33 Attribute name state + status: unknown + parsing.html#after-attribute-name-state: + title: 13.2.5.34 After attribute name state + status: unknown + parsing.html#before-attribute-value-state: + title: 13.2.5.35 Before attribute value state + status: unknown + parsing.html#attribute-value-(double-quoted)-state: + title: 13.2.5.36 Attribute value (double-quoted) state + status: unknown + parsing.html#attribute-value-(single-quoted)-state: + title: 13.2.5.37 Attribute value (single-quoted) state + status: unknown + parsing.html#attribute-value-(unquoted)-state: + title: 13.2.5.38 Attribute value (unquoted) state + status: unknown + parsing.html#after-attribute-value-(quoted)-state: + title: 13.2.5.39 After attribute value (quoted) state + status: unknown + parsing.html#self-closing-start-tag-state: + title: 13.2.5.40 Self-closing start tag state + status: unknown + parsing.html#bogus-comment-state: + title: 13.2.5.41 Bogus comment state + status: unknown + parsing.html#markup-declaration-open-state: + title: 13.2.5.42 Markup declaration open state + status: unknown + parsing.html#comment-start-state: + title: 13.2.5.43 Comment start state + status: unknown + parsing.html#comment-start-dash-state: + title: 13.2.5.44 Comment start dash state + status: unknown + parsing.html#comment-state: + title: 13.2.5.45 Comment state + status: unknown + parsing.html#comment-less-than-sign-state: + title: 13.2.5.46 Comment less-than sign state + status: unknown + parsing.html#comment-less-than-sign-bang-state: + title: 13.2.5.47 Comment less-than sign bang state + status: unknown + parsing.html#comment-less-than-sign-bang-dash-state: + title: 13.2.5.48 Comment less-than sign bang dash state + status: unknown + parsing.html#comment-less-than-sign-bang-dash-dash-state: + title: 13.2.5.49 Comment less-than sign bang dash dash state + status: unknown + parsing.html#comment-end-dash-state: + title: 13.2.5.50 Comment end dash state + status: unknown + parsing.html#comment-end-state: + title: 13.2.5.51 Comment end state + status: unknown + parsing.html#comment-end-bang-state: + title: 13.2.5.52 Comment end bang state + status: unknown + parsing.html#doctype-state: + title: 13.2.5.53 DOCTYPE state + status: unknown + parsing.html#before-doctype-name-state: + title: 13.2.5.54 Before DOCTYPE name state + status: unknown + parsing.html#doctype-name-state: + title: 13.2.5.55 DOCTYPE name state + status: unknown + parsing.html#after-doctype-name-state: + title: 13.2.5.56 After DOCTYPE name state + status: unknown + parsing.html#after-doctype-public-keyword-state: + title: 13.2.5.57 After DOCTYPE public keyword state + status: unknown + parsing.html#before-doctype-public-identifier-state: + title: 13.2.5.58 Before DOCTYPE public identifier state + status: unknown + parsing.html#doctype-public-identifier-(double-quoted)-state: + title: 13.2.5.59 DOCTYPE public identifier (double-quoted) state + status: unknown + parsing.html#doctype-public-identifier-(single-quoted)-state: + title: 13.2.5.60 DOCTYPE public identifier (single-quoted) state + status: unknown + parsing.html#after-doctype-public-identifier-state: + title: 13.2.5.61 After DOCTYPE public identifier state + status: unknown + parsing.html#between-doctype-public-and-system-identifiers-state: + title: 13.2.5.62 Between DOCTYPE public and system identifiers state + status: unknown + parsing.html#after-doctype-system-keyword-state: + title: 13.2.5.63 After DOCTYPE system keyword state + status: unknown + parsing.html#before-doctype-system-identifier-state: + title: 13.2.5.64 Before DOCTYPE system identifier state + status: unknown + parsing.html#doctype-system-identifier-(double-quoted)-state: + title: 13.2.5.65 DOCTYPE system identifier (double-quoted) state + status: unknown + parsing.html#doctype-system-identifier-(single-quoted)-state: + title: 13.2.5.66 DOCTYPE system identifier (single-quoted) state + status: unknown + parsing.html#after-doctype-system-identifier-state: + title: 13.2.5.67 After DOCTYPE system identifier state + status: unknown + parsing.html#bogus-doctype-state: + title: 13.2.5.68 Bogus DOCTYPE state + status: unknown + parsing.html#cdata-section-state: + title: 13.2.5.69 CDATA section state + status: unknown + parsing.html#cdata-section-bracket-state: + title: 13.2.5.70 CDATA section bracket state + status: unknown + parsing.html#cdata-section-end-state: + title: 13.2.5.71 CDATA section end state + status: unknown + parsing.html#processing-instruction-open-state: + title: 13.2.5.72 Processing instruction open state + status: unknown + parsing.html#processing-instruction-target-state: + title: 13.2.5.73 Processing instruction target state + status: unknown + parsing.html#after-processing-instruction-target-state: + title: 13.2.5.74 After processing instruction target state + status: unknown + parsing.html#processing-instruction-data-state: + title: 13.2.5.75 Processing instruction data state + status: unknown + parsing.html#processing-instruction-questionable-state: + title: 13.2.5.76 Processing instruction questionable state + status: unknown + parsing.html#character-reference-state: + title: 13.2.5.77 Character reference state + status: unknown + parsing.html#named-character-reference-state: + title: 13.2.5.78 Named character reference state + status: unknown + parsing.html#ambiguous-ampersand-state: + title: 13.2.5.79 Ambiguous ampersand state + status: unknown + parsing.html#numeric-character-reference-state: + title: 13.2.5.80 Numeric character reference state + status: unknown + parsing.html#hexadecimal-character-reference-start-state: + title: 13.2.5.81 Hexadecimal character reference start state + status: unknown + parsing.html#hexadecimal-character-reference-state: + title: 13.2.5.82 Hexadecimal character reference state + status: unknown + parsing.html#decimal-character-reference-state: + title: 13.2.5.83 Decimal character reference state + status: unknown + parsing.html#numeric-character-reference-end-state: + title: 13.2.5.84 Numeric character reference end state + status: unknown + parsing.html#tree-construction: + title: 13.2.6 Tree construction + status: unknown + parsing.html#creating-and-inserting-nodes: + title: 13.2.6.1 Creating and inserting nodes + status: unknown + parsing.html#parsing-elements-that-contain-only-text: + title: 13.2.6.2 Parsing elements that contain only text + status: unknown + parsing.html#closing-elements-that-have-implied-end-tags: + title: 13.2.6.3 Closing elements that have implied end tags + status: unknown + parsing.html#parsing-main-inhtml: + title: 13.2.6.4 The rules for parsing tokens in HTML content + status: unknown + parsing.html#the-initial-insertion-mode: + title: 13.2.6.4.1 The "initial" insertion mode + status: unknown + parsing.html#the-before-html-insertion-mode: + title: 13.2.6.4.2 The "before html" insertion mode + status: unknown + parsing.html#the-before-head-insertion-mode: + title: 13.2.6.4.3 The "before head" insertion mode + status: unknown + parsing.html#parsing-main-inhead: + title: 13.2.6.4.4 The "in head" insertion mode + status: unknown + parsing.html#parsing-main-inheadnoscript: + title: 13.2.6.4.5 The "in head noscript" insertion mode + status: unknown + parsing.html#the-after-head-insertion-mode: + title: 13.2.6.4.6 The "after head" insertion mode + status: unknown + parsing.html#parsing-main-inbody: + title: 13.2.6.4.7 The "in body" insertion mode + status: unknown + parsing.html#parsing-main-incdata: + title: 13.2.6.4.8 The "text" insertion mode + status: unknown + parsing.html#parsing-main-intable: + title: 13.2.6.4.9 The "in table" insertion mode + status: unknown + parsing.html#parsing-main-intabletext: + title: 13.2.6.4.10 The "in table text" insertion mode + status: unknown + parsing.html#parsing-main-incaption: + title: 13.2.6.4.11 The "in caption" insertion mode + status: unknown + parsing.html#parsing-main-incolgroup: + title: 13.2.6.4.12 The "in column group" insertion mode + status: unknown + parsing.html#parsing-main-intbody: + title: 13.2.6.4.13 The "in table body" insertion mode + status: unknown + parsing.html#parsing-main-intr: + title: 13.2.6.4.14 The "in row" insertion mode + status: unknown + parsing.html#parsing-main-intd: + title: 13.2.6.4.15 The "in cell" insertion mode + status: unknown + parsing.html#parsing-main-intemplate: + title: 13.2.6.4.16 The "in template" insertion mode + status: unknown + parsing.html#parsing-main-afterbody: + title: 13.2.6.4.17 The "after body" insertion mode + status: unknown + parsing.html#parsing-main-inframeset: + title: 13.2.6.4.18 The "in frameset" insertion mode + status: unknown + parsing.html#parsing-main-afterframeset: + title: 13.2.6.4.19 The "after frameset" insertion mode + status: unknown + parsing.html#the-after-after-body-insertion-mode: + title: 13.2.6.4.20 The "after after body" insertion mode + status: unknown + parsing.html#the-after-after-frameset-insertion-mode: + title: 13.2.6.4.21 The "after after frameset" insertion mode + status: unknown + parsing.html#parsing-main-inforeign: + title: 13.2.6.5 The rules for parsing tokens in foreign content + status: unknown + parsing.html#the-end: + title: 13.2.7 The end + status: unknown + parsing.html#speculative-html-parsing: + title: 13.2.8 Speculative HTML parsing + status: unknown + parsing.html#coercing-an-html-dom-into-an-infoset: + title: 13.2.9 Coercing an HTML DOM into an infoset + status: unknown + parsing.html#an-introduction-to-error-handling-and-strange-cases-in-the-parser: + title: 13.2.10 An introduction to error handling and strange cases in the parser + status: unknown + parsing.html#misnested-tags:-b-i-/b-/i: + title: '13.2.10.1 Misnested tags: ' + status: unknown + parsing.html#misnested-tags:-b-p-/b-/p: + title: '13.2.10.2 Misnested tags:

' + status: unknown + parsing.html#unexpected-markup-in-tables: + title: 13.2.10.3 Unexpected markup in tables + status: unknown + parsing.html#scripts-that-modify-the-page-as-it-is-being-parsed: + title: 13.2.10.4 Scripts that modify the page as it is being parsed + status: unknown + parsing.html#the-execution-of-scripts-that-are-moving-across-multiple-documents: + title: 13.2.10.5 The execution of scripts that are moving across multiple documents + status: unknown + parsing.html#unclosed-formatting-elements: + title: 13.2.10.6 Unclosed formatting elements + status: unknown + parsing.html#serialising-html-fragments: + title: 13.3 Serializing HTML fragments + status: unknown + parsing.html#parsing-html-fragments: + title: 13.4 Parsing HTML fragments + status: unknown + named-characters.html#named-character-references: + title: 13.5 Named character references + status: unknown + rendering.html#rendering: + title: 15 Rendering + status: unknown + rendering.html#introduction-17: + title: 15.1 Introduction + status: unknown + rendering.html#the-css-user-agent-style-sheet-and-presentational-hints: + title: 15.2 The CSS user agent style sheet and presentational hints + status: unknown + rendering.html#non-replaced-elements: + title: 15.3 Non-replaced elements + status: unknown + rendering.html#hidden-elements: + title: 15.3.1 Hidden elements + status: unknown + rendering.html#the-page: + title: 15.3.2 The page + status: unknown + rendering.html#flow-content-3: + title: 15.3.3 Flow content + status: unknown + rendering.html#phrasing-content-3: + title: 15.3.4 Phrasing content + status: unknown + rendering.html#bidi-rendering: + title: 15.3.5 Bidirectional text + status: unknown + rendering.html#sections-and-headings: + title: 15.3.6 Sections and headings + status: unknown + rendering.html#lists: + title: 15.3.7 Lists + status: unknown + rendering.html#tables-2: + title: 15.3.8 Tables + status: unknown + rendering.html#margin-collapsing-quirks: + title: 15.3.9 Margin collapsing quirks + status: unknown + rendering.html#form-controls: + title: 15.3.10 Form controls + status: unknown + rendering.html#the-hr-element-2: + title: 15.3.11 The hr element + status: unknown + rendering.html#the-fieldset-and-legend-elements: + title: 15.3.12 The fieldset and legend elements + status: unknown + rendering.html#replaced-elements: + title: 15.4 Replaced elements + status: unknown + rendering.html#embedded-content-rendering-rules: + title: 15.4.1 Embedded content + status: unknown + rendering.html#images-3: + title: 15.4.2 Images + status: unknown + rendering.html#attributes-for-embedded-content-and-images: + title: 15.4.3 Attributes for embedded content and images + status: unknown + rendering.html#image-maps-2: + title: 15.4.4 Image maps + status: unknown + rendering.html#widgets: + title: 15.5 Widgets + status: unknown + rendering.html#native-appearance-2: + title: 15.5.1 Native appearance + status: unknown + rendering.html#writing-mode: + title: 15.5.2 Writing mode + status: unknown + rendering.html#button-layout: + title: 15.5.3 Button layout + status: unknown + rendering.html#the-button-element-2: + title: 15.5.4 The button element + status: unknown + rendering.html#the-details-and-summary-elements: + title: 15.5.5 The details and summary elements + status: unknown + rendering.html#the-input-element-as-a-text-entry-widget: + title: 15.5.6 The input element as a text entry widget + status: unknown + rendering.html#the-input-element-as-domain-specific-widgets: + title: 15.5.7 The input element as domain-specific widgets + status: unknown + rendering.html#the-input-element-as-a-range-control: + title: 15.5.8 The input element as a range control + status: unknown + rendering.html#the-input-element-as-a-colour-well: + title: 15.5.9 The input element as a color well + status: unknown + rendering.html#the-input-element-as-a-checkbox-and-radio-button-widgets: + title: 15.5.10 The input element as a checkbox and radio button widgets + status: unknown + rendering.html#the-input-element-as-a-file-upload-control: + title: 15.5.11 The input element as a file upload control + status: unknown + rendering.html#the-input-element-as-a-button: + title: 15.5.12 The input element as a button + status: unknown + rendering.html#the-marquee-element-2: + title: 15.5.13 The marquee element + status: unknown + rendering.html#the-meter-element-2: + title: 15.5.14 The meter element + status: unknown + rendering.html#the-progress-element-2: + title: 15.5.15 The progress element + status: unknown + rendering.html#the-select-element-2: + title: 15.5.16 The select element + status: unknown + rendering.html#the-textarea-element-2: + title: 15.5.17 The textarea element + status: unknown + rendering.html#frames-and-framesets: + title: 15.6 Frames and framesets + status: unknown + rendering.html#interactive-media: + title: 15.7 Interactive media + status: unknown + rendering.html#links,-forms,-and-navigation: + title: 15.7.1 Links, forms, and navigation + status: unknown + rendering.html#the-title-attribute-2: + title: 15.7.2 The title attribute + status: unknown + rendering.html#editing-hosts: + title: 15.7.3 Editing hosts + status: unknown + rendering.html#text-rendered-in-native-user-interfaces: + title: 15.7.4 Text rendered in native user interfaces + status: unknown + rendering.html#print-media: + title: 15.8 Print media + status: unknown + rendering.html#unstyled-xml-documents: + title: 15.9 Unstyled XML documents + status: unknown + obsolete.html#obsolete: + title: 16 Obsolete features + status: unknown + obsolete.html#obsolete-but-conforming-features: + title: 16.1 Obsolete but conforming features + status: unknown + obsolete.html#warnings-for-obsolete-but-conforming-features: + title: 16.1.1 Warnings for obsolete but conforming features + status: unknown + obsolete.html#non-conforming-features: + title: 16.2 Non-conforming features + status: unknown + obsolete.html#requirements-for-implementations: + title: 16.3 Requirements for implementations + status: unknown + obsolete.html#the-marquee-element: + title: 16.3.1 The marquee element + status: unknown + obsolete.html#frames: + title: 16.3.2 Frames + status: unknown + obsolete.html#other-elements,-attributes-and-apis: + title: 16.3.3 Other elements, attributes and APIs + status: unknown From 5689385207520c87546a873567075d86374c9f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 17:18:41 +0100 Subject: [PATCH 004/150] Verify and document HTML grouping content and lists compliance --- Source/HtmlRenderer/Core/CssDefaults.cs | 9 +- Source/HtmlRenderer/Core/Dom/CssBox.cs | 7 +- .../ListItemCounterIntegrationTests.cs | 54 +----- .../Html/GroupingContentTests.cs | 100 +++++++++++ .../HtmlRenderer.Test/Html/HtmlTestSupport.cs | 23 +++ .../HtmlRenderer.Test/Html/RenderingTests.cs | 156 ++++++++++++++++++ .../Html/TreeConstructionTests.cs | 126 ++++++++++++++ Spec/html.yml | 97 +++++++---- 8 files changed, 475 insertions(+), 97 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/GroupingContentTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Html/HtmlTestSupport.cs create mode 100644 Source/Test/HtmlRenderer.Test/Html/RenderingTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Html/TreeConstructionTests.cs diff --git a/Source/HtmlRenderer/Core/CssDefaults.cs b/Source/HtmlRenderer/Core/CssDefaults.cs index 00dc80708..0bee3e6c3 100644 --- a/Source/HtmlRenderer/Core/CssDefaults.cs +++ b/Source/HtmlRenderer/Core/CssDefaults.cs @@ -98,13 +98,8 @@ internal static class CssDefaults *[DIR=""ltr""] { direction: ltr; unicode-bidi: embed } *[DIR=""rtl""] { direction: rtl; unicode-bidi: embed } - /* Ported from PeachPDF's CssDefaults (spelt with css-break-3's break-* properties rather - than the legacy page-break-* aliases - the two share their storage and initial value, - see InitialValues below, so this is the same cascade either way). Replaces this engine's - own older `h1 { page-break-before: always }` default, which forced a leading blank page - before any document that opened with a heading now that break-before is actually - consumed by layout - break-after: avoid (keep-with-next) is the behavior real print - engines give headings by default. */ + /* Unlike CSS 2.1 Appendix D, h1 doesn't break before itself, a document starting + with one would get an empty first page. */ @media print { h1, h2, h3, h4, h5, h6 { break-after: avoid } diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index e0441b5c7..cfb85b306 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -1242,9 +1242,7 @@ private int GetIndexForList() foreach (CssBox b in ParentBox.Boxes) { - // An explicit
  • (WHATWG HTML presentational hint) sets the running count to - // N for that item; later items (without their own explicit value) continue incrementing - // from there, matching every browser's actual behavior. + // HTML §4.4.8, the following items continue counting from an li's value int explicitValue; if (b.Display == CssConstants.ListItem && int.TryParse(b.GetAttribute("value"), out explicitValue)) { @@ -1286,9 +1284,6 @@ private void CreateListItemBox(RGraphics g) } else if (ListStyleType.Equals(CssConstants.Square, StringComparison.InvariantCultureIgnoreCase)) { - // Was "♠" (U+2660 BLACK SPADE SUIT) - not what CSS2.1 §12.5.1's list-style-type: - // square means at all (a filled square marker, the ♣/♦/♠ card-suit glyph was - // presumably a typo/copy-paste of a nearby symbol). _listItemBox.Text = "▪"; // U+25AA BLACK SMALL SQUARE } else if (ListStyleType.Equals(CssConstants.Decimal, StringComparison.InvariantCultureIgnoreCase)) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Content/ListItemCounterIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Content/ListItemCounterIntegrationTests.cs index 1c37d26ba..2d80cd3bf 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Content/ListItemCounterIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Content/ListItemCounterIntegrationTests.cs @@ -3,60 +3,10 @@ namespace HtmlRenderer.IntegrationTest.Content; -/// -/// Ported from PeachPDF.Tests/Integration/ListItemCounterIntegrationTests.cs - the CSS2.1-relevant subset -/// this fork's simpler, non-CSS-counter-driven list-marker mechanism (CssBox.GetIndexForList) can -/// support: <ol start>/reversed (already implemented) and <li value> (newly -/// added by this port). CSS3 marker-styling extras from the source file are not ported. -/// [DoNotParallelize] [TestClass] public sealed class ListItemCounterIntegrationTests { - [TestMethod] - public void OlStart_OffsetsEveryItem() - { - var html = LayoutHarness.Wrap("
    1. x
    2. y
    "); - var (root, _) = LayoutHarness.Layout(html); - - var a = LayoutHarness.FindById(root, "a")!; - var b = LayoutHarness.FindById(root, "b")!; - - Assert.AreEqual("5.", a.ListItemBox.Words[0].Text); - Assert.AreEqual("6.", b.ListItemBox.Words[0].Text); - } - - [TestMethod] - public void OlReversed_CountsDownFromItemCount() - { - var html = LayoutHarness.Wrap("
    1. x
    2. y
    3. z
    "); - var (root, _) = LayoutHarness.Layout(html); - - var a = LayoutHarness.FindById(root, "a")!; - var b = LayoutHarness.FindById(root, "b")!; - var c = LayoutHarness.FindById(root, "c")!; - - Assert.AreEqual("3.", a.ListItemBox.Words[0].Text); - Assert.AreEqual("2.", b.ListItemBox.Words[0].Text); - Assert.AreEqual("1.", c.ListItemBox.Words[0].Text); - } - - [TestMethod] - public void LiValue_OverridesThatItemAndContinuesFromThere() - { - var html = LayoutHarness.Wrap( - "
    1. x
    2. y
    3. z
    "); - var (root, _) = LayoutHarness.Layout(html); - - var a = LayoutHarness.FindById(root, "a")!; - var b = LayoutHarness.FindById(root, "b")!; - var c = LayoutHarness.FindById(root, "c")!; - - Assert.AreEqual("1.", a.ListItemBox.Words[0].Text); - Assert.AreEqual("10.", b.ListItemBox.Words[0].Text); - Assert.AreEqual("11.", c.ListItemBox.Words[0].Text); - } - [TestMethod] [DataRow(-3, "lower-roman", "-3.")] [DataRow(0, "upper-roman", "0.")] @@ -74,9 +24,9 @@ public void OlStart_OutsideTheStyleRange_FallsBackToDecimal(int start, string st } [TestMethod] - public void ListStyleTypeSquare_UsesAFilledSquareGlyph_NotASpadeSuitSymbol() + [Spec("css21", "generate.html#list-style")] + public void ListStyleTypeSquare_UsesAFilledSquare() { - // Regression: this used to render "♠" (U+2660 BLACK SPADE SUIT), not a square at all. var html = LayoutHarness.Wrap("
    • x
    "); var (root, _) = LayoutHarness.Layout(html); var a = LayoutHarness.FindById(root, "a")!; diff --git a/Source/Test/HtmlRenderer.Test/Html/GroupingContentTests.cs b/Source/Test/HtmlRenderer.Test/Html/GroupingContentTests.cs new file mode 100644 index 000000000..4421e597c --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/GroupingContentTests.cs @@ -0,0 +1,100 @@ +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class GroupingContentTests +{ + [TestMethod] + [Spec("html", "grouping-content.html#the-p-element")] + [Spec("html", "grouping-content.html#the-hr-element")] + [Spec("html", "grouping-content.html#the-pre-element")] + [Spec("html", "grouping-content.html#the-blockquote-element")] + [Spec("html", "grouping-content.html#the-dl-element")] + [Spec("html", "grouping-content.html#the-dt-element")] + [Spec("html", "grouping-content.html#the-dd-element")] + [Spec("html", "grouping-content.html#the-div-element")] + [DataRow("p")] + [DataRow("hr")] + [DataRow("pre")] + [DataRow("blockquote")] + [DataRow("dl")] + [DataRow("dt")] + [DataRow("dd")] + [DataRow("div")] + public void Element_IsABlock(string tag) + { + Assert.AreEqual("block", Box($"<{tag} id=x>a").Display); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "grouping-content.html#the-figure-element")] + [Spec("html", "grouping-content.html#the-figcaption-element")] + [Spec("html", "grouping-content.html#the-main-element")] + [Spec("html", "grouping-content.html#the-search-element")] + [DataRow("figure")] + [DataRow("figcaption")] + [DataRow("main")] + [DataRow("search")] + public void NewerElement_IsABlock(string tag) + { + Assert.AreEqual("block", Box($"<{tag} id=x>a").Display); + } + + [TestMethod] + [Spec("html", "grouping-content.html#the-ul-element")] + [Spec("html", "grouping-content.html#the-menu-element")] + [Spec("html", "grouping-content.html#the-li-element")] + public void UnorderedListItems_HaveADiscMarker() + { + Assert.AreEqual("disc", Box("
    • a
    ").ListStyleType); + Assert.AreEqual("disc", Box("
  • a
  • ").ListStyleType); + } + + [TestMethod] + [Spec("html", "grouping-content.html#the-ol-element")] + [Spec("html", "grouping-content.html#the-li-element")] + public void OrderedListItems_AreNumberedFromOne() + { + Assert.AreEqual("2.", Marker("
    1. a
    2. b
    ")); + } + + [TestMethod] + [Spec("html", "grouping-content.html#the-ol-element")] + public void Start_SetsTheFirstNumber() + { + Assert.AreEqual("6.", Marker("
    1. a
    2. b
    ")); + Assert.AreEqual("-1.", Marker("
    1. a
    2. b
    ")); + } + + [TestMethod] + [Spec("html", "grouping-content.html#the-ol-element")] + public void Reversed_CountsDownFromTheNumberOfItems() + { + Assert.AreEqual("1.", Marker("
    1. a
    2. b
    ")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "grouping-content.html#the-ol-element")] + public void Reversed_WithoutAValue_CountsDown() + { + Assert.AreEqual("1.", Marker("
    1. a
    2. b
    ")); + } + + [TestMethod] + [Spec("html", "grouping-content.html#the-ol-element")] + public void Reversed_WithStart_CountsDownFromStart() + { + Assert.AreEqual("4.", Marker("
    1. a
    2. b
    ")); + } + + [TestMethod] + [Spec("html", "grouping-content.html#the-li-element")] + public void Value_SetsThisAndTheFollowingNumbers() + { + Assert.AreEqual("7.", Marker("
    1. a
    2. b
    ")); + Assert.AreEqual("8.", Marker("
    1. a
    2. b
    ")); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Html/HtmlTestSupport.cs b/Source/Test/HtmlRenderer.Test/Html/HtmlTestSupport.cs new file mode 100644 index 000000000..7f33babbe --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/HtmlTestSupport.cs @@ -0,0 +1,23 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.Html; + +internal static class HtmlTestSupport +{ + /// Lays out and returns the box with the given id. + public static CssBox Box(string body, string id = "x") + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(body)); + var box = LayoutHarness.FindById(root, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box; + } + + /// The tag name of the element the parser put the box with the given id into. + public static string? ParentTag(string body, string id = "x") => Box(body, id).ParentBox?.HtmlTag?.Name; + + /// The list marker of the list item with the given id, e.g. "3.". + public static string Marker(string body, string id = "x") => + string.Concat(Box(body, id).ListItemBox?.Words.Select(w => w.Text) ?? []); +} diff --git a/Source/Test/HtmlRenderer.Test/Html/RenderingTests.cs b/Source/Test/HtmlRenderer.Test/Html/RenderingTests.cs new file mode 100644 index 000000000..5ce9e2717 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/RenderingTests.cs @@ -0,0 +1,156 @@ +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class RenderingTests +{ + [TestMethod] + [Spec("html", "rendering.html#hidden-elements")] + [DataRow("
    ")] + [DataRow("
    ")] + public void MetadataAndScripts_AreNotDisplayed(string html) + { + Assert.AreEqual("none", Box(html).Display); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#hidden-elements")] + [DataRow("")] + [DataRow("")] + [DataRow("a")] + [DataRow("a")] + [DataRow("a")] + [DataRow("a")] + public void HiddenElements_AreNotDisplayed(string html) + { + Assert.AreEqual("none", Box(html).Display); + } + + [TestMethod] + [Spec("html", "rendering.html#flow-content-3")] + [DataRow("address")] + [DataRow("blockquote")] + [DataRow("center")] + [DataRow("div")] + [DataRow("form")] + [DataRow("hr")] + [DataRow("p")] + [DataRow("pre")] + public void FlowContent_IsABlock(string tag) + { + Assert.AreEqual("block", Box($"<{tag} id=x>a").Display); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#flow-content-3")] + [DataRow("article")] + [DataRow("aside")] + [DataRow("details")] + [DataRow("figcaption")] + [DataRow("figure")] + [DataRow("footer")] + [DataRow("header")] + [DataRow("hgroup")] + [DataRow("legend")] + [DataRow("listing")] + [DataRow("main")] + [DataRow("nav")] + [DataRow("plaintext")] + [DataRow("search")] + [DataRow("section")] + [DataRow("summary")] + [DataRow("xmp")] + public void NewerFlowContent_IsABlock(string tag) + { + Assert.AreEqual("block", Box($"<{tag} id=x>a").Display); + } + + [TestMethod] + [Spec("html", "rendering.html#flow-content-3")] + public void Blockquote_IsIndentedOnBothSides() + { + var box = Box("
    a
    "); + + Assert.AreEqual(40, box.ActualMarginLeft); + Assert.AreEqual(40, box.ActualMarginRight); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#flow-content-3")] + [DataRow("p")] + [DataRow("blockquote")] + [DataRow("pre")] + [DataRow("figure")] + public void Paragraphs_HaveOneEmVerticalMargins(string tag) + { + var box = Box($"<{tag} id=x style='font-size:16px'>a"); + + Assert.AreEqual(16, box.ActualMarginTop, 0.01); + Assert.AreEqual(16, box.ActualMarginBottom, 0.01); + } + + [TestMethod] + [Spec("html", "rendering.html#lists")] + public void NestedUnorderedLists_SwitchToCircleThenSquare() + { + Assert.AreEqual("circle", Box("
      • a
    ").ListStyleType); + Assert.AreEqual("circle", Box("
      • a
    ").ListStyleType); + Assert.AreEqual("square", Box("
        • a
    ").ListStyleType); + Assert.AreEqual("decimal", Box("
      1. a
    ").ListStyleType); + } + + [TestMethod] + [Spec("html", "rendering.html#lists")] + public void DescriptionDetails_AreIndented() + { + Assert.AreEqual(40, Box("
    a
    ").ActualMarginLeft); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#lists")] + public void Lists_AreIndentedWithPadding() + { + var box = Box("
    • a
    "); + + Assert.AreEqual(40, box.ActualPaddingLeft); + Assert.AreEqual(0, box.ActualMarginLeft); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#lists")] + public void TypeAttribute_SetsTheMarker() + { + Assert.AreEqual("b.", Marker("
    1. a
    2. b
    ")); + Assert.AreEqual("II.", Marker("
    1. a
    2. b
    ")); + Assert.AreEqual("B.", Marker("
    1. a
    2. b
    ")); + Assert.AreEqual("square", Box("
    • a
    ").ListStyleType); + } + + [TestMethod] + [Spec("html", "rendering.html#the-hr-element-2")] + public void Hr_HasAnInsetOnePixelBorder() + { + var box = Box("
    "); + + Assert.AreEqual("inset", box.BorderTopStyle); + Assert.AreEqual(1, box.ActualBorderTopWidth); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-hr-element-2")] + public void Hr_IsCenteredWithHalfAnEmVerticalMargin() + { + var box = Box("

    "); + + Assert.AreEqual(8, box.ActualMarginTop, 0.01); + Assert.AreEqual(50, box.ActualMarginLeft, 0.01); + Assert.AreEqual("gray", box.Color); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Html/TreeConstructionTests.cs b/Source/Test/HtmlRenderer.Test/Html/TreeConstructionTests.cs new file mode 100644 index 000000000..8e76edd24 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/TreeConstructionTests.cs @@ -0,0 +1,126 @@ +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class TreeConstructionTests +{ + [TestMethod] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void BlockStartTag_ClosesAnOpenParagraph() + { + Assert.AreEqual("body", ParentTag("

    a

    b
    ")); + Assert.AreEqual("body", ParentTag("

    a

    • b
    ")); + Assert.AreEqual("body", ParentTag("

    a

    b")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void BlockStartTag_ClosesAnOpenParagraph_WithAnInlineElementStillOpen() + { + Assert.AreEqual("body", ParentTag("

    a

    b
    ")); + Assert.AreEqual("body", ParentTag("

    a

    b")); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void Pre_DropsTheNewlineRightAfterTheStartTag() + { + Assert.AreEqual("a", Box("

    \na
    ").Boxes[0].Text); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void Textarea_ContentIsText() + { + Assert.AreEqual(0, Box("").Boxes.Count(b => b.HtmlTag?.Name == "b")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void ListItem_ClosesThePreviousListItem() + { + Assert.AreEqual("ul", ParentTag("
    • a
    • b
    ")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void DescriptionTerm_AndDetails_CloseEachOther() + { + Assert.AreEqual("dl", ParentTag("
    a
    b
    ")); + Assert.AreEqual("dl", ParentTag("
    a
    b
    ")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void Option_ClosesThePreviousOption() + { + Assert.AreEqual("select", ParentTag("")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void UnmatchedEndTag_IsIgnored() + { + Assert.AreEqual("span", ParentTag("
    ab
    ")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void UnmatchedParagraphEndTag_InsertsAnEmptyParagraph() + { + Assert.IsTrue(Box("

    ").Boxes.Any(b => b.HtmlTag?.Name == "p")); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-intable")] + public void Caption_IsAChildOfTheTable() + { + Assert.AreEqual("table", ParentTag("
    a
    b
    ")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-intable")] + public void TableRow_OutsideASection_GetsAnImpliedTbody() + { + Assert.AreEqual("tbody", ParentTag("
    a
    ")); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-intbody")] + [Spec("html", "parsing.html#parsing-main-intr")] + public void TableRow_ClosesThePreviousRow() + { + Assert.AreEqual("tbody", ParentTag("
    a
    b
    ")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-intbody")] + public void TableSection_ClosesThePreviousSection() + { + Assert.AreEqual("table", ParentTag("
    a
    b
    ")); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-intd")] + public void DataCell_ClosesThePreviousCell() + { + Assert.AreEqual("tr", ParentTag("
    ab
    ")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-intd")] + public void HeaderCell_ClosesThePreviousCell() + { + Assert.AreEqual("tr", ParentTag("
    ab
    ")); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 244c064a1..1452f6ddf 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -187,7 +187,7 @@ sections: status: unknown semantics.html#semantics: title: 4 The elements of HTML - status: unknown + status: partial semantics.html#the-root-element: title: 4.1 The document element status: unknown @@ -301,55 +301,65 @@ sections: status: unknown grouping-content.html#grouping-content: title: 4.4 Grouping content - status: unknown + status: partial grouping-content.html#the-p-element: title: 4.4.1 The p element - status: unknown + status: supported grouping-content.html#the-hr-element: title: 4.4.2 The hr element - status: unknown + status: supported grouping-content.html#the-pre-element: title: 4.4.3 The pre element - status: unknown + status: supported grouping-content.html#the-blockquote-element: title: 4.4.4 The blockquote element - status: unknown + status: supported grouping-content.html#the-ol-element: title: 4.4.5 The ol element - status: unknown + status: partial + gaps: + - reversed without a value, i.e.
      , counts up grouping-content.html#the-ul-element: title: 4.4.6 The ul element - status: unknown + status: supported grouping-content.html#the-menu-element: title: 4.4.7 The menu element - status: unknown + status: supported grouping-content.html#the-li-element: title: 4.4.8 The li element - status: unknown + status: supported grouping-content.html#the-dl-element: title: 4.4.9 The dl element - status: unknown + status: supported grouping-content.html#the-dt-element: title: 4.4.10 The dt element - status: unknown + status: supported grouping-content.html#the-dd-element: title: 4.4.11 The dd element - status: unknown + status: supported grouping-content.html#the-figure-element: title: 4.4.12 The figure element - status: unknown + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 grouping-content.html#the-figcaption-element: title: 4.4.13 The figcaption element - status: unknown + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 grouping-content.html#the-main-element: title: 4.4.14 The main element - status: unknown + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 grouping-content.html#the-search-element: title: 4.4.15 The search element - status: unknown + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 grouping-content.html#the-div-element: title: 4.4.16 The div element - status: unknown + status: supported text-level-semantics.html#text-level-semantics: title: 4.5 Text-level semantics status: unknown @@ -1441,7 +1451,7 @@ sections: status: unknown syntax.html#syntax: title: 13 The HTML syntax - status: unknown + status: partial syntax.html#writing: title: 13.1 Writing HTML documents status: unknown @@ -1489,7 +1499,7 @@ sections: status: unknown parsing.html#parsing: title: 13.2 Parsing HTML documents - status: unknown + status: partial parsing.html#overview-of-the-parsing-model: title: 13.2.1 Overview of the parsing model status: unknown @@ -1789,7 +1799,7 @@ sections: status: unknown parsing.html#tree-construction: title: 13.2.6 Tree construction - status: unknown + status: partial parsing.html#creating-and-inserting-nodes: title: 13.2.6.1 Creating and inserting nodes status: unknown @@ -1801,7 +1811,7 @@ sections: status: unknown parsing.html#parsing-main-inhtml: title: 13.2.6.4 The rules for parsing tokens in HTML content - status: unknown + status: partial parsing.html#the-initial-insertion-mode: title: 13.2.6.4.1 The "initial" insertion mode status: unknown @@ -1822,13 +1832,21 @@ sections: status: unknown parsing.html#parsing-main-inbody: title: 13.2.6.4.7 The "in body" insertion mode - status: unknown + status: partial + gaps: + - A start tag that closes an open p only does so while the p is the current element, an inline element still open inside it keeps the p open + - li, dt and dd don't close the previous item + - option doesn't close the previous option + - An end tag without a matching open element closes the current element instead of being ignored + - An end tag p without an open p doesn't insert an empty paragraph parsing.html#parsing-main-incdata: title: 13.2.6.4.8 The "text" insertion mode status: unknown parsing.html#parsing-main-intable: title: 13.2.6.4.9 The "in table" insertion mode - status: unknown + status: partial + gaps: + - A tr outside of a table section doesn't get an implied tbody parsing.html#parsing-main-intabletext: title: 13.2.6.4.10 The "in table text" insertion mode status: unknown @@ -1840,13 +1858,17 @@ sections: status: unknown parsing.html#parsing-main-intbody: title: 13.2.6.4.13 The "in table body" insertion mode - status: unknown + status: partial + gaps: + - thead, tbody and tfoot don't close the previous table section parsing.html#parsing-main-intr: title: 13.2.6.4.14 The "in row" insertion mode status: unknown parsing.html#parsing-main-intd: title: 13.2.6.4.15 The "in cell" insertion mode - status: unknown + status: partial + gaps: + - th doesn't close the previous cell parsing.html#parsing-main-intemplate: title: 13.2.6.4.16 The "in template" insertion mode status: unknown @@ -1909,7 +1931,7 @@ sections: status: unknown rendering.html#rendering: title: 15 Rendering - status: unknown + status: partial rendering.html#introduction-17: title: 15.1 Introduction status: unknown @@ -1918,16 +1940,22 @@ sections: status: unknown rendering.html#non-replaced-elements: title: 15.3 Non-replaced elements - status: unknown + status: partial rendering.html#hidden-elements: title: 15.3.1 Hidden elements - status: unknown + status: partial + gaps: + - The hidden attribute is ignored + - template, datalist, noembed, rp and dialog without open are displayed rendering.html#the-page: title: 15.3.2 The page status: unknown rendering.html#flow-content-3: title: 15.3.3 Flow content - status: unknown + status: partial + gaps: + - article, aside, details, figcaption, figure, footer, header, hgroup, legend, listing, main, nav, plaintext, search, section, summary and xmp are inline + - p and blockquote have 1.12em vertical margins instead of 1em, pre has 15px above and nothing below, figure has no margins rendering.html#phrasing-content-3: title: 15.3.4 Phrasing content status: unknown @@ -1939,7 +1967,10 @@ sections: status: unknown rendering.html#lists: title: 15.3.7 Lists - status: unknown + status: partial + gaps: + - ol, ul and menu are indented with margin-left instead of padding-inline-start + - The type attribute of ol, ul and li is ignored rendering.html#tables-2: title: 15.3.8 Tables status: unknown @@ -1951,7 +1982,9 @@ sections: status: unknown rendering.html#the-hr-element-2: title: 15.3.11 The hr element - status: unknown + status: partial + gaps: + - hr isn't centered, has no vertical margins and isn't gray rendering.html#the-fieldset-and-legend-elements: title: 15.3.12 The fieldset and legend elements status: unknown From eb0e560c375011d98f5c05f93df21c4ef07e8fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 17:39:35 +0100 Subject: [PATCH 005/150] Record the HTML snapshot the manifest was checked against --- Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs | 3 +++ Source/Tools/HtmlRenderer.SpecCompliance/Report.cs | 2 ++ Spec/README.md | 2 ++ Spec/html.yml | 1 + 4 files changed, 8 insertions(+) diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs b/Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs index 4221f1f2c..c897a6f4e 100644 --- a/Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs @@ -37,6 +37,9 @@ public sealed class Manifest /// Published URL, anchors are appended to it for links in the report. public string Url { get; set; } = ""; + /// The commit of a living standard the sections were checked against, none for a fixed version. + public string? Snapshot { get; set; } + public Dictionary Sections { get; set; } = new(StringComparer.Ordinal); private static readonly IDeserializer Deserializer = new DeserializerBuilder() diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs b/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs index 8829f7f61..799fb6e69 100644 --- a/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs @@ -24,6 +24,8 @@ public static string Render(Compliance compliance) foreach (var spec in compliance.Specs) { sb.AppendLine().AppendLine($"### {spec.Manifest.Title}").AppendLine(); + if (spec.Manifest.Snapshot != null) + sb.AppendLine($"Checked against commit `{spec.Manifest.Snapshot}`.").AppendLine(); foreach (var chapter in Chapters(spec)) RenderChapter(sb, spec, chapter); } diff --git a/Spec/README.md b/Spec/README.md index 07a3a93ef..ea73bf394 100644 --- a/Spec/README.md +++ b/Spec/README.md @@ -6,6 +6,8 @@ `.yml` has the spec's `title` and `url`, and one entry per section, keyed by its anchor, with the numbered title, the `status` and, if anything is missing, the `gaps`. Every section of the spec is listed, except the subsections of an `n/a` section, so the report also shows what hasn't been reviewed yet. For HTML that's limited to the chapters that matter for rendering a static document: 2.3 microsyntaxes, 3.2 elements, 4 the elements of HTML, 13 the HTML syntax, 15 rendering and 16 obsolete features. +HTML is a living standard without versions, so `html.yml` has a `snapshot`, the commit of [whatwg/html](https://github.com/whatwg/html) its sections were checked against. When the snapshot moves, the sections that changed in between need a recheck. + Statuses: - `supported`: implemented as specified, needs at least one test diff --git a/Spec/html.yml b/Spec/html.yml index 1452f6ddf..2b2c5179a 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1,6 +1,7 @@ spec: html title: HTML Living Standard url: https://html.spec.whatwg.org/multipage/ +snapshot: 7a1d2b8107ef368c4951f15f091d82fb62e59dde sections: common-microsyntaxes.html#common-microsyntaxes: title: 2.3 Common microsyntaxes From ea8afa437847d4e519f174b9c0feff3f6906b81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 17:50:10 +0100 Subject: [PATCH 006/150] Fix CSS 2.1 section titles that start with a pseudo-class --- Spec/css21.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Spec/css21.yml b/Spec/css21.yml index e32d4d78e..737411ea9 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -161,6 +161,9 @@ sections: selector.html#pseudo-class-selectors: title: 5.11 Pseudo-classes status: unknown + selector.html#first-child: + title: 5.11.1 :first-child pseudo-class + status: unknown selector.html#link-pseudo-classes: title: 5.11.2 The link pseudo-classes status: unknown @@ -174,13 +177,13 @@ sections: title: 5.12 Pseudo-elements status: unknown selector.html#first-line-pseudo: - title: 5.12.1 The + title: 5.12.1 The :first-line pseudo-element status: unknown selector.html#first-letter: - title: 5.12.2 The + title: 5.12.2 The :first-letter pseudo-element status: unknown selector.html#before-and-after: - title: 5.12.3 The + title: 5.12.3 The :before and :after pseudo-elements status: unknown cascade.html#q6.0: title: 6 Assigning property values, Cascading, and Inheritance @@ -487,7 +490,7 @@ sections: title: 12 Generated content, automatic numbering, and lists status: unknown generate.html#before-after-content: - title: 12.1 The + title: 12.1 The :before and :after pseudo-elements status: unknown generate.html#content: title: 12.2 The 'content' property From 1a0e10637760093e6475b5241b739c72b3076a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 17:48:40 +0100 Subject: [PATCH 007/150] Verify and document CSS 2.1 introduction compliance --- Spec/css21.yml | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/Spec/css21.yml b/Spec/css21.yml index 737411ea9..6a5a3fcf1 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -4,25 +4,7 @@ url: https://www.w3.org/TR/CSS21/ sections: intro.html#q2.0: title: 2 Introduction to CSS 2.1 - status: unknown - intro.html#html-tutorial: - title: 2.1 A brief CSS 2.1 tutorial for HTML - status: unknown - intro.html#xml-tutorial: - title: 2.2 A brief CSS 2.1 tutorial for XML - status: unknown - intro.html#processing-model: - title: 2.3 The CSS 2.1 processing model - status: unknown - intro.html#the-canvas: - title: 2.3.1 The canvas - status: unknown - intro.html#addressing: - title: 2.3.2 CSS 2.1 addressing model - status: unknown - intro.html#design-principles: - title: 2.4 CSS design principles - status: unknown + status: n/a conform.html#q3.0: title: 3 Conformance status: unknown From a6dbe2fb0d2f3fce3463cb15e831d63c644dcb8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 17:51:07 +0100 Subject: [PATCH 008/150] Verify and document CSS 2.1 conformance compliance --- .../Core/Handlers/StylesheetLoadHandler.cs | 7 +- .../RasterImageSizeIntegrationTests.cs | 45 +++++++++++ .../HtmlRenderer.Test/Css/ConformanceTests.cs | 75 +++++++++++++++++++ Spec/css21.yml | 14 ++-- 4 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Content/RasterImageSizeIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Css/ConformanceTests.cs diff --git a/Source/HtmlRenderer/Core/Handlers/StylesheetLoadHandler.cs b/Source/HtmlRenderer/Core/Handlers/StylesheetLoadHandler.cs index e79f740e5..1bd12ca97 100644 --- a/Source/HtmlRenderer/Core/Handlers/StylesheetLoadHandler.cs +++ b/Source/HtmlRenderer/Core/Handlers/StylesheetLoadHandler.cs @@ -100,11 +100,8 @@ private static async Task LoadStylesheet(HtmlContainerInt htmlContainer, return string.Empty; } - // A response served with a Content-Type other than text/css (e.g. a 404 HTML error page - // returned with a 200 status by a misconfigured server) is treated as "not a stylesheet" - - // return empty and keep rendering, same fail-soft behavior as a missing resource. A response - // with no Content-Type header at all (a local file with an unrecognized extension, etc.) is - // still accepted, matching this handler's pre-existing behavior of trusting the source. + // CSS 2.1 §3.4. A response of another type is skipped like a missing one. A response without + // a Content-Type, e.g. a local file, is trusted. if (networkResponse.ResponseHeaders != null && networkResponse.ResponseHeaders.TryGetValue("Content-Type", out var contentTypeValues) && contentTypeValues.Length > 0 && diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Content/RasterImageSizeIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Content/RasterImageSizeIntegrationTests.cs new file mode 100644 index 000000000..63ab1e415 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Content/RasterImageSizeIntegrationTests.cs @@ -0,0 +1,45 @@ +using System.Drawing; +using System.Drawing.Imaging; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.WinForms.Adapters; + +namespace HtmlRenderer.IntegrationTest.Content; + +[DoNotParallelize] +[TestClass] +public sealed class RasterImageSizeIntegrationTests +{ + private const double Delta = 0.5; + + [TestMethod] + [Spec("css21", "conform.html#defs")] + public void RasterImage_IsOneCssPixelPerImagePixel() + { + var container = new HtmlContainerInt(WinFormsAdapter.Instance) + { + MaxSize = new RSize(1000, 1000), + AvoidAsyncImagesLoading = true + }; + container.SetHtml(LayoutHarness.Wrap($"")).GetAwaiter().GetResult(); + + using var bitmap = new Bitmap(1, 1, PixelFormat.Format32bppArgb); + using var g = Graphics.FromImage(bitmap); + using var graphics = new GraphicsAdapter(g, false); + container.PerformLayout(graphics); + + var image = LayoutHarness.FindById(container.Root!, "i")!; + + Assert.AreEqual(7, image.Words[0].Width, Delta); + Assert.AreEqual(5, image.Words[0].Height, Delta); + } + + private static string PngDataUri(int width, int height) + { + using var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb); + using var stream = new MemoryStream(); + bitmap.Save(stream, ImageFormat.Png); + return "data:image/png;base64," + Convert.ToBase64String(stream.ToArray()); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/ConformanceTests.cs b/Source/Test/HtmlRenderer.Test/Css/ConformanceTests.cs new file mode 100644 index 000000000..eaa5d484a --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/ConformanceTests.cs @@ -0,0 +1,75 @@ +using System.Text; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; + +namespace HtmlRenderer.Test.Css; + +[TestClass] +public sealed class ConformanceTests +{ + private static readonly RColor Green = RColor.FromArgb(0, 128, 0); + + private static RColor ColorOf(string head, MockAdapter? adapter = null) + { + var html = $"{head}

      a

      "; + var (root, _) = LayoutHarness.Layout(html, adapter: adapter); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box.ActualColor; + } + + private static string DataUri(string mediaType, string content) => + $"data:{mediaType};base64," + Convert.ToBase64String(Encoding.UTF8.GetBytes(content)); + + [TestMethod] + [Spec("css21", "conform.html#conformance")] + public void MissingStyleSheet_TheOthersStillApply() + { + Assert.AreEqual(Green, ColorOf("")); + } + + [TestMethod] + [Spec("css21", "conform.html#conformance")] + public void DeclarationWithAnInvalidValue_IsIgnored() + { + Assert.AreEqual(Green, ColorOf("")); + } + + [TestMethod] + [Spec("css21", "media.html#at-media-rule")] + public void RulesForAnotherMediaType_AreIgnored() + { + Assert.AreEqual(Green, ColorOf("")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "conform.html#conformance")] + public void UserStyleSheet_ImportantBeatsAuthorImportant() + { + var adapter = new MockAdapter(); + var container = new HtmlContainerInt(adapter) { MaxSize = new RSize(1000, 4000) }; + container.SetHtml( + "

      a

      ", + CssData.Parse(adapter, "p { color: green !important }")).GetAwaiter().GetResult(); + using var graphics = new RecordingGraphics(); + container.PerformLayout(graphics); + + Assert.AreEqual(Green, LayoutHarness.FindById(container.Root!, "x")!.ActualColor); + } + + [TestMethod] + [Spec("css21", "conform.html#text-css")] + public void TextCssResource_IsUsedAsAStyleSheet() + { + Assert.AreEqual(Green, ColorOf($"")); + } + + [TestMethod] + [Spec("css21", "conform.html#text-css")] + public void ResourceOfAnotherContentType_IsNotUsedAsAStyleSheet() + { + Assert.AreNotEqual(Green, ColorOf($"")); + } +} diff --git a/Spec/css21.yml b/Spec/css21.yml index 6a5a3fcf1..3a7480c15 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -7,19 +7,23 @@ sections: status: n/a conform.html#q3.0: title: 3 Conformance - status: unknown + status: partial conform.html#defs: title: 3.1 Definitions - status: unknown + status: supported conform.html#conformance: title: 3.2 UA Conformance - status: unknown + status: partial + gaps: + - There is no user origin, a base stylesheet given to SetHtml cascades as author style sheet + - Alternate style sheets are never loaded, the user can't select a style sheet set + - Author style sheets can't be turned off conform.html#errors: title: 3.3 Error conditions - status: unknown + status: n/a conform.html#text-css: title: 3.4 The text/css content type - status: unknown + status: supported syndata.html#q4.0: title: 4 Syntax and basic data types status: unknown From 4d7246f2f69d33d55b8438784d4df888434d346f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 17:55:37 +0100 Subject: [PATCH 009/150] Verify and document CSS 2.1 syntax and data types compliance --- .../Core/CssEngine/Parser/Lexer.cs | 23 +- .../Core/Handlers/StylesheetLoadHandler.cs | 2 +- Source/HtmlRenderer/Core/Parse/CssParser.cs | 32 +- .../Test/HtmlRenderer.Test/Css/CasesTests.cs | 13 +- .../Test/HtmlRenderer.Test/Css/ColorTests.cs | 12 +- .../Test/HtmlRenderer.Test/Css/SheetTests.cs | 60 +- .../Css/SyntaxAndDataTypesTests.cs | 710 ++++++++++++++++++ Spec/css21.yml | 70 +- 8 files changed, 815 insertions(+), 107 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/Parser/Lexer.cs b/Source/HtmlRenderer/Core/CssEngine/Parser/Lexer.cs index d920be4fd..9e039e85e 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Parser/Lexer.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Parser/Lexer.cs @@ -330,10 +330,8 @@ private Token ColorLiteral() Back(); - // A '#' always begins a , consuming a whole (CSS Syntax §4.3.4). Classify it as - // a color literal only when the name is entirely hex digits (e.g. "#f00"); otherwise keep it as an - // id hash-token (e.g. "#hero" — an id-selector inside element()), instead of truncating at the - // first non-hex character (which turned "#hero" into an empty color plus a stray "hero" ident). + // CSS Syntax §4.3.4: the hash consumes the whole name. Only an all-hex name ("#f00") is a color + // literal, anything else ("#hero") stays an id hash. var allHex = true; while (true) @@ -941,15 +939,10 @@ private Token UnicodeRange(char current) current = GetNext(); } - // Fewer than 6 hex digits may be followed by '?' wildcards (which pad the value and are - // mutually exclusive with the start-end range form) OR - like a full 6-digit start - by a - // '-' range end (e.g. U+41-5A). Only the wildcard form is handled here; a '-' falls - // through to the shared range handling below. + // Wildcards ("U+4??") and a range end ("U+41-5A") exclude each other, the range end is handled below. if (StringBuffer.Length != 6 && current == Symbols.QuestionMark) { - // The wildcard budget must be captured up front: appending to StringBuffer inside the loop - // would otherwise shrink a "6 - StringBuffer.Length" bound on every iteration, so only half - // the available wildcards were ever consumed (U+?????? stopped after three). + // Captured up front, the loop appends to StringBuffer. var wildcards = 6 - StringBuffer.Length; for (var i = 0; i < wildcards; i++) @@ -1093,12 +1086,8 @@ private Token NewFunction(string value) { var function = new FunctionToken(value, _position); - // Tracks paren depth so a bare (non-function) parenthesized group nested inside the - // function's arguments - e.g. calc((1px + 2px) * 3) - doesn't get mistaken for the end of - // the function: only the closing paren that matches this function's own opening paren (depth - // returning to 0) should terminate it. A nested function call's own parens are already fully - // consumed by its own (recursive) call to this method before it's added as a single token - // here, so they never surface as bare RoundBracketOpen/Close tokens at this level. + // Depth of bare parentheses, e.g. calc((1px + 2px) * 3). Nested functions consume their own + // parentheses recursively. var depth = 1; var token = Get(); while (token.Type != TokenType.EndOfFile) diff --git a/Source/HtmlRenderer/Core/Handlers/StylesheetLoadHandler.cs b/Source/HtmlRenderer/Core/Handlers/StylesheetLoadHandler.cs index 1bd12ca97..dd2167fee 100644 --- a/Source/HtmlRenderer/Core/Handlers/StylesheetLoadHandler.cs +++ b/Source/HtmlRenderer/Core/Handlers/StylesheetLoadHandler.cs @@ -130,7 +130,7 @@ private static async Task LoadStylesheet(HtmlContainerInt htmlContainer, } /// - /// Make relative URLs absolute in the stylesheet using the URI of the stylesheet. + /// Make relative URLs absolute in the stylesheet using the URI of the stylesheet (CSS 2.1 §4.3.4). /// /// the stylesheet to correct /// the stylesheet uri to use to create absolute URLs diff --git a/Source/HtmlRenderer/Core/Parse/CssParser.cs b/Source/HtmlRenderer/Core/Parse/CssParser.cs index 80ffa378e..c395cc084 100644 --- a/Source/HtmlRenderer/Core/Parse/CssParser.cs +++ b/Source/HtmlRenderer/Core/Parse/CssParser.cs @@ -23,11 +23,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Parse { /// - /// Parser to parse CSS stylesheet source string into CSS objects.
      - /// Derived from the same CSS-OM lineage as the vendored engine, adapted to stay fully synchronous - /// (HTML-Renderer's whole SetHtml/DomParser pipeline is synchronous, and - /// already does its own blocking I/O) - the recursive @import-walk logic is kept, just without - /// any Task/await plumbing. + /// Parser to parse CSS stylesheet source string into CSS objects. ///
      internal sealed class CssParser { @@ -44,10 +40,7 @@ internal sealed class CssParser private readonly CssValueParser _valueParser; /// - /// Used to resolve @import references (loads via the same synchronous, event-overridable - /// path as <link rel=stylesheet>). Null when this parser is used outside of a live - /// document parse (e.g. the public convenience API) - in that - /// case @import rules are silently skipped, since there is no container to resolve them through. + /// Loads @import references like <link rel=stylesheet>. Without it @import rules are skipped. /// private readonly HtmlContainerInt _htmlContainer; @@ -209,13 +202,13 @@ private void ParseStyle(CssData data, string stylesheetText, Uri baseUri, HashSe var stylesheet = ParseStyleSheetText(stylesheetText); stylesheet.BaseUri = baseUri; - // Guards against circular @import chains (A imports B imports A) recursing forever; also - // stops a stylesheet from importing itself directly. + // Guards against circular @import chains. if (baseUri != null) { visitedImportUris.Add(baseUri.AbsoluteUri); } + // CSS 2.1 §4.1.5: an @import after any other statement is ignored. var hasReachedNonImportRules = false; foreach (var rule in stylesheet.Children) @@ -225,16 +218,10 @@ private void ParseStyle(CssData data, string stylesheetText, Uri baseUri, HashSe { if (importRule.Href == null || _htmlContainer == null) continue; - // Relative references inside an already-loaded stylesheet resolve against that - // stylesheet's own location, not the document's base. + // CSS 2.1 §4.3.4: relative to the importing style sheet. var resolvedHref = ResolveImportHref(importRule.Href, baseUri); - // Bridges into the async StylesheetLoadHandler.LoadStylesheet from this synchronous - // CssParser.ParseStyleSheet public API. Deliberately, permanently blocking: this - // method is called directly from several WinForms/WPF UI controls' own public API - // (HtmlPanel/HtmlLabel/HtmlToolTip's BaseStylesheet-adjacent paths), so making it async - // would ripple into a much larger, unrelated public-API break well beyond this - // @font-face/network conversion's scope - not a temporary stopgap awaiting a later stage. + // Blocks on purpose, the WinForms/WPF controls call ParseStyleSheet synchronously. var (importedContent, importedCssData) = StylesheetLoadHandler.LoadStylesheet(_htmlContainer, resolvedHref, null).GetAwaiter().GetResult(); if (!string.IsNullOrEmpty(importedContent)) @@ -252,8 +239,7 @@ private void ParseStyle(CssData data, string stylesheetText, Uri baseUri, HashSe } else if (!(rule is LayerStatementRule)) { - // A bare `@layer a, b;` statement is allowed before @import (CSS Cascade 5 §6.4.1), so - // it must not close the @import prologue - otherwise every following @import is skipped. + // CSS Cascade 5 §6.4.1 allows `@layer a, b;` before @import. hasReachedNonImportRules = true; } } @@ -262,9 +248,7 @@ private void ParseStyle(CssData data, string stylesheetText, Uri baseUri, HashSe } /// - /// Resolves an @import href against the importing stylesheet's own base URI (if any), so - /// nested @import chains resolve relative references correctly instead of against the - /// document's base - matching browser behavior for fetched CSS. + /// Resolves an @import href against the importing stylesheet's own base URI (if any). /// private static string ResolveImportHref(string href, Uri baseUri) { diff --git a/Source/Test/HtmlRenderer.Test/Css/CasesTests.cs b/Source/Test/HtmlRenderer.Test/Css/CasesTests.cs index 01e027fe9..3851decf0 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CasesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CasesTests.cs @@ -4,11 +4,6 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/Cases.cs (CssCasesTests). @namespace, charset/linebreak -/// handling, and general stylesheet edge cases are all present 1:1 against HTML-Renderer's -/// Parser/StylesheetParser.cs / Model/Stylesheet.cs. -/// [TestClass] public sealed class CasesTests { @@ -163,6 +158,7 @@ public void StyleSheetCommaSelectorFunction() } [TestMethod] + [Spec("css21", "syndata.html#comments")] public void StyleSheetCommentIn() { var sheet = ParseSheet(@"a { @@ -187,6 +183,7 @@ public void StyleSheetCommentIn() } [TestMethod] + [Spec("css21", "syndata.html#comments")] public void StyleSheetCommentUrl() { var sheet = ParseSheet(@"/* http://foo.com/bar/baz.html */ @@ -203,6 +200,7 @@ public void StyleSheetCommentUrl() } [TestMethod] + [Spec("css21", "syndata.html#comments")] public void StyleSheetComment() { var sheet = ParseSheet(@"/* 1 */ @@ -279,6 +277,7 @@ public void StyleSheetEmpty() } [TestMethod] + [Spec("css21", "syndata.html#characters")] public void StyleSheetEscapes() { var sheet = ParseSheet(@"/* tests compressed for easy testing */ @@ -936,6 +935,7 @@ tobi loki jane { } [TestMethod] + [Spec("css21", "syndata.html#strings")] public void StyleSheetQuoteEscape() { var sheet = ParseSheet(@"p[qwe=""a\"",b""] { color: red } @@ -947,6 +947,7 @@ public void StyleSheetQuoteEscape() } [TestMethod] + [Spec("css21", "syndata.html#declaration")] public void StyleSheetQuoted() { var sheet = ParseSheet(@"body { @@ -1058,6 +1059,7 @@ public void StyleSheetWtf() } [TestMethod] + [Spec("css21", "syndata.html#characters")] public void StyleSheetUnicodeEscapeLiteral() { var sheet = ParseSheet(@"h1 { background-color: \000062 @@ -1066,6 +1068,7 @@ public void StyleSheetUnicodeEscapeLiteral() } [TestMethod] + [Spec("css21", "syndata.html#characters")] public void StyleSheetUnicodeEscapeVarious() { var sheet = ParseSheet( diff --git a/Source/Test/HtmlRenderer.Test/Css/ColorTests.cs b/Source/Test/HtmlRenderer.Test/Css/ColorTests.cs index b708d0bfe..dbdf90532 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ColorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ColorTests.cs @@ -2,12 +2,6 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/Color.cs (ColorTests). Every static factory (TryFromHex, -/// FromRgb(a), FromName, Mix, FromHsl, FromFlexHex, FromGray, -/// FromHwb(a)) exists on HTML-Renderer's -/// with a matching signature. -/// [TestClass] public sealed class ColorTests { @@ -19,6 +13,7 @@ public void TestMethod1() } [TestMethod] + [Spec("css21", "syndata.html#color-units")] public void ColorInvalidHexDigitString() { var color = "BCDEFG"; @@ -36,6 +31,7 @@ public void ColorValidFourLetterString() } [TestMethod] + [Spec("css21", "syndata.html#color-units")] public void ColorInvalidLengthString() { var color = "abcde"; @@ -60,6 +56,7 @@ public void ColorValidLengthLongString() } [TestMethod] + [Spec("css21", "syndata.html#color-units")] public void ColorWhiteShortString() { var color = "fff"; @@ -68,6 +65,7 @@ public void ColorWhiteShortString() } [TestMethod] + [Spec("css21", "syndata.html#color-units")] public void ColorRedShortString() { var color = "f00"; @@ -76,6 +74,7 @@ public void ColorRedShortString() } [TestMethod] + [Spec("css21", "syndata.html#color-units")] public void ColorFromRedName() { var color = "red"; @@ -85,6 +84,7 @@ public void ColorFromRedName() } [TestMethod] + [Spec("css21", "syndata.html#color-units")] public void ColorFromWhiteName() { var color = "white"; diff --git a/Source/Test/HtmlRenderer.Test/Css/SheetTests.cs b/Source/Test/HtmlRenderer.Test/Css/SheetTests.cs index b464f3486..69543e8b1 100644 --- a/Source/Test/HtmlRenderer.Test/Css/SheetTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/SheetTests.cs @@ -4,21 +4,13 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/Sheet.cs (CssSheetTests). Core stylesheet-composition, parsing, -/// serialization, comment-preservation, and @import/@-ms-viewport handling all verified 1:1 -/// against HTML-Renderer's Parser/StylesheetComposer.cs, Model/Stylesheet.cs, -/// Model/StyleDeclaration.cs. -/// [TestClass] public sealed class SheetTests { [TestMethod] public void CssSheetBareSemicolonInsideBlockIsDiscarded() { - // "Consume a block's contents" discards a just like whitespace - // (CSS Syntax 3 §5.5.5), so a stray ';' inside a grouping rule must not disturb the block or - // anything after it. + // CSS Syntax 3 §5.5.5 discards the ';'. CSS 2.1 §4.2 would drop ".b" with the malformed statement. var sheet = ParseStyleSheet(@" @media screen { .a { color: red; } @@ -35,8 +27,6 @@ @media screen { [TestMethod] public void CssSheetBareSemicolonInsideBlockDoesNotSwallowFollowingRule() { - // The trailing ';' used to be folded into a run-on selector that consumed the block's closing - // brace, taking the *next* top-level rule with it. var sheet = ParseStyleSheet(@" @media screen { .a { color: red; } ; } @media print { .b { color: blue; } }"); @@ -56,22 +46,18 @@ public void CssSheetMultipleBareSemicolonsInsideBlockAreDiscarded() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetBareTopLevelSemicolonInvalidatesTheFollowingRule() { - // Deliberately asymmetric with the block case above. "Consume a stylesheet's contents" - // (CSS Syntax 3 §5.5.1) has no case, so a stray top-level ';' falls to - // "anything else" and is consumed into the next qualified rule's prelude by §5.5.3, leaving an - // invalid selector. Only a block passes as that algorithm's stop token. - // - // The Acid2 parser-torture block depends on this: its ".parser { m\argin: 2em; };" is followed - // by ".parser { height: 3em; }", which must NOT apply - one of a run of deliberately-dropped - // rules alongside "width: 200" and "border: 5em solid red ! error". + // Unlike inside a block, a top-level ';' becomes part of the next rule's prelude. + // Acid2 relies on this to drop ".parser { height: 3em; }". var sheet = ParseStyleSheet(@".a { color: red; } ; .b { color: blue; }"); Assert.AreEqual(1, sheet.Rules.Length); Assert.AreEqual(".a", ((StyleRule)sheet.Rules[0]).SelectorText); } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetOnEofDuringRuleWithoutSemicolon() { var sheet = ParseStyleSheet(@" @@ -87,6 +73,7 @@ public void CssSheetOnEofDuringRuleWithoutSemicolon() } [TestMethod] + [Spec("css21", "syndata.html#comments")] public void CssSheet1WithDoubleMarkedCommentFromIssue93() { var sheet = ParseStyleSheet(@" @@ -103,6 +90,7 @@ public void CssSheet1WithDoubleMarkedCommentFromIssue93() } [TestMethod] + [Spec("css21", "syndata.html#comments")] public void CssSheet2WithDoubleMarkedCommentFromIssue93() { var sheet = ParseStyleSheet(@" @@ -156,6 +144,7 @@ public void CssSheetSerializeBackgroundWithUrlPositionRepeatX() } [TestMethod] + [Spec("css21", "syndata.html#vendor-keywords")] public void CssSheetIgnoreVendorPrefixes() { var css = @".something { @@ -188,6 +177,7 @@ public void CssSheetSimpleStyleRuleStringification() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetCloseStringsEndOfLine() { var sheet = ParseStyleSheet(@"p { @@ -206,6 +196,7 @@ public void CssSheetCloseStringsEndOfLine() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetOnEofDuringRuleWithinString() { var sheet = ParseStyleSheet(@" @@ -219,6 +210,7 @@ public void CssSheetOnEofDuringRuleWithinString() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetOnEofDuringAtMediaRuleWithinString() { var sheet = ParseStyleSheet(@" @media screen { @@ -235,6 +227,7 @@ public void CssSheetOnEofDuringAtMediaRuleWithinString() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetIgnoreUnknownProperty() { var sheet = ParseStyleSheet(@"h1 { color: red; rotation: 70minutes }"); @@ -248,6 +241,7 @@ public void CssSheetIgnoreUnknownProperty() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetInvalidStatementRulesetUnexpectedAtKeyword() { var sheet = ParseStyleSheet(@"p @here {color: red}"); @@ -255,6 +249,7 @@ public void CssSheetInvalidStatementRulesetUnexpectedAtKeyword() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetInvalidStatementAtRuleUnexpectedAtKeyword() { var sheet = ParseStyleSheet(@"@foo @bar;"); @@ -262,6 +257,7 @@ public void CssSheetInvalidStatementAtRuleUnexpectedAtKeyword() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetInvalidStatementRulesetUnexpectedRightBrace() { var sheet = ParseStyleSheet(@"}} {{ - }}"); @@ -269,6 +265,7 @@ public void CssSheetInvalidStatementRulesetUnexpectedRightBrace() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetInvalidStatementRulesetUnexpectedRightBraceWithValidQualifiedRule() { var sheet = ParseStyleSheet(@"}} {{ - }} @@ -282,6 +279,7 @@ public void CssSheetInvalidStatementRulesetUnexpectedRightBraceWithValidQualifie } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetInvalidStatementRulesetUnexpectedRightParenthesis() { var sheet = ParseStyleSheet(@") ( {} ) p {color: red }"); @@ -289,6 +287,7 @@ public void CssSheetInvalidStatementRulesetUnexpectedRightParenthesis() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetInvalidStatementRulesetUnexpectedRightParenthesisWithValidQualifiedRule() { var sheet = ParseStyleSheet(@") {} p {color: green }"); @@ -301,6 +300,7 @@ public void CssSheetInvalidStatementRulesetUnexpectedRightParenthesisWithValidQu } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetIgnoreUnknownAtRule() { var sheet = ParseStyleSheet(@"@three-dee { @@ -321,6 +321,7 @@ public void CssSheetIgnoreUnknownAtRule() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetKeepValidValueFloat() { var sheet = ParseStyleSheet(@"img { float: left }"); @@ -334,6 +335,7 @@ public void CssSheetKeepValidValueFloat() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetIgnoreInvalidValueFloat() { var sheet = ParseStyleSheet(@"img { float: left here }"); @@ -345,6 +347,7 @@ public void CssSheetIgnoreInvalidValueFloat() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetIgnoreInvalidValueBackground() { var sheet = ParseStyleSheet(@"img { background: ""red"" }"); @@ -356,6 +359,7 @@ public void CssSheetIgnoreInvalidValueBackground() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetIgnoreInvalidValueBorderWidth() { var sheet = ParseStyleSheet(@"img { border-width: 3 }"); @@ -367,6 +371,7 @@ public void CssSheetIgnoreInvalidValueBorderWidth() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetWellformedDeclaration() { var sheet = ParseStyleSheet(@"p { color:green; }"); @@ -380,6 +385,7 @@ public void CssSheetWellformedDeclaration() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetMalformedDeclarationMissingColon() { var sheet = ParseStyleSheet(@"p { color:green; color }"); @@ -393,6 +399,7 @@ public void CssSheetMalformedDeclarationMissingColon() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetMalformedDeclarationMissingColonWithRecovery() { var sheet = ParseStyleSheet(@"p { color:red; color; color:green }"); @@ -406,6 +413,7 @@ public void CssSheetMalformedDeclarationMissingColonWithRecovery() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetMalformedDeclarationMissingValue() { var sheet = ParseStyleSheet(@"p { color:green; color: }"); @@ -419,6 +427,7 @@ public void CssSheetMalformedDeclarationMissingValue() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetMalformedDeclarationUnexpectedTokens() { var sheet = ParseStyleSheet(@"p { color:green; color{;color:maroon} }"); @@ -432,6 +441,7 @@ public void CssSheetMalformedDeclarationUnexpectedTokens() } [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] public void CssSheetMalformedDeclarationUnexpectedTokensWithRecovery() { var sheet = ParseStyleSheet(@"p { color:red; color{;color:maroon}; color:green }"); @@ -733,17 +743,8 @@ public void CssSeveralFontFamily() Assert.AreEqual("\"Helvetica Neue\", Helvetica, Arial, sans-serif", prop.Value); } - /// - /// PORT-UNIT-IGNORED: PeachPDF's font shorthand expands to 10 longhands (font-family, font-size, - /// font-stretch, font-style, font-variant-caps, font-variant-ligatures, font-variant-numeric, - /// font-variant-east-asian, font-weight, line-height) + content = 11. HTML-Renderer's FontProperty - /// ( via - /// StyleProperties/Font/FontProperty.cs:7-18) only wires font-style/font-variant/font-weight/ - /// font-stretch/font-size/line-height/font-family (7 longhands, no font-variant-ligatures/numeric/ - /// east-asian sub-expansion) - so together with content this totals 8, not 11. - /// [TestMethod] - [Ignore("not yet spec compliant - see doc comment: HTML-Renderer's font shorthand expands to 7 longhands, not 10 (StyleProperties/Font/FontProperty.cs:7-18)")] + [Ignore("font-variant-caps, -ligatures, -numeric and -east-asian aren't properties here, so font expands to 7 longhands, not 10")] public void CssFontWithSlashAndContent() { var decl = ParseDeclarations("font: bold 1em/2em monospace; content: \" (\" attr(href) \")\""); @@ -1144,6 +1145,7 @@ public void CssStyleSheetInsertAndDeleteShouldWork() } [TestMethod] + [Spec("css21", "syndata.html#comments")] public void CssStyleSheetShouldIgnoreHtmlCommentTokens() { var parser = new StylesheetParser(); diff --git a/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs b/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs new file mode 100644 index 000000000..03d1502fe --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs @@ -0,0 +1,710 @@ +using System.Text; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using static HtmlRenderer.Test.CssEngineSupport.CssConstructionFunctions; + +namespace HtmlRenderer.Test.Css; + +[TestClass] +public sealed class SyntaxAndDataTypesTests +{ + private const string Green = "rgb(0, 128, 0)"; + private const string Red = "rgb(255, 0, 0)"; + private const double Delta = 0.01; + + [TestMethod] + [Spec("css21", "syndata.html#tokenization")] + public void LongestMatch_TokenizesRedCdcAsIdentAndDelim() + { + var lexer = new Lexer(new TextSource("red-->")); + + var ident = lexer.Get(); + var delim = lexer.Get(); + + Assert.AreEqual(TokenType.Ident, ident.Type); + Assert.AreEqual("red--", ident.Data); + Assert.AreEqual(TokenType.GreaterThan, delim.Type); + Assert.AreEqual(TokenType.EndOfFile, lexer.Get().Type); + } + + [TestMethod] + [Spec("css21", "syndata.html#tokenization")] + public void TabsLineBreaksAndFormFeeds_AreWhitespace() + { + Assert.AreEqual(Green, Styled("#x{color:\t\f\r\ngreen\f}").Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#tokenization")] + [DataRow(" ")] + [DataRow(" ")] + public void OtherSpaceCharacters_AreNotWhitespace(string space) + { + Assert.AreEqual(Green, Styled($"#x{{color:green}} #x{{color:{space}red}}").Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#tokenization")] + public void Comments_MayAppearBetweenAnyTokens() + { + Assert.AreEqual(Green, Styled("/*a*/#x/*b*/{/*c*/color/*d*/:/*e*/green/*f*/;/*g*/}/*h*/").Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#keywords")] + public void QuotedKeyword_IsAStringAndTheDeclarationIsIgnored() + { + var box = Styled("#x{color:green;width:100px} #x{color:\"red\";width:\"auto\"}"); + + Assert.AreEqual(Green, box.Color); + Assert.AreEqual("100px", box.Width); + } + + [TestMethod] + [Spec("css21", "syndata.html#vendor-keywords")] + [DataRow("-xyz-border-east-color: red")] + [DataRow("_xyz-border-east-color: red")] + [DataRow("background-color: -xyz-red")] + public void VendorExtension_IsParsedAndIgnored(string declaration) + { + Assert.AreEqual(Green, Styled($"#x{{background-color:green; {declaration}; color:green}}").BackgroundColor); + } + + [TestMethod] + [Spec("css21", "syndata.html#characters")] + public void PropertyNamesKeywordsAndFunctions_AreCaseInsensitive() + { + var box = Styled("DIV#x { COLOR: GREEN; Background-Color: RGB(0, 128, 0) } #x { Background-Image: URL(a.png) }"); + + Assert.AreEqual(Green, box.Color); + Assert.AreEqual(Green, box.BackgroundColor); + Assert.AreEqual("url(\"a.png\")", box.BackgroundImage); + Assert.AreEqual(Green, Styled("#p{color:green} #x{color:INHERIT}", "
      a
      ").Color); + Assert.AreEqual("III", Before("#x{counter-reset:c 3} #x::before{content:COUNTER(c, UPPER-ROMAN)}")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "syndata.html#characters")] + public void UnitsAtKeywordsAndImportant_AreCaseInsensitive() + { + Assert.AreEqual(10, Styled("#x{width:10PX}").ActualWidth, Delta); + Assert.AreEqual(Green, Styled("@MEDIA screen { #x { color: green } }").Color); + Assert.AreEqual(Green, Styled("#x { color: green !IMPORTANT } #x { color: red }").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "syndata.html#characters")] + public void PseudoClassAndPseudoElementNames_AreCaseInsensitive() + { + Assert.AreEqual(Green, Styled("#x:FIRST-CHILD { color: green }").Color); + Assert.AreEqual("a", Before("#x::BEFORE { content: 'a' }")); + } + + [TestMethod] + [Spec("css21", "syndata.html#characters")] + [DataRow(@"#x{c\olor:green}")] + [DataRow(@"#x{colo\72:green}")] + [DataRow(@"#x{color:gr\65 en}")] + [DataRow(@"#x{color:gr\000065en}")] + [DataRow(@"#x{color:gr\000065 en}")] + [DataRow("#x{color:gr\\65\r\nen}")] + public void EscapedIdentifier_IsTheSameIdentifier(string css) + { + Assert.AreEqual(Green, Styled(css).Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#characters")] + public void EscapedDigit_CanStartAClassName() + { + Assert.AreEqual(Green, Styled(@".\32 a{color:green}", "
      a
      ").Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#characters")] + public void EscapedPunctuation_IsPartOfTheIdentifier() + { + Assert.AreEqual(Green, Styled(@".a\7B b, .c\{d {color:green}", "
      a
      ").Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#characters")] + public void IdentifierStartingWithADigit_IsInvalid() + { + Assert.AreEqual(Green, Styled(".2a{color:red} #x{color:green}", "
      a
      ").Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#characters")] + public void OnlyOneWhitespaceAfterAHexEscape_IsConsumed() + { + Assert.AreEqual("&B & B", Before(@"#x::before{content:'\26 B \26 B'}")); + } + + [TestMethod] + [Spec("css21", "syndata.html#characters")] + public void EscapeOutsideTheUnicodeRange_IsTheReplacementCharacter() + { + Assert.AreEqual("�", Before(@"#x::before{content:'\110000'}")); + } + + [TestMethod] + [Spec("css21", "syndata.html#statements")] + public void StyleSheet_IsAListOfAtRulesAndRuleSets() + { + var sheet = ParseStyleSheet(" @media print { p { color: red } } \n p { color: green } @page { margin: 1cm } "); + + Assert.AreEqual(3, sheet.Rules.Length); + Assert.AreEqual(RuleType.Media, sheet.Rules[0].Type); + Assert.AreEqual(RuleType.Style, sheet.Rules[1].Type); + Assert.AreEqual(RuleType.Page, sheet.Rules[2].Type); + } + + [TestMethod] + [Spec("css21", "syndata.html#at-rules")] + public void AtRule_EndsAtTheNextSemicolonOrBlock() + { + var box = Styled("@foo bar; #x{color:green} @foo { #x{color:red} } #x{background-color:green}"); + + Assert.AreEqual(Green, box.Color); + Assert.AreEqual(Green, box.BackgroundColor); + } + + [TestMethod] + [Spec("css21", "syndata.html#at-rules")] + [DataRow("@import 'a.css'; p { color: red } @import 'b.css';")] + [DataRow("@media screen { @import 'b.css'; p { color: red } }")] + public void MisplacedImport_IsIgnored(string css) + { + CollectionAssert.DoesNotContain(RequestedStyleSheets(css), "b.css"); + } + + [TestMethod] + [Spec("css21", "syndata.html#block")] + public void Block_MatchesBracketsAndSkipsBracesInStrings() + { + Assert.AreEqual(Green, Styled(@"@foo { causta: ""}"" + ({7} * '\'') } #x { color: green }").Color); + Assert.AreEqual(Green, Styled(@"#x { causta: ""}"" + (7 * '\'') ; color: green }").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "syndata.html#block")] + public void BlockInADeclarationValue_IsSkippedAsPartOfTheDeclaration() + { + Assert.AreEqual(Green, Styled(@"#x { causta: ""}"" + ({7} * '\'') ; color: green }").Color); + Assert.AreEqual(Green, Styled("#x { causta: [{7}] ; color: green }").Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#rule-sets")] + public void InvalidSelector_DropsTheWholeRuleSet() + { + var css = "h1, h2 {color: green} h3, h4 # h5 {color: red} h6 {color: black}"; + + Assert.AreNotEqual(Red, Styled(css, "

      a

      ").Color); + Assert.AreEqual("rgb(0, 0, 0)", Styled(css, "
      a
      ").Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#rule-sets")] + public void BracesInASelectorString_DontEndTheSelector() + { + Assert.AreEqual(Green, Styled("#x[title=\"public class foo {}\"] { color: green }", "
      a
      ").Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#rule-sets")] + public void AmpersandInATopLevelSelector_DropsTheWholeRuleSet() + { + Assert.AreNotEqual(Red, Styled("h3, h4 & h5 {color: red}", "

      a

      ").Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#declaration")] + public void InvalidDeclarations_AreIgnoredIndividually() + { + var box = Styled("#x { color: red; font-style: 12pt } #x { color: green; font-vendor: any; font-variant: small-caps }"); + + Assert.AreEqual(Green, box.Color); + Assert.AreEqual("normal", box.FontStyle); + Assert.AreEqual("small-caps", box.FontVariant); + } + + [TestMethod] + [Spec("css21", "syndata.html#declaration")] + public void SemicolonInAString_DoesntEndTheDeclaration() + { + var box = Styled("#x::before { content: 'a;b'; color: green }"); + + Assert.AreEqual("a;b", BeforeText(box)); + Assert.AreEqual(Green, box.Boxes.First(b => b.IsBeforePseudoElement).Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#comments")] + public void Comments_DontNest() + { + Assert.AreEqual(Green, Styled("#x { color: red; /* /* */ color: green; /* */ }").Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#comments")] + public void SgmlCommentDelimiters_AreIgnoredAroundStatements() + { + var box = Styled(" "); + + Assert.AreEqual(Green, box.Color); + Assert.AreEqual(Green, box.BackgroundColor); + } + + [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] + [DataRow("#x { color: green; rotation: 70minutes }")] + [DataRow("#x { color: green; float: left here }")] + [DataRow("#x { color: green; color }")] + [DataRow("#x { color: red; color; color: green }")] + [DataRow("#x { color: green; color: }")] + [DataRow("#x { color: red; color:; color: green }")] + [DataRow("#x { color: green; color{;color:maroon} }")] + [DataRow("#x { color: red; color{;color:maroon}; color: green }")] + [DataRow("#x { color: green } #x @here {color: red} @foo @bar; #x { background-color: red }")] + [DataRow("}} {{ - }} #x { color: green }")] + [DataRow(") ( {} ) #x { color: red } #x { color: green }")] + [DataRow("@three-dee { @background-lighting { azimuth: 30deg; } #x { color: red } } #x { color: green }")] + [DataRow("@media screen { #x { color: green; azimuth: 30deg; } }")] + [DataRow("@media screen { #x { color: green")] + [DataRow("#x { color: green; font-family: 'Courier New Times\n color: red; }")] + public void ParsingError_IsIgnoredAndParsingResumes(string css) + { + Assert.AreEqual(Green, Styled(css).Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#parsing-errors")] + public void UnexpectedEndOfStyleSheet_ClosesTheOpenString() + { + Assert.AreEqual("Hello", Before("@media screen { #x::before { content: 'Hello")); + } + + [TestMethod] + [Spec("css21", "syndata.html#numbers")] + [DataRow("+10px", 10)] + [DataRow(".5em", 10)] + [DataRow("0.5em", 10)] + [DataRow("10.0px", 10)] + public void Number_AllowsASignAndAFraction(string value, double expected) + { + Assert.AreEqual(expected, Styled($"#x{{font-size:20px;margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css21", "syndata.html#numbers")] + [DataRow("10.px")] + [DataRow("1,5px")] + public void MalformedNumber_IsIgnored(string value) + { + Assert.AreEqual(5, Styled($"#x{{margin-left:5px;margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + public void NegativeZero_IsNotNegative() + { + Assert.AreEqual(0, Styled("#x{width:50px;width:-0px}").ActualWidth, Delta); + } + + [TestMethod] + [Spec("css21", "syndata.html#length-units")] + [DataRow("1in")] + [DataRow("2.54cm")] + [DataRow("25.4mm")] + [DataRow("72pt")] + [DataRow("6pc")] + [DataRow("96px")] + public void AbsoluteUnits_AreFixedInRelationToEachOther(string value) + { + Assert.AreEqual(96, Styled($"#x{{margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css21", "syndata.html#length-units")] + public void UnitAfterZero_IsOptional() + { + Assert.AreEqual(0, Styled("#x{margin-left:5px;margin-left:0}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css21", "syndata.html#length-units")] + public void UnitAfterNonZero_IsRequired() + { + Assert.AreEqual(5, Styled("#x{margin-left:5px;margin-left:3}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css21", "syndata.html#length-units")] + public void Em_IsTheFontSizeOfTheElement() + { + Assert.AreEqual(40, Styled("#x{font-size:20px;margin-left:2em}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css21", "syndata.html#length-units")] + public void EmInFontSize_IsTheFontSizeOfTheParent() + { + var box = Styled("#p{font-size:20px} #x{font-size:1.5em;margin-left:1em}", "
      a
      "); + + Assert.AreEqual(30, box.ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css21", "syndata.html#length-units")] + public void Ex_IsHalfAnEmWithoutFontMetrics() + { + Assert.AreEqual(20, Styled("#x{font-size:20px;margin-left:2ex}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css21", "syndata.html#length-units")] + public void EmOnTheRoot_RefersToTheInitialFontSize() + { + var (root, _) = LayoutHarness.Layout("
      a
      "); + var medium = Styled("#x{font-size:medium;margin-left:1em}").ActualMarginLeft; + + Assert.AreEqual(2 * medium, LayoutHarness.FindById(root, "x")!.ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css21", "syndata.html#length-units")] + public void Children_InheritTheComputedLengthNotTheEmValue() + { + var box = Styled("#p{font-size:12px;text-indent:3em} #x{font-size:15px}", "
      a
      "); + + Assert.AreEqual(36, box.ActualTextIndent, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "syndata.html#length-units")] + [DataRow("width")] + [DataRow("height")] + [DataRow("padding-left")] + [DataRow("border-left-width")] + [DataRow("font-size")] + [DataRow("line-height")] + public void NegativeLength_OnAPropertyThatDoesntAllowIt_IsIgnored(string property) + { + var box = Styled($"#x{{border-left-style:solid;{property}:5px;{property}:-10px}}"); + + Assert.AreEqual("5px", property switch + { + "width" => box.Width, + "height" => box.Height, + "padding-left" => box.PaddingLeft, + "border-left-width" => box.BorderLeftWidth, + "font-size" => box.FontSize, + _ => box.LineHeight, + }); + } + + [TestMethod] + [Spec("css21", "syndata.html#percentage-units")] + public void Percentage_ResolvesAgainstTheReferenceValue() + { + Assert.AreEqual(50, Styled("#x{width:25%}", "
      a
      ").ActualWidth, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "syndata.html#percentage-units")] + public void Children_InheritTheComputedFontSizeNotThePercentage() + { + var box = Styled("#p{font-size:10px} #q{font-size:200%} #x{margin-left:1em}", "
      a
      "); + + Assert.AreEqual(20, box.ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "syndata.html#percentage-units")] + public void Children_InheritTheComputedLineHeightNotThePercentage() + { + var box = Styled("#p{font-size:10px;line-height:120%} #x{font-size:20px}", "
      a
      "); + + Assert.AreEqual("12px", box.LineHeight); + } + + [TestMethod] + [Spec("css21", "syndata.html#uri")] + [DataRow("url(a.png)")] + [DataRow("url( a.png )")] + [DataRow("url('a.png')")] + [DataRow("url( \"a.png\" )")] + public void Url_AllowsQuotesAndWhitespace(string value) + { + Assert.AreEqual("url(\"a.png\")", Styled($"#x{{background-image:{value}}}").BackgroundImage); + } + + [TestMethod] + [Spec("css21", "syndata.html#uri")] + public void UnquotedUrl_TakesEscapedParentheses() + { + Assert.AreEqual("url(\"a(b).png\")", Styled(@"#x{background-image:url(a\(b\).png)}").BackgroundImage); + } + + [TestMethod] + [Spec("css21", "syndata.html#uri")] + public void CommentInsideAUrl_IsPartOfTheUrl() + { + Assert.AreEqual("url(\"/*x*/pic.png\")", Styled("#x{background-image:url(/*x*/pic.png)}").BackgroundImage); + } + + [TestMethod] + [Spec("css21", "syndata.html#uri")] + [DataRow("url(yellow)")] + [DataRow("url('yellow')")] + public void RelativeUrl_ResolvesAgainstTheStyleSheet(string value) + { + var (box, href) = StyledWithFile("style/basic.css", Encoding.UTF8.GetBytes($"#x {{ background-image: {value} }}")); + + StringAssert.Contains(box.BackgroundImage, new Uri(new Uri(href), "yellow").AbsoluteUri); + } + + [TestMethod] + [Spec("css21", "syndata.html#uri")] + public void DoubleQuotedRelativeUrl_ResolvesAgainstTheStyleSheet() + { + var (box, href) = StyledWithFile("style/basic.css", Encoding.UTF8.GetBytes("#x { background-image: url(\"yellow\") }")); + + StringAssert.Contains(box.BackgroundImage, new Uri(new Uri(href), "yellow").AbsoluteUri); + } + + [TestMethod] + [Spec("css21", "syndata.html#counter")] + [DataRow("counter(c)", "3")] + [DataRow("counter( c )", "3")] + [DataRow("counter(c, upper-roman)", "III")] + public void CounterNotation_FormatsTheCounter(string value, string expected) + { + Assert.AreEqual(expected + "|", Before($"#x{{counter-reset:c 3}} #x::before{{content:{value} '|'}}")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "syndata.html#counter")] + [DataRow("counter(c, none)", "")] + [DataRow("counters(c, '.')", "2.3")] + [DataRow("counters( c , '.' , lower-alpha )", "b.c")] + public void CounterNoneAndCountersNotation_FormatTheCounters(string value, string expected) + { + var (root, _) = LayoutHarness.Layout(Document( + $"#o{{counter-reset:c 2}} #x{{counter-reset:c 3}} #x::before{{content:{value} '|'}}", + "
      a
      ")); + + Assert.AreEqual(expected + "|", BeforeText(LayoutHarness.FindById(root, "x")!)); + } + + [TestMethod] + [Spec("css21", "syndata.html#color-units")] + [DataRow("maroon", 0x80, 0x00, 0x00)] + [DataRow("red", 0xff, 0x00, 0x00)] + [DataRow("orange", 0xff, 0xa5, 0x00)] + [DataRow("yellow", 0xff, 0xff, 0x00)] + [DataRow("olive", 0x80, 0x80, 0x00)] + [DataRow("purple", 0x80, 0x00, 0x80)] + [DataRow("fuchsia", 0xff, 0x00, 0xff)] + [DataRow("white", 0xff, 0xff, 0xff)] + [DataRow("lime", 0x00, 0xff, 0x00)] + [DataRow("green", 0x00, 0x80, 0x00)] + [DataRow("navy", 0x00, 0x00, 0x80)] + [DataRow("blue", 0x00, 0x00, 0xff)] + [DataRow("aqua", 0x00, 0xff, 0xff)] + [DataRow("teal", 0x00, 0x80, 0x80)] + [DataRow("black", 0x00, 0x00, 0x00)] + [DataRow("silver", 0xc0, 0xc0, 0xc0)] + [DataRow("gray", 0x80, 0x80, 0x80)] + [DataRow("#fb0", 0xff, 0xbb, 0x00)] + [DataRow("#FFBB00", 0xff, 0xbb, 0x00)] + [DataRow("rgb(255,187,0)", 0xff, 0xbb, 0x00)] + [DataRow("rgb( 100% , 0% , 0% )", 0xff, 0x00, 0x00)] + [DataRow("rgb(300,0,0)", 0xff, 0x00, 0x00)] + public void Color_ResolvesToTheSpecifiedRgbValue(string value, int r, int g, int b) + { + Assert.AreEqual(RColor.FromArgb(255, r, g, b), Styled($"#x{{color:{value}}}").ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "syndata.html#color-units")] + [DataRow("rgb(255,-10,0)")] + [DataRow("rgb(110%, 0%, 0%)")] + public void ColorOutsideTheGamut_IsClipped(string value) + { + Assert.AreEqual(RColor.FromArgb(255, 255, 0, 0), Styled($"#x{{color:{value}}}").ActualColor); + } + + [TestMethod] + [Spec("css21", "syndata.html#color-units")] + [DataRow("#ff000")] + [DataRow("#gg0000")] + [DataRow("ff0000")] + [DataRow("\"red\"")] + public void InvalidColor_IsIgnored(string value) + { + Assert.AreEqual(Green, Styled($"#x{{color:green;color:{value}}}").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "syndata.html#color-units")] + [DataRow("rgb(255, 0%, 0)")] + [DataRow("rgb(255, 0)")] + public void InvalidRgbFunction_IsIgnored(string value) + { + Assert.AreEqual(Green, Styled($"#x{{color:green;color:{value}}}").Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#strings")] + [DataRow("\"this is a 'string'\"", "this is a 'string'")] + [DataRow("\"this is a \\\"string\\\"\"", "this is a \"string\"")] + [DataRow("'this is a \"string\"'", "this is a \"string\"")] + [DataRow("'this is a \\'string\\''", "this is a 'string'")] + [DataRow("'\\22 and \\27'", "\" and '")] + public void String_TakesEitherQuoteAndEscapes(string value, string expected) + { + Assert.AreEqual(expected, Before($"#x::before{{content:{value}}}")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "syndata.html#strings")] + public void StringBrokenOverLines_IsTheSameString() + { + var css = "#x[title=\"a not s\\\no very long title\"] { color: green }"; + + Assert.AreEqual(Green, Styled(css, "
      a
      ").Color); + Assert.AreEqual("ab", Before("#x::before{content:'a\\\nb'}")); + } + + [TestMethod] + [Spec("css21", "syndata.html#unsupported-values")] + public void UnsupportedValue_IsIgnored() + { + Assert.AreEqual("inline", Styled("#x{display:inline;display:run-in}").Display); + } + + [TestMethod] + [Spec("css21", "syndata.html#charset")] + [DataRow("utf-8")] + [DataRow("utf-16")] + [DataRow("utf-16BE")] + public void ExternalStyleSheet_IsDecodedFromItsByteOrderMark(string name) + { + var encoding = Encoding.GetEncoding(name); + var (box, _) = StyledWithFile("style.css", encoding.GetPreamble().Concat(encoding.GetBytes("#x::before{content:'κ'}")).ToArray()); + + Assert.AreEqual("κ", BeforeText(box)); + } + + [TestMethod] + [Spec("css21", "syndata.html#charset")] + public void ExternalStyleSheet_WithoutBomOrCharset_IsUtf8() + { + var (box, _) = StyledWithFile("style.css", Encoding.UTF8.GetBytes("#x::before{content:'κ'}")); + + Assert.AreEqual("κ", BeforeText(box)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "syndata.html#charset")] + public void ExternalStyleSheet_IsDecodedWithItsCharsetRule() + { + var bytes = Encoding.ASCII.GetBytes("@charset \"ISO-8859-1\"; #x::before{content:'L") + .Concat(new byte[] { 0xFC }) + .Concat(Encoding.ASCII.GetBytes("beck'}")).ToArray(); + + Assert.AreEqual("Lübeck", BeforeText(StyledWithFile("style.css", bytes).Box)); + } + + [TestMethod] + [Spec("css21", "syndata.html#charset")] + public void CharsetRuleNotAtTheStart_IsIgnored() + { + Assert.AreEqual(Green, Styled("#x{color:red} @charset \"ISO-8859-1\"; #x{color:green}").Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#escaping")] + public void HexEscapes_ReferToUnicodeCharacters() + { + Assert.AreEqual("κουρος", Before(@"#x::before{content:'\3BA\3BF\3C5\3C1\3BF\3C2'}")); + Assert.AreEqual("Lübeck", Before(@"#x::before{content:'L\FC beck'}")); + } + + private static string Document(string css, string body) => + $"{body}"; + + private static CssBox Styled(string css, string body = "
      a
      ") + { + var (root, _) = LayoutHarness.Layout(Document(css, body)); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } + + private static string Before(string css) => BeforeText(Styled(css)); + + private static string BeforeText(CssBox box) + { + var before = box.Boxes.FirstOrDefault(b => b.IsBeforePseudoElement); + Assert.IsNotNull(before, "No ::before box"); + return before.Text ?? string.Concat(LayoutHarness.Descendants(before).SelectMany(b => b.Words).Select(w => w.Text)); + } + + /// The style sheets the renderer requests while loading a document with . + private static string[] RequestedStyleSheets(string css) + { + var requested = new List(); + var container = new HtmlContainerInt(new MockAdapter()) { MaxSize = new RSize(1000, 4000) }; + container.StylesheetLoad += (_, e) => + { + requested.Add(e.Src); + e.SetStyleSheet = "p { color: green }"; + }; + + container.SetHtml(Document(css, "

      a

      ")).GetAwaiter().GetResult(); + return requested.ToArray(); + } + + /// Lays out a document that links the given style sheet file from a temporary folder. + private static (CssBox Box, string Href) StyledWithFile(string path, byte[] bytes) + { + var dir = Path.Combine(Path.GetTempPath(), "html-renderer-test-" + Guid.NewGuid().ToString("N")); + try + { + var file = Path.Combine(dir, path); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + File.WriteAllBytes(file, bytes); + var href = new Uri(file).AbsoluteUri; + + var container = new HtmlContainerInt(new MockAdapter()) { MaxSize = new RSize(1000, 4000) }; + container.SetHtml($"
      a
      ").GetAwaiter().GetResult(); + using var graphics = new RecordingGraphics(); + container.PerformLayout(graphics); + + var box = LayoutHarness.FindById(container.Root!, "x"); + Assert.IsNotNull(box); + return (box, href); + } + finally + { + Directory.Delete(dir, true); + } + } +} diff --git a/Spec/css21.yml b/Spec/css21.yml index 3a7480c15..7a1b96918 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -26,79 +26,99 @@ sections: status: supported syndata.html#q4.0: title: 4 Syntax and basic data types - status: unknown + status: partial syndata.html#syntax: title: 4.1 Syntax - status: unknown + status: partial syndata.html#tokenization: title: 4.1.1 Tokenization - status: unknown + status: supported syndata.html#keywords: title: 4.1.2 Keywords - status: unknown + status: supported syndata.html#vendor-keywords: title: 4.1.2.1 Vendor-specific extensions - status: unknown + status: supported syndata.html#vendor-keyword-history: title: 4.1.2.2 Informative Historical Notes - status: unknown + status: n/a syndata.html#characters: title: 4.1.3 Characters and case - status: unknown + status: partial + gaps: + - Uppercase units, at-keywords and '!important' aren't recognized, e.g. 10PX, @MEDIA and !IMPORTANT + - "Uppercase pseudo-class and pseudo-element names don't match, e.g. :FIRST-CHILD and ::BEFORE" syndata.html#statements: title: 4.1.4 Statements - status: unknown + status: supported syndata.html#at-rules: title: 4.1.5 At-rules - status: unknown + status: supported syndata.html#block: title: 4.1.6 Blocks - status: unknown + status: partial + gaps: + - A {}-block inside a declaration value drops the declarations after it syndata.html#rule-sets: title: 4.1.7 Rule sets, declaration blocks, and selectors - status: unknown + status: supported syndata.html#declaration: title: 4.1.8 Declarations and properties - status: unknown + status: supported syndata.html#comments: title: 4.1.9 Comments - status: unknown + status: supported syndata.html#parsing-errors: title: 4.2 Rules for handling parsing errors - status: unknown + status: supported syndata.html#values: title: 4.3 Values - status: unknown + status: partial syndata.html#numbers: title: 4.3.1 Integers and real numbers - status: unknown + status: supported syndata.html#length-units: title: 4.3.2 Lengths - status: unknown + status: partial + gaps: + - Negative lengths are accepted for width, height, padding, border widths, font-size and line-height instead of being ignored syndata.html#percentage-units: title: 4.3.3 Percentages - status: unknown + status: partial + gaps: + - A percentage font-size is inherited as the percentage and applied again in each descendant + - A percentage line-height resolves to 0 instead of a percentage of the font size syndata.html#uri: title: 4.3.4 URLs and URIs - status: unknown + status: supported syndata.html#counter: title: 4.3.5 Counters - status: unknown + status: partial + gaps: + - counter(x, none) yields the counter value instead of an empty string + - counters() isn't accepted, the 'content' declaration is dropped syndata.html#color-units: title: 4.3.6 Colors - status: unknown + status: partial + gaps: + - Out-of-range rgb() values aren't clipped, a negative component makes the color invalid and a percentage above 100% wraps around + - rgb() with mixed integers and percentages or with two components isn't ignored syndata.html#strings: title: 4.3.7 Strings - status: unknown + status: partial + gaps: + - A backslash followed by a newline in a string is kept as a line break instead of being removed syndata.html#unsupported-values: title: 4.3.8 Unsupported Values - status: unknown + status: supported syndata.html#charset: title: 4.4 CSS style sheet representation - status: unknown + status: partial + gaps: + - The @charset rule and the HTTP charset parameter are ignored, an external style sheet is decoded from its BOM or as UTF-8 syndata.html#escaping: title: 4.4.1 Referring to characters not represented in a character encoding - status: unknown + status: supported selector.html#q5.0: title: 5 Selectors status: unknown From d8171ebb11477719329359c3c4c561ffc6ff8d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 17:49:27 +0100 Subject: [PATCH 010/150] Verify and document CSS 2.1 selectors compliance --- Source/HtmlRenderer/Core/CssData.cs | 32 +- .../Factories/PseudoClassSelectorFactory.cs | 7 +- .../Factories/PseudoElementSelectorFactory.cs | 2 - Source/HtmlRenderer/Core/Parse/DomParser.cs | 15 +- .../Css/Selectors/AttrSelectorTests.cs | 5 +- .../Css/Selectors/ClassSelectorTests.cs | 2 +- .../Css/Selectors/Css21SelectorTests.cs | 487 ++++++++++++++++++ .../Css/Selectors/SelectorMatchingTests.cs | 18 +- .../StructuralPseudoClassSelectorTests.cs | 13 +- Spec/css21.yml | 71 ++- 10 files changed, 566 insertions(+), 86 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs diff --git a/Source/HtmlRenderer/Core/CssData.cs b/Source/HtmlRenderer/Core/CssData.cs index 5957e3d9f..8a71a55ea 100644 --- a/Source/HtmlRenderer/Core/CssData.cs +++ b/Source/HtmlRenderer/Core/CssData.cs @@ -566,10 +566,6 @@ private static bool DoesSelectorMatch(CompoundSelector compoundSelector, CssBox var lastSelector = compoundSelector.Last(); - // Structural pseudo-classes (ChildSelector subtypes, OnlyChildSelector, OnlyOfTypeSelector) - // are matched against `box` itself here, same as every other compound member - their own - // DoesSelectorMatch overload re-derives sibling scope from `box` as needed, so no special - // handling is required for them beyond the plain path below. var pseudoElementSelector = lastSelector as PseudoElementSelector; if (pseudoElementSelector == null) return compoundSelector.All(selector => DoesSelectorMatch(selector, box)); @@ -587,9 +583,8 @@ private static bool DoesSelectorMatch(CompoundSelector compoundSelector, CssBox return DoesSelectorMatch(pseudoElementSelector, box); } - // Only ::before/::after synthesize a box here - ::marker is intentionally not ported - // (no CssBox.IsMarkerPseudoElement concept in this codebase; list markers are painted - // procedurally, not via a synthesized box - see PaintListStyleShapeMarker and friends). + // Matching the element creates the ::before/::after box, which then matches on its own + // cascade pass. Other pseudo-elements get no box here. if (pseudoElementSelector.Name == CssConstants.Before && !box.Boxes.Any(b => b.IsBeforePseudoElement)) { var beforePseudoBox = new CssBox(box, null); @@ -746,17 +741,14 @@ private static bool DoesSelectorMatch(PseudoClassSelector pseudoClassSelector, C { if (box == null) return false; - // ":hover" is matched structurally (ignoring live mouse-hover state, which this engine - // doesn't track at cascade time) so DomParser.AssignCssBlock can intercept the match and - // divert it to HtmlContainerInt.AddHoverBox instead of applying it - preserving the - // "matched but inert" behavior this engine has always had for :hover (see DomParser). + // CSS 2.1 §5.11.3. Matches whatever the mouse does, DomParser.AssignCssBlocks then hands + // rules with :hover in the subject to HtmlContainerInt.AddHoverBox instead of applying them. if (pseudoClassSelector.Class == "hover") return true; - // ":root" always matches the document's element - this engine has no separate - // CssBox.IsRoot flag, so match on tag name directly (correct for any HTML document). if (pseudoClassSelector.Class == "root") return box.HtmlTag != null && box.HtmlTag.Name.Equals("html", StringComparison.InvariantCultureIgnoreCase); + // CSS 2.1 §5.11.2. There's no history, every link is unvisited and :visited never matches. return pseudoClassSelector.Class == "link" && box.IsClickable; } @@ -773,12 +765,8 @@ private static bool DoesSelectorMatch(PseudoElementSelector pseudoElementSelecto } /// - /// Matches the six structural "An+B" pseudo-classes (:nth-child, :nth-last-child, - /// :nth-of-type, :nth-last-of-type, :nth-column, :nth-last-column - including their bare-ident - /// forms like :first-child, wired to Step=0/Offset=1 by PseudoClassSelectorFactory). - /// Each variant differs only in (a) which sibling scope to count within and (b) whether to - /// count from the start or the end; the actual "does this position satisfy An+B" test is - /// shared via . + /// The An+B pseudo-classes, including the bare forms like :first-child (CSS 2.1 §5.11.1), + /// which PseudoClassSelectorFactory creates with Step 0 and Offset 1. /// private static bool DoesSelectorMatch(ChildSelector childSelector, CssBox box) { @@ -807,11 +795,7 @@ private static bool DoesSelectorMatch(ChildSelector childSelector, CssBox box) b.HtmlTag.Name.Equals(box.HtmlTag.Name, StringComparison.InvariantCultureIgnoreCase)); } - // CSS4 "of " extension (:nth-child(An+B of S)/:nth-last-child(An+B of S)). - // Kind defaults to AllSelector (matches unconditionally) whenever no "of" clause was - // written, and the parser only ever allows a non-default Kind on FirstChildSelector/ - // LastChildSelector - so this filter is always a no-op for the four other subtypes. - // Applying it unconditionally is simpler than special-casing which subtypes can have it. + // The "of S" filter of :nth-child()/:nth-last-child(). Without it Kind is AllSelector. siblingBoxes = siblingBoxes.Where(b => DoesSelectorMatch(childSelector.Kind, b)); var siblings = siblingBoxes.ToList(); diff --git a/Source/HtmlRenderer/Core/CssEngine/Factories/PseudoClassSelectorFactory.cs b/Source/HtmlRenderer/Core/CssEngine/Factories/PseudoClassSelectorFactory.cs index 30a454772..19a77715f 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Factories/PseudoClassSelectorFactory.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Factories/PseudoClassSelectorFactory.cs @@ -55,11 +55,8 @@ private static Dictionary BuildSelectors() selectors.Add(PseudoElementNames.FirstLetter, PseudoElementSelectorFactory.Instance.Create(PseudoElementNames.FirstLetter)); - // Structural pseudo-classes: bare idents are equivalent to their nth-*(1) function form - // (see CSS Selectors spec), so wire them to the same ChildSelector subtypes that - // "nth-child(...)" etc. produce, rather than a generic PseudoClassSelector with no - // positional semantics. Kind uses AllSelector.Create() (never null) to match the - // invariant ChildFunctionState.Produce() upholds for the function-form parse path. + // :first-child (CSS 2.1 §5.11.1) and friends are their nth-*() function with An+B = 1. + // Kind is never null, same as what ChildFunctionState.Produce() creates. selectors.Add(PseudoClassNames.FirstChild, new FirstChildSelector().With(0, 1, AllSelector.Create())); selectors.Add(PseudoClassNames.LastChild, new LastChildSelector().With(0, 1, AllSelector.Create())); selectors.Add(PseudoClassNames.FirstOfType, new FirstTypeSelector().With(0, 1, AllSelector.Create())); diff --git a/Source/HtmlRenderer/Core/CssEngine/Factories/PseudoElementSelectorFactory.cs b/Source/HtmlRenderer/Core/CssEngine/Factories/PseudoElementSelectorFactory.cs index 46dc7947e..4d581eccb 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Factories/PseudoElementSelectorFactory.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Factories/PseudoElementSelectorFactory.cs @@ -16,8 +16,6 @@ internal sealed class PseudoElementSelectorFactory private readonly Dictionary _selectors = new HashSet(StringComparer.OrdinalIgnoreCase) { - //TODO some lack implementation (selection, content, ...) - // some implementations are dubious (first-line, first-letter, ...) PseudoElementNames.Before, PseudoElementNames.After, PseudoElementNames.Marker, diff --git a/Source/HtmlRenderer/Core/Parse/DomParser.cs b/Source/HtmlRenderer/Core/Parse/DomParser.cs index 36d3ad0ae..79afd9c87 100644 --- a/Source/HtmlRenderer/Core/Parse/DomParser.cs +++ b/Source/HtmlRenderer/Core/Parse/DomParser.cs @@ -233,11 +233,8 @@ private void CascadeApplyStyles(CssBox box, CssData cssData, MediaQueryContext m // FINAL custom property values regardless of which phase declared them. var pendingVarProperties = new Dictionary(); - // Anonymous layout-only wrapper boxes (HtmlTag == null, not a pseudo-element) never match - // anything and are skipped, matching this engine's pre-existing behavior. Synthesized - // ::before/::after boxes ARE queried despite also having HtmlTag == null - their whole - // purpose is to receive the declarations from the rule whose selector synthesized them - // (e.g. "li::before { content: ... }"), matched via CssData's PseudoElementSelector check. + // Anonymous boxes aren't elements and match nothing. ::before/::after boxes have no HtmlTag + // either, but receive the declarations of the rule that created them. var canMatchRules = box.HtmlTag != null || box.IsPseudoElement; var uaRules = canMatchRules ? cssData.GetUserAgentStyleRules(media, box).ToList() : new List(); var authorRules = canMatchRules ? cssData.GetAuthorStyleRules(media, box).ToList() : new List(); @@ -336,9 +333,8 @@ private void CascadeApplyStyles(CssBox box, CssData cssData, MediaQueryContext m /// /// Assigns the given css style rules to the given css box, applying only the declarations whose - /// !important flag matches . A rule whose selector's subject - /// includes ":hover" is diverted to instead - matching - /// this engine's pre-existing "matched but never actually applied" :hover behavior. + /// !important flag matches . A rule with :hover in its subject + /// goes to instead. /// private static void AssignCssBlocks( CssBox box, @@ -364,8 +360,7 @@ private static void AssignCssBlocks( } /// - /// True if the rule's selector's subject (rightmost, in the case of a combinator chain) compound - /// includes a ":hover" pseudo-class - e.g. "a:hover", ".foo:hover", or bare ":hover". + /// True if the subject of the rule's selector, the rightmost compound of a chain, has :hover. /// private static bool IsHoverRule(IStyleRule rule) { diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/AttrSelectorTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/AttrSelectorTests.cs index 7da8d1d11..5dbc63e08 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/AttrSelectorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/AttrSelectorTests.cs @@ -3,11 +3,11 @@ namespace HtmlRenderer.Test.Css.Selectors; -/// Ported from PeachPDF.Tests/CSS/AttrSelectorTests.cs. [TestClass] public sealed class AttrSelectorTests { [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] public void FindAllAttrMatchSelectorsThatMatchAttributeName() { var sheet = GetAttributeStylesheet(""); @@ -16,6 +16,7 @@ public void FindAllAttrMatchSelectorsThatMatchAttributeName() } [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] public void FindAllAttrInListSelectorsThatMatchAttributeName() { var sheet = GetAttributeStylesheet("~"); @@ -24,6 +25,7 @@ public void FindAllAttrInListSelectorsThatMatchAttributeName() } [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] public void FindAllAttrHyphenSelectorsThatMatchAttributeName() { var sheet = GetAttributeStylesheet("|"); @@ -64,6 +66,7 @@ public void FindAllAttrNotMatchSelectorsThatMatchAttributeName() } [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] public void BareAttributePresenceSelector_ResolvesToAttrAvailableSelector() { // A `[attr]` with no combinator/value falls through the factory's dispatch to the presence diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/ClassSelectorTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/ClassSelectorTests.cs index 1c824feee..cb0211bbe 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/ClassSelectorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/ClassSelectorTests.cs @@ -3,11 +3,11 @@ namespace HtmlRenderer.Test.Css.Selectors; -/// Ported from PeachPDF.Tests/CSS/ClassSelectorTests.cs. [TestClass] public sealed class ClassSelectorTests { [TestMethod] + [Spec("css21", "selector.html#class-html")] public void FindAllClassSelectorsThatMatchClassName() { var css = diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs new file mode 100644 index 000000000..c5219dfcd --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs @@ -0,0 +1,487 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.Css.Selectors; + +[TestClass] +public sealed class Css21SelectorTests +{ + private static readonly RColor Red = RColor.FromArgb(255, 255, 0, 0); + + [TestMethod] + [Spec("css21", "selector.html#pattern-matching")] + [Spec("css21", "selector.html#type-selectors")] + public void TypeSelector_MatchesElementNamesCaseInsensitively() + { + Assert.IsTrue(Matches("DIV", "
      a
      ")); + Assert.IsTrue(Matches("div", "
      a
      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#type-selectors")] + public void TypeSelector_MatchesOnlyThatElementType() + { + Assert.IsTrue(Matches("p", "

      a

      ")); + Assert.IsFalse(Matches("p", "
      a
      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#selector-syntax")] + public void SimpleSelector_MatchesOnlyWhenAllComponentsMatch() + { + Assert.IsTrue(Matches("p.a#x[title]:first-child", "

      a

      ")); + Assert.IsFalse(Matches("p.a#x[title]:first-child", "

      a

      ")); + Assert.IsFalse(Matches("p.a#x[title]:first-child", "

      a

      ")); + Assert.IsFalse(Matches("p.a#x[title]:first-child", "

      a

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#selector-syntax")] + public void Combinators_WorkWithAndWithoutSurroundingWhitespace() + { + Assert.IsTrue(Matches("div>p", "

      a

      ")); + Assert.IsTrue(Matches("div > p", "

      a

      ")); + Assert.IsTrue(Matches("h1+p", "

      h

      a

      ")); + Assert.IsTrue(Matches("h1 + p", "

      h

      a

      ")); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "selector.html#selector-syntax")] + public void CombinatorChain_TriesFartherAncestors() + { + Assert.IsTrue(Matches(".a > div span", "
      a
      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#grouping")] + public void SelectorGroup_AppliesTheDeclarationsToEveryAlternative() + { + var html = Document("h1, h2, p { background-color: #ff0000 }", "

      a

      b

      c

      d
      "); + + Assert.IsTrue(IsRed(html, "a")); + Assert.IsTrue(IsRed(html, "b")); + Assert.IsTrue(IsRed(html, "c")); + Assert.IsFalse(IsRed(html, "d")); + } + + [TestMethod] + [Spec("css21", "selector.html#universal-selector")] + public void UniversalSelector_MatchesAnyElement() + { + Assert.IsTrue(Matches("*", "

      a

      ")); + Assert.IsTrue(Matches("*", "a")); + } + + [TestMethod] + [Spec("css21", "selector.html#universal-selector")] + public void UniversalSelector_CanBeOmittedInFrontOfOtherComponents() + { + Assert.IsTrue(Matches("*[lang=fr]", "

      a

      ")); + Assert.IsTrue(Matches("*.warning", "

      a

      ")); + Assert.IsTrue(Matches("*#x", "

      a

      ")); + Assert.IsFalse(Matches("*.warning", "

      a

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#descendant-selectors")] + public void DescendantSelector_MatchesAtAnyDepth() + { + Assert.IsTrue(Matches("h1 em", "

      This is very

      ")); + Assert.IsFalse(Matches("h1 em", "

      This very

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#descendant-selectors")] + public void DescendantSelector_WithUniversalInBetween_SkipsChildren() + { + var html = Document("div * p { background-color: #ff0000 }", + "

      a

      b

      "); + + Assert.IsFalse(IsRed(html, "child")); + Assert.IsTrue(IsRed(html, "grandchild")); + } + + [TestMethod] + [Spec("css21", "selector.html#descendant-selectors")] + public void DescendantSelector_CombinesWithAttributeSelectors() + { + Assert.IsTrue(Matches("div p *[href]", "")); + Assert.IsFalse(Matches("div p *[href]", "")); + } + + [TestMethod] + [Spec("css21", "selector.html#child-selectors")] + public void ChildSelector_MatchesChildrenOfBody() + { + var html = Document("body > p { background-color: #ff0000 }", "

      a

      b

      "); + + Assert.IsTrue(IsRed(html, "a")); + Assert.IsFalse(IsRed(html, "b")); + } + + [TestMethod] + [Spec("css21", "selector.html#child-selectors")] + public void ChildSelector_MixedWithDescendantSelectors() + { + Assert.IsTrue(Matches("div ol>li p", "
      1. a

      ")); + Assert.IsFalse(Matches("div ol>li p", "
      1. a

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#adjacent-selectors")] + public void AdjacentSelector_IgnoresTextAndComments() + { + Assert.IsTrue(Matches("h1 + h2", "

      a

      text

      b

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#adjacent-selectors")] + public void AdjacentSelector_WithClassOnTheFirstElement() + { + Assert.IsTrue(Matches("h1.opener + h2", "

      a

      b

      ")); + Assert.IsFalse(Matches("h1.opener + h2", "

      a

      b

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] + public void AttributePresence_MatchesWhateverTheValue() + { + Assert.IsTrue(Matches("h1[title]", "

      a

      ")); + Assert.IsTrue(Matches("h1[title]", "

      a

      ")); + Assert.IsFalse(Matches("h1[title]", "

      a

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] + public void AttributeEquals_MatchesTheExactValue() + { + Assert.IsTrue(Matches("span[class=example]", "a")); + Assert.IsFalse(Matches("span[class=example]", "a")); + Assert.IsTrue(Matches("a[href=\"http://www.w3.org/\"]", "a")); + } + + [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] + public void SeveralAttributeSelectors_MustAllMatch() + { + const string selector = "span[hello=\"Cleveland\"][goodbye=\"Columbus\"]"; + Assert.IsTrue(Matches(selector, "a")); + Assert.IsFalse(Matches(selector, "a")); + } + + [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] + public void AttributeIncludes_MatchesOneWordOfAWhitespaceSeparatedList() + { + Assert.IsTrue(Matches("a[rel~=\"copyright\"]", "a")); + Assert.IsFalse(Matches("a[rel~=\"copy\"]", "a")); + } + + [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] + public void AttributeIncludes_WithWhitespaceOrEmptyValue_NeverMatches() + { + Assert.IsFalse(Matches("a[rel~=\"a b\"]", "a")); + Assert.IsFalse(Matches("a[rel~=\"\"]", "a")); + } + + [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] + public void AttributeDashMatch_MatchesTheValueOrTheValueFollowedByAHyphen() + { + Assert.IsTrue(Matches("*[lang|=\"en\"]", "

      a

      ")); + Assert.IsTrue(Matches("*[lang|=\"en\"]", "

      a

      ")); + Assert.IsTrue(Matches("*[lang|=\"en\"]", "

      a

      ")); + Assert.IsFalse(Matches("*[lang|=\"en\"]", "

      a

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] + public void AttributeNames_AreCaseInsensitiveInHtml() + { + Assert.IsTrue(Matches("[TITLE]", "

      a

      ")); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] + public void AttributeValues_AreCaseSensitiveInHtml() + { + Assert.IsFalse(Matches("[title=Foo]", "

      a

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#class-html")] + public void ClassSelector_MatchesOneOfTheClasses() + { + Assert.IsTrue(Matches(".pastoral", "

      a

      ")); + Assert.IsTrue(Matches("H1.pastoral", "

      a

      ")); + Assert.IsFalse(Matches("H1.pastoral", "

      a

      ")); + Assert.IsFalse(Matches("H1.pastoral", "

      a

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#class-html")] + public void SeveralClassSelectors_MustAllMatch() + { + Assert.IsTrue(Matches("p.marine.pastoral", "

      a

      ")); + Assert.IsFalse(Matches("p.marine.pastoral", "

      a

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#class-html")] + public void ClassSelector_IsTheSameAsAttributeIncludes() + { + Assert.IsTrue(Matches("div[class~=value]", "
      a
      ")); + Assert.IsTrue(Matches("div.value", "
      a
      ")); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "selector.html#class-html")] + public void ClassSelector_IsCaseSensitiveInHtml() + { + Assert.IsFalse(Matches(".Foo", "

      a

      ")); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "selector.html#class-html")] + public void ClassSelector_SplitsTheClassAttributeOnAnyWhitespace() + { + Assert.IsTrue(Matches(".b", "

      a

      ")); + Assert.IsTrue(Matches(".b", "

      a

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#id-selectors")] + public void IdSelector_MatchesTheElementWithThatId() + { + Assert.IsTrue(Matches("*#z98y", "

      a

      ", "z98y")); + Assert.IsTrue(Matches("#z98y", "

      a

      ", "z98y")); + Assert.IsFalse(Matches("h1#z98y", "

      a

      ", "z98y")); + } + + [TestMethod] + [Spec("css21", "selector.html#id-selectors")] + public void IdSelector_IsMoreSpecificThanAnAttributeSelector() + { + var html = Document("#p123 { background-color: #ff0000 } [id=p123] { background-color: #0000ff }", "

      a

      "); + var (root, _) = LayoutHarness.Layout(html); + + Assert.AreEqual(Red, LayoutHarness.FindById(root, "p123")!.ActualBackgroundColor); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "selector.html#id-selectors")] + public void IdSelector_IsCaseSensitiveInHtml() + { + Assert.IsFalse(Matches("#Foo", "

      a

      ", "foo")); + } + + [TestMethod] + [Spec("css21", "selector.html#pseudo-elements")] + public void PseudoClasses_CanAppearAnywhereInTheSelector() + { + Assert.IsTrue(Matches("div:first-child > p", "

      a

      ")); + Assert.IsFalse(Matches("div:first-child > p", "

      a

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#pseudo-elements")] + public void SeveralPseudoClasses_CanApplyToTheSameElement() + { + Assert.IsTrue(Matches("a:link:first-child", "

      a

      ")); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "selector.html#pseudo-elements")] + public void PseudoClassNames_AreCaseInsensitive() + { + Assert.IsTrue(Matches("p:FIRST-CHILD", "

      a

      ")); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "selector.html#pseudo-elements")] + public void PseudoElementNames_AreCaseInsensitive() + { + var p = Layout("p:BEFORE { content: 'N' }", "

      a

      "); + + Assert.AreEqual("N", GeneratedText(p, before: true)); + } + + [TestMethod] + [Spec("css21", "selector.html#first-child")] + public void FirstChild_MatchesTheFirstChildElement() + { + Assert.IsTrue(Matches("div > p:first-child", "

      before

      first

      ")); + Assert.IsFalse(Matches("div > p:first-child", "

      before

      Note

      first

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#first-child")] + public void FirstChild_IgnoresLeadingText() + { + Assert.IsTrue(Matches("em:first-child", "

      abc default

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#first-child")] + public void FirstChild_InADescendantChain() + { + Assert.IsTrue(Matches("p:first-child em", "

      a b

      ")); + Assert.IsFalse(Matches("p:first-child em", "

      a b

      ")); + } + + [TestMethod] + [Spec("css21", "selector.html#link-pseudo-classes")] + public void Link_MatchesAnchorsWithAnHref() + { + Assert.IsTrue(Matches("a:link", "a")); + Assert.IsTrue(Matches(":link", "a")); + Assert.IsFalse(Matches("a:link", "a")); + Assert.IsFalse(Matches(":link", "a")); + } + + [TestMethod] + [Spec("css21", "selector.html#link-pseudo-classes")] + public void Visited_NeverMatches_AllLinksCountAsUnvisited() + { + Assert.IsFalse(Matches("a:visited", "a")); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "selector.html#dynamic-pseudo-classes")] + public void Hover_OnANonSubjectElement_DoesntMatchWhileNothingIsHovered() + { + Assert.IsFalse(Matches("div:hover p", "

      a

      ")); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "selector.html#dynamic-pseudo-classes")] + public void Hover_AppliesWhileTheMouseIsOverTheElement() + { + var adapter = new MockAdapter(); + var html = Document("p:hover { background-color: #ff0000 }", "

      a

      "); + var (root, container) = LayoutHarness.Layout(html, adapter: adapter); + var p = LayoutHarness.FindById(root, "x")!; + + container.HandleMouseMove(new MockControl(adapter), new RPoint(p.Location.X + 1, p.Location.Y + 1)); + + Assert.AreEqual(Red, p.ActualBackgroundColor); + } + + [TestMethod] + [Spec("css21", "selector.html#lang")] + public void AttributeDashMatch_OnlyMatchesTheElementWithTheAttribute() + { + var html = Document("[lang|=fr] { background-color: #ff0000 }", "

      Je suis Français.

      "); + + Assert.IsTrue(IsRed(html, "b")); + Assert.IsFalse(IsRed(html, "p")); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "selector.html#lang")] + public void Lang_MatchesElementsInThatLanguage() + { + var html = Document(":lang(fr) { background-color: #ff0000 }", "

      Je suis Français.

      a

      "); + + Assert.IsTrue(IsRed(html, "b")); + Assert.IsTrue(IsRed(html, "p")); + Assert.IsFalse(IsRed(html, "en")); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "selector.html#first-line-pseudo")] + public void FirstLine_StylesTheFirstFormattedLine() + { + var p = Layout("p:first-line { color: #ff0000 }", "

      Hello

      "); + + Assert.AreEqual(Red, FirstWord(p)!.OwnerBox.ActualColor); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "selector.html#first-letter")] + public void FirstLetter_StylesTheFirstLetter() + { + var p = Layout("p:first-letter { color: #ff0000 }", "

      Hello

      "); + var first = FirstWord(p)!; + + Assert.AreEqual("H", first.Text); + Assert.AreEqual(Red, first.OwnerBox.ActualColor); + } + + [TestMethod] + [Spec("css21", "selector.html#before-and-after")] + public void BeforeAndAfter_WithASingleColon_GenerateContent() + { + var p = Layout("p:before { content: 'Note: ' } p:after { content: '!' }", "

      a

      "); + + Assert.AreEqual("Note:", GeneratedText(p, before: true)); + Assert.AreEqual("!", GeneratedText(p, before: false)); + } + + private static string Document(string css, string body) => + $"{body}"; + + private static bool Matches(string selector, string body, string id = "x") => + IsRed(Document(selector + " { background-color: #ff0000 }", body), id); + + private static bool IsRed(string html, string id) + { + var (root, _) = LayoutHarness.Layout(html); + var box = LayoutHarness.FindById(root, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box.ActualBackgroundColor == Red; + } + + private static CssBox Layout(string css, string body) + { + var (root, _) = LayoutHarness.Layout(Document(css, body)); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } + + private static string GeneratedText(CssBox box, bool before) + { + var generated = box.Boxes.FirstOrDefault(b => before ? b.IsBeforePseudoElement : b.IsAfterPseudoElement); + Assert.IsNotNull(generated); + return string.Join(" ", LayoutHarness.Descendants(generated).SelectMany(b => b.Words).Select(w => w.Text.Trim())); + } + + private static CssRect? FirstWord(CssBox box) => + box.Words.Count > 0 ? box.Words[0] : box.Boxes.Select(FirstWord).FirstOrDefault(w => w != null); + + private sealed class MockControl(RAdapter adapter) : RControl(adapter) + { + public override bool LeftMouseButton => false; + public override bool RightMouseButton => false; + public override RPoint MouseLocation => RPoint.Empty; + public override void SetCursorDefault() { } + public override void SetCursorHand() { } + public override void SetCursorIBeam() { } + public override void DoDragDropCopy(object dragDropData) { } + + public override void MeasureString(string str, RFont font, double maxWidth, out int charFit, out double charFitWidth) + { + charFit = str.Length; + charFitWidth = 0; + } + + public override void Invalidate() { } + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorMatchingTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorMatchingTests.cs index be7546730..92842709f 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorMatchingTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorMatchingTests.cs @@ -4,12 +4,6 @@ namespace HtmlRenderer.Test.Css.Selectors; -/// -/// Ported from PeachPDF.Tests/CSS/SelectorMatchingTests.cs. Attribute (^=, $=, |=) and combinator -/// (+, ~, >, descendant) selectors are all implemented 1:1 against HTML-Renderer's CSS engine port, -/// so this is ported via the / pattern (see -/// ) instead of PeachPDF's PdfSharpAdapter-based BuildRoot. -/// [TestClass] public sealed class SelectorMatchingTests { @@ -52,6 +46,7 @@ public void AttrEnds_DoesNotMatch_WhenValueDoesNotEndWith() // ── Attribute: hyphen-prefix [attr|=value] ─────────────────────────────── [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] public void AttrHyphen_Matches_ExactValue() { var html = Html("[lang|='en'] { background-color: #ff0000; }", "text"); @@ -60,6 +55,7 @@ public void AttrHyphen_Matches_ExactValue() } [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] public void AttrHyphen_Matches_HyphenPrefixed() { var html = Html("[lang|='en'] { background-color: #ff0000; }", "text"); @@ -68,6 +64,7 @@ public void AttrHyphen_Matches_HyphenPrefixed() } [TestMethod] + [Spec("css21", "selector.html#matching-attrs")] public void AttrHyphen_DoesNotMatch_SubstringOnly() { var html = Html("[lang|='en'] { background-color: #ff0000; }", "text"); @@ -78,6 +75,7 @@ public void AttrHyphen_DoesNotMatch_SubstringOnly() // ── Combinator: adjacent sibling (div + p) ─────────────────────────────── [TestMethod] + [Spec("css21", "selector.html#adjacent-selectors")] public void AdjacentSibling_Matches_ImmediatelyFollowingSibling() { var html = Html("div + p { background-color: #ff0000; }", "
      d

      first

      "); @@ -86,6 +84,7 @@ public void AdjacentSibling_Matches_ImmediatelyFollowingSibling() } [TestMethod] + [Spec("css21", "selector.html#adjacent-selectors")] public void AdjacentSibling_DoesNotMatch_SecondSibling() { var html = Html("div + p { background-color: #ff0000; }", "
      d

      first

      second

      "); @@ -96,6 +95,7 @@ public void AdjacentSibling_DoesNotMatch_SecondSibling() } [TestMethod] + [Spec("css21", "selector.html#adjacent-selectors")] public void AdjacentSibling_DoesNotMatch_PrecedingSibling() { var html = Html("div + p { background-color: #ff0000; }", "

      before

      d
      "); @@ -123,9 +123,10 @@ public void GeneralSibling_DoesNotMatch_PrecedingSibling() Assert.AreEqual(RColor.Transparent, box.ActualBackgroundColor); } - // ── Regression: existing combinators still work ────────────────────────── + // ── Combinator: child (div > p) and descendant (div p) ────────────────── [TestMethod] + [Spec("css21", "selector.html#child-selectors")] public void ChildCombinator_StillMatches_DirectChild() { var html = Html("div > p { background-color: #ff0000; }", "

      direct

      nested

      "); @@ -136,6 +137,7 @@ public void ChildCombinator_StillMatches_DirectChild() } [TestMethod] + [Spec("css21", "selector.html#descendant-selectors")] public void DescendantCombinator_StillMatches_AnyDescendant() { var html = Html("div p { background-color: #ff0000; }", "

      deep

      outside

      "); @@ -145,7 +147,7 @@ public void DescendantCombinator_StillMatches_AnyDescendant() Assert.AreEqual(RColor.Transparent, boxes[1].ActualBackgroundColor); } - // ── Helpers (mirrors CssSpecificityOrderingTests.cs conventions) ───────── + // ── Helpers ────────────────────────────────────────────────────────────── private static string Html(string css, string body) => $"{body}"; diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/StructuralPseudoClassSelectorTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/StructuralPseudoClassSelectorTests.cs index e1ebe377a..012190b2e 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/StructuralPseudoClassSelectorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/StructuralPseudoClassSelectorTests.cs @@ -4,22 +4,13 @@ namespace HtmlRenderer.Test.Css.Selectors; -/// -/// Ported from PeachPDF.Tests/CSS/StructuralPseudoClassSelectorTests.cs. The structural pseudo-class -/// family - bare :first-child/:last-child/:only-child/:first-of-type/:last-of-type/:only-of-type, -/// :nth-child()/:nth-last-child()/:nth-of-type()/:nth-last-of-type() (incl. the CSS4 "of <selector>" -/// clause) and :nth-column()/:nth-last-column() - is all wired and matching in HTML-Renderer's CSS -/// engine port (see and -/// ), so this is ported via the -/// / pattern (see -/// ) instead of PeachPDF's PdfSharpAdapter-based BuildRoot. -/// [TestClass] public sealed class StructuralPseudoClassSelectorTests { // ── :first-child ────────────────────────────────────────────────────────── [TestMethod] + [Spec("css21", "selector.html#first-child")] public void FirstChild_Matches_FirstElement() { var html = Html(":first-child { background-color: #ff0000; }", "

      first

      second

      "); @@ -287,6 +278,7 @@ public void TagPlusNthChild_MatchesOnlyMatchingTagAtThatPosition() } [TestMethod] + [Spec("css21", "selector.html#first-child")] public void ClassPlusFirstChild_MatchesOnlyWhenBothConditionsHold() { var html = Html(".item:first-child { background-color: #ff0000; }", "

      1

      2

      "); @@ -297,6 +289,7 @@ public void ClassPlusFirstChild_MatchesOnlyWhenBothConditionsHold() } [TestMethod] + [Spec("css21", "selector.html#first-child")] public void ClassPlusFirstChild_DoesNotMatch_WhenFirstChildLacksClass() { var html = Html(".item:first-child { background-color: #ff0000; }", "

      1

      2

      "); diff --git a/Spec/css21.yml b/Spec/css21.yml index 7a1b96918..6a8bf1b32 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -121,76 +121,97 @@ sections: status: supported selector.html#q5.0: title: 5 Selectors - status: unknown + status: partial selector.html#pattern-matching: title: 5.1 Pattern matching - status: unknown + status: supported selector.html#selector-syntax: title: 5.2 Selector syntax - status: unknown + status: partial + gaps: + - "Combinator chains don't backtrack: '.a > div span' fails when only an outer div is the child of .a" selector.html#grouping: title: 5.2.1 Grouping - status: unknown + status: supported selector.html#universal-selector: title: 5.3 Universal selector - status: unknown + status: supported selector.html#type-selectors: title: 5.4 Type selectors - status: unknown + status: supported selector.html#descendant-selectors: title: 5.5 Descendant selectors - status: unknown + status: supported selector.html#child-selectors: title: 5.6 Child selectors - status: unknown + status: supported selector.html#adjacent-selectors: title: 5.7 Adjacent sibling selectors - status: unknown + status: supported selector.html#attribute-selectors: title: 5.8 Attribute selectors - status: unknown + status: partial selector.html#matching-attrs: title: 5.8.1 Matching attributes and attribute values - status: unknown + status: partial + gaps: + - Attribute values are matched case-insensitively, e.g. [title=Foo] matches title="foo" selector.html#default-attrs: title: 5.8.2 Default attribute values in DTDs - status: unknown + status: n/a selector.html#class-html: title: 5.8.3 Class selectors - status: unknown + status: partial + gaps: + - Class names are matched case-insensitively, e.g. .Foo matches class="foo" + - The class attribute is only split on spaces, tabs and newlines don't separate class names selector.html#id-selectors: title: 5.9 ID selectors - status: unknown + status: partial + gaps: + - IDs are matched case-insensitively, e.g. #Foo matches id="foo" selector.html#pseudo-elements: title: 5.10 Pseudo-elements and pseudo-classes - status: unknown + status: partial + gaps: + - "Pseudo-class names are case-sensitive, 'p:FIRST-CHILD' never matches" + - "Pseudo-element names are case-sensitive, 'p:BEFORE' generates nothing" selector.html#pseudo-class-selectors: title: 5.11 Pseudo-classes - status: unknown + status: partial selector.html#first-child: - title: 5.11.1 :first-child pseudo-class - status: unknown + title: "5.11.1 :first-child pseudo-class" + status: supported selector.html#link-pseudo-classes: title: 5.11.2 The link pseudo-classes - status: unknown + status: supported selector.html#dynamic-pseudo-classes: title: 5.11.3 The dynamic pseudo-classes - status: unknown + status: unsupported + gaps: + - ":hover rules are collected but never applied when the mouse is over the element, :active and :focus never match" + - "A :hover outside the subject, e.g. 'div:hover p', matches as if the element were always hovered" selector.html#lang: title: 5.11.4 The language pseudo-class - status: unknown + status: unsupported + gaps: + - ":lang() never matches" selector.html#pseudo-element-selectors: title: 5.12 Pseudo-elements - status: unknown + status: partial selector.html#first-line-pseudo: title: 5.12.1 The :first-line pseudo-element - status: unknown + status: parsed-only + gaps: + - ":first-line is accepted but its declarations never apply" selector.html#first-letter: title: 5.12.2 The :first-letter pseudo-element - status: unknown + status: parsed-only + gaps: + - ":first-letter is accepted but its declarations never apply" selector.html#before-and-after: title: 5.12.3 The :before and :after pseudo-elements - status: unknown + status: supported cascade.html#q6.0: title: 6 Assigning property values, Cascading, and Inheritance status: unknown From 2eded5dd3c874d2d5c02e557560ae0f494b7b271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 17:52:18 +0100 Subject: [PATCH 011/150] Verify and document CSS 2.1 cascade and inheritance compliance --- Source/HtmlRenderer/Core/CssData.cs | 26 +- Source/HtmlRenderer/Core/CssDefaults.cs | 17 +- .../HtmlRenderer/Core/Dom/CssBoxProperties.cs | 18 +- Source/HtmlRenderer/Core/Parse/CssParser.cs | 2 +- Source/HtmlRenderer/Core/Parse/DomParser.cs | 73 +--- .../Layout/GlobalKeywordCascadeTests.cs | 20 +- .../HtmlRenderer.Test/Css/CascadeTests.cs | 375 ++++++++++++++++++ .../Css/CssSpecificityOrderingTests.cs | 31 +- .../Css/GlobalKeywordPropertyTests.cs | 9 +- .../HtmlRenderer.Test/Css/ImportRuleTests.cs | 12 +- Spec/css21.yml | 43 +- 11 files changed, 468 insertions(+), 158 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs diff --git a/Source/HtmlRenderer/Core/CssData.cs b/Source/HtmlRenderer/Core/CssData.cs index 8a71a55ea..8be4b08b8 100644 --- a/Source/HtmlRenderer/Core/CssData.cs +++ b/Source/HtmlRenderer/Core/CssData.cs @@ -22,16 +22,9 @@ namespace TheArtOfDev.HtmlRenderer.Core { /// - /// Holds parsed stylesheets (UA default + author + inline), and provides origin-aware, - /// specificity-ordered selector matching against the box tree.
      + /// Holds the parsed style sheets (UA default and author) and matches their rules against the + /// box tree, ordered for the cascade. ///
      - /// - /// Derived from the same ExCSS lineage as the vendored CSS-OM (originally Tyler Brinks' ExCSS), - /// adapted to HTML-Renderer's CssBox/HtmlTag shape. One deliberate omission from the source: the - /// ::marker pseudo-element box-synthesis path is removed - ::before/::after - /// synthesis is kept, but this codebase has no CssBox.IsMarkerPseudoElement concept and no - /// consumer for it (list markers are painted procedurally, not via a synthesized box). - /// public sealed class CssData { /// @@ -491,9 +484,7 @@ private IEnumerable GetStyleRulesByOrigin(MediaQueryContext media, C } } - // Stable sort by specificity, tie-broken by DocumentOrder (true source order, including - // across the plain-rule/@media boundary, assigned once up front by IndexRules) - equal- - // specificity rules keep true document order for correct source-order tiebreaking. + // CSS 2.1 §6.4.1 steps 3 and 4 return matched .OrderBy(indexed => GetMatchedSpecificity(indexed.Rule.Selector, box)) .ThenBy(indexed => indexed.DocumentOrder) @@ -501,14 +492,9 @@ private IEnumerable GetStyleRulesByOrigin(MediaQueryContext media, C } /// - /// The effective specificity of a matched rule's selector for cascade-ordering purposes, - /// relative to a specific matched box. This deliberately differs from calling - /// selector.Specificity directly for a top-level (comma- - /// separated rule selector, e.g. "h1, h2 {}"): per spec a selector list used as a whole - /// rule's selector is cascade-equivalent to separate rules, so the specificity that governs - /// THIS box's cascade is whichever one alternative actually matched it - not every - /// alternative's static max (which is what reports, - /// correctly, for its OTHER use as an argument to :is()/:not()/:has()). + /// The specificity of a matched rule for , CSS 2.1 §6.4.3. A rule's + /// selector list counts with the highest matching selector, unlike + /// , which is the static maximum used inside :is() and :not(). /// private static Priority GetMatchedSpecificity(ISelector selector, CssBox box) { diff --git a/Source/HtmlRenderer/Core/CssDefaults.cs b/Source/HtmlRenderer/Core/CssDefaults.cs index 0bee3e6c3..d2f083859 100644 --- a/Source/HtmlRenderer/Core/CssDefaults.cs +++ b/Source/HtmlRenderer/Core/CssDefaults.cs @@ -129,14 +129,9 @@ where the group carries an avoid break-inside. */ }"; /// - /// The initial value of every property this engine recognizes ('s exact - /// whitelist), used to resolve the initial/unset/revert/revert-layer - /// CSS-wide keywords in the six-phase cascade (). Deliberately matches - /// this engine's own existing field defaults ('s private field - /// initializers) rather than the CSS spec's initial values where the two differ (e.g. this engine's - /// border-color default is "black", not the spec's "currentcolor"; text-align/text-decoration/ - /// list-style-image default to an empty string here, not "left"/"none"/"none") - so resolving - /// "initial" produces exactly what an unstyled box already looks like today, not a new value. + /// The initial value of every property in 's whitelist, for the + /// CSS-wide keywords. Must match the field initializers in , + /// which is why some differ from the spec (e.g. border colors are "black", text-align is ""). /// internal static readonly System.Collections.Generic.Dictionary InitialValues = new System.Collections.Generic.Dictionary(System.StringComparer.OrdinalIgnoreCase) @@ -218,10 +213,8 @@ where the group carries an avoid break-inside. */ }; /// - /// Properties inherited from parent to child during cascade - kept exactly in sync with the set - /// already (independently) copies, since this is - /// used only to decide whether the unset keyword should behave like inherit or - /// initial for a given property, and the two must agree for "unset" to make sense. + /// The inherited properties, used to resolve unset. Must match what + /// copies. /// internal static readonly System.Collections.Generic.HashSet InheritedProperties = new System.Collections.Generic.HashSet(System.StringComparer.OrdinalIgnoreCase) diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index 552e3a325..47dd89c7b 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs @@ -1988,9 +1988,9 @@ protected void MeasureWordSpacing(RGraphics g) } /// - /// Inherits inheritable values from specified box. + /// Inherits inheritable values from specified box, CSS 2.1 §6.2. /// - /// Set to true to inherit all CSS properties instead of only the ineritables + /// Set to true to copy all CSS properties, for another box of the same element /// Box to inherit the properties protected void InheritStyle(CssBox p, bool everything) { @@ -2079,18 +2079,8 @@ protected void InheritStyle(CssBox p, bool everything) _maxWidth = p._maxWidth; _wordSpacing = p._wordSpacing; - // css-break-3 3: break-before/break-after/break-inside attach to the ELEMENT, not to - // whichever one of its boxes happens to hold them - so a structural clone (a fragment - // of the same element, as opposed to an ordinary, unrelated child) must carry them too, - // even though they are not part of the ordinary CSS inheritance this method's non- - // "everything" branch above implements. Confirmed missing by direct inspection: this - // "everything" branch copied every other originating-element property (background, - // border, position, size...) but never these three, so both of this method's real - // "everything: true" callers silently produced auto/auto/auto clones regardless of what - // the source element declared - TableHeaderRepeat.CloneSubtree's per-page repeated - // row clones, and DomParser.CorrectBlockSplitBadBox's block-in-inline split - // (leftbox/rightBox), both of which exist specifically because one element is being - // represented by more than one box and every representative must agree. + // css-break-3 §3: breaks belong to the element, so every box of a split or + // repeated element carries them _pageBreakInside = p._pageBreakInside; _breakBefore = p._breakBefore; _breakAfter = p._breakAfter; diff --git a/Source/HtmlRenderer/Core/Parse/CssParser.cs b/Source/HtmlRenderer/Core/Parse/CssParser.cs index c395cc084..f78a420f6 100644 --- a/Source/HtmlRenderer/Core/Parse/CssParser.cs +++ b/Source/HtmlRenderer/Core/Parse/CssParser.cs @@ -100,7 +100,7 @@ public static Stylesheet ParseStyleSheetText(string stylesheet) /// /// Parse the given stylesheet source and add its rules into the given css data (author origin), - /// recursively resolving @import rules (guarded against circular imports). + /// preceded by the style sheets it imports. /// /// the CSS data to fill with parsed CSS objects /// raw css stylesheet to parse diff --git a/Source/HtmlRenderer/Core/Parse/DomParser.cs b/Source/HtmlRenderer/Core/Parse/DomParser.cs index 79afd9c87..05bd46098 100644 --- a/Source/HtmlRenderer/Core/Parse/DomParser.cs +++ b/Source/HtmlRenderer/Core/Parse/DomParser.cs @@ -111,7 +111,7 @@ public DomParser(CssParser cssParser) /// the html container to use for reference resolve /// the style data to fill with found styles /// check if the css data has been modified by the handled html not to change the base css data - /// the (possibly cloned) css data and whether it has been cloned yet, threaded through the recursive tree walk in place of the original ref parameters (async methods cannot take ref/out parameters) + /// the (possibly cloned) css data and whether it has been cloned yet private async Task<(CssData cssData, bool cssDataChanged)> CascadeParseStyles(CssBox box, HtmlContainerInt htmlContainer, CssData cssData, bool cssDataChanged) { if (box.HtmlTag != null) @@ -202,35 +202,22 @@ private static async Task RegisterFontFaces(CssData cssData, HtmlContainerInt ht /// - /// Applies style to all boxes in the tree via a spec-correct, origin-aware six-phase cascade: - /// UA-normal, Author-normal, Inline-normal, Author-!important, Inline-!important, UA-!important - /// (origin order reverses for the !important tier, per spec). Each phase's revert/revert-layer - /// target is a snapshot of the box's state immediately before that phase ran. + /// Applies the cascade (CSS 2.1 §6.4) to every box in the tree. Declarations are applied from + /// lowest to highest precedence, so the last write wins: UA normal, author normal, style + /// attribute normal, author !important, style attribute !important, UA !important. /// /// the box to apply the style to /// the style data for the html private void CascadeApplyStyles(CssBox box, CssData cssData, MediaQueryContext media) { - // Every box's fields already start at their initial value (CssBoxProperties's private - // field initializers, which CssDefaults.InitialValues was itself built to mirror exactly - - // see CssDefaults.cs) - so unlike a from-initial cascade, there is no separate "set every - // property to its initial value" pass here. Deliberately: a handful of this engine's - // property setters (e.g. LineHeight, CssBoxProperties.cs:550) are eager - they immediately - // resolve the assigned value to an absolute pixel string using the box's CURRENT font/size, - // rather than storing it as-is for lazy resolution later. Calling every whitelisted - // property's setter unconditionally on every box, before InheritStyle() has propagated a - // real font-size down and before layout has given the box a real size, fires those eager - // setters against not-yet-meaningful state and bakes in garbage (observed: line-height - // resolving to "-0px", corrupting all downstream layout). CssDefaults.InitialValues is - // still very much used - see GetInitialValue() calls below - just only consulted on demand - // when a declaration literally says initial/unset/revert, never as a blanket pre-pass. - - // 1. Inherit inheritable properties from parent + // There's no pass that resets every property to its initial value, the fields already + // start there. Some setters (e.g. LineHeight) resolve lengths eagerly against the box's + // current font and size, and calling them before inheritance and layout bakes in wrong values. + + // CSS 2.1 §6.2 box.InheritStyle(); - // Regular property declarations whose value contains var(...) are deferred here and resolved - // once, after the whole cascade (all phases below) has finished, so var() sees this box's - // FINAL custom property values regardless of which phase declared them. + // Declarations with var() are resolved after all phases, against the final custom properties var pendingVarProperties = new Dictionary(); // Anonymous boxes aren't elements and match nothing. ::before/::after boxes have no HtmlTag @@ -239,28 +226,17 @@ private void CascadeApplyStyles(CssBox box, CssData cssData, MediaQueryContext m var uaRules = canMatchRules ? cssData.GetUserAgentStyleRules(media, box).ToList() : new List(); var authorRules = canMatchRules ? cssData.GetAuthorStyleRules(media, box).ToList() : new List(); - // Inline style is parsed up front - parsing is pure text -> rule with no cascade side - // effects, so hoisting it here (ahead of TranslateAttributes, which still runs at its - // original point below) doesn't change behavior, and lets RulesUseRevertKeyword below see it too. IStyleRule inlineRule = null; if (box.HtmlTag != null && box.HtmlTag.HasAttribute("style")) { inlineRule = _cssParser.ParseInlineStyle(box.HtmlTag.TryGetAttribute("style")); } - // The relatively expensive property/custom-property snapshots below are only ever read - // back when a later phase's declaration is literally revert/revert-layer, so each is only - // captured when something that will actually consult it uses one of those keywords. + // The snapshots used as revert targets are only taken when a phase uses revert var authorUsesRevert = RulesUseRevertKeyword(authorRules); var uaUsesRevert = RulesUseRevertKeyword(uaRules); var inlineUsesRevert = inlineRule != null && RulesUseRevertKeyword(new[] { inlineRule }); - // Cascade precedence (lowest to highest, i.e. applied in this order so "last write wins" - // naturally produces the correct winner): UA-normal, Author-normal (inline-normal wins ties - // within this tier), Author-!important (inline-!important wins ties within this tier), - // UA-!important (per spec, origin order REVERSES for !important, so it's applied - and wins - // - last). - // 3. UA normal AssignCssBlocks(box, uaRules, false, null, null, pendingVarProperties); var needsUaSnapshot = authorUsesRevert; @@ -289,10 +265,8 @@ private void CascadeApplyStyles(CssBox box, CssData cssData, MediaQueryContext m inlineNormalCustomSnapshot = needsInlineNormalSnapshot ? CssUtils.SnapshotCustomProperties(box) : null; } - // 6. Author !important. Note this means an author-!important "revert" can roll back to a - // value inline *normal* style just set, not all the way back to the UA snapshot - a - // deliberate choice for mechanical consistency ("revert = state right before this phase") - // rather than a stricter per-origin reading of the spec. + // 6. Author !important. Its revert target is the state after the style attribute's normal + // declarations, not the UA snapshot. AssignCssBlocks(box, authorRules, true, inlineNormalSnapshot, inlineNormalCustomSnapshot, pendingVarProperties); var needsAuthorImportantSnapshot = inlineUsesRevert || uaUsesRevert; var authorImportantSnapshot = needsAuthorImportantSnapshot ? CssUtils.SnapshotProperties(box) : null; @@ -309,8 +283,7 @@ private void CascadeApplyStyles(CssBox box, CssData cssData, MediaQueryContext m afterInlineImportantCustomSnapshot = needsAfterInlineImportantSnapshot ? CssUtils.SnapshotCustomProperties(box) : null; } - // 8. UA !important - applied globally last so it wins over everything else, per spec's - // origin reversal for the !important tier. + // 8. UA !important AssignCssBlocks(box, uaRules, true, afterInlineImportantSnapshot, afterInlineImportantCustomSnapshot, pendingVarProperties); // 9. Resolve var() references now that every custom property's final cascaded value is known @@ -408,15 +381,10 @@ private static bool RulesUseRevertKeyword(IEnumerable rules) } /// - /// Assigns the given css style rule's declarations to the given css box, applying only the - /// declarations whose !important flag matches (the caller is - /// expected to call this once per origin per pass - see - so - /// that within a properly-ordered sequence of calls, "last write wins" alone produces the - /// spec-correct result). Handles the CSS-wide keywords: inherit, initial, unset, revert, - /// revert-layer. Custom property declarations (--foo) are routed to - /// instead. Regular declarations whose value - /// contains var(...) are deferred into rather than - /// applied immediately - see for why. + /// Applies the declarations of the given rule whose !important flag matches + /// , resolving the CSS-wide keywords. Custom properties go to + /// , values with var() are deferred to + /// . /// private static void AssignCssBlock( CssBox box, @@ -438,6 +406,7 @@ private static void AssignCssBlock( } string value; + // CSS 2.1 §6.2.1 if (prop.Value == CssConstants.Inherit) { value = box.ParentBox != null @@ -946,10 +915,8 @@ private static (CssData cssData, bool cssDataChanged) CloneCssData(CssData cssDa } /// - /// + /// Translates presentational HTML attributes to CSS, CSS 2.1 §6.4.4. /// - /// - /// private void TranslateAttributes(HtmlTag tag, CssBox box) { if (tag.HasAttributes()) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Layout/GlobalKeywordCascadeTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Layout/GlobalKeywordCascadeTests.cs index 923939a37..934544bb7 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Layout/GlobalKeywordCascadeTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Layout/GlobalKeywordCascadeTests.cs @@ -2,14 +2,6 @@ namespace HtmlRenderer.IntegrationTest.Layout; -/// -/// Ported from PeachPDF.Tests' GlobalKeywordCascadeTests.cs - the CSS 2.1-relevant subset (CSS 2.1 §6.1/ -/// §6.2's inheritance and cascade-order rules, plus inherit/initial/unset, the latter -/// from CSS Cascading and Inheritance Level 3 but already ported at the CSS-OM/parse level in -/// Css/GlobalKeywordPropertyTests.cs - this file is the cascade-RESOLUTION level that was still -/// untested). revert/revert-layer, cascade layers, and PeachPDF's own eager em-to-pt UA -/// font-size conversion are not ported - out of CSS 2.1 scope or specific to PeachPDF's own architecture. -/// [DoNotParallelize] [TestClass] public sealed class GlobalKeywordCascadeTests @@ -17,6 +9,7 @@ public sealed class GlobalKeywordCascadeTests // ── inherit ──────────────────────────────────────────────────────────── [TestMethod] + [Spec("css21", "cascade.html#value-def-inherit")] public void Inherit_ColorFromParent_ChildGetsParentColor() { var html = LayoutHarness.Wrap( @@ -28,9 +21,9 @@ public void Inherit_ColorFromParent_ChildGetsParentColor() } [TestMethod] + [Spec("css21", "cascade.html#value-def-inherit")] public void Inherit_NonInheritedProperty_ChildGetsParentValue() { - // margin-top is NOT inherited by default; explicit "inherit" should force it. var html = LayoutHarness.Wrap( "
      text
      "); var (root, _) = LayoutHarness.Layout(html); @@ -40,6 +33,7 @@ public void Inherit_NonInheritedProperty_ChildGetsParentValue() } [TestMethod] + [Spec("css21", "cascade.html#value-def-inherit")] public void Inherit_ColorWithoutExplicitParent_UsesInitialBlack() { var html = LayoutHarness.Wrap("
      text
      "); @@ -117,9 +111,10 @@ public void Unset_InheritedPropertyWithNoParentValue_UsesInitial() Assert.AreEqual("black", el.Color); } - // ── cascade order regressions ──────────────────────────────────────── + // ── cascade order ──────────────────────────────────────────────────── [TestMethod] + [Spec("css21", "cascade.html#inheritance")] public void Regression_ColorInheritsFromParentWithoutKeyword() { var html = LayoutHarness.Wrap( @@ -131,6 +126,7 @@ public void Regression_ColorInheritsFromParentWithoutKeyword() } [TestMethod] + [Spec("css21", "cascade.html#specificity")] public void Regression_InlineStyleOverridesAuthorRule() { var html = LayoutHarness.Wrap( @@ -142,6 +138,7 @@ public void Regression_InlineStyleOverridesAuthorRule() } [TestMethod] + [Spec("css21", "cascade.html#cascading-order")] public void Regression_AuthorRuleOverridesUaDefault() { var html = LayoutHarness.Wrap("

      heading

      "); @@ -152,6 +149,7 @@ public void Regression_AuthorRuleOverridesUaDefault() } [TestMethod] + [Spec("css21", "cascade.html#important-rules")] public void Regression_ImportantAuthorRuleBeatsInlineStyle() { var html = LayoutHarness.Wrap( @@ -163,6 +161,7 @@ public void Regression_ImportantAuthorRuleBeatsInlineStyle() } [TestMethod] + [Spec("css21", "cascade.html#important-rules")] public void InlineImportant_BeatsAuthorImportantRule() { var html = LayoutHarness.Wrap( @@ -174,6 +173,7 @@ public void InlineImportant_BeatsAuthorImportantRule() } [TestMethod] + [Spec("css21", "cascade.html#cascading-order")] public void Regression_LaterAuthorRuleOverridesEarlierSameSpecificity() { var html = LayoutHarness.Wrap("
      text
      "); diff --git a/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs b/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs new file mode 100644 index 000000000..09ead29e9 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs @@ -0,0 +1,375 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Parse; + +namespace HtmlRenderer.Test.Css; + +[TestClass] +public sealed class CascadeTests +{ + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + private static readonly RColor Green = RColor.FromArgb(0, 128, 0); + + // ── 6.1 Specified, computed, used values ───────────────────────────── + + [TestMethod] + [Spec("css21", "cascade.html#specified-value")] + public void CascadedValue_WinsOverTheInheritedValue() + { + var x = Box("

      a

      "); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css21", "cascade.html#specified-value")] + public void InheritedProperty_WithoutCascadedValue_TakesTheParentsValue() + { + var x = Box("

      a

      "); + + Assert.AreEqual("italic", x.FontStyle); + Assert.AreEqual("pre", x.WhiteSpace); + } + + [TestMethod] + [Spec("css21", "cascade.html#specified-value")] + public void NonInheritedProperty_WithoutCascadedValue_TakesTheInitialValue() + { + var x = Box("
      a
      "); + + Assert.AreEqual("0", x.MarginTop); + Assert.AreEqual("0", x.PaddingLeft); + Assert.AreEqual("transparent", x.BackgroundColor); + } + + [TestMethod] + [Spec("css21", "cascade.html#computed-value")] + public void EmLength_IsComputedAgainstTheElementsFontSize() + { + var x = Box("
      a
      "); + + Assert.AreEqual(40, ParsePx(x.TextIndent), 0.01); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "cascade.html#computed-value")] + public void EmLength_DeclaredBeforeTheFontSize_IsComputedAgainstTheElementsFontSize() + { + var x = Box("
      a
      "); + + Assert.AreEqual(40, ParsePx(x.TextIndent), 0.01); + } + + [TestMethod] + [Spec("css21", "cascade.html#used-value")] + public void PercentageWidth_IsResolvedAgainstTheContainingBlock() + { + var x = Box("
      a
      "); + + Assert.AreEqual("50%", x.Width); + Assert.AreEqual(100, x.Size.Width, 0.5); + } + + // ── 6.2 Inheritance ────────────────────────────────────────────────── + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "cascade.html#inheritance")] + public void PercentageFontSize_IsInheritedAsTheComputedSize() + { + var (root, _) = LayoutHarness.Layout( + "" + + "

      A large heading

      "); + var h1 = LayoutHarness.FindById(root, "h1")!; + var em = LayoutHarness.FindById(root, "em")!; + + Assert.AreEqual(13, h1.ActualFont.Size, 0.01); + Assert.AreEqual(h1.ActualFont.Size, em.ActualFont.Size, 0.01); + } + + [TestMethod] + [Spec("css21", "cascade.html#inheritance")] + public void EmLength_IsInheritedAsTheComputedLength() + { + var x = Box("
      a
      "); + + Assert.AreEqual(40, ParsePx(x.TextIndent), 0.01); + } + + [TestMethod] + [Spec("css21", "cascade.html#inheritance")] + public void Inheritance_FollowsTheDocumentTree_NotAnonymousBoxes() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "a
      b
      c
      ")); + var x = LayoutHarness.FindById(root, "x")!; + + Assert.AreEqual(Red, x.ActualColor); + Assert.AreEqual("7px", x.MarginLeft); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "cascade.html#inheritance")] + public void WordSpacing_IsInherited() + { + var x = Box("

      a b

      "); + + Assert.AreEqual("10px", x.WordSpacing); + } + + // ── 6.2.1 'inherit' ────────────────────────────────────────────────── + + [TestMethod] + [Spec("css21", "cascade.html#value-def-inherit")] + public void Inherit_OnANonInheritedProperty_TakesTheParentsValue() + { + var x = Box("
      a
      "); + + Assert.AreEqual("solid", x.BorderTopStyle); + Assert.AreEqual("transparent", x.BackgroundColor); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "cascade.html#value-def-inherit")] + public void Inherit_OnTheRootElement_TakesTheInitialValue() + { + var (root, _) = LayoutHarness.Layout("a"); + var x = LayoutHarness.FindById(root, "x")!; + + Assert.AreEqual("inline", x.Display); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "cascade.html#value-def-inherit")] + public void Inherit_TakesTheParentsComputedEmLength() + { + var x = Box("
      a
      "); + + Assert.AreEqual(40, x.ActualMarginLeft, 0.01); + } + + [TestMethod] + [Spec("css21", "cascade.html#value-def-inherit")] + public void InheritImportant_EnforcesTheParentsValue() + { + var x = Box( + "" + + "

      a

      "); + + Assert.AreEqual(Green, x.ActualColor); + } + + // ── 6.3 @import ────────────────────────────────────────────────────── + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "cascade.html#at-import")] + public void Import_AppliesTheImportedStyleSheet_BeforeTheImportingOne() + { + var x = BoxWithStylesheets( + "

      a

      ", + new() { ["a.css"] = "p { color: red; font-style: normal }", ["b.css"] = "p { color: green }" }); + + Assert.AreEqual(Green, x.ActualColor); + Assert.AreEqual("italic", x.FontStyle); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "cascade.html#at-import")] + public void Import_WithANonMatchingMediaList_IsIgnored() + { + var x = BoxWithStylesheets( + "

      a

      ", + new() { ["screen.css"] = "p { color: green }", ["print.css"] = "p { font-style: italic }" }); + + Assert.AreEqual(Green, x.ActualColor); + Assert.AreEqual("normal", x.FontStyle); + } + + // ── 6.4 The cascade ────────────────────────────────────────────────── + + [TestMethod] + [Spec("css21", "cascade.html#cascading-order")] + public void AuthorRule_BeatsTheDefaultStyleSheet_RegardlessOfSpecificity() + { + var x = Box("a"); + + Assert.AreEqual("normal", x.FontWeight); + } + + [TestMethod] + [Spec("css21", "cascade.html#cascading-order")] + public void RuleInANonMatchingMediaBlock_DoesNotApply() + { + var x = Box("

      a

      "); + + Assert.AreEqual(Green, x.ActualColor); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "cascade.html#cascading-order")] + public void StyleSheet_WithANonMatchingMediaAttribute_DoesNotApply() + { + var x = BoxWithStylesheets( + "

      a

      ", + new() { ["screen.css"] = "p { color: green }", ["print.css"] = "p { color: red }" }); + + Assert.AreEqual(Green, x.ActualColor); + Assert.AreEqual(RColor.Black, Box("

      a

      ").ActualColor); + } + + [TestMethod] + [Spec("css21", "cascade.html#cascading-order")] + public void LaterStyleSheet_WinsAtEqualSpecificity() + { + var x = BoxWithStylesheets( + "

      a

      ", + new() { ["a.css"] = "p { color: green }" }); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css21", "cascade.html#important-rules")] + public void ImportantDeclaration_BeatsAMoreSpecificNormalOne() + { + var x = Box("
      a
      "); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css21", "cascade.html#important-rules")] + public void ImportantShorthand_MakesEverySubPropertyImportant() + { + var x = Box("
      a
      "); + + Assert.AreEqual("5px", x.MarginLeft); + Assert.AreEqual("5px", x.MarginTop); + } + + [TestMethod] + [Spec("css21", "cascade.html#important-rules")] + public void ImportantDeclaration_WinsWithinTheAuthorStyleSheet() + { + var x = Box( + "

      a

      "); + + Assert.AreEqual("12pt", x.FontSize); + } + + // ── 6.4.3 Specificity ──────────────────────────────────────────────── + + [TestMethod] + [Spec("css21", "cascade.html#specificity")] + [DataRow("*", 0, 0, 0)] + [DataRow("li", 0, 0, 1)] + [DataRow("li:first-line", 0, 0, 2)] + [DataRow("ul li", 0, 0, 2)] + [DataRow("ul ol+li", 0, 0, 3)] + [DataRow("h1 + *[rel=up]", 0, 1, 1)] + [DataRow("ul ol li.red", 0, 1, 3)] + [DataRow("li.red.level", 0, 2, 1)] + [DataRow("#x34y", 1, 0, 0)] + [DataRow("[id=p33]", 0, 1, 0)] + [DataRow("a:link", 0, 1, 1)] + public void Specificity_MatchesTheSpecExamples(string selector, int ids, int classes, int tags) + { + var rule = CssParser.ParseStyleSheetText(selector + " {}").StyleRules.Single(); + + Assert.AreEqual(new Priority(0, (byte)ids, (byte)classes, (byte)tags), rule.Selector.Specificity); + } + + [TestMethod] + [Spec("css21", "cascade.html#specificity")] + public void StyleAttribute_BeatsAnIdSelector() + { + var x = Box("

      a

      "); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css21", "cascade.html#specificity")] + public void IdAttributeSelector_CountsAsAnAttribute_NotAsAnId() + { + Assert.AreEqual(Green, Box("

      a

      ").ActualColor); + Assert.AreEqual(Green, Box("

      a

      ").ActualColor); + } + + [TestMethod] + [Spec("css21", "cascade.html#specificity")] + public void PseudoClass_CountsAsAClass() + { + Assert.AreEqual(Green, Box("

      a

      ").ActualColor); + Assert.AreEqual(Green, Box("

      a

      ").ActualColor); + } + + // ── 6.4.4 Presentational hints ─────────────────────────────────────── + + [TestMethod] + [Spec("css21", "cascade.html#preshint")] + public void PresentationalHint_BeatsTheDefaultStyleSheet() + { + var x = Box("
      a
      "); + + Assert.AreEqual("left", x.TextAlign); + } + + [TestMethod] + [Spec("css21", "cascade.html#preshint")] + public void StyleAttribute_BeatsAPresentationalHint() + { + var x = Box("a"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "cascade.html#preshint")] + public void PresentationalHint_LosesToAnAuthorRule() + { + Assert.AreEqual(Green, Box("a").ActualColor); + Assert.AreEqual("100px", Box("
      a
      ").Width); + } + + private static CssBox Box(string body, string id = "x") + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(body)); + var box = LayoutHarness.FindById(root, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box; + } + + /// Lays out with external style sheets served from memory. + private static CssBox BoxWithStylesheets(string body, Dictionary stylesheets, string id = "x") + { + var container = new HtmlContainerInt(new MockAdapter()) { MaxSize = new RSize(1000, 4000) }; + container.StylesheetLoad += (_, e) => + { + if (stylesheets.TryGetValue(e.Src, out var css)) + e.SetStyleSheet = css; + }; + + container.SetHtml(LayoutHarness.Wrap(body)); + using (var graphics = new RecordingGraphics()) + container.PerformLayout(graphics); + + var box = LayoutHarness.FindById(container.Root!, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box; + } + + private static double ParsePx(string length) => + double.Parse(length.Replace("px", ""), System.Globalization.CultureInfo.InvariantCulture); +} diff --git a/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs index 1f1d5ad14..884aee928 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs @@ -4,33 +4,20 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/CssSpecificityOrderingTests.cs. -/// PeachPDF's CssData resolves matched rules in (specificity ascending, true document order), a -/// real CSS specificity computation. HTML-Renderer's old CssData had no specificity concept at -/// all - selector matching was bucketed by class name with no id/class/tag specificity and no true -/// cross-bucket source order, and @media-scoped rules were stored under a separate key that -/// DomParser never queried, so an "@media print" rule never actually applied regardless of -/// specificity. The CSS engine port replaced all of that with the real thing: -/// (via GetMatchedSpecificity) orders matched rules by real CSS specificity, tie-broken by true -/// document order (assigned across the plain-rule/@media boundary by IndexRules), and @media -/// is evaluated for real (see ). All four -/// cases below now genuinely pass against the real engine and are un-ignored. -/// [TestClass] public sealed class CssSpecificityOrderingTests { [TestMethod] + [Spec("css21", "cascade.html#specificity")] public void HigherSpecificityRule_WinsEvenWhenDeclaredEarlier() { - // #el (id, highest specificity) is declared FIRST; div (type, lowest specificity) is - // declared SECOND. Specificity decides this, not declaration order. var html = Html("#el { color: #0000ff; } div { color: #ff0000; }", "
      text
      "); var box = FindBoxByTag(html, "div"); Assert.AreEqual(RColor.FromArgb(0, 0, 255), box.ActualColor); } [TestMethod] + [Spec("css21", "cascade.html#cascading-order")] public void EqualSpecificity_SameOrigin_StillResolvesByLastDeclared() { var html = Html("div { color: #ff0000; } div { color: #0000ff; }", "
      text
      "); @@ -39,12 +26,9 @@ public void EqualSpecificity_SameOrigin_StillResolvesByLastDeclared() } [TestMethod] + [Spec("css21", "cascade.html#cascading-order")] public void MediaRuleDeclaredEarlier_LosesToEqualSpecificityPlainRuleDeclaredLater() { - // The @media print block (containing a "div" rule) appears FIRST in the source; a plain - // "div" rule appears SECOND. LayoutHarness's default MockAdapter reports "screen" media, so - // the @media print rule doesn't apply at all here regardless of source order - this also - // exercises that a non-matching-media rule is correctly excluded even at equal specificity. var html = Html( "@media print { div { color: #0000ff; } } div { color: #ff0000; }", "
      text
      "); @@ -53,12 +37,10 @@ public void MediaRuleDeclaredEarlier_LosesToEqualSpecificityPlainRuleDeclaredLat } [TestMethod] + [Spec("css21", "cascade.html#specificity")] public void CommaListRule_UsesOnlyTheMatchedBranchsSpecificity_NotASum() { - // The box matches ".a" (one class) but NOT "#b" (an id) in the list selector ".a, #b" - per - // GetMatchedSpecificity, a matched list selector's effective specificity is whichever - // alternative actually matched (".a"), not a summed/static max across the whole list, so - // ".a.c" (two classes - higher specificity than a single class) correctly wins. + // A selector list is equivalent to one rule per selector, so only ".a" counts here. var html = Html( ".a, #b { color: #0000ff; } .a.c { color: #ff0000; }", "
      text
      "); @@ -66,9 +48,6 @@ public void CommaListRule_UsesOnlyTheMatchedBranchsSpecificity_NotASum() Assert.AreEqual(RColor.FromArgb(255, 0, 0), box.ActualColor); } - // ── Helpers (mirrors PeachPDF's SelectorMatchingTests.cs conventions, adapted to the - // synchronous LayoutHarness used across this test project) ──────────────── - private static string Html(string css, string body) => $"{body}"; diff --git a/Source/Test/HtmlRenderer.Test/Css/GlobalKeywordPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/GlobalKeywordPropertyTests.cs index 2ffa443c3..f11ffd833 100644 --- a/Source/Test/HtmlRenderer.Test/Css/GlobalKeywordPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/GlobalKeywordPropertyTests.cs @@ -3,9 +3,7 @@ namespace HtmlRenderer.Test.Css; /// -/// Unit tests verifying that the CSS module correctly parses all five global keywords -/// (inherit, initial, unset, revert, revert-layer) for a representative set of properties. -/// Ported from PeachPDF.Tests/CSS/PropertyTests/GlobalKeywordPropertyTests.cs. +/// Parsing of the CSS-wide keywords inherit, initial, unset, revert and revert-layer. /// [TestClass] public sealed class GlobalKeywordPropertyTests @@ -13,6 +11,7 @@ public sealed class GlobalKeywordPropertyTests // -- inherit --------------------------------------------------------- [TestMethod] + [Spec("css21", "cascade.html#value-def-inherit")] public void Color_Inherit_IsMarkedAsInherited() { var property = CssConstructionFunctions.ParseDeclaration("color: inherit"); @@ -22,6 +21,7 @@ public void Color_Inherit_IsMarkedAsInherited() } [TestMethod] + [Spec("css21", "cascade.html#value-def-inherit")] public void MarginTop_Inherit_IsMarkedAsInherited() { var property = CssConstructionFunctions.ParseDeclaration("margin-top: inherit"); @@ -31,6 +31,7 @@ public void MarginTop_Inherit_IsMarkedAsInherited() } [TestMethod] + [Spec("css21", "cascade.html#value-def-inherit")] public void FontSize_Inherit_IsMarkedAsInherited() { var property = CssConstructionFunctions.ParseDeclaration("font-size: inherit"); @@ -40,6 +41,7 @@ public void FontSize_Inherit_IsMarkedAsInherited() } [TestMethod] + [Spec("css21", "cascade.html#value-def-inherit")] public void Display_Inherit_IsMarkedAsInherited() { var property = CssConstructionFunctions.ParseDeclaration("display: inherit"); @@ -167,6 +169,7 @@ public void Display_RevertLayer_HasRevertLayerValue() // -- !important is preserved alongside global keywords ----------------------- [TestMethod] + [Spec("css21", "cascade.html#value-def-inherit")] public void Color_InheritImportant_IsImportantAndInherited() { var property = CssConstructionFunctions.ParseDeclaration("color: inherit !important"); diff --git a/Source/Test/HtmlRenderer.Test/Css/ImportRuleTests.cs b/Source/Test/HtmlRenderer.Test/Css/ImportRuleTests.cs index fe6a0d8ac..17242b1b7 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ImportRuleTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ImportRuleTests.cs @@ -3,11 +3,6 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/ImportRule.cs (CssImportRuleTests). ImportRule, Rule.Text, -/// Href, and Media.MediaText/Length all match HTML-Renderer's -/// exactly. -/// [TestClass] public sealed class ImportRuleTests { @@ -19,6 +14,7 @@ private static ImportRule NewImportRule(string cssText) } [TestMethod] + [Spec("css21", "cascade.html#at-import")] public void CssImportWithNonQuotedUrl() { var source = "@import url(button.css);"; @@ -28,6 +24,7 @@ public void CssImportWithNonQuotedUrl() } [TestMethod] + [Spec("css21", "cascade.html#at-import")] public void CssImportWithDoubleQuotedUrl() { var source = "@import url(\"button.css\");"; @@ -37,6 +34,7 @@ public void CssImportWithDoubleQuotedUrl() } [TestMethod] + [Spec("css21", "cascade.html#at-import")] public void CssImportWithSingleQuotedUrl() { var source = "@import url('button.css');"; @@ -46,6 +44,7 @@ public void CssImportWithSingleQuotedUrl() } [TestMethod] + [Spec("css21", "cascade.html#at-import")] public void CssImportWithDoubleQuotedStringAsUrl() { var source = "@import \"button.css\";"; @@ -55,6 +54,7 @@ public void CssImportWithDoubleQuotedStringAsUrl() } [TestMethod] + [Spec("css21", "cascade.html#at-import")] public void CssImportWithSingleQuotedStringAsUrl() { var source = "@import 'button.css';"; @@ -64,6 +64,7 @@ public void CssImportWithSingleQuotedStringAsUrl() } [TestMethod] + [Spec("css21", "cascade.html#at-import")] public void CssImportWithUrlAndAllMedia() { var media = "all"; @@ -75,6 +76,7 @@ public void CssImportWithUrlAndAllMedia() } [TestMethod] + [Spec("css21", "cascade.html#at-import")] public void CssImportWithUrlAndComplicatedMedia() { var media = "screen and (color), projection and (min-color: 256)"; diff --git a/Spec/css21.yml b/Spec/css21.yml index 6a8bf1b32..7b8b40c1f 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -214,46 +214,61 @@ sections: status: supported cascade.html#q6.0: title: 6 Assigning property values, Cascading, and Inheritance - status: unknown + status: partial cascade.html#value-stages: title: 6.1 Specified, computed, and actual values - status: unknown + status: partial cascade.html#specified-value: title: 6.1.1 Specified values - status: unknown + status: supported cascade.html#computed-value: title: 6.1.2 Computed values - status: unknown + status: partial + gaps: + - An em length declared before the element's own 'font-size' is computed against the parent's font size cascade.html#used-value: title: 6.1.3 Used values - status: unknown + status: supported cascade.html#actual-value: title: 6.1.4 Actual values - status: unknown + status: n/a cascade.html#inheritance: title: 6.2 Inheritance - status: unknown + status: partial + gaps: + - A percentage 'font-size' is inherited as the percentage and compounds, instead of as the computed size + - "'word-spacing' isn't inherited" cascade.html#value-def-inherit: title: 6.2.1 The 'inherit' value - status: unknown + status: partial + gaps: + - "'inherit' copies the parent's specified value, so an em margin is resolved against the child's font size" + - "'inherit' on the root element takes the value of the anonymous box around it (display: block) instead of the initial value" cascade.html#at-import: title: 6.3 The @import rule - status: unknown + status: parsed-only + gaps: + - The imported style sheet is never loaded + - The media list of an @import isn't evaluated cascade.html#cascade: title: 6.4 The cascade - status: unknown + status: partial cascade.html#cascading-order: title: 6.4.1 Cascading order - status: unknown + status: partial + gaps: + - The media attribute of and ")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "media.html#media-sheets")] + public void ImportWithMatchingMedia_Applies() + { + Assert.AreEqual(Green, ColorOf($"")); + } + + [TestMethod] + [Spec("css21", "media.html#at-media-rule")] + [DataRow("print", "10pt")] + [DataRow("screen", "13px")] + public void AtMedia_SelectsTheRulesForTheMediaType(string mediaType, string fontSize) + { + var box = Box(""" + + """, mediaType); + + Assert.AreEqual(fontSize, box.FontSize); + Assert.AreEqual(Green, box.ActualColor); + } + + [TestMethod] + [Spec("css21", "media.html#at-media-rule")] + public void RulesOutsideAtMedia_ApplyToAllMediaTypes() + { + Assert.AreEqual(Green, ColorOf("", "print")); + } + + [TestMethod] + [Spec("css21", "media.html#at-media-rule")] + public void InvalidStatementInsideAtMedia_IsIgnored() + { + Assert.AreEqual(Green, ColorOf("")); + } + + [TestMethod] + [Spec("css21", "media.html#media-types")] + [DataRow("braille")] + [DataRow("embossed")] + [DataRow("handheld")] + [DataRow("print")] + [DataRow("projection")] + [DataRow("screen")] + [DataRow("speech")] + [DataRow("tty")] + [DataRow("tv")] + public void RecognizedMediaType_MatchesItself(string mediaType) + { + Assert.AreEqual(Green, ColorOf($"", mediaType)); + } + + [TestMethod] + [Spec("css21", "media.html#media-types")] + [DataRow("screen")] + [DataRow("print")] + public void All_MatchesEveryMediaType(string mediaType) + { + Assert.AreEqual(Green, ColorOf("", mediaType)); + } + + [TestMethod] + [Spec("css21", "media.html#media-types")] + [DataRow("SCREEN")] + [DataRow("Screen")] + public void MediaTypeNames_AreCaseInsensitive(string name) + { + Assert.AreEqual(Green, ColorOf($"")); + } + + [TestMethod] + [Spec("css21", "media.html#media-types")] + public void MediaType_OtherThanTheRenderedOne_DoesNotMatch() + { + Assert.AreNotEqual(Green, ColorOf("")); + } + + [TestMethod] + [Spec("css21", "media.html#media-types")] + [DataRow("holographic, screen")] + [DataRow("screen, holographic")] + public void UnknownMediaType_IsTreatedAsNotPresent(string mediaList) + { + Assert.AreEqual(Green, ColorOf($"")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "media.html#media-types")] + public void MalformedEntryInAMediaList_DoesNotDisableTheOthers() + { + Assert.AreEqual(Green, ColorOf("")); + } + + [TestMethod] + [Spec("css21", "media.html#media-types")] + public void OnlyUnknownMediaTypes_DoNotMatch() + { + Assert.AreNotEqual(Green, ColorOf("")); + } + + [TestMethod] + [Spec("css21", "media.html#media-types")] + public void MalformedMediaType_InvalidatesTheStatement() + { + Assert.AreNotEqual(Green, ColorOf("")); + } +} diff --git a/Spec/css21.yml b/Spec/css21.yml index 7b8b40c1f..5c55d7ef0 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -271,22 +271,27 @@ sections: - Presentational attributes are applied after the author style sheets and override them media.html#q7.0: title: 7 Media types - status: unknown + status: partial media.html#media-intro: title: 7.1 Introduction to media types - status: unknown + status: n/a media.html#media-sheets: title: 7.2 Specifying media-dependent style sheets - status: unknown + status: partial + gaps: + - The media attribute of link and style elements is ignored, the style sheet applies to every media type + - "@import rules are never loaded, so their media list has no effect" media.html#at-media-rule: title: 7.2.1 The @media rule - status: unknown + status: supported media.html#media-types: title: 7.3 Recognized media types - status: unknown + status: partial + gaps: + - A malformed entry in a media list, e.g. '3D' in '@media screen, 3D', makes the whole list not match instead of only that entry media.html#media-groups: title: 7.3.1 Media groups - status: unknown + status: n/a box.html#box-model: title: 8 Box model status: partial From 59f64929daba3682ec54196ad14c6c9867188a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:02:06 +0100 Subject: [PATCH 013/150] Verify and document CSS 2.1 visual formatting model compliance --- Source/HtmlRenderer/Core/Dom/CssBox.cs | 84 +---- .../HtmlRenderer/Core/Dom/CssBoxProperties.cs | 16 +- .../HtmlRenderer/Core/Dom/CssLayoutEngine.cs | 31 +- .../Core/Paint/FragmentPainter.cs | 2 +- Source/HtmlRenderer/Core/Utils/DomUtils.cs | 57 +-- .../FixedPositionRepeatsPerPageTest.cs | 19 +- .../Layout/BoxGenerationIntegrationTests.cs | 269 ++++++++++++++ .../Layout/FloatLayoutIntegrationTests.cs | 329 ++++++++++++++++-- .../Layout/NormalFlowIntegrationTests.cs | 161 +++++++++ .../Painting/StackingContextTests.cs | 69 +++- .../AbsolutePositioningIntegrationTests.cs | 37 +- ...FixedPositionPaginationIntegrationTests.cs | 41 +-- .../OutOfFlowPositioningIntegrationTests.cs | 157 +++++++++ .../RelativePositioningIntegrationTests.cs | 159 +++++++++ .../Text/DirectionIntegrationTests.cs | 71 ++++ .../Css/FloatPropertyTests.cs | 13 +- Spec/css21.yml | 107 +++--- 17 files changed, 1300 insertions(+), 322 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Layout/NormalFlowIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Positioning/OutOfFlowPositioningIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Positioning/RelativePositioningIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Text/DirectionIntegrationTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index cfb85b306..5116247ae 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -258,10 +258,8 @@ public bool IsFloated } /// - /// True for a box removed from normal flow: floated, or absolutely/fixed positioned. Ported - /// from PeachPDF's CssBox.IsOutOfFlow - used to find a container's last IN-FLOW child (e.g. - /// ), since an out-of-flow box doesn't contribute to a - /// non-BFC-establishing container's own auto-height (CSS 2.1 10.6.3/10.6.7). + /// Floated, absolutely or fixed positioned, i.e. out of flow (CSS 2.1 §9.3). The root isn't + /// included. /// public bool IsOutOfFlow { @@ -506,9 +504,7 @@ public static CssBox CreateBox(HtmlTag tag, CssBox parent = null) /// If no before box is given the new box will be added at the end of parent boxes collection.
      /// If before box doesn't exists in parent box exception is thrown.
      ///
      - /// - /// To learn more about anonymous inline boxes visit: http://www.w3.org/TR/CSS21/visuren.html#anonymous - /// + /// CSS 2.1 §9.2.2.1 /// the box to add the new box to it as child /// optional: the html tag to define the box /// optional: to insert as specific location in parent box @@ -544,10 +540,7 @@ public static CssBox CreateBlock() /// If no before box is given the new box will be added at the end of parent boxes collection.
      /// If before box doesn't exists in parent box exception is thrown.
      /// - /// - /// To learn more about anonymous block boxes visit CSS spec: - /// http://www.w3.org/TR/CSS21/visuren.html#anonymous-block-level - /// + /// CSS 2.1 §9.2.1.1 /// the box to add the new block box to it as child /// optional: the html tag to define the box /// optional: to insert as specific location in parent box @@ -880,10 +873,6 @@ protected virtual void PerformLayoutImp(RGraphics g) if (Position == CssConstants.Fixed) { - // Computed here (not eagerly from the Left/Top property setters, which used to - // race ahead of ActualMarginLeft/Top and ContainingBlock/HtmlContainer being ready - // and cache a margin-less Location that never got recomputed) so margin is always - // resolved against a fully-set-up box, matching every other positioning scheme. var fixedLocation = GetActualLocation(Left, Top); left = fixedLocation.X; top = fixedLocation.Y; @@ -902,9 +891,7 @@ protected virtual void PerformLayoutImp(RGraphics g) // one, matching the same walk CollectAdjoiningMarginsBeforeSibling does when // gathering the margin SET itself. var marginAnchor = FindMarginAnchorSibling(prevSibling); - // StaticBottom (not ActualBottom): a relatively-positioned previous sibling's visual - // offset must not drag this box down with it (CSS 2.1 9.4.3 - relative positioning - // "has no effect on the position of any other box"). Ported from PeachPDF. + // StaticBottom, the previous sibling's relative offset doesn't move this box (CSS 2.1 §9.4.3) var baseTopWithoutMargin = (marginAnchor == null && ParentBox != null ? ParentBox.ClientTop : ParentBox == null ? Location.Y : 0) + (marginAnchor != null ? marginAnchor.StaticBottom + marginAnchor.ActualBorderBottomWidth : 0); if (_incomingToken != null && ReferenceEquals(_incomingToken.Box, this)) @@ -958,22 +945,12 @@ protected virtual void PerformLayoutImp(RGraphics g) Location = new RPoint(left, top); ActualBottom = top; - // static position committed above; float/clear now overwrite Location using that - // static position as input. No-op for boxes that are neither floated nor clearing. + // Floats and clearance start from the static position (CSS 2.1 §9.5) CssLayoutEngine.FloatBox(this); - // CSS 2.1 §9.4.3/§10.3.7: position:relative/absolute apply on top of the static - // position just committed above. Ported from PeachPDF's CssBox.CommitBlockChildOffset - // (adapted: this fork places a box within its own PerformLayoutImp rather than a - // parent placing its child, and position:fixed's own offset - which never runs - // through this static-flow branch at all, see the Position==Fixed arm above - is - // instead resolved by CssBoxProperties.GetActualLocation). if (Position == CssConstants.Relative) { - // Purely visual (§9.4.3): the offset is recorded separately (RelativeOffsetX/Y) - // so StaticBottom can back it out again for margin-collapse/sibling-placement - // consumers - "the effect of relative positioning on ... the box's parent's or - // following siblings' layout is nil". + // Kept in RelativeOffsetX/Y, so StaticBottom can take it out again (CSS 2.1 §9.4.3) var offsetX = ResolveNearFarOffset(this, Left, Right, ActualWidth); var offsetY = ResolveNearFarOffset(this, Top, Bottom, ActualHeight); @@ -988,13 +965,8 @@ protected virtual void PerformLayoutImp(RGraphics g) var leftIsAuto = string.IsNullOrEmpty(Left) || Left == CssConstants.Auto; var rightIsAuto = string.IsNullOrEmpty(Right) || Right == CssConstants.Auto; - // left/top are measured from the containing block's PADDING edge (ClientLeft/ - // ClientTop), not its border-box edge, and the box's own margin still applies - // on top of that offset (CSS 2.1 §10.3.7). When left is auto but right is set, - // anchor off the containing block's right edge instead - this box's own - // border-box width (Size.Width) is already resolved by this point (the shrink- - // to-fit/explicit-width computation above), unlike its height (see the - // top/bottom case, resolved later in this method once ApplyHeight has run). + // Offsets are from the containing block's padding edge (CSS 2.1 §10.1). 'bottom' + // needs the height and is applied after ApplyHeight. var absLeft = !leftIsAuto ? nearestPositionedAncestor.ClientLeft + ActualMarginLeft + ResolveOffsetOrZero(this, Left, nearestPositionedAncestor.ActualWidth) @@ -1118,20 +1090,8 @@ protected virtual void PerformLayoutImp(RGraphics g) if (topIsAuto && !bottomIsAuto) { - // The top/bottom counterpart of the left/right shrink-to-fit-anchoring case above: - // unlike width, this box's own height is only known now, after ApplyHeight has run - // (auto height depends on this box's own already-laid-out content) - so the - // bottom-anchored case can't resolve at the same point the left/right one does, and - // is instead corrected here by shifting the whole subtree (OffsetTop, the same deep- - // move helper break relocation/table-header repetition already use) once this box's - // final height is known. CSS 2.1 §10.3.7. - // - // The ancestor's own ClientBottom/ActualBottom is NOT usable here: this box is still - // laying out as one of the ancestor's descendants, so the ancestor's own ApplyHeight - // (which sets ActualBottom, run only after ALL of its children finish) has not run yet - // either. ActualHeight, unlike ActualBottom, resolves directly from the ancestor's own - // explicit Height CSS string without depending on that - so the ancestor's content-box - // bottom edge is derived from Location.Y + ActualHeight instead. + // The ancestor is still being laid out and has no ActualBottom yet, so its bottom edge + // comes from ActualHeight var nearestPositionedAncestor = DomUtils.GetNearestPositionedAncestor(this); var ancestorBorderBoxBottom = nearestPositionedAncestor.Location.Y + nearestPositionedAncestor.ActualHeight; var ancestorClientBottom = ancestorBorderBoxBottom @@ -1141,9 +1101,7 @@ protected virtual void PerformLayoutImp(RGraphics g) var targetBottom = ancestorClientBottom - ActualMarginBottom - offsetBottom; var deltaY = targetBottom - ActualBottom; - // ActualBottom is computed (Location.Y + Size.Height, see CssBoxProperties.ActualBottom), - // so shifting Location.Y via OffsetTop already moves it by the same delta - no separate - // update needed (and adding one double-counts the shift). + // OffsetTop moves ActualBottom along if (deltaY != 0) { OffsetTop(deltaY); @@ -2192,20 +2150,15 @@ protected override CssImage GetActualBackgroundImageValue(string value) protected override RPoint GetActualLocation(string X, string Y) { - // position:fixed's own left/top offset resolves against the page/viewport size (CSS 2.1 - // §10.1: the initial containing block) and, like every other positioning scheme, the box's own - // margin still applies on top of that offset. Ported from PeachPDF's CommitBlockChildOffset - // Fixed branch (PeachPDF does not consult right/bottom for position:fixed either). + // The containing block of a fixed box is the viewport, or the page (CSS 2.1 §9.6.1) var left = ActualMarginLeft + ResolveOffsetOrZero(this, X, this.HtmlContainer.PageSize.Width); var top = ActualMarginTop + ResolveOffsetOrZero(this, Y, this.HtmlContainer.PageSize.Height); return new RPoint(left, top); } /// - /// CSS 2.1 §9.4.3's near/far offset resolution for one axis: the near offset (left/top) - /// wins when set; if it's auto and the far offset (right/bottom) isn't, the far - /// offset applies with its sign flipped; if both are auto, the offset is 0. Ported from - /// PeachPDF's CssBox.ResolveNearFarOffset. + /// The relative offset along one axis from left/top and right/bottom + /// (CSS 2.1 §9.4.3). /// private static double ResolveNearFarOffset(CssBox box, string near, string far, double basis) { @@ -2225,12 +2178,7 @@ private static double ResolveNearFarOffset(CssBox box, string near, string far, return 0; } - /// - /// Resolves a single left/top/right/bottom offset for the absolute/fixed - /// positioning branches, where the counterpart edge is never consulted (unlike the relative- - /// positioning near/far resolution in ) - an auto offset - /// simply contributes 0. Ported from PeachPDF's CssBox.ResolveOffsetOrZero. - /// + /// One box offset in pixels, 0 for auto. private static double ResolveOffsetOrZero(CssBox box, string offset, double basis) { return offset != CssConstants.Auto && !string.IsNullOrEmpty(offset) diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index 47dd89c7b..196cb56b2 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs @@ -576,10 +576,6 @@ public string Left get { return _left; } set { - // Deliberately no eager position:fixed recompute here (as this once had): it raced ahead - // of ActualMarginLeft/Top and ContainingBlock/HtmlContainer being ready and cached a - // margin-less Location that never got recomputed once they were. position:fixed placement - // is instead resolved once, in PerformLayoutImp, once the box is fully set up. _left = value; } } @@ -815,10 +811,8 @@ public string Bottom } /// - /// The visual-only offset a position:relative box's placement branch applied, per CSS 2.1 - /// §9.4.3 - kept separately so can back it back out for margin-collapse/ - /// sibling-placement consumers that must lay out against the box's un-offset (static) position. - /// Ported from PeachPDF's CssBox.RelativeOffsetX/Y. + /// The offset applied by position: relative (CSS 2.1 §9.4.3), kept so + /// can take it out again. /// public double RelativeOffsetX { get; set; } @@ -826,10 +820,8 @@ public string Bottom public double RelativeOffsetY { get; set; } /// - /// with any position:relative visual offset - /// backed out - the coordinate a following sibling or this box's own parent (for auto height) must - /// lay out against, since relative positioning "has no effect on the position of any other box" - /// (CSS 2.1 §9.4.3). Ported from PeachPDF's CssBox.StaticBottom. + /// without the relative offset, which following siblings and + /// the parent's auto height lay out against (CSS 2.1 §9.4.3). /// public double StaticBottom => ActualBottom - RelativeOffsetY; diff --git a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs index 4e4a21759..17f48aa41 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs @@ -169,12 +169,10 @@ public static void CreateLineBoxes(RGraphics g, CssBox blockBox) } /// - /// Applies float positioning (float: left/right) and clearance (clear) to a box that has already - /// had its static in-flow position committed by the caller () - - /// overwrites using that static position as input. No-op if the box - /// is neither floated nor has clear set. Ported from PeachPDF. + /// Moves a floated or clearing box from its static position to where 'float' and 'clear' put it + /// (CSS 2.1 §9.5.1, §9.5.2). /// - /// the box to float/clear - its Location.Y must already hold its static position + /// the box to float/clear, its Location.Y must already hold its static position public static void FloatBox(CssBox box) { if (box.Float == CssConstants.None && box.Clear == CssConstants.None) @@ -261,10 +259,8 @@ public static void ApplyCellVerticalAlignment(RGraphics g, CssBox cell) #region Private methods /// - /// Pushes down past any floated preceding sibling (at any depth - a float - /// nested inside a plain non-floated wrapper still needs clearing against, CSS 2.1 9.5.2) that - /// matches 's clear direction. Only ever changes Y, never X - X is - /// left exactly as /normal static-position assignment already set it. + /// Moves below the floats it clears, including floats nested in preceding + /// siblings (CSS 2.1 §9.5.2). Only changes Y. /// private static void ClearBox(CssBox box, int currentBoxIdx, CssBox containingBox) { @@ -279,11 +275,8 @@ private static void ClearBox(CssBox box, int currentBoxIdx, CssBox containingBox } /// - /// Recursively finds the outer (margin) bottom edge of every floated box in 's - /// subtree that matches , testing itself - /// and every descendant at every depth (not just direct children) - so a float directly inside another - /// float (e.g. ACID1's dd float:right containing blockquote/h1 float:left as direct - /// children) is never missed. + /// The lowest bottom margin edge of the floats in and its subtree that + /// clears. /// private static double GetClearance(CssBox sibling, string clearDirection) { @@ -315,13 +308,7 @@ private static void FloatBoxLeft(CssBox box, double startX, double startY, doubl MaxBottom = startY, MarginLeft = box.ActualMarginLeft, MarginRight = box.ActualMarginRight, - // FloatBox (this method's only caller) runs from PerformLayoutImp's block-width- - // resolution branch, after Size.Width is already resolved to this box's border-box - // width - use it directly rather than ActualWidth, which re-parses the Width CSS - // string against Size.Width as a percentage base (correct for the inline-block callers - // it otherwise serves, but not what a border-box reference width means here: for a - // fixed-unit Width like "34em" it returns the bare content-only value, ignoring - // Size.Width entirely, understating a padded/bordered float's true footprint). + // The resolved border box width, ActualWidth would be the content width of an explicit 'width' ReferenceWidth = box.Size.Width }; @@ -366,8 +353,6 @@ private static void FloatBoxRight(CssBox box, double startX, double startY, doub MaxBottom = startY, MarginLeft = box.ActualMarginLeft, MarginRight = box.ActualMarginRight, - // See FloatBoxLeft's matching comment - Size.Width is this box's own already-resolved - // border-box width, which is what ReferenceWidth means here. ReferenceWidth = box.Size.Width }; diff --git a/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs b/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs index 517b9dd32..2e0ca541c 100644 --- a/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs +++ b/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs @@ -242,7 +242,7 @@ private void PaintFragmentContent(RGraphics g, BoxFragment fragment) } } - // Split to match the old live-tree walk's z-order: normal flow, then absolute, then fixed. + // Normal flow, then absolute, then fixed, per parent rather than per stacking context (CSS 2.1 §9.9.1) foreach (var child in fragment.Children) { if (child.Box.Position != CssConstants.Absolute && !child.Box.IsFixed) diff --git a/Source/HtmlRenderer/Core/Utils/DomUtils.cs b/Source/HtmlRenderer/Core/Utils/DomUtils.cs index 1fb3d30df..9f8cc4238 100644 --- a/Source/HtmlRenderer/Core/Utils/DomUtils.cs +++ b/Source/HtmlRenderer/Core/Utils/DomUtils.cs @@ -129,9 +129,7 @@ public static CssBox GetPreviousSibling(CssBox b) int diff = 1; CssBox sib = b.ParentBox.Boxes[index - diff]; - // floated siblings are removed from normal flow: a subsequent sibling's static - // position must be computed as if they weren't there (CSS 2.1 9.5), so they're - // skipped exactly like display:none boxes are. + // Out-of-flow siblings don't affect the static position (CSS 2.1 §9.3) while ((sib.Display == CssConstants.None || sib.Position == CssConstants.Absolute || sib.Position == CssConstants.Fixed || sib.IsFloated) && index - diff - 1 >= 0) { sib = b.ParentBox.Boxes[index - ++diff]; @@ -232,10 +230,8 @@ public static bool IsBoxHasWhitespace(CssBox box) } /// - /// The nearest positioned ancestor (CSS 2.1 §10.1: a box whose position is anything other - /// than static) of , or the document root if none is found - the - /// containing block a position:absolute box's offsets/percentages resolve against. Ported - /// from PeachPDF's DomUtils.GetNearestPositionedAncestor. + /// The containing block of an absolutely positioned box: the nearest positioned ancestor, or the root + /// (CSS 2.1 §10.1). /// internal static CssBox GetNearestPositionedAncestor(CssBox box) { @@ -250,10 +246,8 @@ internal static CssBox GetNearestPositionedAncestor(CssBox box) } /// - /// The candidate rectangle being tested against existing floats, either during float placement - /// ('s FloatBoxLeft/FloatBoxRight) or while flowing a line's inline - /// content around already-placed floats (/ - /// ). Ported from PeachPDF. + /// The space a float is being placed in, or a line's content is flowed into, tested against the + /// floats placed before it. /// internal sealed class CssFloatCoordinates { @@ -265,24 +259,16 @@ internal sealed class CssFloatCoordinates public double MarginRight; public double ReferenceWidth; + /// Left border edge of a right float, already excludes its right margin. public double FloatRightStartX { - // Right is already margin-right-adjusted (FloatBoxRight sets Right = limitRight - - // box.ActualMarginRight), so subtracting MarginLeft here too pulled a right-floated - // box's own right edge inward by its own left margin - a box's left margin is space - // to its own left and must not affect where its own right edge lands (the left-float - // counterpart never double-applies a margin term either). Ported from PeachPDF - // (jhaygood86/PeachPDF#688). get { return Right - ReferenceWidth; } } } /// - /// Finds the first floated box that intersects for the given float - /// direction, searching in document order: climb from up to the root, - /// scanning each level's preceding siblings' whole subtrees. There is no dedicated float list - /// anywhere - this is a live tree search, ported from PeachPDF. Short-circuits immediately (no - /// walk at all) for the common case of a document with no floated boxes. + /// The first float in document order that intersects , searched in the + /// subtrees of the preceding siblings of and of each of its ancestors. /// internal static CssBox GetFirstIntersectingFloatBox(CssBox reference, CssFloatCoordinates coordinates, string floatDirection) { @@ -319,8 +305,7 @@ internal static CssBox GetLastLeftIntersectingFloatBox(CssBox box, double curx, var left = curx; CssBox lastIntersectingFloat = null; - // Bounded by a flat iteration count, matching PeachPDF: the number of distinct floats in a - // real document is always finite, and this loop's only job is to walk past each one once. + // Walks past each float once, the bound only guards against a loop var iterations = 0; while (iterations++ < 10000) { @@ -347,17 +332,8 @@ internal static CssBox GetLastLeftIntersectingFloatBox(CssBox box, double curx, } /// - /// A right float's constraint on a line is unlike a left float's: a left float caps where the - /// cursor itself currently sits (a point-collision test, correct in - /// above), but a right float caps how far right - /// the cursor is allowed to reach in advance - a lookahead, independent of the cursor's current - /// position or the word being placed. Reusing the point-collision test here (querying - /// in mode, as an - /// earlier version of this method did) can only detect the float once the cursor has already - /// walked into its span, never before - so this scans for every float:right box whose vertical - /// span covers the current row and returns the one with the smallest left edge, which is the - /// actual binding constraint regardless of where the cursor is right now. Ported from PeachPDF - /// (jhaygood86/PeachPDF#687). + /// The right float that limits the line at the most, i.e. the one with the + /// leftmost margin edge. Unlike for left floats, the current position on the line doesn't matter. /// internal static CssBox GetLastRightIntersectingFloatBox(CssBox box, double top) { @@ -368,10 +344,8 @@ internal static CssBox GetLastRightIntersectingFloatBox(CssBox box, double top) } /// - /// Same ancestor/preceding-sibling traversal shape as 's - /// walk, but accumulates the narrowest match across the whole walk instead of returning on the - /// first hit - the wrap-limit query needs the binding constraint among every float:right box - /// covering this row, not merely the first one the traversal order happens to reach. + /// Walks like , but checks every float instead of stopping + /// at the first. /// private static CssBox FindNarrowestRightFloatBox(CssBox reference, double top) { @@ -803,11 +777,6 @@ private static bool IsFloatIntersecting(CssFloatCoordinates coordinates, string return targetRight > currentLeft && targetLeft <= currentLeft; if (floatDirection == CssConstants.Right) - // Before FloatRightStartX's own fix (above), its erroneous -MarginLeft term happened - // to cancel this +MarginLeft, leaving the correct margin-box right edge - // (Right + MarginRight) by coincidence. With FloatRightStartX corrected, this leftover - // +MarginLeft must go too, or the threshold is inflated by the box's own left margin - - // ported from PeachPDF (jhaygood86/PeachPDF#688). return targetLeft > coordinates.FloatRightStartX + coordinates.ReferenceWidth + coordinates.MarginRight; return false; diff --git a/Source/Test/HtmlRenderer.IntegrationTest/FixedPositionRepeatsPerPageTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/FixedPositionRepeatsPerPageTest.cs index 1b6734598..a942cf764 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/FixedPositionRepeatsPerPageTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/FixedPositionRepeatsPerPageTest.cs @@ -9,18 +9,6 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; -/// -/// Verifies a real, spec-confirmed missing feature found while auditing this port's fragmentation engine -/// against PeachPDF a third time, then checking the actual W3C text directly -/// (css-position-3): "in paged media, the page -/// area of each page; fixed positioned boxes are thus replicated on every page", and user agents "must -/// not paginate the content of fixed-positioned boxes". A position:fixed element (a print -/// header/watermark - bottom/right anchoring is a separate, pre-existing gap: neither -/// property is parsed for absolute/fixed positioning at all, so a bottom-anchored footer, the more common -/// real print pattern, is out of scope here) previously rendered on exactly one page - wherever its -/// top/left offset happened to be interpreted as an absolute document coordinate - instead -/// of being replicated identically on every page. -/// [TestClass] [DoNotParallelize] public sealed class FixedPositionRepeatsPerPageTest @@ -42,6 +30,7 @@ private static HtmlContainerInt GetInternal(HtmlContainer wrapper) } [TestMethod] + [Spec("css21", "visuren.html#fixed-positioning")] public async Task TopLeftFixedElement_RepeatsIdenticallyOnEveryPage() { using var wrapper = new HtmlContainer(); @@ -75,12 +64,10 @@ await wrapper.SetHtml( } [TestMethod] + [Spec("css21", "visuren.html#fixed-positioning")] public async Task FixedElement_StillRendersOnce_WithoutARealPageGrid() { - // WinForms/WPF's continuous-scroll convention (no PageSize set - HasRealPageGrid=false): the - // repeat-per-page mechanism must not apply here at all, since "stays put" for that viewport is a - // paint-time scroll-offset suppression (CssBox.IsFixed), not a per-page repeat concern - confirms - // the new exclusion in FragmentEmitter is correctly gated on HasRealPageGrid. + // Continuous media, no PageSize, so no page grid to repeat on using var wrapper = new HtmlContainer(); var filler = string.Concat(Enumerable.Repeat("

      filler line of body text

      ", 60)); await wrapper.SetHtml( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs new file mode 100644 index 000000000..4143c865c --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs @@ -0,0 +1,269 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.Layout; + +[DoNotParallelize] +[TestClass] +public sealed class BoxGenerationIntegrationTests +{ + private const double Delta = 1.0; + + [TestMethod] + [Spec("css21", "visuren.html#viewport")] + public void AutoWidthBlock_FollowsTheViewportWidth() + { + var html = LayoutHarness.Wrap("
      text
      "); + + var (narrowRoot, _) = LayoutHarness.Layout(html, maxWidth: 300); + var (wideRoot, _) = LayoutHarness.Layout(html, maxWidth: 600); + + Assert.AreEqual(300, LayoutHarness.FindById(narrowRoot, "d")!.Size.Width, Delta); + Assert.AreEqual(600, LayoutHarness.FindById(wideRoot, "d")!.Size.Width, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#containing-block")] + public void ABoxWiderThanItsContainingBlock_OverflowsIt() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      ")); + + var cb = LayoutHarness.FindById(root, "cb")!; + var c = LayoutHarness.FindById(root, "c")!; + + Assert.AreEqual(cb.Location.X, c.Location.X, Delta); + Assert.AreEqual(250, c.Size.Width, Delta); + } + + [TestMethod] + [DataRow("block")] + [DataRow("list-item")] + [DataRow("table")] + [Spec("css21", "visuren.html#block-boxes")] + public void BlockLevelDisplayValues_StackVertically(string display) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + $"ab")); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(a.Location.X, b.Location.X, Delta); + Assert.IsGreaterThanOrEqualTo(a.ActualBottom - Delta, b.Location.Y); + } + + [TestMethod] + [Spec("css21", "visuren.html#anonymous-block-level")] + public void TextBeforeABlock_IsWrappedInAnAnonymousBlock() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      Some text

      More text

      ")); + + var div = LayoutHarness.FindById(root, "div")!; + var p = LayoutHarness.FindById(root, "p")!; + + Assert.HasCount(2, div.Boxes); + var anonymous = div.Boxes[0]; + Assert.IsNull(anonymous.HtmlTag); + Assert.AreEqual("block", anonymous.Display); + Assert.AreEqual(anonymous.ActualBottom, p.Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#anonymous-block-level")] + public void AnonymousBlock_InheritsInheritedPropertiesAndResetsTheOthers() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      Some text

      More text

      ")); + + var anonymous = LayoutHarness.FindById(root, "div")!.Boxes[0]; + + Assert.AreEqual(RColor.FromArgb(0, 128, 0), anonymous.ActualColor); + Assert.AreEqual(0, anonymous.ActualMarginLeft); + Assert.AreEqual(0, anonymous.ActualBorderLeftWidth); + } + + [TestMethod] + [Spec("css21", "visuren.html#anonymous-block-level")] + public void BlockInsideInline_StacksTheInlineContentAroundIt() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "

      beforeinsideafter

      ")); + + var block = LayoutHarness.FindById(root, "block")!; + var before = FindWord(root, "before")!; + var after = FindWord(root, "after")!; + + Assert.IsLessThanOrEqualTo(block.Location.Y + Delta, before.Bottom); + Assert.IsGreaterThanOrEqualTo(block.ActualBottom - Delta, after.Top); + } + + [TestMethod] + [Spec("css21", "visuren.html#anonymous-block-level")] + public void BlockInsideInline_IsNotInsetByTheInlinesBorder() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "

      beforeinsideafter

      ")); + + Assert.AreEqual(0, LayoutHarness.FindById(root, "block")!.Location.X, Delta); + } + + [TestMethod] + [DataRow("inline")] + [DataRow("inline-block")] + [Spec("css21", "visuren.html#inline-boxes")] + public void InlineLevelDisplayValues_SitOnTheSameLine(string display) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + $"
      first second
      ")); + + var first = FindWord(root, "first")!; + var second = FindWord(root, "second")!; + + Assert.AreEqual(first.Top, second.Top, 5); + Assert.IsGreaterThan(first.Right, second.Left + Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#inline-boxes")] + public void InlineTable_SitsOnTheSameLine() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      first second
      ")); + + var first = FindWord(root, "first")!; + var second = FindWord(root, "second")!; + + Assert.AreEqual(first.Top, second.Top, 5); + Assert.IsGreaterThan(first.Right, second.Left + Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#anonymous")] + public void AnonymousInlineText_InheritsTheColorButNotTheBackground() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "

      Some emphasized text

      ")); + + var p = LayoutHarness.FindById(root, "p")!; + var anonymous = p.Boxes[0]; + + Assert.IsNull(anonymous.HtmlTag); + Assert.AreEqual(RColor.FromArgb(0, 0, 255), anonymous.ActualColor); + Assert.AreEqual(0, anonymous.ActualBackgroundColor.A); + } + + [TestMethod] + [Spec("css21", "visuren.html#anonymous")] + public void CollapsibleWhiteSpaceBetweenBlocks_GeneratesNoBox() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      \n
      a
      \n
      b
      \n
      ")); + + Assert.HasCount(2, LayoutHarness.FindById(root, "d")!.Boxes); + } + + [TestMethod] + [Spec("css21", "visuren.html#display-prop")] + public void DisplayNone_GeneratesNoBoxAndTakesNoSpace() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      ")); + + Assert.AreEqual(LayoutHarness.FindById(root, "a")!.ActualBottom, LayoutHarness.FindById(root, "b")!.Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#display-prop")] + public void UnknownElement_IsInlineByDefault() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
      first second
      ")); + + Assert.AreEqual(FindWord(root, "first")!.Top, FindWord(root, "second")!.Top, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#display-prop")] + public void InlineBlock_TakesItsWidthAndHeightOnTheLine() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      first second
      ")); + + var ib = LayoutHarness.FindById(root, "ib")!; + var first = FindWord(root, "first")!; + var second = FindWord(root, "second")!; + + Assert.IsGreaterThanOrEqualTo(80 - Delta, second.Left - first.Right); + Assert.AreEqual(80, ib.ActualWidth, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#dis-pos-flo")] + public void DisplayNone_IgnoresPositionAndFloat() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "

      text

      ")); + + Assert.IsLessThan(10, FindWord(root, "text")!.Left); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#dis-pos-flo")] + public void FloatedInline_IsBlockified() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      text
      ")); + + var f = LayoutHarness.FindById(root, "f")!; + + Assert.AreEqual("block", f.Display); + Assert.AreEqual(30, f.ActualBottom - f.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#dis-pos-flo")] + public void AbsolutelyPositionedInline_IsBlockified() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      text
      ")); + + var a = LayoutHarness.FindById(root, "a")!; + + Assert.AreEqual("block", a.Display); + Assert.AreEqual(30, a.ActualBottom - a.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#dis-pos-flo")] + public void AbsolutePosition_ComputesFloatToNone() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "

      text

      ")); + + Assert.IsLessThan(10, FindWord(root, "text")!.Left); + } + + private static CssRect? FindWord(CssBox box, string text) + { + foreach (var word in box.Words) + { + if (word.Text == text) + return word; + } + + foreach (var child in box.Boxes) + { + var found = FindWord(child, text); + if (found is not null) + return found; + } + + return null; + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Layout/FloatLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Layout/FloatLayoutIntegrationTests.cs index ad0726aa7..b97f56dfa 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Layout/FloatLayoutIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Layout/FloatLayoutIntegrationTests.cs @@ -3,20 +3,6 @@ namespace HtmlRenderer.IntegrationTest.Layout; -/// -/// Ported from PeachPDF.Tests/Integration/FloatLayoutRegressionTests.cs. CSS 2.1 §9.5: a floated box is taken -/// out of normal flow and shifted to the left/right edge of its containing block; subsequent inline content -/// flows around it, and other floats stack against it rather than overlapping. §9.5.2 defines clear as -/// only clearing floats of the matching (or "both") side. -/// -/// The four perf-regression-guard cases from the source file (FloatScanCalls/FloatScanBoxVisits -/// counters on HtmlContainerInt, guarding an O(document size) vs O(1) float-scan short-circuit) are not -/// ported: this fork's HtmlContainerInt has the same HasFloatedBoxes short-circuit and float-scan -/// helpers (confirmed in DomUtils.GetFirstIntersectingFloatBox et al.), but no FloatScanCalls/ -/// FloatScanBoxVisits instrumentation to observe it through - that's an internal perf counter, not CSS -/// 2.1 behavior, so it's out of scope for this pass rather than something to add. -/// -/// [DoNotParallelize] [TestClass] public sealed class FloatLayoutIntegrationTests @@ -24,6 +10,7 @@ public sealed class FloatLayoutIntegrationTests private const double Delta = 1.0; [TestMethod] + [Spec("css21", "visuren.html#floats")] public void Float_PushesFollowingSiblingTextToTheRight() { var html = LayoutHarness.Wrap( @@ -58,6 +45,7 @@ public void WithoutFloat_SiblingTextStartsAtContainerEdge() } [TestMethod] + [Spec("css21", "visuren.html#floats")] public void Float_NarrowsAvailableWidth_SoTextWrapsToMoreLines() { const string longText = @@ -83,10 +71,9 @@ public void Float_NarrowsAvailableWidth_SoTextWrapsToMoreLines() } [TestMethod] + [Spec("css21", "visuren.html#float-position")] public void FloatLeft_WrapsBelowAFullWidthFloatRightSibling() { - // A float:left box that would overlap a previously-placed, full-width float:right sibling must wrap - // below it rather than overlapping. var html = LayoutHarness.Wrap( "
      " + "
      " @@ -102,39 +89,42 @@ public void FloatLeft_WrapsBelowAFullWidthFloatRightSibling() } [TestMethod] - public void FloatRight_InNarrowerNestedBlock_AvoidsAWiderAncestorFloatRightSibling() + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "visuren.html#float-position")] + public void FloatRight_InNarrowerNestedBlock_StaysInsideItsContainingBlock() { - // A float:right box placed inside a narrower, non-floated nested block still avoids an ancestor - // float:right sibling that sits past the nested block's own right edge. var html = LayoutHarness.Wrap( "
      " + "
      " - + "
      "); + + "
      "); var (root, _) = LayoutHarness.Layout(html); - var outerR = LayoutHarness.FindById(root, "outerR")!; + var cb = LayoutHarness.FindById(root, "cb")!; var r = LayoutHarness.FindById(root, "r")!; - Assert.AreEqual(outerR.Location.X - outerR.ActualMarginLeft, r.ActualRight, Delta); + Assert.AreEqual(cb.ActualRight, r.ActualRight, Delta); } [TestMethod] - public void FloatRight_InNarrowerNestedBlock_WithMarginLeft_StillAvoidsAWiderAncestorFloatRightSibling() + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "visuren.html#float-position")] + public void FloatRight_InNarrowerNestedBlock_WithMarginLeft_StaysInsideItsContainingBlock() { var html = LayoutHarness.Wrap( "
      " + "
      " - + "
      " + + "
      " + "
      "); var (root, _) = LayoutHarness.Layout(html); - var outerR = LayoutHarness.FindById(root, "outerR")!; + var cb = LayoutHarness.FindById(root, "cb")!; var r = LayoutHarness.FindById(root, "r")!; - Assert.AreEqual(outerR.Location.X - outerR.ActualMarginLeft, r.ActualRight, Delta); + Assert.AreEqual(cb.ActualRight, r.ActualRight, Delta); } [TestMethod] + [Spec("css21", "visuren.html#floats")] public void FloatRight_NarrowsLineWrapWidth_SoTextWrapsBeforeReachingIt() { var html = LayoutHarness.Wrap( @@ -160,6 +150,7 @@ public void FloatRight_NarrowsLineWrapWidth_SoTextWrapsBeforeReachingIt() } [TestMethod] + [Spec("css21", "visuren.html#float-position")] public void FloatRight_WithMarginLeft_StillReachesContainingBlockRightEdge() { var html = LayoutHarness.Wrap( @@ -174,6 +165,7 @@ public void FloatRight_WithMarginLeft_StillReachesContainingBlockRightEdge() } [TestMethod] + [Spec("css21", "visuren.html#floats")] public void FloatLeft_StillNarrowsLineWrapWidth_AfterTheRightFloatFix() { const string longText = @@ -211,9 +203,9 @@ public void FloatLeft_StillNarrowsLineWrapWidth_AfterTheRightFloatFix() } [TestMethod] + [Spec("css21", "visuren.html#flow-control")] public void ClearLeft_IgnoresAPrecedingFloatRightSibling() { - // clear:left only clears past float:left siblings - a float:right sibling must not push it down. var html = LayoutHarness.Wrap( "
      " + "
      " @@ -227,9 +219,9 @@ public void ClearLeft_IgnoresAPrecedingFloatRightSibling() } [TestMethod] + [Spec("css21", "visuren.html#flow-control")] public void ClearRight_IgnoresAPrecedingFloatLeftSibling() { - // Symmetric case: clear:right ignoring a float:left sibling. var html = LayoutHarness.Wrap( "
      " + "
      " @@ -242,8 +234,289 @@ public void ClearRight_IgnoresAPrecedingFloatLeftSibling() $"clear:right must not clear past a float:left sibling, was pushed to Y={cleared.Location.Y}"); } + [TestMethod] + [Spec("css21", "visuren.html#floats")] + public void FollowingBlock_FlowsAsIfTheFloatDidNotExist() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      "); + + var (root, _) = LayoutHarness.Layout(html); + var f = LayoutHarness.FindById(root, "f")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(f.Location.Y, b.Location.Y, Delta); + Assert.AreEqual(0, b.Location.X, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#floats")] + public void BlockFormattingContextRoot_DoesNotOverlapAFloat() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      "); + + var (root, _) = LayoutHarness.Layout(html); + var f = LayoutHarness.FindById(root, "f")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.IsTrue(b.Location.X >= f.ActualRight - Delta || b.Location.Y >= f.ActualBottom - Delta, + $"b at ({b.Location.X}, {b.Location.Y}) overlaps the float ending at ({f.ActualRight}, {f.ActualBottom})"); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#floats")] + public void InlineFloat_IsPlacedAtTheTopOfTheCurrentLine_AndTheEarlierTextMovesPastIt() + { + var html = LayoutHarness.Wrap( + "
      before after
      "); + + var (root, _) = LayoutHarness.Layout(html); + var f = LayoutHarness.FindById(root, "f")!; + var before = FindWordByText(root, "before")!; + + Assert.AreEqual(0, f.Location.X, Delta); + Assert.AreEqual(before.Top, f.Location.Y, Delta); + Assert.IsGreaterThanOrEqualTo(50 - Delta, before.Left); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#floats")] + public void Float_PaintsInFrontOfAnOverlappingInFlowBlock() + { + var html = PaintHarness.Wrap( + "
      " + + "
      "); + + var (_, container) = PaintHarness.Layout(html); + var rects = PaintHarness.PaintPage(container).Log.OfType().ToList(); + + var floatIndex = rects.FindIndex(c => c.Color.R == 255 && c.Color.B == 0); + var blockIndex = rects.FindIndex(c => c.Color.B == 255 && c.Color.R == 0); + + Assert.IsGreaterThanOrEqualTo(0, floatIndex); + Assert.IsGreaterThan(blockIndex, floatIndex); + } + + [TestMethod] + [Spec("css21", "visuren.html#float-position")] + public void LeftFloat_OuterEdgeTouchesTheContainingBlockEdge() + { + var html = LayoutHarness.Wrap( + "
      "); + + var (root, _) = LayoutHarness.Layout(html); + + Assert.AreEqual(30, LayoutHarness.FindById(root, "f")!.Location.X, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#float-position")] + public void SuccessiveLeftFloats_StackSideBySide() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      "); + + var (root, _) = LayoutHarness.Layout(html); + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(a.Location.Y, b.Location.Y, Delta); + Assert.AreEqual(a.ActualRight + 5 + 7, b.Location.X, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#float-position")] + public void SuccessiveRightFloats_StackSideBySide() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      "); + + var (root, _) = LayoutHarness.Layout(html); + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(250, a.Location.X, Delta); + Assert.AreEqual(a.Location.Y, b.Location.Y, Delta); + Assert.AreEqual(a.Location.X, b.ActualRight, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#float-position")] + public void LeftFloatThatDoesNotFit_MovesBelowTheEarlierLeftFloat() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      "); + + var (root, _) = LayoutHarness.Layout(html); + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(0, b.Location.X, Delta); + Assert.AreEqual(a.ActualBottom, b.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#float-position")] + public void FloatThatDoesNotFit_IsPlacedAsHighAsPossible() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      " + + "
      "); + + var (root, _) = LayoutHarness.Layout(html); + var b = LayoutHarness.FindById(root, "b")!; + var c = LayoutHarness.FindById(root, "c")!; + + Assert.AreEqual(b.ActualBottom, c.Location.Y, Delta); + Assert.AreEqual(50, c.Location.X, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#float-position")] + public void Float_IsNotHigherThanAnEarlierBlock() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      "); + + var (root, _) = LayoutHarness.Layout(html); + + Assert.AreEqual(LayoutHarness.FindById(root, "a")!.ActualBottom, LayoutHarness.FindById(root, "f")!.Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#float-position")] + public void Float_IsNotHigherThanAnEarlierLineBox() + { + var html = LayoutHarness.Wrap( + "
      " + + "

      text

      " + + "
      "); + + var (root, _) = LayoutHarness.Layout(html); + + Assert.IsGreaterThanOrEqualTo(FindWordByText(root, "text")!.Bottom - Delta, LayoutHarness.FindById(root, "f")!.Location.Y); + } + + [TestMethod] + [Spec("css21", "visuren.html#flow-control")] + public void ClearBoth_MovesTheBoxBelowLeftAndRightFloats() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      " + + "
      "); + + var (root, _) = LayoutHarness.Layout(html); + + Assert.AreEqual(70, LayoutHarness.FindById(root, "c")!.Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#flow-control")] + public void Clearance_PutsTheBorderEdgeAtTheFloatsBottomOuterEdge() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      " + + "
      "); + + var (root, _) = LayoutHarness.Layout(html); + var b1 = LayoutHarness.FindById(root, "b1")!; + + Assert.AreEqual(b1.ActualBottom + 10 + 50 + 5, LayoutHarness.FindById(root, "b2")!.Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#flow-control")] + public void NegativeClearance_KeepsTheHypotheticalPositionWhenAlreadyPastTheFloat() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      " + + "
      "); + + var (root, _) = LayoutHarness.Layout(html); + var p1 = LayoutHarness.FindById(root, "p1")!; + + Assert.AreEqual(p1.ActualBottom + 40 + 20, LayoutHarness.FindById(root, "p3")!.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#flow-control")] + public void ClearOnAFloat_MovesItBelowEarlierFloats() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      "); + + var (root, _) = LayoutHarness.Layout(html); + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(0, b.Location.X, Delta); + Assert.AreEqual(a.ActualBottom, b.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#flow-control")] + public void Clear_IgnoresFloatsInOtherBlockFormattingContexts() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      "); + + var (root, _) = LayoutHarness.Layout(html); + + Assert.AreEqual(10, LayoutHarness.FindById(root, "c")!.Location.Y, Delta); + } + // ── Helpers ──────────────────────────────────────────────────────────── + private static CssRect? FindWordByText(CssBox box, string text) + { + foreach (var word in box.Words) + { + if (word.Text == text) + return word; + } + + foreach (var child in box.Boxes) + { + var found = FindWordByText(child, text); + if (found is not null) + return found; + } + + return null; + } + private static CssRect? FindFirstWord(CssBox box) { if (box.Words.Count > 0) return box.Words[0]; diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Layout/NormalFlowIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Layout/NormalFlowIntegrationTests.cs new file mode 100644 index 000000000..9c7152ed1 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Layout/NormalFlowIntegrationTests.cs @@ -0,0 +1,161 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.Layout; + +[DoNotParallelize] +[TestClass] +public sealed class NormalFlowIntegrationTests +{ + private const double Delta = 1.0; + + [TestMethod] + [Spec("css21", "visuren.html#block-formatting")] + public void Blocks_AreLaidOutOneAfterTheOtherFromTheTop() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      ")); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(5, a.Location.Y, Delta); + Assert.AreEqual(a.ActualBottom, b.Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#block-formatting")] + public void ABlockNextToAFloat_StillTouchesTheContainingBlocksLeftEdge() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      text
      ")); + + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(0, b.Location.X, Delta); + Assert.AreEqual(300, b.Size.Width, Delta); + Assert.IsGreaterThanOrEqualTo(100 - Delta, FindWord(root, "text")!.Left); + } + + [TestMethod] + [Spec("css21", "visuren.html#block-formatting")] + public void FloatsInsideAnInlineBlock_DoNotShortenLinesOutsideIt() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "

      text

      ")); + + Assert.IsLessThan(10, FindWord(root, "text")!.Left); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#block-formatting")] + public void FloatsInsideAnOverflowHiddenBlock_DoNotShortenLinesOutsideIt() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "

      text

      ")); + + Assert.IsLessThan(10, FindWord(root, "text")!.Left); + } + + [TestMethod] + [Spec("css21", "visuren.html#inline-formatting")] + public void InlineBoxes_AreLaidOutHorizontallyRespectingMarginsBordersAndPadding() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      firstsecond
      ")); + + var first = FindWord(root, "first")!; + var second = FindWord(root, "second")!; + + Assert.AreEqual(first.Top, second.Top, Delta); + Assert.AreEqual(first.Right + 22, second.Left, 2); + } + + [TestMethod] + [Spec("css21", "visuren.html#inline-formatting")] + public void ContentThatDoesNotFit_IsDistributedOverStackedLineBoxes() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      aaaa bbbb cccc dddd
      ")); + + var lines = LayoutHarness.FindById(root, "d")!.LineBoxes; + + Assert.IsGreaterThan(1, lines.Count); + for (var i = 1; i < lines.Count; i++) + { + Assert.AreEqual(lines[i - 1].LineBottom, lines[i].LineTop, Delta); + } + } + + [TestMethod] + [Spec("css21", "visuren.html#inline-formatting")] + public void AnUnbreakableWord_OverflowsTheLineBox() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      aaaaaaaaaaaaaaaaaaaa
      ")); + + var word = FindWord(root, "aaaaaaaaaaaaaaaaaaaa")!; + + Assert.AreEqual(0, word.Left, Delta); + Assert.IsGreaterThan(40, word.Right); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#inline-formatting")] + public void AnUnbreakableWord_StaysOnTheFirstLine() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      aaaaaaaaaaaaaaaaaaaa
      ")); + + var d = LayoutHarness.FindById(root, "d")!; + + Assert.AreEqual(d.Location.Y, FindWord(root, "aaaaaaaaaaaaaaaaaaaa")!.Top, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#inline-formatting")] + public void ASplitInlineBox_HasNoPaddingWhereItIsSplit() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      aaaa bbbb cccc dddd eeee ffff
      ")); + + var d = LayoutHarness.FindById(root, "d")!; + Assert.IsGreaterThan(1, d.LineBoxes.Count); + + var secondLineFirstWord = d.LineBoxes[1].Words[0]; + Assert.AreEqual(0, secondLineFirstWord.Left, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#inline-formatting")] + public void ALineBoxWithoutContent_HasZeroHeight() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      ")); + + var d = LayoutHarness.FindById(root, "d")!; + + Assert.AreEqual(0, d.ActualBottom - d.Location.Y, Delta); + } + + private static CssRect? FindWord(CssBox box, string text) + { + foreach (var word in box.Words) + { + if (word.Text == text) + return word; + } + + foreach (var child in box.Boxes) + { + var found = FindWord(child, text); + if (found is not null) + return found; + } + + return null; + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs index 1e5b377d8..b6c76a750 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs @@ -3,23 +3,62 @@ namespace HtmlRenderer.IntegrationTest.Painting; -/// -/// Ported from PeachPDF.Tests' Acid2FeatureVerificationTests.cs (the z-index/stacking-context section). -/// CSS 2.1 §9.9/Appendix E: a positioned box's z-index establishes a stacking context, and boxes in a -/// higher stacking context paint after (on top of) boxes in a lower one, regardless of document/source order - -/// e.g. a position:relative; z-index:2 box must paint over a position:fixed box declared later -/// in the document. -/// [DoNotParallelize] [TestClass] public sealed class StackingContextTests { - [Ignore("z-index and stacking-context paint order aren't implemented yet, boxes paint in document order")] [TestMethod] + [Spec("css21", "visuren.html#z-index")] + public void AbsolutelyPositionedBox_PaintsAfterALaterInFlowBlock() + { + var html = PaintHarness.Wrap( + "
      " + + "
      "); + + Assert.IsGreaterThan(PaintIndex(html, 0, 0, 255), PaintIndex(html, 255, 0, 0)); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#z-index")] + public void RelativelyPositionedBox_PaintsAfterALaterInFlowBlock() + { + var html = PaintHarness.Wrap( + "
      " + + "
      "); + + Assert.IsGreaterThan(PaintIndex(html, 0, 0, 255), PaintIndex(html, 255, 0, 0)); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#z-index")] + public void HigherZIndex_PaintsInFrontRegardlessOfDocumentOrder() + { + var html = PaintHarness.Wrap( + "
      " + + "
      "); + + Assert.IsGreaterThan(PaintIndex(html, 0, 0, 255), PaintIndex(html, 255, 0, 0)); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#z-index")] + public void NegativeZIndex_PaintsBehindInFlowBlocks() + { + var html = PaintHarness.Wrap( + "
      " + + "
      "); + + Assert.IsLessThan(PaintIndex(html, 0, 0, 255), PaintIndex(html, 255, 0, 0)); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#z-index")] public void PositionedZIndex_PaintsOverFixedPositionedContent() { - // The white z-index:2 box must paint on top of the black fixed bar, even though the painter - // currently draws the fixed bar after the normal flow. var html = LayoutHarness.Wrap( "
      " + "
      "); @@ -36,4 +75,14 @@ public void PositionedZIndex_PaintsOverFixedPositionedContent() Assert.IsTrue(fixedBarPaintIndex < introPaintIndex, "the z-index:2 box must paint after (on top of) the fixed bar, regardless of document order"); } + + private static int PaintIndex(string html, int r, int g, int b) + { + var (_, container) = PaintHarness.Layout(html); + var rects = PaintHarness.PaintPage(container).Log.OfType().ToList(); + var index = rects.FindIndex(c => c.Color.R == r && c.Color.G == g && c.Color.B == b); + + Assert.IsGreaterThanOrEqualTo(0, index); + return index; + } } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/AbsolutePositioningIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/AbsolutePositioningIntegrationTests.cs index 83235affb..3315e7389 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/AbsolutePositioningIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/AbsolutePositioningIntegrationTests.cs @@ -2,21 +2,6 @@ namespace HtmlRenderer.IntegrationTest.Positioning; -/// -/// Ported from PeachPDF.Tests' Acid2FeatureVerificationTests.cs (the position:relative/absolute/fixed offset -/// section) and AbsolutePositioningIntegrationTests.cs's shrink-to-fit cases. CSS 2.1 §9.4.3: for each axis of -/// a relatively/absolutely positioned box, the "near" offset (left/top) wins when set; if it's -/// auto and the "far" offset (right/bottom) isn't, the far offset applies with its sign -/// flipped. §9.4.3 also requires relative positioning to be purely visual - it must not affect the parent's -/// content-driven height or any following sibling's layout. §10.3.7: an absolutely positioned box's offsets -/// are measured from its nearest positioned ancestor's PADDING edge, and (like every other positioning scheme) -/// its own margin still applies on top of that offset; with no explicit width, it shrinks to fit its content. -/// -/// The PeachPDF source file's flexbox/grid blockification cases and detached-<thead>/<tfoot> -/// containing-block cases are not ported: this fork has neither a flex/grid layout engine nor the notion of a -/// detached header/footer proxy box those target. -/// -/// [DoNotParallelize] [TestClass] public sealed class AbsolutePositioningIntegrationTests @@ -24,22 +9,20 @@ public sealed class AbsolutePositioningIntegrationTests private const double Delta = 1.0; [TestMethod] + [Spec("css21", "visuren.html#relative-positioning")] public void PositionRelative_BottomOffset_MovesBoxOppositeDirection() { - // top is auto, bottom is set - a positive "bottom" pulls the box UP, i.e. subtracts from Y. var html = LayoutHarness.Wrap("
      "); var (root, _) = LayoutHarness.Layout(html); var box = LayoutHarness.FindById(root, "t")!; - // Static-flow position is Y=0 (LayoutHarness.Wrap sets body margin:0); "bottom:10px" must move it to Y=-10. Assert.AreEqual(-10, box.Location.Y, Delta); } [TestMethod] + [Spec("css21", "visuren.html#relative-positioning")] public void PositionRelative_Offset_DoesNotAffectParentHeightOrFollowingSibling() { - // The offset box (and its descendants) move, but the parent's content-driven height and every - // following sibling must lay out against the STATIC position. var html = LayoutHarness.Wrap( "
      " + "
      " @@ -50,21 +33,16 @@ public void PositionRelative_Offset_DoesNotAffectParentHeightOrFollowingSibling( var shifted = LayoutHarness.FindById(root, "shifted")!; var after = LayoutHarness.FindById(root, "after")!; - // The offset itself is applied visually: the child sits 30px below the parent's top... Assert.AreEqual(30, shifted.Location.Y - parent.Location.Y, Delta); - - // ...but the parent is still exactly 40px tall (the child's static extent)... Assert.AreEqual(40, parent.ActualBottom - parent.Location.Y, Delta); - - // ...and the following sibling starts at the parent's un-inflated bottom. Assert.AreEqual(parent.ActualBottom, after.Location.Y, Delta); } [TestMethod] + [Spec("css21", "visuren.html#relative-positioning")] public void PositionRelative_WithAForcedBreakInside_AppliesTheOffsetOnce() { - // The forced break makes a later layout pass resume inside the relative box, which must not add - // its offset a second time. + // The forced break makes a later layout pass resume inside the relative box var (root, _) = PaintHarness.LayoutPaginated(PaintHarness.Wrap( "

      a

      " + "
      " @@ -78,9 +56,9 @@ public void PositionRelative_WithAForcedBreakInside_AppliesTheOffsetOnce() } [TestMethod] + [Spec("css21", "visuren.html#relative-positioning")] public void PositionRelative_OffsetOnBoxItself_DoesNotShiftFollowingSibling() { - // "after" must lay out against "shifted"'s static bottom, not its visually offset bottom 25px lower. var html = LayoutHarness.Wrap( "
      " + "
      "); @@ -93,6 +71,7 @@ public void PositionRelative_OffsetOnBoxItself_DoesNotShiftFollowingSibling() } [TestMethod] + [Spec("css21", "visuren.html#position-props")] public void PositionAbsolute_BottomOffset_PositionsRelativeToContainingBlockBottomEdge() { var html = LayoutHarness.Wrap( @@ -102,11 +81,11 @@ public void PositionAbsolute_BottomOffset_PositionsRelativeToContainingBlockBott var cb = LayoutHarness.FindById(root, "cb")!; var box = LayoutHarness.FindById(root, "t")!; - // Box's bottom edge must sit 10px above the containing block's own bottom (padding) edge. Assert.AreEqual(cb.ActualBottom - 10, box.ActualBottom, Delta); } [TestMethod] + [Spec("css21", "visuren.html#position-props")] public void PositionAbsolute_WithMarginAndBorderedContainingBlock_AppliesBothCorrectly() { var html = LayoutHarness.Wrap( @@ -116,12 +95,12 @@ public void PositionAbsolute_WithMarginAndBorderedContainingBlock_AppliesBothCor var cb = LayoutHarness.FindById(root, "cb")!; var box = LayoutHarness.FindById(root, "t")!; - // Expected: containing block's PADDING edge (border-box + 16px border) + the box's own margin. Assert.AreEqual(cb.Location.X + 16 + 60, box.Location.X, Delta); Assert.AreEqual(cb.Location.Y + 16 + 36, box.Location.Y, Delta); } [TestMethod] + [Spec("css21", "visuren.html#fixed-positioning")] public void PositionFixed_WithMargin_AppliesMarginOnTopOfOffset() { var html = LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/FixedPositionPaginationIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/FixedPositionPaginationIntegrationTests.cs index 4edae4c5a..3fed3576f 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/FixedPositionPaginationIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/FixedPositionPaginationIntegrationTests.cs @@ -4,49 +4,18 @@ namespace HtmlRenderer.IntegrationTest.Positioning; -/// -/// Port of PeachPDF.Tests' FixedPositionPaginationIntegrationTests, which confirms position: fixed -/// content repeats identically across real, multi-page PdfGenerator output - the "running header/footer" -/// mechanic, verified there by scanning generated PDF content streams for the fixed rect appearing on every one -/// of 3 real pages produced via page-break-before: always. -/// -/// -/// This fork has no PDF-generation pipeline and no real multi-page concept in Core at all - confirmed by direct -/// source read: there is no PageBreakBefore/PageBreakAfter property anywhere (only -/// PageBreakInside, on CssBoxProperties), and the default stylesheet's own -/// h1 { page-break-before: always } etc. rules (Core/CssDefaults.cs ~102-105) are inert - there is -/// no consuming property/code path for them, so they are silently dropped by the cascade. Content instead flows -/// continuously down one (possibly very tall) canvas, and "scrolling" is the one real, working mechanism this -/// fork has for changing what's visible without re-laying-out: HtmlContainerInt.ScrollOffset -/// (Core/HtmlContainerInt.cs ~358-362). -/// -/// The actual feature under test - a fixed box staying visually anchored while everything else moves - IS real -/// and working here: CssBox.PaintImp (Core/Dom/CssBox.cs ~1230-1234) only applies -/// HtmlContainer.ScrollOffset to a box's painted rectangle when !IsFixed; a fixed box's own -/// Location is resolved once, directly from its top/left CSS values against the page size -/// (CssBoxProperties.Left/Top setters, ~412-438, call GetActualLocation immediately whenever -/// Position == Fixed), independent of layout flow and of ScrollOffset. So the closest faithful -/// adaptation of "repeats identically on every page" is: paint the same laid-out tree at different -/// ScrollOffset values (standing in for different simulated "pages") and confirm the fixed box's painted -/// position never moves while an ordinary box's does, by exactly the scroll delta. -/// [DoNotParallelize] [TestClass] public sealed class FixedPositionPaginationIntegrationTests { [TestMethod] + [Spec("css21", "visuren.html#fixed-positioning")] public void FixedPositionedBox_PaintPosition_IsInvariantToScrollOffset_WhileNormalContentShifts() { - // NOTE: uses the "background-color" longhand, not the "background" shorthand - this fork's - // AddProperty/CssUtils dispatch has no case for the "background" shorthand key at all (confirmed by - // source grep), so it falls through to being stored verbatim under the literal key "background", - // which nothing ever reads - the box's real ActualBackgroundColor stays fully transparent and paints - // no DrawRectCall at all, which is what caused this test to fail before this fix. var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( "
      " + "
      ")); - // Sanity: the fixed box really did resolve its own viewport-relative position, independent of flow. var fixedBox = PaintHarness.FindById(root, "fixedBox")!; Assert.AreEqual(10.0, fixedBox.Location.X, 0.5); Assert.AreEqual(10.0, fixedBox.Location.Y, 0.5); @@ -56,19 +25,13 @@ public void FixedPositionedBox_PaintPosition_IsInvariantToScrollOffset_WhileNorm var fixedRect0 = g0.Log.OfType().Single(r => r.Color == RColor.FromArgb(12, 34, 56)); var normalRect0 = g0.Log.OfType().Single(r => r.Color == RColor.FromArgb(65, 43, 21)); - // Stand in for "the next simulated page" - a large scroll offset, the way a real page 2/3 would shift - // the visible band of one tall continuous canvas in this fork's model. container.ScrollOffset = new RPoint(0, 500); var g1 = PaintHarness.PaintBox(container, root); var fixedRect1 = g1.Log.OfType().Single(r => r.Color == RColor.FromArgb(12, 34, 56)); var normalRect1 = g1.Log.OfType().Single(r => r.Color == RColor.FromArgb(65, 43, 21)); - // position:fixed content repaints at the same screen position regardless of ScrollOffset - the real - // mechanism behind PeachPDF's "repeats identically on every page". Assert.AreEqual(fixedRect0.X, fixedRect1.X, 0.5); Assert.AreEqual(fixedRect0.Y, fixedRect1.Y, 0.5); - - // Ordinary in-flow content visually scrolls: its painted position shifts by exactly the scroll delta. Assert.AreEqual(normalRect0.Y + 500, normalRect1.Y, 0.5); } @@ -91,8 +54,6 @@ public void PageBreakBefore_PushesFollowingContentToTheNextSimulatedPage() var p2 = PaintHarness.FindById(root, "p2")!; var p3 = PaintHarness.FindById(root, "p3")!; - // What honoring page-break-before would look like: each paragraph starts at the top of its own page - // band, not simply stacked directly below its predecessor. Assert.IsTrue(p2.Location.Y > p1.ActualBottom + 100, "expected page-break-before to push p2 onto a new page"); Assert.IsTrue(p3.Location.Y > p2.ActualBottom + 100, "expected page-break-before to push p3 onto a new page"); } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/OutOfFlowPositioningIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/OutOfFlowPositioningIntegrationTests.cs new file mode 100644 index 000000000..352329b41 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/OutOfFlowPositioningIntegrationTests.cs @@ -0,0 +1,157 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.Positioning; + +[DoNotParallelize] +[TestClass] +public sealed class OutOfFlowPositioningIntegrationTests +{ + private const double Delta = 1.0; + + [TestMethod] + [Spec("css21", "visuren.html#absolute-positioning")] + public void PositionAbsolute_HasNoImpactOnLaterSiblings() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      " + + "
      "); + var (root, _) = LayoutHarness.Layout(html); + + Assert.AreEqual(LayoutHarness.FindById(root, "a")!.ActualBottom, LayoutHarness.FindById(root, "b")!.Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "box.html#collapsing-margins")] + public void PositionAbsolute_MarginsDoNotCollapse() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      "); + var (root, _) = LayoutHarness.Layout(html); + var cb = LayoutHarness.FindById(root, "cb")!; + var t = LayoutHarness.FindById(root, "t")!; + var c = LayoutHarness.FindById(root, "c")!; + + Assert.AreEqual(cb.Location.Y + 20, t.Location.Y, Delta); + Assert.AreEqual(t.Location.Y + 30, c.Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#absolute-positioning")] + public void PositionAbsolute_EstablishesTheContainingBlockForAbsoluteDescendants() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      "); + var (root, _) = LayoutHarness.Layout(html); + + var inner = LayoutHarness.FindById(root, "inner")!; + Assert.AreEqual(80, inner.Location.X, Delta); + Assert.AreEqual(60, inner.Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#absolute-positioning")] + public void PositionAbsolute_WithoutPositionedAncestor_UsesTheInitialContainingBlock() + { + var html = "
      " + + "
      "; + var (root, _) = LayoutHarness.Layout(html); + + var t = LayoutHarness.FindById(root, "t")!; + Assert.AreEqual(7, t.Location.X, Delta); + Assert.AreEqual(5, t.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#absolute-positioning")] + public void PositionAbsolute_ContentDoesNotFlowAroundOutsideFloats() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      " + + "
      text
      "); + var (root, _) = LayoutHarness.Layout(html); + + Assert.IsLessThan(10, FindWord(root, "text")!.Left); + } + + [TestMethod] + [Spec("css21", "visuren.html#position-props")] + public void PositionAbsolute_RightOffset_PositionsRelativeToContainingBlockRightEdge() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      "); + var (root, _) = LayoutHarness.Layout(html); + var cb = LayoutHarness.FindById(root, "cb")!; + var t = LayoutHarness.FindById(root, "t")!; + + Assert.AreEqual(cb.Location.X + 5 + 100 - 10, t.ActualRight, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#position-props")] + public void PositionAbsolute_PercentageOffsets_ReferToTheContainingBlock() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      "); + var (root, _) = LayoutHarness.Layout(html); + var cb = LayoutHarness.FindById(root, "cb")!; + var t = LayoutHarness.FindById(root, "t")!; + + Assert.AreEqual(cb.Location.X + 20, t.Location.X, Delta); + Assert.AreEqual(cb.Location.Y + 40, t.Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#fixed-positioning")] + public void PositionFixed_IgnoresPositionedAncestors() + { + var html = LayoutHarness.Wrap( + "
      " + + "
      "); + var (root, _) = LayoutHarness.Layout(html); + var t = LayoutHarness.FindById(root, "t")!; + + Assert.AreEqual(20, t.Location.X, Delta); + Assert.AreEqual(10, t.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#fixed-positioning")] + public void PositionFixed_RightAndBottom_AnchorToTheViewport() + { + var html = LayoutHarness.Wrap("
      "); + var (root, _) = LayoutHarness.Layout(html, maxWidth: 500, maxHeight: 600); + var t = LayoutHarness.FindById(root, "t")!; + + Assert.AreEqual(500 - 10, t.ActualRight, Delta); + Assert.AreEqual(600 - 20, t.ActualBottom, Delta); + } + + private static CssRect? FindWord(CssBox box, string text) + { + foreach (var word in box.Words) + { + if (word.Text == text) + return word; + } + + foreach (var child in box.Boxes) + { + var found = FindWord(child, text); + if (found is not null) + return found; + } + + return null; + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/RelativePositioningIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/RelativePositioningIntegrationTests.cs new file mode 100644 index 000000000..3ccb0bbc4 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/RelativePositioningIntegrationTests.cs @@ -0,0 +1,159 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.Positioning; + +[DoNotParallelize] +[TestClass] +public sealed class RelativePositioningIntegrationTests +{ + private const double Delta = 1.0; + + [TestMethod] + [Spec("css21", "visuren.html#choose-position")] + public void PositionStatic_IgnoresTheBoxOffsets() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      ")); + + var t = LayoutHarness.FindById(root, "t")!; + + Assert.AreEqual(0, t.Location.X, Delta); + Assert.AreEqual(0, t.Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#relative-positioning")] + public void LeftAndTop_MoveTheBoxRightAndDown() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      ")); + + var t = LayoutHarness.FindById(root, "t")!; + + Assert.AreEqual(15, t.Location.X, Delta); + Assert.AreEqual(25, t.Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#relative-positioning")] + public void RightAlone_MovesTheBoxLeft() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      ")); + + Assert.AreEqual(35, LayoutHarness.FindById(root, "t")!.Location.X, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#relative-positioning")] + public void LeftAndRight_LeftWinsInLtr() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      ")); + + Assert.AreEqual(15, LayoutHarness.FindById(root, "t")!.Location.X, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#relative-positioning")] + public void LeftAndRight_RightWinsInRtl() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      ")); + + Assert.AreEqual(10, LayoutHarness.FindById(root, "t")!.Location.X, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#relative-positioning")] + public void TopAndBottom_TopWins() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      ")); + + Assert.AreEqual(15, LayoutHarness.FindById(root, "t")!.Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#relative-positioning")] + public void RelativeOffset_KeepsTheBoxSize() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      ")); + + Assert.AreEqual(200, LayoutHarness.FindById(root, "t")!.Size.Width, Delta); + } + + [TestMethod] + [Spec("css21", "visuren.html#position-props")] + public void NegativeOffsets_AreAllowed() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      ")); + + var t = LayoutHarness.FindById(root, "t")!; + + Assert.AreEqual(30, t.Location.X, Delta); + Assert.AreEqual(20, t.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#position-props")] + public void PercentageLeft_RefersToTheContainingBlockWidth() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      ")); + + Assert.AreEqual(40, LayoutHarness.FindById(root, "t")!.Location.X, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#position-props")] + public void PercentageTop_RefersToTheContainingBlockHeight() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      ")); + + Assert.AreEqual(40, LayoutHarness.FindById(root, "t")!.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#relative-positioning")] + public void RelativeInline_MovesItsText() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      first second
      " + + "
      first2 second2
      ")); + + var moved = FindWord(root, "second")!; + var reference = FindWord(root, "second2")!; + var first = FindWord(root, "first")!; + var first2 = FindWord(root, "first2")!; + + Assert.AreEqual(10, (moved.Top - first.Top) - (reference.Top - first2.Top), Delta); + Assert.AreEqual(20, (moved.Left - first.Right) - (reference.Left - first2.Right), Delta); + } + + private static CssRect? FindWord(CssBox box, string text) + { + foreach (var word in box.Words) + { + if (word.Text == text) + return word; + } + + foreach (var child in box.Boxes) + { + var found = FindWord(child, text); + if (found is not null) + return found; + } + + return null; + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/DirectionIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/DirectionIntegrationTests.cs new file mode 100644 index 000000000..f0f4dc34c --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/DirectionIntegrationTests.cs @@ -0,0 +1,71 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.Text; + +[DoNotParallelize] +[TestClass] +public sealed class DirectionIntegrationTests +{ + [TestMethod] + [Spec("css21", "visuren.html#direction")] + public void Direction_IsInherited() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

      text

      ")); + + Assert.AreEqual("rtl", LayoutHarness.FindById(root, "p")!.Direction); + } + + [TestMethod] + [Spec("css21", "visuren.html#direction")] + public void RightToLeftWords_InAnRtlBlock_RunFromRightToLeft() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      אבג דהו
      ")); + + var first = FindWord(root, "אבג")!; + var second = FindWord(root, "דהו")!; + + Assert.IsGreaterThan(second.Left, first.Left); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#direction")] + public void LeftToRightWords_InAnRtlBlock_KeepTheirOrder() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      one two
      ")); + + Assert.IsLessThan(FindWord(root, "two")!.Left, FindWord(root, "one")!.Left); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visuren.html#direction")] + public void DirectionOnAnInline_WithoutUnicodeBidi_DoesNotReorder() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
      one two three four
      ")); + + Assert.IsLessThan(FindWord(root, "three")!.Left, FindWord(root, "two")!.Left); + } + + private static CssRect? FindWord(CssBox box, string text) + { + foreach (var word in box.Words) + { + if (word.Text == text) + return word; + } + + foreach (var child in box.Boxes) + { + var found = FindWord(child, text); + if (found is not null) + return found; + } + + return null; + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/FloatPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/FloatPropertyTests.cs index 83594b7df..6e5f8a7a0 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FloatPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FloatPropertyTests.cs @@ -2,17 +2,6 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/FloatClearProperty.cs. -/// HTML-Renderer does have a `clear` CSS property (CssEngine.ClearProperty, CssBoxProperties.Clear) - -/// the original note claiming otherwise predates that property being added. Both the "invalid keyword" -/// float and clear cases are dropped: TheArtOfDev.HtmlRenderer.Core.Parse.CssParser does not validate -/// `float`/`clear` values against a keyword set, and CssUtils.SetPropertyValue assigns whatever string -/// was parsed straight to CssBox.Float/CssBox.Clear with no rejection path, so there is no "illegal -/// keyword" outcome to observe in this fork. -/// Exercised via the real box tree (LayoutHarness + inline style) rather than raw property parsing, so -/// the assertion is against the actual CssBoxProperties.Float/Clear value a laid-out box ends up with. -/// [TestClass] public sealed class FloatPropertyTests { @@ -20,6 +9,7 @@ public sealed class FloatPropertyTests [DataRow("left")] [DataRow("right")] [DataRow("none")] + [Spec("css21", "visuren.html#float-position")] public void FloatKeywordLegal_SetsBoxFloat(string keyword) { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap($"
      content
      ")); @@ -35,6 +25,7 @@ public void FloatKeywordLegal_SetsBoxFloat(string keyword) [DataRow("right")] [DataRow("both")] [DataRow("none")] + [Spec("css21", "visuren.html#flow-control")] public void ClearKeywordLegal_SetsBoxClear(string keyword) { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap($"
      content
      ")); diff --git a/Spec/css21.yml b/Spec/css21.yml index 5c55d7ef0..020ab2c6a 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -343,100 +343,127 @@ sections: - A split inline box in RTL text gets its left border on the first line and its right border on the last, like in LTR visuren.html#q9.0: title: 9 Visual formatting model - status: unknown + status: partial visuren.html#visual-model-intro: title: 9.1 Introduction to the visual formatting model - status: unknown + status: supported visuren.html#viewport: title: 9.1.1 The viewport - status: unknown + status: supported visuren.html#containing-block: title: 9.1.2 Containing blocks - status: unknown + status: supported visuren.html#box-gen: title: 9.2 Controlling box generation - status: unknown + status: partial visuren.html#block-boxes: title: 9.2.1 Block-level elements and block boxes - status: unknown + status: supported visuren.html#anonymous-block-level: title: 9.2.1.1 Anonymous block boxes - status: unknown + status: partial + gaps: + - The first display:none child of an inline-block is moved out of it, next to the anonymous block box around the inline-block visuren.html#inline-boxes: title: 9.2.2 Inline-level elements and inline boxes - status: unknown + status: partial + gaps: + - "'display: inline-table' is laid out like a block, on a line of its own" visuren.html#anonymous: title: 9.2.2.1 Anonymous inline boxes - status: unknown + status: supported visuren.html#run-in: title: 9.2.3 Run-in boxes - status: unknown + status: n/a visuren.html#display-prop: title: 9.2.4 The 'display' property - status: unknown + status: supported visuren.html#positioning-scheme: title: 9.3 Positioning schemes - status: unknown + status: partial visuren.html#choose-position: title: 9.3.1 Choosing a positioning scheme - status: unknown + status: supported visuren.html#position-props: title: 9.3.2 Box offsets - status: unknown + status: partial + gaps: + - Percentage offsets of a relatively positioned box resolve against its own width or height instead of the containing block's visuren.html#normal-flow: title: 9.4 Normal flow - status: unknown + status: partial visuren.html#block-formatting: title: 9.4.1 Block formatting contexts - status: unknown + status: partial + gaps: + - Floats inside a block with overflow other than visible still shorten the line boxes after it + - A block after a sibling with a bottom border is placed that border's width too low visuren.html#inline-formatting: title: 9.4.2 Inline formatting contexts - status: unknown + status: partial + gaps: + - The start padding, border and margin of an inline box are repeated at the start of every line it's split onto + - A word wider than the line box starts a new line, leaving an empty line above it visuren.html#relative-positioning: title: 9.4.3 Relative positioning - status: unknown + status: partial + gaps: + - Relative positioning of inline boxes is ignored + - When 'left' and 'right' are both set, 'left' wins even if the containing block is rtl visuren.html#floats: title: 9.5 Floats - status: unknown + status: partial + gaps: + - A float inside a line, e.g. a floated span, isn't floated and stays in the line + - A box that establishes a new block formatting context overlaps preceding floats + - Floats paint in document order, so a later in-flow block's background covers them visuren.html#float-position: title: 9.5.1 Positioning the float - status: unknown + status: partial + gaps: + - Successive right floats overlap instead of stacking side by side + - A float that doesn't fit next to the previous ones moves below the lowest of them instead of as high as possible + - A right float in a block narrower than its block formatting context is placed against an earlier right float outside that block, past its containing block's right edge visuren.html#flow-control: title: 9.5.2 Controlling flow next to floats - status: unknown + status: partial + gaps: + - "'clear' on a float moves it down but keeps it next to the floats it clears instead of at the containing block edge" + - "'clear' also clears floats inside earlier boxes that establish their own block formatting context" visuren.html#absolute-positioning: title: 9.6 Absolute positioning - status: unknown + status: partial + gaps: + - The content of an absolutely positioned box flows around floats outside of it visuren.html#fixed-positioning: title: 9.6.1 Fixed positioning - status: unknown + status: partial + gaps: + - "'right' and 'bottom' are ignored, a fixed box is always placed by 'left' and 'top'" visuren.html#dis-pos-flo: title: 9.7 Relationships between 'display', 'position', and 'float' - status: unknown + status: partial + gaps: + - "Floated and absolutely positioned inline elements keep 'display: inline' instead of becoming block" + - An absolutely positioned box with 'float' still acts as a float for the content after it visuren.html#comparison: title: 9.8 Comparison of normal flow, floats, and absolute positioning - status: unknown - visuren.html#comp-normal-flow: - title: 9.8.1 Normal flow - status: unknown - visuren.html#comp-relpos: - title: 9.8.2 Relative positioning - status: unknown - visuren.html#comp-float: - title: 9.8.3 Floating a box - status: unknown - visuren.html#comp-abspos: - title: 9.8.4 Absolute positioning - status: unknown + status: n/a visuren.html#layers: title: 9.9 Layered presentation - status: unknown + status: partial visuren.html#z-index: title: 9.9.1 Specifying the stack level - status: unknown + status: partial + gaps: + - "'z-index' is ignored, boxes paint in the order normal flow, absolute, fixed" + - Relatively positioned boxes paint with the normal flow, behind later in-flow blocks visuren.html#direction: title: 9.10 Text direction - status: unknown + status: partial + gaps: + - "No Unicode bidirectional algorithm, 'direction: rtl' reverses the order of all words on a line, left-to-right runs included" + - "'direction' on an inline element reorders its words although 'unicode-bidi' is 'normal'" visudet.html#q10.0: title: 10 Visual formatting model details status: unknown From 14b1890d24029991af9dbc72078b9fe620c62f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:00:49 +0100 Subject: [PATCH 014/150] Verify and document CSS 2.1 visual formatting details compliance --- Source/HtmlRenderer/Core/Dom/CssBox.cs | 92 +---- .../HtmlRenderer/Core/Dom/CssBoxProperties.cs | 28 +- .../HtmlRenderer/Core/Dom/CssLayoutEngine.cs | 10 +- Source/HtmlRenderer/Core/Dom/CssLineBox.cs | 4 +- .../AutoMarginWidthIntegrationTests.cs | 116 +----- .../ContainingBlockIntegrationTests.cs | 89 +++++ .../BoxModel/HeightIntegrationTests.cs | 313 +++++++++++++++ .../BoxModel/MarginIntegrationTests.cs | 1 + .../BoxModel/MinMaxHeightIntegrationTests.cs | 18 +- .../BoxModel/WidthIntegrationTests.cs | 360 ++++++++++++++++++ .../TestSupport/TestImages.cs | 31 ++ .../Text/LineBaselineIntegrationTests.cs | 8 +- .../Text/LineHeightIntegrationTests.cs | 163 ++++++++ .../Text/VerticalAlignIntegrationTests.cs | 86 +---- Spec/css21.yml | 110 ++++-- 15 files changed, 1123 insertions(+), 306 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/BoxModel/ContainingBlockIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/TestSupport/TestImages.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Text/LineHeightIntegrationTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index 5116247ae..8cfea3ccd 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -715,16 +715,9 @@ public virtual void Dispose() #region Private Methods /// - /// Computes this box's used height, ported from PeachPDF's CssLayoutEngine.GetBoxHeight - - /// scoped to the height features this engine supports (no aspect-ratio, no absolutely- - /// positioned top+bottom fill, no per-page height: PeachPDF features this engine never had). - /// PeachPDF's Size.Height is content-only, so its GetBoxHeight starts from - /// ActualBoxSizingHeight (Size.Height + padding/border) as the auto-height baseline and can - /// return null for an indeterminate percentage height, leaving ApplyHeight's Math.Max to - /// preserve whatever content-driven value is already there. This engine's Size.Height is - /// already the border-box measure (see ), so the equivalent baseline - /// is Size.Height itself, and there is no null case to thread through - an indeterminate - /// percentage height simply falls back to that same baseline directly. + /// The used border box height before 'max-height', CSS 2.1 §10.5 and §10.7. Size.Height holds the + /// content driven border box height, which is also what an auto or unresolvable percentage height + /// keeps. /// private double GetBoxHeight() { @@ -748,21 +741,12 @@ private double GetBoxHeight() } /// - /// Applies this box's used height (CSS 2.1 10.5/10.6.3/10.7) to , - /// and computes whether this box counts as height-calculated for a descendant's percentage - /// height resolution. Ported from PeachPDF's CssLayoutEngine.ApplyHeight. + /// Sets to the used height, CSS 2.1 §10.5, §10.6.3 and §10.7, + /// and whether a descendant's percentage height can resolve against this box. /// - /// The assignment below is unconditional (not the plain Math.Max(ActualBottom, ...) - /// this replaced) - a no-op for auto height, since 's baseline is - /// already ActualBottom's own current value, but a real correction for a definite height, - /// able to shrink ActualBottom back down, which Math.Max alone never could. That correction is - /// what fixes a real bug this exposed: 's own "handle - /// height setting" step (for a nested atomic inline-block) pushes a block's own maxBottom out - /// to the block's full ActualHeight measured from its content top, and CreateLineBoxes then - /// adds that same block's bottom padding/border again on top - inflating ActualBottom by one - /// extra padding+border box for any floated or block-level box with both an explicit height - /// and non-zero padding/border (e.g. every floated box in the W3C ACID1 test). A definite - /// height here authoritatively overwrites that inflated value instead of merely preserving it. + /// ActualBottom is overwritten, not maxed: measures an explicit + /// height from the content top and CreateLineBoxes then adds the bottom padding and border on top of + /// it, so the content driven value can be too large. /// /// private void ApplyHeight() @@ -829,24 +813,14 @@ protected virtual void PerformLayoutImp(RGraphics g) if (explicitWidth) { - // CSS 2.1 10.2: a percentage/length `width` resolves against the containing block's - // content width. Per CSS Box Sizing 3, that resolved value is this box's CONTENT - // width under the default `box-sizing: content-box` - its border-box width (what - // Size.Width holds throughout this engine) additionally includes this box's own - // padding+border (ActualBoxSizeIncludedWidth is 0 for `box-sizing: border-box`, - // where the resolved value already IS the border-box width). + // CSS 2.1 §10.2, Size.Width is the border box, ActualBoxSizeIncludedWidth adds the + // padding and border for box-sizing: content-box. width = CssValueParser.ParseLength(Width, availableWidth, this) + ActualBoxSizeIncludedWidth; } else if (IsFloated || Position == CssConstants.Absolute) { - // CSS 2.1 10.3.5/10.3.7: a floated box, or an absolutely positioned box with no - // explicit width (the common case - both `left`/`right` auto), shrinks to fit its - // content instead of taking the full containing-block width like an ordinary block. - // (The full §10.3.7 seven-case width-auto-resolution algorithm - solving width from - // explicit left+right+margins - is not implemented; this covers the shrink-to-fit - // case PeachPDF's own Acid2 regression tests exercise.) GetMinMaxWidth already - // returns border-box-inclusive bounds (its own padding/border baked in), so no - // box-sizing adjustment is needed here. + // Shrink-to-fit, CSS 2.1 §10.3.5 and §10.3.7. GetMinMaxWidth already includes the + // padding and border. double minWidth, maxWidth; GetMinMaxWidth(out minWidth, out maxWidth); width = Math.Min(Math.Max(minWidth, width), maxWidth); @@ -856,11 +830,7 @@ protected virtual void PerformLayoutImp(RGraphics g) if (!explicitWidth && !IsFloated) { - // CSS 2.1 10.3.3: only a plain (non-floated) width:auto block absorbs its own - // margin into the available fill width to become its used (border-box) width - an - // explicit width, or a floated shrink-to-fit width, is the used width as-is; margin - // is separate space outside the border box (already accounted for via this box's - // Location.X), not a further reduction of Size.Width on top of that. + // CSS 2.1 §10.3.3, an auto width fills what the margins leave of the containing block Size = new RSize(width - ActualMarginLeft - ActualMarginRight, Size.Height); } } @@ -1056,14 +1026,8 @@ protected virtual void PerformLayoutImp(RGraphics g) } ActualRight = CalculateActualRight(); - // CSS 2.1 10.6.3/10.6.7: a plain (non-BFC-establishing) box's own auto-height - // doesn't contribute from out-of-flow (floated/absolutely-positioned) children - // at all - if EVERY child is out-of-flow (e.g. a
        whose only children are - // floated
      • s), there is no in-flow content to measure, so ActualBottom is - // left exactly as the static-position commit above already set it (its own - // top, i.e. zero content height) rather than being pulled down to match a - // floated child's position. Ported from PeachPDF's CssBox.PerformLayoutImp - // (`if (Boxes.Any(b => !b.IsOutOfFlow)) { ActualBottom = MarginBottomCollapse(); }`). + // CSS 2.1 §10.6.3, with only floats and absolutely positioned children there is no + // content and the height stays 0 if (Boxes.Exists(b => !b.IsOutOfFlow)) { ActualBottom = MarginBottomCollapse(); @@ -1457,13 +1421,7 @@ private static void GetMinMaxSumWords(CssBox box, ref double min, ref double max double? oldSum = null; double oldBrokenLinesMax = 0; - // paddingSum must be scoped per "line" the same way maxSum is (see the oldSum save/restore - // below) - it represents the border/padding belonging to the WIDEST line found so far, not a - // running total across every sibling's own unrelated line. Without oldPaddingSum, a block - // box's own border/padding (and every descendant's, recursively) permanently accumulated into - // paddingSum and was never reset between siblings - e.g. a content-bearing box followed by - // border-only siblings summed all their unrelated border/padding into one shrink-to-fit width - // instead of using only the widest line's own padding. Ported from PeachPDF's GetMinMaxSumWords. + // paddingSum is per line like maxSum, the padding and border of the widest line, not of all lines double? oldPaddingSum = null; // not inline (block) boxes start a new line so we need to reset the max sum @@ -1518,18 +1476,10 @@ private static void GetMinMaxSumWords(CssBox box, ref double min, ref double max var maxSumBeforeChild = maxSum; GetMinMaxSumWords(childBox, ref min, ref maxSum, ref paddingSum, ref marginSum, ref brokenLinesMax); - // This walk otherwise never consults a box's own explicit CSS `width` at all - only - // literal word/text content. That's usually fine (explicit width constrains layout - // AFTER content is measured) but breaks down for a child whose only real sizing - // signal IS an explicit width with no word content to measure (e.g. a solid-color - // box). A plain absolute length (not a percentage, which would read this box's own - // not-yet-final ActualWidth) is folded in as an explicit floor for this line's - // running total. Excludes a non-replaced inline box (Display:Inline with no Words of - // its own): per CSS2.1 10.3.3, `width` has no effect on a non-replaced inline-level - // box. A child that starts its OWN new "line" must have its explicit width combined - // via Math.Max against maxSum, NOT added to maxSumBeforeChild - which already - // reflects whatever an earlier, unrelated block-level sibling contributed and must - // compete for "widest line wins", not accumulate. Ported from PeachPDF. + // A child's own length width counts as content, e.g. for an empty box with a width. + // Percentages are skipped, the width they resolve against isn't known yet. 'width' doesn't + // apply to a non-replaced inline, CSS 2.1 §10.3.1. A block child starts its own line, so + // its width competes with the widest line instead of adding to the current one. if (CssValueParser.IsValidLength(childBox.Width) && !childBox.Width.EndsWith("%") && !(childBox.Display == CssConstants.Inline && childBox.Words.Count == 0)) { @@ -2210,4 +2160,4 @@ public override string ToString() #endregion } -} \ No newline at end of file +} diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index 196cb56b2..d73db3afd 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs @@ -620,14 +620,9 @@ public string MaxHeight } /// - /// Whether this box's own height is "specified explicitly" per CSS 2.1 10.5: a definite - /// (non-auto) length, or a percentage against a containing block that is itself - /// height-calculated - or this box is the root/initial containing block (whose used height is - /// the page height regardless of its own computed `height`). Drives whether a DESCENDANT's - /// percentage height/min-height/max-height resolves against this box; computed by - /// once this box's own height is finalized, not a pure - /// computed getter (a descendant's own ApplyHeight needs to read it before this box's parent - /// has necessarily finished, so it can't be derived on demand from current state alone). + /// Whether a descendant's percentage height, min-height and max-height can resolve against this box, + /// CSS 2.1 §10.5. Set by , which runs after this box's children are + /// laid out. /// public bool IsHeightCalculated { @@ -1123,11 +1118,8 @@ public double ActualHeight { var parsedHeight = CssValueParser.ParseLength(Height, Size.Height, this); - // An explicit (non-auto) Height is a content-box measure under the default box-sizing - // (CSS Box Sizing 3) - add this box's own padding+border to reach the border-box measure - // this getter's one caller (CssBox.PerformLayoutImp's ActualBottom expansion) expects. - // ActualBoxSizeIncludedHeight is 0 for box-sizing:border-box, where parsedHeight already - // IS the border-box measure. + // The border box height, ActualBoxSizeIncludedHeight adds the padding and border for + // box-sizing: content-box _actualHeight = Height != null && Height != CssConstants.Auto && !string.IsNullOrEmpty(Height) ? parsedHeight + ActualBoxSizeIncludedHeight : parsedHeight; @@ -1137,7 +1129,7 @@ public double ActualHeight } /// - /// Gets the actual height + /// Gets the actual width, 0 for 'width: auto' /// public double ActualWidth { @@ -1145,12 +1137,8 @@ public double ActualWidth { if (double.IsNaN(_actualWidth)) { - // A percentage Width was already resolved to an absolute pixel value (against its - // containing block, box-sizing-included) by CssBox.PerformLayoutImp's block-width pass - // and baked into Size.Width - re-parsing it here against Size.Width (already-resolved - // pixels, not a percentage base) would resolve the percentage against itself, squaring - // it. A fixed-unit Width (em/px/pt) is unaffected either way, since ParseLength ignores - // the percentage base for those units - only percentage Width ever hits this path. + // Layout already resolved a percentage into Size.Width, parsing it again would apply + // it to itself _actualWidth = Width != null && Width.EndsWith("%") ? Size.Width : CssValueParser.ParseLength(Width, Size.Width, this); diff --git a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs index 17f48aa41..df07564f2 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs @@ -444,7 +444,7 @@ private static void FlowBox(RGraphics g, CssBox blockbox, CssBox box, double lim foreach (var word in b.Words) { // A line with text is at least as tall as the block's own font, even if all its text - // is smaller (the strut, CSS 2.1 10.8.1). + // is smaller (the strut, CSS 2.1 §10.8.1). var lineHeight = word.IsImage ? box.ActualLineHeight : Math.Max(box.ActualLineHeight, Math.Max(blockbox.ActualLineHeight, GetFontHeight(g, blockbox.ActualFont))); @@ -716,9 +716,9 @@ private static void ApplyRightToLeftOnSingleBox(CssLineBox lineBox, CssBox box) /// /// Puts every text word of the line on one baseline, the lowest one any word or the block's own font - /// (the line's strut) needs. Words are flowed top-aligned, so a smaller font would otherwise hang from - /// the top of the line instead of sitting on it. Images and words inside a sub/super box keep their - /// current position, every other vertical-align value isn't supported and sits on the baseline. + /// (the line's strut) needs, CSS 2.1 §10.8.1. Words are flowed top-aligned, so a smaller font would + /// otherwise hang from the top of the line. Images keep their position, words inside a sub/super box + /// are moved by . /// private static void ApplyBaselineAlignment(RGraphics g, CssLineBox lineBox) { @@ -797,7 +797,7 @@ private static void ApplyVerticalAlignment(RGraphics g, CssLineBox lineBox) var boxes = new List(lineBox.Rectangles.Keys); foreach (CssBox box in boxes) { - //Important notes on http://www.w3.org/TR/CSS21/tables.html#height-layout + // CSS 2.1 §10.8.1 switch (box.VerticalAlign) { case CssConstants.Sub: diff --git a/Source/HtmlRenderer/Core/Dom/CssLineBox.cs b/Source/HtmlRenderer/Core/Dom/CssLineBox.cs index 2042dc5d4..f46a6e4c9 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLineBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLineBox.cs @@ -269,7 +269,6 @@ internal void AssignRectanglesToBoxes() /// baseline internal void SetBaseLine(RGraphics g, CssBox b, double baseline) { - //TODO: Aqui me quede, checar poniendo "by the" con un font-size de 3em List ws = WordsOf(b); if (!Rectangles.ContainsKey(b)) @@ -295,8 +294,7 @@ internal void SetBaseLine(RGraphics g, CssBox b, double baseline) } //New top that words will have - //float newtop = baseline - (Height - OwnerBox.FontDescent - 3); //OLD - double newtop = baseline; // -GetBaseLineHeight(b, g); //OLD + double newtop = baseline; if (b.ParentBox != null && b.ParentBox.Rectangles.ContainsKey(this) && diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/AutoMarginWidthIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/AutoMarginWidthIntegrationTests.cs index e9b52c591..a045029af 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/AutoMarginWidthIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/AutoMarginWidthIntegrationTests.cs @@ -2,42 +2,9 @@ namespace HtmlRenderer.IntegrationTest.BoxModel; -/// -/// CSS 2.1 §10.3.3: an in-flow, non-replaced block with width: auto and margin: … auto -/// fills its containing block (the auto margins resolve to 0); centering via auto margins only applies -/// when the used width is definite - an explicit width, or an auto width clamped below the -/// fill width by max-width. -/// /// -/// -/// Direct reads of this fork's source confirm several independent gaps against that spec, most of which this -/// file's tests trip over: -/// -/// -/// No auto-margin centering at all. CssBoxProperties.ActualMarginLeft/ActualMarginRight/ -/// ActualMarginTop (Dom/CssBoxProperties.cs ~880-965) resolve a raw "auto" value to plain -/// "0" before parsing - there is no centering logic anywhere that redistributes free space. -/// min-width is not implemented at all. No CSS property dispatch for min-width exists -/// anywhere in Core (confirmed: no case for it in CssUtils.GetPropertyValue/SetPropertyValue, and -/// no MinWidth property on CssBoxProperties - only unrelated internal table-column min-width -/// tracking in CssLayoutEngineTable happens to share a similar name). -/// max-width is never consulted for ordinary block width. The block width computation in -/// CssBox.PerformLayoutImp (Dom/CssBox.cs ~629-647) computes the fill width purely from -/// ContainingBlock.Size.Width and the box's own (non-auto) Width string - it never reads -/// MaxWidth at all. MaxWidth is only consulted for <img> sizing -/// (CssLayoutEngine.cs) and table sizing (CssLayoutEngineTable.cs), so on a plain block it is -/// silently inert. -/// ActualWidth reads back 0 for the default width:auto, even though the real layout -/// geometry is correct. CssBoxProperties.ActualWidth (~806-816) parses the raw Width CSS -/// string via CssValueParser.ParseLength with NO special-case for "auto" (unlike the margin -/// getters above, which explicitly rewrite "auto" to "0" first) - and -/// CssValueParser.ParseLength("auto", ...) finds no recognized unit and double.TryParse("auto", ...) -/// fails, so it returns 0. Meanwhile the box's real fill width IS correctly computed and stored directly into -/// Size.Width by PerformLayoutImp (line ~643) - but that computed value is never written back into -/// the Width string, so ActualWidth can never see it for an auto-width box. A box with an -/// explicit (non-auto) Width, including a percentage one, is unaffected - percentage values are -/// parsed fine, and since they resolve relative to Size.Width itself the result is self-consistent. -/// +/// ActualWidth is the computed width and reads 0 for width: auto, the used border box width is +/// Size.Width. /// [DoNotParallelize] [TestClass] @@ -45,15 +12,9 @@ public sealed class AutoMarginWidthIntegrationTests { private const double Delta = 1.0; - // Page is 400px wide with zero page/body margins (LayoutHarness.Wrap sets body margin:0), so the body's - // content box spans x:0..400 and a filling child should be 400px wide at x=0. + private const string ActualWidthOfAuto = "Asserts ActualWidth, which is the computed width and 0 for width: auto"; - [Ignore("box.ActualWidth reads back 0 for this box's default width:auto, per this class's remarks (the " + - "CssBoxProperties.ActualWidth getter has no auto-special-case, unlike the margin getters) - even " + - "though the box's real Size.Width (and Location.X) are actually filled/positioned correctly by " + - "CssBox.PerformLayoutImp. The margin:0 auto / max-width part of this scenario is fine on this fork " + - "(auto margins already resolve to 0, matching the expected fill-not-center outcome) - it's purely " + - "the ActualWidth read that diverges.")] + [Ignore(ActualWidthOfAuto)] [TestMethod] public void AutoWidth_MarginZeroAuto_FillsContainingBlock() { @@ -65,10 +26,7 @@ public void AutoWidth_MarginZeroAuto_FillsContainingBlock() Assert.AreEqual(400, box.ActualWidth, Delta); } - [Ignore("Same box.ActualWidth-reads-0-for-width:auto gap as AutoWidth_MarginZeroAuto_FillsContainingBlock - " + - "see this class's remarks. The padding-doesn't-shrink-the-fill part of the scenario is fine on this " + - "fork (Size.Width, the border-box width, is filled independently of the box's own padding), it's " + - "purely the ActualWidth read that diverges.")] + [Ignore(ActualWidthOfAuto)] [TestMethod] public void AutoWidth_MarginZeroAuto_WithPadding_FillsAndOffsetsByBorderPadding() { @@ -77,17 +35,13 @@ public void AutoWidth_MarginZeroAuto_WithPadding_FillsAndOffsetsByBorderPadding( var box = LayoutHarness.FindById(root, "c")!; Assert.AreEqual(0, box.Location.X, Delta); - Assert.AreEqual(400, box.ActualWidth, Delta); // border-box width fills + Assert.AreEqual(400, box.ActualWidth, Delta); } [TestMethod] + [Spec("css21", "visudet.html#the-width-property")] public void WidthPercent100_Child_FillsInsideAutoMarginWrapper() { - // The exact cascade the invoice-bug regression targets: a width:100% child inside a margin:0 auto - // wrapper must resolve 100% against the now-filled wrapper, not a collapsed one. Unlike the two tests - // above, "inner" has an EXPLICIT width (100%, not auto), which sidesteps the ActualWidth-reads-0-for-auto - // gap documented on this class - percentage widths parse fine and resolve consistently against the - // already-correctly-filled Size.Width of the wrapper, so this one genuinely passes on this fork. var html = LayoutHarness.Wrap( "
        " + "
        x
        "); @@ -98,13 +52,11 @@ public void WidthPercent100_Child_FillsInsideAutoMarginWrapper() Assert.AreEqual(400, inner.ActualWidth, Delta); } - [Ignore("This fork's ActualMarginLeft/ActualMarginRight getters (Dom/CssBoxProperties.cs) resolve a raw " + - "'auto' margin to plain '0' before parsing - there is no centering logic anywhere - so a box with a " + - "definite width and margin:0 auto sits flush left at x=0, not centered at (400-100)/2=150.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "visudet.html#blockwidth")] public void DefiniteWidth_MarginZeroAuto_Centers() { - // A definite width should center via auto margins per CSS 2.1 (unchanged expectation): (400-100)/2 = 150. var html = LayoutHarness.Wrap("
        x
        "); var (root, _) = LayoutHarness.Layout(html, maxWidth: 400, maxHeight: 2000); var box = LayoutHarness.FindById(root, "c")!; @@ -113,16 +65,11 @@ public void DefiniteWidth_MarginZeroAuto_Centers() Assert.AreEqual(100, box.ActualWidth, Delta); } - [Ignore("Two compounding gaps: (1) max-width is never consulted for ordinary block width (see class remarks) " + - "so this box's width never clamps to 200px at all - it stays width:auto and fills to the full 400px " + - "container instead; (2) even if it had clamped, auto margins resolve to 0 (no centering), so " + - "Location.X would be 0, not the expected (400-200)/2=100. box.ActualWidth also reads back 0 here " + - "(width stays 'auto' the whole time), not the expected clamped 200.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "visudet.html#min-max-widths")] public void AutoWidth_MaxWidthBinds_MarginZeroAuto_Centers() { - // When max-width is smaller than the page it should clamp the used width, which becomes definite and - // centers: width = 200px, (400-200)/2 = 100px each side. var html = LayoutHarness.Wrap("
        x
        "); var (root, _) = LayoutHarness.Layout(html, maxWidth: 400, maxHeight: 2000); var box = LayoutHarness.FindById(root, "c")!; @@ -131,15 +78,11 @@ public void AutoWidth_MaxWidthBinds_MarginZeroAuto_Centers() Assert.AreEqual(100, box.Location.X, Delta); } - [Ignore("min-width is not implemented at all on this fork (see class remarks), and max-width is never " + - "consulted for ordinary block width either - so neither the 200px clamp nor the 300px re-widen ever " + - "happens; the box just stays width:auto and fills the 400px container. Auto margins also resolve to " + - "0 (no centering), so neither box.ActualWidth (expected 300) nor box.Location.X (expected 50) match.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "visudet.html#min-max-widths")] public void AutoWidth_MinWidthReWidensPastMaxWidth_CentersOnMinWidth() { - // Degenerate min-width > max-width: min should win (CSS 2.1 §10.4), so the used width is 300px, not the - // clamped 200px - the auto margins should then center on 300px: (400-300)/2 = 50px. var html = LayoutHarness.Wrap("
        x
        "); var (root, _) = LayoutHarness.Layout(html, maxWidth: 400, maxHeight: 2000); var box = LayoutHarness.FindById(root, "c")!; @@ -148,16 +91,11 @@ public void AutoWidth_MinWidthReWidensPastMaxWidth_CentersOnMinWidth() Assert.AreEqual(50, box.Location.X, Delta); } - [Ignore("min-width is not implemented at all on this fork (see class remarks) - so this box never widens " + - "past the 400px container to the expected 600px overflow; it just fills the container normally, and " + - "its width:auto also means box.ActualWidth reads back 0 rather than either 400 or 600 (see class " + - "remarks on the ActualWidth-for-auto gap). Location.X may coincidentally still read ~0, but for an " + - "unrelated reason (auto margins resolving to 0), not because min-width forced an overflow.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "visudet.html#min-max-widths")] public void AutoWidth_MinWidthExceedsContainingBlock_MarginsCollapseToZero() { - // A min-width wider than the containing block should overflow: no free space, auto margins 0, box - // pinned at the containing-block left edge. var html = LayoutHarness.Wrap("
        x
        "); var (root, _) = LayoutHarness.Layout(html, maxWidth: 400, maxHeight: 2000); var box = LayoutHarness.FindById(root, "c")!; @@ -166,16 +104,10 @@ public void AutoWidth_MinWidthExceedsContainingBlock_MarginsCollapseToZero() Assert.AreEqual(0, box.Location.X, Delta); } - [Ignore("Sanity companion to the other tests in this class, isolating the ActualWidth-reads-0-for-width:auto " + - "gap on its own: margin here is plain '0' (not 'auto'), so the auto-margin-centering bug is not in " + - "play at all, and max-width being inert for blocks doesn't matter either since it doesn't bind (5000 " + - "> 400) even on a correct engine - box.Location.X (0) is right, but box.ActualWidth still reads back " + - "0 instead of 400 purely because CssBoxProperties.ActualWidth has no 'auto' special-case (see class " + - "remarks).")] + [Ignore(ActualWidthOfAuto)] [TestMethod] public void AutoWidth_MarginZero_LeftAligns_Fills() { - // Sanity: plain margin:0 (no auto) should fill and left-align, matching the auto-margin fill. var html = LayoutHarness.Wrap("
        x
        "); var (root, _) = LayoutHarness.Layout(html, maxWidth: 400, maxHeight: 2000); var box = LayoutHarness.FindById(root, "c")!; @@ -184,21 +116,11 @@ public void AutoWidth_MarginZero_LeftAligns_Fills() Assert.AreEqual(400, box.ActualWidth, Delta); } - [Ignore("Adapted from PeachPDF: the original test calls static CssLayoutEngine.GetActualMarginLeft/" + - "GetActualMarginRight helpers that resolve a table's centered margin against its own resolved width - " + - "no such methods exist anywhere in this fork's (internal static) CssLayoutEngine/CssLayoutEngineTable " + - "classes (confirmed: no 'GetActualMargin' hits in either file), so the call itself would not compile " + - "here. This fork centers a table (if at all) only via the parent's inherited text-align:center " + - "(CssLayoutEngineTable.cs ~line 617), never via the table's own margin:auto - a table's own " + - "ActualMarginLeft/ActualMarginRight are the same generic CssBoxProperties getters every other box " + - "uses, which resolve 'auto' to '0' with no centering (see class remarks). Adapted here to assert on " + - "the table's resulting Location.X directly, which is the closest equivalent observation the current " + - "harness/API can make.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "visudet.html#blockwidth")] public void Table_MarginZeroAuto_CentersAgainstResolvedWidth() { - // A margin: 0 auto table should be centered against its own resolved (explicit) width: wrap is 200px - // wide, table is 100px wide, so it should sit 50px in from each side. var html = LayoutHarness.Wrap( "
        " + "
        A
        "); @@ -206,7 +128,7 @@ public void Table_MarginZeroAuto_CentersAgainstResolvedWidth() var wrap = LayoutHarness.FindById(root, "wrap")!; var table = LayoutHarness.FindById(root, "t")!; - var expected = wrap.ClientLeft + (200.0 - 100.0) / 2.0; // 50px each side + var expected = wrap.ClientLeft + (200.0 - 100.0) / 2.0; Assert.AreEqual(expected, table.Location.X, Delta); } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/ContainingBlockIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/ContainingBlockIntegrationTests.cs new file mode 100644 index 000000000..48107c76d --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/ContainingBlockIntegrationTests.cs @@ -0,0 +1,89 @@ +using HtmlRenderer.IntegrationTest.TestSupport; + +namespace HtmlRenderer.IntegrationTest.BoxModel; + +[DoNotParallelize] +[TestClass] +public sealed class ContainingBlockIntegrationTests +{ + private const double Delta = 0.5; + + [TestMethod] + [Spec("css21", "visudet.html#containing-block-details")] + public void InFlowBlock_IsContainedByTheContentEdgeOfItsParent() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
        ")); + var c = LayoutHarness.FindById(root, "c")!; + + Assert.AreEqual(15, c.Location.X, Delta); + Assert.AreEqual(15, c.Location.Y, Delta); + Assert.AreEqual(200, c.Size.Width, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#containing-block-details")] + public void BlockInsideAnInline_IsContainedByTheNearestBlockContainer() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
        ")); + var c = LayoutHarness.FindById(root, "c")!; + + Assert.AreEqual(0, c.Location.X, Delta); + Assert.AreEqual(200, c.Size.Width, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#containing-block-details")] + public void AbsoluteBox_IsPlacedInTheNearestPositionedAncestor() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
        " + + "
        ")); + var a = LayoutHarness.FindById(root, "a")!; + + Assert.AreEqual(50, a.Location.X, Delta); + Assert.AreEqual(20, a.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#containing-block-details")] + public void AbsoluteBox_PercentageWidth_ResolvesAgainstTheNearestPositionedAncestor() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
        " + + "
        ")); + var a = LayoutHarness.FindById(root, "a")!; + + Assert.AreEqual(100, a.Size.Width, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#containing-block-details")] + public void AbsoluteBox_WithoutAPositionedAncestor_IsPlacedInTheInitialContainingBlock() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
        ")); + var a = LayoutHarness.FindById(root, "a")!; + + Assert.AreEqual(10, a.Location.X, Delta); + Assert.AreEqual(20, a.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#containing-block-details")] + public void AbsoluteBox_IsContainedByThePaddingEdgeOfThePositionedAncestor() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
        " + + "
        ")); + var a = LayoutHarness.FindById(root, "a")!; + + Assert.AreEqual(5, a.Location.X, Delta); + Assert.AreEqual(5, a.Location.Y, Delta); + Assert.AreEqual(120, a.Size.Width, Delta); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs new file mode 100644 index 000000000..8dc137b49 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs @@ -0,0 +1,313 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.BoxModel; + +[DoNotParallelize] +[TestClass] +public sealed class HeightIntegrationTests +{ + private const double Delta = 0.5; + + private static readonly string Image = TestImages.Png(40, 20); + + private static CssBox Layout(string body) => LayoutHarness.Layout(LayoutHarness.Wrap(body), maxWidth: 400).Root; + + private static CssBox Box(CssBox root, string id) => LayoutHarness.FindById(root, id)!; + + private static double Height(CssBox box) => box.ActualBottom - box.Location.Y; + + private static double LineHeight() => Height(Box(Layout("
        x
        "), "p")); + + [TestMethod] + [Spec("css21", "visudet.html#the-height-property")] + public void LengthHeight_IsTheContentHeight() + { + var root = Layout("
        "); + + Assert.AreEqual(75, Height(Box(root, "c")), Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#the-height-property")] + public void PercentageHeight_AgainstAnAutoHeightContainingBlock_IsAuto() + { + var root = Layout("
        x
        "); + + Assert.AreEqual(LineHeight(), Height(Box(root, "c")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#the-height-property")] + public void PercentageHeight_ResolvesAgainstAnExplicitContainingBlockHeight() + { + var root = Layout("
        "); + + Assert.AreEqual(100, Height(Box(root, "c")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#the-height-property")] + public void NegativeHeight_IsIgnored() + { + var root = Layout("
        x
        "); + + Assert.AreEqual(LineHeight(), Height(Box(root, "c")), Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#the-height-property")] + public void HeightOnAnInline_DoesNotApply() + { + var plain = Layout("
        axb
        "); + var withHeight = Layout("
        axb
        "); + + Assert.AreEqual(Height(Box(plain, "p")), Height(Box(withHeight, "p")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#the-height-property")] + public void HeightOnAnInlineBlock_IsApplied() + { + var root = Layout("
        ax
        "); + + Assert.AreEqual(100, Box(root, "s").Rectangles.Values.Single().Height, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#inline-non-replaced")] + public void InlineContentArea_IsAsTallAsTheFont() + { + var root = Layout("
        ax
        "); + var s = Box(root, "s"); + + Assert.AreEqual(s.ActualFont.Height, s.Rectangles.Values.Single().Height, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#inline-non-replaced")] + public void InlineVerticalPaddingAndBorder_DontChangeTheLineHeight() + { + var plain = Layout("
        ax
        "); + var padded = Layout("
        ax
        "); + + Assert.AreEqual(Height(Box(plain, "p")), Height(Box(padded, "p")), Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#inline-replaced-height")] + public void AutoHeight_FollowsTheIntrinsicRatioOfTheWidth() + { + var root = TestImages.Layout($""); + + Assert.AreEqual(40, TestImages.ImageRect(root, "i").Height, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#inline-replaced-height")] + public void BlockImage_AutoHeight_IsTheIntrinsicHeight() + { + var root = TestImages.Layout($""); + + Assert.AreEqual(20, TestImages.ImageRect(root, "i").Height, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#inline-replaced-height")] + public void EmHeight_IsApplied() + { + var root = TestImages.Layout($"
        "); + + Assert.AreEqual(40, TestImages.ImageRect(root, "i").Height, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#normal-block")] + public void AutoHeight_EndsAtTheLastChildsMarginEdge_WhenTheBottomMarginDoesNotCollapseThrough() + { + var root = Layout("
        "); + + Assert.AreEqual(39, Height(Box(root, "p")), Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#normal-block")] + public void AutoHeight_EndsAtTheLastChildsBorderEdge_WhenTheBottomMarginCollapsesThrough() + { + var root = Layout("
        "); + + Assert.AreEqual(10, Height(Box(root, "p")), Delta); + Assert.AreEqual(30, Box(root, "n").Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#normal-block")] + public void AutoHeight_EndsAtTheLastLineBox() + { + var root = Layout("
        x
        y
        "); + + Assert.AreEqual(2 * LineHeight() + 10, Height(Box(root, "p")), Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#normal-block")] + public void AutoHeight_IgnoresFloatsAndAbsoluteChildren() + { + var root = Layout("
        " + + "
        "); + + Assert.AreEqual(0, Height(Box(root, "f")), Delta); + Assert.AreEqual(0, Height(Box(root, "a")), Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#abs-non-replaced-height")] + public void Absolute_WithTopAndHeight_IsPlacedAtTop() + { + var root = Layout("
        "); + var a = Box(root, "a"); + + Assert.AreEqual(10, a.Location.Y, Delta); + Assert.AreEqual(30, Height(a), Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#abs-non-replaced-height")] + public void Absolute_WithBottomAndHeight_IsPlacedAtBottom() + { + var root = Layout("
        "); + + Assert.AreEqual(60, Box(root, "a").Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#abs-non-replaced-height")] + public void Absolute_WithAutoHeight_IsAsTallAsItsContent() + { + var root = Layout("
        x
        "); + + Assert.AreEqual(LineHeight(), Height(Box(root, "a")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#abs-non-replaced-height")] + public void Absolute_WithTopAndBottom_StretchesTheAutoHeight() + { + var root = Layout("
        "); + + Assert.AreEqual(60, Height(Box(root, "a")), Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#abs-replaced-height")] + public void AbsoluteImage_KeepsItsIntrinsicHeight() + { + var root = TestImages.Layout($"
        "); + + Assert.AreEqual(20, TestImages.ImageRect(root, "i").Height, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#block-root-margin")] + public void OverflowHiddenBlock_WithAHeight_IsThatTall() + { + var root = Layout("
        x
        y
        z
        "); + + Assert.AreEqual(10, Height(Box(root, "c")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#block-root-margin")] + public void InlineBlock_WithBlockContent_StaysOnTheLine() + { + var root = Layout("
        a
        y
        "); + var s = Box(root, "s"); + + Assert.IsNotNull(s); + Assert.AreEqual(50, s.Rectangles.Values.Single().Height, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#root-height")] + public void BlockFormattingContextRoot_AutoHeight_CoversItsInFlowContent() + { + var root = Layout("
        "); + + Assert.AreEqual(30, Height(Box(root, "p")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#root-height")] + public void BlockFormattingContextRoot_AutoHeight_CoversItsFloats() + { + var root = Layout("
        "); + + Assert.AreEqual(60, Height(Box(root, "p")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#root-height")] + public void Float_AutoHeight_CoversItsFloatingDescendants() + { + var root = Layout("
        "); + + Assert.AreEqual(90, Height(Box(root, "f")), Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#min-max-heights")] + public void MinHeight_GrowsAnAutoHeightBlock() + { + var root = Layout("
        x
        "); + + Assert.AreEqual(50, Height(Box(root, "c")), Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#min-max-heights")] + public void MaxHeight_LimitsAnAutoHeightBlock() + { + var root = Layout("
        x
        y
        z
        "); + + Assert.AreEqual(5, Height(Box(root, "c")), Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#min-max-heights")] + public void PercentageMinHeight_AgainstAnAutoHeightContainingBlock_IsZero() + { + var root = Layout("
        x
        "); + + Assert.AreEqual(LineHeight(), Height(Box(root, "c")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#min-max-heights")] + public void PercentageMaxHeight_ResolvesAgainstAnExplicitContainingBlockHeight() + { + var root = Layout("
        "); + + Assert.AreEqual(100, Height(Box(root, "c")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#min-max-heights")] + public void MaxHeight_ScalesDownAnImageKeepingItsRatio() + { + var root = TestImages.Layout($""); + var r = TestImages.ImageRect(root, "i"); + + Assert.AreEqual((20d, 10d), (r.Width, r.Height)); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs index 1d3e4b900..1206b15ff 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs @@ -59,6 +59,7 @@ public void InlineHorizontalMargin_MovesTheText() [TestMethod] [Spec("css21", "box.html#margin-properties")] + [Spec("css21", "visudet.html#inline-non-replaced")] public void InlineVerticalMargins_DontChangeTheLineHeight() { var (plainRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
        x y z
        ")); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MinMaxHeightIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MinMaxHeightIntegrationTests.cs index 563f258a3..a2cd0ed00 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MinMaxHeightIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MinMaxHeightIntegrationTests.cs @@ -2,9 +2,6 @@ namespace HtmlRenderer.IntegrationTest.BoxModel; -/// -/// Ported from PeachPDF.Tests' Acid2 min-height/max-height regression cases (CSS 2.1 §10.7). -/// [DoNotParallelize] [TestClass] public sealed class MinMaxHeightIntegrationTests @@ -12,10 +9,9 @@ public sealed class MinMaxHeightIntegrationTests private const double Delta = 0.5; [TestMethod] + [Spec("css21", "visudet.html#min-max-heights")] public void MinHeightWinsOverConflictingMaxHeight() { - // min-height (20px) and max-height (5px) conflict; min-height must win per §10.7, so the box's - // actual height must be 20px, not clamped down to 5px. var html = LayoutHarness.Wrap("
        "); var (root, _) = LayoutHarness.Layout(html); var box = LayoutHarness.FindById(root, "b")!; @@ -24,6 +20,7 @@ public void MinHeightWinsOverConflictingMaxHeight() } [TestMethod] + [Spec("css21", "visudet.html#min-max-heights")] public void MaxHeightAppliesWhenNotConflictingWithMinHeight() { var html = LayoutHarness.Wrap("
        "); @@ -34,20 +31,17 @@ public void MaxHeightAppliesWhenNotConflictingWithMinHeight() } [TestMethod] - public void PercentageHeight_AgainstAutoHeightContainingBlock_ResolvesToAuto_LettingMaxHeightWin() + [Spec("css21", "visudet.html#the-height-property")] + public void PercentageHeight_AgainstAutoHeightContainingBlock_ComputesToAuto() { - // "height: 60%" must resolve to auto (zero intrinsic content) against an auto-height containing - // block (CSS 2.1 §10.5/§10.7), not against the container's own content-driven height - so - // max-height:20px is the only thing constraining #b's height. var html = LayoutHarness.Wrap( "
        " + "
        " - + "
        " + + "
        " + "
        "); var (root, _) = LayoutHarness.Layout(html); var box = LayoutHarness.FindById(root, "b")!; - var height = box.ActualBottom - box.Location.Y; - Assert.IsTrue(height is >= 0 and <= 20.5, $"expected height clamped to max-height:20px, was {height}"); + Assert.AreEqual(0, box.ActualBottom - box.Location.Y, Delta); } } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs new file mode 100644 index 000000000..0ea2e01e9 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs @@ -0,0 +1,360 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.BoxModel; + +[DoNotParallelize] +[TestClass] +public sealed class WidthIntegrationTests +{ + private const double Delta = 0.5; + + private static readonly string Image = TestImages.Png(40, 20); + + private static CssBox Layout(string body) => LayoutHarness.Layout(LayoutHarness.Wrap(body), maxWidth: 400).Root; + + private static CssBox Box(CssBox root, string id) => LayoutHarness.FindById(root, id)!; + + [TestMethod] + [Spec("css21", "visudet.html#the-width-property")] + public void LengthWidth_IsTheContentWidth() + { + var root = Layout("
        "); + + Assert.AreEqual(130, Box(root, "c").Size.Width, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#the-width-property")] + public void PercentageWidth_ResolvesAgainstTheContainingBlockWidth() + { + var root = Layout("
        "); + var c = Box(root, "c"); + + Assert.AreEqual(50, c.Location.X, Delta); + Assert.AreEqual(110, c.Size.Width, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#the-width-property")] + public void NegativeWidth_IsIgnored() + { + var root = Layout("
        "); + + Assert.AreEqual(200, Box(root, "c").Size.Width, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#inline-width")] + public void InlineAutoMargins_AreZero() + { + var root = Layout("
        axb
        "); + var a = LayoutHarness.FindById(root, "s")!.ParentBox!.Boxes[0].Words[0]; + + Assert.AreEqual(a.Right, FirstWord(Box(root, "s"))!.Left, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#inline-width")] + public void WidthOnAnInline_DoesNotApply() + { + var root = Layout("
        axb
        "); + + Assert.AreEqual(FirstWord(Box(root, "s"))!.Right, FirstWord(Box(root, "t"))!.Left, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#inline-replaced-width")] + public void AutoWidthAndHeight_UseTheIntrinsicSize() + { + var root = TestImages.Layout($""); + var r = TestImages.ImageRect(root, "i"); + + Assert.AreEqual((40d, 20d), (r.Width, r.Height)); + } + + [TestMethod] + [Spec("css21", "visudet.html#inline-replaced-width")] + public void AutoWidth_FollowsTheIntrinsicRatioOfTheHeight() + { + var root = TestImages.Layout($""); + + Assert.AreEqual(80, TestImages.ImageRect(root, "i").Width, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#inline-replaced-width")] + public void PercentageWidth_ResolvesAgainstTheContainingBlock() + { + var root = TestImages.Layout($"
        "); + + Assert.AreEqual(100, TestImages.ImageRect(root, "i").Width, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#inline-replaced-width")] + public void EmWidth_IsApplied() + { + var root = TestImages.Layout($"
        "); + + Assert.AreEqual(80, TestImages.ImageRect(root, "i").Width, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#blockwidth")] + public void AutoWidth_FillsTheContainingBlockMinusTheMargins() + { + var root = Layout("
        "); + var c = Box(root, "c"); + + Assert.AreEqual(10, c.Location.X, Delta); + Assert.AreEqual(170, c.Size.Width, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#blockwidth")] + public void OverConstrained_IgnoresMarginRight() + { + var root = Layout("
        "); + var c = Box(root, "c"); + + Assert.AreEqual(10, c.Location.X, Delta); + Assert.AreEqual(100, c.Size.Width, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#blockwidth")] + public void AutoWidth_WithAutoMargins_FillsAndTheMarginsAreZero() + { + var root = Layout("
        "); + var c = Box(root, "c"); + + Assert.AreEqual(0, c.Location.X, Delta); + Assert.AreEqual(200, c.Size.Width, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#blockwidth")] + public void AutoMarginLeft_TakesTheRemainingSpace() + { + var root = Layout("
        "); + + Assert.AreEqual(100, Box(root, "c").Location.X, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#blockwidth")] + public void OverConstrained_InRtl_IgnoresMarginLeft() + { + var root = Layout("
        "); + + Assert.AreEqual(90, Box(root, "c").Location.X, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#block-replaced-width")] + public void BlockImage_KeepsItsIntrinsicWidth() + { + var root = TestImages.Layout($"
        "); + + Assert.AreEqual(40, TestImages.ImageRect(root, "i").Width, Delta); + Assert.AreEqual(20, Box(root, "n").Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#block-replaced-width")] + public void BlockImage_IsCenteredByAutoMargins() + { + var root = TestImages.Layout($"
        "); + + Assert.AreEqual(80, TestImages.ImageRect(root, "i").X, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#float-width")] + public void Float_WithAutoMargins_HasZeroMargins() + { + var root = Layout("
        "); + var f = Box(root, "f"); + + Assert.AreEqual(0, f.Location.X, Delta); + Assert.AreEqual(50, f.Size.Width, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#float-width")] + public void AutoWidthFloat_ShrinksToFitItsContent() + { + var root = Layout("
        xx yy
        zz
        "); + var f = Box(root, "f"); + var words = LayoutHarness.Descendants(f).SelectMany(b => b.Words).ToList(); + + Assert.AreEqual(words[^1].Right - words[0].Left, f.Size.Width, 1); + Assert.AreEqual(words[0].Top, words[^1].Top, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#float-replaced-width")] + public void FloatedImage_KeepsItsIntrinsicWidth() + { + var root = TestImages.Layout($"
        "); + + Assert.AreEqual(40, TestImages.ImageRect(root, "i").Width, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#abs-non-replaced-width")] + public void Absolute_WithLeftAndWidth_IsPlacedAtLeft() + { + var root = Layout("
        "); + var a = Box(root, "a"); + + Assert.AreEqual(10, a.Location.X, Delta); + Assert.AreEqual(50, a.Size.Width, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#abs-non-replaced-width")] + public void Absolute_WithRightAndWidth_IsPlacedAtRight() + { + var root = Layout("
        "); + + Assert.AreEqual(140, Box(root, "a").Location.X, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#abs-non-replaced-width")] + public void Absolute_WithLeftAndRight_StretchesTheAutoWidth() + { + var root = Layout("
        "); + var a = Box(root, "a"); + + Assert.AreEqual(10, a.Location.X, Delta); + Assert.AreEqual(160, a.Size.Width, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#abs-non-replaced-width")] + public void Absolute_WithAutoWidth_ShrinksToFitItsContent() + { + var root = Layout("
        xx yy
        "); + var a = Box(root, "a"); + var words = LayoutHarness.Descendants(a).SelectMany(b => b.Words).ToList(); + + Assert.AreEqual(words[^1].Right - words[0].Left, a.Size.Width, 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#abs-non-replaced-width")] + public void Absolute_WithLeftRightAndWidth_IsCenteredByAutoMargins() + { + var root = Layout("
        "); + + Assert.AreEqual(50, Box(root, "a").Location.X, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#abs-replaced-width")] + public void AbsoluteImage_KeepsItsIntrinsicWidth() + { + var root = TestImages.Layout($"
        "); + + Assert.AreEqual(40, TestImages.ImageRect(root, "i").Width, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#inlineblock-width")] + public void InlineBlock_WithAWidth_TakesThatWidthOnTheLine() + { + var root = Layout("
        axy
        "); + var s = Box(root, "s"); + + Assert.AreEqual(100, s.Rectangles.Values.Single().Width, Delta); + Assert.AreEqual(s.Rectangles.Values.Single().Right, FirstWord(Box(root, "t"))!.Left, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#inlineblock-width")] + public void InlineBlock_WithAutoWidth_IsAsWideAsItsContent() + { + var root = Layout("
        axxy
        "); + var word = FirstWord(Box(root, "s"))!; + + Assert.AreEqual(word.Width, Box(root, "s").Rectangles.Values.Single().Width, Delta); + Assert.AreEqual(word.Right, FirstWord(Box(root, "t"))!.Left, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#inlineblock-width")] + public void InlineBlock_TooWideForTheLine_ShrinksToTheAvailableWidthAsOneBox() + { + var root = Layout("
        a aaaa bbbb cccc dddd eeee ffff gggg hhhh
        "); + var s = Box(root, "s"); + + Assert.AreEqual(1, s.Rectangles.Count); + Assert.AreEqual(0, s.Rectangles.Values.Single().X, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#inlineblock-replaced-width")] + public void InlineBlockImage_KeepsItsIntrinsicWidth() + { + var root = TestImages.Layout($"
        "); + + Assert.AreEqual(80, TestImages.ImageRect(root, "i").Width, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#min-max-widths")] + public void MaxWidth_ScalesDownAnImageKeepingItsRatio() + { + var root = TestImages.Layout($""); + var r = TestImages.ImageRect(root, "i"); + + Assert.AreEqual((20d, 10d), (r.Width, r.Height)); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#min-max-widths")] + public void MaxWidth_LimitsABlock() + { + var root = Layout("
        "); + + Assert.AreEqual(100, Box(root, "c").Size.Width, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#min-max-widths")] + public void MinWidth_WidensABlock() + { + var root = Layout("
        "); + + Assert.AreEqual(100, Box(root, "c").Size.Width, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#min-max-widths")] + public void MinWidth_WidensAnImage() + { + var root = TestImages.Layout($""); + + Assert.AreEqual(80, TestImages.ImageRect(root, "i").Width, Delta); + } + + private static CssRect? FirstWord(CssBox box) => + box.Words.Count > 0 ? box.Words[0] : box.Boxes.Select(FirstWord).FirstOrDefault(w => w != null); +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/TestSupport/TestImages.cs b/Source/Test/HtmlRenderer.IntegrationTest/TestSupport/TestImages.cs new file mode 100644 index 000000000..f28ca456c --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/TestSupport/TestImages.cs @@ -0,0 +1,31 @@ +using System.Drawing; +using System.Drawing.Imaging; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.TestSupport; + +/// Images for replaced element tests, loaded during layout so it sees their intrinsic size. +internal static class TestImages +{ + /// A transparent PNG of the given size as a data URI. + internal static string Png(int width, int height) + { + using var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb); + using var stream = new MemoryStream(); + bitmap.Save(stream, ImageFormat.Png); + return "data:image/png;base64," + Convert.ToBase64String(stream.ToArray()); + } + + /// Lays out a body fragment, loading its images synchronously. + internal static CssBox Layout(string body, double maxWidth = 1000) => + LayoutHarness.Layout(LayoutHarness.Wrap(body), maxWidth, prepare: root => + { + root.HtmlContainer!.AvoidAsyncImagesLoading = true; + root.HtmlContainer.AvoidImagesLateLoading = true; + }).Root; + + /// The border box of the image with the given id. + internal static RRect ImageRect(CssBox root, string id) => + LayoutHarness.FindById(root, id)!.Rectangles.Values.Single(); +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs index 145b1643f..d45cce0e8 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs @@ -4,9 +4,8 @@ namespace HtmlRenderer.IntegrationTest.Text; /// -/// Text of different sizes on one line shares a baseline, and a line is at least as tall as the block's own -/// font (CSS 2.1 10.8). The layout uses real fonts, so the expected positions come from their metrics, where -/// UnderlineOffset is the distance from the top of the line to the baseline. +/// The expected positions come from the real font metrics, where UnderlineOffset is the distance from +/// the top of the text to its baseline. /// [DoNotParallelize] [TestClass] @@ -15,6 +14,7 @@ public sealed class LineBaselineIntegrationTests private const double Delta = 0.5; [TestMethod] + [Spec("css21", "visudet.html#leading")] public void SmallerText_SitsOnTheBaselineOfTheLargerText() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -27,6 +27,7 @@ public void SmallerText_SitsOnTheBaselineOfTheLargerText() } [TestMethod] + [Spec("css21", "visudet.html#leading")] public void SmallerText_SitsOnTheBaselineOfTheBlocksOwnFont() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -38,6 +39,7 @@ public void SmallerText_SitsOnTheBaselineOfTheBlocksOwnFont() } [TestMethod] + [Spec("css21", "visudet.html#leading")] public void LineWithOnlySmallerText_IsAsTallAsTheBlocksFont() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineHeightIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineHeightIntegrationTests.cs new file mode 100644 index 000000000..47dccd7ff --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineHeightIntegrationTests.cs @@ -0,0 +1,163 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.Text; + +[DoNotParallelize] +[TestClass] +public sealed class LineHeightIntegrationTests +{ + private const double Delta = 0.5; + + private static readonly string Image = TestImages.Png(10, 10); + + private static CssBox Layout(string body) => LayoutHarness.Layout(LayoutHarness.Wrap(body), maxWidth: 400).Root; + + private static CssBox Box(CssBox root, string id) => LayoutHarness.FindById(root, id)!; + + private static double Height(CssBox box) => box.ActualBottom - box.Location.Y; + + private static CssRect FirstWord(CssBox box) => LayoutHarness.Descendants(box).SelectMany(b => b.Words).First(); + + [TestMethod] + [Spec("css21", "visudet.html#line-height")] + public void TallerInlineText_MakesTheLineTaller() + { + var root = Layout("
        xT
        "); + + Assert.AreEqual(Box(root, "s").ActualFont.Height, Height(Box(root, "p")), Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#line-height")] + public void TallImage_MakesTheLineTaller() + { + var root = TestImages.Layout($"
        xy
        "); + + Assert.AreEqual(100, Height(Box(root, "p")), Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#line-height")] + public void InlineBlockVerticalPadding_MakesTheLineTaller() + { + var plain = Layout("
        a
        x
        "); + var padded = Layout("
        a
        x
        "); + + Assert.AreEqual(Height(Box(plain, "p")) + 60, Height(Box(padded, "p")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#line-height")] + public void InlineBlockVerticalMargins_MakeTheLineTaller() + { + var plain = Layout("
        a
        x
        "); + var withMargins = Layout("
        a
        x
        "); + + Assert.AreEqual(Height(Box(plain, "p")) + 60, Height(Box(withMargins, "p")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#line-height")] + public void InlineLineHeight_MakesTheLineTaller() + { + var root = Layout("
        xy
        "); + + Assert.AreEqual(60, Height(Box(root, "p")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#line-height")] + public void Image_SitsOnTheBaseline() + { + var root = TestImages.Layout($"
        x
        "); + var word = FirstWord(Box(root, "t")); + var baseline = word.Top + word.OwnerBox.ActualFont.UnderlineOffset; + + Assert.AreEqual(baseline, TestImages.ImageRect(root, "i").Bottom, Delta); + } + + [TestMethod] + [Spec("css21", "visudet.html#leading")] + public void LineHeightLargerThanTheFont_MakesTheLineTaller() + { + var root = Layout("
        x
        "); + var p = Box(root, "p"); + + Assert.IsGreaterThan(p.ActualFont.Height, Height(p)); + } + + [TestMethod] + [Spec("css21", "visudet.html#leading")] + public void NumberLineHeight_ScalesWithTheFontSize() + { + var small = Layout("
        x
        "); + var large = Layout("
        x
        "); + + Assert.AreEqual(2 * Height(Box(small, "p")), Height(Box(large, "p")), 1); + } + + [TestMethod] + [Spec("css21", "visudet.html#leading")] + public void EveryLine_GetsTheLineHeight() + { + var one = Layout("
        x
        "); + var two = Layout("
        x
        y
        "); + + Assert.AreEqual(2 * Height(Box(one, "p")), Height(Box(two, "p")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#leading")] + public void LengthLineHeight_IsTheHeightOfTheLine() + { + var root = Layout("
        x
        "); + + Assert.AreEqual(40, Height(Box(root, "p")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#leading")] + public void LineHeightSmallerThanTheFont_MakesTheLineShorter() + { + var root = Layout("
        x
        "); + + Assert.AreEqual(5, Height(Box(root, "p")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#leading")] + public void HalfLeading_CentersTheTextInTheLine() + { + var root = Layout("
        x
        "); + var p = Box(root, "p"); + + Assert.AreEqual((60 - p.ActualFont.Height) / 2, FirstWord(p).Top - p.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#leading")] + public void PercentageLineHeight_ResolvesAgainstTheFontSize() + { + var root = Layout("
        x
        "); + + Assert.AreEqual(60, Height(Box(root, "p")), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#leading")] + public void NumberLineHeight_IsInheritedAsTheNumber() + { + var root = Layout("
        x
        "); + + Assert.AreEqual(60, Height(Box(root, "p")), Delta); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs index 471e15996..811dc3d72 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs @@ -3,53 +3,6 @@ namespace HtmlRenderer.IntegrationTest.Text; -/// -/// Verifies whether vertical-align actually repositions inline-level content relative to its line box. -/// Ported from PeachPDF's VerticalAlignIntegrationTests, whose header describes a fixed bug: in -/// PeachPDF, top/bottom/middle/text-top/text-bottom used to hit an empty -/// case in CssLayoutEngine.ApplyVerticalAlignment and were silent no-ops, while baseline/ -/// sub/super (and, in PeachPDF, table cells via the separate ApplyCellVerticalAlignment) -/// genuinely worked. -/// -/// -/// -/// HTML-Renderer's ApplyVerticalAlignment (Core/Dom/CssLayoutEngine.cs) has the same empty -/// case bodies for top/bottom/middle/text-top/text-bottom that -/// PeachPDF used to have, and real logic for sub/super/baseline (via -/// CssLineBox.SetBaseLine). But confirmed by direct execution against the built assembly, that -/// sub/super logic never actually moves ordinary inline content either - for the completely -/// standard <span style="vertical-align:sub">text</span> shape this file's helper uses -/// (and PeachPDF's did too), two things combine to make it a no-op: -/// -/// -/// Text is always split into its own anonymous child CssBox -/// (DomParser.CorrectTextBoxes) - the <span> itself owns no CssBox.Words -/// directly, its anonymous text child does. -/// vertical-align is not copied by the normal (non-"everything") overload of -/// CssBoxProperties.InheritStyle (Core/Dom/CssBoxProperties.cs, ~line 1490-1565) - so that -/// anonymous text child never inherits the span's vertical-align and keeps the default "baseline" -/// case. -/// -/// -/// CssLineBox.SetBaseLine(g, box, baseline) only ever moves the words returned by -/// WordsOf(box) (an exact word.OwnerBox == box match). So the switch's sub/super -/// branches do run for the <span> itself (which has the real vertical-align value) but -/// touch zero words (WordsOf(span) is empty), while the branch that runs for the anonymous text child -/// (which owns the real word) always takes the default/baseline case, because that child's own -/// vertical-align was never inherited. Net result, confirmed empirically: laying out eleven variants -/// (top, bottom, middle, text-top, text-bottom, sub, super, -/// baseline, and numeric/percentage lengths, none of which have any case in the switch at all) -/// produced the exact same word Top in every case - inline vertical-align is a total no-op in -/// this fork for the ordinary "element wraps a text node" markup shape. -/// -/// -/// Table cells are unaffected by any of this: ApplyCellVerticalAlignment is a separate code path that -/// calls b.OffsetTop(dist) directly on every child box of the cell (bypassing WordsOf and -/// inheritance entirely), and top/middle/bottom there are confirmed genuinely working by -/// direct execution (distinct word tops for top/middle/bottom, with middle landing exactly at the midpoint) - -/// so the two table-cell cases below are ported as real, non-ignored tests. -/// -/// // This fork's CssParser keeps a process-wide, non-thread-safe regex cache // (RegexParserUtils.GetRegex's static Dictionary) that SetHtml/DefaultCssData populate lazily on first use per // AppDomain; running HtmlContainerInt.SetHtml from more than one thread at once (as MSTestSettings.cs's @@ -62,14 +15,6 @@ public sealed class VerticalAlignIntegrationTests { private const double Delta = 0.5; - private const string InlineNoOpReason = - "HTML-Renderer's inline vertical-align is a total no-op for the standard text shape " + - "used here - confirmed by direct execution: the span's own Words collection is always empty (text " + - "lives on an anonymous child box instead), and that anonymous child never inherits vertical-align " + - "(CssBoxProperties.InheritStyle's normal overload does not copy it), so it always takes the " + - "default/baseline case in CssLayoutEngine.ApplyVerticalAlignment regardless of what the span's own " + - "vertical-align was set to. See class remarks for the full mechanism."; - private static double GetAlignedTop(string verticalAlign, string? lineHeight = null) { // "v" is deliberately smaller than its parent's own font (10px vs 16px) - text-top/text-bottom align @@ -85,7 +30,8 @@ private static double GetAlignedTop(string verticalAlign, string? lineHeight = n return LayoutHarness.Descendants(v).SelectMany(b => b.Words).First().Top; } - [Ignore(InlineNoOpReason)] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "visudet.html#leading")] [TestMethod] public void Top_PositionsHigherThanBottom() { @@ -95,7 +41,8 @@ public void Top_PositionsHigherThanBottom() Assert.IsTrue(topY < bottomY, $"expected top-aligned span ({topY}) to sit above bottom-aligned span ({bottomY})"); } - [Ignore(InlineNoOpReason)] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "visudet.html#leading")] [TestMethod] public void Middle_PositionsBetweenTopAndBottom() { @@ -106,7 +53,8 @@ public void Middle_PositionsBetweenTopAndBottom() Assert.IsTrue(middleY > topY && middleY < bottomY); } - [Ignore(InlineNoOpReason)] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "visudet.html#leading")] [TestMethod] public void TextTop_PositionsAboveTextBottom() { @@ -116,7 +64,8 @@ public void TextTop_PositionsAboveTextBottom() Assert.IsTrue(textTopY < textBottomY, $"top={textTopY} bottom={textBottomY}"); } - [Ignore(InlineNoOpReason)] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "visudet.html#leading")] [TestMethod] public void Sub_PositionsBelowSuper() { @@ -126,7 +75,8 @@ public void Sub_PositionsBelowSuper() Assert.IsTrue(subY > superY, $"sub={subY} super={superY}"); } - [Ignore(InlineNoOpReason)] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "visudet.html#leading")] [TestMethod] public void Bottom_DiffersFromDefaultBaselineAlignment() { @@ -136,7 +86,8 @@ public void Bottom_DiffersFromDefaultBaselineAlignment() Assert.AreNotEqual(baselineY, bottomY); } - [Ignore(InlineNoOpReason)] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "visudet.html#leading")] [TestMethod] public void Middle_DiffersFromDefaultBaselineAlignment() { @@ -234,20 +185,19 @@ public void Middle_OnATableCellWithExplicitHeight_CentersShortContent() Assert.AreEqual((topY + bottomY) / 2, middleY, Delta); } - [Ignore(InlineNoOpReason + " Numeric/percentage lengths have no case at all in the switch, so they fall " + - "to the same no-op default path.")] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "visudet.html#leading")] [TestMethod] public void Length_PositiveValue_RaisesTheBoxAboveBaseline() { - // CSS 2.1 §10.8.1: a positive length raises the box by that distance from its own baseline. var baselineY = GetAlignedTop("baseline"); var raisedY = GetAlignedTop("5px"); Assert.IsTrue(raisedY < baselineY, $"raised={raisedY} baseline={baselineY}"); } - [Ignore(InlineNoOpReason + " Numeric/percentage lengths have no case at all in the switch, so they fall " + - "to the same no-op default path.")] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "visudet.html#leading")] [TestMethod] public void Length_NegativeValue_LowersTheBoxBelowBaseline() { @@ -257,8 +207,8 @@ public void Length_NegativeValue_LowersTheBoxBelowBaseline() Assert.IsTrue(loweredY > baselineY, $"lowered={loweredY} baseline={baselineY}"); } - [Ignore(InlineNoOpReason + " Numeric/percentage lengths have no case at all in the switch, so they fall " + - "to the same no-op default path.")] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "visudet.html#leading")] [TestMethod] public void Percentage_PositiveValue_RaisesTheBoxAboveBaseline() { diff --git a/Spec/css21.yml b/Spec/css21.yml index 020ab2c6a..73c07ac8e 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -466,85 +466,141 @@ sections: - "'direction' on an inline element reorders its words although 'unicode-bidi' is 'normal'" visudet.html#q10.0: title: 10 Visual formatting model details - status: unknown + status: partial visudet.html#containing-block-details: title: 10.1 Definition of "containing block" - status: unknown + status: partial + gaps: + - An absolutely positioned box is placed against the content edge of its positioned ancestor instead of the padding edge + - Percentages on an absolutely positioned box resolve against its parent instead of its containing block + - A block inside an inline is laid out inside the inline's padding instead of the content box of the nearest block container visudet.html#the-width-property: title: 10.2 Content width - status: unknown + status: partial + gaps: + - Negative widths are applied instead of being rejected visudet.html#Computing_widths_and_margins: title: 10.3 Calculating widths and margins - status: unknown + status: partial visudet.html#inline-width: title: 10.3.1 Inline, non-replaced elements - status: unknown + status: partial + gaps: + - "'width' is applied to non-replaced inline boxes, it widens the box on the line" visudet.html#inline-replaced-width: title: 10.3.2 Inline, replaced elements - status: unknown + status: partial + gaps: + - Widths in units other than px and % are ignored, the image keeps its intrinsic width visudet.html#blockwidth: title: 10.3.3 Block-level, non-replaced elements in normal flow - status: unknown + status: partial + gaps: + - Auto margins compute to 0, a block with a width is never centered or pushed right by them + - In RTL, an over-constrained block ignores 'margin-right' instead of 'margin-left' visudet.html#block-replaced-width: title: 10.3.4 Block-level, replaced elements in normal flow - status: unknown + status: partial + gaps: + - Auto margins compute to 0, a block image is never centered by them visudet.html#float-width: title: 10.3.5 Floating, non-replaced elements - status: unknown + status: partial + gaps: + - The shrink-to-fit width of an auto width float is computed before its content is measured, on the first layout it's 0 or NaN and the text wraps after every word visudet.html#float-replaced-width: title: 10.3.6 Floating, replaced elements - status: unknown + status: supported visudet.html#abs-non-replaced-width: title: 10.3.7 Absolutely positioned, non-replaced elements - status: unknown + status: partial + gaps: + - With 'left' and 'right' set, an auto width is 0 instead of filling the space between them + - The shrink-to-fit width of an auto width box is computed before its content is measured, on the first layout it's 0 or NaN + - With 'left', 'right' and 'width' set, auto margins compute to 0 instead of centering the box visudet.html#abs-replaced-width: title: 10.3.8 Absolutely positioned, replaced elements - status: unknown + status: supported visudet.html#inlineblock-width: title: 10.3.9 'Inline-block', non-replaced elements in normal flow - status: unknown + status: partial + gaps: + - An auto width inline-block is flowed like an inline, content too wide for the line wraps across lines instead of shrinking the box to the available width visudet.html#inlineblock-replaced-width: title: 10.3.10 'Inline-block', replaced elements in normal flow - status: unknown + status: supported visudet.html#min-max-widths: title: 10.4 Minimum and maximum widths - status: unknown + status: partial + gaps: + - "'min-width' is ignored" + - "'max-width' only applies to images, it's ignored on non-replaced boxes" visudet.html#the-height-property: title: 10.5 Content height - status: unknown + status: partial + gaps: + - Percentage heights compute to auto even when the containing block has an explicit height + - Negative heights are applied instead of being rejected + - "'height' is ignored on inline-block boxes" visudet.html#Computing_heights_and_margins: title: 10.6 Calculating heights and margins - status: unknown + status: partial visudet.html#inline-non-replaced: title: 10.6.1 Inline, non-replaced elements - status: unknown + status: partial + gaps: + - Vertical padding and border of an inline box can push the line's content down and make the line box taller visudet.html#inline-replaced-height: title: 10.6.2 Inline replaced elements, block-level replaced elements in normal flow, 'inline-block' replaced elements in normal flow and floating replaced elements - status: unknown + status: partial + gaps: + - Heights in units other than px are ignored, the image keeps its intrinsic height visudet.html#normal-block: title: 10.6.3 Block-level non-replaced elements in normal flow when 'overflow' computes to 'visible' - status: unknown + status: supported visudet.html#abs-non-replaced-height: title: 10.6.4 Absolutely positioned, non-replaced elements - status: unknown + status: partial + gaps: + - With 'top' and 'bottom' set, an auto height is the content height instead of filling the space between them visudet.html#abs-replaced-height: title: 10.6.5 Absolutely positioned, replaced elements - status: unknown + status: supported visudet.html#block-root-margin: title: 10.6.6 Complicated cases - status: unknown + status: partial + gaps: + - An inline-block with block-level content is split out of the line like an inline + - The bottom padding of an inline-block without top padding isn't part of its height visudet.html#root-height: title: 10.6.7 'Auto' heights for block formatting context roots - status: unknown + status: partial + gaps: + - The auto height of a block formatting context root doesn't grow to contain its floats visudet.html#min-max-heights: title: 10.7 Minimum and maximum heights - status: unknown + status: partial + gaps: + - Percentages are ignored even when the containing block has an explicit height + - "'min-height' and 'max-height' are ignored on images" visudet.html#line-height: title: 10.8 Line height calculations - status: unknown + status: partial + gaps: + - Images are aligned with the top of the line instead of sitting on the baseline + - Vertical margins of an inline-block don't make the line box taller + - "'line-height' on an inline box is ignored, only the block's own 'line-height' sizes the line" visudet.html#leading: title: 10.8.1 Leading and half-leading - status: unknown + status: partial + gaps: + - The used line height is 90% of 'line-height' + - A 'line-height' smaller than the font doesn't make the line shorter + - No half-leading, the text sits at the top of the line instead of in its middle + - Percentages compute to 'normal' + - A number is inherited as the parent's computed length instead of the number + - "'vertical-align' top, bottom, middle, text-top, text-bottom, lengths and percentages are treated as 'baseline'" + - "'vertical-align: sub' and 'super' both move the text to the top of the line" visufx.html#q11.0: title: 11 Visual effects status: unknown From 25f2c26ddfb343df6d4b65946ba178fad6725c18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:01:39 +0100 Subject: [PATCH 015/150] Verify and document CSS 2.1 visual effects compliance --- .../Core/Paint/FragmentPainter.cs | 13 +- Source/HtmlRenderer/Core/Utils/RenderUtils.cs | 17 +- .../Painting/FragmentPaintIntegrationTests.cs | 44 +--- .../Painting/OverflowClipIntegrationTests.cs | 233 ++++++++++++++---- .../VisibilityPaintIntegrationTests.cs | 50 ++++ Spec/css21.yml | 20 +- 6 files changed, 267 insertions(+), 110 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/VisibilityPaintIntegrationTests.cs diff --git a/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs b/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs index 2e0ca541c..ae03396e8 100644 --- a/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs +++ b/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs @@ -137,19 +137,20 @@ internal void PaintFragmentSubtree(RGraphics g, BoxFragment fragment, double ban } /// - /// Paints one box fragment: display/visibility gate, fixed-position clip suspension, and the - /// same "is this rect actually in the visible area" cull the old live-tree walk used, before - /// handing off to the box's own content. + /// Paints one box fragment and its descendants, skipping it when it's hidden (CSS 2.1 §11.2) or + /// lies entirely outside the current clip. /// private void PaintFragment(RGraphics g, BoxFragment fragment) { var box = fragment.Box; try { + // Returning here also skips descendants that set 'visibility: visible' again. if (box.Display == CssConstants.None || box.Visibility != CssConstants.Visible) return; - // Only this box's own Position, not IsFixed's ancestor-aware sense - matching the old live-tree walk. + // CSS 2.1 §11.1.1, the containing block of a fixed box is the viewport, so the clips of its + // ancestors don't apply. var suspendsClip = box.Position == CssConstants.Fixed; if (suspendsClip) g.SuspendClipping(); @@ -157,9 +158,7 @@ private void PaintFragment(RGraphics g, BoxFragment fragment) var visible = box.Rectangles.Count == 0; if (!visible) { - // box.ContainingBlock.ClientRectangle is read off the live box tree - still absolute - // document-Y, unlike fragment-tree geometry, so this needs LiveTreeOffset (not just - // ScrollOffset) to land in this painter's target frame. + // The containing block's rectangle is in document coordinates, unlike fragment geometry. var clip = g.GetClip(); var rect = box.ContainingBlock.ClientRectangle; rect.X -= 2; diff --git a/Source/HtmlRenderer/Core/Utils/RenderUtils.cs b/Source/HtmlRenderer/Core/Utils/RenderUtils.cs index 4239f569c..238e336ac 100644 --- a/Source/HtmlRenderer/Core/Utils/RenderUtils.cs +++ b/Source/HtmlRenderer/Core/Utils/RenderUtils.cs @@ -32,20 +32,15 @@ public static bool IsColorVisible(RColor color) } /// - /// Clip the region the graphics will draw on by the overflow style of the containing block.
        - /// Recursively travel up the tree to find containing block that has overflow style set to hidden. if not - /// block found there will be no clipping and null will be returned. + /// Pushes a clip to the containing block's content box if any ancestor block has 'overflow: hidden' + /// (CSS 2.1 §11.1.1). ///
        /// the graphics to clip - /// the box that is rendered to get containing blocks - /// true - was clipped, false - not clipped + /// the box that is about to be painted + /// true if a clip was pushed and has to be popped by the caller /// - /// Added unconditionally (regardless of ) on top of the usual - /// scroll-offset handling below - passes its - /// to additionally undo the current - /// fragmentainer's band top, since .ContainingBlock's client rectangle is - /// read straight off the live box tree (still absolute document-Y) while the caller may be - /// painting into a page-local or page-origin-translated surface. + /// Added on top of the scroll offset. The client rectangle comes from the live box tree in document + /// coordinates, moves it to the page being painted. /// public static bool ClipGraphicsByOverflow(RGraphics g, CssBox box, RPoint extraOffset = default) { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs index b85a668b5..c2577a294 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs @@ -10,30 +10,9 @@ namespace HtmlRenderer.IntegrationTest.Painting; /// -/// Ported from PeachPDF.Tests/Integration/FragmentPaintIntegrationTests.cs: paint driven by the fragment -/// tree - each page paints its own fragmentainer and nothing else, and every drawn rectangle is the one the -/// fragment carries. Maps directly to Core/Paint/FragmentPainter.cs, and (for the multi-page tests) -/// its production per-page entry point HtmlContainerInt.PerformPaint(RGraphics, FragmentainerFragment), -/// exercised here through . +/// Paint driven by the fragment tree: each page paints its own fragmentainer and nothing else, and every drawn +/// rectangle is the one the fragment carries. /// -/// -/// All 8 of PeachPDF's tests port. Fixtures use CSS px directly (this port's -based -/// PageSize matches 1:1, per the convention PageBreakIntegrationTests already established) -/// rather than PeachPDF's pt. -/// -/// One confirmed gap surfaced while porting: StackingOrder_IsPreservedWhenPaintingFromFragments is -/// [Ignore]d - z-index is a parse-only stub, never consulted by paint order (see that test's own -/// remarks). A second, more consequential real bug was found and fixed as part of this batch, not merely -/// documented: HtmlContainerInt.PerformPaint(RGraphics, FragmentainerFragment) - the per-page paint -/// entry point PdfGenerator's own page loop calls - pushed a paint clip starting at Y=MarginTop -/// rather than Y=0, silently clipping away the first MarginTop-tall strip of every single page's own -/// content (fragment-tree geometry is already band-local, where a band's own top is local Y=0, not -/// MarginTop - see FragmentEmitter's own doc comment). Found while adapting -/// StraddlingListMarkerTests.EveryMarker_IsDrawnOnExactlyOnePage (a list item landing entirely within -/// the clipped strip and never appearing in any page's paint log, with no exception raised), fixed at its -/// source in HtmlContainerInt.cs - see that method's own updated remarks for the full mechanism. -/// -/// [TestClass] [DoNotParallelize] public sealed class FragmentPaintIntegrationTests @@ -133,16 +112,9 @@ public void FixedBox_PaintsAtTheSameCoordinatesOnEveryPage() Assert.AreEqual(painted[0].Y, painted[1].Y, 0.001); } - // CSS 2.1 Appendix E within one stacking context: the lower z-index sibling should paint first. - [Ignore("Confirmed gap, unrelated to fragment-tree painting: z-index is parsed and stored " + - "(CssEngine/StyleProperties/Flow/ZIndexProperty.cs) but never consulted anywhere in " + - "Core/Paint/ - confirmed by grepping the whole paint tree for it (no matches). " + - "FragmentPainter.PaintFragmentContent paints absolutely-positioned children in fragment.Children " + - "(document/DOM) order, with no z-index sort at all - so this fixture paints blue (DOM-first, " + - "higher z-index) before red (DOM-second, lower z-index), the opposite of what css-break-3 - " + - "unrelated, this is a pre-existing paint-order gap - requires. Confirmed by running this test " + - "unignored.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "zindex.html#painting-order")] public void StackingOrder_IsPreservedWhenPaintingFromFragments() { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -175,10 +147,9 @@ public void RowspanCell_ShowsThroughEveryRowItSpans() } [TestMethod] + [Spec("css21", "visufx.html#visibility")] public void VisibilityHidden_ReservesLayoutSpace_ButPaintsNothing_VisibleSiblingStillPaints() { - // Unlike display:none (which removes the box from layout entirely), visibility:hidden must still - // reserve its own space - the visible sibling starts right after it, not overlapping. var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( "" + "
        Visible
        ")); @@ -198,12 +169,9 @@ public void VisibilityHidden_ReservesLayoutSpace_ButPaintsNothing_VisibleSibling } [TestMethod] + [Spec("css21", "visufx.html#visibility")] public void VisibilityCollapse_ReservesLayoutSpace_ButPaintsNothing_VisibleSiblingStillPaints() { - // HTML-Renderer doesn't implement table row/column collapse layout either - FragmentPainter's own - // paint gate (Core/Paint/FragmentPainter.cs's PaintFragment) checks only "!= CssConstants.Visible", - // not the specific value, so visibility:collapse renders identically to visibility:hidden here too. - // Confirmed by direct source read. var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( "
        Collapsed
        " + "
        Visible
        ")); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/OverflowClipIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OverflowClipIntegrationTests.cs index 557d9ed27..a22fa061f 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/OverflowClipIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OverflowClipIntegrationTests.cs @@ -1,42 +1,20 @@ +using System.Collections.Generic; using System.Linq; using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; namespace HtmlRenderer.IntegrationTest.Painting; /// -/// Port of PeachPDF.Tests' OverflowClipIntegrationTests, which verifies that overflow:hidden clips -/// at the CSS padding edge (not the content edge) - PeachPDF's own fix for a bug where table cells (which -/// inherit overflow:hidden from the default stylesheet) clipped rounded children too tightly. +/// 'overflow' and 'clip'. The clip tests replay the recorded PushClip/PopClip calls to find the clip that was +/// active when a box painted its background. /// -/// -/// This fork shares the same default-stylesheet detail PeachPDF's bug report relies on - confirmed by direct -/// source read of Core/CssDefaults.cs line 111: td, th { border-color:#dfdfdf; overflow: hidden; }. -/// -/// However, direct source read of the actual clip mechanism shows the opposite divergence from what this port -/// was originally briefed on. RenderUtils.ClipGraphicsByOverflow (Core/Utils/RenderUtils.cs -/// ~42-59) clips to box.ContainingBlock.ClientRectangle (plus an unrelated 2px left-edge fudge). And -/// CssBoxProperties.ClientLeft/ClientRight (Core/Dom/CssBoxProperties.cs ~751-769) each -/// subtract BOTH border and padding on their own side: ClientLeft = Location.X + ActualBorderLeftWidth + -/// ActualPaddingLeft, ClientRight = ActualRight - ActualPaddingRight - ActualBorderRightWidth. That -/// makes ClientRectangle the CONTENT box (border and padding both excluded, symmetrically), not the -/// padding box - so this fork's real overflow:hidden clip lands at the content edge, narrower than a padding-box -/// clip, not wider. See the one test below marked [Ignore] for where this actually breaks a -/// padding-box-precision assertion; every other case here is a pure child-containment geometry check that holds -/// regardless of which edge the clip itself lands at (an auto-width/height child naturally stays within its -/// parent's content box, which is always the smallest of the three candidate edges). -/// -/// Standard CSS border-radius (and its longhands) used to not be recognized anywhere in this fork's -/// Core, so PeachPDF's rounded-corner fixtures were originally re-expressed here using this fork's then-only -/// proprietary corner-radius/corner-*-radius properties instead. The CSS engine port added real, -/// standard border-radius support (see the sibling BorderRadiusIntegrationTests class's remarks) -/// and removed the proprietary mechanism entirely, so the fixtures below now use the real property directly. -/// Since border-radius only affects paint shape, never box geometry, this substitution does not change what -/// any of the geometry assertions below are actually checking. -/// [DoNotParallelize] [TestClass] public sealed class OverflowClipIntegrationTests { + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + // --- Geometry tests (verify clip bounds after layout) --- [TestMethod] @@ -61,8 +39,7 @@ public void OverflowHidden_WithPadding_ChildBoundsWithinPaddingBoxClip() [TestMethod] public void RoundedBoxInTableCell_NonUniformRadius_BoundsWithinPaddingBoxClip() { - // Reproduces the original bug's setup: td gets overflow:hidden from the default stylesheet, and a - // non-uniformly-rounded div fills the td content area. + // td gets overflow:hidden from the default stylesheet, a non-uniformly rounded div fills its content area. var (root, _) = PaintHarness.Layout(PaintHarness.Wrap( "" + "" @@ -99,7 +76,7 @@ public void RoundedBoxInTableCell_FourCornerRadii_BoundsWithinPaddingBoxClip() [TestMethod] public void OverflowHidden_ZeroPadding_ClipEqualsContentBox() { - // When padding is 0, padding-box == content-box. The clip right should equal ClientRight (no expansion). + // With zero padding the padding box and the content box coincide. var (root, _) = PaintHarness.Layout(PaintHarness.Wrap( "
        " + "
        ")); @@ -120,9 +97,6 @@ public void OverflowHidden_ZeroPadding_ClipEqualsContentBox() [TestMethod] public void RoundedBoxInTableCell_MultipleRadii_LayoutAndPaintDoNotThrow() { - // Adapted from PeachPDF's PDF-generation smoke test - this fork has no PdfGenerator, so the equivalent - // "must not throw" check is a plain layout + paint pass over several varied rounded-corner combinations - // inside table cells (using the real border-radius/border-*-radius properties). var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( "
        " + "" @@ -135,19 +109,9 @@ public void RoundedBoxInTableCell_MultipleRadii_LayoutAndPaintDoNotThrow() PaintHarness.PaintBox(container, table); } - [Ignore("CONTRADICTS THIS FILE'S ORIGINAL PORTING BRIEF - confirmed by direct source read: " + - "CssBoxProperties.ClientLeft/ClientRight (Core/Dom/CssBoxProperties.cs ~751-769) subtract BOTH " + - "border AND padding on EACH side (ClientLeft = Location.X + ActualBorderLeftWidth + " + - "ActualPaddingLeft; ClientRight = ActualRight - ActualPaddingRight - ActualBorderRightWidth), so " + - "ClientRectangle is the CONTENT box, not the padding box. RenderUtils.ClipGraphicsByOverflow " + - "(Core/Utils/RenderUtils.cs ~42-59) clips directly to box.ContainingBlock.ClientRectangle with only " + - "an unrelated 2px left-edge fudge factor (a standing 'TODO: find a better way to fix it') - it never " + - "adds padding back the way PeachPDF's fix does. So this fork's real overflow:hidden clip lands at " + - "the CONTENT edge, narrower than the padding-box edge this test (and PeachPDF's current/fixed " + - "behavior) expects - the OPPOSITE direction of divergence from what this port was originally " + - "briefed on (which described the clip as too wide, not too narrow). Left in and ignored, rather than " + - "silently deleted, specifically so this correction is visible for the follow-up verification pass.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "visufx.html#overflow")] public void OverflowHidden_WithPadding_PaintClipReachesThePaddingBoxEdge() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -158,12 +122,183 @@ public void OverflowHidden_WithPadding_PaintClipReachesThePaddingBoxEdge() var g = PaintHarness.PaintBox(container, outer); - // The clip is pushed while painting 'inner' (whose containing block, 'outer', has overflow:hidden) - not - // while painting 'outer' itself. The harness's own container-bounds clip is pushed first, so the clip - // we want is the last one on the log. + // The harness pushes the container bounds first, the overflow clip is the last push. var clip = g.Log.OfType().Last().Rect; var paddingBoxRight = outer.ClientRight + outer.ActualPaddingRight; Assert.AreEqual(paddingBoxRight, clip.Right, 1.5); } + + [TestMethod] + [Spec("css21", "visufx.html#overflow")] + public void OverflowVisible_DoesNotClipOverflowingContent() + { + var clip = ClipOfRedBox( + "
        "); + + Assert.AreEqual(1000, clip.Right, 0.5); + Assert.AreEqual(4000, clip.Bottom, 0.5); + } + + [TestMethod] + [Spec("css21", "visufx.html#overflow")] + public void OverflowHidden_ClipsOverflowingChildToTheBox() + { + var clip = ClipOfRedBox( + "
        "); + + Assert.AreEqual(0, clip.Top, 0.5); + Assert.AreEqual(50, clip.Right, 0.5); + Assert.AreEqual(60, clip.Bottom, 0.5); + } + + [TestMethod] + [Spec("css21", "visufx.html#overflow")] + public void OverflowHidden_ClipsOverflowingText() + { + var (_, container) = PaintHarness.Layout(Doc( + "
        aaa bbb ccc ddd
        ")); + var g = PaintHarness.PaintPage(container); + + Assert.AreEqual(10, ClipAt(g, e => e is RecordingGraphics.DrawStringCall s && s.Text == "aaa").Bottom, 0.5); + if (g.DrawStringCalls.Any(c => c.Text == "ddd")) + Assert.AreEqual(10, ClipAt(g, e => e is RecordingGraphics.DrawStringCall s && s.Text == "ddd").Bottom, 0.5); + } + + [TestMethod] + [Spec("css21", "visufx.html#overflow")] + public void OverflowHidden_ClipsAbsoluteDescendantWhoseContainingBlockIsTheBox() + { + var clip = ClipOfRedBox( + "
        " + + "
        "); + + Assert.AreEqual(50, clip.Right, 0.5); + Assert.AreEqual(50, clip.Bottom, 0.5); + } + + [TestMethod] + [Spec("css21", "visufx.html#overflow")] + public void OverflowHidden_OnAnInlineElement_DoesNotClip() + { + var clip = ClipOfRedBox( + "
        "); + + Assert.AreEqual(1000, clip.Right, 0.5); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visufx.html#overflow")] + [DataRow("scroll")] + [DataRow("auto")] + public void ScrollAndAuto_ClipOverflowingContent(string overflow) + { + var clip = ClipOfRedBox( + $"
        "); + + Assert.AreEqual(50, clip.Right, 0.5); + Assert.AreEqual(50, clip.Bottom, 0.5); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visufx.html#overflow")] + public void OverflowHidden_DoesNotClipAtAnIntermediateNonClippingBox() + { + var clip = ClipOfRedBox( + "
        " + + "
        "); + + Assert.AreEqual(200, clip.Right, 0.5); + Assert.AreEqual(200, clip.Bottom, 0.5); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visufx.html#overflow")] + public void OverflowHidden_DoesNotClipAbsoluteDescendantWhoseContainingBlockIsAnAncestor() + { + var clip = ClipOfRedBox( + "
        " + + "
        "); + + Assert.AreEqual(1000, clip.Right, 0.5); + Assert.AreEqual(4000, clip.Bottom, 0.5); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visufx.html#overflow")] + public void OverflowHidden_DoesNotClipFixedDescendant() + { + var clip = ClipOfRedBox( + "
        " + + "
        "); + + Assert.AreEqual(1000, clip.Right, 0.5); + Assert.AreEqual(4000, clip.Bottom, 0.5); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visufx.html#overflow")] + public void OverflowOnBody_IsAppliedToTheViewport() + { + var (_, container) = PaintHarness.Layout( + "
        ", + 300, 300); + var g = PaintHarness.PaintPage(container); + + var clip = ClipAt(g, e => e is RecordingGraphics.DrawRectCall r && r.Color == Red); + + Assert.AreEqual(300, clip.Bottom, 0.5); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visufx.html#clipping")] + public void ClipRect_ClipsAnAbsolutelyPositionedBox() + { + var clip = ClipOfRedBox( + "
        "); + + Assert.AreEqual(10, clip.Right, 0.5); + Assert.AreEqual(20, clip.Bottom, 0.5); + } + + private static string Doc(string body) => $"{body}"; + + private static RRect ClipOfRedBox(string body) + { + var (_, container) = PaintHarness.Layout(Doc(body)); + var g = PaintHarness.PaintPage(container); + + return ClipAt(g, e => e is RecordingGraphics.DrawRectCall r && r.Color == Red); + } + + /// The intersection of all clips pushed and not yet popped when the first matching call was recorded. + private static RRect ClipAt(RecordingGraphics g, System.Func match) + { + var stack = new List(); + foreach (var entry in g.Log) + { + switch (entry) + { + case RecordingGraphics.PushClipCall push: + stack.Add(push.Rect); + break; + case RecordingGraphics.PopClipCall: + stack.RemoveAt(stack.Count - 1); + break; + default: + if (match(entry)) + return stack.Aggregate(RRect.Intersect); + break; + } + } + + Assert.Fail("No matching draw call was recorded"); + return RRect.Empty; + } } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/VisibilityPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/VisibilityPaintIntegrationTests.cs new file mode 100644 index 000000000..0d51b86df --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/VisibilityPaintIntegrationTests.cs @@ -0,0 +1,50 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[TestClass] +public sealed class VisibilityPaintIntegrationTests +{ + [TestMethod] + [Spec("css21", "visufx.html#visibility")] + public void Hidden_IsInheritedByDescendants() + { + var recording = Paint("
        Child
        "); + + Assert.IsFalse(recording.Log.OfType().Any(r => r.Color == RColor.FromArgb(255, 0, 0))); + Assert.IsFalse(recording.DrawStringCalls.Any(c => c.Text == "Child")); + } + + [TestMethod] + [Spec("css21", "visufx.html#visibility")] + public void HiddenInline_PaintsNothingButKeepsItsSpace() + { + var hidden = Paint("
        aaa Hid bbb
        "); + var visible = Paint("
        aaa Hid bbb
        "); + + Assert.IsFalse(hidden.DrawStringCalls.Any(c => c.Text == "Hid")); + Assert.AreEqual(visible.DrawStringCalls.Single(c => c.Text == "bbb").Point, hidden.DrawStringCalls.Single(c => c.Text == "bbb").Point); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visufx.html#visibility")] + public void VisibleDescendantOfAHiddenElement_IsPainted() + { + var recording = Paint("
        Shown
        "); + + Assert.IsTrue(recording.Log.OfType().Any(r => r.Color == RColor.FromArgb(255, 0, 0))); + Assert.IsTrue(recording.DrawStringCalls.Any(c => c.Text == "Shown")); + } + + private static string Doc(string body) => $"{body}"; + + private static RecordingGraphics Paint(string body) + { + var (_, container) = PaintHarness.Layout(Doc(body)); + return PaintHarness.PaintPage(container); + } +} diff --git a/Spec/css21.yml b/Spec/css21.yml index 73c07ac8e..b1c5af93b 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -603,19 +603,29 @@ sections: - "'vertical-align: sub' and 'super' both move the text to the top of the line" visufx.html#q11.0: title: 11 Visual effects - status: unknown + status: partial visufx.html#overflow-clipping: title: 11.1 Overflow and clipping - status: unknown + status: partial visufx.html#overflow: title: 11.1.1 Overflow - status: unknown + status: partial + gaps: + - "'hidden' clips at the content edge (widened by 2px on the left) instead of the padding edge" + - "'scroll' and 'auto' don't clip, they behave like 'visible'" + - Content overflowing a non-clipping box inside a clipping one is clipped at the content edge of the non-clipping box + - Absolutely positioned and fixed descendants are clipped even when their containing block is outside the clipping box + - "'overflow' on the root or body clips that box instead of being applied to the viewport" visufx.html#clipping: title: 11.1.2 Clipping - status: unknown + status: parsed-only + gaps: + - "'clip' is ignored, absolutely positioned boxes are painted unclipped" visufx.html#visibility: title: 11.2 Visibility - status: unknown + status: partial + gaps: + - "Descendants with 'visibility: visible' of a hidden element are not painted" generate.html#generated-text: title: 12 Generated content, automatic numbering, and lists status: unknown From dc9b4c6e9ad57b122cc500fb4c7c72e651b5fbad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:02:35 +0100 Subject: [PATCH 016/150] Verify and document CSS 2.1 generated content and lists compliance --- .../HtmlRenderer/Core/Dom/CssBoxProperties.cs | 6 +- .../HtmlRenderer/Core/Dom/CssContentEngine.cs | 38 +-- .../HtmlRenderer/Core/Dom/CssCounterEngine.cs | 39 +-- Source/HtmlRenderer/Core/Utils/CommonUtils.cs | 19 +- .../CssContentEngineIntegrationTests.cs | 35 +-- .../Content/GeneratedContentTests.cs | 289 ++++++++++++++++++ .../Content/ListStyleTests.cs | 108 +++++++ .../Css/ListPropertyTests.cs | 39 ++- .../Css/PropertyContentTests.cs | 33 +- .../Dom/CssCounterEngineTests.cs | 14 +- Spec/css21.yml | 45 ++- 11 files changed, 516 insertions(+), 149 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index d73db3afd..87f344cea 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs @@ -791,10 +791,8 @@ public RColor ActualOutlineColor } /// - /// This box's resolved named-counter values (CSS 2.1 §12.4), as of just after its own - /// counter-reset/counter-increment have been applied - populated once, by - /// , before layout runs. Consulted by - /// content: counter() (). + /// The counter values in scope after this box's own counter-reset and counter-increment + /// (CSS 2.1 §12.4), set by . /// internal System.Collections.Generic.Dictionary Counters { get; set; } = new System.Collections.Generic.Dictionary(); diff --git a/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs b/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs index 09ceba7b5..629124594 100644 --- a/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs @@ -20,26 +20,13 @@ namespace TheArtOfDev.HtmlRenderer.Core.Dom { /// - /// Resolves a synthesized ::before/::after pseudo-element box's - /// value into literal text, so it renders through the box's normal / - /// machinery like any other text box.
        - /// Deliberately a small, self-contained subset of the full content grammar: literal strings, - /// attr(), and the four quote keywords are supported; counter()/counters()/ - /// string()/content() are not (CSS counters and named strings remain out of scope - /// for this backport - an unrecognized component is simply skipped, the same graceful-ignore - /// behavior this engine already applies to unsupported CSS elsewhere).
        - /// Tokenizes via the shared CSS lexer () rather than - /// hand-parsing the CSS text, so string literals (e.g. "\A", the standard UA-stylesheet - /// line-break trick used by br:before) get the same correct escape decoding the lexer - /// already applies when the stylesheet is first parsed - see CalcParser/BackgroundPositionGrammar - /// for the same "don't write two independent parsers for the same grammar" precedent. + /// Turns the content value of a ::before/::after box into its + /// (CSS 2.1 §12.2). Components it doesn't know are skipped. ///
        internal static class CssContentEngine { /// - /// If is a synthesized pseudo-element with a resolved content value, - /// sets its to the resolved literal text. No-op for any other box, or - /// if content is none/normal/empty. + /// Sets the text of a pseudo-element box from its content, other boxes are left alone. /// public static void ApplyContent(CssBox box) { @@ -60,6 +47,7 @@ private static string Resolve(CssBox box, string content) { var sb = new StringBuilder(); + // The CSS lexer decodes string escapes such as "\A" foreach (var token in CssValueParser.GetCssTokens(content)) { if (token is StringToken stringToken) @@ -79,11 +67,7 @@ private static string Resolve(CssBox box, string content) else if (token is FunctionToken counterFunctionToken && counterFunctionToken.Data.Equals(FunctionNames.Counter, StringComparison.OrdinalIgnoreCase)) { - // counter( [, "); @@ -46,6 +36,7 @@ public void ApplyContent_WithNone_DoesNotCreateContent() } [TestMethod] + [Spec("css21", "generate.html#content")] public void ApplyContent_WithAttrFunction_RetrievesAttribute() { var html = LayoutHarness.Wrap( @@ -58,6 +49,7 @@ public void ApplyContent_WithAttrFunction_RetrievesAttribute() } [TestMethod] + [Spec("css21", "generate.html#quotes-insert")] public void ApplyContent_WithOpenAndCloseQuote_UsesCurlyQuotes() { var before = ResolveBeforeContent("open-quote \"quoted\" close-quote"); @@ -65,6 +57,7 @@ public void ApplyContent_WithOpenAndCloseQuote_UsesCurlyQuotes() } [TestMethod] + [Spec("css21", "generate.html#counter-styles")] public void ApplyContent_WithCounterNoStyle_UsesDecimal() { var html = LayoutHarness.Wrap( @@ -76,6 +69,7 @@ public void ApplyContent_WithCounterNoStyle_UsesDecimal() } [TestMethod] + [Spec("css21", "generate.html#counter-styles")] public void ApplyContent_WithCounterDecimalLeadingZero_PadsToTwoDigits() { var html = LayoutHarness.Wrap( @@ -88,6 +82,7 @@ public void ApplyContent_WithCounterDecimalLeadingZero_PadsToTwoDigits() } [TestMethod] + [Spec("css21", "generate.html#counter-styles")] public void ApplyContent_WithCounterAlphabeticStyle_FormatsWithStyle() { var html = LayoutHarness.Wrap( @@ -102,7 +97,7 @@ public void ApplyContent_WithCounterAlphabeticStyle_FormatsWithStyle() [TestMethod] public void ApplyContent_WithCounterUnknownStyle_FallsBackToDecimal() { - // CSS Counter Styles Level 3 §2: unknown style must render as decimal, not empty. + // CSS Counter Styles 3 §2 var html = LayoutHarness.Wrap( "

        text

        " + ""); @@ -113,6 +108,7 @@ public void ApplyContent_WithCounterUnknownStyle_FallsBackToDecimal() } [TestMethod] + [Spec("css21", "generate.html#content")] public void ApplyContent_WithCounterAndStyleAndLiteral_Concatenates() { var html = LayoutHarness.Wrap( @@ -125,10 +121,9 @@ public void ApplyContent_WithCounterAndStyleAndLiteral_Concatenates() } [TestMethod] + [Spec("css21", "generate.html#counters")] public void CounterIncrement_ThreadsSequentiallyAcrossSiblings() { - // CSS 2.1 §12.4: counter-increment accumulates through document order - three siblings each - // incrementing "item" should see 1, 2, 3 in turn. var html = LayoutHarness.Wrap( "
        " + "

        text

        " @@ -148,10 +143,9 @@ public void CounterIncrement_ThreadsSequentiallyAcrossSiblings() } [TestMethod] + [Spec("css21", "generate.html#scope")] public void CounterReset_OnNestedAncestor_IsVisibleToDescendant() { - // A counter-reset on an ancestor is visible to descendants further down the tree, not just - // direct children. var html = LayoutHarness.Wrap( "

        text

        " + ""); @@ -162,10 +156,9 @@ public void CounterReset_OnNestedAncestor_IsVisibleToDescendant() } [TestMethod] + [Spec("css21", "generate.html#scope")] public void CounterReset_InANestedList_DoesNotLeakIntoTheOuterList() { - // The inner list's reset creates a new counter that goes out of scope with the inner list, so - // the outer list continues at 2. var html = LayoutHarness.Wrap( "
        " + "
        A" @@ -185,6 +178,7 @@ public void CounterReset_InANestedList_DoesNotLeakIntoTheOuterList() } [TestMethod] + [Spec("css21", "generate.html#undisplayed-counters")] public void CounterIncrement_OnADisplayNoneElement_IsIgnored() { var html = LayoutHarness.Wrap( @@ -201,6 +195,7 @@ public void CounterIncrement_OnADisplayNoneElement_IsIgnored() } [TestMethod] + [Spec("css21", "generate.html#scope")] public void Counter_NotInScope_ReadsAsZero() { Assert.AreEqual("0", ResolveBeforeContent("counter(nothing)").Text); diff --git a/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs new file mode 100644 index 000000000..20a090c55 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs @@ -0,0 +1,289 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Content; + +[TestClass] +public sealed class GeneratedContentTests +{ + private const double Delta = 0.5; + + [TestMethod] + [Spec("css21", "generate.html#before-after-content")] + public void BeforeAndAfter_AreTheFirstAndLastChildOfTheElement() + { + var x = Box("

        a

        "); + + Assert.IsTrue(x.Boxes[0].IsBeforePseudoElement); + Assert.AreEqual("B", x.Boxes[0].Text); + Assert.IsTrue(x.Boxes[^1].IsAfterPseudoElement); + Assert.AreEqual("A", x.Boxes[^1].Text); + } + + [TestMethod] + [Spec("css21", "generate.html#before-after-content")] + public void PseudoElement_InheritsInheritedPropertiesOnly() + { + var before = Before("

        a

        "); + + Assert.AreEqual(255, before.ActualColor.R); + Assert.AreEqual(0, before.ActualColor.G); + Assert.AreEqual(0, before.ActualBorderLeftWidth); + Assert.AreEqual("inline", before.Display); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "generate.html#before-after-content")] + public void BlockPseudoElement_IsLaidOutAsABlockInsideTheElement() + { + var inline = Box("
        Text
        "); + var block = Box("
        Text
        "); + + Assert.AreEqual(2 * inline.Size.Height, block.Size.Height, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "generate.html#before-after-content")] + [DataRow("none")] + [DataRow("normal")] + public void PseudoElementWithoutContent_IsNotGenerated(string content) + { + var plain = Box("
        Text
        "); + var x = Box($"
        Text
        "); + + Assert.AreEqual(plain.Size.Height, x.Size.Height, Delta); + } + + [TestMethod] + [Spec("css21", "generate.html#content")] + public void Content_ConcatenatesStringsAndAttributes() + { + var before = Before("

        a

        "); + + Assert.AreEqual("No. 7!", before.Text); + } + + [TestMethod] + [Spec("css21", "generate.html#content")] + public void Content_OnAnElement_IsIgnored() + { + var x = Box("

        a

        "); + + Assert.IsFalse(LayoutHarness.Descendants(x).Any(b => b.Text == "X")); + } + + [TestMethod] + [Spec("css21", "generate.html#content")] + public void NewlineEscape_BreaksTheLineWhenWhiteSpaceIsPreserved() + { + var oneLine = Box("
        "); + var pre = Box("
        "); + + Assert.AreEqual(2 * oneLine.Size.Height, pre.Size.Height, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "generate.html#content")] + public void NewlineEscape_CollapsesToASpaceWithNormalWhiteSpace() + { + var oneLine = Box("
        "); + var collapsed = Box("
        "); + + Assert.AreEqual(oneLine.Size.Height, collapsed.Size.Height, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "generate.html#content")] + public void CountersFunction_JoinsAllCountersInScope() + { + var html = "

        a

        " + + ""; + + Assert.AreEqual("1.2", Before(html).Text); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "generate.html#content")] + public void Uri_InsertsTheImage() + { + var before = Before("

        a

        "); + + Assert.IsTrue(LayoutHarness.Descendants(before).Any(b => b.Words.Any(w => w.IsImage))); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "generate.html#quotes-specify")] + public void Quotes_SuppliesTheQuoteMarks() + { + Assert.AreEqual("", Before("

        '\">a

        ").Text); + Assert.AreEqual("a", Before("

        a

        ").Text); + } + + [TestMethod] + [Spec("css21", "generate.html#quotes-insert")] + public void OpenAndCloseQuote_InsertQuoteMarks() + { + var html = "

        a

        "; + var x = Box(html); + + Assert.AreEqual("“", x.Boxes[0].Text); + Assert.AreEqual("”", x.Boxes[^1].Text); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "generate.html#quotes-insert")] + public void NestedQuote_UsesTheNextPair() + { + var html = "

        a b

        " + + ""; + + Assert.AreEqual("2", Before(html).Text); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "generate.html#quotes-insert")] + public void CloseQuoteAtDepthZero_InsertsNothing() + { + Assert.AreEqual("a", Before("

        a

        ").Text); + } + + [TestMethod] + [Spec("css21", "generate.html#counters")] + public void CounterResetAndIncrement_OnTheSameElement_ResetFirst() + { + Assert.AreEqual("6", Before("

        a

        ").Text); + } + + [TestMethod] + [Spec("css21", "generate.html#counters")] + public void RepeatedCounterName_IsProcessedInOrder() + { + Assert.AreEqual("0", Before("

        a

        ").Text); + Assert.AreEqual("3", Before("

        a

        ").Text); + } + + [TestMethod] + [Spec("css21", "generate.html#counters")] + public void ZeroAndNegativeIncrements_AreAllowed() + { + Assert.AreEqual("5", Before("

        a

        ").Text); + Assert.AreEqual("3", Before("

        a

        ").Text); + } + + [TestMethod] + [Spec("css21", "generate.html#counters")] + public void CounterReset_FollowsTheCascade() + { + var html = "

        a

        "; + + Assert.AreEqual("02", Before(html).Text); + } + + [TestMethod] + [Spec("css21", "generate.html#counters")] + [Spec("css21", "generate.html#scope")] + public void ChaptersAndSections_AreNumbered() + { + var html = "
        " + + "

        About CSS

        CSS 2

        CSS 2.1

        Style

        Selectors

        " + + "
        "; + + Assert.AreEqual("Chapter 1. ", Before(html, "c1").Text); + Assert.AreEqual("1.1 ", Before(html, "s1").Text); + Assert.AreEqual("1.2 ", Before(html, "s2").Text); + Assert.AreEqual("Chapter 2. ", Before(html, "c2").Text); + Assert.AreEqual("2.1 ", Before(html, "s3").Text); + } + + [TestMethod] + [Spec("css21", "generate.html#scope")] + public void NestedLists_GetANewCounterInstancePerList() + { + var html = "
        1. i
        2. i" + + "
          1. i
          2. i
          3. i" + + "
            1. i
            1. i
            " + + "
          4. i
          " + + "
        3. i
        4. i
        " + + "
        1. i
        2. i
        " + + ""; + + var expected = new[] { ("a", "1"), ("b", "2"), ("c", "1"), ("d", "2"), ("e", "3"), ("f", "1"), ("g", "1"), ("h", "4"), ("i", "3"), ("j", "4"), ("k", "1"), ("l", "2") }; + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(html)); + foreach (var (id, number) in expected) + Assert.AreEqual(number, LayoutHarness.Descendants(LayoutHarness.FindById(root, id)!).First(b => b.IsBeforePseudoElement).Text, id); + } + + [TestMethod] + [Spec("css21", "generate.html#scope")] + public void CounterIncrement_OutOfScope_ResetsTheCounterOnThatElement() + { + var html = "

        a

        b

        "; + + Assert.AreEqual("1", Before(html).Text); + } + + [TestMethod] + [Spec("css21", "generate.html#counter-styles")] + [DataRow("upper-latin", "C")] + [DataRow("lower-latin", "c")] + [DataRow("lower-greek", "γ")] + [DataRow("lower-roman", "iii")] + [DataRow("armenian", "Գ")] + [DataRow("georgian", "გ")] + [DataRow("decimal-leading-zero", "03")] + public void Counter_IsFormattedInTheGivenStyle(string style, string expected) + { + Assert.AreEqual(expected, Before($"

        a

        ").Text); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "generate.html#counter-styles")] + public void Counter_WithGlyphOrNoneStyle_UsesTheGlyphOrNothing() + { + Assert.AreEqual("[]", Before("

        a

        ").Text); + Assert.AreEqual(Marker("
        • a
        "), + Before("

        a

        ").Text); + } + + [TestMethod] + [Spec("css21", "generate.html#undisplayed-counters")] + public void HiddenElement_StillIncrements() + { + var html = "

        a

        b

        c

        " + + ""; + + Assert.AreEqual("1", Before(html).Text); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "generate.html#undisplayed-counters")] + public void PseudoElementThatIsNotGenerated_DoesNotIncrement() + { + var html = "

        a

        b

        " + + ""; + + Assert.AreEqual("0", Before(html).Text); + } + + private static CssBox Before(string body, string id = "x") + { + var before = Box(body, id).Boxes.FirstOrDefault(b => b.IsBeforePseudoElement); + Assert.IsNotNull(before, $"No ::before on '{id}'"); + return before; + } +} diff --git a/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs b/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs new file mode 100644 index 000000000..7f046a9f0 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs @@ -0,0 +1,108 @@ +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Content; + +[TestClass] +public sealed class ListStyleTests +{ + [TestMethod] + [Spec("css21", "generate.html#lists")] + public void ListItem_GetsAMarkerBox() + { + var x = Box("
        a
        "); + + Assert.IsNotNull(x.ListItemBox); + Assert.AreEqual("•", Marker("
        a
        ")); + } + + [TestMethod] + [Spec("css21", "generate.html#list-style")] + [DataRow("decimal", "3.")] + [DataRow("decimal-leading-zero", "03.")] + [DataRow("lower-roman", "iii.")] + [DataRow("upper-roman", "III.")] + [DataRow("lower-greek", "γ.")] + [DataRow("lower-latin", "c.")] + [DataRow("upper-latin", "C.")] + [DataRow("lower-alpha", "c.")] + [DataRow("upper-alpha", "C.")] + [DataRow("armenian", "Գ.")] + [DataRow("georgian", "გ.")] + public void NumberedListStyleType_NumbersTheItem(string type, string expected) + { + Assert.AreEqual(expected, Marker($"
        1. a
        2. b
        3. c
        ")); + } + + [TestMethod] + [Spec("css21", "generate.html#list-style")] + public void GlyphListStyleTypes_AreDistinct() + { + var disc = Marker("
        • a
        "); + var circle = Marker("
        • a
        "); + var square = Marker("
        • a
        "); + + Assert.AreEqual(3, new[] { disc, circle, square }.Where(m => m.Length > 0).Distinct().Count()); + } + + [TestMethod] + [Spec("css21", "generate.html#list-style")] + public void ListStyleTypeNone_HasNoMarker() + { + Assert.IsNull(Box("
        • a
        ").ListItemBox); + } + + [TestMethod] + [Spec("css21", "generate.html#list-style")] + public void ListStyleShorthand_SetsTheType() + { + Assert.AreEqual("square", Box("
        • a
        ").ListStyleType); + Assert.IsNull(Box("
        • a
        ").ListItemBox); + } + + [TestMethod] + [Spec("css21", "generate.html#list-style")] + public void OutsideMarker_IsLeftOfTheContent() + { + var x = Box("
        • a
        "); + + Assert.IsTrue(x.ListItemBox!.Words[0].Right <= x.Location.X); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "generate.html#list-style")] + public void InsideMarker_IsInsideThePrincipalBox() + { + var x = Box("
        • a
        "); + + Assert.IsTrue(x.ListItemBox!.Words[0].Left >= x.Location.X); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "generate.html#list-style")] + public void RtlMarker_IsRightOfTheContent() + { + var x = Box("
        • a
        "); + + Assert.IsTrue(x.ListItemBox!.Words[0].Left >= x.Location.X + x.Size.Width); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "generate.html#list-style")] + public void ListStyleImage_ReplacesTheMarker() + { + var x = Box("
        • a
        "); + + Assert.IsTrue(x.ListItemBox!.Words[0].IsImage); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "generate.html#list-style")] + public void Georgian_Twenty_IsKan() + { + Assert.AreEqual("კ.", Marker("
        1. a
        ")); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/ListPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/ListPropertyTests.cs index 44f55dfc9..2e7ba758c 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ListPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ListPropertyTests.cs @@ -4,18 +4,11 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/ListProperty.cs. Exercises list-style-position -/// (), list-style-image (), -/// list-style-type (), the list-style shorthand -/// (), and counter-reset/counter-increment -/// (/) — all under -/// Source/HtmlRenderer/Core/CssEngine/StyleProperties/List/. -/// [TestClass] public sealed class ListPropertyTests { [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStylePositionOutsideLegal() { var property = ParseDeclaration("list-style-position: outside "); @@ -29,6 +22,7 @@ public void CssListStylePositionOutsideLegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStylePositionOutsideIllegal() { var property = ParseDeclaration("list-style-position: out-side "); @@ -41,6 +35,7 @@ public void CssListStylePositionOutsideIllegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStylePositionNoneIllegal() { var property = ParseDeclaration("list-style-position: none "); @@ -53,6 +48,7 @@ public void CssListStylePositionNoneIllegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStylePositionInsideLegal() { var property = ParseDeclaration("list-style-position: insiDe "); @@ -66,6 +62,7 @@ public void CssListStylePositionInsideLegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStyleImageNoneLegal() { var property = ParseDeclaration("list-style-image: none "); @@ -79,6 +76,7 @@ public void CssListStyleImageNoneLegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStyleImageUrlLegal() { var property = ParseDeclaration("list-style-image: url(http://www.example.com/images/list.png)"); @@ -92,6 +90,7 @@ public void CssListStyleImageUrlLegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStyleTypeDiscLegal() { var property = ParseDeclaration("list-style-type: disc "); @@ -105,6 +104,7 @@ public void CssListStyleTypeDiscLegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStyleTypeLowerAlphaLegal() { var property = ParseDeclaration("list-style-type: lower-ALPHA "); @@ -121,6 +121,7 @@ public void CssListStyleTypeLowerAlphaLegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStyleTypeGeorgianLegal() { var property = ParseDeclaration("list-style-type: georgian "); @@ -134,6 +135,7 @@ public void CssListStyleTypeGeorgianLegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStyleTypeDecimalLeadingZeroLegal() { var property = ParseDeclaration("list-style-type: decimal-leading-zerO "); @@ -149,12 +151,7 @@ public void CssListStyleTypeDecimalLeadingZeroLegal() [TestMethod] public void CssListStyleTypeHebrewLegal() { - // "hebrew" is the one CSS2-era list-style-type keyword from PeachPDF's extended-keyword-set - // theory (CSS Counter Styles Level 3 predefined counter styles) that Map.ListStyles / - // ListStyle actually defines here. The rest of that set (hiragana, katakana, arabic-indic, - // cjk-decimal, the lower-armenian/upper-armenian split, disclosure-open/closed, etc.) has no - // ListStyle enum member or Map entry at all - see CssListStyleTypeExtendedKeywordSetUnsupported - // below. + // CSS 2.0 keyword, dropped from CSS 2.1, still defined in Map.ListStyles var property = ParseDeclaration("list-style-type: HEbrew "); Assert.AreEqual("list-style-type", property.Name); Assert.IsFalse(property.IsImportant); @@ -204,6 +201,7 @@ public void CssListStyleTypeStringLegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStyleTypeNumberIllegal() { var property = ParseDeclaration("list-style-type: number "); @@ -216,6 +214,7 @@ public void CssListStyleTypeNumberIllegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStyleCircleLegal() { var property = ParseDeclaration("list-style: circle "); @@ -229,6 +228,7 @@ public void CssListStyleCircleLegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStyleNone() { var property = ParseDeclaration("list-style: none"); @@ -242,6 +242,7 @@ public void CssListStyleNone() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStyleSquareInsideLegal() { var property = ParseDeclaration("list-style: square inside "); @@ -255,6 +256,7 @@ public void CssListStyleSquareInsideLegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStyleSquareImageInsideLegal() { var property = ParseDeclaration("list-style: square url('image.png') inside "); @@ -268,6 +270,7 @@ public void CssListStyleSquareImageInsideLegal() } [TestMethod] + [Spec("css21", "generate.html#counters")] public void CssCounterResetLegal() { var property = ParseDeclaration("counter-reset: chapter section 1 page;"); @@ -281,6 +284,7 @@ public void CssCounterResetLegal() } [TestMethod] + [Spec("css21", "generate.html#counters")] public void CssCounterResetSingleLegal() { var property = ParseDeclaration("counter-reset: counter-name"); @@ -294,6 +298,7 @@ public void CssCounterResetSingleLegal() } [TestMethod] + [Spec("css21", "generate.html#counters")] public void CssCounterResetNoneLegal() { var property = ParseDeclaration("counter-reset: none"); @@ -307,6 +312,7 @@ public void CssCounterResetNoneLegal() } [TestMethod] + [Spec("css21", "generate.html#counters")] public void CssCounterResetNumberIllegal() { var property = ParseDeclaration("counter-reset: 3"); @@ -319,6 +325,7 @@ public void CssCounterResetNumberIllegal() } [TestMethod] + [Spec("css21", "generate.html#counters")] public void CssCounterResetNegativeLegal() { var property = ParseDeclaration("counter-reset : counter-name -1"); @@ -332,6 +339,7 @@ public void CssCounterResetNegativeLegal() } [TestMethod] + [Spec("css21", "generate.html#counters")] public void CssCounterResetTwoCountersExplicitLegal() { var property = ParseDeclaration("counter-reset : counter1 1 counter2 4 "); @@ -345,6 +353,7 @@ public void CssCounterResetTwoCountersExplicitLegal() } [TestMethod] + [Spec("css21", "generate.html#counters")] public void CssCounterIncrementNoneLegal() { var property = ParseDeclaration("counter-increment: none"); @@ -358,6 +367,7 @@ public void CssCounterIncrementNoneLegal() } [TestMethod] + [Spec("css21", "generate.html#counters")] public void CssCounterIncrementLegal() { var property = ParseDeclaration("counter-increment: chapter section 2 page"); @@ -371,6 +381,7 @@ public void CssCounterIncrementLegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStyleNoneSquareLegal() { var property = ParseDeclaration("list-style: none square"); diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyContentTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyContentTests.cs index 54c21fbea..d12d08454 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PropertyContentTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PropertyContentTests.cs @@ -5,22 +5,6 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/Property.cs (the Color/Content/Quotes cases). -/// -/// color (StyleProperties/Font/ColorProperty.cs) uses Converters.ColorConverter, which resolves -/// hex/named colors to a normalized rgb(r, g, b)/rgba(r, g, b, a) text form while keeping -/// Original as authored - confirmed identical to the color normalization already exercised by -/// BorderPropertyTests.cs's border-color/border-*-color ports. -/// -/// content (StyleProperties/ContentProperty.cs) accepts normal, none, one or more -/// <string>s, url(), and the open-quote/close-quote/no-open-quote/no-close-quote keywords -/// (all present in Enumerations/Keywords.cs) via ContentModes.ToConverter().Or(UrlConverter)...Many(). -/// -/// quotes (StyleProperties/QuotesProperty.cs) uses Converters.EvenStringsConverter.OrNone() - an -/// even (2, 4, ...) list of quote-mark string pairs, or the literal keyword none; an odd count or -/// non-string tokens are rejected. -/// [TestClass] public sealed class PropertyContentTests { @@ -113,6 +97,7 @@ public void CssColorNameIllegal() } [TestMethod] + [Spec("css21", "generate.html#content")] public void CssContentNormalLegal() { var snippet = "content : normal "; @@ -128,6 +113,7 @@ public void CssContentNormalLegal() } [TestMethod] + [Spec("css21", "generate.html#content")] public void CssContentNoneLegalUppercaseN() { var snippet = "content : noNe "; @@ -143,6 +129,7 @@ public void CssContentNoneLegalUppercaseN() } [TestMethod] + [Spec("css21", "generate.html#content")] public void CssContentStringLegal() { var snippet = "content : 'hi' "; @@ -158,6 +145,7 @@ public void CssContentStringLegal() } [TestMethod] + [Spec("css21", "generate.html#content")] public void CssContentNoOpenQuoteNoCloseQuoteLegal() { var snippet = "content : no-open-quote no-close-quote "; @@ -173,6 +161,7 @@ public void CssContentNoOpenQuoteNoCloseQuoteLegal() } [TestMethod] + [Spec("css21", "generate.html#content")] public void CssContentUrlLegal() { var snippet = "content : url(test.html) "; @@ -188,6 +177,7 @@ public void CssContentUrlLegal() } [TestMethod] + [Spec("css21", "generate.html#content")] public void CssContentStringsLegal() { var snippet = "content : 'how' 'are' 'you' "; @@ -203,6 +193,7 @@ public void CssContentStringsLegal() } [TestMethod] + [Spec("css21", "generate.html#quotes-specify")] public void CssQuoteStringIllegal() { var snippet = "quotes : '\"' "; @@ -216,6 +207,7 @@ public void CssQuoteStringIllegal() } [TestMethod] + [Spec("css21", "generate.html#quotes-specify")] public void CssQuoteStringsLegal() { var snippet = "quotes : '\"' '\"' "; @@ -231,6 +223,7 @@ public void CssQuoteStringsLegal() } [TestMethod] + [Spec("css21", "generate.html#quotes-specify")] public void CssQuoteStringsIllegal() { var snippet = "quotes : \"'\""; @@ -244,10 +237,10 @@ public void CssQuoteStringsIllegal() } [TestMethod] + [Spec("css21", "generate.html#quotes-specify")] public void CssQuoteStringsMultipleLegal() { - // The fourth quote mark is a literal U+FFFD REPLACEMENT CHARACTER, exactly as authored in - // PeachPDF's own source file (src/PeachPDF.Tests/CSS/Property.cs) - not a mangled encoding here. + // The fourth quote mark is a literal U+FFFD on purpose var snippet = "quotes : '\"' '\"' '`' '�' "; var property = ParseDeclaration(snippet); Assert.AreEqual("quotes", property.Name); @@ -261,6 +254,7 @@ public void CssQuoteStringsMultipleLegal() } [TestMethod] + [Spec("css21", "generate.html#quotes-specify")] public void CssQuoteStringsMultipleIllegal() { var snippet = "quotes : '\"' '\"' '`' "; @@ -274,6 +268,7 @@ public void CssQuoteStringsMultipleIllegal() } [TestMethod] + [Spec("css21", "generate.html#quotes-specify")] public void CssQuoteNoneLegal() { var snippet = "quotes : none"; @@ -289,6 +284,7 @@ public void CssQuoteNoneLegal() } [TestMethod] + [Spec("css21", "generate.html#quotes-specify")] public void CssQuoteNoneStringIllegal() { var snippet = "quotes : 'none'"; @@ -302,6 +298,7 @@ public void CssQuoteNoneStringIllegal() } [TestMethod] + [Spec("css21", "generate.html#quotes-specify")] public void CssQuoteNormalIllegal() { var snippet = "quotes : normal "; diff --git a/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs b/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs index f0ad7bff0..d4c59fba9 100644 --- a/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs +++ b/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs @@ -2,21 +2,16 @@ namespace HtmlRenderer.Test.Dom; -/// -/// Ported from PeachPDF.Tests/Html/Core/Dom/CssCounterEngineTests.cs. Unit tests for -/// , the counter-style resolver content: counter() -/// uses. Pins the CSS Counter Styles Level 3 §2 requirement that an unknown/invalid style falls back to -/// decimal rather than rendering nothing. -/// [TestClass] public sealed class CssCounterEngineTests { [TestMethod] + [Spec("css21", "generate.html#counter-styles")] [DataRow(1, "decimal", "1")] [DataRow(12, "decimal", "12")] [DataRow(1, "decimal-leading-zero", "01")] [DataRow(9, "decimal-leading-zero", "09")] - [DataRow(12, "decimal-leading-zero", "12")] // already two digits - no over-padding + [DataRow(12, "decimal-leading-zero", "12")] [DataRow(100, "decimal-leading-zero", "100")] [DataRow(4, "lower-roman", "iv")] [DataRow(4, "upper-roman", "IV")] @@ -33,7 +28,7 @@ public void FormatCounterValue_KnownStyles_FormatAsExpected(int number, string s [DataRow(42)] public void FormatCounterValue_UnknownStyle_FallsBackToDecimal(int number) { - // CSS Counter Styles Level 3 §2: an unknown/invalid counter style renders as decimal, never empty. + // CSS Counter Styles 3 §2 Assert.AreEqual(number.ToString(System.Globalization.CultureInfo.InvariantCulture), CssCounterEngine.FormatCounterValue(number, "not-a-real-style")); } @@ -47,8 +42,7 @@ public void FormatCounterValue_UnknownStyle_FallsBackToDecimal(int number) [DataRow(4000, "lower-roman", "4000")] public void FormatCounterValue_AlphabeticStyleOutOfRange_FallsBackToDecimal(int number, string style, string expected) { - // Alphabetic/symbolic styles can't represent 0 or negatives; CSS Counter Styles L3 §2 says such - // out-of-range values render with the fallback style (decimal), not empty. + // CSS Counter Styles 3 §2, values out of the style's range use the fallback style Assert.AreEqual(expected, CssCounterEngine.FormatCounterValue(number, style)); } diff --git a/Spec/css21.yml b/Spec/css21.yml index b1c5af93b..90164d72a 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -628,40 +628,61 @@ sections: - "Descendants with 'visibility: visible' of a hidden element are not painted" generate.html#generated-text: title: 12 Generated content, automatic numbering, and lists - status: unknown + status: partial generate.html#before-after-content: title: 12.1 The :before and :after pseudo-elements - status: unknown + status: partial + gaps: + - A block-level ::before or ::after gets no height, its text overlaps the element's content + - "A ::before or ::after with 'content: none' or 'normal' still generates a box" generate.html#content: title: 12.2 The 'content' property - status: unknown + status: partial + gaps: + - counters() inserts nothing + - url() inserts nothing instead of the image + - "\"\\A\" breaks the line even when 'white-space' collapses newlines" generate.html#quotes: title: 12.3 Quotation marks - status: unknown + status: partial generate.html#quotes-specify: title: 12.3.1 Specifying quotes with the 'quotes' property - status: unknown + status: parsed-only + gaps: + - "'quotes' is ignored, open-quote and close-quote always insert “ and ”" generate.html#quotes-insert: title: 12.3.2 Inserting quotes with the 'content' property - status: unknown + status: partial + gaps: + - The quote depth isn't tracked, nested quotes use the same pair and no-open-quote/no-close-quote do nothing + - A close-quote at depth 0 still inserts a quote mark generate.html#counters: title: 12.4 Automatic counters and numbering - status: unknown + status: supported generate.html#scope: title: 12.4.1 Nested counters and scope - status: unknown + status: supported generate.html#counter-styles: title: 12.4.2 Counter styles - status: unknown + status: partial + gaps: + - disc, circle, square and none format the counter as a decimal number generate.html#undisplayed-counters: title: 12.4.3 Counters in elements with 'display - status: unknown + status: partial + gaps: + - "A ::before or ::after with 'content: none' or 'normal' still resets and increments counters" generate.html#lists: title: 12.5 Lists - status: unknown + status: partial generate.html#list-style: title: 12.5.1 Lists - status: unknown + status: partial + gaps: + - "'list-style-image' is ignored, the marker is always a glyph or number" + - "'list-style-position' is ignored, the marker is always outside" + - "The marker is on the left with 'direction: rtl'" + - georgian renders 20 as პ instead of კ page.html#the-page: title: 13 Paged media status: unknown From 29f63a256992e8f49c46883898ec587e1125595a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:06:42 +0100 Subject: [PATCH 017/150] Fix a truncated CSS 2.1 section title and gap --- Spec/css21.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Spec/css21.yml b/Spec/css21.yml index 90164d72a..89c9fd3f5 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -47,7 +47,7 @@ sections: status: partial gaps: - Uppercase units, at-keywords and '!important' aren't recognized, e.g. 10PX, @MEDIA and !IMPORTANT - - "Uppercase pseudo-class and pseudo-element names don't match, e.g. :FIRST-CHILD and ::BEFORE" + - Uppercase pseudo-class and pseudo-element names don't match, e.g. :FIRST-CHILD and ::BEFORE syndata.html#statements: title: 4.1.4 Statements status: supported @@ -169,18 +169,18 @@ sections: title: 5.9 ID selectors status: partial gaps: - - IDs are matched case-insensitively, e.g. #Foo matches id="foo" + - 'IDs are matched case-insensitively, e.g. #Foo matches id="foo"' selector.html#pseudo-elements: title: 5.10 Pseudo-elements and pseudo-classes status: partial gaps: - - "Pseudo-class names are case-sensitive, 'p:FIRST-CHILD' never matches" - - "Pseudo-element names are case-sensitive, 'p:BEFORE' generates nothing" + - Pseudo-class names are case-sensitive, 'p:FIRST-CHILD' never matches + - Pseudo-element names are case-sensitive, 'p:BEFORE' generates nothing selector.html#pseudo-class-selectors: title: 5.11 Pseudo-classes status: partial selector.html#first-child: - title: "5.11.1 :first-child pseudo-class" + title: 5.11.1 :first-child pseudo-class status: supported selector.html#link-pseudo-classes: title: 5.11.2 The link pseudo-classes @@ -189,13 +189,13 @@ sections: title: 5.11.3 The dynamic pseudo-classes status: unsupported gaps: - - ":hover rules are collected but never applied when the mouse is over the element, :active and :focus never match" - - "A :hover outside the subject, e.g. 'div:hover p', matches as if the element were always hovered" + - :hover rules are collected but never applied when the mouse is over the element, :active and :focus never match + - A :hover outside the subject, e.g. 'div:hover p', matches as if the element were always hovered selector.html#lang: title: 5.11.4 The language pseudo-class status: unsupported gaps: - - ":lang() never matches" + - :lang() never matches selector.html#pseudo-element-selectors: title: 5.12 Pseudo-elements status: partial @@ -203,12 +203,12 @@ sections: title: 5.12.1 The :first-line pseudo-element status: parsed-only gaps: - - ":first-line is accepted but its declarations never apply" + - :first-line is accepted but its declarations never apply selector.html#first-letter: title: 5.12.2 The :first-letter pseudo-element status: parsed-only gaps: - - ":first-letter is accepted but its declarations never apply" + - :first-letter is accepted but its declarations never apply selector.html#before-and-after: title: 5.12.3 The :before and :after pseudo-elements status: supported @@ -280,7 +280,7 @@ sections: status: partial gaps: - The media attribute of link and style elements is ignored, the style sheet applies to every media type - - "@import rules are never loaded, so their media list has no effect" + - '@import rules are never loaded, so their media list has no effect' media.html#at-media-rule: title: 7.2.1 The @media rule status: supported @@ -668,7 +668,7 @@ sections: gaps: - disc, circle, square and none format the counter as a decimal number generate.html#undisplayed-counters: - title: 12.4.3 Counters in elements with 'display + title: "12.4.3 Counters in elements with 'display: none'" status: partial gaps: - "A ::before or ::after with 'content: none' or 'normal' still resets and increments counters" From 187f925f6c34d08ae9cd0f1c577c121ca6a8eaf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:12:36 +0100 Subject: [PATCH 018/150] Verify and document CSS 2.1 paged media compliance --- Source/HtmlRenderer.PdfSharp/PdfGenerator.cs | 7 +- Source/HtmlRenderer/Core/Dom/CssBox.cs | 13 +- .../Core/Fragmentation/BlockFragmentation.cs | 186 ++------ .../Core/Fragmentation/BreakToken.cs | 13 +- .../Core/Fragmentation/BreakValues.cs | 23 +- .../Core/Fragmentation/FragmentEmitter.cs | 32 +- .../Core/Fragmentation/InlineFragmentation.cs | 138 ++---- .../Core/Fragmentation/MonolithicContent.cs | 31 +- Source/HtmlRenderer/Core/HtmlContainerInt.cs | 16 +- Source/HtmlRenderer/Core/PageBandGeometry.cs | 7 +- .../ContainerLeftBehindKeepWithNextTest.cs | 22 +- .../ContainerLeftBehindTest.cs | 22 +- .../BlockContentListMarkerTests.cs | 62 +-- .../BreakPropagationIntegrationTests.cs | 67 +-- .../EarlyBreakLayoutIntegrationTests.cs | 101 +---- .../EngineRelayoutIdempotencyTests.cs | 19 +- .../FragmentainerCursorIntegrationTests.cs | 32 +- .../JustifiedLineAtABreakTests.cs | 21 +- .../KeepWithNextIntegrationTests.cs | 104 +---- ...MonolithicContentLayoutIntegrationTests.cs | 33 +- .../OrphansWidowsIntegrationTests.cs | 140 ++---- .../PageBreakIntegrationTests.cs | 129 ++---- .../PageMarginPaginationIntegrationTests.cs | 43 +- .../PagedMediaIntegrationTests.cs | 423 ++++++++++++++++++ .../ResumableBlockLayoutIntegrationTests.cs | 66 +-- .../Fragmentation/StraddlingLineClaimTests.cs | 35 +- .../StraddlingListMarkerTests.cs | 58 +-- .../Fragmentation/UnreachedWordClaimTests.cs | 46 +- .../OffsetTopLineTopSyncTest.cs | 16 +- .../OrphansOnFirstRunTest.cs | 16 +- .../Painting/FragmentPaintIntegrationTests.cs | 7 +- ...GhostTextOnPreviousPageIntegrationTests.cs | 53 +-- .../PageOriginPaintIntegrationTests.cs | 1 + .../StageD2FragmentBucketingSmokeTest.cs | 8 +- .../StageD3PrecisionTest.cs | 4 +- .../StageR1DriverLoopTest.cs | 19 +- .../StageR3RelocationTest.cs | 15 +- .../StageR4KeepWithNextTest.cs | 30 +- .../StageR5WidowsMultiPageTest.cs | 50 +-- ...tageR9KeepWithNextAcrossForcedBreakTest.cs | 21 +- .../StageR9OversizedKeepWithNextRunTest.cs | 35 +- .../MultiPageTextVisibilityTest.cs | 18 +- .../PageRuleTests.cs | 62 +++ .../StageD2VerificationTest.cs | 22 +- .../StageD3VerificationTest.cs | 18 +- .../StageF1VerificationTest.cs | 11 +- .../HtmlRenderer.Test/Css/PageRuleTests.cs | 25 +- .../Css/PropertyBreakTests.cs | 49 +- .../Css/PropertyPaginationTests.cs | 24 +- .../Fragmentation/BreakTokenTests.cs | 19 +- .../Fragmentation/BreakValuesTests.cs | 35 +- .../ForcedBreakTargetIsTheFramesTests.cs | 105 +---- .../HtmlContainerIntPaginationTests.cs | 53 +-- .../Fragmentation/MonolithicContentTests.cs | 23 +- Spec/css21.yml | 47 +- 55 files changed, 955 insertions(+), 1720 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.PdfSharp.Test/PageRuleTests.cs diff --git a/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs b/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs index b47ff9184..5b481392b 100644 --- a/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs +++ b/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs @@ -197,12 +197,7 @@ public static async Task AddPdfPages(PdfDocument document, string html, PdfGener container.PerformLayout(measure); } - // One PDF page per fragmentainer the fragment tree actually materialized - a - // content-empty page slot (CSS Paged Media 3 3.2, e.g. a huge margin that would - // otherwise paginate through blank vertical space - see the margin-truncation - // correction in BlockFragmentation) is simply never in this list, which is what - // gives blank-page skipping for free here instead of the old ceil(height/pageHeight) - // loop's naive page count. + // One PDF page per fragmentainer, pages without content have none. var tree = container.FragmentTree; foreach (var fragmentainer in tree?.Fragmentainers ?? (IReadOnlyList)Array.Empty()) { diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index 8cfea3ccd..a5e6a75c3 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -1783,17 +1783,8 @@ private double MarginBottomCollapse() /// /// /// - /// A real gap found while auditing this port's fragmentation engine against PeachPDF a second - /// time: this box's own entry for a line was kept in sync, but the - /// line's OWN mirror of the same value (, keyed the other way - /// around) was not - the two are separate dictionaries updated by separate call sites - /// ( keeps both in sync when a line-level shift initiates the - /// move; this method didn't when a box-level shift does). / - /// LineBottom - and therefore for any inline-only box, since it - /// reads them - went stale after this method ran, even though (this - /// method's own last statement) was correctly updated. Confirmed by directly inspecting both - /// dictionaries after a real EnforceKeepWithNext run-shift: Location.Y reflected the - /// new position while EffectiveTop still reported the old one. + /// A line's rectangle is stored both in and in , + /// both are shifted so and follow. /// internal void OffsetTop(double amount) { diff --git a/Source/HtmlRenderer/Core/Fragmentation/BlockFragmentation.cs b/Source/HtmlRenderer/Core/Fragmentation/BlockFragmentation.cs index e22fcf049..382f44538 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/BlockFragmentation.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/BlockFragmentation.cs @@ -8,27 +8,15 @@ namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation { /// - /// Block-level page-break decisions. Being replaced, stage by stage, with real resumable-pass-loop - /// equivalents matching PeachPDF's architecture (see the fragmentation-engine-parity plan): forced - /// breaks (, plan R1) go through CssBox's real pass loop - /// across fragmentainers; break-inside:avoid/monolithic relocation (, - /// plan R3) and keep-with-next (, plan R4) both relay the affected - /// box out fresh at its target position within the SAME pass, rather than shifting already-finished - /// geometry - real relayout, but not yet a cross-pass token, since nothing downstream has been touched - /// yet when either fires. Margin truncation () remains the older - /// pre-placement arithmetic correction, since it needs no relayout at all - it's already applied - /// before a box is ever positioned, the same timing uses. + /// Page break decisions between and around block-level boxes (CSS 2.1 §13.3, css-break-3 §3 to §5). /// internal static class BlockFragmentation { /// - /// Resolves a block box's document-space top, applying css-break-3 §5.2 margin truncation at - /// unforced breaks. Forced break-before/break-after: page is handled earlier, by - /// and CssBox's own pass loop - a box this method is - /// reached for has already been confirmed not to have a forced break pending. - /// is the position before this box's own collapsed top - /// margin is added (the containing block's content top, or the previous sibling's border-box - /// bottom). + /// Resolves a block box's top, truncating a collapsed margin that crosses a page boundary at an + /// unforced break (CSS 2.1 §13.3.3, css-break-3 §5.2). is + /// the position before the box's own collapsed top margin, i.e. the containing block's content top + /// or the previous sibling's bottom. /// internal static double ResolveBlockTop(CssBox box, CssBox prevSibling, double baseTopWithoutMargin) { @@ -38,32 +26,20 @@ internal static double ResolveBlockTop(CssBox box, CssBox prevSibling, double ba if (container == null || !container.HasRealPageGrid) return naturalTop; - // css-break-3 §5.2: a collapsed margin that, by itself, pushes content across one or more - // page boundaries is truncated to zero - content starts flush at the next page instead of - // paginating through blank vertical space. var baseSlot = container.PageIndexOf(baseTopWithoutMargin); var naturalSlot = container.PageIndexOf(naturalTop); return naturalSlot > baseSlot ? container.PageTopOf(baseSlot + 1) : naturalTop; } /// - /// Whether has a forced page break before it (its own break-before, - /// or 's break-after - including the legacy always - /// value) that isn't already satisfied by its natural top landing flush at a page top - and if so, - /// the pagination slot/document-Y it must be deferred to. A box with a forced break pending is not - /// placed this pass at all (see CssBox.RequestedBreakBeforeTop); its parent's child loop - /// stops and the pass ends, resuming with this box placed fresh at . + /// Whether has a forced break before it (its own break-before or + /// 's break-after, CSS 2.1 §13.3.4) that its natural top doesn't + /// already satisfy, and if so the page slot and top it moves to. /// /// - /// Suppressed when there's no previous sibling: css-break-3 §3.1 propagation says the break point - /// before a container's first in-flow child IS the break point before the container itself - so a - /// forced break here would really belong to an ancestor (and ultimately, if that ancestor also has - /// no previous sibling, to the fragmentation root, where it's inherently inert - there's no earlier - /// page to break away from). Full cross-ancestor propagation is out of scope for this port; - /// suppressing at the box's own level is what keeps a heading that merely happens to be first on - /// the page from forcing a spurious leading blank page - the common case this UA default - /// (`h1 { page-break-before: always }`) exists for is a heading that starts a new section partway - /// through a document, not one. + /// Without a previous sibling the break belongs to an ancestor (css-break-3 §3.1). It isn't + /// propagated, it's dropped, which also keeps a heading at the top of the document from opening a + /// blank page. /// internal static bool TryGetForcedBreakTarget(CssBox box, CssBox prevSibling, double baseTopWithoutMargin, out int slot, out double targetTop) { @@ -81,7 +57,7 @@ internal static bool TryGetForcedBreakTarget(CssBox box, CssBox prevSibling, dou var naturalSlot = container.PageIndexOf(naturalTop); var pageTop = container.PageTopOf(naturalSlot); if (naturalTop <= pageTop + 0.01) - return false; // Already flush at a fresh page's top - a forced break here does not skip a page. + return false; // Already at the top of a page, breaking again would leave a blank page. slot = naturalSlot + 1; targetTop = container.PageTopOf(slot); @@ -89,33 +65,15 @@ internal static bool TryGetForcedBreakTarget(CssBox box, CssBox prevSibling, dou } /// - /// Called by a block container's child loop right after (and its whole - /// subtree) has finished laying out this pass. If the child straddles a page boundary and either - /// asks not to be broken (break-inside: avoid) or may not be broken at all (a replaced - /// element, a scroll container), and it fits within a single page's height, the child is relaid - /// out fresh at the next page's content top. Does not itself consider whether this leaves a - /// preceding sibling stranded - , called right after this in the - /// same loop iteration, catches that uniformly for every trigger (this one included). + /// Moves to the top of the next page when it straddles a page boundary but + /// must not be broken, because of break-inside: avoid (CSS 2.1 §13.3.3 rule D) or because it's + /// monolithic (css-break-3 §2). A child taller than a page stays where it is, CSS 2.1 §13.3.3 drops + /// rule D when there aren't enough break points. /// /// - /// The child is genuinely relaid out (ResumeAt + PerformLayout), not - /// OffsetTop-shifted: nothing after this child in its parent's loop has been touched yet - /// this pass, so re-entering its own layout at the new top is cheap, and it is also more correct - /// than a flat shift - any of the child's OWN descendants that themselves have - /// break-inside:avoid or a nested forced break get to make their own decision relative to - /// the real page boundaries at the new position, rather than blindly carrying whatever decision - /// they made at the old one. - /// - /// - /// A real gap found while auditing this port's fragmentation engine against PeachPDF a second - /// time: css-break-3 §3.1's break-point propagation was only ever applied to forced breaks (see - /// 's own remark), never to this kind of relocation. A child - /// moved by this method while it's its parent's first in-flow child - a plain wrapper with no - /// content before it - left the parent spanning from its original page to the child's new one, its - /// own background/border painted as a stub-then-continuation for no reason a CSS author would - /// expect (e.g. a card/panel div wrapping a single table or figure). - /// fixes this by climbing the first-in-flow-child chain and shifting each such ancestor's own top - /// by the same delta, rather than leaving it behind. + /// Called by the parent's child loop right after the child is laid out. Nothing after the child has + /// been laid out yet, so the child is laid out again at its new top instead of being shifted, which + /// lets its own descendants decide their breaks against the new position. /// internal static void RelocateIfNeeded(RGraphics g, CssBox child) { @@ -129,7 +87,7 @@ internal static void RelocateIfNeeded(RGraphics g, CssBox child) return; var topSlot = container.PageIndexOf(top); - // Bottom-edge convention: a bottom landing exactly on a boundary belongs to the band above it. + // A bottom exactly on a boundary belongs to the page above it. var bottomSlot = container.PageIndexOf(Math.Max(top, bottom - 0.01)); if (bottomSlot <= topSlot) return; @@ -139,7 +97,7 @@ internal static void RelocateIfNeeded(RGraphics g, CssBox child) var height = bottom - top; if (height > container.PageSize.Height) - return; // Fits on no single page - left in place rather than moved somewhere it also won't fit. + return; var target = container.PageTopOf(topSlot + 1); child.ResumeAt(null, target); @@ -149,41 +107,15 @@ internal static void RelocateIfNeeded(RGraphics g, CssBox child) } /// - /// Called by a block container's child loop right after has finished - /// laying out (and, if applicable, been relocated by ) this pass. If - /// a page break actually falls between and its immediately preceding - /// in-flow sibling, and either of them asks it not to (break-after/break-before: avoid, - /// keep-with-next, css-break-3 §3.1), the whole preceding run chained to that sibling is pulled - /// down to join 's page instead of leaving it stranded on the page it just - /// left - then itself is relaid out fresh, since its own natural top - /// depends on the now-shifted sibling's new bottom. + /// When a page break falls between and its previous in-flow sibling and + /// either of them avoids it (break-after/break-before: avoid, CSS 2.1 §13.3.3 rule A), + /// pulls the preceding run of avoid-chained siblings onto 's page and lays + /// out again below them. /// /// - /// A real gap found while building this: the pre-existing keep-with-next code only ever ran as a side effect - /// of relocating itself - so it only ever - /// fired when was ALSO break-inside:avoid or monolithic. The - /// ordinary case (an unremarkable paragraph that simply doesn't fit after a keep-with-next-chained - /// heading) never triggered it at all: the heading was left stranded on the page it started on - /// while the paragraph moved on alone. This method is the general fix - checked unconditionally, - /// not only after a relocation - and 's own preceding-run handling - /// was removed as redundant once this covers it too (after a relocation moves the child, the - /// preceding sibling is exactly as "left behind" as in the ordinary case, and this method treats - /// both identically). - /// - /// - /// A second real bug found while investigating the fragmentation-engine-parity plan's R9 stage: - /// an earlier version of this method always pulled the WHOLE preceding run to 's - /// page, without checking whether the run (which can be arbitrarily tall - a long chain of - /// break-after:avoid siblings) then fit there at all. This did not just mis-place content - - /// it corrupted layout outright: when a run too tall for one page got pulled, its own later - /// members remained just as likely to trigger their own keep-with-next check against the now - /// artificially-stretched-out run, each firing its own unconditional pull and compounding - /// shifts on the same earlier boxes without bound (observed - /// empirically reaching a box position around 8.6e11 for a 60-member chain on a short page). The - /// fix is css-break-3 §4.3's actual staged relaxation: trim the run from its front (the earliest, - /// least-important-to-keep members) until what remains actually fits the target page alongside - /// ("RunTrimmed"), or leave the run in place entirely if even its last - /// member doesn't fit there ("RunDropped") - never pull a run that can't actually fit. + /// The run is trimmed from its front until it fits on the page together with , + /// if not even its last member fits nothing moves (css-break-3 §4.3). Pulling a run that can't fit + /// makes its later members trigger the same pull again and again. /// internal static void EnforceKeepWithNext(RGraphics g, CssBox child) { @@ -203,14 +135,11 @@ internal static void EnforceKeepWithNext(RGraphics g, CssBox child) var childBottomBeforeRelayout = child.ActualBottom; var childTopSlot = container.PageIndexOf(childTopBeforeRelayout); if (childTopSlot <= prevBottomSlot) - return; // No break actually falls between them - nothing to enforce. + return; // No break between them. var run = CollectPrecedingKeepWithNextRun(prevSibling); run.Add(prevSibling); - // Trim from the front (earliest members) until what remains fits alongside child on the - // target page - see the second remarks block above for why pulling an oversized run - // unconditionally is not just suboptimal but actively corrupts layout. var childHeight = childBottomBeforeRelayout - childTopBeforeRelayout; var pageHeight = container.PageSize.Height; var start = 0; @@ -223,12 +152,12 @@ internal static void EnforceKeepWithNext(RGraphics g, CssBox child) } if (start >= run.Count) - return; // RunDropped - not even the run's last member fits alongside child; leave everything in place. + return; - var originalGroupTop = run[start].EffectiveTop; // captured before OffsetTop below moves it + var originalGroupTop = run[start].EffectiveTop; // Before OffsetTop below moves it. var delta = container.PageTopOf(childTopSlot) - originalGroupTop; if (delta <= 0) - return; // Defensive - a positive shift is the only sensible outcome here. + return; for (var i = start; i < run.Count; i++) { @@ -238,16 +167,12 @@ internal static void EnforceKeepWithNext(RGraphics g, CssBox child) child.ResumeAt(null, null); child.PerformLayout(g); - // css-break-3 §3.1 propagation (see PropagateContainerRelocation and RelocateIfNeeded's own - // remark on the same gap): run[start] is the run's earliest member - if it's also its - // parent's first in-flow child, the parent's own top should follow it up by the same delta. PropagateContainerRelocation(run[start], delta); } /// - /// Walks backward through already-positioned preceding in-flow siblings chained to - /// by break-after/break-before: avoid (css-break-3 §3.1), - /// so a heading is never left stranded on the page its content just moved off of. + /// The already positioned in-flow siblings before that are chained to it by + /// break-after/break-before: avoid, in document order. /// private static List CollectPrecedingKeepWithNextRun(CssBox box) { @@ -267,44 +192,15 @@ private static List CollectPrecedingKeepWithNextRun(CssBox box) } /// - /// css-break-3 §3.1's break-point propagation applied to relocation, not just to forced breaks - /// (see 's own "no previous sibling" check, which tests the - /// same condition): while is its parent's first in-flow child, the - /// parent's own top has no meaning independent of it - so the parent's - /// is shifted by the same , and the check repeats one level further up - /// (the parent, now itself "the thing that moved"). + /// While is its parent's first in-flow child, the break before it is + /// also the break before the parent (css-break-3 §3.1), so the parent's top moves by the same + /// , and so on up the ancestors. /// /// - /// Deliberately touches only the parent's top, never its bottom/: - /// a container's bottom is independently, correctly computed from its LAST child once that child - /// finishes its own layout (ordinary block flow, unaffected by an EARLIER sibling moving) - only - /// the top, decided once before any child is laid out and never revisited otherwise, needs this - /// correction. This also means the check doesn't need "does the parent have any OTHER content" at - /// all: a later sibling that hasn't been laid out yet (or moved by a different amount) has no - /// bearing on whether the FIRST child's own top should still anchor the parent's. - /// - /// - /// Deliberately narrower than PeachPDF's actual anchor-climbing (which participates in the same - /// call-stack-unwind bubbling every break decision does): this port has no such bubbling for - /// RelocateIfNeeded/EnforceKeepWithNext/InlineFragmentation's relocations (each fires and completes - /// within its own parent's child loop, several stack frames below any grandparent that might also - /// need to react), so climbing further and actually re-laying out an ancestor from underneath its - /// own in-progress layout call would be reentrant and unsafe. This version only ever adjusts the - /// parent's own directly - never a subtree-wide - /// ( has already been repositioned; shifting it again would double-count - /// it) and never a relayout. - /// - /// - /// A third audit pass raised a plausible-sounding concern worth recording as a non-issue: does a - /// list-item marker () go stale here the way it would after a raw - /// change elsewhere? No - confirmed empirically (a diagnostic test - /// showed identical marker positions with and without an explicit marker shift added here). - /// CssBox.CreateListItemBox recomputes the marker's position from its owner's CURRENT - /// Location unconditionally on every PerformLayoutImp call (not only once, at - /// creation) - and every ancestor this method climbs is, by construction, still mid-PerformLayoutImp - /// when it runs (this method is only ever called from deep within that same call's own child-loop - /// or line-breaking step), so CreateListItemBox always re-fires afterward with the - /// already-corrected Location. No explicit marker handling needed here. + /// Only the parent's top moves, its bottom still follows from its last child. The ancestors are in + /// the middle of their own layout, so they're neither shifted as a subtree (that would move + /// twice) nor laid out again. Their list markers are positioned later in + /// that same layout call and pick up the new top. /// internal static void PropagateContainerRelocation(CssBox movedBox, double delta) { diff --git a/Source/HtmlRenderer/Core/Fragmentation/BreakToken.cs b/Source/HtmlRenderer/Core/Fragmentation/BreakToken.cs index 9008d037b..57b45e2a4 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/BreakToken.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/BreakToken.cs @@ -3,16 +3,9 @@ namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation { /// - /// A resumption record: where layout stopped in one fragmentainer, so the next one can pick up from - /// exactly that point (https://www.w3.org/TR/css-break-3/#breaking-controls, CSS Fragmentation Level 3 - /// §2/§4.4). Ported from PeachPDF's BreakToken, reduced to what this port's driver loop - /// actually needs: only forced break-before/break-after: page ever produces a real - /// cross-pass token here (see ) - overflow, - /// break-inside:avoid, keep-with-next, widows/orphans, and table-row breaks all turned out to - /// be same-pass local corrections instead (confirmed empirically stage by stage while investigating - /// the fragmentation-engine-parity plan's R2-R9), so PeachPDF's inline and table token kinds - and its - /// per-token FanOutContinuations "parallel flows" mechanism, which only those kinds ever used - - /// have no counterpart in this port and were never added. + /// Where layout stopped in one fragmentainer, so the next pass resumes from there (css-break-3 §4.4). + /// Only forced breaks () produce one, every + /// other break is corrected within the same pass. /// /// /// Tokens form a chain, one link per ancestor between the fragmentation-context root and the box that diff --git a/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs b/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs index daf1b6eb2..f5a41f719 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs @@ -3,31 +3,20 @@ namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation { /// - /// Classifies a cascaded break-before/break-after/break-inside value, per - /// https://www.w3.org/TR/css-break-3/#break-between (CSS Fragmentation Level 3 §3.1/§3.2). Ported from - /// PeachPDF's BreakValues, reduced to this port's scope: pages only (no multi-column, so no - /// column/avoid-column handling) and no directional breaks (no left/right/ - /// recto/verso/@page :left/:right matching - see the plan's scope decision). + /// Classifies break-before/break-after/break-inside values (CSS 2.1 §13.3.1, + /// css-break-3 §3). Only pages fragment, so column and region values never force or avoid a break. + /// left and right don't force a break. /// - /// - /// One home for every question layout asks about a break value, so a future widening of the accepted - /// value set only has to change one place. - /// internal static class BreakValues { /// - /// Whether forces a page break: page, or the legacy - /// page-break-before/page-break-after: always value, which HTML-Renderer's CSS - /// engine accepts directly on the modern properties too (see BreakMode) rather than - /// normalizing it away at parse time - so both spellings are classified here. + /// Whether forces a page break. The legacy always is accepted on + /// break-before/break-after too, they share their value with page-break-*. /// internal static bool IsForcedBreak(string value) => value is CssConstants.Page or CssConstants.Always; - /// - /// Whether forbids a break - avoid (both break-inside and - /// the legacy page-break-inside use it) or avoid-page. - /// + /// Whether avoids a page break. internal static bool AvoidsBreak(string value) => value is CssConstants.Avoid or CssConstants.AvoidPage; } diff --git a/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs b/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs index a28ac444c..44f057747 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs @@ -9,13 +9,9 @@ namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation { /// - /// Collects layout's output into the immutable . Layout (see - /// ) already positions every box correctly across however many - /// pages the document spans, in one continuous top-down pass with local relocation corrections - - /// so unlike PeachPDF's pass-based emitter, this one does not need to collect per-pass output over - /// multiple EmitPass calls. Its job is simpler: walk the already-finished box tree once per - /// page band and bucket each box's rectangles into whichever band(s) they fall in, splitting a box - /// that spans multiple pages into one per page it appears on. + /// Builds the immutable from the laid-out box tree. Layout has already + /// placed every box across the pages, so this walks the tree once per page and puts each box's + /// rectangles into the pages they fall in, one per page a box appears on. /// internal sealed class FragmentEmitter { @@ -49,21 +45,12 @@ internal FragmentTree Finish() return new FragmentTree(new List { fragmentainer }); } - // root.ActualBottom (Location.Y + Size.Height), not _container.ActualSize.Height: the - // latter is document height *excluding* the root's own top offset (ActualSize.Height = - // ActualBottom - Root.Location.Y, set at the end of CssBox.PerformLayoutImp/Epilogue), so - // using it directly here as an absolute Y would double-subtract MarginTop inside - // PageIndexOf and under-report the last slot whenever content's true bottom lands just - // past a page boundary that ActualSize.Height alone doesn't yet cross. + // ActualSize.Height excludes the root's top offset, so it isn't a document Y. var lastSlot = _container.PageIndexOf(Math.Max(0, root.ActualBottom - Epsilon)); var fragmentainers = new List(); - // css-position-3, paged media: a fixed box's containing block is each page's own page area, - // and it "is thus replicated on every page". Collected once - each fixed box's own Location - // is already page-relative (CssBox never runs it through normal top-computing flow; see - // CssBox.PerformLayoutImp's own Position==Fixed branch), so building its fragment against a - // band starting at Y=0 (rather than this slot's real band top) localizes it to exactly that - // same relative position on every page, unchanged. + // A fixed box repeats on every page (css-position-3). Its Location is already page-relative, + // so it's built against a band starting at 0 on every page. var fixedRoots = new List(); CollectFixedRoots(root, fixedRoots); var fixedBand = new PageBand(0, _container.PageSize.Height); @@ -74,11 +61,8 @@ internal FragmentTree Finish() var bandBottom = _container.PageBottomOf(slot); var band = new PageBand(bandTop, bandBottom, bandTop - _container.MarginTop); - // CSS Paged Media 3 3.2: a page-slot no box has any content in is never materialized - - // this falls out of the walk rather than being special-cased, since a box only gets - // built into this fragmentainer at all when HasContentInBand finds something. Fixed - // content deliberately does not itself justify materializing an otherwise content-empty - // slot - matches this port's existing blank-page-skipping scope. + // A page without content gets no fragmentainer (CSS 2.1 §13.2.3), fixed content alone + // doesn't count. if (!HasContentInBand(root, band)) continue; diff --git a/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs b/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs index 50f0aaa30..cd631e515 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs @@ -6,47 +6,26 @@ namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation { /// - /// Inline-flow page-break corrections, applied the same way as : - /// as local shifts to lines has already computed, not - /// via a resumable re-entry into word measurement/line breaking. A line box is monolithic - /// (css-break-3 4.1) and never straddles a page boundary; where the whole run of already-laid-out - /// lines from the last break point would otherwise have too few lines before it (orphans) or - /// leave too few after (widows), the break point moves instead of the line count. + /// Page breaks between the line boxes of a block container (CSS 2.1 §13.3.3, css-break-3 §5.4). Lines + /// already laid out by are shifted, a line never straddles + /// a page boundary and orphans/widows move the break instead of the lines. /// internal static class InlineFragmentation { /// - /// Called right after finishes for - /// : pushes any line that would land on a later page than its run's - /// break down to that page's content top - and, honoring orphans/widows, the lines - /// around it - then updates to match. + /// Moves the lines of that start a new page down to that page's top and + /// updates to match. /// /// - /// Two phases, deliberately kept separate. Phase 1 decides every break index using each line's - /// own NATURAL (never-shifted) position - a run's total height is preserved under a uniform - /// shift, so "does a candidate run fit on one page" (and therefore where the next break falls) - /// can be decided without knowing where the run will actually land. This is what lets widows - /// cascade backward across more than one earlier break when needed (by removing entries from the - /// decided break list) without having to undo a shift already applied to specific lines - an - /// earlier single-pass version of this method shifted lines incrementally as it went, which - /// couldn't cleanly support that. It also had a subtler failure mode worth recording: once a - /// shift happens to land a run's lines in perfect page-boundary alignment (uniform line heights - /// make this common), no line ever straddles again, so a single-pass method driven purely by "did - /// this line straddle" silently stopped checking orphans/widows for every later page transition - - /// found via a paragraph long enough to span dozens of pages, whose final page ended up with - /// fewer lines than widows required and was never corrected. Phase 1's height-cumulative - /// natural-position test has no such blind spot, since it never depends on whether a straddle was - /// observed. Phase 2 applies the decided breaks as cumulative shifts to the real line boxes, in - /// one forward pass - no decisions left to make there, just arithmetic. + /// Phase 1 decides all breaks from the lines' unshifted positions. A run's height doesn't change when + /// it's shifted as a whole, so whether a run fits on a page doesn't depend on where it lands, and + /// widows can merge runs back across several earlier breaks without undoing shifts. Phase 2 then + /// applies the shifts in one pass. /// internal static void ApplyLineBreaking(CssBox blockBox) { var container = blockBox.HtmlContainer; - // A fixed box (css-position-3, paged media) is repeated identically on every page and its - // own coordinates are page-relative, not absolute document-Y (see FragmentEmitter's - // CollectFixedRoots) - unlike a float or an absolutely-positioned box, which stay in normal - // document flow and must still paginate like anything else, the UA "must not paginate the - // content of fixed-positioned boxes" (css-position-3), so this correction does not apply. + // A fixed box repeats on every page in page coordinates and isn't paginated (css-position-3). if (container == null || !container.HasRealPageGrid || blockBox.IsFixed) return; @@ -54,49 +33,24 @@ internal static void ApplyLineBreaking(CssBox blockBox) if (lines.Count == 0) return; - // Captured before any shifting, for BlockFragmentation.PropagateContainerRelocation at the - // end - see that method's own remarks for why css-break-3 §3.1 propagation applies here too, - // not only to BlockFragmentation's own relocations: a box whose orphans violation pushes its - // whole first run to a fresh page (below) moves its own EffectiveTop exactly the way - // RelocateIfNeeded's block-level relocation does, and a parent that starts with this box - // needs its own top to follow just the same. var originalTop = lines[0].LineTop; var orphans = blockBox.ActualOrphans; var widows = blockBox.ActualWidows; var pageHeight = container.PageSize.Height; - // The first run starts wherever CreateLineBoxes naturally placed line 0 - not necessarily a - // page's top (this box may start partway down a page, after preceding sibling content) - so - // its capacity is only whatever room remains on that page, not a full page height the way - // every later run (which always starts fresh at a page's top, by construction) gets. + // The first run only has the room left on the page the box starts on, every later run starts + // at the top of a page. var firstPageIndex = container.PageIndexOf(lines[0].LineTop); var firstRunCapacity = container.PageBottomOf(firstPageIndex) - lines[0].LineTop; - // How many lines actually fit in the room remaining on the page this box starts on - a - // run's total height measured from line 0 is invariant under a uniform shift (see the - // two-phase remark above), so this natural-position count is valid regardless of where the - // run ends up landing. var firstRunLineCount = 0; while (firstRunLineCount < lines.Count && lines[firstRunLineCount].LineBottom - lines[0].LineTop <= firstRunCapacity) firstRunLineCount++; - // Orphans (css-break-3 §5.4) applies to the box's very first run exactly like every later - // one: a paragraph starting close enough to a page's bottom that fewer than `orphans` lines - // fit there must move in its ENTIRETY to the next page, not leave a too-small first fragment - // behind. The main loop below cannot fix this on its own - its merge-back correction only - // ever runs once at least one earlier break already exists (`breaks.Count > 1`), which is - // never true while still deciding the first run, so an otherwise-identical violation at the - // very start of a paragraph was silently exempt. Folding it into where the first run begins - // (the same mechanism already used for a single first line taller than the remaining room) - // fixes it without needing a special case in the main loop. Subsumes that single-line case - // too - it is just the `orphans` violation that can never be waived (0 lines fitting is - // always fewer than any orphans value of at least 1). - // A forced break (or any other placement) may already have put this run flush at a fresh - // page's own top - in which case its capacity IS a full page height already, and pushing it - // to yet ANOTHER fresh page cannot gain any more room (same content, same capacity, same - // unsatisfiable result), it would just leave the page it was actually placed on blank. Only - // worth doing when there is real room being left on the table by staying put. + // Fewer than `orphans` lines fit on the first page (or none at all), so the whole first run + // starts on the next page. Not when it already starts at the top of a page, the next page has + // no more room than this one. var alreadyAtFreshPageTop = Math.Abs(lines[0].LineTop - container.PageTopOf(firstPageIndex)) < 0.01; var firstRunMovedToFreshPage = !alreadyAtFreshPageTop && firstRunLineCount < lines.Count && firstRunLineCount < orphans; if (firstRunMovedToFreshPage) @@ -112,31 +66,22 @@ internal static void ApplyLineBreaking(CssBox blockBox) var runStart = breaks[breaks.Count - 1]; var capacity = runStart == 0 ? firstRunCapacity : pageHeight; if (lines[i].LineBottom - lines[runStart].LineTop <= capacity) - continue; // line i still fits in the run that started at runStart + continue; var linesBefore = i - runStart; if (linesBefore > 0 && linesBefore < orphans && breaks.Count > 1) { - // Too few lines to justify breaking here - the attempted run merges into the - // previous page's run instead of leaving a near-empty fragment behind. Re-test this - // same line against the now-earlier run start (cascades further back if needed). + // Too few lines for orphans, merge this run into the previous one and test line i again. breaks.RemoveAt(breaks.Count - 1); i--; } else if (linesBefore > 0 && linesBefore < orphans && runStart == 0 && !firstRunMovedToFreshPage && !alreadyAtFreshPageTop) { - // Same violation as above, but there is no earlier run to merge into - runStart==0 - // IS the first run. The only fix here is exactly what the pre-loop check above already - // does for the common case: push it whole to a fresh page - gated by the SAME - // alreadyAtFreshPageTop condition that check uses, for the same reason: if this run is - // already sitting at a fresh page's own top, it already got the full pageHeight - // capacity and still could not fit `orphans` lines (the pre-loop check's own - // firstRunLineCount 1 && lines.Count - breaks[breaks.Count - 1] < widows) { var prevRunStart = breaks[breaks.Count - 2]; @@ -174,13 +101,13 @@ internal static void ApplyLineBreaking(CssBox blockBox) for (var newBreak = breaks[breaks.Count - 1] - 1; newBreak > prevRunStart; newBreak--) { if (newBreak - prevRunStart < orphans) - break; // shifting further would strand the earlier run below its own orphans minimum + break; if (lines.Count - newBreak < widows) - continue; // this candidate still does not have enough lines after it + continue; if (lines[lines.Count - 1].LineBottom - lines[newBreak].LineTop > pageHeight) - continue; // the (now larger) last run would not fit a fresh page either + continue; breaks[breaks.Count - 1] = newBreak; shifted = true; @@ -194,14 +121,11 @@ internal static void ApplyLineBreaking(CssBox blockBox) if (mergedHeight <= (prevRunStart == 0 ? firstRunCapacity : pageHeight)) { breaks.RemoveAt(breaks.Count - 1); - continue; // re-test widows against the now one-level-earlier run (cascades further back) + continue; } if (prevRunStart == 0 && !firstRunMovedToFreshPage && mergedHeight <= pageHeight) { - // Merging everything back into run 0 does not fit run 0's own natural (tighter) room, - // but WOULD fit a full page - exactly the boost firstRunMovedToFreshPage already gives - // the first run for orphans. Apply it here too and finish the merge. firstRunMovedToFreshPage = true; firstPageIndex++; firstRunCapacity = pageHeight; @@ -209,12 +133,10 @@ internal static void ApplyLineBreaking(CssBox blockBox) continue; } - break; // neither a shift nor any merge can satisfy widows - decline gracefully + break; } - // Phase 2: apply the decided breaks as cumulative shifts, in one forward pass. The first - // run's own delta is seeded up front (zero unless firstRunMovedToFreshPage moved it) since - // the loop below only assigns a fresh delta when it crosses breaks[1] onward. + // Phase 2 var delta = firstRunMovedToFreshPage ? container.PageTopOf(firstPageIndex) - lines[0].LineTop : 0.0; var breakOrdinal = 0; @@ -224,7 +146,7 @@ internal static void ApplyLineBreaking(CssBox blockBox) { breakOrdinal++; var target = container.PageTopOf(firstPageIndex + breakOrdinal); - delta = target - lines[i].LineTop; // lines[i] not yet shifted this pass + delta = target - lines[i].LineTop; // lines[i] isn't shifted yet. } if (delta != 0) diff --git a/Source/HtmlRenderer/Core/Fragmentation/MonolithicContent.cs b/Source/HtmlRenderer/Core/Fragmentation/MonolithicContent.cs index 765f3ab84..7e5fe937c 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/MonolithicContent.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/MonolithicContent.cs @@ -5,17 +5,11 @@ namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation { /// - /// Classifies content that cannot be broken, per - /// https://www.w3.org/TR/css-break-3/#monolithic (CSS Fragmentation Level 3 §2). Ported from - /// PeachPDF's MonolithicContent, reduced to what this port's scope needs: no flex/grid/columns - /// (so narrows to "is a table"), no vertical writing mode, no - /// box-decoration-break clone insets, and HTML-Renderer's own (smaller) set of replaced element types. + /// Content that can't be broken (css-break-3 §2, CSS 2.1 §13.3.5 for replaced elements). /// /// - /// is css-break-3 §2's own set: a property of the content, which no user - /// agent may break. is an implementation constraint - the table - /// engine fragments its own subtree, so the driver must not hand it a half-laid-out one. Keeping the - /// two apart is the point of this file, exactly as in PeachPDF. + /// is a property of the content. is an + /// implementation constraint, the table layout fragments its own subtree. /// internal static class MonolithicContent { @@ -23,9 +17,7 @@ internal static class MonolithicContent internal static bool IsMonolithic(CssBox box) => IsReplaced(box) || IsScrollContainer(box); /// - /// Whether is a replaced element, whose content the UA cannot fragment - /// because it has no fragmentable inner structure. HTML-Renderer's replaced-element set is - /// smaller than PeachPDF's - no <object>/<video>, inline SVG, or form-field widgets. + /// Whether is a replaced element (an image or a frame). /// internal static bool IsReplaced(CssBox box) => box is CssBoxImage or CssBoxFrame; @@ -55,26 +47,19 @@ private static bool IsNamed(CssBox box, string name) => string.Equals(box.HtmlTag?.Name, name, StringComparison.OrdinalIgnoreCase); /// - /// Whether runs a layout engine that fragments its own subtree. In - /// PeachPDF this covers flex, grid, table and multi-column; none of the first three exist in - /// HTML-Renderer, so this narrows to table/inline-table. + /// Whether runs a layout engine that fragments its own subtree, i.e. is a table. /// internal static bool PaginatesItsOwnContent(CssBox box) => RunsAnEngineOfItsOwn(box.Display); /// - /// The display-value half of . Kept as its own method (rather - /// than inlined) so a future engine addition only has to widen this one place, mirroring PeachPDF's - /// shape even though it currently names only one display value. + /// The display-value half of . /// internal static bool RunsAnEngineOfItsOwn(string display) => display is CssConstants.Table or CssConstants.InlineTable; /// - /// Whether must be treated as an indivisible unit by its parent's own - /// fragmentation. In PeachPDF this also covers unresumable vertical-writing-mode content; that - /// doesn't exist in HTML-Renderer, so this is currently the same set as . - /// Kept as a separate name (rather than inlined at call sites) so a future reason can be added here - /// without touching every caller. + /// Whether must be treated as an indivisible unit by its parent's + /// fragmentation. Currently the same set as . /// internal static bool IsMonolithicForFragmentation(CssBox box) => IsMonolithic(box); diff --git a/Source/HtmlRenderer/Core/HtmlContainerInt.cs b/Source/HtmlRenderer/Core/HtmlContainerInt.cs index 99ce7eafd..a833c5c22 100644 --- a/Source/HtmlRenderer/Core/HtmlContainerInt.cs +++ b/Source/HtmlRenderer/Core/HtmlContainerInt.cs @@ -779,14 +779,9 @@ public void PerformLayout(RGraphics g) } /// - /// The resumable per-fragmentainer pass loop (matching PeachPDF's LayoutDocument): lay the - /// whole document out once; if stopped partway through (its own - /// is set - see that property's doc comment for how a break - /// discovered arbitrarily deep in the tree reaches it), resume from exactly that point and lay out - /// again; repeat until nothing is left pending. For a container with no real page grid (WinForms/ - /// WPF's continuous-scroll convention), or a document with no forced breaks at all, this runs - /// exactly once - 's default (no token, no override) is indistinguishable - /// from this engine's original single unbounded pass. + /// Lays the document out, and while stops at a forced break + /// (), resumes from there in another pass. Without a page grid + /// or forced breaks this is a single pass. /// private void DriveLayoutPasses(RGraphics g) { @@ -796,10 +791,7 @@ private void DriveLayoutPasses(RGraphics g) return; } - // A backstop, not a real budget (matching PeachPDF's own sentinel) - a real document can only - // exhaust this many passes if something is genuinely wrong (a break token that never resolves - // forward), not from ordinary content length, since R1's scope (forced breaks only) resumes - // at most once per forced break in the whole document. + // A backstop against a break token that never moves forward, there's one pass per forced break. const int maxPasses = 100_000; BreakToken token = null; diff --git a/Source/HtmlRenderer/Core/PageBandGeometry.cs b/Source/HtmlRenderer/Core/PageBandGeometry.cs index 2fc5c45d4..d0de2e13f 100644 --- a/Source/HtmlRenderer/Core/PageBandGeometry.cs +++ b/Source/HtmlRenderer/Core/PageBandGeometry.cs @@ -1,10 +1,9 @@ namespace TheArtOfDev.HtmlRenderer.Core { /// - /// The resolved block-axis band and margins one fragmentainer (page) occupies, in true output units. - /// HTML-Renderer keeps a single fixed page size/margins per document (no per-page @page overrides, - /// unlike PeachPDF's variable-geometry PageGeometryTable), so this is a plain value computed once - /// from the container's and margins rather than a table. + /// The block-axis band and margins of one page, in output units. All pages share the container's + /// and margins, @page rules aren't applied + /// (CSS 2.1 §13.2). /// internal readonly struct PageBandGeometry { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/ContainerLeftBehindKeepWithNextTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/ContainerLeftBehindKeepWithNextTest.cs index 7541f020f..6cb723c57 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/ContainerLeftBehindKeepWithNextTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/ContainerLeftBehindKeepWithNextTest.cs @@ -10,23 +10,9 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; /// -/// Verifies the same css-break-3 §3.1 propagation gap as , applied -/// to EnforceKeepWithNext's run-pull instead of RelocateIfNeeded's relocation: a heading -/// pulled onto a paragraph's page (because they're chained by break-after:avoid) is also the -/// section wrapping both of them's first in-flow child - the section's own top needs to follow the -/// heading up, or the section is left spanning from its original page to the pulled-together pair's new -/// one. +/// A heading pulled onto its paragraph's page by break-after:avoid takes the section that starts +/// with it along. /// -/// -/// Diagnosing this surfaced a SECOND, more fundamental bug along the way: CssBox.OffsetTop (what -/// EnforceKeepWithNext uses to pull the run) kept the box's own Rectangles dictionary in -/// sync but never the corresponding CssLineBox.Rectangles entry (a separate dictionary, keyed the -/// other way, that CssLineBox.LineTop/LineBottom - and therefore CssBox.EffectiveTop -/// for any inline-only box - read from). Location.Y (this method's own last statement) was -/// correctly updated while EffectiveTop silently kept reporting the pre-shift position - confirmed -/// by inspecting both dictionaries directly on a real shifted heading before the fix. Fixed by having -/// OffsetTop also update the line's own mirror entry for each line it touches. -/// [TestClass] [DoNotParallelize] public sealed class ContainerLeftBehindKeepWithNextTest @@ -46,6 +32,7 @@ private static IEnumerable Walk(CssBox box) } [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task SectionWrappingHeadingAndParagraph_MovesWithThePulledHeading_NeverSpansBothPages() { var checkedAnyPull = false; @@ -83,8 +70,7 @@ await wrapper.SetHtml( var sectionSlot = container.PageIndexOf(section.Location.Y); var headingSlot = container.PageIndexOf(heading.EffectiveTop); - // Only meaningful once the heading has actually been pulled forward (flush at a fresh page - // top) - otherwise there's no run-pull for the section to have gotten left behind by. + // Only meaningful once the heading was pulled to a page top. if (System.Math.Abs(heading.EffectiveTop - container.PageTopOf(headingSlot)) > 0.5) continue; diff --git a/Source/Test/HtmlRenderer.IntegrationTest/ContainerLeftBehindTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/ContainerLeftBehindTest.cs index 7e8f828a2..6dc9240ad 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/ContainerLeftBehindTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/ContainerLeftBehindTest.cs @@ -9,19 +9,6 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; -/// -/// Verifies a real, previously-undocumented gap found while auditing this port's fragmentation engine -/// against PeachPDF a second time (a later, separate pass over what remained after the R0-R10 plan -/// completed): css-break-3 §3.1's break-point propagation was only ever applied to forced breaks -/// ('s -/// own "no previous sibling" check), never to break-inside:avoid/monolithic relocation -/// (RelocateIfNeeded). A box moved by that method while it's its parent's first (and here, only) -/// in-flow child - a plain wrapper div with no content before it - left the parent spanning from its -/// original page to the child's new one, its own background/border rendered as a stub-then-continuation -/// for no reason a CSS author would expect (e.g. a card/panel div wrapping a single table or figure). -/// Confirmed by temporarily reverting the fix: card and table reliably landed on different page slots at -/// several filler counts. -/// [TestClass] [DoNotParallelize] public sealed class ContainerLeftBehindTest @@ -41,13 +28,12 @@ private static IEnumerable Walk(CssBox box) } [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task WrapperDivWithOneAvoidBreakChild_MovesWithIt_NeverSpansBothPages() { var checkedAnyRelocation = false; - // Sweep filler counts - the exact boundary where the relocation fires depends on font-metric - // arithmetic (this session's established testing lesson: never hardcode a "just barely - // straddles" calibration). + // Where the relocation fires depends on font metrics, so sweep instead of calibrating. for (var fillerCount = 1; fillerCount < 60; fillerCount++) { using var wrapper = new HtmlContainer(); @@ -83,9 +69,7 @@ await wrapper.SetHtml( var cardSlot = container.PageIndexOf(card.Location.Y); var tableSlot = container.PageIndexOf(table.Location.Y); - // Only meaningful once the table has actually been relocated (flush - within border-rounding - // slack - at a fresh page top) - otherwise there's nothing for the card to have gotten left - // behind by in the first place. + // Only meaningful once the table was relocated to a page top (within border rounding). if (System.Math.Abs(table.Location.Y - container.PageTopOf(tableSlot)) > 2.0) continue; diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BlockContentListMarkerTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BlockContentListMarkerTests.cs index 8ff1e0d8c..72bb43fa0 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BlockContentListMarkerTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BlockContentListMarkerTests.cs @@ -10,33 +10,7 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/BlockContentListMarkerTests.cs. PeachPDF's root defect -/// (CssBox.LayoutOutsideMarker re-parenting a block-content item's marker into the anonymous block its -/// inline run needs, then scanning only direct children to find it again) does not exist by -/// construction here: HTML-Renderer's marker is , a field entirely separate -/// from - it is never wrapped in an anonymous block regardless of whether the -/// item's content is inline or block-level, so every PeachPDF test asserting that structural fact -/// (AnOutsideMarker_IsNotWrappedInTheItemsAnonymousBlock), the resulting mispositioned content -/// (AnItemWhoseContentIsBlockLevel_LaysThatContentOutBelowTheItemsTop), or the marker's own screen -/// position (AnItemWhoseContentIsBlockLevel_PositionsItsMarkerLikeAnInlineOne, -/// AnItemMixingInlineAndBlockContent_KeepsItsAnonymousBlockAndItsMarker) is general list-layout -/// correctness unrelated to pagination, not fragment-claiming - out of scope for this porting batch per the -/// plan's own instruction to port only the claiming-relevant half of this file. -/// AnInsideMarker_IsStillWrappedWithTheItemsInlineRun is dropped outright: -/// list-style-position is parsed and stored (CssBoxProperties.ListStylePosition) but never -/// consulted by CssBox.CreateListItemBox - confirmed by reading it in full - so this port has no -/// "inside" marker rendering mode at all, matching the parse-only-stub precedent already established for -/// other CSS properties this porting effort has found (e.g. the page property in Batch 2). -/// AnItemWhoseKeptContentCarriesNoWords_KeepsItsMarkerWhereItBegins is dropped: it requires a real -/// multi-column engine (column-count), out of scope for this whole porting effort. -/// -/// The 2 tests that remain are genuinely about fragment-claiming: whether a block-content item's marker is -/// claimed by a fragment at all (single-page - the base case PeachPDF's bug broke completely, drawing the -/// marker on no page), and whether the item travels whole with its marker across a real forced page -/// break (multi-page - the pagination-relevant half of PeachPDF's own file). The second is ported but -/// [Ignore]d: it hits a different, confirmed gap (forced-break ancestor propagation, not the marker -/// mechanism) - see its own remarks below. -/// +/// The marker of a list item with block-level content is claimed by exactly one fragment. /// [TestClass] [DoNotParallelize] @@ -47,10 +21,6 @@ public sealed class BlockContentListMarkerTests + "
      • block content here

      • " + "
      • inline content
      • "; - /// - /// The fragment-tree statement of PeachPDF's own symptom: a marker no fragment claims is the state paint - /// reads when it draws nothing. Single page - this is the base case, not a pagination scenario. - /// [TestMethod] public void AnItemWhoseContentIsBlockLevel_HasItsMarkerClaimedExactlyOnce() { @@ -70,32 +40,10 @@ public void AnItemWhoseContentIsBlockLevel_HasItsMarkerClaimedExactlyOnce() } } - /// - /// An item whose only content asks to start on the next page has nothing to keep on the page it was - /// declined on, so css-break-3 §3.1 moves the whole item - "a break before a container's own first - /// in-flow child is the break point before the container". - /// - /// - /// Confirmed NOT to reproduce here, for a reason unrelated to the marker mechanism this file is - /// otherwise about: BlockFragmentation.TryGetForcedBreakTarget's own doc comment documents a - /// deliberate scope limit - "suppressed when there's no previous sibling", since full css-break-3 §3.1 - /// ancestor propagation (a forced break with no previous sibling really belongs to the nearest ancestor - /// that HAS one) is out of scope for this port. Here p (the break-before:page box) is - /// li's only child, so prevSibling == null for p itself and the forced break is - /// suppressed outright - it never even reaches the point where li (which DOES have a previous - /// sibling, the earlier <p>before</p>) could inherit it. Confirmed by running this - /// test unignored: the whole document stays on one page, never spanning more than one fragmentainer at - /// all. This is the same confirmed gap as this port's own PageBreakIntegrationTests remarks call - /// out for margin-truncation propagation - here it blocks a forced break instead. - /// + // The forced break on the item's only child is the break before the item. [TestMethod] - [Ignore("Confirmed gap: BlockFragmentation.TryGetForcedBreakTarget suppresses a forced break-before " + - "entirely when the box has no previous sibling (full css-break-3 §3.1 ancestor propagation - " + - "letting a nested first-in-flow-child's break become its own parentless ancestor's - is out of " + - "scope for this port, per that method's own doc comment). Here

        is " + - "

      • 's only child, so the break is suppressed before it could ever reach
      • (which does have a " + - "previous sibling). Confirmed by running this test unignored: the document never spans more than " + - "one fragmentainer at all.")] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#forced")] public void AnItemDeferredBeforeItsContentWasEverFlowed_TravelsWholeWithItsMarker() { var html = PaintHarness.Wrap( @@ -112,8 +60,6 @@ public void AnItemDeferredBeforeItsContentWasEverFlowed_TravelsWholeWithItsMarke Assert.IsTrue(container.FragmentTree!.Fragmentainers.Count > 1, "the fixture must span more than one page"); - // One fragment, on the page the item's content is on - no stub left behind on the page it was - // declined on, and the marker with it. Assert.AreEqual(1, fragments.Count); var fragment = fragments[0]; diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BreakPropagationIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BreakPropagationIntegrationTests.cs index 5288525e4..504069ce1 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BreakPropagationIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BreakPropagationIntegrationTests.cs @@ -10,38 +10,8 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/BreakPropagationIntegrationTests.cs: css-break-3 §3.1 forced- -/// break combination and propagation - whether a break point stated before/after a box travels up to an -/// ancestor that begins/ends with it. +/// Whether a forced break or a keep stated on a first or last child applies to the break point of its ancestors. /// -/// -/// Confirmed, by reading both the call site (CssBox.PerformLayoutImp's child loop, which calls -/// BlockFragmentation.TryGetForcedBreakTarget(this, prevSibling, ...) with prevSibling scoped -/// to the box's OWN parent's child list) and TryGetForcedBreakTarget's own remark: unlike PeachPDF's -/// BreakPropagation.PropagatesBreakBeforeOutward, this port does NOT climb the ancestor chain for a -/// forced break-before/break-after - a first-in-flow child's forced break-before is -/// suppressed outright (never redirected onto its parent), and a last-in-flow child's break-after -/// never bubbles up to make its parent's own BreakAfter "page" either (CssBoxProperties has -/// no such cascade - confirmed by reading the BreakAfter/BreakBefore property getters, plain -/// backing-field reads with no ancestor lookup). This is explicitly called out as "out of scope for this -/// port" in TryGetForcedBreakTarget's own doc remark. Real ancestor-following DOES exist, but only -/// for the RELOCATION-triggered movers (BlockFragmentation.PropagateContainerRelocation, called from -/// both RelocateIfNeeded and EnforceKeepWithNext) - confirmed working end to end by this -/// repo's own pre-existing ContainerLeftBehindTest.cs/ContainerLeftBehindKeepWithNextTest.cs. -/// Three tests below (ForcedBreakBeforeAFirstChild_MovesTheContainer, -/// ForcedBreakBeforeANestedFirstChild_MovesTheOutermostContainerItBegins, -/// BreakAfterOnALastChild_ForcesTheBreakBeforeTheFollowingSibling) are ported with their PeachPDF -/// assertions intact but [Ignore]d against this confirmed gap; a fourth -/// (AForcedBreakPropagatedOutOfAContainer_BreaksTheKeepWithNextChain) is dropped rather than -/// Ignored, since its premise (a forced break that travelled out of the container) never occurs here at -/// all, so there is nothing left of the scenario to characterize as pending. -/// -/// Also dropped: the 2 directional-break-value tests (recto/verso, unsupported per the port -/// plan's exclusion list) and ForcedBreakBeforeAnEngineItem_DoesNotTravelOutOfTheEngine (flex/grid - -/// no such layout engine exists in this port, and PeachPDF's own BreakPropagation type it asserts -/// against has no counterpart here either). -/// -/// [TestClass] [DoNotParallelize] public sealed class BreakPropagationIntegrationTests @@ -90,14 +60,11 @@ private static CssBox FindById(CssBox root, string id) private static int SlotOf(HtmlContainerInt container, CssBox box) => container.PageIndexOf(box.Location.Y); - #region Propagation moves the container, not only the child (confirmed gap - forced breaks only) + #region Propagation moves the container, not only the child [TestMethod] - [Ignore("Confirmed gap: BlockFragmentation.TryGetForcedBreakTarget suppresses a forced break-before on a " - + "first-in-flow child (no previous sibling) rather than redirecting it onto the parent - see this " - + "method's own doc remark ('Full cross-ancestor propagation is out of scope for this port'). Unlike " - + "PeachPDF, the break is simply dropped: 'wrap' never moves and 'first' lands wherever ordinary flow " - + "puts it, both on the original page.")] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#forced")] public async Task ForcedBreakBeforeAFirstChild_MovesTheContainer() { var (root, container) = await BuildAsync( @@ -116,8 +83,8 @@ public async Task ForcedBreakBeforeAFirstChild_MovesTheContainer() } [TestMethod] - [Ignore("Same confirmed gap as ForcedBreakBeforeAFirstChild_MovesTheContainer - see this class's own doc " - + "remark - just nested one level deeper.")] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#forced")] public async Task ForcedBreakBeforeANestedFirstChild_MovesTheOutermostContainerItBegins() { var (root, container) = await BuildAsync( @@ -132,9 +99,8 @@ public async Task ForcedBreakBeforeANestedFirstChild_MovesTheOutermostContainerI Assert.AreEqual(container.PageTopOf(1), outer.Location.Y, 3); } - // A box with an in-flow sibling above it names its own break point directly (prevSibling != null in - // TryGetForcedBreakTarget), so nothing needs to propagate and the container stays where it is. [TestMethod] + [Spec("css21", "page.html#forced")] public async Task ForcedBreakBeforeALaterChild_LeavesTheContainerWhereItIs() { var (root, container) = await BuildAsync( @@ -152,11 +118,7 @@ public async Task ForcedBreakBeforeALaterChild_LeavesTheContainerWhereItIs() Assert.AreEqual(1, SlotOf(container, second)); } - // §3.1 propagation would stop before breaking through the fragmentation root, so the chain here reaches - // the root either way (whether or not ancestor propagation exists) and no break is taken - which is - // also §4.4's "no empty fragmentainer" falling out rather than being asserted. Passes in this port for - // the same *suppression* that makes the two Ignored tests above fail their PeachPDF assertions - the - // observable outcome (nothing moves, single page) happens to coincide here. + // css-break-3 §3.1: a forced break before the first content of the root makes no blank page. [TestMethod] public async Task ForcedBreakBeforeTheFirstBoxInTheFlow_ManufacturesNoBlankPage() { @@ -175,11 +137,8 @@ public async Task ForcedBreakBeforeTheFirstBoxInTheFlow_ManufacturesNoBlankPage( #region Combination and precedence (§3.1) [TestMethod] - [Ignore("Confirmed gap: a container's own BreakAfter is a plain CSS-cascaded backing field " - + "(CssBoxProperties.BreakAfter) with no bubbling from its last in-flow child's break-after - " - + "TryGetForcedBreakTarget only ever tests prevSibling.BreakAfter directly, and prevSibling here is " - + "'wrap' itself (whose own break-after was never set), not 'tail' (whose break-after:page never " - + "reaches 'wrap'). So 'next' is not pushed to a new page at all.")] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#forced")] public async Task BreakAfterOnALastChild_ForcesTheBreakBeforeTheFollowingSibling() { var (root, container) = await BuildAsync( @@ -194,12 +153,10 @@ public async Task BreakAfterOnALastChild_ForcesTheBreakBeforeTheFollowingSibling #endregion - #region Keep-with-next across a container (relocation-triggered - confirmed working) + #region Keep-with-next across a container - // The §4.3 movers (RelocateIfNeeded/EnforceKeepWithNext) reach ancestor-propagation through a real, - // confirmed mechanism (PropagateContainerRelocation): the run is collected, the anchor (the first - // in-flow box the relocated box begins) travels, and the child loop that owns the run positions it. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task BreakInsideAvoidOnAFirstChild_PullsTheRunAcrossTheContainer() { var (root, container) = await BuildAsync( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/EarlyBreakLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/EarlyBreakLayoutIntegrationTests.cs index 15989fec1..e3bfbba23 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/EarlyBreakLayoutIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/EarlyBreakLayoutIntegrationTests.cs @@ -12,29 +12,9 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/EarlyBreakLayoutIntegrationTests.cs: a box relocated by one of -/// css-break-3 §4.3's corrections (BlockFragmentation.RelocateIfNeeded/EnforceKeepWithNext) -/// is laid out again at its new position rather than translated to it - so it never carries a -/// fragmentainer-boundary gap inside its own content the way a flat OffsetTop translation would. +/// A box moved to the next page is laid out again there instead of being translated, on a 200px page with +/// 20px margins (band [20, 220)). /// -/// -/// PeachPDF's own version of this file is built around a real cross-pass rewind (PassRewind.RollBackTo, -/// a FragmentainerPasses/PassRewinds pass counter, and table <thead> repetition via a -/// detached CssProxyBox per page) - none of which this port has: 's own doc -/// comment confirms only a forced break-before/break-after: page ever produces a real -/// cross-pass token here, and TableHeaderRepeat.CloneAndPosition clones real laid-out -/// instances rather than PeachPDF's detached proxies. 9 of PeachPDF's 20 tests are -/// therefore dropped rather than ported - see the "Dropped" region at the bottom of this file for exactly -/// which, and why each one's premise doesn't reach this port's architecture. -/// -/// Fixtures use a 200-unit page with 20-unit margins (band [20, 220), matching PeachPDF's own -/// pt-denominated fixture geometry 1:1 in CSS px - this port's -based -/// matches px exactly, unlike pt (confirmed via a -/// ~1.333 WinForms conversion ratio while calibrating the sibling files in this folder), so reusing -/// PeachPDF's own numbers as px keeps the same proportions). orphans/widows are pinned -/// to 1 wherever the box under test is not itself the one being tested for them. -/// -/// [TestClass] [DoNotParallelize] public sealed class EarlyBreakLayoutIntegrationTests @@ -111,10 +91,7 @@ private static string GapDocument(double fillerHeight, string cardCss) => $"
        filler
        " + $"
        Aaa Bbb Ccc Ddd Eee Fff Ggg Hhh
        "; - // Sweeps a range of filler heights rather than hardcoding PeachPDF's own pt-calibrated 130/140/150 - - // the exact boundary where a card starts straddling depends on font-metric arithmetic (this session's - // own established testing lesson from the sibling Stage*/ContainerLeftBehind* tests: never hardcode a - // "just barely straddles" calibration across a different text-measurement backend). + // Sweeps filler heights, where the card starts straddling depends on font metrics. [TestMethod] public async Task RelocatedBox_HasNoInteriorGap() { @@ -128,9 +105,6 @@ public async Task RelocatedBox_HasNoInteriorGap() if (container.PageIndexOf(card.EffectiveTop) != container.PageIndexOf(card.ActualBottom - 0.01)) continue; // relocation failed to land it on a single page - not the case under test - // Only meaningful once the box was actually straddling before relocation, i.e. some filler in - // this range genuinely pushed it across a boundary - confirmed indirectly by checking more than - // one filler height below all land on a page other than 0. checkedAny = true; AssertLinesAreEvenlySpaced(card); } @@ -176,9 +150,9 @@ private static double HeightOfCard(CssBox root) return System.Math.Round(card.ActualBottom - card.EffectiveTop, 3); } - // The latch: an unsatisfiable avoid (content taller than the band) is relaxed rather than walked down - // the document one page at a time. + // An avoid that can't be satisfied is dropped instead of moving the box down page by page. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task BoxTallerThanTheBand_MovesAtMostOnce() { var lines = string.Concat(Enumerable.Range(0, 14).Select(i => $"Line {i}
        ")); @@ -210,10 +184,8 @@ public async Task OrphansPushedParagraph_HasNoInteriorGap() AssertLinesAreEvenlySpaced(FindById(root, "card")); } - // The keep-with-next pull is the one correction a box cannot carry out for itself: the break falls - // before a sibling placed before it, so only the parent's child loop can re-run it. Whichever way it is - // carried out, the heading comes along and lands at the destination band's top with the box below it. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task PulledRun_MovesTogetherToTheDestinationBandTop() { var checkedAny = false; @@ -254,9 +226,7 @@ public async Task PulledRun_IsLaidOutAgain_SoTheBoxHasNoInteriorGap() Assert.IsTrue(checkedAny, "test is not meaningful as written"); } - // Every word the document authored is claimed by exactly one fragment - fails one way if a rewound - // pass leaves a ghost, the other way if a correction discards content that legitimately belonged - // somewhere. + // Every word is claimed by exactly one fragment, no ghost and no lost content. [TestMethod] public async Task PulledRun_ClaimsEveryWordExactlyOnce() { @@ -287,9 +257,7 @@ public async Task PulledRun_ClaimsEveryWordExactlyOnce() // ── §3.1 propagation (the container travels too) ────────────────────────────────────────────── - // §3.1's break point before a container's first in-flow child IS the break point before the container, - // so a §4.3 mover relocating that child has to move the container with it - confirmed working end to - // end via BlockFragmentation.PropagateContainerRelocation (see this repo's own ContainerLeftBehindTest.cs). + // css-break-3 §3.1: the break before a first child is the break before its container. [TestMethod] [DataRow("break-inside:avoid")] [DataRow("overflow:hidden")] @@ -310,17 +278,14 @@ public async Task RelocatedFirstChild_TakesItsContainerWithIt(string cardCss) Assert.IsTrue(card.EffectiveTop >= wrapper.Location.Y - 0.001, $"the card must sit inside its wrapper, but is at {card.EffectiveTop:F1} against {wrapper.Location.Y:F1}"); - // And the wrapper is no longer on the page it left, which is the whole visible defect. Assert.IsTrue(wrapper.Location.Y > container.PageTopOf(0) + 1.0); } - // The redirect is a relaxation ladder rung, not an unconditional rewrite: a container that does not fit - // the destination is left where it is and the box moves alone. + // A container that doesn't fit the destination stays, the box moves alone. [TestMethod] public async Task RelocatedFirstChild_LeavesAContainerThatDoesNotFitTheDestination() { - // The card straddles the boundary and fits a band on its own, so the mover fires; the wrapper's own - // extent (its top down to the card's bottom) is 180 against a 160 band, so it cannot go. + // The wrapper's extent from its top to the card's bottom is 180px against a 160px band. var html = "
        filler
        " + "
        " + $"
        Aaa Bbb Ccc Ddd Eee Fff Ggg Hhh
        "; @@ -335,13 +300,8 @@ public async Task RelocatedFirstChild_LeavesAContainerThatDoesNotFitTheDestinati Assert.AreEqual(1, container.PageIndexOf(card.EffectiveTop)); } - // A box whose subtree contains a table that repeats a header still gets relocated correctly - unlike - // PeachPDF's CssProxyBox-based repeat (detached from the tree, replaced fresh per page and therefore - // unsafe to re-lay-out a second time), TableHeaderRepeat.CloneAndPosition clones real laid-out CssBox - // instances rather than mutating/removing the source subtree, so a second layout of the same table - // (which is exactly what RelocateIfNeeded's re-entrant PerformLayout does) finds the same real content - // it did the first time. [TestMethod] + [Spec("css21", "page.html#page-break-props")] public async Task BoxContainingARepeatingTable_IsStillRelocated() { var rows = string.Concat(Enumerable.Range(0, 4).Select(i => $"
      • ")); @@ -356,43 +316,4 @@ public async Task BoxContainingARepeatingTable_IsStillRelocated() Assert.AreEqual(1, container.PageIndexOf(card.EffectiveTop)); Assert.AreEqual(container.PageTopOf(1), card.EffectiveTop, 1.0); } - - // ── Dropped (9 of PeachPDF's 20 tests) ────────────────────────────────────────────────────────── - // - // All 9 depend on PeachPDF's own resumable-pass rewind architecture, which this port never built (see - // this class's own doc remark, and StageR5WidowsMultiPageTest.cs's own confirmation that the - // investigation into needing one concluded it wasn't necessary): - // - // - RelocatingABox_TakesNoExtraFragmentainerPass, PulledRun_FromAPassThatResumedIntoAParagraph_ - // ReEntersThatPass: assert a bounded/equal HtmlContainerInt.FragmentainerPasses/PassRewinds pass - // counter. Neither property exists - DriveLayoutPasses' own pass counter is a local loop variable, - // not exposed state, and every correction in this port (RelocateIfNeeded, EnforceKeepWithNext, - // InlineFragmentation's orphans/widows) is a same-pass local fix, so there is no "extra pass" or - // "pass rewind" concept to bound in the first place. - // - PulledRun_FromAnEarlierPass_LeavesNoFragmentOnThePageItLeft: asserts a moved box's fragment is - // absent from an "already-emitted" earlier page. FragmentEmitter runs exactly once, after every - // DriveLayoutPasses pass has settled (HtmlContainerInt.PerformLayout's own call order) - nothing is - // ever "already emitted" mid-layout for a later correction to have un-emitted, so the scenario this - // test characterizes cannot arise. - // - PulledRun_AlreadyRestartedOnThisPass_IsMovedRatherThanRestartedAgain: asserts a "restart" limiter - // (PeachPDF's own per-pass-per-box guard against restarting the same run twice) falls back to a - // flat move. EnforceKeepWithNext has no restart counter or fallback path to test - it always - // computes the same trim-and-shift outcome from the current geometry, deterministically, every time - // it is called. - // - RunHeadContainingARepeatingTable_KeepsItsHeaderIntact: asserts against PeachPDF's CssProxyBox - // (a detached per-page proxy row inserted into the live tree) surviving a restart. No such type - // exists here (TableHeaderRepeat.CloneAndPosition's clones are never inserted into CssBox.Boxes at - // all - see that class's own doc comment), and the underlying "does the header actually repeat - // correctly" concern is already covered by StageD4RepeatedHeaderTest.cs and Batch 1's - // HtmlContainerIntPaginationTests.cs, so re-asserting it here under a keep-with-next run would be - // redundant, not a new gap. - // - PulledRun_ReEnteringAPassThatResumedIntoAParagraph_LaysItOutAgain, - // PulledRun_FromAPassThatResumedIntoAParagraph_KeepsEachHeadingWithItsBlock, - // PulledRun_FromAPassThatResumedIntoAParagraph_ClaimsEachBlockWordExactlyOnce, - // PulledRun_ReEnteringAPassThatResumedIntoAParagraph_RetakesAForcedBreakOnAGrandchild: all four - // exist specifically to characterize PassRewind.RollBackTo's own correctness (discarding lines a - // replayed pass would otherwise duplicate, retaking a forced break latched by a discarded attempt). - // InlineFragmentation.ApplyLineBreaking computes an entire paragraph's lines in one unbounded, - // side-effect-free call (its own doc comment), so there is no resumed/replayed pass for duplicate - // lines or a latched break to survive from - the bug class these tests guard against cannot occur. } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/EngineRelayoutIdempotencyTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/EngineRelayoutIdempotencyTests.cs index d00ffaa84..3f2013626 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/EngineRelayoutIdempotencyTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/EngineRelayoutIdempotencyTests.cs @@ -12,25 +12,8 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/EngineRelayoutIdempotencyTests.cs: whether laying the same -/// subtree out again reproduces the first result. +/// Laying the same, already built tree out again reproduces the first result. /// -/// -/// Reframed per the port plan: PeachPDF's version is about general resumed-pass idempotency (re-running an -/// engine's measurement phases mid-resume). This port's relevant relayout triggers are more specific - -/// BlockFragmentation.RelocateIfNeeded/EnforceKeepWithNext relaying a box out fresh within the -/// same pass (child.ResumeAt + child.PerformLayout), and -/// CssLayoutEngineTable's repeated-header rebuild, which its own call site resets -/// (_tableBox.RepeatedHeaderRows = null) and rebuilds "from scratch" via -/// TableHeaderRepeat.CloneAndPosition on every table layout, per that class's own doc comment. -/// Dropped entirely: PeachPDF's flex/grid/multicol Theories (4 of 7 methods) - none of those engines exist -/// in this port (MonolithicContent.RunsAnEngineOfItsOwn's own doc comment narrows "engines that -/// paginate their own content" to table only). The remaining 3 (the plain-block-flow control, and the -/// table header-repeat family) are ported, adapted to call -/// directly, more than once, on the SAME already-built tree - the real repeated-layout shape this port -/// actually has (HtmlContainerInt.PerformLayout's own unrestricted-width double layout, and a host -/// control's own resize-driven re-layout), rather than PeachPDF's resumed-pass re-entry. -/// [TestClass] [DoNotParallelize] public sealed class EngineRelayoutIdempotencyTests diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/FragmentainerCursorIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/FragmentainerCursorIntegrationTests.cs index 4ad0f71e9..d87b872fc 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/FragmentainerCursorIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/FragmentainerCursorIntegrationTests.cs @@ -11,20 +11,8 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/FragmentainerCursorIntegrationTests.cs: a forced break steps a -/// layout pass over slots without ending it, so the band a later correction (orphans) should reason about -/// is the one the break actually landed on, not the one the pass nominally started in. +/// Later corrections such as orphans reason about the page a forced break actually landed on. /// -/// -/// Adapted per the port plan: PeachPDF's FragmentainerContext (an explicit per-pass "which -/// fragmentainer is being filled" cursor object) has no counterpart here. This port has no separate cursor -/// at all - InlineFragmentation.ApplyLineBreaking always computes firstPageIndex directly from -/// lines[0].LineTop, the line's own real, already-placed position (which already reflects wherever a -/// forced break/margin-truncation/relocation put the box), so there is no stale-cursor state that could -/// disagree with it. These 3 tests (of PeachPDF's 5 - 2 more dropped, both using the directional -/// break-before: right value, unsupported per the port plan's exclusion list) are ported as -/// regression checks against the equivalent real-geometry reasoning. -/// [TestClass] [DoNotParallelize] public sealed class FragmentainerCursorIntegrationTests @@ -69,11 +57,9 @@ private static CssBox FindById(CssBox root, string id) => private static string LongText() => string.Join(" ", Enumerable.Range(0, 600).Select(i => "word" + i)); - // orphans: 99 is a minimum no band here can satisfy, which is §5.4 read through §4.3's relaxation - // ladder: the constraint is given up rather than acted on pointlessly. A box placed by a forced break - // is already at the top of a fresh page - moving it again (as if there were a whole page of content - // above it) would blank the page the forced break named. + // orphans:99 can't be satisfied, and moving a box that already starts a page wouldn't help. [TestMethod] + [Spec("css21", "page.html#forced")] public async Task AForcedBreak_LandsOnThePageItNames_EvenWhenTheBoxCannotMeetItsOrphansMinimum() { var html = "
        A
        " @@ -85,15 +71,14 @@ public async Task AForcedBreak_LandsOnThePageItNames_EvenWhenTheBoxCannotMeetIts Assert.AreEqual(container.PageTopOf(1), b.EffectiveTop, 1.0); - // And no blank page in the middle: every fragmentainer from the first to the last carries content. + // No blank page in between. var slots = container.FragmentTree!.Fragmentainers.Select(f => f.SlotIndex).ToArray(); Assert.IsTrue(Enumerable.Range(0, slots.Length).SequenceEqual(slots)); } - // The other side of the same reasoning: here the box with the unsatisfiable orphans minimum is not the - // one the forced break placed - it follows it on the same page - so there genuinely is something above - // it, and the orphans mover is entitled to fire and start it on the next page. + // Here content precedes the orphans box on its page, so moving it to the next page does help. [TestMethod] + [Spec("css21", "page.html#forced")] public async Task AboveTheForcedBreaksBox_IsStillRoomAbove_ForWhatFollowsItOnThatPage() { var html = "
        A
        " @@ -110,10 +95,9 @@ public async Task AboveTheForcedBreaksBox_IsStillRoomAbove_ForWhatFollowsItOnTha Assert.AreEqual(container.PageTopOf(2), c.EffectiveTop, 1.0); } - // The shape most likely to have relied on a wrong cursor: content that overflows the fragmentainer the - // break stepped to. A box taller than the band still starts on the page the break named, and the box - // after it picks up at its real bottom, not at the bottom of some other band. + // A box taller than the page still starts on the page the break named. [TestMethod] + [Spec("css21", "page.html#forced")] public async Task AfterAForcedBreak_ABoxTallerThanTheBand_StillStartsOnThePageTheBreakNamed() { var html = "
        X
        " diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/JustifiedLineAtABreakTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/JustifiedLineAtABreakTests.cs index a28f9de1b..e263ba857 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/JustifiedLineAtABreakTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/JustifiedLineAtABreakTests.cs @@ -11,24 +11,8 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/JustifiedLineAtABreakTests.cs: CSS Text §7.3 exempts the last -/// line of a block from text-align: justify - a line that ends at a fragmentation break is not that -/// line (the block continues in the next fragmentainer), so it must still be justified like any other. +/// CSS Text §7.3: a line that ends at a page break isn't the last line of its block, so it's still justified. /// -/// -/// Verified NOT to reproduce in this port, and ported anyway as a locked-in non-regression check (per the -/// port plan's guidance for a scenario that doesn't reproduce but is still meaningful to pin). PeachPDF's -/// bug depended on its own resumable-pass architecture: a pass that stops mid-block leaves -/// LineBoxes looking complete when it is not, so "is this line the last one" (read off -/// LineBoxes.Count - 1) gave a false positive for whatever line a pass happened to stop on. -/// HTML-Renderer's CssLayoutEngine.CreateLineBoxes computes an entire paragraph's lines in one -/// unbounded, side-effect-free call (confirmed by InlineFragmentation.ApplyLineBreaking's own doc -/// comment: the "run of already-laid-out lines... is monolithic and never straddles" - fragmentation only -/// ever shifts already-finished lines' Y coordinates afterward, never touches LineBoxes membership), -/// so by the time CssLayoutEngine.ApplyJustifyAlignment reads LineBoxes[LineBoxes.Count - 1] -/// (its own exact check), that index always names the block's true last line, page break or not. All three -/// tests below pass unmodified from PeachPDF's own assertions - none needed adaptation or [Ignore]. -/// [TestClass] [DoNotParallelize] public sealed class JustifiedLineAtABreakTests @@ -92,8 +76,7 @@ public async Task TheLineAPageBreakFallsAfter_IsJustified() Assert.AreEqual(block.ClientRight, lastOnFirstPage.Words[lastOnFirstPage.Words.Count - 1].Right, 1.0); } - /// The control, and the half that must not regress: the block's real last line is still - /// exempt. + /// The block's real last line is still exempt. [TestMethod] public async Task TheBlocksOwnLastLine_IsNotJustified() { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/KeepWithNextIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/KeepWithNextIntegrationTests.cs index 49a276e32..38eef7db7 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/KeepWithNextIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/KeepWithNextIntegrationTests.cs @@ -11,42 +11,9 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/KeepWithNextIntegrationTests.cs: css-break-3 §3.1 keep-with-next -/// (a break-after: avoid on an earlier sibling, or break-before: avoid on the later one, -/// forbids a break between the two) pinned at the actual nudge sites - -/// BlockFragmentation.RelocateIfNeeded (break-inside:avoid/monolithic/table-row-atomicity), -/// InlineFragmentation.ApplyLineBreaking (orphans, or ordinary word-flow pushing a line across a -/// boundary) - each followed by BlockFragmentation.EnforceKeepWithNext. +/// Keep-with-next through break-after: avoid or break-before: avoid. Headings set it explicitly, +/// the UA default only applies to print media. /// -/// -/// The UA default stylesheet's h1-h6 { break-after: avoid } lives under @media print -/// (RAdapter.DefaultMediaType vs PdfSharpAdapter's) and never applies to this -/// IntegrationTest project's WinForms-based HtmlContainer, which reports media type "screen" - see -/// StageR4KeepWithNextTest.cs's own established handling of the same trap. Every heading fixture -/// below sets break-after: avoid explicitly rather than relying on the UA default PeachPDF's -/// PdfSharp-hosted tests get for free. -/// -/// A table with no explicit break-inside:avoid still moves wholesale here when it has exactly one -/// row: css-tables-3 §6.1's row-atomicity default (TableRowDefaultAtomicityTest.cs, commit -/// "Preserve table rows unfragmented by default") pushes the whole (single) row - and with it the table, -/// which has no other content - to the next page on its own, without needing PeachPDF's own -/// table-specific whole-table pre-check (which has no counterpart here). -/// -/// -/// Confirmed gap found while calibrating these fixtures against the real engine: unlike -/// BlockFragmentation.RelocateIfNeeded (a real relayout, so the moved box's own -/// is genuinely updated), -/// CssLayoutEngineTable.LayoutCells's row-atomicity shift only offsets each CELL's own rectangle -/// (cell.OffsetTop(delta)) - it never touches the outer <table> box's own -/// Location/EffectiveTop, which stays exactly where the table's own (unmoved) natural top -/// fell. EnforceKeepWithNext(g, table) - called uniformly on the table like any other child in its -/// parent's child loop - reads that same stale EffectiveTop, so it never observes the boundary -/// crossing the row-shift just performed, and the table's own avoid-chained heading is never pulled. -/// Confirmed empirically: TableMovedToNextPage_LeavesNonAvoidHeadingBehind (which only checks that -/// the table's row itself moved, not that a heading follows it) passes; the two heading-pull variants below -/// are Ignored against this gap. -/// -/// [TestClass] [DoNotParallelize] public sealed class KeepWithNextIntegrationTests @@ -101,17 +68,12 @@ private static CssBox FindByClass(CssBox root, string className) return null!; } - // css-tables-3 §6.1's row-atomicity shift (CssLayoutEngineTable.LayoutCells) moves each CELL's own - // rectangle (cell.OffsetTop); the outer
        Row {i}
        box's own Location follows too, but only when the - // shifted row is the table's very first content (nothing rendered above it within the table yet) - an - // ordinary row straddling further down a multi-page table correctly leaves the table's Location where - // its real first row is. Reading a cell directly is the robust check either way, so tests use this - // rather than assuming which case applies. + // A moved row shifts its cells, the table box itself doesn't always follow, so read the cell. private static CssBox FindFirstCell(CssBox table) => Walk(table).FirstOrDefault(b => b.HtmlTag?.Name == "td")!; - // A table moved wholesale to the next page (css-tables-3 §6.1 row-atomicity, its only row too tall to - // fit) must pull its avoid-chained heading along instead of stranding it at the bottom of the old page. + // The table's only row doesn't fit, so the whole table moves to the next page. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task TableMovedToNextPage_PullsAvoidChainedHeadingAlong() { var (root, container) = await BuildAsync( @@ -132,9 +94,9 @@ public async Task TableMovedToNextPage_PullsAvoidChainedHeadingAlong() $"Heading (bottom={heading.ActualBottom}) must sit above the table (top={cell.Location.Y}) after both moved"); } - // Without an avoid link (break-after explicitly reset to auto), the heading must stay behind exactly - // as before - the pull is driven by the avoid chain, not proximity. + // Without an avoid the heading stays behind. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task TableMovedToNextPage_LeavesNonAvoidHeadingBehind() { var (root, container) = await BuildAsync( @@ -153,9 +115,9 @@ public async Task TableMovedToNextPage_LeavesNonAvoidHeadingBehind() Assert.AreEqual(0, container.PageIndexOf(heading.Location.Y)); } - // The chain walk must skip a display:none sibling and pull BOTH the heading and an avoid-chained intro - // paragraph along when the table moves to the next page. + // The chain skips a display:none sibling. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task TableMovedToNextPage_ChainSkipsDisplayNoneSibling_PullsHeadingAndIntroAlong() { var (root, container) = await BuildAsync( @@ -181,8 +143,8 @@ public async Task TableMovedToNextPage_ChainSkipsDisplayNoneSibling_PullsHeading Assert.IsTrue(intro.ActualBottom <= cell.Location.Y + 1.0); } - // A div pushed by break-inside: avoid must pull its avoid-chained heading the same way. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task BreakInsideAvoidBox_PullsAvoidChainedHeadingAlong() { var (root, container) = await BuildAsync( @@ -201,10 +163,9 @@ public async Task BreakInsideAvoidBox_PullsAvoidChainedHeadingAlong() Assert.IsTrue(heading.ActualBottom <= keep.Location.Y + 1.0); } - // The canonical real-document case: a heading followed by a plain paragraph. The paragraph is not - // relocated wholesale - word flow pushes its first LINE to the next page - and the keep-with-next retry - // must still bring the heading along. + // Only the paragraph's first line is pushed to the next page, not the whole paragraph. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task ParagraphFirstLinePushedByWordFlow_PullsAvoidChainedHeadingAlong() { var (root, container) = await BuildAsync( @@ -223,9 +184,9 @@ public async Task ParagraphFirstLinePushedByWordFlow_PullsAvoidChainedHeadingAlo Assert.IsTrue(heading.ActualBottom <= para.Location.Y + 1.0); } - // §4.3 relaxation, one tier at a time: where the whole chain cannot travel, the run is trimmed from its - // *front* rather than dropped entirely - the h3 (nearest the breaking box) travels, the h2 does not. + // css-break-3 §4.3: a chain too tall to travel whole is trimmed from its front. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task ChainedAvoidHeadings_TooTallToTravelWhole_AreTrimmedFromTheFront() { var (root, container) = await BuildAsync( @@ -245,18 +206,15 @@ public async Task ChainedAvoidHeadings_TooTallToTravelWhole_AreTrimmedFromTheFro var keepPage = container.PageIndexOf(keep.Location.Y); Assert.IsTrue(keepPage >= 1, $"Test setup expects the avoid box to move, but it is at y={keep.Location.Y}"); - // The tail of the run travelled... Assert.AreEqual(keepPage, container.PageIndexOf(inner.Location.Y)); Assert.IsTrue(inner.ActualBottom <= keep.Location.Y + 1.0); - // ...and the head of it did not - dropping the run whole would have stranded the h3 as well. Assert.IsTrue(container.PageIndexOf(outer.Location.Y) < keepPage, $"expected the chapter heading to stay behind, it is at y={outer.Location.Y}"); } - // Two consecutive avoid headings (h2 then h3) chain transitively - both move together with the content - // that triggered the break. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task ChainedAvoidHeadings_AllMoveTogether() { var (root, container) = await BuildAsync( @@ -280,9 +238,8 @@ public async Task ChainedAvoidHeadings_AllMoveTogether() Assert.IsTrue(inner.ActualBottom <= keep.Location.Y + 1.0); } - // A paragraph relocated by the orphans rule (too few lines would remain before the page boundary) must - // pull its avoid-chained heading along too - same idea, third nudge site. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task OrphansPushedParagraph_PullsAvoidChainedHeadingAlong() { var (root, container) = await BuildAsync( @@ -300,10 +257,8 @@ public async Task OrphansPushedParagraph_PullsAvoidChainedHeadingAlong() Assert.AreEqual(paraPage, container.PageIndexOf(heading.Location.Y)); } - // css-break §5.2: a forced break value takes precedence over an avoid on the other side of the same - // break point - a forced-break pair must never be treated as keep-together, even when the later box is - // subsequently relocated by break-inside: avoid. [TestMethod] + [Spec("css21", "page.html#page-break-props")] public async Task ForcedBreakAfter_TakesPrecedenceOverAvoid_HeadingIsNotPulled() { var (root, container) = await BuildAsync( @@ -317,19 +272,12 @@ public async Task ForcedBreakAfter_TakesPrecedenceOverAvoid_HeadingIsNotPulled() Assert.IsNotNull(heading); Assert.IsNotNull(keep); - // The forced break puts the keep box flush at page 2's own top (RelocateIfNeeded then declines to - // move it any further: its content is taller than a whole page, so - per RelocateIfNeeded's own - // "fits on no single page" rule - it is left exactly where the forced break put it rather than - // moved somewhere it also would not fit). The heading must stay behind on page 1 either way: the - // forced break between the two forbids keeping them together, independent of where the keep box - // itself ends up. Assert.AreEqual(1, container.PageIndexOf(keep.Location.Y), $"Test setup expects the forced break to place the keep box on page 2, but it is at y={keep.Location.Y}"); Assert.AreEqual(0, container.PageIndexOf(heading.Location.Y)); } - // css-break-3 §3.2: `avoid-page` names the page context explicitly, so it chains exactly as bare - // `avoid` does. `avoid-column`/`avoid-region` name other fragmentation contexts and must not. + // css-break-3 §3.2: avoid-column and avoid-region don't avoid page breaks. [TestMethod] [DataRow("break-after:avoid", true)] [DataRow("break-after:avoid-page", true)] @@ -355,13 +303,9 @@ public async Task KeepWithNext_ChainsOnlyOnPageContextAvoidance(string headingDe Assert.AreEqual(0, container.PageIndexOf(heading.Location.Y)); } - // Unsatisfiable avoid at the break-inside site: heading + keep box taller than one page. PeachPDF's - // relaxation moves the box alone; this port's RelocateIfNeeded relaxes the same constraint differently - // - its own "fits on no single page" rule (see BlockFragmentation.RelocateIfNeeded's doc comment) - // declines to move a box that cannot fit ANY page at all, leaving it exactly where ordinary flow placed - // it rather than moved somewhere it also would not fit. Either way the outcome that matters is - // preserved: the heading is never dragged into a multi-page mess alongside it. + // A keep box taller than a page fits nowhere, so neither it nor the heading moves. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task UnsatisfiableAvoidAtBreakInsideSite_IsRelaxed_NeitherIsMoved() { var (root, container) = await BuildAsync( @@ -380,9 +324,9 @@ public async Task UnsatisfiableAvoidAtBreakInsideSite_IsRelaxed_NeitherIsMoved() "content taller than a page fits nowhere, so RelocateIfNeeded must leave it exactly where ordinary flow placed it"); } - // An unsatisfiable avoid (heading + content taller than one page) is relaxed per spec: the content - // moves alone and the heading stays, instead of looping or overflowing. + // Heading plus content taller than a page: the avoid is dropped and the heading stays. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task UnsatisfiableAvoid_IsRelaxed_ContentMovesAlone() { var rows = string.Concat(Enumerable.Range(1, 60).Select(i => $"")); @@ -394,13 +338,11 @@ public async Task UnsatisfiableAvoid_IsRelaxed_ContentMovesAlone() var heading = FindByClass(root, "heading"); Assert.IsNotNull(heading); - // The heading must not be moved somewhere nonsensical: it stays on page 1. Assert.AreEqual(0, container.PageIndexOf(heading.Location.Y)); } - // break-before: avoid on the later sibling is the symmetric author-side trigger and must chain exactly - // like break-after: avoid on the earlier one. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task BreakBeforeAvoid_OnMovedBox_PullsPrecedingSiblingAlong() { var (root, container) = await BuildAsync( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/MonolithicContentLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/MonolithicContentLayoutIntegrationTests.cs index 7f1e3a12c..125973c2f 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/MonolithicContentLayoutIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/MonolithicContentLayoutIntegrationTests.cs @@ -12,10 +12,7 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/MonolithicContentLayoutIntegrationTests.cs: what css-break-3 §2's -/// monolithic set (MonolithicContent.IsMonolithic - a scroll container or a replaced element) does -/// to pagination - moved whole to the next fragmentainer rather than sliced, via the same -/// BlockFragmentation.RelocateIfNeeded mover break-inside:avoid uses. +/// css-break-3 §2 monolithic content (scroll containers, replaced elements) moves whole to the next page. /// [TestClass] [DoNotParallelize] @@ -74,7 +71,7 @@ private static string StraddleDocument(string cardCss) => $"
        filler
        " + $"
        content
        "; - // The headline case: a card with overflow: hidden is a scroll container, so it may not be split. + // overflow:hidden makes a scroll container, which may not be split. [TestMethod] public async Task StraddlingScrollContainer_MovesWholeToTheNextPage() { @@ -88,8 +85,9 @@ public async Task StraddlingScrollContainer_MovesWholeToTheNextPage() Assert.AreEqual(container.PageTopOf(1), card.Location.Y, 0.5); } - // The control: the identical box without the declaration still straddles. + // The same box without the declaration still straddles. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task StraddlingVisibleBox_IsStillSplit() { var (root, container) = await BuildAsync(StraddleDocument("")); @@ -102,8 +100,7 @@ public async Task StraddlingVisibleBox_IsStillSplit() "fixture must straddle a page boundary when nothing forbids it"); } - // The two movers share the same relocation code path, so they must agree exactly however the box came - // to straddle. + // A monolithic box moves exactly like a break-inside:avoid one. [TestMethod] [DataRow(750.0)] [DataRow(800.0)] @@ -140,9 +137,7 @@ public async Task EveryScrollContainerValue_MovesWhole(string css) container.PageIndexOf(card.ActualBottom - 0.01)); } - // §2 would have content that fits in no fragmentainer overflow rather than be sliced. This port - // deliberately keeps fragmenting instead (RelocateIfNeeded's own "fits on no single page - left in - // place" rule), matching PeachPDF's own documented choice for the same case. + // css-break-3 §2 lets monolithic content taller than a page overflow, this keeps fragmenting it instead. [TestMethod] public async Task ScrollContainerTallerThanTheBand_KeepsFragmentingRatherThanOverflowing() { @@ -168,8 +163,7 @@ public async Task ScrollContainerTallerThanTheBand_KeepsFragmentingRatherThanOve Assert.AreEqual(80, placed); } - // A fixed box is emitted in every fragmentainer at identical coordinates, so "move it to the next page" - // names nothing for it - the mover has to leave it alone however it is styled. + // A fixed box repeats on every page, there is no next page to move it to. [TestMethod] public async Task FixedScrollContainer_IsNotRelocated() { @@ -189,8 +183,7 @@ string FixedDocument(string cardCss) => Assert.AreEqual(plainCard.Location.Y, clippedCard.Location.Y, 0.5); } - // A display:none box is never placed - LayoutContents copies its previous sibling's Location/ - // ActualBottom instead, so it must be untouched by the mover. + // A display:none box takes its previous sibling's geometry and must not be moved. [TestMethod] public async Task HiddenScrollContainer_IsNotRelocated() { @@ -228,8 +221,7 @@ public async Task ScrollContainerExactlyAsTallAsTheBand_StillMovesWhole() container.PageIndexOf(card.ActualBottom - 0.01)); } - // A scroll container too tall for any band, with nothing inside it to fragment, overflows in place - - // and content after it must still see a truthful position, not one measured against a stale band. + // An empty scroll container taller than a page overflows in place, content after it follows its bottom. [TestMethod] public async Task ContentAfterAScrollContainerTallerThanTheBand_SeesATruthfulCursor() { @@ -246,15 +238,12 @@ public async Task ContentAfterAScrollContainerTallerThanTheBand_SeesATruthfulCur Assert.IsTrue(card.ActualBottom - card.Location.Y > PageHeight, "the fixture must be taller than a whole band, or this asserts nothing"); - // The content after the oversized box must flow from its real bottom, not from some earlier, - // stale band boundary. Assert.AreEqual(card.ActualBottom, after.Location.Y, 0.5); } - // The replaced half of §2 reaches the same outcome by a different route: an is forced inline, so - // it never runs the epilogue's mover at all - its whole word moves through the ordinary per-word - // fragmentainer check instead, the same path any other word takes. + // An inline image is a word, so it moves with its line instead of through the block mover. [TestMethod] + [Spec("css21", "page.html#best-page-breaks")] public async Task StraddlingImage_MovesWholeThroughTheWordPath() { var html = "
        filler
        " diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/OrphansWidowsIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/OrphansWidowsIntegrationTests.cs index 661f18a2c..fa22efe2f 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/OrphansWidowsIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/OrphansWidowsIntegrationTests.cs @@ -11,30 +11,9 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/OrphansWidowsIntegrationTests.cs: orphans/widows -/// CSS parsing/inheritance, and their break-avoidance effect via -/// InlineFragmentation.ApplyLineBreaking - the minimum number of lines kept before/after a -/// fragmentainer break. +/// orphans and widows. Break tests sweep filler heights and count the lines on either side of +/// the break, line positions depend on font metrics. /// -/// -/// PeachPDF's own exact pixel expectations (calibrated against its PdfSharp text measurement) do not -/// transfer to this port's WinForms-measured fixtures, so the break-avoidance tests below sweep a range of -/// filler heights and assert the underlying invariant (how many lines land either side of the boundary, -/// read from the fragment tree) rather than a hardcoded Location.Y - the same "never hardcode a just- -/// barely-straddles calibration" approach already established by this project's own -/// OrphansOnFirstRunTest.cs/StageR5WidowsMultiPageTest.cs. -/// -/// 2 of PeachPDF's 22 tests are dropped - Widows2_RewoundPass_LeavesEveryWordClaimedExactlyOnce and -/// Widows_RewindingABox_TakesABoundedNumberOfPasses both assert against a real cross-pass rewind -/// (HtmlContainerInt.FragmentainerPasses/a "rewound pass" concept) that has no counterpart here - -/// InlineFragmentation.ApplyLineBreaking's own doc comment confirms orphans/widows correction is a -/// same-pass, side-effect-free computation over the block's own already-complete line list, never a -/// resumed/replayed pass. 2 more (Orphans2_NothingAboveItInTheFragmentainer_IsLeftWhereItIs, -/// Orphans2_HeadingAndParagraph_AreCorrectedOnceRatherThanWalkingTheDocument) drop only their own -/// FragmentainerPasses bound-check for the same reason, keeping their substantive geometry -/// assertion. -/// -/// [TestClass] [DoNotParallelize] public sealed class OrphansWidowsIntegrationTests @@ -78,6 +57,7 @@ private static CssBox FindById(CssBox root, string id) => // ─── CSS parsing/inheritance ──────────────────────────────────────────── [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Orphans_DefaultsToTwo() { var (root, _) = await BuildAsync("

        text

        "); @@ -85,6 +65,7 @@ public async Task Orphans_DefaultsToTwo() } [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Widows_DefaultsToTwo() { var (root, _) = await BuildAsync("

        text

        "); @@ -92,6 +73,7 @@ public async Task Widows_DefaultsToTwo() } [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Orphans_ParsesExplicitValue() { var (root, _) = await BuildAsync("

        text

        "); @@ -99,22 +81,14 @@ public async Task Orphans_ParsesExplicitValue() } [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Widows_ParsesExplicitValue() { var (root, _) = await BuildAsync("

        text

        "); Assert.AreEqual("1", FindById(root, "p").Widows); } - // orphans/widows must be >= 1 per spec (a used-value constraint) - but the DECLARED value ("0") is - // still syntactically legal CSS and is stored verbatim, exactly matching PeachPDF's own real behavior: - // Css/PropertyPaginationTests.cs (ported directly from PeachPDF.Tests/CSS/Property.cs) already asserts - // OrphansProperty/WidowsProperty parse "0" into Property.Value=="0", not a fallback. CssBox.Orphans/ - // Widows are thin wrappers over that same declared string, so they correctly return "0" too - this was - // confirmed the hard way: an earlier attempt to make the raw string reject 0 at the CSS-engine level - // broke CssOrphansZeroLegal/CssWidowsZeroLegal outright. The actual spec constraint (>=1) is enforced - // exactly once, at the point real layout consumes the value: ActualOrphans/ActualWidows (below) already - // treat any non-positive parse as unset and fall back to the CSS initial value of 2 - which is what - // this test should really be pinning, not the raw declared string. + // The declared "0" is kept, layout uses 2 instead. [TestMethod] public async Task Orphans_ZeroResolvesToDefault_ThoughTheDeclaredStringStaysZero() { @@ -141,6 +115,7 @@ public async Task Widows_NegativeResolvesToDefault() } [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Widows_IsInherited() { var (root, _) = await BuildAsync("

        text

        "); @@ -148,6 +123,7 @@ public async Task Widows_IsInherited() } [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Orphans_IsInherited() { var (root, _) = await BuildAsync("

        text

        "); @@ -158,21 +134,8 @@ public async Task Orphans_IsInherited() private const double LineHeight = 20; - // A narrow width (each word alone easily exceeds half of it, so no two ever share a line) forces - // exactly one word per rendered line via natural wrapping - deliberately not
        : this fork's
        - // handling (DomParser.CorrectLineBreaksBlocks) only folds a
        into a real forced-newline "\n" word - // when it is the LAST thing in its inline run; a
        with more inline content after it is left as a - // literal box, which the parser's block-correction pass then splits into SEPARATE anonymous BLOCK - // siblings (one per
        -delimited run), each holding exactly one line of its own - confirmed directly - // by inspecting the box tree (CssBox.Boxes came back as 7 children: 4 display:block text runs - // interleaved with 3 display:inline
        boxes, and the

        itself had zero LineBoxes of its own). - // InlineFragmentation.ApplyLineBreaking operates on a SINGLE box's own LineBoxes list, so a
        -joined - // fixture never exercises its multi-line widows/orphans correction at all - every "line" is really an - // independent 1-line sibling block, which is a fundamentally different (and, for this feature, useless) - // shape than what these tests are meant to probe. A real multi-word-wrapped paragraph does not have - // this problem (confirmed empirically: no such splitting, and no spurious extra line box either - that - // only showed up at truly Ext width like 10px, an unrelated edge case avoided by staying well clear of - // it here). + // One word per line through wrapping, not
        :
        in the middle of a run splits the paragraph into + // anonymous blocks of one line each. private static string Paragraph(int lineCount, string extraStyle = "") => "

        " + string.Join(" ", Enumerable.Range(1, lineCount).Select(i => $"Line{i}")) @@ -198,10 +161,9 @@ private static string Paragraph(int lineCount, string extraStyle = "") => return (before, after, pageIndex); } - // §5.4 asks for the *minimum* number of lines to be moved across the break, not for the whole box: a - // 4-line paragraph straddling with only 1 line naturally following the break (violating widows:2) must - // have exactly one line pulled across, landing 2-before/2-after - not the whole box pushed on. + // css-break-3 §5.4 moves the fewest lines across the break, not the whole box. [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Widows2_OnlyOneLineWouldFollowTheBreak_MovesOneLineRatherThanTheWholeBox() { var checkedAny = false; @@ -220,6 +182,7 @@ public async Task Widows2_OnlyOneLineWouldFollowTheBreak_MovesOneLineRatherThanT } [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Widows3_MovesAsManyLinesAsItTakes() { var checkedAny = false; @@ -236,9 +199,9 @@ public async Task Widows3_MovesAsManyLinesAsItTakes() Assert.IsTrue(checkedAny, "test is not meaningful as written"); } - // Where the two constraints meet, one has to give: honoring widows:4 on a 4-line paragraph would leave - // none before the break, so the per-line correction gives up in favor of pushing the whole box. + // widows:4 on a 4-line paragraph leaves nothing before the break, so the whole box moves. [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Widows4_CannotBeSatisfiedWithoutBreakingOrphans_PushesTheWholeBox() { var checkedAny = false; @@ -250,9 +213,6 @@ public async Task Widows4_CannotBeSatisfiedWithoutBreakingOrphans_PushesTheWhole if (s.Before == 0) continue; // already on a fresh page - nothing to characterize here checkedAny = true; - // Since widows:4 can never be satisfied alongside orphans on a 4-line paragraph without an - // empty leading fragment, whichever way it lands, all 4 lines must be together on one page - // (the whole-box push), not split. Assert.IsTrue(s.Before == 0 || s.After == 0, $"filler={filler}: expected the whole box pushed together (0 before or 0 after), got {s.Before}/{s.After}"); } @@ -261,6 +221,7 @@ public async Task Widows4_CannotBeSatisfiedWithoutBreakingOrphans_PushesTheWhole } [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Orphans2_ParagraphNudgedWhenOnlyOneLineWouldPrecedeTheBreak() { var checkedAny = false; @@ -280,31 +241,27 @@ public async Task Orphans2_ParagraphNudgedWhenOnlyOneLineWouldPrecedeTheBreak() } [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task OrphansWidows_NoEffect_WhenSplitAlreadySatisfiesBothMinimums_Regression() { - // A taller page than the other sweeps in this file, deliberately: this test wants a *comfortable* - // natural 2-2 split (plenty of slack either side), not one right at the tight margin the confirmed - // gap on Widows2_OnlyOneLineWouldFollowTheBreak_MovesOneLineRatherThanTheWholeBox lives at. + // A taller page leaves slack on both sides of a natural 2-2 split. var checkedAny = false; for (var filler = 0.0; filler < 400; filler += 4) { - var split = await SplitAsync(filler, 4, "orphans:2;widows:2", pageHeight: 200); - if (split is not { Before: >= 2, After: >= 2 } s) continue; - if (s.Before + s.After != 4) continue; + var natural = await SplitAsync(filler, 4, "orphans:1;widows:1", pageHeight: 200); + if (natural is not { Before: >= 2, After: >= 2 } n) continue; checkedAny = true; - // Already satisfied by the natural split - both minimums hold without further adjustment - // (the invariant every other test in this file also relies on). - Assert.IsTrue(s.Before >= 2 && s.After >= 2); + var split = await SplitAsync(filler, 4, "orphans:2;widows:2", pageHeight: 200); + Assert.AreEqual(n, split, $"filler={filler}"); } Assert.IsTrue(checkedAny, "test is not meaningful as written"); } - // An 8-line paragraph is taller than the page itself - pushing it whole can't satisfy orphans/widows - // anyway (it would just recreate the same violation on the next page), so it is a documented, accepted - // limitation: left straddling rather than nudged pointlessly. + // An 8-line paragraph is taller than the page, moving it whole wouldn't help. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task TallParagraph_ExceedsOnePage_IsNotNudged() { var html = "

        " + Paragraph(8); @@ -315,16 +272,14 @@ public async Task TallParagraph_ExceedsOnePage_IsNotNudged() var tops = Walk(p).SelectMany(b => b.Words).Where(w => !w.IsLineBreak).Select(w => w.Top).Distinct().OrderBy(t => t).ToList(); Assert.AreEqual(8, tops.Count, "fixture must produce all 8 of its own lines for this to test anything"); - // Taller than one page's own band: no single-page relocation could ever help, so nothing here - // should have looped or dropped content trying. var pageIndexes = tops.Select(container.PageIndexOf).Distinct().ToList(); Assert.IsTrue(pageIndexes.Count > 1, "fixture must actually straddle more than one page for this to test anything"); + Assert.AreEqual(38, tops[0], 1.0, "the page break should fall between the paragraph's lines, not before it"); } - // orphans decided at the break point rather than afterwards: a paragraph taller than the band cannot - // be helped by moving it whole, but the break *before it* can fall earlier - with too few lines above - // the boundary, orphans:2 must still push the whole thing to the next page rather than stranding one. + // A paragraph taller than the page still doesn't leave a single line behind. [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Orphans2_ParagraphTallerThanTheBand_BreaksBeforeItselfRatherThanStrandingOneLine() { var checkedAny = false; @@ -351,9 +306,8 @@ public async Task Orphans2_ParagraphTallerThanTheBand_BreaksBeforeItselfRatherTh Assert.IsTrue(checkedAny, "test is not meaningful as written"); } - // The author's own relaxation: orphans:1 permits a single-line fragment, so a paragraph taller than - // the band may still leave exactly one line on the page it started on. [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Orphans1_ParagraphTallerThanTheBand_KeepsItsSingleLineFragment() { var foundASingleLineFragment = false; @@ -378,26 +332,26 @@ public async Task Orphans1_ParagraphTallerThanTheBand_KeepsItsSingleLineFragment } [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Orphans2_SatisfiedByTheNaturalBreak_LeavesTheParagraphWhereItIs() { var checkedAny = false; for (var filler = 0.0; filler < 100; filler += 2) { - var split = await SplitAsync(filler, 8, "orphans:2"); - if (split is not { Before: >= 2 } s) continue; - if (s.After == 0) continue; + var natural = await SplitAsync(filler, 8, "orphans:1"); + if (natural is not { Before: >= 2, After: > 0 } n) continue; checkedAny = true; - Assert.IsTrue(s.Before >= 2); + var split = await SplitAsync(filler, 8, "orphans:2"); + Assert.AreEqual(n, split, $"filler={filler}"); } Assert.IsTrue(checkedAny, "test is not meaningful as written"); } - // With nothing above it in the fragmentainer, moving the box cannot give it more room, so the - // constraint is given up rather than acted on - a band too small for `orphans` lines must not walk the - // box down the document one page at a time. + // At the top of a page, moving the box gives it no more room, so orphans is dropped. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task Orphans2_NothingAboveItInTheFragmentainer_IsLeftWhereItIs() { var html = "
        " + Paragraph(3, "orphans:2"); @@ -408,7 +362,7 @@ public async Task Orphans2_NothingAboveItInTheFragmentainer_IsLeftWhereItIs() Assert.AreEqual(0, p.EffectiveTop, 1.0); } - // One correction per box per layout, not per pass - this must terminate promptly rather than looping. + // Must terminate instead of moving the paragraph down page by page. [TestMethod] [Timeout(10000)] public async Task Orphans2_HeadingAndParagraph_AreCorrectedOnceRatherThanWalkingTheDocument() @@ -422,6 +376,7 @@ public async Task Orphans2_HeadingAndParagraph_AreCorrectedOnceRatherThanWalking } [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Widows2_ParagraphStartingOnSecondPage_StillCorrected() { var checkedAny = false; @@ -438,23 +393,24 @@ public async Task Widows2_ParagraphStartingOnSecondPage_StillCorrected() Assert.IsTrue(checkedAny, "test is not meaningful as written - no filler in range started the paragraph past page 0 while still straddling"); } - // css4.pub's real dictionary sets "widows: 1; orphans: 1" - already maximally permissive, so this - // feature should never nudge anything on that document. + // orphans:1 and widows:1 never require moving a line. [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task Orphans1Widows1_MatchesDictionaryCssValues_NoEffect_Regression() { - for (var filler = 0.0; filler < 100; filler += 10) + var oneBefore = false; + var oneAfter = false; + for (var filler = 0.0; filler < 100; filler += 2) { var split = await SplitAsync(filler, 4, "orphans:1;widows:1"); if (split is not { } s) continue; if (s.Before + s.After != 4) continue; - // orphans:1/widows:1 never requires more than one line either side, so any natural split with - // at least one line on each side (once it straddles at all) must be left alone. - if (s.Before > 0 && s.After > 0) - { - Assert.IsTrue(s.Before >= 1 && s.After >= 1); - } + oneBefore |= s.Before == 1; + oneAfter |= s.After == 1; } + + Assert.IsTrue(oneBefore, "orphans:1 should leave a single line before the break"); + Assert.IsTrue(oneAfter, "widows:1 should leave a single line after the break"); } } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageBreakIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageBreakIntegrationTests.cs index 1d4771e56..a724b454d 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageBreakIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageBreakIntegrationTests.cs @@ -10,35 +10,8 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/PageBreakIntegrationTests.cs: core forced break-before/ -/// break-after end-to-end regressions, mapped onto BlockFragmentation.TryGetForcedBreakTarget's -/// forced-break handling and BlockFragmentation.ResolveBlockTop's css-break-3 §5.2 margin -/// truncation (both in Core/Fragmentation/BlockFragmentation.cs), driven end to end through -/// HtmlContainerInt.DriveLayoutPasses. Fixtures use a 1000-unit page with a 50-unit margin (band -/// [50, 1050)), all in CSS px (which this port's -based -/// matches 1:1 - unlike pt, which the WinForms adapter -/// converts at a ~1.333 ratio, confirmed empirically while calibrating these fixtures). +/// Forced breaks and margin truncation on a 1000px page with a 50px top margin (band [50, 1050)). /// -/// -/// Three of PeachPDF's original 20 tests (PageNameChange_ForcesBreak, -/// SamePageName_DoesNotForceBreak, UnsetPageName_CarriesForwardWithoutForcingBreak) are -/// dropped: this port's page CSS property (CssBoxProperties.PageName, -/// PageNameProperty.cs) is parsed and stored but never consulted anywhere in the fragmentation/ -/// layout code - confirmed by reading BlockFragmentation.TryGetForcedBreakTarget in full, which -/// only ever tests BreakValues.IsForcedBreak on break-before/break-after. Named-page -/// attribution/transitions are out of scope per the port plan's general exclusion list. -/// -/// Confirmed by actually running these against the real engine (not just reading source): two more real -/// behavioral differences from PeachPDF surfaced, each documented on its own test below - -/// BreakBeforeAlways_IsAcceptedAsAForcedBreak_UnlikePeachPDF (inverted, not dropped: a deliberate, -/// documented design choice - see BreakValues.IsForcedBreak's own remark), and the "container left -/// behind" gap extending to margin-truncation-caused -/// overflow specifically (2 tests Ignored - EnforceKeepWithNext's pull only fires on an actual slot -/// gap between a container and ITS OWN previous sibling, which a grandchild's margin truncation alone never -/// creates, unlike RelocateIfNeeded's straddle-triggered relocation - the case -/// ContainerLeftBehindTest.cs already confirms working). -/// -/// [TestClass] [DoNotParallelize] public sealed class PageBreakIntegrationTests @@ -60,10 +33,7 @@ private static HtmlContainerInt GetInternal(HtmlContainer wrapper) var container = GetInternal(wrapper); container.PageSize = new RSize(400, PageHeight); container.MarginTop = MarginTop; - // Content must actually start at MarginTop, matching production (PdfGenerator.SetContent) - - // otherwise PageIndexOf/PageTopOf's grid (anchored at MarginTop) disagrees with where box - // geometry actually begins (Y=0 by HtmlContainerInt's own default Location), corrupting every - // margin-truncation/forced-break slot computation that follows. + // Content starts at MarginTop like in PdfGenerator, the page grid is anchored there. container.Location = new RPoint(0, MarginTop); wrapper.MaxSize = new SizeF(400, 0); @@ -103,9 +73,9 @@ private static CssBox FindById(CssBox root, string id) return null!; } - // Reproduces PeachPDF issue #50: page-break-inside: avoid splits content when preceded by an empty - // page-break-after: always div. + // An empty page-break-after:always div followed by a page-break-inside:avoid box. [TestMethod] + [Spec("css21", "page.html#forced")] public async Task PageBreakAfter_ForcesBreakBeforeNextSection() { var (root, container) = await BuildAsync( @@ -126,6 +96,7 @@ public async Task PageBreakAfter_ForcesBreakBeforeNextSection() } [TestMethod] + [Spec("css21", "page.html#forced")] public async Task PageBreakBefore_ForcesBreak() { var (root, container) = await BuildAsync( @@ -138,9 +109,9 @@ public async Task PageBreakBefore_ForcesBreak() $"Bordered box with break-before:page should start on page 2, but starts at y={bordered.Location.Y}"); } - // The modern spelling of the same forced break: break-before: page is the css-break-3 §3.1 value - // "page-break-before: always" is defined (§3.3) to map onto, so both must paginate identically. + // css-break-3 §3.3 maps page-break-before:always onto break-before:page. [TestMethod] + [Spec("css21", "page.html#forced")] public async Task BreakBeforePage_ForcesBreak() { var (root, container) = await BuildAsync(ForcedBreakHtml("break-before:page")); @@ -151,13 +122,7 @@ public async Task BreakBeforePage_ForcesBreak() $"break-before: page should start the box on page 2, but it starts at y={second.Location.Y}"); } - // PeachPDF treats "break-before: always" as invalid (only the legacy page-break-before accepts - // "always") and expects it to fall back to "auto", not forcing a break. HTML-Renderer deliberately - // does not: BreakValues.IsForcedBreak's own remark documents that this port's CSS engine accepts - // "always" directly on the modern break-before/break-after properties too, rather than normalizing it - // away at parse time - confirmed here by BreakBeforeProperty's converter (BreakModeConverter, which - // parses "always" successfully) and by this test actually observing the forced break fire. Inverted - // rather than dropped, since it is a real, deliberate design choice worth pinning either way. + // 'always' is also accepted on the unprefixed break-before (css-break-4). [TestMethod] public async Task BreakBeforeAlways_IsAcceptedAsAForcedBreak_UnlikePeachPDF() { @@ -167,18 +132,17 @@ public async Task BreakBeforeAlways_IsAcceptedAsAForcedBreak_UnlikePeachPDF() Assert.IsNotNull(second); Assert.AreEqual("always", second.BreakBefore); Assert.AreEqual(1, container.PageIndexOf(second.Location.Y), - $"break-before: always is accepted as a forced break in this port, but the box starts at y={second.Location.Y}"); + $"break-before: always should force a break, but the box starts at y={second.Location.Y}"); } private static string ForcedBreakHtml(string breakDeclaration) => $"
        First
        " + $"
        Second
        "; - // css-break-3 §3.2: `avoid` and `avoid-page` both forbid a page break, and must reposition the box to - // the next page's content top. [TestMethod] [DataRow("break-inside:avoid;page-break-inside:avoid")] [DataRow("break-inside:avoid-page")] + [Spec("css21", "page.html#page-break-props")] public async Task BreakInside_AvoidingAPageBreak_PositionsAtTopOfNextPage(string declaration) { var (root, container) = await BuildAsync(BreakInsideHtml(declaration)); @@ -198,8 +162,7 @@ public async Task BreakInside_AvoidingAPageBreak_PositionsAtTopOfNextPage(string "Relocated box should sit flush at its page's content top"); } - // The other half of §3.2: `avoid-column` and `avoid-region` name fragmentation contexts other than the - // page, so they must NOT suppress a page break. + // css-break-3 §3.2: avoid-column and avoid-region don't avoid page breaks. [TestMethod] [DataRow("break-inside:avoid-column")] [DataRow("break-inside:avoid-region")] @@ -220,9 +183,8 @@ private static string BreakInsideHtml(string declaration) => "
        filler
        " + $"
        Keep together
        "; - // css-break-3 §5.2: a collapsed margin that stays within the same page as its previous sibling's - // bottom never triggers truncation. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task Margin_NotCrossingPageBoundary_IsNotTruncated() { var (root, _) = await BuildAsync( @@ -238,9 +200,8 @@ public async Task Margin_NotCrossingPageBoundary_IsNotTruncated() "second's margin-top doesn't cross a page boundary, so it must be completely unaffected by truncation"); } - // A margin just barely large enough to cross a page boundary must be discarded entirely - the box - // lands flush at the top of the very next page. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task Margin_CrossingOnePageBoundary_TruncatesToZero_LandsAtTopOfNextPage() { var (root, container) = await BuildAsync( @@ -261,9 +222,9 @@ public async Task Margin_CrossingOnePageBoundary_TruncatesToZero_LandsAtTopOfNex "Truncated margin should leave second flush at its page's content top"); } - // Acid2's own actual scenario: a margin so large it would span several page heights with no real - // content in it at all. Truncation must land the box on the very NEXT page - not skip further pages. + // A margin spanning several pages still only moves the box to the next page. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task HugeMultiPageMargin_TruncatesToZero_LandsOnVeryNextPage() { var (root, container) = await BuildAsync( @@ -278,9 +239,8 @@ public async Task HugeMultiPageMargin_TruncatesToZero_LandsOnVeryNextPage() Assert.AreEqual(container.PageTopOf(1), second.Location.Y, 0.5); } - // A forced break already relocates the previous sibling's bottom to the next page's top - per - // css-break-3 §5.2, PeachPDF preserves (does not truncate) the margin AFTER a forced break. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task ForcedBreak_MarginAfterBreak_IsPreservedNotTruncated() { var (root, container) = await BuildAsync( @@ -301,11 +261,11 @@ private static string FirstChildDocument(string outerStyle, string margin = "120 + $"
        first
        " + ""; - // Either a border or padding on the container blocks margin-collapse-through, so the margin is the - // first child's own and the break falls before it. + // A border or padding on the container stops the margin from collapsing through it. [TestMethod] [DataRow("border-top:1px solid black")] [DataRow("padding-top:1px")] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task FirstChildOfACollapseBlockingContainer_HasItsOversizedMarginTruncated(string outerStyle) { var (root, container) = await BuildAsync(FirstChildDocument(outerStyle)); @@ -317,9 +277,8 @@ public async Task FirstChildOfACollapseBlockingContainer_HasItsOversizedMarginTr "flush at the very next slot's content top - never wherever the untruncated margin reached"); } - // The margin is taken as a break *before* the box, so the document really does resume in the next - // fragmentainer - a second, real fragmentainer must exist. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task TruncatedFirstChildMargin_IsTakenAsABreakBefore() { var (_, container) = await BuildAsync(FirstChildDocument("border-top:1px solid black")); @@ -328,8 +287,8 @@ public async Task TruncatedFirstChildMargin_IsTakenAsABreakBefore() "the truncated margin must actually open a second fragmentainer"); } - // A margin that stays inside its own slot is untouched, exactly as for a box with a sibling. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task FirstChildMargin_StayingWithinItsOwnSlot_IsNotTruncated() { var (root, _) = await BuildAsync(FirstChildDocument("border-top:1px solid black", margin: "200px")); @@ -342,21 +301,10 @@ public async Task FirstChildMargin_StayingWithinItsOwnSlot_IsNotTruncated() Assert.AreEqual(outer.ClientTop + 200, first.Location.Y, 1); } - // css-break-3 §3.1 keep-with-next across a container, via margin truncation rather than - // break-inside:avoid/monolithic relocation. Unlike RelocateIfNeeded (confirmed working end to end by - // this repo's own ContainerLeftBehindTest.cs), a margin-truncated FIRST child never gives its - // container's own EnforceKeepWithNext(wrap, prevSibling: head) anything to act on: 'wrap'.EffectiveTop - // never itself crosses a page boundary (only its grandchild 'body's truncated top does), so - // childTopSlot == prevBottomSlot from wrap's own perspective and EnforceKeepWithNext returns - // immediately ("no break actually falls between them"). Confirmed by running this test unignored: the - // heading never moves and stays on the original page while 'body' alone jumps to the next one, leaving - // 'wrap' spanning both - exactly the bug ContainerLeftBehindTest.cs fixes for the OTHER mover, still - // present for this one. + // The heading stays on the first page while 'body' moves, so 'wrap' spans both pages. [TestMethod] - [Ignore("Confirmed gap: margin-truncation-caused overflow of a container's own first/only child does " - + "not propagate a keep-with-next pull to the container's preceding avoid-chained sibling - see " - + "this test's own remark above for the exact mechanism. RelocateIfNeeded's break-inside:avoid " - + "trigger IS fixed for this (ContainerLeftBehindTest.cs); margin truncation (ResolveBlockTop) is not.")] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task FirstChildRelocation_PullsTheRunAcrossTheContainer() { var (root, container) = await BuildAsync( @@ -380,13 +328,9 @@ public async Task FirstChildRelocation_PullsTheRunAcrossTheContainer() Assert.AreEqual(container.PageIndexOf(head.Location.Y), container.PageIndexOf(body.Location.Y)); } - // The structurally equivalent document, with the paragraph as the heading's own next sibling (no - // wrapping container). Same confirmed gap as above: the wrapped shape does not move its heading, the - // flat (sibling) shape does (an ordinary EnforceKeepWithNext(body, prevSibling: head) call, which - // sees a real slot gap directly), so the two shapes disagree. + // Same document with and without the wrapping container should put the heading on the same page. [TestMethod] - [Ignore("Same confirmed gap as FirstChildRelocation_PullsTheRunAcrossTheContainer - the nested shape's " - + "heading never moves, so it disagrees with the flat shape's, which does.")] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task FirstChildRelocation_MatchesTheEquivalentSiblingShape() { static string Document(string open, string close) => @@ -407,16 +351,9 @@ static string Document(string open, string close) => Assert.AreEqual(container.PageIndexOf(flatHead.Location.Y), container.PageIndexOf(nestedHead.Location.Y)); } - // A box carrying a forced break that is *not* taken - because nothing precedes it in the flow - still - // counts as forced-break-governed in PeachPDF, so §5.2 leaves its margin alone there. HTML-Renderer's - // ResolveBlockTop has no such exemption: it applies its crossing-margin truncation uniformly, with no - // check of the box's own BreakBefore value at all (confirmed by reading ResolveBlockTop in full - the - // "untaken forced break" case falls into its plain `else` branch inside CssBox.PerformLayoutImp exactly - // like an ordinary box), so the oversized margin here IS truncated. Confirmed by running this test - // unignored: the box lands at exactly PageTopOf(1), not wrap.ClientTop + 500. + // css-break-3 §5.2 keeps the margin of a box whose forced break isn't taken. [TestMethod] - [Ignore("Confirmed gap: ResolveBlockTop truncates a crossing margin regardless of whether the box " - + "carries an untaken forced break-before - see this test's own remark above.")] + [Ignore("The crossing margin is truncated even when the box carries a forced break-before that isn't taken")] public async Task FirstBoxInTheFlow_CarryingAnUntakenForcedBreak_KeepsItsMargin() { var (root, _) = await BuildAsync( @@ -431,15 +368,9 @@ public async Task FirstBoxInTheFlow_CarryingAnUntakenForcedBreak_KeepsItsMargin( Assert.AreEqual(wrap.ClientTop + 1200, only.Location.Y, 1); } - // PeachPDF documents a known boundary here: with nothing on the ancestor chain to block collapse- - // through, the margin collapses all the way to the root, which (there) has no containing block for a - // break to fall at the top of, so the margin escapes truncation entirely. HTML-Renderer does not share - // this limitation: ResolveBlockTop is called directly on 'first' itself (using whatever its own - // MarginTopCollapse resolves to, wherever that collapse reaches), not through a separate "root margin" - // special case - so the truncation still applies. Confirmed by running this test unignored: 'first' - // lands at exactly PageTopOf(1), not past it. [TestMethod] - public async Task FirstChildMarginCollapsingToTheRoot_IsStillTruncated_UnlikePeachPDF() + [Spec("css21", "page.html#allowed-page-breaks")] + public async Task FirstChildMarginCollapsingToTheRoot_IsStillTruncated() { var (root, container) = await BuildAsync(FirstChildDocument("")); @@ -447,7 +378,7 @@ public async Task FirstChildMarginCollapsingToTheRoot_IsStillTruncated_UnlikePea Assert.IsNotNull(first); Assert.AreEqual(container.PageTopOf(1), first.Location.Y, 1, - "unlike PeachPDF's own documented limitation, this port truncates the margin even when it collapses through to the root"); + "the margin is truncated even when it collapses through to the root"); } #endregion diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageMarginPaginationIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageMarginPaginationIntegrationTests.cs index 4d4359f64..6a1b9092b 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageMarginPaginationIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageMarginPaginationIntegrationTests.cs @@ -11,28 +11,13 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/PageMarginPaginationIntegrationTests.cs: a regression class for -/// "@page margins waste marginTop+marginBottom of every page" - is -/// already margin-free, so the real per-page content band is the shifted grid -/// [k*PageSize.Height + MarginTop, (k+1)*PageSize.Height + MarginTop), and -/// / are the single -/// definition of that grid. +/// Pagination with page margins: is the page area without margins, +/// so page k spans [k*PageSize.Height + MarginTop, (k+1)*PageSize.Height + MarginTop). /// -/// -/// Mirrors production's real relationship between , -/// and - the same -/// relationship confirmed (the hard way, via a full test-run failure sweep while porting the sibling files -/// in this folder) to matter for every margin-truncation/forced-break test in this batch: content must -/// actually start at Location = (0, MarginTop), not at the default (0, 0), or the pagination -/// grid disagrees with where box geometry begins. -/// [TestClass] [DoNotParallelize] public sealed class PageMarginPaginationIntegrationTests { - // Roughly the customer's own repro proportions: a Letter-ish page, sizeable asymmetric top/bottom - // margins - the shape "double-subtracting" the margins from an already margin-free PageSize.Height - // breaks most visibly on. private const double RawPageHeight = 800; private const double MarginTopValue = 40; private const double MarginBottomValue = 50; @@ -55,8 +40,7 @@ private static HtmlContainerInt GetInternal(HtmlContainer wrapper) container.MarginLeft = 0; container.MarginRight = 0; - // Mirrors PdfGenerator.SetContent exactly: PageSize.Height is the margin-free content band, and - // layout starts at (0, MarginTop) - not the raw page height/origin. + // Same setup as PdfGenerator. container.PageSize = new RSize(400, RawPageHeight - marginTop - marginBottom); container.Location = new RPoint(0, marginTop); wrapper.MaxSize = new SizeF(400, 0); @@ -87,9 +71,7 @@ private static CssBox FindByClass(CssBox root, string className) return null!; } - // A
        box's own Location is never assigned by table layout - only its CELLS' is (see - // TableHeaderRepeat.cs's own doc remark, and CssLayoutEngineTable's row loop) - so geometry has to be - // read off each row's first cell, not the row box itself. + // Table layout positions the cells, not the box. private static List FindAllRows(CssBox table) => Walk(table).Where(b => b.HtmlTag?.Name == "td").ToList(); @@ -103,6 +85,7 @@ private static string BuildManyRowTableHtml(int rowCount) } [TestMethod] + [Spec("css21", "page.html#page-box")] public async Task Table_WithPageMargins_FillsEachFullPageCloseToBottomMargin() { var (root, container) = await BuildAsync(BuildManyRowTableHtml(80), MarginTopValue, MarginBottomValue); @@ -117,19 +100,15 @@ public async Task Table_WithPageMargins_FillsEachFullPageCloseToBottomMargin() var lastRowOnPage0 = page0Rows.OrderByDescending(r => r.ActualBottom).First(); var rowHeight = lastRowOnPage0.ActualBottom - lastRowOnPage0.Location.Y; - // Before the fix, availableHeight double-subtracted marginTop+marginBottom from an already - // margin-free PageSize.Height, so the page broke ~marginTop+marginBottom early - far more than one - // row's worth of slack. After the fix, the last row on the page should land within about one - // row-height of the real page-1 boundary. Assert.IsTrue(page0Bottom - lastRowOnPage0.ActualBottom <= rowHeight * 1.5, $"Page 0's last row (bottom={lastRowOnPage0.ActualBottom:F1}) stops {page0Bottom - lastRowOnPage0.ActualBottom:F1}px " + $"short of the real page boundary ({page0Bottom:F1}) - more than one row's worth (~{rowHeight:F1}px), indicating the page is under-filled."); } [TestMethod] + [Spec("css21", "page.html#page-box")] public async Task Table_WithZeroPageMargins_StillFillsEachPage() { - // Guards the historical (always-correct) zero-margin default against regressing. var (root, container) = await BuildAsync(BuildManyRowTableHtml(80), marginTop: 0, marginBottom: 0); var rows = FindAllRows(root); @@ -145,6 +124,8 @@ public async Task Table_WithZeroPageMargins_StillFillsEachPage() } [TestMethod] + [Spec("css21", "page.html#page-box")] + [Spec("css21", "page.html#forced")] public async Task ForcedPageBreak_WithPageMargins_LandsAtShiftedPageTop() { var (root, container) = await BuildAsync( @@ -160,6 +141,8 @@ public async Task ForcedPageBreak_WithPageMargins_LandsAtShiftedPageTop() } [TestMethod] + [Spec("css21", "page.html#page-box")] + [Spec("css21", "page.html#page-break-props")] public async Task BreakInsideAvoid_WithPageMargins_PositionsAtShiftedPageTop() { var (root, container) = await BuildAsync( @@ -180,6 +163,8 @@ public async Task BreakInsideAvoid_WithPageMargins_PositionsAtShiftedPageTop() } [TestMethod] + [Spec("css21", "page.html#page-box")] + [Spec("css21", "page.html#break-inside")] public async Task OrphansWidows_WithPageMargins_PushesWholeParagraphToShiftedPageTop() { var (root, container) = await BuildAsync( @@ -192,9 +177,7 @@ public async Task OrphansWidows_WithPageMargins_PushesWholeParagraphToShiftedPag var para = FindByClass(root, "para"); Assert.IsNotNull(para); - // If orphans/widows relocated the whole paragraph, it should sit exactly at the shifted page-1 - // top - if it didn't need to relocate (all lines already fit), that's fine too, but then we can't - // validate the push, so skip in that case. + // Nothing to check when all lines fit on the first page. if (para.Location.Y < container.PageSize.Height) return; var expectedTop = container.PageTopOf(1); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs new file mode 100644 index 000000000..8504d7652 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs @@ -0,0 +1,423 @@ +using System.Globalization; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.Fragmentation; + +[DoNotParallelize] +[TestClass] +public sealed class PagedMediaIntegrationTests +{ + private const double Delta = 0.5; + private const double PageWidth = 400; + private const double PageHeight = 300; + + private const string OnePixelPng = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + + private static (CssBox Root, HtmlContainerInt Container) Paginate(string body, double pageHeight = PageHeight, double margin = 0) => + LayoutHarness.Layout(LayoutHarness.Wrap(body), PageWidth, pageHeight, margin: margin); + + private static CssBox Find(CssBox root, string id) => LayoutHarness.FindById(root, id)!; + + private static int PageOf(HtmlContainerInt container, CssBox box) => container.PageIndexOf(box.Location.Y); + + private static int LastPageOf(HtmlContainerInt container, CssBox box) => + container.PageIndexOf(Math.Max(box.Location.Y, box.ActualBottom - 0.01)); + + /// One line per <br>, each tall. + private static string Paragraph(string id, int lines, string style = "") => + $"
        " + + string.Join("
        ", Enumerable.Range(1, lines).Select(i => $"L{i}")) + + "
        "; + + private static readonly Lazy LineHeightValue = new(() => + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(Paragraph("p", 1))); + return Find(root, "p").Size.Height; + }); + + private static double LineHeight => LineHeightValue.Value; + + private static string Px(double value) => value.ToString("0.###", CultureInfo.InvariantCulture) + "px"; + + private static int[] LinesPerPage(HtmlContainerInt container, CssBox block, int pages) + { + var perPage = new int[pages]; + foreach (var line in block.LineBoxes) + perPage[container.PageIndexOf(line.LineTop)]++; + return perPage; + } + + #region 13.2 Page boxes + + [TestMethod] + [Spec("css21", "page.html#page-box")] + public void PageArea_IsTheInitialContainingBlock() + { + var (root, _) = Paginate("
        ", margin: 20); + var d = Find(root, "d"); + + Assert.AreEqual(20, d.Location.X, Delta); + Assert.AreEqual(20, d.Location.Y, Delta); + Assert.AreEqual(PageWidth / 2, d.Size.Width, Delta); + } + + [TestMethod] + [Spec("css21", "page.html#page-box")] + public void EachPageArea_StartsBelowItsPageMargin() + { + var (root, container) = Paginate( + "
        ", margin: 20); + var b = Find(root, "b"); + + Assert.AreEqual(20 + PageHeight, b.Location.Y, Delta); + Assert.AreEqual(2, container.FragmentTree!.Fragmentainers.Count); + } + + #endregion + + #region 13.2.3 Content outside the page box + + [TestMethod] + [Spec("css21", "page.html#outside-page-box")] + public void FarAwayAbsoluteBox_DoesNotProduceBlankPages() + { + var (_, container) = Paginate( + "
        far
        "); + + Assert.IsTrue(container.FragmentTree!.Fragmentainers.Count <= 2, + $"{container.FragmentTree.Fragmentainers.Count} pages for two small boxes"); + } + + #endregion + + #region 13.3.1 Page break properties + + [TestMethod] + [Spec("css21", "page.html#page-break-props")] + public void PageBreakBeforeAlways_StartsTheBoxOnANewPage() + { + var (root, container) = Paginate( + "
        "); + + Assert.AreEqual(container.PageTopOf(1), Find(root, "b").Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "page.html#page-break-props")] + public void PageBreakAfterAlways_StartsTheNextBoxOnANewPage() + { + var (root, container) = Paginate( + "
        "); + + Assert.AreEqual(container.PageTopOf(1), Find(root, "b").Location.Y, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#page-break-props")] + [DataRow("
        ")] + [DataRow("
        ")] + [DataRow("
        ")] + [DataRow("
        ")] + public void LeftAndRight_ForceABreak(string body) + { + var (root, container) = Paginate(body); + + Assert.IsTrue(PageOf(container, Find(root, "b")) >= 1); + } + + [TestMethod] + [Spec("css21", "page.html#page-break-props")] + [DataRow("
        ")] + [DataRow("
        ")] + public void Always_TakesPrecedenceOverAvoid(string body) + { + var (root, container) = Paginate(body); + + Assert.AreEqual(container.PageTopOf(1), Find(root, "b").Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "page.html#page-break-props")] + public void PageBreakInsideAvoid_KeepsTheBoxOnOnePage() + { + var (root, container) = Paginate( + "
        " + Paragraph("p", 4, "page-break-inside:avoid")); + var p = Find(root, "p"); + + Assert.AreEqual(1, PageOf(container, p)); + Assert.AreEqual(1, LastPageOf(container, p)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#page-break-props")] + public void PageBreakInsideAvoid_KeepsABoxWithoutTextOnOnePage() + { + var (root, container) = Paginate( + "
        "); + + Assert.AreEqual(container.PageTopOf(1), Find(root, "b").Location.Y, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#page-break-props")] + public void ASplitBox_HasNoBorderWhereItIsSplit() + { + var (_, container) = PaintHarness.LayoutPaginated(PaintHarness.Wrap( + "
        "), + PageWidth, PageHeight); + var color = RColor.FromArgb(255, 1, 2, 3); + + for (var page = 0; page < 2; page++) + { + var horizontalEdges = PaintHarness.PaintPage(container, page).Log + .OfType() + .Count(l => l.Color == color && Math.Abs(l.Y1 - l.Y2) < 0.01); + + Assert.AreEqual(1, horizontalEdges, $"page {page}"); + } + } + + #endregion + + #region 13.3.2 Breaks inside elements: 'orphans', 'widows' + + [TestMethod] + [Spec("css21", "page.html#break-inside")] + public void OrphansAndWidows_DefaultToTwoAndInherit() + { + var (root, _) = Paginate("

        c

        d

        "); + + Assert.AreEqual(5, Find(root, "c").ActualOrphans); + Assert.AreEqual(4, Find(root, "c").ActualWidows); + Assert.AreEqual(2, Find(root, "d").ActualOrphans); + Assert.AreEqual(2, Find(root, "d").ActualWidows); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#break-inside")] + [DataRow("orphans:0;widows:0")] + [DataRow("orphans:-3;widows:-3")] + public void NonPositiveOrphansAndWidows_AreIgnored(string declaration) + { + var (root, _) = Paginate($"

        c

        "); + + Assert.AreEqual(5, Find(root, "c").ActualOrphans); + Assert.AreEqual(4, Find(root, "c").ActualWidows); + } + + [TestMethod] + [Spec("css21", "page.html#break-inside")] + public void Orphans_MovesTheParagraphWhenTooFewLinesFit() + { + var filler = PageHeight - 2.5 * LineHeight; + var (root, container) = Paginate($"
        " + Paragraph("p", 6, "orphans:3")); + + CollectionAssert.AreEqual(new[] { 0, 6 }, LinesPerPage(container, Find(root, "p"), 2)); + } + + [TestMethod] + [Spec("css21", "page.html#break-inside")] + public void Widows_PullsLinesToTheNextPage() + { + var filler = PageHeight - 5.5 * LineHeight; + var (root, container) = Paginate($"
        " + Paragraph("p", 6, "widows:3")); + + CollectionAssert.AreEqual(new[] { 3, 3 }, LinesPerPage(container, Find(root, "p"), 2)); + } + + #endregion + + #region 13.3.3 Allowed page breaks + + [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] + public void UnforcedBreakInTheMargin_SetsTheMarginToZero() + { + var (root, container) = Paginate( + "
        " + + "
        text
        "); + + Assert.AreEqual(container.PageTopOf(1), Find(root, "b").Location.Y, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#allowed-page-breaks")] + public void ABlockWhoseLinesAllMoveToTheNextPage_MovesWithThem() + { + var filler = PageHeight - 1.5 * LineHeight; + var (root, container) = Paginate( + $"
        " + Paragraph("p", 4, "border-top:3px solid black;orphans:3")); + + Assert.AreEqual(container.PageTopOf(1), Find(root, "p").Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] + public void Breaks_FallBetweenLineBoxes() + { + var (root, container) = Paginate("
        " + Paragraph("p", 40)); + var p = Find(root, "p"); + + foreach (var line in p.LineBoxes) + Assert.AreEqual(container.PageIndexOf(line.LineTop), container.PageIndexOf(line.LineBottom - 0.01)); + Assert.IsTrue(LastPageOf(container, p) >= 2); + } + + [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] + [DataRow("page-break-after:avoid", "")] + [DataRow("", "page-break-before:avoid")] + public void AvoidBetweenSiblings_MovesTheFirstAlongWithTheSecond(string headStyle, string nextStyle) + { + var (root, container) = Paginate( + "
        " + + $"
        Head
        " + + Paragraph("p", 8, "page-break-inside:avoid;" + nextStyle)); + + Assert.AreEqual(1, PageOf(container, Find(root, "p"))); + Assert.AreEqual(container.PageTopOf(1), Find(root, "h").Location.Y, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#allowed-page-breaks")] + public void AvoidBetweenSiblings_AlsoMovesABlockWithoutText() + { + var (root, container) = Paginate( + "
        " + + "
        " + + Paragraph("p", 8, "page-break-inside:avoid")); + + Assert.AreEqual(container.PageTopOf(1), Find(root, "h").Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] + public void PageBreakInsideAvoidOnAnAncestor_ForbidsBreaksBetweenItsChildren() + { + var (root, container) = Paginate( + "
        " + + "
        "); + var w = Find(root, "w"); + + Assert.AreEqual(container.PageTopOf(1), w.Location.Y, Delta); + Assert.AreEqual(1, LastPageOf(container, w)); + } + + [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] + public void AvoidBoxTallerThanAPage_IsStillBroken() + { + var (root, container) = Paginate("
        " + Paragraph("p", 25, "page-break-inside:avoid")); + var p = Find(root, "p"); + + Assert.AreEqual(0, PageOf(container, p)); + Assert.IsTrue(LastPageOf(container, p) >= 1); + } + + [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] + public void OrphansAndWidowsThatCannotBeSatisfied_AreDropped() + { + // Fewer than 20 lines fit on a page, so no split of 25 lines satisfies both. + var (root, container) = Paginate(Paragraph("p", 25, "orphans:20;widows:20")); + var p = Find(root, "p"); + + Assert.IsTrue(LastPageOf(container, p) >= 1); + foreach (var line in p.LineBoxes) + Assert.AreEqual(container.PageIndexOf(line.LineTop), container.PageIndexOf(line.LineBottom - 0.01)); + } + + [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] + public void ForcedBreak_RetainsTheMarginTop() + { + var (root, container) = Paginate( + "
        "); + + Assert.AreEqual(container.PageTopOf(1) + 20, Find(root, "b").Location.Y, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#allowed-page-breaks")] + public void ForcedBreak_SetsTheMarginBottomToZero() + { + var (root, container) = Paginate( + "
        "); + + Assert.IsTrue(Find(root, "b").Location.Y <= container.PageTopOf(1) + 20 + Delta); + } + + #endregion + + #region 13.3.4 Forced page breaks + + [TestMethod] + [Spec("css21", "page.html#forced")] + public void ForcedBreak_OccursEvenWhereTheContentWouldFit() + { + var (root, container) = Paginate( + "
        " + + "
        "); + + Assert.AreEqual(1, PageOf(container, Find(root, "b"))); + Assert.AreEqual(2, PageOf(container, Find(root, "c"))); + } + + #endregion + + #region 13.3.5 "Best" page breaks + + [TestMethod] + [Spec("css21", "page.html#best-page-breaks")] + public void ReplacedElement_IsNotBroken() + { + var (root, container) = Paginate( + $"

        "); + + var image = LayoutHarness.Descendants(root).SelectMany(b => b.Words).Single(w => w.IsImage); + + Assert.AreEqual(container.PageTopOf(1), image.Top, Delta); + } + + [TestMethod] + [Spec("css21", "page.html#best-page-breaks")] + [DataRow(20, new[] { 20, 0 })] + [DataRow(21, new[] { 19, 2 })] + [DataRow(22, new[] { 20, 2 })] + [DataRow(25, new[] { 20, 5 })] + public void Example_Orphans4Widows2With20LinesAvailable(int lines, int[] expected) + { + var pageHeight = 20 * LineHeight + 1; + var (root, container) = Paginate(Paragraph("p", lines, "orphans:4;widows:2"), pageHeight); + + CollectionAssert.AreEqual(expected, LinesPerPage(container, Find(root, "p"), 2)); + } + + [TestMethod] + [Spec("css21", "page.html#best-page-breaks")] + [DataRow(8, new[] { 8, 0, 0 })] + [DataRow(9, new[] { 0, 9, 0 })] + [DataRow(30, new[] { 0, 10, 20 })] + public void Example_Orphans10Widows20With8LinesAvailable(int lines, int[] expected) + { + var pageHeight = 20 * LineHeight + 1; + var filler = pageHeight - 8 * LineHeight - 0.5; + var (root, container) = Paginate( + $"
        " + Paragraph("p", lines, "orphans:10;widows:20"), pageHeight); + + CollectionAssert.AreEqual(expected, LinesPerPage(container, Find(root, "p"), 3)); + } + + #endregion +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/ResumableBlockLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/ResumableBlockLayoutIntegrationTests.cs index cfa7f6c25..b67a37de8 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/ResumableBlockLayoutIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/ResumableBlockLayoutIntegrationTests.cs @@ -12,21 +12,8 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/ResumableBlockLayoutIntegrationTests.cs: css-break-3 §5.2 -/// margin-truncation becoming a real break-before, driven through -/// 's own resumable per-fragmentainer pass loop (DriveLayoutPasses, -/// matching PeachPDF's LayoutDocument) - the one case in this port where a whole extra pass really -/// is taken, per 's own doc comment. +/// An unforced break in a margin taken as a break before the next box, in a later layout pass. /// -/// -/// Verified test by test per the port plan: portable ones map onto BlockFragmentation.ResolveBlockTop -/// (margin truncation) and CssBox.ResumeAt/PendingBreakToken (the real cross-pass token this -/// port's driver loop actually uses). Two of PeachPDF's 10 are dropped - -/// ResumedPass_RegistersEachNamedPageElementOnce (named pages are parse-only in this port - -/// HtmlContainerInt.NamedPageElements has no counterpart) - and two Theories are narrowed from -/// PeachPDF's flex/grid/table/multicol set to table only (the only one of those engines this port has - -/// see MonolithicContent.RunsAnEngineOfItsOwn's own doc comment narrowing it the same way). -/// [TestClass] [DoNotParallelize] public sealed class ResumableBlockLayoutIntegrationTests @@ -83,14 +70,13 @@ private static bool Contains(BoxFragment fragment, CssBox box) => private static List FragmentSlotsOf(HtmlContainerInt container, CssBox box) => container.FragmentTree!.Fragmentainers.Where(f => Contains(f.Root, box)).Select(f => f.SlotIndex).ToList(); - // A first block, then a margin far taller than the remaining band, then a second block - the margin - // alone pushes the second block onto a later page, which is exactly the §5.2 unforced break that is - // now taken as a real break-before via CssBox.PendingBreakToken/ResumeAt. + // The margin alone pushes the second block past the end of the page. private static string MarginTruncationDocument() => "
        first
        " + "
        second
        "; [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task MarginPushingABoxAcrossABoundary_StartsItAtTheNextPagesContentTop() { var (root, container) = await BuildAsync(MarginTruncationDocument()); @@ -103,6 +89,7 @@ public async Task MarginPushingABoxAcrossABoundary_StartsItAtTheNextPagesContent } [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task BoxBrokenBefore_ProducesNoFragmentInTheFragmentainerItLeaves() { var (root, container) = await BuildAsync(MarginTruncationDocument()); @@ -111,12 +98,9 @@ public async Task BoxBrokenBefore_ProducesNoFragmentInTheFragmentainerItLeaves() var slots = FragmentSlotsOf(container, second); - // §4.4: a break *before* a box means the box was never entered in the earlier fragmentainer, so it - // has no geometry there and therefore no fragment. Assert.IsTrue(slots.Count > 0); Assert.IsFalse(slots.Contains(0)); - // And it got there by actually resuming: the driver had to open a second fragmentainer. Assert.IsTrue(container.FragmentTree!.Fragmentainers.Count >= 2); } @@ -125,8 +109,6 @@ public async Task DocumentThatFitsWithoutBreaking_TakesASinglePass() { var (_, container) = await BuildAsync("
        first
        second
        "); - // The common case: no forced break/margin-truncation token is ever pending, so the driver's own - // pass loop runs exactly once - one real fragmentainer. Assert.AreEqual(1, container.FragmentTree!.Fragmentainers.Count); } @@ -143,8 +125,6 @@ public async Task ContentFollowingTheBreak_IsLaidOutFreshRatherThanResumed() Assert.IsNotNull(second); Assert.IsNotNull(third); - // The sibling after the break is reached only by the resumed pass, and still stacks immediately - // below its predecessor. Assert.AreEqual(second.ActualBottom, third.Location.Y, 1.0); Assert.AreEqual(container.PageIndexOf(second.Location.Y), container.PageIndexOf(third.Location.Y)); } @@ -154,9 +134,7 @@ public async Task ResumedPass_DoesNotDuplicateWordsOrRectangles() { var (root, _) = await BuildAsync(MarginTruncationDocument()); - // Re-running the prologue on a resumed pass would reset and rebuild these, so a duplicate here is - // how that mistake would show up - meaningful in this port because DriveLayoutPasses really does - // call _root.PerformLayout(g) a second time for a forced/margin-truncated break. + // The resumed pass must not rebuild words or line boxes a second time. foreach (var box in Walk(root)) { Assert.AreEqual(box.Words.Count, box.Words.Distinct().Count()); @@ -164,11 +142,7 @@ public async Task ResumedPass_DoesNotDuplicateWordsOrRectangles() } } - // These engines paginate their own content; the driver must not try to break inside them, or their - // internal bookkeeping would see a half-laid-out subtree. Narrowed from PeachPDF's - // flex/grid/table/column-count/break-inside:avoid set to table and break-inside:avoid - the only two - // that exist in this port (MonolithicContent.RunsAnEngineOfItsOwn's own doc comment does the same - // narrowing). + // Tables and avoid boxes paginate their own content, the driver must not break inside them. [TestMethod] [DataRow("display:table")] [DataRow("break-inside:avoid")] @@ -195,8 +169,7 @@ public async Task LayoutCompletes_LeavingNoResumptionRecordBehind() { var (root, _) = await BuildAsync(MarginTruncationDocument()); - // Every box finished. A record left dangling would be resumed into by the next layout of the same - // tree (the unrestricted-width double layout, the per-page-width reflow loop). + // A leftover record would be resumed into by the next layout of the same tree. foreach (var box in Walk(root)) { Assert.IsNull(box.PendingBreakToken); @@ -205,13 +178,13 @@ public async Task LayoutCompletes_LeavingNoResumptionRecordBehind() } [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task KeepWithNextRun_MovesWithTheBoxItIsChainedTo() { var (root, container) = await BuildAsync( "
        filler
        " + "

        heading

        " - // Sized so the run plus the gap above it still fits the destination band - a larger margin - // makes the avoid unsatisfiable, which §5.3 says to relax rather than honor. + // A larger margin would make the avoid unsatisfiable. + "
        body
        "); var heading = FindById(root, "heading"); @@ -222,19 +195,9 @@ public async Task KeepWithNextRun_MovesWithTheBoxItIsChainedTo() Assert.AreEqual(container.PageIndexOf(heading.EffectiveTop), container.PageIndexOf(body.Location.Y)); } - // An out-of-flow box is positioned against its containing block, not against the page the flow has - // reached - a break token recorded inside one has no link in the chain to travel up - // (CssBox.LayoutOutOfFlowChildren discards whatever a child leaves behind). Narrowed to table (the - // only "engine container" this port has) from PeachPDF's flex/grid/table set. + // An out-of-flow box doesn't leave a break token behind. [TestMethod] - [Ignore("Confirmed gap, found while calibrating this fixture: a position:absolute child of a table " - + "cell, under a real page grid, reliably loses one line of its own content from the fragment tree " - + "(5 authored lines, 4 placed) regardless of how generously the document's own measured height is " - + "padded out afterward. Left Ignored rather than root-caused further, since diagnosing table-" - + "internal absolute positioning is outside this port batch's scope (fragmentation-engine parity, " - + "not table layout) - the load-bearing part of this test, that no PendingBreakToken/" - + "RequestedBreakBeforeTop is ever left dangling on the out-of-flow box, is unaffected and still " - + "asserted below.")] + [Ignore("An absolutely positioned child of a table cell loses one of its 5 lines from the fragment tree")] public async Task TallOutOfFlowChildOfATableCell_KeepsAllOfItsContent() { var lines = string.Concat(Enumerable.Range(0, 5).Select(i => $"Line{i}
        ")); @@ -242,11 +205,8 @@ public async Task TallOutOfFlowChildOfATableCell_KeepsAllOfItsContent() + "in flow" + $"
        {lines}
        " + "
        row {i}
        " - // The table's own natural row height is tiny (one short line of "in flow" text) - without - // something after it holding the document's own measured height open, HtmlContainerInt.ActualSize - // stops short of where the absolutely-positioned sibling's own overflow content actually reaches, - // clipping the fragment tree's own word count to whatever falls within that (unrelated) bound. - + "
        tail
        "; + // Keeps the document tall enough to cover the absolute box's overflow. + +"
        tail
        "; var (root, container) = await BuildAsync(html); var abs = FindById(root, "abs"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/StraddlingLineClaimTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/StraddlingLineClaimTests.cs index 047173333..549c1b39c 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/StraddlingLineClaimTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/StraddlingLineClaimTests.cs @@ -12,29 +12,8 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/StraddlingLineClaimTests.cs: content taller than the whole page -/// band has nowhere to go (css-break-3 §2's "content too large for any fragment" case), so layout leaves it -/// exactly where it is - and every band it geometrically covers must still claim it. Maps to -/// FragmentEmitter.Overlaps (a strict rect/band overlap test, applied independently per band with no -/// special case for oversized content, so an oversized word is claimed by construction rather than through -/// any dedicated "straddling" logic). +/// Content taller than a page stays where it is, and every page it covers claims it. /// -/// -/// Only 1 of PeachPDF's 3 tests ports: -/// -/// ContentAfterAWordTallerThanTheBand_SeesATruthfulCursor is dropped: it asserts -/// HtmlContainerInt.CursorSpills stays zero, a counter belonging to PeachPDF's own per-pass document -/// cursor. Confirmed by reading Core/Fragmentation/InlineFragmentation.cs and -/// Core/Fragmentation/BlockFragmentation.cs in full plus HtmlContainerInt.cs: this port has no -/// such cursor at all (layout runs the whole document's flow in one pass; there is nothing for a "stale -/// cursor after an oversized word" bug to corrupt). -/// ARowOrLineTheEngineCouldNotFit_ContinuesOnTheNextPageInstead is dropped: all 3 -/// [InlineData] rows use display:grid/display:flex, neither of which exists in -/// HTML-Renderer - MonolithicContent.RunsAnEngineOfItsOwn's own doc comment confirms this port's -/// PaginatesItsOwnContent narrows to table/inline-table only, matching the general flex/grid -/// exclusion already established for this porting effort. -/// -/// [TestClass] [DoNotParallelize] public sealed class StraddlingLineClaimTests @@ -86,14 +65,8 @@ private static List SlotsClaiming(HtmlContainerInt container, CssRect word) .Select(f => f.SlotIndex) .ToList(); - /// - /// A word taller than the whole band - the one production mechanism left that leaves a word straddling - /// by more than a hairline: content with nowhere to fit must not be treated as breakable (moving it only - /// repeats the problem forever), so layout leaves it exactly where it naturally lands, covering more than - /// one band by construction. The word's own rectangle straddles here because its height comes from the - /// font rather than from line-height - hence an enormous font-size rather than an enormous - /// leading, which would grow the line box while leaving the word itself small enough to fit. - /// + // A word taller than the page fits nowhere, so it stays and every page it covers claims it. Its height + // comes from font-size, a tall line-height would only grow the line box. [TestMethod] public async Task AWordTallerThanTheBand_IsClaimedByEveryBandItCovers() { @@ -105,8 +78,6 @@ public async Task AWordTallerThanTheBand_IsClaimedByEveryBandItCovers() Assert.IsTrue(word.Height > container.PageBottomOf(band) - container.PageTopOf(band), $"the fixture must produce a word taller than the band, not {word.Height}"); - // Every band the word covers, from the grid's own materialized fragmentainers - "claimed by band + - // 1" alone would still pass if a taller word silently lost the bands below its second. var covered = container.FragmentTree!.Fragmentainers .Select(f => f.SlotIndex) .Where(slot => word.Bottom > container.PageTopOf(slot) && word.Top < container.PageBottomOf(slot)) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/StraddlingListMarkerTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/StraddlingListMarkerTests.cs index a156447d6..0321950f7 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/StraddlingListMarkerTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/StraddlingListMarkerTests.cs @@ -11,40 +11,15 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/StraddlingListMarkerTests.cs: an outside ::marker belongs to -/// the fragmentainer its list item BEGINS in, settled the moment the item is placed. +/// An outside marker belongs to the fragmentainer its list item begins in. /// -/// -/// -/// HTML-Renderer's marker is architecturally different from PeachPDF's independently-positioned -/// ::marker box: it is , a single field built once by -/// CssBox.CreateListItemBox at the very end of the item's own PerformLayoutImp and -/// repositioned - every time that method runs - directly against the item's own, current -/// Location/ActualPaddingTop (never against a per-pass "epilogue" separate from the item's own -/// placement). PeachPDF's bug (#444) was staleness between when the marker was positioned and which pass -/// completed the item; that specific failure mode does not exist here, since there is only ever one marker- -/// positioning statement and it always runs against the item's own truth. These tests are still ported: they -/// pin the same observable invariant (a marker belongs to the fragmentainer its item begins in, exactly once) -/// as a regression check on this port's own, structurally different mechanism. -/// -/// -/// 3 of PeachPDF's 8 tests are dropped - AnItemCrossingAColumnBoundary_KeepsItsMarkerInTheColumnItBeginsIn, -/// AListWhoseItemsCrossColumnBoundaries_ClaimsEveryWordExactlyOnce and -/// AnItemAColumnPlacedButKeptNothingOf_StillClaimsItsMarkerExactlyOnce all require a real multi-column -/// engine (column-count/column-fill:balance producing one fragmentainer per column). HTML-Renderer -/// has no such engine - out of scope for this whole porting effort, per the plan's general exclusion list. -/// -/// [TestClass] [DoNotParallelize] public sealed class StraddlingListMarkerTests { private const string ItemStyle = "margin:0;font-size:10px;line-height:20px;orphans:1;widows:1"; - /// - /// #374's claimed-exactly-once invariant, over the whole document. A marker is a thing that can be - /// claimed zero times, which is the direction a duplicate-only check would miss. - /// + /// Every word, markers included, is claimed exactly once. [TestMethod] public void AListItemStraddlingAPageBoundary_ClaimsEveryWordExactlyOnce() { @@ -64,10 +39,6 @@ public void AListItemStraddlingAPageBoundary_ClaimsEveryWordExactlyOnce() Assert.AreEqual(authored.Count, claims.Count); } - /// - /// The same statement narrowed to the markers, which is where PeachPDF's bug failed it: every item's - /// marker is claimed, and by the fragmentainer the item's own first fragment is in. - /// [TestMethod] public void AStraddlingItemsMarker_IsClaimedByTheFragmentainerItsItemBeginsIn() { @@ -90,11 +61,7 @@ public void AStraddlingItemsMarker_IsClaimedByTheFragmentainerItsItemBeginsIn() Assert.IsTrue(SlotsOf(container, straddler).Count > 1); } - /// - /// The visible symptom, asked of the paint calls themselves: a lost marker is not a mispositioned - /// bullet, it is a bullet that is never drawn on any page. Numbered so each marker is identifiable in the - /// log by its own text. - /// + /// Numbered, so each marker is identifiable by its text. [TestMethod] public void EveryMarker_IsDrawnOnExactlyOnePage() { @@ -116,10 +83,7 @@ public void EveryMarker_IsDrawnOnExactlyOnePage() } } - /// - /// The fix's shape, restated positively: the marker still sits against the item's own border box - /// (CSS 2.1 §12.5.1), for an item that breaks exactly as for one that does not. - /// + /// CSS 2.1 §12.5.1, for an item that breaks as for one that doesn't. [TestMethod] public void AMarkerSitsAgainstItsItemsBorderBox_WhetherOrNotTheItemBreaks() { @@ -141,17 +105,11 @@ public void AMarkerSitsAgainstItsItemsBorderBox_WhetherOrNotTheItemBreaks() offsets.Add(word.Top - item.Location.Y); } - // The straddling item's marker is offset from its own item exactly as every other item's is - the - // statement that it was not positioned against something else. Assert.AreEqual(1, offsets.Select(o => Math.Round(o, 3)).Distinct().Count()); Assert.IsTrue(ListItems(root).Contains(straddler)); } - /// - /// A pass that declines to place the item - css-break-3 §5.2's margin truncation concluding the - /// break falls before it - has written no position for the marker to sit against until the item is - /// actually placed on its real page; the claim still stands exactly once there. - /// + /// The item's own margin moves it to the next page. [TestMethod] public void AnItemWhoseFirstPassDeclinedToPlaceIt_StillClaimsItsMarkerExactlyOnce() { @@ -179,11 +137,7 @@ public void AnItemWhoseFirstPassDeclinedToPlaceIt_StillClaimsItsMarkerExactlyOnc // ── Fixtures/helpers ───────────────────────────────────────────────────── - /// - /// Three items, the middle one long enough to run over several pages, so exactly one of them straddles - /// - guaranteed by word count rather than hoped for from platform font metrics (this harness's - /// deterministic MockAdapter metrics make it so regardless). - /// + /// Three items, the middle one long enough to run over several pages. private static (CssBox Root, HtmlContainerInt Container) Layout(string listStyleType = "disc") { var items = string.Join("", new[] { 12, 1200, 12 }.Select((words, i) => diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/UnreachedWordClaimTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/UnreachedWordClaimTests.cs index d79822cf6..27df4f9a0 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/UnreachedWordClaimTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/UnreachedWordClaimTests.cs @@ -13,31 +13,8 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation; /// -/// Ported from PeachPDF.Tests/Integration/UnreachedWordClaimTests.cs: #374's workhorse invariant - every -/// word the document authored is claimed by exactly one fragment - checked over several shapes that each -/// reach line-building by their own route (an inline box, a float, a list item), plus the specific symptom -/// PeachPDF's own bug (#433) produced, that the first page's fragment claimed words far past what it shows. +/// Every word is claimed by exactly one fragment, for content that reaches line building by different routes. /// -/// -/// PeachPDF's bug was that an unpositioned word (still at its zero-initialized rectangle) fell inside the -/// FIRST slot's own band, so pagination claimed it there. That specific failure mode does not exist in this -/// port's architecture - HtmlContainerInt.PerformLayout runs the whole document's flow to completion, -/// positioning every word, before FragmentEmitter.Finish ever walks the tree (see -/// FragmentEmitter's own doc comment: "layout already positions every box correctly across however -/// many pages the document spans... so unlike PeachPDF's pass-based emitter, this one does not need to -/// collect per-pass output"). These tests are ported anyway as a direct regression pin of the underlying -/// invariant on this port's own (different) mechanism - FragmentEmitter.Overlaps, a strict per-band -/// rectangle overlap with no tolerance (PeachPDF's own BandMembershipToleranceTests is dropped -/// entirely from this port - documented in this batch's commit message - for why "no tolerance" does not -/// also imply a double-claim risk here: InlineFragmentation.ApplyLineBreaking's break decisions and -/// this same overlap test both derive from the same PageTopOf/PageBottomOf arithmetic via one -/// uniform per-run shift, not two independently-rounded fitting tests, so there is no separate computation -/// left for the emitter to disagree with). -/// -/// One PeachPDF theory row is dropped: column-count:2, since HTML-Renderer has no multi-column engine -/// (out of scope for this whole porting effort, per the plan's general exclusion list). -/// -/// [TestClass] [DoNotParallelize] public sealed class UnreachedWordClaimTests @@ -126,13 +103,7 @@ private static string DescribeDoubleClaims(HtmlContainerInt container) private static string Document(string template, int wordCount) => $"{template.Replace("{F}", string.Join(" ", System.Linq.Enumerable.Range(0, wordCount).Select(i => $"w{i}")))}"; - /// - /// #374's workhorse invariant, over the whole document: every word the document authored is claimed by - /// exactly one fragment. It fails one way if a fragment claims a word another one also holds, and the - /// other way if a word is dropped entirely. Asked of several shapes because what stops is the fill - /// rather than the paragraph: an inline box, a float and a list item each reach line-building by their - /// own route. - /// + // An inline box, a float and a list item each reach line building by their own route. [TestMethod] [DataRow("

        {F}

        ")] [DataRow("

        {F} bold words carried across the break {F}

        ")] @@ -157,10 +128,6 @@ public async Task AParagraphSplitAtAPageBoundary_ClaimsEveryWordExactlyOnce(stri Assert.AreEqual(authored.Count, claimed.Count); } - /// - /// The symptom PeachPDF's #433 stated concretely: the first page's own text layer holds only the words - /// that page shows. - /// [TestMethod] public async Task TheFirstPage_ClaimsOnlyTheWordsItShows() { @@ -177,17 +144,10 @@ public async Task TheFirstPage_ClaimsOnlyTheWordsItShows() Assert.IsTrue(onFirstPage.Count < authored, $"the first page claimed {onFirstPage.Count} of the document's {authored} words"); - // Stated from the page grid rather than from any internal flag, so it is an independent statement - // of the symptom: every word this page claims really does sit in this page's band. Assert.IsTrue(onFirstPage.All(w => container.PageIndexOf(w.Word.Top) == 0)); } - /// - /// A list whose items each fit on one line still needs every marker claimed - an outside marker - /// () is positioned by the item's own layout epilogue - /// (CssBox.CreateListItemBox), not by the ordinary inline flow, so this asks the claim invariant - /// of a box type the paragraph-shaped fixtures above never exercise. - /// + // Outside markers are positioned by the list item, not by the inline flow. [TestMethod] public async Task AListWhoseItemsDoNotBreak_StillClaimsEveryMarker() { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/OffsetTopLineTopSyncTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/OffsetTopLineTopSyncTest.cs index 281e11369..cd1c3edb6 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/OffsetTopLineTopSyncTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/OffsetTopLineTopSyncTest.cs @@ -10,20 +10,8 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; /// -/// Verifies a real, previously-undocumented bug found while investigating -/// : CssBox.OffsetTop kept the box's own -/// Rectangles dictionary in sync with a shift, but never the corresponding entry in the line's OWN -/// mirror dictionary (CssLineBox.Rectangles, keyed the other way around) that -/// CssLineBox.LineTop/LineBottom - and therefore CssBox.EffectiveTop for any -/// inline-only box - read from. Location.Y (updated by OffsetTop's own last statement) was -/// correct immediately after the call, while EffectiveTop silently kept reporting the pre-shift -/// position - confirmed directly by inspecting both dictionaries on a real shifted heading before the fix. -/// Exercised directly via reflection here (rather than only through whichever fragmentation mechanism -/// happens to call OffsetTop at a given filler count - EnforceKeepWithNext's run-pull and -/// InlineFragmentation's own orphans-driven push are both live callers, and only the former uses -/// OffsetTop, so a test gated only on "the heading visibly moved" can't reliably tell which path it -/// hit) since OffsetTop's own contract - keep every derived position getter consistent after a -/// shift - should hold regardless of which caller invokes it. +/// CssBox.OffsetTop has to shift the line-side rectangles too, EffectiveTop of an +/// inline-only box reads from them. /// [TestClass] [DoNotParallelize] diff --git a/Source/Test/HtmlRenderer.IntegrationTest/OrphansOnFirstRunTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/OrphansOnFirstRunTest.cs index 7be86fd85..76e4dda31 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/OrphansOnFirstRunTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/OrphansOnFirstRunTest.cs @@ -9,17 +9,6 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; -/// -/// Verifies a real, previously-undocumented gap found while auditing this port's fragmentation engine -/// against PeachPDF a second time (the first audit produced the R0-R10 plan; this is a later, separate -/// pass over what remained): InlineFragmentation.ApplyLineBreaking's orphans merge-back correction -/// only ever ran once at least one earlier break already existed (breaks.Count > 1), which can -/// never be true while still deciding a paragraph's very FIRST run - so a paragraph starting close enough -/// to a page's bottom that fewer than orphans lines fit there was left with a too-small stranded -/// first fragment, uncorrected. Confirmed by temporarily reverting the fix and re-running this exact test: -/// it reliably reproduced a 1-line first page against orphans:2 at several filler counts (13, 28, -/// 43, 58 - the same ~15-count period the page-height/line-height ratio produces). -/// [TestClass] [DoNotParallelize] public sealed class OrphansOnFirstRunTest @@ -41,11 +30,10 @@ private static HtmlContainerInt GetInternal(HtmlContainer wrapper) } [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task ParagraphStartingNearPageBottom_NeverStrandsFewerThanOrphansLines() { - // Sweep filler counts rather than hardcoding one - this is a "just barely fits" calibration - // (see this session's own established testing lesson), and the exact boundary depends on - // font-metric arithmetic other changes are expected to keep touching. + // Where the paragraph straddles depends on font metrics, so sweep instead of calibrating. for (var fillerCount = 1; fillerCount < 60; fillerCount++) { using var wrapper = new HtmlContainer(); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs index c2577a294..0930f19c8 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs @@ -18,6 +18,7 @@ namespace HtmlRenderer.IntegrationTest.Painting; public sealed class FragmentPaintIntegrationTests { [TestMethod] + [Spec("css21", "page.html#forced")] public void EachPage_PaintsOnlyItsOwnFragmentainersContent() { var (_, container) = PaintHarness.LayoutPaginated(PaintHarness.Wrap( @@ -53,8 +54,7 @@ public void PaintedText_LandsAtItsOwnFragmentsCoordinates() Assert.IsTrue(recording.DrawStringCalls.Count > 0); - // Every drawn glyph run sits exactly where its own fragment says, in page-local coordinates - - // no page offset is applied at paint time any more. + // Fragment coordinates are page-local, paint adds no page offset. foreach (var call in recording.DrawStringCalls) { Assert.IsTrue(wordRects.Any(r => Math.Abs(r.X - call.Point.X) < 0.001)); @@ -78,8 +78,7 @@ public void BoxSpanningTwoPages_PaintsItsBackgroundOnBoth() { var recording = PaintHarness.PaintPage(container, page); - // Sliced, not cloned: each fragment paints the whole box's background rectangle and the page - // clip does the cutting (box-decoration-break: slice, the initial value). + // box-decoration-break: slice, each fragment paints the whole background and the page clip cuts it. Assert.IsTrue(recording.Log.OfType() .Any(r => r.Color == RColorOf(10, 20, 30))); } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/GhostTextOnPreviousPageIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/GhostTextOnPreviousPageIntegrationTests.cs index 14c74f096..3ae275b87 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/GhostTextOnPreviousPageIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/GhostTextOnPreviousPageIntegrationTests.cs @@ -5,44 +5,8 @@ namespace HtmlRenderer.IntegrationTest.Painting; /// -/// Ported from PeachPDF.Tests/Integration/GhostTextOnPreviousPageIntegrationTests.cs (issue #113): a box -/// relocated to the next page's content top (forced breaks, break-inside:avoid) must not also leave a -/// clipped-but-still-logged duplicate behind on the page it left, when it lands flush against exactly that -/// page's own boundary. +/// A box moved flush to the next page's top paints nothing on the page it left. /// -/// -/// PeachPDF's underlying bug was a paint-time clip-intersection check (RRect.Intersect treating two -/// rects that merely touch at an edge as non-empty) in a live-tree paint walk that re-painted the WHOLE -/// document, translated, once per page and relied on that check alone to cull content that belonged to a -/// different page. -/// -/// That specific mechanism does not exist in this port. Paint here is driven from the immutable -/// (Core/Paint/FragmentPainter.cs), -/// which is built once by FragmentEmitter.Finish - and a box only gets a -/// / -/// on a given page's band at all if FragmentEmitter.HasContentInBand finds its geometry actually -/// overlapping that band (a strict rect.Top < band.Bottom && rect.Bottom > band.Top test - -/// exactly touching a boundary, as a relocated box does by construction, does not overlap the band it left). -/// So a box relocated flush to the very next page's top structurally has nothing built for the previous -/// page's fragment to paint in the first place - there is no separate paint-time clip check left to get -/// wrong. Ported anyway as a direct regression pin of the same observable, user-facing invariant PeachPDF's -/// fix targets, using the real multi-page harness plus -/// (which exercises the same production per-page paint entry point, -/// HtmlContainerInt.PerformPaint(RGraphics, FragmentainerFragment), that PdfGenerator uses) -/// rather than because the exact defect was expected to reproduce. -/// -/// -/// PeachPDF's sibling PageMarginPixelsPerPointIntegrationTests class (3 tests, same source file) is -/// dropped entirely rather than ported: it tests an @page {{ margin: ... }} rule round-tripping into -/// HtmlContainer.MarginTop/PixelsPerPoint scaling. HTML-Renderer has no such cascade at all - -/// confirmed by grepping the whole Core/ tree for PixelsPerPoint (no matches) and for any -/// @page-margin consumer feeding HtmlContainerInt.MarginTop (none found; -/// MarginTop/MarginBottom/MarginLeft/MarginRight are set only by the hosting -/// application, e.g. PdfGenerator/PdfSharpAdapter callers, never derived from parsed CSS) - this -/// matches the precedent already established for other @page-adjacent features in this port (e.g. -/// @page size is a confirmed parse-only stub per the porting plan's own exclusion list). -/// -/// [TestClass] [DoNotParallelize] public sealed class GhostTextOnPreviousPageIntegrationTests @@ -50,11 +14,9 @@ public sealed class GhostTextOnPreviousPageIntegrationTests private const double PageHeight = 400.0; [TestMethod] + [Spec("css21", "page.html#forced")] public void ForcedBreak_RelocatedHeading_DoesNotPaintOnPreviousPage() { - // .filler pushes the flow close to the page-1 boundary; the forced break on #second lands it flush - // at exactly PageHeight (zero margins keep the landing position an exact, deterministic multiple of - // PageHeight - the precise scenario that triggered PeachPDF's "merely touching the clip edge" bug). var html = PaintHarness.Wrap( "
        " + "

        RelocatedHeadingMarker

        "); @@ -64,8 +26,7 @@ public void ForcedBreak_RelocatedHeading_DoesNotPaintOnPreviousPage() var heading = PaintHarness.FindById(root, "second"); Assert.IsNotNull(heading); - // Confirm the test is actually exercising the boundary-touching case: the heading must land exactly - // at the page-1 top, not merely somewhere on page 1. + // The heading touches the page boundary exactly. Assert.AreEqual(PageHeight, heading!.Location.Y, 0.01); var page0 = PaintHarness.PaintPage(container, 0); @@ -76,14 +37,10 @@ public void ForcedBreak_RelocatedHeading_DoesNotPaintOnPreviousPage() } [TestMethod] + [Spec("css21", "page.html#page-break-props")] public void BreakInsideAvoid_RelocatedBox_DoesNotPaintOnPreviousPage() { - // .filler leaves only 20px of page 0 remaining (400 - 380) - not enough room for even one of - // #avoid's three 12px lines, so break-inside:avoid has to relocate the whole box rather than let it - // start there. .filler itself stays short of PageHeight so it still fits on page 0, which is what - // makes the relocated box land flush at exactly PageHeight (zero margins keep that an exact, - // deterministic multiple - the precise scenario that triggers the "merely touching the clip edge" - // bug PeachPDF documents). + // 20px are left on page 0, too little for the three 12px lines. var html = PaintHarness.Wrap( "
        " + "
        " diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageOriginPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageOriginPaintIntegrationTests.cs index 9a96461e9..c150b0a26 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageOriginPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageOriginPaintIntegrationTests.cs @@ -15,6 +15,7 @@ namespace HtmlRenderer.IntegrationTest.Painting; public sealed class PageOriginPaintIntegrationTests { [TestMethod] + [Spec("css21", "page.html#page-box")] public void EveryPage_KeepsTheTopMargin() { const double margin = 20; diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageD2FragmentBucketingSmokeTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageD2FragmentBucketingSmokeTest.cs index 2337554ee..b1a94bde9 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/StageD2FragmentBucketingSmokeTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageD2FragmentBucketingSmokeTest.cs @@ -8,9 +8,7 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; -// This assembly parallelizes at the method level (MSTestSettings.cs); HtmlContainerInt's underlying -// adapter singletons (font/brush caches, etc.) aren't safe against that for tests that drive full -// layout passes directly - HtmlRenderingRegressionTests already opts out for the same reason. +// The adapter singletons (font and brush caches) aren't safe for parallel full layout passes. [TestClass] [DoNotParallelize] public sealed class StageD2FragmentBucketingSmokeTest @@ -60,6 +58,7 @@ public async Task MultiPageDocument_ProducesOneFragmentainerPerPage_WithSplitBox } [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task HugeMargin_SkipsBlankFragmentainers() { using var wrapper = new HtmlContainer(); @@ -77,8 +76,7 @@ public async Task HugeMargin_SkipsBlankFragmentainers() var tree = container.FragmentTree; Assert.IsNotNull(tree); - // The margin is truncated (D2), so content should land on an early page, not one 3000px down - - // this also implicitly confirms no run of ~4 blank fragmentainers was materialized for the gap. + // The truncated margin leaves no run of blank fragmentainers. Assert.IsTrue(tree.Fragmentainers.Count <= 2, $"expected at most 2 fragmentainers, got {tree.Fragmentainers.Count}"); } } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageD3PrecisionTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageD3PrecisionTest.cs index 9d1584af0..0303c139e 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/StageD3PrecisionTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageD3PrecisionTest.cs @@ -9,8 +9,6 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; -// See StageD2FragmentBucketingSmokeTest for why this opts out of this assembly's default -// method-level parallelization (MSTestSettings.cs). [TestClass] [DoNotParallelize] public sealed class StageD3PrecisionTest @@ -35,6 +33,7 @@ private static HtmlContainerInt Layout(string html, int pageWidth, int pageHeigh } [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public void NoLine_EverStraddlesAPageBoundary() { var sentence = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty "; @@ -65,6 +64,7 @@ public void NoLine_EverStraddlesAPageBoundary() } [TestMethod] + [Spec("css21", "page.html#break-inside")] public void Widows_NeverLeavesFewerThanMinimumLinesAtTopOfPage() { var filler = string.Concat(Enumerable.Repeat("

        filler line of text

        ", 53)); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR1DriverLoopTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR1DriverLoopTest.cs index f74201377..0cdde8f13 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/StageR1DriverLoopTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR1DriverLoopTest.cs @@ -10,12 +10,7 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; /// -/// Verifies the R1 stage of the fragmentation-engine-parity plan: forced page breaks now go through a -/// real resumable pass loop ('s per-fragmentainer driver, CssBox's -/// ResumeAt/PendingBreakToken child-loop bubbling) instead of a single-pass local -/// correction. These tests exercise the loop across multiple passes specifically, which the existing -/// single-forced-break tests (StageD2VerificationTest) don't - a bug in child-index bookkeeping -/// across repeated resumes wouldn't necessarily show up with only one break in the document. +/// Several forced breaks in one document, each resuming the layout pass loop. /// [TestClass] [DoNotParallelize] @@ -28,6 +23,7 @@ private static HtmlContainerInt GetInternal(HtmlContainer wrapper) } [TestMethod] + [Spec("css21", "page.html#forced")] public async Task TwoForcedBreaksInSequence_EachStartsANewPageWithCorrectContent() { using var wrapper = new HtmlContainer(); @@ -53,17 +49,13 @@ await wrapper.SetHtml( Assert.IsNotNull(tree); Assert.AreEqual(3, tree.Fragmentainers.Count, "each forced break should land its own div on its own page"); - // Each page's fragmentainer must be flush at its own band top (no leftover offset carried - // across the second break from the first, which an off-by-one in ResumeChildIndex would produce). for (var slot = 0; slot < 3; slot++) { var fragmentainer = tree.Fragmentainers[slot]; Assert.AreEqual(slot, fragmentainer.SlotIndex); } - // The three divs resolve to three distinct, correctly-ordered per-page fragments - proves the - // second break resumed the child loop at the right index rather than re-processing or skipping - // a sibling. + // No sibling is skipped or laid out twice when the second break resumes the child loop. StringAssert.Contains(AllText(tree.Fragmentainers[0].Root), "First"); StringAssert.Contains(AllText(tree.Fragmentainers[1].Root), "Second"); StringAssert.Contains(AllText(tree.Fragmentainers[2].Root), "Third"); @@ -85,6 +77,7 @@ static void Collect(BoxFragment f, List into) } [TestMethod] + [Spec("css21", "page.html#forced")] public async Task ManyForcedBreaksInSequence_TerminatesPromptlyWithOnePagePerBreak() { using var wrapper = new HtmlContainer(); @@ -101,9 +94,7 @@ public async Task ManyForcedBreaksInSequence_TerminatesPromptlyWithOnePagePerBre using var g = Graphics.FromImage(bitmap); wrapper.PerformLayout(g); - // 50 divs, every one but the first forcing its own break: 50 pages. A hang or a runaway pass - // count would fail this test by timeout rather than by assertion - that's the point of covering - // the pass loop's backstop with a large-but-realistic case rather than only single/double breaks. + // The first div's break isn't taken, nothing precedes it. A runaway pass loop fails by timeout. var tree = container.FragmentTree; Assert.IsNotNull(tree); Assert.AreEqual(50, tree.Fragmentainers.Count); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR3RelocationTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR3RelocationTest.cs index 22392cf61..f06bab3e2 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/StageR3RelocationTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR3RelocationTest.cs @@ -6,12 +6,6 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; -/// -/// Verifies the R3 stage of the fragmentation-engine-parity plan: break-inside:avoid/monolithic -/// relocation now relays the child out fresh at its target position (CssBox.ResumeAt + a second -/// PerformLayout call within the same pass) instead of shifting already-finished geometry with -/// OffsetTop. -/// [TestClass] [DoNotParallelize] public sealed class StageR3RelocationTest @@ -23,12 +17,11 @@ private static HtmlContainerInt GetInternal(HtmlContainer wrapper) } [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task MonolithicContentTallerThanOnePage_IsLeftInPlace_NotMoved() { using var wrapper = new HtmlContainer(); - // A scroll container (overflow:hidden, MonolithicContent.IsScrollContainer) taller than the - // 700px page - RelocateIfNeeded's "fits on no single page" guard must leave it straddling the - // boundary in place rather than moving it (nowhere to move it TO would help) or looping. + // A scroll container is monolithic, this one is taller than the 700px page. await wrapper.SetHtml( """ @@ -48,9 +41,7 @@ await wrapper.SetHtml( var tree = container.FragmentTree; Assert.IsNotNull(tree); - // Straddles the one boundary it naturally crosses (250 + 900 = 1150, past the 700px mark) and - // stops there - not moved to a later page (which would still not fit it whole) and not spun - // into extra pages by a mistaken relocation attempt. + // Straddles the boundary it naturally crosses instead of moving to a page it wouldn't fit either. Assert.AreEqual(2, tree.Fragmentainers.Count); } } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR4KeepWithNextTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR4KeepWithNextTest.cs index f516db0a0..6ac5b5adb 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/StageR4KeepWithNextTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR4KeepWithNextTest.cs @@ -9,20 +9,6 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; -/// -/// Verifies the R4 stage of the fragmentation-engine-parity plan: keep-with-next -/// (BlockFragmentation.EnforceKeepWithNext) now fires for the ordinary case, not just as a side -/// effect of the following box also being break-inside:avoid/monolithic. -/// -/// -/// The pre-existing KeepWithNext_HeadingStaysWithFollowingParagraph PDF test (still passing, still -/// kept) only ever asserted a page COUNT of 2 - which is also exactly what you get if the heading is left -/// stranded alone at the bottom of page 1 while the paragraph moves to page 2 by itself (2 pages either -/// way). It never actually proved the heading and paragraph land on the SAME page. This test does, using -/// the fragment tree directly: filler content is calibrated so the heading provably fits alone on page 0 -/// in isolation (confirmed by a companion assertion with no trailing paragraph), then, with the paragraph -/// present, both must appear in the SAME fragmentainer. -/// [TestClass] [DoNotParallelize] public sealed class StageR4KeepWithNextTest @@ -68,18 +54,11 @@ static void Collect(BoxFragment f, List into) private static string Filler(int count) => string.Concat(Enumerable.Repeat("

        filler line of text

        ", count)); - // WinForms reports media type "screen", not "print" - the UA stylesheet's h1-h6 { break-after: avoid } - // rule lives under @media print (see PdfSharpAdapter vs RAdapter.DefaultMediaType) and never applies - // to this IntegrationTest project's WinForms-based HtmlContainer. Set it explicitly rather than - // relying on the UA default. + // The UA's h1-h6 { break-after: avoid } is under @media print, WinForms renders as screen. private const string HeadingStyle = "margin:0; break-after: avoid;"; /// - /// Finds, by direct search rather than a hardcoded magic number, a filler count where the heading - /// fits alone on page 0 but heading+paragraph together do not - the exact boundary this stage's real - /// test needs. Hardcoding the count made this test fragile to unrelated, still-correct changes - /// elsewhere in the pagination arithmetic (this happened once already, when InlineFragmentation's - /// algorithm was rewritten for an unrelated widows bug and shifted the boundary by one filler). + /// A filler count where the heading fits alone on page 0, but not together with its paragraph. /// private static async Task FindBoundaryFillerCountAsync() { @@ -95,7 +74,7 @@ private static async Task FindBoundaryFillerCountAsync() var bothFitOnPageZero = withParagraph.Fragmentainers.Count >= 1 && StringContains(AllText(withParagraph.Fragmentainers[0].Root), "Paragraph right after the heading."); if (!bothFitOnPageZero) - return count; // heading alone fits; heading+paragraph together doesn't - the boundary. + return count; } Assert.Fail("could not find a filler count where the heading fits alone but not with its paragraph"); @@ -105,6 +84,7 @@ private static async Task FindBoundaryFillerCountAsync() private static bool StringContains(string haystack, string needle) => haystack.Contains(needle); [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task HeadingAndParagraph_LandOnTheSamePage_NotStranded() { var count = await FindBoundaryFillerCountAsync(); @@ -112,8 +92,6 @@ public async Task HeadingAndParagraph_LandOnTheSamePage_NotStranded() var tree = await LayoutAsync( $"{Filler(count)}

        Section heading

        Paragraph right after the heading.

        "); - // Page 0's own text must NOT contain the heading - it should have been pulled forward to join - // the paragraph, not left stranded where the boundary search shows it would otherwise fit alone. var pageZeroText = AllText(tree.Fragmentainers[0].Root); StringAssert.DoesNotMatch(pageZeroText, new System.Text.RegularExpressions.Regex("Section heading")); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR5WidowsMultiPageTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR5WidowsMultiPageTest.cs index 5eb2dfa50..f15399311 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/StageR5WidowsMultiPageTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR5WidowsMultiPageTest.cs @@ -9,24 +9,6 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; -/// -/// Verifies a real bug found while investigating the fragmentation-engine-parity plan's R5/R6 stages -/// (inline resumption, widows as a driver-level rewind): the investigation concluded neither stage needed -/// new resumption machinery after all - CreateLineBoxes already computes an entire paragraph's -/// lines in one unbounded, side-effect-free call, so there is never a point where a later pass reveals -/// information the same-shot correction didn't already have. What it DID find was a real bug in that -/// same-shot correction's own cascading logic. -/// -/// -/// The old single-pass version of InlineFragmentation.ApplyLineBreaking shifted lines -/// incrementally as it walked them, driven by "did this line straddle a page boundary". Once a shift -/// happened to land a run of lines in perfect page-boundary alignment (very common with uniform line -/// heights), no line ever straddled again for the rest of the paragraph - so widows was silently never -/// re-checked for any later page transition. A paragraph long enough to span dozens of pages could end -/// with a final page far short of its `widows` minimum and nothing would catch it. The rewritten version -/// computes every break point up front from each line's own natural (never-shifted) position, which has -/// no such blind spot, and applies the decided breaks in a single separate pass. -/// [TestClass] [DoNotParallelize] public sealed class StageR5WidowsMultiPageTest @@ -55,16 +37,11 @@ private static void CollectWordTops(BoxFragment f, List into) } [TestMethod] + [Spec("css21", "page.html#break-inside")] public async Task LongParagraph_PullsBackAcrossMultipleEarlierPages_WhenTheFirstDoesNotHaveRoom() { using var wrapper = new HtmlContainer(); - // A deliberately non-round page height relative to the line height (100 vs a 24-tall line: 4 - // lines is 96, leaving 4 units of slack; a straight single-page-back merge for widows:3 needs to - // reach past that slack into the page before it too) - this is exactly the shape the old - // single-pass algorithm's "stops checking after perfect alignment" blind spot could miss, and - // the shape the two-phase rewrite's break-list (rather than incremental-shift) design exists to - // handle: cascading the merge across more than one earlier break by removing list entries, - // without needing to undo a shift already applied to specific lines. + // Page height 100 against a 24px line leaves 4px of slack per page, every page break lines up. var sentence = "Alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo sierra tango uniform victor whiskey "; var paragraph = string.Concat(Enumerable.Repeat(sentence, 40)); await wrapper.SetHtml($"

        {paragraph}

        "); @@ -81,21 +58,19 @@ public async Task LongParagraph_PullsBackAcrossMultipleEarlierPages_WhenTheFirst var tree = container.FragmentTree; Assert.IsGreaterThan(3, tree.Fragmentainers.Count, "test content should span several pages for this to be meaningful"); - var lastPageWords = CountWords(tree.Fragmentainers[tree.Fragmentainers.Count - 1].Root); - Assert.IsGreaterThanOrEqualTo(3, lastPageWords, - $"the final page has only {lastPageWords} line(s), fewer than widows:3 - the paragraph's own last line was left stranded"); + var lastPageTops = new List(); + CollectWordTops(tree.Fragmentainers[tree.Fragmentainers.Count - 1].Root, lastPageTops); + var lastPageLines = lastPageTops.Distinct().Count(); + Assert.IsGreaterThanOrEqualTo(3, lastPageLines, + $"the final page has only {lastPageLines} line(s), fewer than widows:3"); } [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task LongParagraph_DeclinesGracefully_WhenSatisfyingWidowsWouldOverflowAPage() { using var wrapper = new HtmlContainer(); - // Deliberately degenerate: a single repeated word gives every line identical height, so pages - // pack to exactly the same capacity throughout - satisfying widows:3 on the trailing page would - // require merging in lines from an already-full preceding page, producing a run taller than any - // page can hold. This must not overflow, crash, or loop - it must simply leave the shorter final - // page as the best achievable result (css-break-3 4.3's "some constraints can't always be - // satisfied" relaxation philosophy). + // Every page is full, so widows:3 on the last page can only be met by overflowing the one before. var words = string.Concat(Enumerable.Repeat("word ", 300)); await wrapper.SetHtml($"

        {words}

        "); @@ -111,10 +86,7 @@ public async Task LongParagraph_DeclinesGracefully_WhenSatisfyingWidowsWouldOver var tree = container.FragmentTree; Assert.IsGreaterThan(3, tree.Fragmentainers.Count); - // No page's own words may span more vertical room than the page itself has - the real - // regression this guards against is a "fix" that satisfies widows by producing a run that - // silently overflows its fragmentainer (word rects are already fragmentainer-local, so a span - // near or under one page height is the correct expectation regardless of scroll/margin setup). + // Word rects are fragmentainer-local. foreach (var fragmentainer in tree.Fragmentainers) { var tops = new List(); @@ -127,8 +99,6 @@ public async Task LongParagraph_DeclinesGracefully_WhenSatisfyingWidowsWouldOver $"fragmentainer at slot {fragmentainer.SlotIndex} holds words spanning more than one page's height"); } - // The total word count must be conserved - nothing dropped, nothing duplicated, across however - // many pages the graceful-decline path produced. var total = tree.Fragmentainers.Sum(f => CountWords(f.Root)); Assert.AreEqual(300, total); } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR9KeepWithNextAcrossForcedBreakTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR9KeepWithNextAcrossForcedBreakTest.cs index 081e61487..190bbbc31 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/StageR9KeepWithNextAcrossForcedBreakTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR9KeepWithNextAcrossForcedBreakTest.cs @@ -10,26 +10,8 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; /// -/// Verifies the fragmentation-engine-parity plan's R9 investigation finding: PeachPDF's "keep-with-next -/// run-pull rewind across an already-frozen fragmentainer" does not have a counterpart problem in this -/// port's architecture, so no new rewind machinery is needed - the existing same-pass -/// (R4) -/// already covers it. +/// Keep-with-next still applies in a layout pass that resumed after a forced break. /// -/// -/// PeachPDF needs a real cross-pass rewind because ordinary overflow-driven pagination is itself a real -/// pass boundary there - a keep-with-next violation discovered while laying out page N+1 may need to -/// reach back into page N's content, which was already committed via that pass's own EmitPass. -/// In this port, only a FORCED break (break-before/after: page) ever creates a real pass boundary -/// in HtmlContainerInt.DriveLayoutPasses - ordinary overflow and break-inside:avoid are both -/// same-pass local corrections (R2/R3), and FragmentEmitter runs once, only after every pass has -/// settled, so nothing is ever truly "frozen" mid-layout the way PeachPDF's per-pass emit makes it. -/// A keep-with-next run is therefore always laid out - and checked by EnforceKeepWithNext - within -/// the SAME pass as the sibling it's chained to, even immediately after resuming from an unrelated forced -/// break earlier in the document, as this test confirms directly against the fragment tree. And a run -/// could never need to be pulled across a forced break itself either way: the forced break is the -/// intentional separator keep-with-next exists to avoid accidentally recreating, not an obstacle to undo. -/// [TestClass] [DoNotParallelize] public sealed class StageR9KeepWithNextAcrossForcedBreakTest @@ -56,6 +38,7 @@ void Collect(BoxFragment x) } [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task KeepWithNextPairRightAfterAForcedBreak_StaysTogether_OnTheResumedPage() { using var wrapper = new HtmlContainer(); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR9OversizedKeepWithNextRunTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR9OversizedKeepWithNextRunTest.cs index 7d44e2376..0ccd94209 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/StageR9OversizedKeepWithNextRunTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR9OversizedKeepWithNextRunTest.cs @@ -10,16 +10,7 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; /// -/// Verifies a real bug found while investigating the fragmentation-engine-parity plan's R9 stage: an -/// earlier version of -/// always pulled the WHOLE preceding break-after:avoid-chained run to a child's page without -/// checking whether the run then fit there. For a long chain (taller than one page combined), this did -/// not just mis-place content - it corrupted layout outright: each subsequent chained sibling's own -/// keep-with-next check re-fired against the now artificially-stretched-out run, compounding -/// CssBox.OffsetTop shifts on the same earlier boxes without bound (observed reaching a box -/// position of roughly 8.6e11 for a 60-member chain on a short page, before the fix). The fix implements -/// css-break-3 §4.3's actual staged relaxation - trim the run from its front until what remains fits, or -/// drop it entirely rather than pulling something that can't fit. +/// A break-after:avoid chain taller than a page can't be kept together, the avoids get dropped. /// [TestClass] [DoNotParallelize] @@ -41,7 +32,17 @@ private static IEnumerable AllWords(BoxFragment f) yield return w; } + private static void CollectWordTops(BoxFragment f, List into) + { + foreach (var w in f.Words) + if (!w.Word.IsLineBreak) + into.Add(w.Rect.Top); + foreach (var c in f.Children) + CollectWordTops(c, into); + } + [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task LongAvoidChainTallerThanOnePage_NeverCorruptsGeometry_AndLosesNothing() { using var wrapper = new HtmlContainer(); @@ -68,12 +69,20 @@ await wrapper.SetHtml( var tree = container.FragmentTree; Assert.IsNotNull(tree); - // The real bug produced an ActualSize.Height in the hundreds of billions and zero fragmentainers - // (FragmentEmitter could not bucket geometry that far out of range) - a sane document is nowhere - // close to that regardless of exact page count, which depends on font metrics. + // The exact page count depends on font metrics. Assert.IsLessThan(100_000.0, wrapper.ActualSize.Height, "document height must stay sane - not blow up from compounding OffsetTop shifts"); Assert.IsGreaterThan(0, tree.Fragmentainers.Count); + // CSS 2.1 §13.3.3: the avoids are dropped rather than letting the run overflow a page. + foreach (var fragmentainer in tree.Fragmentainers) + { + var tops = new List(); + CollectWordTops(fragmentainer.Root, tops); + if (tops.Count > 0) + Assert.IsLessThanOrEqualTo(container.PageSize.Height, tops.Max() - tops.Min(), + $"fragmentainer at slot {fragmentainer.SlotIndex} holds words spanning more than one page's height"); + } + var allWords = tree.Fragmentainers.SelectMany(f => AllWords(f.Root)).ToList(); var expected = Enumerable.Range(0, 60).Select(i => $"RunMember{i}").Append("FinalParagraph").Append("TopMarker"); foreach (var e in expected) diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/MultiPageTextVisibilityTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/MultiPageTextVisibilityTest.cs index a6daa971b..d596afdbf 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/MultiPageTextVisibilityTest.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/MultiPageTextVisibilityTest.cs @@ -5,21 +5,9 @@ namespace HtmlRenderer.PdfSharp.Test; /// -/// Regression coverage for a real bug found while giving FragmentPainter a page-origin translate -/// (so HtmlContainerInt.PerformPaint(RGraphics)'s multi-fragmentainer fallback could stop -/// depending on CssBox.Paint): FragmentPainter.PaintFragmentContent painted line -/// backgrounds/borders from the fragment tree's already page-local rects, but painted the actual text via -/// CssBox.PaintWords, which reads CssRect.Rectangle straight off the live box tree - still -/// absolute document-Y - offset only by ScrollOffset (always zero for PDF generation). Every page -/// after the first got a content stream with zero text-draw operators, since a fresh per-page -/// XGraphics's origin is that page's own band top, not the document's. Existing tests only ever -/// asserted page *count*, never that a page's content stream actually contains text - this would have -/// stayed silently broken indefinitely otherwise. +/// Every PDF page draws its own text, not only the first one. /// -// Concurrent full-layout-pass tests race on shared adapter singleton state (same MSTest ClassLevel -// parallelism issue documented for HtmlRenderer.IntegrationTest) - reproduced here: this test passes -// reliably alone but intermittently reports a missing Tj on page 0 when run alongside the rest of the -// suite. +// Parallel full layout passes race on the adapter singletons. [TestClass] [DoNotParallelize] public sealed class MultiPageTextVisibilityTest @@ -30,8 +18,6 @@ public async Task EveryPage_HasRealTextDrawingOperators() var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; config.SetMargins(20); - // Sized well past what fits on one A4 page, so every page has genuine paragraph content, not - // just a trailing sliver. var sentence = "This is a moderately long sentence used to build a paragraph that will wrap across many lines and, eventually, across more than one page. "; var html = $"

        {string.Concat(Enumerable.Repeat(sentence, 200))}

        "; diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/PageRuleTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/PageRuleTests.cs new file mode 100644 index 000000000..430e71238 --- /dev/null +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/PageRuleTests.cs @@ -0,0 +1,62 @@ +using PdfSharp; +using TheArtOfDev.HtmlRenderer.PdfSharp; + +namespace HtmlRenderer.PdfSharp.Test; + +/// +/// An A4 page is 842pt tall, the 400pt block fits on one page unless the margins leave less than 400pt. +/// +// Parallel full layout passes race on the adapter singletons. +[TestClass] +[DoNotParallelize] +public sealed class PageRuleTests +{ + private const string Block = "
        "; + + private static async Task PageCount(string html, int marginTop = 0, int marginBottom = 0) + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4, MarginTop = marginTop, MarginBottom = marginBottom }; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + return document.Pages.Count; + } + + [TestMethod] + [Spec("css21", "page.html#page-box")] + public async Task PageArea_IsThePageMinusTheConfiguredMargins() + { + Assert.AreEqual(1, await PageCount($"{Block}")); + Assert.AreEqual(2, await PageCount($"{Block}", 250, 250)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#page-margins")] + [DataRow("@page { margin: 250pt 0 }")] + [DataRow("@page { margin-top: 30%; margin-bottom: 30% }")] + public async Task PageMargins_ShrinkThePageArea(string rule) + { + Assert.AreEqual(2, await PageCount($"{Block}")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#page-selectors")] + [DataRow("@page :first { margin-top: 500pt }")] + [DataRow("@page :right { margin-top: 500pt }")] + public async Task PageSelector_AppliesToTheFirstPage(string rule) + { + Assert.AreEqual(2, await PageCount($"{Block}")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "page.html#page-cascade")] + public async Task PageContext_FollowsTheCascade() + { + const string rules = "@page :first { margin-top: 500pt } @page { margin-top: 0 }"; + + Assert.AreEqual(2, await PageCount($"{Block}")); + } +} diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs index 544e5bbd2..43dc709d3 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs @@ -7,6 +7,7 @@ namespace HtmlRenderer.PdfSharp.Test; public sealed class StageD2VerificationTest { [TestMethod] + [Spec("css21", "page.html#forced")] public async Task ForcedBreakBefore_Page_StartsNewPage() { var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; @@ -38,6 +39,7 @@ public async Task NoForcedBreak_SmallContent_StaysOnOnePage() } [TestMethod] + [Spec("css21", "page.html#forced")] public async Task LegacyPageBreakBefore_Always_StartsNewPage() { var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; @@ -61,15 +63,8 @@ public async Task BreakInsideAvoid_KeepsBlockTogether_OnOnePage() var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; config.SetMargins(20); - // Enough filler to span several pages regardless of exactly which font ends up resolving on - // whatever machine runs this (a small, precisely-calibrated filler count is fragile to font - // substitution - CI's non-Windows runners fall back to an embedded font with different metrics - // than Windows' real "Times New Roman", so a boundary tuned for one silently misses the other; - // see this project's own established testing lesson about hardcoded "just barely" magic - // numbers). Precise per-page content verification lives in HtmlRenderer.IntegrationTest's - // ContainerLeftBehindTest/StageR3RelocationTest, which read the fragment tree directly instead - // of inferring behavior from a PDF's total page count - this is only a regression-style guard - // that the avoid-block relocation doesn't crash or misbehave outright. + // Generous filler, fonts differ between platforms. This only guards that the relocation + // doesn't fail outright, the integration tests check where boxes land. var filler = string.Concat(Enumerable.Repeat("

        filler line of text

        ", 150)); var html = $""" @@ -102,13 +97,12 @@ public async Task ManyParagraphs_FlowAcrossMultiplePages() } [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task HugeMargin_DoesNotProduceRunawayBlankPages() { var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; config.SetMargins(20); - // A margin far taller than a single page - margin truncation (css-break-3 5.2) must - // discard it rather than paginating through blank vertical space. const string html = "
        content
        "; using var document = await PdfGenerator.GeneratePdf(html, config); @@ -122,11 +116,7 @@ public async Task KeepWithNext_HeadingStaysWithFollowingParagraph() var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; config.SetMargins(20); - // h4 has UA break-after: avoid. Generous filler (see BreakInsideAvoid_KeepsBlockTogether_OnOnePage's - // own remark on why a precisely-calibrated boundary is fragile to font substitution across CI - // platforms) - this is a regression-style guard that the pair doesn't blow up across an - // unreasonable number of pages, not a precise "did they move together" check (that lives at the - // fragment-tree level, in HtmlRenderer.IntegrationTest's ContainerLeftBehindKeepWithNextTest). + // h4 has UA break-after: avoid. Only a smoke test, StageR4KeepWithNextTest checks the placement. var filler = string.Concat(Enumerable.Repeat("

        filler line of text

        ", 150)); var html = $""" diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs index e9a808854..61e6bebbc 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs @@ -12,10 +12,7 @@ public async Task LongParagraph_SpansPagesWithoutError() var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; config.SetMargins(20); - // Generous repeat count - a precisely-calibrated boundary is fragile to font substitution - // across CI platforms (non-Windows runners fall back to an embedded font with different metrics - // than Windows' real "Times New Roman"); this only needs to comfortably exceed one page - // regardless of exactly which font resolves. + // Generous repeat count, fonts differ between platforms. var sentence = "This is a moderately long sentence used to build a paragraph that will wrap across many lines and, eventually, across more than one page. "; var html = $"

        {string.Concat(Enumerable.Repeat(sentence, 100))}

        "; @@ -30,11 +27,8 @@ public async Task Widows_PullsMinimumLinesToNextPage() var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; config.SetMargins(20); - // Generous filler, not precisely calibrated to a specific boundary - see - // StageD2VerificationTest.BreakInsideAvoid_KeepsBlockTogether_OnOnePage's remark on why a tight - // "just barely" filler count is fragile to font substitution across CI platforms. Precise - // per-page widows verification lives in HtmlRenderer.IntegrationTest's StageR5WidowsMultiPageTest, - // which reads the fragment tree directly. + // Generous filler, fonts differ between platforms. Only a smoke test, the integration tests + // check the line counts. var filler = string.Concat(Enumerable.Repeat("

        filler line of text

        ", 150)); var sentence = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty "; var html = $""" @@ -46,9 +40,6 @@ public async Task Widows_PullsMinimumLinesToNextPage() using var document = await PdfGenerator.GeneratePdf(html, config); - // The widowed paragraph must not leave fewer than 3 of its lines alone at the top of a page - - // this is a structural/behavioral guard (page count is stable and small) rather than pixel - // inspection, matching the other D2/D3 verification tests in this project. Assert.IsGreaterThanOrEqualTo(2, document.Pages.Count); } @@ -58,8 +49,7 @@ public async Task Orphans_KeepsMinimumLinesOnFirstPage() var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; config.SetMargins(20); - // Generous filler - see Widows_PullsMinimumLinesToNextPage's own remark on why a tight "just - // barely" filler count is fragile to font substitution across CI platforms. + // Generous filler, fonts differ between platforms. var filler = string.Concat(Enumerable.Repeat("

        filler line of text

        ", 150)); var sentence = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty "; var html = $""" diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs index 33cacb7f5..abb499dcb 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs @@ -12,9 +12,7 @@ public async Task WebLinkAndAnchorLink_AcrossPages_DoNotThrow() var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; config.SetMargins(20); - // Generous filler, not a precisely-calibrated boundary - a tight "just barely" filler count is - // fragile to font substitution across CI platforms (non-Windows runners fall back to an - // embedded font with different metrics than Windows' real "Times New Roman"). + // Generous filler, fonts differ between platforms. var filler = string.Concat(Enumerable.Repeat("

        filler line of text

        ", 150)); var html = $""" @@ -29,9 +27,7 @@ public async Task WebLinkAndAnchorLink_AcrossPages_DoNotThrow() Assert.IsGreaterThan(1, document.Pages.Count); - // At least one page carries some link annotation (either the web link or the document link) - - // this is a smoke check that HandleLinks' new slot-to-page mapping runs without throwing and - // actually attaches annotations, not a check of which exact page holds which link. + // Smoke check only, it doesn't verify which page holds which link. var anyLinks = false; for (var i = 0; i < document.PageCount; i++) { @@ -45,6 +41,7 @@ public async Task WebLinkAndAnchorLink_AcrossPages_DoNotThrow() } [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public async Task HugeMargin_ProducesNoBlankPages() { var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; @@ -54,8 +51,6 @@ public async Task HugeMargin_ProducesNoBlankPages() using var document = await PdfGenerator.GeneratePdf(html, config); - // css-break-3 5.2 margin truncation (D2) keeps this on very few pages; blank-page skipping - // (F1's page-per-fragmentainer loop) means whatever pages exist are never content-empty. Assert.IsLessThanOrEqualTo(2, document.Pages.Count); } } diff --git a/Source/Test/HtmlRenderer.Test/Css/PageRuleTests.cs b/Source/Test/HtmlRenderer.Test/Css/PageRuleTests.cs index c2413d216..7a9a47a8d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PageRuleTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PageRuleTests.cs @@ -5,18 +5,7 @@ namespace HtmlRenderer.Test.Css; /// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/PageRuleTests.cs. -/// All structural @page tests apply: , , -/// (Style.Size), Style.MarginTop, -/// (Style.PageName), and Style.Width all exist with matching shape. -/// Dropped entirely (SKIP, not ported, not [Ignore]d): every test that calls -/// PeachPDF.Html.Core.Parse.DomParser.ParseLengthToPdfPoints(...) or constructs a -/// PageLengthContext - the ParseLengthToPdfPoints_* theories, plus -/// AtPage_FirstPseudoSelector_MarginTopIsParseable and -/// AtPage_FirstPseudoSelector_ZeroMargin_RoundTripsToZeroPoints (which call it after the structural -/// assertion). This is a PDF-page-box length-to-points resolver with no HTML-Renderer counterpart at all -/// (HTML-Renderer isn't a PDF renderer) - confirmed zero hits for "ParseLengthToPdfPoints"/ -/// "PageLengthContext" anywhere under Source/HtmlRenderer. +/// Parsing of @page rules. Nothing applies them to the page yet. /// [TestClass] public sealed class PageRuleTests @@ -62,6 +51,7 @@ public void AtPage_SizeExplicitDimensions_IsStoredOnStyleDeclaration() // ── @page pseudo-selectors ───────────────────────────────────────────── [TestMethod] + [Spec("css21", "page.html#page-box")] public void AtPage_NoSelector_HasNullSelector() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page { margin: 20mm; }"); @@ -71,6 +61,7 @@ public void AtPage_NoSelector_HasNullSelector() } [TestMethod] + [Spec("css21", "page.html#page-selectors")] public void AtPage_FirstPseudoSelector_SelectorTextIsFirst() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page :first { margin-top: 0; }"); @@ -80,6 +71,7 @@ public void AtPage_FirstPseudoSelector_SelectorTextIsFirst() } [TestMethod] + [Spec("css21", "page.html#page-selectors")] public void AtPage_LeftPseudoSelector_SelectorTextIsLeft() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page :left { margin-left: 30mm; }"); @@ -89,6 +81,7 @@ public void AtPage_LeftPseudoSelector_SelectorTextIsLeft() } [TestMethod] + [Spec("css21", "page.html#page-selectors")] public void AtPage_RightPseudoSelector_SelectorTextIsRight() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page :right { margin-right: 30mm; }"); @@ -98,9 +91,6 @@ public void AtPage_RightPseudoSelector_SelectorTextIsRight() } // ── @page named selectors ────────────────────────────────────────────── - // Regression coverage: a bare identifier selector used to make the whole rule fail to parse in - // PeachPDF (CreatePageSelector only handled the leading-colon pseudo-class case), so - // "@page chapter { }" silently vanished instead of producing a PageRule at all. [TestMethod] public void AtPage_NamedSelector_SelectorTextIsName() @@ -114,8 +104,6 @@ public void AtPage_NamedSelector_SelectorTextIsName() [TestMethod] public void AtPage_NamedSelector_IsNotColonPrefixed() { - // Distinguishes a named-page selector from a pseudo-class one: SelectPageRule's matching - // logic branches on whether the selector text starts with ':'. var sheet = CssConstructionFunctions.ParseStyleSheet("@page chapter { margin-top: 0; }"); var rule = sheet.Rules.OfType().FirstOrDefault(); Assert.IsNotNull(rule); @@ -141,9 +129,6 @@ public void AtPage_NamedSelector_PreservesCase() } // ── @page compound name:pseudo selectors ─────────────────────────────── - // Regression coverage: "@page chapter1:left { }" used to fail to parse entirely in PeachPDF - // (CreatePageSelector stopped consuming tokens at the first non-comma token after an ident), - // silently dropping the whole rule. [TestMethod] public void AtPage_CompoundNamePseudoSelector_ParsesAsOneRule() diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs index c2bc3dc94..f4e33a728 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs @@ -6,26 +6,8 @@ namespace HtmlRenderer.Test.Css; /// -/// Ported from PeachPDF.Tests/CSS/Property.cs (the break-*/page-break-* cases). -/// Exercises the modern css-break-3 break-before/break-after/break-inside properties -/// (//) -/// against the legacy page-break-before/page-break-after/page-break-inside aliases -/// (//), -/// confirming they really do use two distinct converters/keyword sets as the port task described: -/// Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs lines 279-281 define -/// Converters.BreakModeConverter (break-before/break-after), Converters.BreakInsideModeConverter -/// (break-inside) and Converters.PageBreakModeConverter (page-break-before/page-break-after) as three -/// separate maps, and page-break-inside (Break/PageBreakInsideProperty.cs) uses a fourth, hand-rolled -/// auto/avoid-only converter rather than any of the three maps. -/// -/// One real divergence from PeachPDF's own grammar was found while verifying this port (Model/Map.cs -/// lines 265-277) and is documented in place below rather than assumed away: -/// Map.BreakModes (break-before/break-after) implements only 9 of the 12 css-break-3 §3.1 values - -/// it is missing "recto"/"verso"/"avoid-region"/"region" entirely (no such keywords exist anywhere in -/// Enumerations/Keywords.cs), and unlike PeachPDF it treats "always" as a supported value on the modern -/// properties (PeachPDF scopes "always" to the legacy page-break-* aliases only, per css-break-4). -/// Map.BreakInsideModes (break-inside) and Map.PageBreakModes (legacy page-break-before/after), -/// by contrast, match their respective spec value sets exactly. +/// The css-break-3 break-* properties and the CSS 2.1 page-break-* ones, which accept +/// different value sets. /// [TestClass] public sealed class PropertyBreakTests @@ -46,6 +28,7 @@ public void CssBreakAfterLegalAvoid() } [TestMethod] + [Spec("css21", "page.html#page-break-props")] public void CssPageBreakAfterLegalAvoid() { var snippet = "page-break-after:avoid"; @@ -76,6 +59,7 @@ public void CssBreakAfterLegalPageCapital() } [TestMethod] + [Spec("css21", "page.html#page-break-props")] public void CssPageBreakAfterIllegalAvoidColumn() { var snippet = "page-break-after:avoid-column"; @@ -119,6 +103,7 @@ public void CssBreakBeforeLegalAuto() } [TestMethod] + [Spec("css21", "page.html#page-break-props")] public void CssPageBreakBeforeLegalAuto() { var snippet = "page-break-before:AUTO"; @@ -134,6 +119,7 @@ public void CssPageBreakBeforeLegalAuto() } [TestMethod] + [Spec("css21", "page.html#page-break-props")] public void CssPageBreakBeforeLegalLeft() { var snippet = "page-break-before:left"; @@ -190,6 +176,7 @@ public void CssBreakInsideLegalAvoidRegionUppercase() } [TestMethod] + [Spec("css21", "page.html#page-break-props")] public void CssPageBreakInsideLegalAvoid() { var snippet = "page-break-inside:avoid"; @@ -205,6 +192,7 @@ public void CssPageBreakInsideLegalAvoid() } [TestMethod] + [Spec("css21", "page.html#page-break-props")] public void CssPageBreakInsideLegalAutoUppercase() { var snippet = "page-break-inside:AUTO"; @@ -219,10 +207,6 @@ public void CssPageBreakInsideLegalAutoUppercase() Assert.AreEqual("AUTO", concrete.Original); } - // The css-break-3 §3.1 break-before/break-after value set, restricted to the subset this engine's - // Map.BreakModes (Converters.cs line 279) actually implements. "recto"/"verso"/"avoid-region"/"region" - // are asserted as rejected separately below, rather than folded in here as PeachPDF does, because they - // are genuinely unsupported (no matching keywords exist in Enumerations/Keywords.cs). [TestMethod] [DataRow("break-before")] [DataRow("break-after")] @@ -241,9 +225,7 @@ public void BreakBeforeAfter_AcceptsSupportedSpecValues(string name) } } - // Documents the gap noted in the class header: these four css-break-3 values have no keyword defined - // anywhere in this engine (Enumerations/Keywords.cs) and are absent from Map.BreakModes - // (Model/Map.cs lines 265-277), so break-before/break-after reject them outright. + // css-break-3 values the engine doesn't implement. [TestMethod] [DataRow("break-before", "recto")] [DataRow("break-before", "verso")] @@ -264,18 +246,12 @@ public void BreakBeforeAfter_RejectsUnimplementedSpecValues(string name, string [DataRow("break-after")] public void BreakBeforeAfter_IsCaseInsensitiveAndKeepsAuthoredText(string name) { - // "avoid-page" replaces PeachPDF's "verso" probe value here, since "verso" is one of the - // unimplemented values documented above and would not exercise case-insensitivity at all. var property = ParseDeclaration($"{name}:AvOiD-PaGe"); Assert.IsTrue(property.HasValue); Assert.AreEqual("avoid-page", property.Value); Assert.AreEqual("AvOiD-PaGe", property.Original); } - // "all" and "avoid-nothing" are not part of any value set this engine defines for break-before/ - // break-after and are correctly rejected. "always" is asserted separately below since - unlike - // PeachPDF, which scopes "always" to the legacy page-break-* aliases only - this engine's - // Map.BreakModes (Model/Map.cs line 269) includes it as a supported break-before/break-after value. [TestMethod] [DataRow("break-before", "all")] [DataRow("break-after", "all")] @@ -286,8 +262,7 @@ public void BreakBeforeAfter_RejectsNonSpecValue(string name, string value) Assert.IsFalse(property.HasValue); } - // Divergence from PeachPDF (see class header): this engine's Map.BreakModes treats "always" as a - // legal break-before/break-after value, not just a legacy page-break-* one. + // css-break-3 only has "always" on the page-break-* aliases, the engine also takes it here. [TestMethod] [DataRow("break-before")] [DataRow("break-after")] @@ -335,6 +310,7 @@ public void BreakInside_RejectsNonSpecValue(string value) [DataRow("page-break-after", "avoid")] [DataRow("page-break-after", "left")] [DataRow("page-break-after", "right")] + [Spec("css21", "page.html#page-break-props")] public void PageBreakBeforeAfter_AcceptsLegacyValue(string name, string value) { var property = ParseDeclaration($"{name}:{value}"); @@ -351,6 +327,7 @@ public void PageBreakBeforeAfter_AcceptsLegacyValue(string name, string value) [DataRow("page-break-after", "page")] [DataRow("page-break-after", "avoid-page")] [DataRow("page-break-after", "verso")] + [Spec("css21", "page.html#page-break-props")] public void PageBreakBeforeAfter_RejectsModernOnlyValue(string name, string value) { var property = ParseDeclaration($"{name}:{value}"); @@ -360,6 +337,7 @@ public void PageBreakBeforeAfter_RejectsModernOnlyValue(string name, string valu [TestMethod] [DataRow("auto")] [DataRow("avoid")] + [Spec("css21", "page.html#page-break-props")] public void PageBreakInside_AcceptsLegacyValue(string value) { var property = ParseDeclaration($"page-break-inside:{value}"); @@ -372,6 +350,7 @@ public void PageBreakInside_AcceptsLegacyValue(string value) [DataRow("avoid-column")] [DataRow("avoid-region")] [DataRow("page")] + [Spec("css21", "page.html#page-break-props")] public void PageBreakInside_RejectsNonLegacyValue(string value) { var property = ParseDeclaration($"page-break-inside:{value}"); diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyPaginationTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyPaginationTests.cs index 5b3e83536..56a1a21e3 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PropertyPaginationTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PropertyPaginationTests.cs @@ -5,25 +5,6 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/Property.cs (the Orphans/Widows/UnicodeBidirectional cases). -/// -/// orphans (StyleProperties/OrphansProperty.cs) uses Converters.NaturalIntegerConverter -/// (ValueExtensions.ToNaturalInteger, Model/Converters.cs lines 36-37) - a non-negative integer -/// only, rejecting both negative values and non-integers. -/// -/// widows (StyleProperties/WidowsProperty.cs) uses the plain Converters.IntegerConverter -/// (ValueExtensions.ToInteger) rather than the natural-number variant - it still rejects a value -/// carrying a length unit like "5px" since that isn't a bare integer token at all. -/// -/// unicode-bidi (StyleProperties/UnicodeBidirectionalProperty.cs) uses -/// Converters.UnicodeModeConverter (backed by Map.UnicodeModes, Model/Map.cs lines 310-319), -/// which defines normal/embed/isolate/isolate-override/bidirectional-override/plaintext - "bidi-override" -/// and "isolate"/"plaintext"/"embed" are all present, confirming this port's value set matches the source -/// file's expectations unchanged; PeachPDF's own UnicodeMode-typed assertions are commented out in -/// the source (the concrete State member does not exist on this port's Property, mirroring -/// how the source itself already leaves them commented rather than asserting them). -/// [TestClass] public sealed class PropertyPaginationTests { @@ -43,6 +24,7 @@ public void CssOrphansZeroLegal() } [TestMethod] + [Spec("css21", "page.html#break-inside")] public void CssOrphansTwoLegal() { var snippet = "orphans : 2 "; @@ -58,6 +40,7 @@ public void CssOrphansTwoLegal() } [TestMethod] + [Spec("css21", "page.html#break-inside")] public void CssOrphansNegativeIllegal() { var snippet = "orphans : -2 "; @@ -71,6 +54,7 @@ public void CssOrphansNegativeIllegal() } [TestMethod] + [Spec("css21", "page.html#break-inside")] public void CssOrphansFloatingIllegal() { var snippet = "orphans : 1.5 "; @@ -99,6 +83,7 @@ public void CssWidowsZeroLegal() } [TestMethod] + [Spec("css21", "page.html#break-inside")] public void CssWidowsThreeLegal() { var snippet = "widows: 3"; @@ -114,6 +99,7 @@ public void CssWidowsThreeLegal() } [TestMethod] + [Spec("css21", "page.html#break-inside")] public void CssWidowsLengthIllegal() { var snippet = "widows: 5px"; diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/BreakTokenTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/BreakTokenTests.cs index 444953680..69c2ef82b 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/BreakTokenTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/BreakTokenTests.cs @@ -4,20 +4,9 @@ namespace HtmlRenderer.Test.Fragmentation; /// -/// Ported from PeachPDF.Tests/Html/Core/Fragmentation/BreakTokenTests.cs (BreakTokenTests). +/// The resumption record says where layout stopped, the box tree holds the geometry, so these pin its +/// shape rather than any placement. /// -/// -/// The resumption record: says where layout stopped and nothing about geometry - the box tree still -/// holds the coordinates - so these pin its shape rather than any placement. PeachPDF's own file also covers -/// InlineBreakToken/FlexBreakToken/GridBreakToken/FlexColumnBreakToken, none of -/// which exist here - BreakToken.cs's own doc comment confirms only forced break-before/ -/// break-after: page ever produces a real cross-pass token in this port (everything else - overflow, -/// break-inside:avoid, keep-with-next, widows/orphans, table-row breaks - turned out to be same-pass local -/// corrections instead), so is the only concrete to -/// test. Every test below that PeachPDF built over a different token kind is dropped as out of scope rather -/// than adapted; Chain_... is kept but rewritten to chain only links, -/// since that's the only concrete kind this port has to chain. -/// [TestClass] public sealed class BreakTokenTests { @@ -86,9 +75,7 @@ public void BlockBreakToken_IsARecord_WithStructuralEquality() { var box = new CssBox(null, null); - // A record's compiler-generated equality is what lets a resumed pass compare "did this pass land - // on the same resumption point as a previous one" without hand-written Equals/GetHashCode - two - // independently-built tokens over the same field values must compare equal. + // A resumed pass compares tokens to detect landing on the same resumption point again. var first = new BlockBreakToken(box, ResumeSlotIndex: 2, ResumeChildIndex: 4, ChildToken: null, IsBreakBefore: true, ResumeTopOverride: null); var second = new BlockBreakToken(box, ResumeSlotIndex: 2, ResumeChildIndex: 4, ChildToken: null, IsBreakBefore: true, ResumeTopOverride: null); diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/BreakValuesTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/BreakValuesTests.cs index 3d8b241f6..147d0e314 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/BreakValuesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/BreakValuesTests.cs @@ -2,33 +2,6 @@ namespace HtmlRenderer.Test.Fragmentation; -/// -/// Ported from PeachPDF.Tests/Html/Core/Fragmentation/BreakValuesTests.cs (BreakValuesTests). -/// -/// -/// PeachPDF's BreakValues answers several questions - a full css-break-3 §3.1 forced-break value -/// set (including the four directional values), a RequiredSide/PageSide resolver for them, a -/// two-context (page/column) AvoidsBreak/IsForcedBreak, a SlotIsOn parity -/// helper, and PageRuleResolver.IsRightPage. This port's own is reduced to -/// exactly what this port's engine (pages only, no multi-column, no directional/@page :left/:right -/// matching - see its own class doc comment) needs: and -/// , each single-argument (no FragmentationContext - there is -/// only ever one context, the page) and single-context-valued (only page/always force a break; -/// only avoid/avoid-page forbid one). Every PeachPDF theory row naming a directional -/// (left/right/recto/verso), region/avoid-region, or -/// column/avoid-column value is dropped per the port plan's scope decision, and with them the -/// entire RequiredSide/PageSide/SlotIsOn/PageRuleResolver surface, which has no -/// counterpart here at all. -/// -/// One real divergence from PeachPDF found while porting: PeachPDF's classifier rejects the legacy -/// always spelling outright (it only ever reaches a box through the legacy page-break-* alias, -/// which PeachPDF's own CssUtils rewrites to page before the classifier ever sees it). This port's -/// (Core/Fragmentation/BreakValues.cs) accepts always directly -/// instead, per its own doc comment: "HTML-Renderer's CSS engine accepts directly on the modern properties -/// too... rather than normalizing it away at parse time - so both spellings are classified here." Confirmed -/// independently by PropertyBreakTests.BreakBeforeAfter_AcceptsAlwaysUnlikePeachPdf -/// (Css/PropertyBreakTests.cs), which documents the same divergence at the CSS-parsing layer. -/// [TestClass] public sealed class BreakValuesTests { @@ -39,16 +12,12 @@ public sealed class BreakValuesTests [DataRow("avoid", false)] [DataRow("avoid-page", false)] [DataRow(null, false)] + [Spec("css21", "page.html#page-break-props")] public void IsForcedBreak_MatchesThisPortsReducedValueSet(string value, bool expected) => Assert.AreEqual(expected, BreakValues.IsForcedBreak(value)); - // The divergence from PeachPDF documented in the class remarks: unlike PeachPDF's - // IsForcedPageBreak_RejectsTheLegacyAlwaysSpelling, this port's classifier accepts "always" directly. - [TestMethod] - public void IsForcedBreak_AcceptsTheLegacyAlwaysSpellingUnlikePeachPdf() => - Assert.IsTrue(BreakValues.IsForcedBreak("always")); - [TestMethod] + [Spec("css21", "page.html#page-break-props")] [DataRow("avoid", true)] [DataRow("avoid-page", true)] [DataRow("auto", false)] diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/ForcedBreakTargetIsTheFramesTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/ForcedBreakTargetIsTheFramesTests.cs index 9aa53cfe7..deea804d3 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/ForcedBreakTargetIsTheFramesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/ForcedBreakTargetIsTheFramesTests.cs @@ -6,33 +6,13 @@ namespace HtmlRenderer.Test.Fragmentation; /// -/// Ported from PeachPDF.Tests/Html/Core/Fragmentation/ForcedBreakTargetIsTheFramesTests.cs -/// (ForcedBreakTargetIsTheFramesTests). +/// Where puts a box with a forced break, compared +/// with where layout actually placed it. /// -/// -/// Where a forced break (css-break-3 §3.1) puts a box, asked directly of the frame -/// () rather than latched once by the box's own -/// prologue - and against where the box actually ended up, so "re-derived per placement" and "the value the -/// placement used" cannot drift apart unnoticed. -/// -/// Two real adaptations from PeachPDF's version: (1) PeachPDF asks the question through -/// box.ParentBox.ForcedBreakTopFor(box) - a single-argument method that derives the previous sibling -/// and base-top internally. This port's takes them -/// explicitly (box, prevSibling, baseTopWithoutMargin, out slot/targetTop) -/// - below derives them the same way the real pass loop does -/// (, prevSibling.ActualBottom). (2) Two PeachPDF tests - -/// NamedPageOnANestedFirstChild_ResolvesAgainstThePredecessorOfTheChainItBegins and -/// NamedPageTransition_ResolvesThroughTheSameTarget - exercise CSS Paged Media 3 §3 named-page -/// transitions (@page :name/page: name), which this port does not attribute at all; both are -/// dropped rather than adapted. -/// [TestClass] public sealed class ForcedBreakTargetIsTheFramesTests { - // Sheet height 300, 20 margin top/bottom -> a 260-tall content band. LayoutHarness's own pageHeight - // parameter is already the content band (its own doc comment), so Band - not PageHeight - is what's - // passed to it; PageHeight/Margin are kept as named constants purely for SlotTop's readability, matching - // the source test's own shape. + // Sheet height 300 with 20 margin top and bottom, LayoutHarness takes the 260 content band. private const double PageHeight = 300; private const double Margin = 20; private const double Band = PageHeight - 2 * Margin; @@ -40,10 +20,6 @@ public sealed class ForcedBreakTargetIsTheFramesTests private static double SlotTop(int slot) => Margin + slot * Band; /// The target the frame resolves for , asked after layout. - /// - /// Asking afterwards is the point: a value that is re-derived rather than consumed answers the same - /// way whenever it is asked, so this is exactly the assertion a latched field could not pass. - /// private static double? TargetFor(CssBox root, string id) { var box = LayoutHarness.FindById(root, id); @@ -51,7 +27,7 @@ public sealed class ForcedBreakTargetIsTheFramesTests var prevSibling = DomUtils.GetPreviousSibling(box!); if (prevSibling is null) - return null; // TryGetForcedBreakTarget requires a previous sibling - see its own doc comment. + return null; var baseTopWithoutMargin = prevSibling.ActualBottom; return BlockFragmentation.TryGetForcedBreakTarget(box!, prevSibling, baseTopWithoutMargin, out _, out var targetTop) @@ -59,9 +35,8 @@ public sealed class ForcedBreakTargetIsTheFramesTests : null; } - // The ordinary case: a predecessor that ends part-way down slot 0 puts the break at slot 1's own - // content top, and the box is placed exactly there (no margin to preserve). [TestMethod] + [Spec("css21", "page.html#forced")] public void PlainForcedBreak_TargetsTheNextSlotsContentTop_AndIsWhereTheBoxLanded() { var (root, container) = LayoutHarness.Layout( @@ -75,17 +50,9 @@ public void PlainForcedBreak_TargetsTheNextSlotsContentTop_AndIsWhereTheBoxLande Assert.AreEqual(SlotTop(1), LayoutHarness.FindById(root, "second")!.Location.Y, 1e-6); } - // §4.4: a predecessor whose content ENDS flush on a slot boundary already satisfies the break, so - // no separate target is manufactured for it and the box just lands there through ordinary flow. - // Sizing the first box to exactly one band is the canonical shape (a full-bleed cover), and the - // epsilon is what stops it manufacturing a blank page. - // Adapted: PeachPDF's ForcedBreakTopFor always returns a (possibly redundant) target, so its own - // version of this test asserts a non-null TargetFor(root,"second") equal to PageTopOf(1) even in the - // already-flush case. This port's TryGetForcedBreakTarget instead returns false specifically to mean - // "already satisfied, no relocation needed" (Core/Fragmentation/BlockFragmentation.cs ~83-84: "Already - // flush at a fresh page's top - a forced break here does not skip a page"), so the faithful assertion - // here is that TargetFor returns null, not a redundant restated boundary - confirmed empirically. + // A predecessor ending flush on a page boundary already satisfies the break, no target is reported. [TestMethod] + [Spec("css21", "page.html#forced")] public void PredecessorEndingFlushOnABoundary_TargetsThatBoundary_NotTheSlotAfterIt() { var (root, container) = LayoutHarness.Layout( @@ -97,25 +64,16 @@ public void PredecessorEndingFlushOnABoundary_TargetsThatBoundary_NotTheSlotAfte // The first box occupies the whole of slot 0 and ends exactly where slot 1 begins. Assert.AreEqual(SlotTop(1), LayoutHarness.FindById(root, "first")!.ActualBottom, 1e-6); - // Slot 1, not slot 2: the flush end is already the break, and no page is skipped - and, per the - // adaptation above, no separate target is reported for an already-satisfied break either. + // Slot 1, not slot 2, no page is skipped. Assert.IsNull(TargetFor(root, "second")); Assert.AreEqual(SlotTop(1), LayoutHarness.FindById(root, "second")!.Location.Y, 1e-6); Assert.AreEqual(2, container.FragmentTree!.Fragmentainers.Count); } - // The case PeachPDF's epsilon must not swallow: a zero-height marker that its OWN forced break - // already relocated to a boundary sits AT that boundary, which is the later slot - so the break - // between it and the next box should still push past it, preserving the intentional blank page. - [Ignore("This port's BlockFragmentation.TryGetForcedBreakTarget implements only PeachPDF's first " + - "epsilon rule (naturalTop <= pageTop + 0.01 => already satisfied, Core/Fragmentation/" + - "BlockFragmentation.cs ~77-88), not PeachPDF's second, consecutive-forced-break rule that " + - "distinguishes a predecessor genuinely filling a slot from a zero-height marker sitting AT a " + - "boundary because its OWN forced break already put it there. Confirmed empirically: 'marker' " + - "lands at SlotTop(1) via its own break-before as expected, but 'second' (whose prevSibling is " + - "now 'marker', flush at that same boundary) is then ALSO judged already-satisfied by the single " + - "epsilon rule and placed at SlotTop(1) too, colliding with 'marker' on the same page instead of " + - "stepping to slot 2 - the deliberately-blank page this test exists to prove out is lost.")] + // A zero-height marker moved to a page top by its own forced break, followed by another forced + // break, should leave a blank page. + [Ignore("A forced break after a zero-height box that its own forced break moved to a page top is " + + "treated as already satisfied, both boxes share the page.")] [TestMethod] public void ConsecutiveForcedBreaks_StepPastTheMarkerRatherThanCollapsingOntoIt() { @@ -133,17 +91,13 @@ public void ConsecutiveForcedBreaks_StepPastTheMarkerRatherThanCollapsingOntoIt( Assert.AreEqual(SlotTop(1), marker!.Location.Y, 1e-6); Assert.AreEqual(marker.Location.Y, marker.ActualBottom, 1e-6); - // Its bottom is flush on slot 1's top, which SlotEndingAt reads as slot 0 - but its own top is - // AT that boundary, so the second rule fires and the break lands one slot further on. Without - // it the two boxes would share slot 1 and the deliberately-blank page would be lost. Assert.AreEqual(container.PageTopOf(2), TargetFor(root, "second")!.Value, 1e-6); Assert.AreEqual(SlotTop(2), LayoutHarness.FindById(root, "second")!.Location.Y, 1e-6); } - // §5.2 preserves the margin on the new page's side of a FORCED break, so the box lands one margin - // below the target rather than on it - which is exactly why the target is worth asserting - // separately from the position. + // The margin-top after a forced break is retained. [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] public void TargetIsTheBoundary_AndThePreservedMarginIsAddedToIt() { var (root, container) = LayoutHarness.Layout( @@ -156,19 +110,10 @@ public void TargetIsTheBoundary_AndThePreservedMarginIsAddedToIt() Assert.AreEqual(SlotTop(1) + 30, LayoutHarness.FindById(root, "second")!.Location.Y, 1e-6); } - // §3.1's break point before a container's FIRST in-flow child is the same break point as the one - // before the container, so a `break-before` there should be taken by the container the box begins, - // not by the box. - [Ignore("This port's BlockFragmentation.TryGetForcedBreakTarget does not implement css-break-3 §3.1's " + - "cross-ancestor break-point propagation - confirmed by direct source read of its own remark " + - "(Core/Fragmentation/BlockFragmentation.cs ~58-67): 'Full cross-ancestor propagation is out of " + - "scope for this port; suppressing at the box's own level is what keeps a heading that merely " + - "happens to be first on the page from forcing a spurious leading blank page.' The method requires " + - "a non-null prevSibling (~74), so a first-in-flow child's own break-before is simply never taken " + - "up by its parent here: 'second' has no sibling within 'wrapper' and 'wrapper' itself carries no " + - "break-before of its own, so TargetFor returns null for BOTH boxes and no page break happens at " + - "all - unlike PeachPDF, where the container hoists the break and lands on the next page.")] + // The break before a container's first in-flow child is also the one before the container. + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "page.html#forced")] public void BreakBeforeAFirstInFlowChild_IsTakenByTheContainerItBegins() { var (root, container) = LayoutHarness.Layout( @@ -185,9 +130,7 @@ public void BreakBeforeAFirstInFlowChild_IsTakenByTheContainerItBegins() Assert.AreEqual(SlotTop(1), LayoutHarness.FindById(root, "second")!.Location.Y, 1e-6); } - // Nothing precedes the box in the flow at all: there is no break to take, and §4.4 asks user - // agents not to manufacture a blank page in front of a document's first content. A null target is - // how that is said. + // Nothing precedes the box, so there is no break to take and no leading blank page. [TestMethod] public void BoxThatBeginsTheFlow_HasNoTargetAtAll() { @@ -200,8 +143,6 @@ public void BoxThatBeginsTheFlow_HasNoTargetAtAll() Assert.AreEqual(1, container.FragmentTree!.Fragmentainers.Count); } - // A box with no forced break before it has no target either - the method answers about the break, - // not about the box's position, so an ordinary sibling gets null rather than "wherever it is". [TestMethod] public void BoxWithNoForcedBreak_HasNoTarget() { @@ -214,15 +155,7 @@ public void BoxWithNoForcedBreak_HasNoTarget() Assert.IsNull(TargetFor(root, "second")); } - // CSS 2.1 §9.4.3: a relative offset moves a box visually without affecting the layout of anything - // around it, so it must not decide which slot the break lands in. - // Adapted: this port does not implement position:relative's top/left visual offset at all - confirmed - // by direct source read, CssBoxProperties.Left/Top (Core/Dom/CssBoxProperties.cs ~569-595) only ever - // call GetActualLocation when Position == Fixed, never for Relative, and no other call site applies a - // relative offset anywhere in Core. So these assertions still hold, just for a different reason than - // PeachPDF's own (there is no offset to exclude from the flow calculation, rather than a correctly - // excluded one) - kept active rather than [Ignore]d since the assertions genuinely pass, and the - // adaptation is documented here rather than assumed away. + // CSS 2.1 §9.4.3: a relative offset doesn't affect the flow, so it doesn't move the break either. [TestMethod] [DataRow("top: -40px")] [DataRow("top: 40px")] diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/HtmlContainerIntPaginationTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/HtmlContainerIntPaginationTests.cs index a852c83e6..35f78cbd2 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/HtmlContainerIntPaginationTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/HtmlContainerIntPaginationTests.cs @@ -4,50 +4,16 @@ namespace HtmlRenderer.Test.Fragmentation; /// -/// Ported from PeachPDF.Tests/Html/Core/HtmlContainerIntPaginationTests.cs (HtmlContainerIntPaginationTests). +/// Which page slots get a fragmentainer. css-page-3 §3.2 asks to avoid content-empty pages. /// -/// -/// Tests for the fragment tree's page-materialization rule, which is SUPPOSED to skip building a -/// fragmentainer for a wholly content-empty page-slot, per CSS Paged Media Level 3 §3.2 ("User agents -/// SHOULD avoid generating a large number of content-empty pages"). This port's own equivalent -/// (FragmentEmitter.HasContentInBand, Core/Fragmentation/FragmentEmitter.cs ~120-167) turns out NOT -/// to skip anything, confirmed empirically (see the two [Ignore]d tests below) and by direct source -/// read: Finish()'s per-slot loop (~70-82) calls HasContentInBand(root, band) with the -/// DOCUMENT ROOT as the starting box on every iteration, and HasContentInBand's very first check -/// (~127-130, reached because a plain block container's Rectangles - a per-CssLineBox dictionary, -/// Core/Dom/CssBox.cs ~430 - is empty unless it hosts inline content of its own) is -/// Overlaps(box.Bounds, band), where Bounds (Core/Dom/CssBoxProperties.cs ~911-914) is simply -/// Location+Size - the root's own auto-height border box, which by construction already spans -/// every band the slot loop ever visits (lastSlot is itself derived from root.ActualBottom). -/// So HasContentInBand(root, band) returns true on this very first check, for every slot, regardless -/// of what is or isn't inside - the recursion into children/words that would actually distinguish a -/// content-empty band from a content-having one is unreachable from this top-level call. Only -/// (which asserts nothing ever -/// false-skips, not that anything real skips) is unaffected by this and stays active. -/// Adapted to the adapter-free (real PerformLayout over MockAdapter) -/// rather than PeachPDF's real PdfSharpAdapter-driven harness, with plain "px" markup rather than -/// PeachPDF's "pt" (this fork's internal layout unit is CSS px; "pt" would scale by -/// Length.PointsPerPx and throw off the exact page-boundary numbers the assertions depend on), and -/// "background-color" rather than the "background" shorthand, matching this repository's own established -/// adaptation (see HtmlRenderer.IntegrationTest.Positioning.FixedPositionPaginationIntegrationTests's -/// identical note: this fork's CssUtils dispatch has no case for the "background" shorthand key at all). -/// [TestClass] public sealed class HtmlContainerIntPaginationTests { - [Ignore("FragmentEmitter.HasContentInBand never actually skips a content-empty slot in this port - " + - "confirmed empirically and by direct source read, see the class remarks above. A document with " + - "an 880px content-free gap between two 20px content divs (page height 200) produces one " + - "fragmentainer for EVERY slot (0/200/400/600/800), not just the two genuinely content-having " + - "ones, because Finish()'s per-slot HasContentInBand(root, band) check is satisfied by the " + - "document root's own auto-height Bounds before it ever considers whether the gap div itself " + - "has anything printable in it.")] + [Ignore("Every page slot the root spans gets a fragmentainer, content-empty ones included.")] [TestMethod] public void Fragmentainers_RealContentSeparatedByMultiPageGap_SkipWhollyEmptySlots() { - // Page height 200: real content at the very top (page-slot 0) and real content starting - // at y=900 (page-slot 4) - slots 1-3 have nothing painted in them at all and, per css-page-media-3 - // §3.2, must not be materialized. + // Slots 1 to 3 have nothing painted in them. var (_, container) = LayoutHarness.Layout( LayoutHarness.Wrap( "
        " + @@ -67,10 +33,6 @@ public void Fragmentainers_RealContentSeparatedByMultiPageGap_SkipWhollyEmptySlo [TestMethod] public void Fragmentainers_ContiguousRealContent_KeepEveryPage() { - // Real, painted content spanning several page-heights (no gaps) must still produce one - // slot per page, exactly matching the un-skipped pagination behavior. Unaffected by the dead - // skip-path documented in the class remarks: this only asserts nothing is ever WRONGLY skipped, - // which holds either way. var (_, container) = LayoutHarness.Layout( LayoutHarness.Wrap("
        section content spanning pages
        "), pageHeight: 200, margin: 0); @@ -81,16 +43,11 @@ public void Fragmentainers_ContiguousRealContent_KeepEveryPage() CollectionAssert.AreEqual(new[] { 0, 1, 2, 3, 4 }, fragmentainers.Select(f => f.SlotIndex).ToList()); } - [Ignore("Same dead skip-path as Fragmentainers_RealContentSeparatedByMultiPageGap_SkipWhollyEmptySlots " + - "(see the class remarks) - confirmed empirically: a 900px, entirely background-less filler div " + - "(page height 200) produces 5 fragmentainers (one per slot it geometrically spans), not the " + - "single content-empty-document fallback css-page-media-3 §3.2 asks for.")] + [Ignore("A document without printable content gets a fragmentainer per page slot it spans.")] [TestMethod] public void Fragmentainers_PureMarginOnlyDocument_FallBackToASingleFragmentainer() { - // A document that laid out to a real, non-zero height but has nothing "printable" - // anywhere (an extreme, all-margin edge case) must still produce exactly one page - never - // zero - rather than emitting a content-less document. + // Nothing printable anywhere still makes one page, never zero. var (_, container) = LayoutHarness.Layout( LayoutHarness.Wrap("
        "), pageHeight: 200, margin: 0); diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs index 55f0e520c..ecdce9a3e 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs @@ -6,22 +6,8 @@ namespace HtmlRenderer.Test.Fragmentation; /// -/// Ported from PeachPDF.Tests/Html/Core/Fragmentation/MonolithicContentTests.cs (MonolithicContentTests). +/// The monolithic content classifier against css-break-3 §2. /// -/// -/// The monolithic classifier, asserted against css-break-3 §2's own set rather than the engine's prior -/// behaviour, the same way reads §3's value sets. Adapted for -/// Core/Fragmentation/MonolithicContent.cs's own reduced scope (see its class doc comment): no flex/ -/// grid/multi-column engine (so PaginatesItsOwnContent narrows to table/inline-table), and -/// HTML-Renderer's smaller replaced-element set - only <img>/<iframe> are replaced -/// (CssBox.CreateBox, Core/Dom/CssBox.cs), confirmed by direct source read: there is no -/// CssBoxObject/inline-SVG/form-widget box type anywhere in this fork, so PeachPDF's own -/// <svg>/<object> theory rows and its UnresolvedObject_IsNotReplaced test -/// (which exists specifically to probe PeachPDF's dynamic object-resolution behaviour) are dropped rather -/// than adapted - there is no dynamic resolution question to ask here. FitsNoFragmentainer/ -/// FitsInBand also lost their clonedStart/clonedEnd parameters (box-decoration-break -/// clone insets do not exist in this port), so the theory rows that varied only those are dropped too. -/// [TestClass] public sealed class MonolithicContentTests { @@ -54,9 +40,7 @@ public void OrdinaryBlock_IsNotMonolithic() [DataRow("scroll", true)] [DataRow("auto", true)] [DataRow("visible", false)] - // Not in Map.OverflowModes (Core/CssEngine/Model/Map.cs), so it never converts and the box keeps - // "visible" - which is the answer §2 wants for `clip` anyway, though by accident rather than by design - // (matching PeachPDF's own identically-accidental behaviour here). + // 'clip' isn't a known overflow value, the box stays 'visible', which is also what §2 wants. [DataRow("clip", false)] public void Overflow_DecidesScrollContainer(string overflow, bool expected) { @@ -133,8 +117,7 @@ public void EngineThatPaginatesItself_IsNotBySpecMonolithic(string style) Assert.IsFalse(MonolithicContent.IsMonolithic(box)); } - // The narrowed scope itself (see class remarks): PeachPDF also recognizes flex/grid/multi-column as - // self-paginating engines; none of the three exist in this fork, so none of them qualify here. + // Flex, grid and multi-column layout don't exist, so they don't paginate their own content. [TestMethod] [DataRow("display:flex")] [DataRow("display:grid")] diff --git a/Spec/css21.yml b/Spec/css21.yml index 89c9fd3f5..2c1e3d372 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -685,43 +685,64 @@ sections: - georgian renders 20 as პ instead of კ page.html#the-page: title: 13 Paged media - status: unknown + status: partial page.html#page-intro: title: 13.1 Introduction to paged media - status: unknown + status: n/a page.html#page-box: title: 13.2 Page boxes - status: unknown + status: partial page.html#page-margins: title: 13.2.1 Page margins - status: unknown + status: parsed-only + gaps: + - Margins in @page rules are parsed but never applied, the page margins come only from PdfGenerateConfig page.html#page-selectors: title: 13.2.2 Page selectors - status: unknown + status: parsed-only + gaps: + - "@page :first, :left and :right rules are parsed but never applied" page.html#outside-page-box: title: 13.2.3 Content outside the page box - status: unknown + status: supported page.html#page-breaks: title: 13.3 Page breaks - status: unknown + status: partial page.html#page-break-props: title: 13.3.1 Page break properties - status: unknown + status: partial + gaps: + - "'left' and 'right' don't force a break" + - "A 'page-break-inside: avoid' block without text, e.g. an empty div with a height, is split instead of moved to the next page" + - A box split across pages draws its border on both sides of the split page.html#break-inside: title: 13.3.2 Breaks inside elements - status: unknown + status: partial + gaps: + - "'orphans' and 'widows' of 0 or less are accepted and fall back to 2 instead of being ignored" page.html#allowed-page-breaks: title: 13.3.3 Allowed page breaks - status: unknown + status: partial + gaps: + - When all lines of a block move to the next page, the block's top edge (border, background) stays on the previous page + - "'page-break-after: avoid' doesn't pull a block without text along to the next page" + - "'page-break-after: avoid' isn't honored when the next box's first child moves to the next page through margin truncation" + - "At a forced break the previous box's 'margin-bottom' isn't set to 0, it collapses with 'margin-top' at the top of the new page" + - The top margin of an hr isn't set to 0 at an unforced page break page.html#forced: title: 13.3.4 Forced page breaks - status: unknown + status: partial + gaps: + - A forced break before a first child is dropped instead of breaking before its ancestors, which meet at the same margin + - "A forced 'page-break-after' on a last child doesn't break before its parent's next sibling" page.html#best-page-breaks: title: 13.3.5 "Best" page breaks - status: unknown + status: supported page.html#page-cascade: title: 13.4 Cascading in the page context - status: unknown + status: parsed-only + gaps: + - "@page declarations aren't cascaded, nothing reads them" colors.html#q14.0: title: 14 Colors and Backgrounds status: unknown From 4a9eb9425a8b62ed4e885dafd1b4048325f6b63e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:04:57 +0100 Subject: [PATCH 019/150] Verify and document CSS 2.1 colors and backgrounds compliance --- .../CssEngine/BackgroundPositionGrammar.cs | 9 +- .../Core/CssEngine/BackgroundSizeGrammar.cs | 8 +- .../Core/CssEngine/Model/Converters.cs | 6 - .../BackgroundPositionValueConverter.cs | 5 +- .../HtmlRenderer/Core/Dom/CssBoxProperties.cs | 1 - .../Handlers/BackgroundImageDrawHandler.cs | 4 +- .../BackgroundPaintIntegrationTests.cs | 258 +++++++++++++++++- .../ForegroundColorPaintIntegrationTests.cs | 47 ++++ .../Css/BackgroundPropertyTests.cs | 10 +- Spec/css21.yml | 15 +- 10 files changed, 323 insertions(+), 40 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/ForegroundColorPaintIntegrationTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/BackgroundPositionGrammar.cs b/Source/HtmlRenderer/Core/CssEngine/BackgroundPositionGrammar.cs index b9b05f2ac..1cc522ca5 100644 --- a/Source/HtmlRenderer/Core/CssEngine/BackgroundPositionGrammar.cs +++ b/Source/HtmlRenderer/Core/CssEngine/BackgroundPositionGrammar.cs @@ -3,12 +3,9 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// - /// Shared grammar for the CSS background-position value (the 1/2/3/4-token forms, - /// including the edge-relative offset syntax, e.g. "right 20px bottom 10px"). Used by both - /// (CSS-OM grammar validation/serialization) and - /// the render-time background-layer resolution (pixel-offset arithmetic) so - /// there is one parser for this grammar, not two independent re-implementations - mirroring how - /// CalcParser is shared between CalcValueConverter and CssValueParser rather than re-parsed twice. + /// Grammar of a background-position value: the 1 to 4 token forms, including edge offsets + /// like "right 20px bottom 10px". Used by and + /// to validate and serialize the value. /// internal static class BackgroundPositionGrammar { diff --git a/Source/HtmlRenderer/Core/CssEngine/BackgroundSizeGrammar.cs b/Source/HtmlRenderer/Core/CssEngine/BackgroundSizeGrammar.cs index 99bf73e5d..4d6787684 100644 --- a/Source/HtmlRenderer/Core/CssEngine/BackgroundSizeGrammar.cs +++ b/Source/HtmlRenderer/Core/CssEngine/BackgroundSizeGrammar.cs @@ -3,11 +3,9 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// - /// Shared grammar for the CSS background-size value (auto, cover, - /// contain, or a 1-2 token length/percentage/auto form). Used by both - /// (CSS-OM grammar validation/serialization) - /// and the render-time background-layer resolution (size resolution), so there - /// is one parser for this grammar, not two. + /// Grammar of a background-size value: auto, cover, contain or + /// 1 to 2 length, percentage or auto tokens. Used by + /// to validate and serialize the value. /// internal static class BackgroundSizeGrammar { diff --git a/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs b/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs index cb018d112..813083f3b 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs @@ -89,10 +89,6 @@ public static readonly IValueConverter Assign(Keywords.Top, 1.0).Or(Keywords.Bottom, -1.0).Option(0.0) ); - // The 1/2/3/4-token background-position grammar (including the edge-relative offset syntax, - // e.g. "right 20px bottom 10px") is implemented once in BackgroundPositionGrammar and shared - // with the render layer's BackgroundLayerResolver, rather than re-implemented as a second, - // independent parser of the same value string - see that class's doc comment. public static readonly IValueConverter PointConverter = new BackgroundPositionValueConverter(); public static readonly IValueConverter AttrConverter = new FunctionValueConverter( @@ -523,8 +519,6 @@ public static readonly IValueConverter public static readonly IValueConverter FontFamiliesConverter = DefaultFontFamiliesConverter.Or(StringConverter).Or(LiteralsConverter).FromList(); - // Shared with the render layer's BackgroundLayerResolver via BackgroundSizeGrammar - see - // BackgroundPositionGrammar's doc comment for why. public static readonly IValueConverter BackgroundSizeConverter = new BackgroundSizeValueConverter(); public static readonly IValueConverter BackgroundRepeatsConverter = BackgroundRepeatConverter.Or( diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/BackgroundPositionValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/BackgroundPositionValueConverter.cs index f254c0901..5e2e9ef85 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/BackgroundPositionValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/BackgroundPositionValueConverter.cs @@ -4,9 +4,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// - /// Validates and serializes a single-layer background-position value via the shared - /// (see that class for why this isn't a hand-written - /// combinator tree like most other converters in this file). + /// Validates and serializes a single-layer background-position value through + /// . /// internal sealed class BackgroundPositionValueConverter : IValueConverter { diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index 87f344cea..dcfc14324 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs @@ -1606,7 +1606,6 @@ public double ActualWordSpacing } /// - /// /// Gets the actual color for the text. /// public RColor ActualColor diff --git a/Source/HtmlRenderer/Core/Handlers/BackgroundImageDrawHandler.cs b/Source/HtmlRenderer/Core/Handlers/BackgroundImageDrawHandler.cs index 275f23320..9a9421905 100644 --- a/Source/HtmlRenderer/Core/Handlers/BackgroundImageDrawHandler.cs +++ b/Source/HtmlRenderer/Core/Handlers/BackgroundImageDrawHandler.cs @@ -23,8 +23,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.Handlers internal static class BackgroundImageDrawHandler { /// - /// Draw the background image of the given box in the given rectangle.
        - /// Handle background-repeat and background-position values. + /// Draw the background image of the given box in the given rectangle, CSS 2.1 §14.2.1. + /// The image is positioned and tiled within , the border box. ///
        /// the device to draw into /// the box to draw its background image diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs index 07d5c5784..92e489a1a 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs @@ -1,19 +1,24 @@ using System.Linq; using HtmlRenderer.IntegrationTest.TestSupport; using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; namespace HtmlRenderer.IntegrationTest.Painting; -/// -/// CSS 2.1 §14.2.1: background-color paints a solid fill behind a box's content, covering the box's -/// padding + content area (border-box minus the border itself). No dedicated background paint-call test -/// existed anywhere in this repo before this - only CSS-OM parsing (BackgroundPropertyTests.cs). -/// [DoNotParallelize] [TestClass] public sealed class BackgroundPaintIntegrationTests { + // MockAdapter decodes every image as 40x30 + private const string Image = "url(data:image/png;base64,iVBORw0KGgo=)"; + private const double ImageWidth = 40; + private const double ImageHeight = 30; + + private static readonly RColor Fill = RColor.FromArgb(10, 20, 30); + [TestMethod] + [Spec("css21", "colors.html#background-properties")] public void BackgroundColor_PaintsASolidFillRect() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -30,6 +35,7 @@ public void BackgroundColor_PaintsASolidFillRect() } [TestMethod] + [Spec("css21", "colors.html#background-properties")] public void BackgroundColorTransparent_PaintsNothing() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -42,10 +48,9 @@ public void BackgroundColorTransparent_PaintsNothing() } [TestMethod] + [Spec("css21", "colors.html#background-properties")] public void BackgroundColorDefault_PaintsNothing() { - // The initial value of background-color is "transparent" - a box with no background-color set at - // all must not paint a fill either. var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( "
        x
        ")); var div = PaintHarness.FindById(root, "b")!; @@ -58,7 +63,6 @@ public void BackgroundColorDefault_PaintsNothing() [TestMethod] public void BackgroundColor_PaintsBeforeBorderAndOutline() { - // CSS 2.1 Appendix E: background paints before border/outline for the same box. var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( "
        x
        ")); @@ -74,4 +78,242 @@ public void BackgroundColor_PaintsBeforeBorderAndOutline() Assert.IsTrue(backgroundIndex < borderIndex, "background must paint before the border"); Assert.IsTrue(borderIndex < outlineIndex, "border must paint before the outline"); } + + [TestMethod] + [Spec("css21", "colors.html#background")] + public void Background_CoversContentPaddingAndBorderButNotMargin() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
        x
        ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + var fill = Fills(g).Single(); + + Assert.AreEqual(10, fill.X, 0.5); + Assert.AreEqual(10, fill.Y, 0.5); + Assert.AreEqual(116, fill.Width, 0.1); + Assert.AreEqual(66, fill.Height, 0.1); + } + + [TestMethod] + [Spec("css21", "colors.html#background")] + public void Background_IsNotInherited() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "

        x

        ")); + var child = PaintHarness.FindById(root, "c")!; + + var g = PaintHarness.PaintBox(container, child); + + Assert.AreEqual("transparent", child.BackgroundColor); + Assert.IsFalse(Fills(g).Any()); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "colors.html#background")] + public void RootBackground_CoversTheWholeCanvas() + { + var (_, container) = PaintHarness.Layout( + "
        ", + 500, 300); + + var g = PaintHarness.PaintPage(container); + var fill = Fills(g).Single(); + + Assert.IsTrue(fill.Width >= 500 && fill.Height >= 300, $"canvas fill was {fill.Width}x{fill.Height}"); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "colors.html#background")] + public void BodyBackground_PropagatesToTheCanvas() + { + var (_, container) = PaintHarness.Layout( + "
        ", + 500, 300); + + var g = PaintHarness.PaintPage(container); + var fill = Fills(g).Single(); + + Assert.AreEqual(0, fill.X, 0.5); + Assert.AreEqual(0, fill.Y, 0.5); + Assert.IsTrue(fill.Width >= 500 && fill.Height >= 300, $"canvas fill was {fill.Width}x{fill.Height}"); + } + + [TestMethod] + [Spec("css21", "colors.html#background-properties")] + public void BackgroundImage_PaintsOnTopOfTheBackgroundColor() + { + var g = PaintBackground($"width:100px;height:50px;background-color:rgb(10,20,30);background-image:{Image};background-repeat:no-repeat"); + + var fillIndex = g.Log.FindIndex(c => c is RecordingGraphics.DrawRectCall r && r.Color == Fill); + var imageIndex = g.Log.FindIndex(c => c is RecordingGraphics.DrawImageCall); + + Assert.IsTrue(fillIndex >= 0 && imageIndex >= 0); + Assert.IsTrue(fillIndex < imageIndex); + } + + [TestMethod] + [Spec("css21", "colors.html#background-properties")] + public void BackgroundImageNone_PaintsNoImage() + { + var g = PaintBackground("width:100px;height:50px;background-image:none;background-repeat:no-repeat"); + + Assert.IsFalse(g.DrawImageCalls.Any()); + } + + [TestMethod] + [Spec("css21", "colors.html#background-properties")] + public void BackgroundRepeatNoRepeat_DrawsOneCopyAtTheTopLeft() + { + var g = PaintBackground($"width:100px;height:50px;background-image:{Image};background-repeat:no-repeat"); + + var image = g.DrawImageCalls.Single(); + Assert.AreEqual(new RRect(0, 0, ImageWidth, ImageHeight), image.DestRect); + } + + [TestMethod] + [DataRow("repeat", 0, 0, 100, 50)] + [DataRow("repeat-x", 0, 0, 100, ImageHeight)] + [DataRow("repeat-y", 0, 0, ImageWidth, 50)] + [Spec("css21", "colors.html#background-properties")] + public void BackgroundRepeat_TilesAlongTheGivenAxes(string repeat, double x, double y, double width, double height) + { + var g = PaintBackground($"width:100px;height:50px;background-image:{Image};background-repeat:{repeat}"); + + // Tiling paints the area with a texture brush, which RecordingGraphics records with an empty color + var tiles = g.Log.OfType().Single(r => r.Color == RColor.Empty); + Assert.AreEqual(x, tiles.X, 0.5); + Assert.AreEqual(y, tiles.Y, 0.5); + Assert.AreEqual(width, tiles.Width, 0.5); + Assert.AreEqual(height, tiles.Height, 0.5); + Assert.IsFalse(g.DrawImageCalls.Any()); + } + + [TestMethod] + [Spec("css21", "colors.html#background-properties")] + public void BackgroundRepeat_TilingCoversTheBorderArea() + { + var g = PaintBackground($"width:100px;height:50px;border:5px solid black;background-image:{Image}"); + + var tiles = g.Log.OfType().Single(r => r.Color == RColor.Empty); + Assert.AreEqual(new RRect(0, 0, 110, 60), new RRect(tiles.X, tiles.Y, tiles.Width, tiles.Height)); + } + + [TestMethod] + [DataRow("left top", 0, 0)] + [DataRow("right bottom", 60, 70)] + [DataRow("center", 30, 35)] + [DataRow("right", 60, 35)] + [DataRow("bottom", 30, 70)] + [DataRow("top center", 30, 0)] + [DataRow("0% 0%", 0, 0)] + [Spec("css21", "colors.html#background-properties")] + public void BackgroundPosition_Keywords(string position, double x, double y) + { + var g = PaintBackground($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-position:{position}"); + + var image = g.DrawImageCalls.Single(); + Assert.AreEqual(x, image.DestRect.X, 0.5); + Assert.AreEqual(y, image.DestRect.Y, 0.5); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [DataRow("50% 50%", 30, 35)] + [DataRow("100% 100%", 60, 70)] + [DataRow("25% 50%", 15, 35)] + [DataRow("10% 90%", 6, 63)] + [Spec("css21", "colors.html#background-properties")] + public void BackgroundPosition_Percentages(string position, double x, double y) + { + var g = PaintBackground($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-position:{position}"); + + var image = g.DrawImageCalls.Single(); + Assert.AreEqual(x, image.DestRect.X, 0.5); + Assert.AreEqual(y, image.DestRect.Y, 0.5); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [DataRow("10px 20px", 10, 20)] + [DataRow("-5px 7px", -5, 7)] + [DataRow("right 15px", 60, 15)] + [Spec("css21", "colors.html#background-properties")] + public void BackgroundPosition_Lengths(string position, double x, double y) + { + var g = PaintBackground($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-position:{position}"); + + var image = g.DrawImageCalls.Single(); + Assert.AreEqual(x, image.DestRect.X, 0.5); + Assert.AreEqual(y, image.DestRect.Y, 0.5); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "colors.html#background-properties")] + public void BackgroundPosition_IsRelativeToThePaddingBox() + { + var g = PaintBackground($"width:100px;height:100px;border:5px solid black;background-image:{Image};background-repeat:no-repeat"); + + var image = g.DrawImageCalls.Single(); + Assert.AreEqual(5, image.DestRect.X, 0.5); + Assert.AreEqual(5, image.DestRect.Y, 0.5); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "colors.html#background-properties")] + public void BackgroundAttachmentFixed_PositionsTheImageAgainstTheViewport() + { + var g = PaintBackground($"margin-top:100px;width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-attachment:fixed"); + + var image = g.DrawImageCalls.Single(); + Assert.AreEqual(0, image.DestRect.Y, 0.5); + } + + [TestMethod] + [Spec("css21", "colors.html#background-properties")] + public void BackgroundShorthand_SetsAllGivenLonghands() + { + var g = PaintBackground($"width:100px;height:100px;background:rgb(10,20,30) {Image} no-repeat right bottom"); + + Assert.AreEqual(1, Fills(g).Count); + var image = g.DrawImageCalls.Single(); + Assert.AreEqual(60, image.DestRect.X, 0.5); + Assert.AreEqual(70, image.DestRect.Y, 0.5); + } + + [TestMethod] + [Spec("css21", "colors.html#background-properties")] + public void BackgroundShorthand_ResetsOmittedLonghandsToTheirInitialValues() + { + var g = PaintBackground($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background:rgb(10,20,30)"); + + Assert.AreEqual(1, Fills(g).Count); + Assert.IsFalse(g.DrawImageCalls.Any()); + Assert.IsFalse(g.Log.OfType().Any(r => r.Color == RColor.Empty)); + } + + private static List Fills(RecordingGraphics g) => + g.Log.OfType().Where(r => r.Color == Fill).ToList(); + + /// Lays out a div with at the origin, loading images synchronously, and paints it. + private static RecordingGraphics PaintBackground(string style) + { + var container = new HtmlContainerInt(new MockAdapter()) + { + MaxSize = new RSize(1000, 4000), + Location = RPoint.Empty, + PageSize = new RSize(1000, 4000), + AvoidAsyncImagesLoading = true + }; + container.SetHtml(PaintHarness.Wrap($"
        ")).GetAwaiter().GetResult(); + + using (var layoutGraphics = new RecordingGraphics()) + container.PerformLayout(layoutGraphics); + + return PaintHarness.PaintBox(container, PaintHarness.FindById(container.Root!, "b")!); + } } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/ForegroundColorPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/ForegroundColorPaintIntegrationTests.cs new file mode 100644 index 000000000..684055d76 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/ForegroundColorPaintIntegrationTests.cs @@ -0,0 +1,47 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[TestClass] +public sealed class ForegroundColorPaintIntegrationTests +{ + [TestMethod] + [DataRow("rgb(10,20,30)")] + [DataRow("#0a141e")] + [Spec("css21", "colors.html#colors")] + public void Color_PaintsTheText(string color) + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + $"
        x
        ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + + Assert.AreEqual(RColor.FromArgb(10, 20, 30), g.DrawStringCalls.Single().Color); + } + + [TestMethod] + [Spec("css21", "colors.html#colors")] + public void Color_IsInherited() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "

        x

        ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + + Assert.AreEqual(RColor.FromArgb(10, 20, 30), g.DrawStringCalls.Single().Color); + } + + [TestMethod] + [Spec("css21", "colors.html#colors")] + public void Color_InitialValueIsBlack() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap("
        x
        ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + + Assert.AreEqual(RColor.Black, g.DrawStringCalls.Single().Color); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/BackgroundPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/BackgroundPropertyTests.cs index e09ba0cae..ca8791255 100644 --- a/Source/Test/HtmlRenderer.Test/Css/BackgroundPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/BackgroundPropertyTests.cs @@ -3,7 +3,6 @@ namespace HtmlRenderer.Test.Css; -/// Ported from PeachPDF.Tests/CSS/PropertyTests/BackgroundProperty.cs. Pure CSSOM parse tests - no layout involved. [TestClass] public sealed class BackgroundPropertyTests { @@ -874,6 +873,7 @@ public void BackgroundShorthandValues(string snippet, string expectedValue) } [TestMethod] + [Spec("css21", "colors.html#background-properties")] public void BackgroundShorthand_Color_SetsBackgroundColor() { var style = CssConstructionFunctions.ParseDeclarations("background: red"); @@ -894,6 +894,7 @@ public void BackgroundShorthand_Transparent_SetsBackgroundColorToTransparent() } [TestMethod] + [Spec("css21", "colors.html#background-properties")] public void BackgroundShorthand_ImageAndColor_SetsBothLonghands() { var style = CssConstructionFunctions.ParseDeclarations("background: white url(\"pendant.png\")"); @@ -904,6 +905,7 @@ public void BackgroundShorthand_ImageAndColor_SetsBothLonghands() } [TestMethod] + [Spec("css21", "colors.html#background-properties")] public void BackgroundShorthand_ImageRepeatAttachmentColor_SetsAllFourLonghands() { var style = CssConstructionFunctions.ParseDeclarations("background: url(\"topbanner.png\") #00d repeat-y fixed"); @@ -915,6 +917,7 @@ public void BackgroundShorthand_ImageRepeatAttachmentColor_SetsAllFourLonghands( } [TestMethod] + [Spec("css21", "colors.html#background-properties")] public void BackgroundShorthand_ImageNoRepeatPosition_SetsImageRepeatPosition() { var style = CssConstructionFunctions.ParseDeclarations("background: url(\"img_tree.png\") no-repeat right top"); @@ -945,10 +948,7 @@ public void BackgroundShorthand_ImagePositionSize_SetsPositionAndSize() [TestMethod] public void BackgroundShorthand_MultipleLayers_ExtractsCommaJoinedNotSpaceJoinedLonghands() { - // Regression test: EndListValueConverter used to join per-layer longhand values with a - // space instead of a comma when extracted from a multi-layer `background` shorthand, - // silently corrupting them (e.g. "no-repeat repeat-x" looks like a single valid - // two-axis repeat value, not two layers). + // Layers are joined with a comma, "no-repeat repeat-x" would read as one two-axis value var style = CssConstructionFunctions.ParseDeclarations( "background: url(\"a.png\") top no-repeat, url(\"b.png\") bottom repeat-x"); diff --git a/Spec/css21.yml b/Spec/css21.yml index 2c1e3d372..59d6a5982 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -745,16 +745,23 @@ sections: - "@page declarations aren't cascaded, nothing reads them" colors.html#q14.0: title: 14 Colors and Backgrounds - status: unknown + status: partial colors.html#colors: title: 14.1 Foreground color - status: unknown + status: supported colors.html#background: title: 14.2 The background - status: unknown + status: partial + gaps: + - The root element's background only covers the root box, not the whole canvas + - The body's background isn't propagated to the canvas when the root has none, the body paints it itself colors.html#background-properties: title: 14.2.1 Background properties - status: unknown + status: partial + gaps: + - "'background-position' only resolves keywords, a percentage or length anchors to the left or top edge if it contains the digit 0 and is centered otherwise" + - "'background-position' is relative to the border box instead of the padding box" + - "'background-attachment: fixed' is ignored, the image scrolls with the element" fonts.html#q15.0: title: 15 Fonts status: unknown From 7fade9e80bfbf12d160524380982406565306663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:13:25 +0100 Subject: [PATCH 020/150] Verify and document CSS 2.1 fonts compliance --- .../FontResolution/FontFamilyModel.cs | 17 +- .../FontResolution/FontResolver.cs | 30 +- .../HtmlRenderer/Core/Dom/CssBoxProperties.cs | 19 +- .../Core/Handlers/FontsHandler.cs | 104 ++-- Source/HtmlRenderer/Core/Parse/CssParser.cs | 9 +- .../HtmlRenderer/Core/Utils/CssConstants.cs | 2 +- .../Core/Utils/CssFontWeightResolver.cs | 11 +- Source/HtmlRenderer/Core/Utils/CssUtils.cs | 6 - .../Text/FontIntegrationTests.cs | 449 ++++++++++++++++++ .../Text/SmallCapsIntegrationTests.cs | 70 +-- .../Css/FontFamilyPropertyTests.cs | 26 +- .../Css/FontKeywordPropertyTests.cs | 57 +-- .../Css/FontShorthandPropertyTests.cs | 75 +-- Spec/css21.yml | 51 +- 14 files changed, 597 insertions(+), 329 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Text/FontIntegrationTests.cs diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/FontFamilyModel.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/FontFamilyModel.cs index f8af02288..f01110b3d 100644 --- a/Source/HtmlRenderer.PdfSharp/FontResolution/FontFamilyModel.cs +++ b/Source/HtmlRenderer.PdfSharp/FontResolution/FontFamilyModel.cs @@ -5,18 +5,11 @@ namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution { /// - /// One registered face of a : its CSS Fonts Level 4 matching axes - /// (numeric weight, italic, stretch), the codepoint s it is restricted to - /// (from an @font-face unicode-range descriptor) or null when it has none - a face with - /// no explicit ranges is treated as covering everything (matching the same, deliberately simpler, - /// scope as the shared Core.Handlers.FontsHandler face registry - see its own doc comments for - /// why cmap-based coverage extraction isn't ported here: this codebase resolves one font per box, not - /// per glyph, so there is no per-glyph precision to exploit) - and the sniffed description - /// hands back once this face is - /// chosen; doubles as this face's storage - /// key into . + /// One registered face of a : its CSS Fonts 4 matching axes, its + /// unicode-range or null when it has none (then it covers everything), and its description. + /// is the face's key into + /// . /// - /// Ported from PeachPDF's Fonts\FontFamilyModel.cs. internal sealed class FontFaceEntry { public FontFaceEntry(int weight, bool italic, int stretch, IReadOnlyList explicitRanges, TtfFontDescription description) @@ -35,7 +28,7 @@ public FontFaceEntry(int weight, bool italic, int stretch, IReadOnlyListOne CSS font-family: its registered faces. Ported from PeachPDF's Fonts\FontFamilyModel.cs. + /// One CSS font-family: its registered faces. internal sealed class FontFamilyModel { public string Name { get; set; } diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/FontResolver.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/FontResolver.cs index 45187f3a5..5460c1a7d 100644 --- a/Source/HtmlRenderer.PdfSharp/FontResolution/FontResolver.cs +++ b/Source/HtmlRenderer.PdfSharp/FontResolution/FontResolver.cs @@ -14,32 +14,14 @@ namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution { /// - /// PDFsharp implementation backing the PdfSharp backend's font handling - - /// OS font discovery plus CSS Fonts Level 4 §5 nearest-match (slant→stretch→weight) face selection, - /// ported from PeachPDF's Fonts\FontResolver.cs. Unlike WinForms/WPF (which register an - /// @font-face face as an opaque platform font-family handle and let the OS/UI framework do - /// matching), PDFsharp's contract needs raw font bytes at PDF-generation - /// time for embedding, so this type does its own matching entirely and is called directly by - /// Adapters.PdfSharpAdapter's AddFontFace/GetFont overrides, bypassing the shared - /// Core.Handlers.FontsHandler registry other backends use. + /// PDFsharp of the PdfSharp backend: system font discovery and + /// CSS Fonts 4 §5 face matching (slant, stretch, weight). PDFsharp needs the font bytes for embedding, + /// so this does its own matching instead of going through Core.Handlers.FontsHandler. /// /// - /// Two deliberate scope departures from PeachPDF's own resolver, both already established for this - /// port's shared Core.Handlers.FontsHandler (see its own doc comments): - /// - /// No cmap-coverage extraction fallback for unicode-range-less faces - a face with no - /// explicit unicode-range is simply treated as covering everything. This codebase resolves one - /// font per box (not per glyph/run), so there's no per-glyph precision for cmap coverage to buy. - /// No per-FontResolver-instance glyph-typeface/descriptor caching. PeachPDF supports many - /// concurrent PdfGenerators each with their own FontResolver, so it isolates their custom - /// font caches from each other. This backend's PdfSharpAdapter (and this resolver with it) is a - /// single process-wide singleton via , matching this project's pre-existing - /// architecture - there's only ever one instance, so nothing to isolate. - /// - /// System font discovery itself is also adapted, not verbatim: no Android/iOS branches (this project's - /// netstandard2.0;net8.0 target framework list has no mobile leg to run them on), and Linux - /// discovery shells out to the fc-list CLI rather than P/Invoking libfontconfig.so.1 - /// directly (see 's own doc comment for why). + /// A face without unicode-range is assumed to cover everything, its glyphs aren't checked, + /// there's one font per box anyway. The resolver is a process-wide singleton (). + /// On Linux, fonts are found with the fc-list CLI, see . /// public sealed class FontResolver : IFontResolver { diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index dcfc14324..ec296f2f3 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs @@ -1646,9 +1646,8 @@ public RFont ActualParentFont } /// - /// Gets the box's own resolved CSS Fonts Level 4 numeric font-weight (1-1000) - cached and - /// computed lazily like , since bolder/lighter resolve - /// relative to the parent's own resolved weight (see ). + /// Gets the numeric font-weight, bolder and lighter are relative to the + /// parent's (CSS 2.1 §15.6). /// internal int ActualFontWeight { @@ -1756,10 +1755,7 @@ public RFont ActualFont _actualFont = GetCachedFont(FontFamily, fsize, st, weight, stretch, codepoint); if (_actualFont == null) { - // A codepoint-scoped miss (the family has @font-face faces registered, but none - // of them cover this box's first character) - fall back to the default font family - // rather than propagate null, matching the pre-existing "nothing matched" behavior - // for any other unresolvable font-family value. + // No @font-face face of the family covers the box's first character. _actualFont = GetCachedFont(CssConstants.DefaultFont, fsize, st, weight, stretch, null); } } @@ -1861,13 +1857,8 @@ public double ActualBorderSpacingVertical protected abstract CssBoxProperties GetParent(); /// - /// Gets the size of 1em in CSS pixels, per spec: an element's own computed font-size, not - /// the font's line-spacing metric (ascent+descent+leading), which is typically 15-30%+ - /// larger and would inflate every em-based margin/padding/line-height. - /// is created from a font-size already converted to points ('s - /// fontAdjust factor, 72/96, applied when the declared value is in CSS px), so its own - /// is in points too - convert back to CSS px (the unit - /// every other length in this engine resolves to) with the inverse, 96/72. + /// Gets the size of 1em in CSS pixels, the computed font-size (CSS 2.1 §15.7), not the font's + /// line spacing. is sized in points. /// /// public double GetEmHeight() diff --git a/Source/HtmlRenderer/Core/Handlers/FontsHandler.cs b/Source/HtmlRenderer/Core/Handlers/FontsHandler.cs index 7f0bebc0c..c26a279c5 100644 --- a/Source/HtmlRenderer/Core/Handlers/FontsHandler.cs +++ b/Source/HtmlRenderer/Core/Handlers/FontsHandler.cs @@ -46,13 +46,8 @@ public FontFaceEntry(int weight, bool italic, int stretch, IReadOnlyList - /// One registered family: either a legacy simple (the pre-existing - /// path - e.g. a WinForms demo's LoadCustomFonts, - /// unchanged behavior), or a list of @font-face-registered faces (new path), or both at once. - /// Named to match PeachPDF's Fonts\FontFamilyModel.cs, since it's the same "one family, its - /// registered faces" concept - extended here with the escape hatch - /// PeachPDF's PDF-only resolver has no equivalent of, since this registry is shared across backends - /// that still need the legacy single-family API to keep working unchanged. + /// One registered family: an installed or family + /// (), the faces registered by @font-face rules, or both. /// internal sealed class FontFamilyModel { @@ -84,16 +79,14 @@ internal sealed class FontsHandler private readonly Dictionary _existingFontFamilies = new Dictionary(StringComparer.InvariantCultureIgnoreCase); /// - /// cache of all the font used not to create same font again and again (legacy family-string path) + /// cache of all the font used not to create same font again and again /// private readonly Dictionary>> _fontsCache = new Dictionary>>(StringComparer.InvariantCultureIgnoreCase); /// - /// cache of fonts resolved through the @font-face matching path, keyed by everything that - /// can change which face gets chosen (family, size, weight, italic, stretch, and the codepoint used - /// for unicode-range disambiguation, -1 when none) - a wider key than - /// needs, since two requests with the same can still resolve to different - /// faces (e.g. weight 300 vs 600, both non-bold). + /// cache of fonts resolved from @font-face faces, keyed by everything that can change which + /// face gets chosen (codepoint -1 when none). Two requests with the same can + /// still resolve to different faces, e.g. weight 300 and 500. /// private readonly Dictionary> _faceFontsCache = new Dictionary>(StringComparer.InvariantCultureIgnoreCase); @@ -130,13 +123,9 @@ public bool IsFontExists(string family) } /// - /// Finds an already-registered family's representative by name - a - /// system font, an registration, or an earlier - /// registration (its first face, arbitrarily - this is used to satisfy an @font-face - /// src: local(...) candidate, which only needs *a* face to exist under that name, not a - /// specific one). Returns null if no family is registered under that exact name (mapping via - /// is deliberately not consulted here - local() refers to - /// an actual installed/registered font by its own name, not this renderer's not-found fallback). + /// Finds a registered family by its exact name, for @font-face src: local(...), which + /// only needs some face to exist under that name. Mappings from + /// aren't consulted, local() names an actual font. Returns null if there's none. /// public RFontFamily TryGetExistingFamily(string family) { @@ -214,9 +203,7 @@ public void AddFontFace(string familyName, RFontFamily fontFamily, int weight, b model.Faces.Add(new FontFaceEntry(weight, isItalic, stretch, ranges, fontFamily)); - // Faces changed under this family name - any font already resolved/cached against the old face - // set (legacy or @font-face path alike) could now be stale (a re-registration can change which - // face is nearest for a given request). + // A new face can change which face is nearest for a request. _fontsCache.Remove(familyName); _faceFontsCache.Remove(familyName); } @@ -256,12 +243,10 @@ public RFont GetCachedFont(string family, double size, RFontStyle style) } /// - /// Get cached font instance for the given font properties, matching against any @font-face - /// faces registered for via using the CSS - /// Fonts Level 4 §5 nearest-match algorithm (). Falls through to - /// the legacy path when the family has no - /// registered faces (the common case - no @font-face for this family), so existing - /// system-font/ behavior is unaffected. + /// Get cached font instance for the given font properties, matching against the @font-face + /// faces registered for (CSS Fonts 4 §5, ). + /// A family without such faces goes through , + /// where the platform picks the face from the (CSS 2.1 §15.2). /// /// the css font-family name /// the font size @@ -271,9 +256,7 @@ public RFont GetCachedFont(string family, double size, RFontStyle style) /// the box's first non-whitespace character's codepoint, for unicode-range face disambiguation, or null to skip it /// /// the resolved font, or null when is given and no registered face - /// covers it (a real "this family can't render this character" outcome - see the unicode-range - /// scope note on - the caller falls back to the box's next - /// candidate/default font, matching today's existing "nothing matched" behavior) + /// covers it, the caller then falls back to the default font /// public RFont GetCachedFont(string family, double size, RFontStyle style, int weight, int stretch, int? codepoint) { @@ -290,14 +273,11 @@ public RFont GetCachedFont(string family, double size, RFontStyle style, int wei if (codepoint != null) { - // A codepoint-scoped miss must not substitute an arbitrary non-covering face - report - // it so the caller (CssBoxProperties.ActualFont) falls back to the box's next candidate - // family / CssConstants.DefaultFont, matching today's existing "nothing matched" behavior. + // Don't substitute a face that doesn't cover the character. return null; } - // Codepoint-less miss shouldn't happen (AddFontFace always leaves >=1 face registered), but - // degrade to the legacy path rather than throw if it ever does. + // Can't happen, AddFontFace always leaves a face registered. } return GetCachedFont(family, size, style); @@ -307,11 +287,8 @@ public RFont GetCachedFont(string family, double size, RFontStyle style, int wei #region Private methods /// - /// Resolves the chosen 's actual , computing the - /// residual - only the gap between what was requested and what was - /// actually found (e.g. faux-bold when no true bold face exists), plus the box's own - /// underline/strikeout bits, which apply regardless of which face was chosen - and caching by - /// every axis that could change the result. + /// Creates the chosen 's , synthesizing bold or italic + /// when the face lacks what was requested. /// private RFont GetCachedFaceFont(string family, FontFaceEntry face, double size, RFontStyle requestedStyle, int requestedWeight, bool requestedItalic, int requestedStretch, int codepointKey) { @@ -330,20 +307,10 @@ private RFont GetCachedFaceFont(string family, FontFaceEntry face, double size, return font; } - // Every backend's face.FontFamily is a FAMILY handle, not distinct per-face content - WinForms' - // PrivateFontCollection and WPF's AddFontMemResourceEx-registered FontFamily both group every - // face of one family (Regular/Bold/Italic/BoldItalic) under one platform family object, and - // rely on the RFontStyle passed to CreateFont to pick the actual glyphs at that point (exactly - // like a plain installed font). So the requested style must always carry the CHOSEN face's own - // weight/italic bits (faceIsBold/face.Italic below), not just a synthesis gap - dropping them - // on an exact match would ask the platform for the wrong (regular) face despite having found - // and registered the real bold/italic one. - // - // Synthesis on top of that: the chosen face may still be a compromise (nearest-weight/slant - // match, not exact) - decide whether the gap is large enough that faux-bold/italic should also - // kick in, so e.g. "font-weight: bold" against a Regular-only family doesn't render with zero - // visual distinction. Threshold mirrors the common UA convention that weights >=600 read as - // "bold" and <600 don't. + // face.FontFamily is a platform family handle that groups all faces, the style passed to + // CreateFont picks the face. So the style carries the chosen face's own bold and italic bits, + // plus synthesized ones when the nearest face is lighter or upright. Weights from 600 count as + // bold, like the regular/bold matching of CSS 2.1 §15.6. var faceIsBold = face.Weight >= 600; var mustSimulateBold = requestedWeight >= 600 && !faceIsBold; var mustSimulateItalic = requestedItalic && !face.Italic; @@ -358,14 +325,10 @@ private RFont GetCachedFaceFont(string family, FontFaceEntry face, double size, } /// - /// CSS Fonts Level 4 §5 face matching, direct port of PeachPDF's FontResolver.TryFindNearestFace. - /// When is supplied, only faces whose explicit unicode-range - /// covers it are candidates (a face with no declared range is treated as covering everything - see - /// the class-level unicode-range scope note; unlike PeachPDF, there is no cmap-coverage fallback - /// for an unlabeled face, since per-box single-font resolution has no per-glyph text-shaping layer - /// to exploit that precision). Otherwise every face is a candidate. Within the candidates it - /// narrows italic/slant first, then stretch, then weight; an exact axis match short-circuits. - /// Returns false when no candidate face qualifies. + /// CSS Fonts 4 §5 face matching: italic first, then stretch, then weight. With a + /// , only faces whose unicode-range covers it are candidates. A + /// face without a unicode-range is assumed to cover everything, its glyphs aren't checked. + /// Returns false when no face qualifies. /// private static bool TryFindNearestFace(FontFamilyModel family, int weight, bool isItalic, int stretch, int? codepoint, out FontFaceEntry face) { @@ -401,8 +364,7 @@ private static bool TryFindNearestFace(FontFamilyModel family, int weight, bool /// /// Whether is used for : inside its declared - /// unicode-range if it has one, else it covers everything (see the unicode-range scope note - /// on ). + /// unicode-range if it has one, else it covers everything. /// private static bool FaceCovers(FontFaceEntry entry, int codepoint) { @@ -429,13 +391,9 @@ private static int PickNearestStretch(List availableStretches, int target) } /// - /// CSS Fonts Level 4 §5.2's nearest-weight search order (the standard browser algorithm): a target - /// in [400,500] searches upward to 500 first, then below the target, then above 500; a target - /// below 400 searches downward first, then upward; a target above 500 searches upward first, then - /// downward. must be non-empty and is assumed to NOT already - /// contain an exact match for (the caller checks that separately, since - /// an exact match also has to match the requested italic-ness, which this purely-numeric helper - /// doesn't know about). + /// CSS Fonts 4 §5.2 nearest weight, the CSS 2.1 §15.6 order extended to any number. + /// must be non-empty. Outside [400,500] it skips + /// itself, the caller handles exact matches. /// private static int PickNearestWeight(List availableWeights, int target) { diff --git a/Source/HtmlRenderer/Core/Parse/CssParser.cs b/Source/HtmlRenderer/Core/Parse/CssParser.cs index f78a420f6..18b3799ab 100644 --- a/Source/HtmlRenderer/Core/Parse/CssParser.cs +++ b/Source/HtmlRenderer/Core/Parse/CssParser.cs @@ -159,11 +159,10 @@ public CssImage ParseBackgroundImage(string value) } /// - /// Parse a "font-family" value - possibly a comma-separated fallback list, each candidate - /// possibly quoted (e.g. 'Helvetica Neue', Arial, sans-serif) - into the first - /// candidate that's actually installed/registered, or - /// if none of them are. Previously returned the literal string "inherit" as a not-found - /// sentinel, which was confusable with the real CSS inherit keyword downstream. + /// Resolves a "font-family" list to its first installed or mapped family, or + /// if there's none (CSS 2.1 §15.2). The choice is made + /// once per element, not per character. Quotes are stripped, so a quoted generic name matches + /// the generic family. /// /// the font-family value to parse /// parsed font-family value diff --git a/Source/HtmlRenderer/Core/Utils/CssConstants.cs b/Source/HtmlRenderer/Core/Utils/CssConstants.cs index d4d050ac6..3cab2fe85 100644 --- a/Source/HtmlRenderer/Core/Utils/CssConstants.cs +++ b/Source/HtmlRenderer/Core/Utils/CssConstants.cs @@ -189,7 +189,7 @@ internal static class CssConstants public const double FontSize = 11f; /// - /// Default font used for the generic 'serif' family + /// Font used when no family of 'font-family' is installed (CSS 2.1 §15.2) /// public const string DefaultFont = "Segoe UI"; } diff --git a/Source/HtmlRenderer/Core/Utils/CssFontWeightResolver.cs b/Source/HtmlRenderer/Core/Utils/CssFontWeightResolver.cs index dfc799a6a..f4b444384 100644 --- a/Source/HtmlRenderer/Core/Utils/CssFontWeightResolver.cs +++ b/Source/HtmlRenderer/Core/Utils/CssFontWeightResolver.cs @@ -3,10 +3,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Utils { /// - /// Resolves a box's cascaded font-weight value (normal/bold/bolder/ - /// lighter/a number) to a concrete CSS Fonts Level 4 numeric weight (1-1000), for - /// 's face-matching call into - /// . + /// Resolves a box's font-weight to a number (CSS 2.1 §15.6). /// internal static class CssFontWeightResolver { @@ -14,11 +11,7 @@ internal static class CssFontWeightResolver internal const int Bold = 700; /// - /// bolder/lighter are relative to the parent's resolved weight; true CSS2.1 defines - /// this via a lookup table over 9 named weight steps, not a fixed offset. A flat +/-300 clamped to - /// [1,1000] is a real, explicitly-simplified approximation of that table - close enough to move a - /// normal-weight (400) parent to bold (700) and back, without reproducing the full step table for - /// a CSS feature real-world content rarely nests more than one level deep. + /// Step of bolder and lighter, a fixed step instead of the table of CSS 2.1 §15.6. /// private const int RelativeStep = 300; diff --git a/Source/HtmlRenderer/Core/Utils/CssUtils.cs b/Source/HtmlRenderer/Core/Utils/CssUtils.cs index 1d8c0b437..09ee08f21 100644 --- a/Source/HtmlRenderer/Core/Utils/CssUtils.cs +++ b/Source/HtmlRenderer/Core/Utils/CssUtils.cs @@ -495,12 +495,6 @@ public static void SetPropertyValue(CssBox cssBox, string propName, string value cssBox.WordSpacing = value; break; case "font-family": - // The declared value may be a comma-separated fallback list and/or have quoted - // candidates (e.g. "'Helvetica Neue', Arial, sans-serif") - resolve it down to the - // first candidate that's actually installed, same as the legacy - // attribute already does, rather than handing the literal, possibly-quoted CSS - // text straight to font creation (which fails to match any real font by name and - // silently falls back to a generic system font for every unresolved candidate). cssBox.FontFamily = cssBox.HtmlContainer.CssParser.ParseFontFamily(value); break; case "font-size": diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/FontIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/FontIntegrationTests.cs new file mode 100644 index 000000000..9a258716a --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/FontIntegrationTests.cs @@ -0,0 +1,449 @@ +using System.Drawing; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.WinForms.Adapters; + +namespace HtmlRenderer.IntegrationTest.Text; + +[DoNotParallelize] +[TestClass] +public sealed class FontIntegrationTests +{ + private const double Delta = 0.01; + + // 16px in points, the font size of the elements in most tests. + private const double SixteenPx = 12; + + [TestMethod] + [Spec("css21", "fonts.html#algorithm")] + public void FirstInstalledFamily_IsUsed() + { + var font = FontOf("x"); + + Assert.AreEqual("Georgia", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#algorithm")] + public void NoInstalledFamily_FallsBackToTheDefaultFont() + { + var font = FontOf("x"); + + Assert.AreEqual("Segoe UI", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#algorithm")] + public void FontSize_IsMatchedExactly() + { + var font = FontOf("x"); + + Assert.AreEqual(17 * 72d / 96d, font.Size, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "fonts.html#algorithm")] + public void FamilyWithoutTheGlyph_FallsBackToTheNextFamily() + { + var font = FontOf("\U0001F600"); + + Assert.AreEqual("Segoe UI Emoji", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] + public void QuotedFamilyName_IsMatched() + { + var font = FontOf("x"); + + Assert.AreEqual("Times New Roman", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] + public void UnquotedIdentifierSequence_IsJoinedIntoOneName() + { + var font = FontOf("x"); + + Assert.AreEqual("Times New Roman", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] + public void FontFamily_IsInherited() + { + var font = FontOf("
        x
        "); + + Assert.AreEqual("Georgia", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] + public void InvalidFamilyList_IsIgnored() + { + var font = FontOf("
        x
        "); + + Assert.AreEqual("Georgia", font.Name); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] + public void QuotedGenericName_IsAFamilyNameNotTheKeyword() + { + var font = FontOf("x"); + + Assert.AreNotEqual("Courier New", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#sans-serif-def")] + public void SansSerif_MapsToASansSerifFont() + { + var font = FontOf("x"); + + CollectionAssert.Contains(new[] { "Segoe UI", "Arial", "Tahoma", "Verdana" }, font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#monospace-def")] + public void Monospace_MapsToAMonospaceFont() + { + var font = FontOf("x"); + + Assert.AreEqual("Courier New", font.Name); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "fonts.html#serif-def")] + public void Serif_MapsToASerifFont() + { + var font = FontOf("x"); + + CollectionAssert.Contains(new[] { "Times New Roman", "Georgia", "Cambria" }, font.Name); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "fonts.html#cursive-def")] + public void Cursive_MapsToACursiveFont() + { + var font = FontOf("x"); + + CollectionAssert.Contains(new[] { "Comic Sans MS", "Segoe Script", "Segoe Print", "Gabriola" }, font.Name); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "fonts.html#fantasy-def")] + public void Fantasy_MapsToAFantasyFont() + { + var font = FontOf("x"); + + CollectionAssert.Contains(new[] { "Impact", "Jokerman", "Papyrus", "Gabriola" }, font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#generic-font-families")] + public void GenericFamily_IsUsedAsTheLastAlternative() + { + var font = FontOf("x"); + + Assert.AreEqual("Courier New", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-styling")] + [DataRow("italic")] + [DataRow("oblique")] + public void ItalicAndOblique_SelectASlantedFace(string style) + { + var font = FontOf($"x"); + + Assert.IsTrue(font.Italic); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-styling")] + public void Normal_InsideItalic_SelectsTheUprightFace() + { + var font = FontOf("
        x
        "); + + Assert.IsFalse(font.Italic); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-styling")] + public void FontStyle_IsInherited() + { + var font = FontOf("
        x
        "); + + Assert.IsTrue(font.Italic); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "fonts.html#small-caps")] + public void SmallCaps_RendersLowercaseLettersAsCapitals() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "hello")); + + var word = FirstWord(LayoutHarness.FindById(root, "s")!)!; + + Assert.IsFalse(word.Text.Any(char.IsLower), word.Text); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-boldness")] + [DataRow("100", false)] + [DataRow("300", false)] + [DataRow("normal", false)] + [DataRow("400", false)] + [DataRow("500", false)] + [DataRow("600", true)] + [DataRow("bold", true)] + [DataRow("700", true)] + [DataRow("900", true)] + public void Weight_SelectsTheNearestFaceOfARegularAndBoldFamily(string weight, bool bold) + { + var font = FontOf($"x"); + + Assert.AreEqual(bold, font.Bold); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-boldness")] + public void Bolder_OnANormalParent_IsBold() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
        x
        ")); + var s = LayoutHarness.FindById(root, "s")!; + + Assert.AreEqual(700, s.ActualFontWeight); + Assert.IsTrue(Font(s).Bold); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-boldness")] + public void Lighter_OnABoldParent_IsNormal() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
        x
        ")); + var s = LayoutHarness.FindById(root, "s")!; + + Assert.AreEqual(400, s.ActualFontWeight); + Assert.IsFalse(Font(s).Bold); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "fonts.html#font-boldness")] + [DataRow(100, 400, 100)] + [DataRow(200, 400, 100)] + [DataRow(300, 400, 100)] + [DataRow(500, 700, 100)] + [DataRow(600, 900, 400)] + [DataRow(700, 900, 400)] + [DataRow(800, 900, 700)] + [DataRow(900, 900, 700)] + public void BolderAndLighter_FollowTheTable(int inherited, int bolder, int lighter) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + $"
        xx
        ")); + + Assert.AreEqual(bolder, LayoutHarness.FindById(root, "b")!.ActualFontWeight); + Assert.AreEqual(lighter, LayoutHarness.FindById(root, "l")!.ActualFontWeight); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "fonts.html#font-boldness")] + public void Bolder_IsInheritedAsTheComputedWeight() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
        x
        ")); + + Assert.AreEqual(700, LayoutHarness.FindById(root, "s")!.ActualFontWeight); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-boldness")] + public void FontWeight_IsInherited() + { + var font = FontOf("
        x
        "); + + Assert.IsTrue(font.Bold); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + public void AbsoluteSizeKeywords_GrowFromXxSmallToXxLarge() + { + string[] keywords = ["xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large"]; + var body = string.Concat(keywords.Select(k => $"
        x
        ")); + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(body)); + + var sizes = keywords.Select(k => LayoutHarness.FindById(root, k)!.ActualFont.Size).ToArray(); + + for (var i = 1; i < sizes.Length; i++) + Assert.IsGreaterThan(sizes[i - 1], sizes[i], keywords[i]); + Assert.IsGreaterThanOrEqualTo(9 * 72d / 96d, sizes[0]); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + public void Medium_IsTheInitialSize() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
        x
        x
        ")); + + Assert.AreEqual(LayoutHarness.FindById(root, "i")!.ActualFont.Size, LayoutHarness.FindById(root, "m")!.ActualFont.Size, Delta); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + public void LargerAndSmaller_OnAMediumParent_StepOneKeyword() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
        xx
        " + + "
        x
        x
        ")); + + Assert.AreEqual(LayoutHarness.FindById(root, "large")!.ActualFont.Size, LayoutHarness.FindById(root, "larger")!.ActualFont.Size, Delta); + Assert.AreEqual(LayoutHarness.FindById(root, "small")!.ActualFont.Size, LayoutHarness.FindById(root, "smaller")!.ActualFont.Size, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + public void Larger_IsInheritedAsTheComputedSize() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
        x
        ")); + + Assert.AreEqual(LayoutHarness.FindById(root, "p")!.ActualFont.Size, LayoutHarness.FindById(root, "s")!.ActualFont.Size, Delta); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + public void Percentage_RefersToTheParentSize() + { + var font = FontOf("
        x
        "); + + Assert.AreEqual(30 * 72d / 96d, font.Size, Delta); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + public void Em_RefersToTheParentSize() + { + var font = FontOf("
        x
        "); + + Assert.AreEqual(30 * 72d / 96d, font.Size, Delta); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + public void Length_IsUsedAsIs() + { + var font = FontOf("x"); + + Assert.AreEqual(12, font.Size, Delta); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + public void FontSize_IsInherited() + { + var font = FontOf("
        x
        "); + + Assert.AreEqual(SixteenPx, font.Size, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + public void NegativeSize_IsIgnored() + { + var font = FontOf("
        x
        "); + + Assert.AreEqual(SixteenPx, font.Size, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + public void TinySize_IsKept() + { + var font = FontOf("x"); + + Assert.AreEqual(0.75, font.Size, Delta); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] + public void Shorthand_SetsStyleWeightSizeAndFamily() + { + var font = FontOf("x"); + + Assert.AreEqual("Georgia", font.Name); + Assert.IsTrue(font.Bold); + Assert.IsTrue(font.Italic); + Assert.AreEqual(SixteenPx, font.Size, Delta); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] + public void Shorthand_SetsTheLineHeight() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
        x
        x
        ")); + + Assert.AreEqual("40px", LayoutHarness.FindById(root, "shorthand")!.LineHeight); + Assert.AreEqual(LayoutHarness.FindById(root, "longhand")!.Size.Height, LayoutHarness.FindById(root, "shorthand")!.Size.Height, Delta); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] + public void Shorthand_ResetsOmittedPropertiesToTheirInitialValues() + { + var font = FontOf("
        x
        "); + + Assert.IsFalse(font.Bold); + Assert.IsFalse(font.Italic); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] + public void Shorthand_PercentageSizeRefersToTheParentSize() + { + var font = FontOf("
        x
        "); + + Assert.AreEqual(30 * 72d / 96d, font.Size, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] + public void SystemFont_SetsTheFontOfTheSystem() + { + var font = FontOf("
        x
        "); + var menu = SystemFonts.MenuFont!; + + Assert.AreEqual(menu.Name, font.Name); + Assert.AreEqual(menu.SizeInPoints, font.Size, 0.5); + } + + private static Font FontOf(string body) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(body)); + return Font(LayoutHarness.FindById(root, "s")!); + } + + private static Font Font(CssBox box) => ((FontAdapter)box.ActualFont).Font; + + private static CssRect? FirstWord(CssBox box) => + box.Words.Count > 0 ? box.Words[0] : box.Boxes.Select(FirstWord).FirstOrDefault(w => w != null); +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs index 2cabe0dd8..26a699135 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs @@ -4,51 +4,12 @@ namespace HtmlRenderer.IntegrationTest.Text; -/// -/// Regression coverage for font-variant: small-caps. Ported from PeachPDF's -/// SmallCapsIntegrationTests, which covers a real small-caps synthesis pipeline (originally-lowercase -/// runs upper-cased and re-measured/painted at a reduced size via DerivedStyle.ActualSmallCapsFont, -/// exposed on CssRectWord via FontSizeScale/SuppressWrapBefore, and a dedicated -/// RecordingGraphics paint harness asserting DrawString call order/fonts). -/// -/// -/// HTML-Renderer has none of that: font-variant IS parsed/stored as a plain string property -/// (, default "normal", inherited - recognized via both the -/// standalone font-variant property and the font shorthand regex in -/// CssParser.ParseFontProperty), but a full-tree grep across CssLayoutEngine.cs and the -/// font/paint code (ActualFont, RFontStyle construction) found zero non-storage read sites - -/// it is a complete no-op beyond storage. There is also no font-variant-caps/all-small-caps -/// support at all (not a recognized property name anywhere in this fork). -/// -/// Confirmed by direct execution against the built assembly (not just source reading): laying out -/// <b style="font-variant:small-caps">Hello</b> leaves the box with a single, unsplit -/// "Hello" word - the same as with no font-variant at all. -/// -/// -/// Because this fork has no equivalent of FontSizeScale/SmallCapsFontScale/ -/// ActualSmallCapsFont/SuppressWrapBefore (confirmed absent by grep - not merely unused, the -/// members do not exist), PeachPDF's scale/measured-width/wrap-suppression/space-flag-on-fragment/paint-call -/// tests have no faithful, compilable equivalent here and are intentionally not ported (porting a test file -/// cannot invent new production API surface). What IS ported below is: (a) a parse/storage check, since that -/// part of the property genuinely still works, and (b) the word-splitting expectation itself - the one -/// observable signal shared by every PeachPDF case - both for real small-caps and for the (unsupported) -/// all-small-caps spelling. -/// -/// -// This fork's CssParser keeps a process-wide, non-thread-safe regex cache -// (RegexParserUtils.GetRegex's static Dictionary) that SetHtml/DefaultCssData populate lazily on first use per -// AppDomain; running HtmlContainerInt.SetHtml from more than one thread at once (as MSTestSettings.cs's -// assembly-wide [Parallelize(Scope = ExecutionScope.MethodLevel)] does by default) can corrupt it and throw -// "A concurrent update was performed on this collection". [DoNotParallelize] avoids tripping that pre-existing -// library race rather than masking it. +// The regex cache in RegexParserUtils isn't thread-safe, parallel SetHtml calls can corrupt it. [DoNotParallelize] [TestClass] public sealed class SmallCapsIntegrationTests { - /// Finds the box that actually owns the word(s): text nodes get their own anonymous child - /// (DomParser.CorrectTextBoxes), so an element like - /// <b id="w">Hello</b>'s own box has an empty - the words - /// live on its single anonymous text child instead. + /// Text gets an anonymous child box, the element's own box has no words. private static CssBox FindWordsBox(CssBox root, string id) { var element = LayoutHarness.FindById(root, id)!; @@ -60,6 +21,7 @@ private static CssBox FindWordsBox(CssBox root, string id) } [TestMethod] + [Spec("css21", "fonts.html#small-caps")] public void FontVariant_SmallCaps_StandaloneProperty_ParsesAndStores() { var (root, _) = LayoutHarness.Layout( @@ -70,6 +32,7 @@ public void FontVariant_SmallCaps_StandaloneProperty_ParsesAndStores() } [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] public void FontVariant_SmallCaps_ViaFontShorthand_ParsesAndStores() { var (root, _) = LayoutHarness.Layout( @@ -79,18 +42,16 @@ public void FontVariant_SmallCaps_ViaFontShorthand_ParsesAndStores() Assert.AreEqual("small-caps", w.FontVariant); } - [Ignore("HTML-Renderer's font-variant is storage-only - CssBox.FontVariant is set but never read anywhere " + - "in layout or paint (confirmed by grep and by direct execution: the word stays a single unsplit " + - "'Hello', not split into 'H' + 'ELLO' the way PeachPDF's synthesis pipeline produces). Real word " + - "splitting/scaling is out of scope for this fork; see class remarks.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "fonts.html#small-caps")] public void SmallCaps_SplitsWordIntoCaseRuns() { var (root, _) = LayoutHarness.Layout( LayoutHarness.Wrap("Hello")); var box = FindWordsBox(root, "w"); - // "Hello" -> "H" (already upper) + "ELLO" (synthesized small-caps run), per PeachPDF's real behavior. + // "ELLO" is the synthesized small-caps run Assert.AreEqual(2, box.Words.Count); Assert.AreEqual("H", box.Words[0].Text); Assert.AreEqual("ELLO", box.Words[1].Text); @@ -117,17 +78,11 @@ public void SmallCaps_WordWithNoLowercaseLetters_IsNotSplit() Assert.AreEqual("ABC", box.Words[0].Text); } - // ─── font-variant-caps / all-small-caps: not a recognized property name anywhere in this fork (only the - // standalone "font-variant" property and its "normal|small-caps" values are wired up), so these always - // behave identically to plain unset font-variant - confirmed via grep, no case in CssUtils's property - // switch (get or set) mentions "font-variant-caps" at all. ──────────────────────────────────────────── + // font-variant-caps (CSS Fonts 3) isn't supported, it's ignored. [TestMethod] public void AllSmallCaps_WordWithNoLowercaseLetters_WordStaysIntact() { - // PeachPDF: font-variant-caps:all-small-caps shrinks an already-uppercase word (c2sc approximation). - // Here font-variant-caps isn't a recognized property at all, so this reduces to a plain unsplit-word - // regression check - it is not exercising any shrinking behavior. var (root, _) = LayoutHarness.Layout( LayoutHarness.Wrap("ABC")); var box = FindWordsBox(root, "w"); @@ -147,10 +102,7 @@ public void AllSmallCaps_WordWithNoLetters_IsNotSplit() Assert.AreEqual("123", box.Words[0].Text); } - [Ignore("font-variant-caps isn't a recognized property in this fork (grep-confirmed absent from CssUtils's " + - "property switch), so there is no c2sc/small-caps approximation to split a mixed-case word into " + - "upper/lower runs - the word stays a single unsplit 'AbC', not the three runs " + - "('A','B','C') PeachPDF's synthesis produces.")] + [Ignore("font-variant-caps isn't supported")] [TestMethod] public void AllSmallCaps_MixedCaseWord_WouldSplitIntoThreeRuns() { @@ -164,9 +116,7 @@ public void AllSmallCaps_MixedCaseWord_WouldSplitIntoThreeRuns() Assert.AreEqual("C", box.Words[2].Text); } - [Ignore("font-variant-caps isn't a recognized property in this fork, so there is no run-splitting at all - " + - "the word stays a single unsplit 'a1b', not the three runs ('A','1','B') PeachPDF's synthesis " + - "produces around the non-lowercase digit run.")] + [Ignore("font-variant-caps isn't supported")] [TestMethod] public void AllSmallCaps_DigitRunBetweenLowercaseRuns_WouldSplitIntoThreeRuns() { diff --git a/Source/Test/HtmlRenderer.Test/Css/FontFamilyPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontFamilyPropertyTests.cs index e37c49e70..47422be85 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontFamilyPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontFamilyPropertyTests.cs @@ -4,21 +4,11 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/FontProperty.cs (source class CssFontPropertyTests), the -/// font-family cases only. Exercises -/// (Source/HtmlRenderer/Core/CssEngine/StyleProperties/Font/FontFamilyProperty.cs), whose converter is -/// Converters.FontFamiliesConverter = DefaultFontFamiliesConverter.Or(StringConverter).Or(LiteralsConverter).FromList() -/// (Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs). Each comma-separated family in the list must be -/// either a generic keyword recognized by Map.DefaultFontFamilies (serif/sans-serif/monospace/ -/// cursive/fantasy - Map.cs), a quoted <string>, or a whitespace-joined run of bare <ident> tokens -/// (ValueExtensions.ToLiterals) - never a mix of a string and a bare ident in the same entry, and never -/// a numeric/hash/at/delimiter token anywhere in an unquoted entry. -/// [TestClass] public sealed class FontFamilyPropertyTests { [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] public void CssFontFamilyMultipleWithIdentifiersLegal() { var property = ParseDeclaration("font-family: Gill Sans Extrabold, sans-serif "); @@ -45,6 +35,7 @@ public void CssFontFamilyInitialLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] public void CssFontFamilyMultipleDiverseLegal() { var property = ParseDeclaration("font-family: Courier, \"Lucida Console\", monospace "); @@ -58,6 +49,7 @@ public void CssFontFamilyMultipleDiverseLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] public void CssFontFamilyMultipleStringLegal() { var property = ParseDeclaration("font-family: \"Goudy Bookletter 1911\", sans-serif "); @@ -71,10 +63,10 @@ public void CssFontFamilyMultipleStringLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] public void CssFontFamilyMultipleNumberIllegal() { - // "1911" is a Number token, not an Ident - ValueExtensions.ToLiterals requires every token in an - // unquoted family entry to be an Ident, so the whole list is rejected. + // "1911" is a number, an unquoted family name may only contain identifiers. var property = ParseDeclaration("font-family: Goudy Bookletter 1911, sans-serif "); Assert.AreEqual("font-family", property.Name); Assert.IsFalse(property.IsImportant); @@ -85,9 +77,9 @@ public void CssFontFamilyMultipleNumberIllegal() } [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] public void CssFontFamilyMultipleFractionIllegal() { - // The "/" delimiter between "Red" and "Black" breaks ToLiterals's whitespace-only-between-idents rule. var property = ParseDeclaration("font-family: Red/Black, sans-serif "); Assert.AreEqual("font-family", property.Name); Assert.IsFalse(property.IsImportant); @@ -98,9 +90,9 @@ public void CssFontFamilyMultipleFractionIllegal() } [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] public void CssFontFamilyMultipleStringMixedWithIdentifierIllegal() { - // A single family entry can be a string OR a run of idents, never both. var property = ParseDeclaration("font-family: \"Lucida\" Grande, sans-serif "); Assert.AreEqual("font-family", property.Name); Assert.IsFalse(property.IsImportant); @@ -111,6 +103,7 @@ public void CssFontFamilyMultipleStringMixedWithIdentifierIllegal() } [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] public void CssFontFamilyMultipleExclamationMarkIllegal() { var property = ParseDeclaration("font-family: Ahem!, sans-serif "); @@ -123,6 +116,7 @@ public void CssFontFamilyMultipleExclamationMarkIllegal() } [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] public void CssFontFamilyMultipleAtIllegal() { var property = ParseDeclaration("font-family: test@foo, sans-serif "); @@ -135,6 +129,7 @@ public void CssFontFamilyMultipleAtIllegal() } [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] public void CssFontFamilyHashIllegal() { var property = ParseDeclaration("font-family: #POUND "); @@ -147,6 +142,7 @@ public void CssFontFamilyHashIllegal() } [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] public void CssFontFamilyDashIllegal() { var property = ParseDeclaration("font-family: Hawaii 5-0 "); diff --git a/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs index 36cee0e28..c2887b1b8 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs @@ -5,22 +5,7 @@ namespace HtmlRenderer.Test.Css; /// -/// Ported from PeachPDF.Tests/CSS/FontProperty.cs (source class CssFontPropertyTests), the -/// standalone font-related keyword/value longhands: font-variant (base css2 grammar only - -/// see the class remarks below), font-style, font-size, font-weight, font-stretch, -/// letter-spacing, and font-size-adjust. All under -/// Source/HtmlRenderer/Core/CssEngine/StyleProperties/Font/. -/// -/// Not ported: PeachPDF's font-variant-ligatures, font-variant-caps, font-variant-numeric, -/// font-variant-east-asian, and font-feature-settings tests - none of those properties exist in -/// this fork (confirmed: no matching type under StyleProperties/Font/, no PropertyNames entries). For -/// the same reason, Converters.FontVariantConverter = Map.FontVariants.ToConverter() -/// (Converters.cs) only recognizes the original CSS2 two-keyword grammar - Map.FontVariants -/// (Model/Map.cs) maps just "normal" and "small-caps", not the CSS Fonts 4 font-variant-caps -/// seven-keyword grammar PeachPDF's own font-variant shorthand now also accepts standalone. So PeachPDF's -/// CssFontVariantNoneLegal, CssFontVariantCombinesCapsAndLigaturesAxesLegal, and -/// CssFontVariantShorthandAcceptsPreviouslyRejectedCapsKeywords (all-small-caps/petite-caps/etc.) are skipped -/// too - they'd need font-variant-caps/-ligatures support this fork doesn't have. +/// Parsing of the font longhands. font-variant only knows the CSS 2.1 keywords normal and small-caps. /// [TestClass] public sealed class FontKeywordPropertyTests @@ -28,6 +13,7 @@ public sealed class FontKeywordPropertyTests // ── font-variant (base keywords only) ──────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css21", "fonts.html#small-caps")] public void CssFontVariantNormalUppercaseLegal() { var property = ParseDeclaration("font-variant : NORMAL"); @@ -41,6 +27,7 @@ public void CssFontVariantNormalUppercaseLegal() } [TestMethod] + [Spec("css21", "fonts.html#small-caps")] public void CssFontVariantSmallCapsLegal() { var property = ParseDeclaration("font-variant : small-caps "); @@ -54,6 +41,7 @@ public void CssFontVariantSmallCapsLegal() } [TestMethod] + [Spec("css21", "fonts.html#small-caps")] public void CssFontVariantSmallCapsIllegal() { var property = ParseDeclaration("font-variant : smallCaps "); @@ -68,6 +56,7 @@ public void CssFontVariantSmallCapsIllegal() // ── font-style ──────────────────────────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css21", "fonts.html#font-styling")] public void CssFontStyleItalicLegal() { var property = ParseDeclaration("font-style : italic"); @@ -81,6 +70,7 @@ public void CssFontStyleItalicLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-styling")] public void CssFontStyleObliqueLegal() { var property = ParseDeclaration("font-style : oblique "); @@ -96,8 +86,7 @@ public void CssFontStyleObliqueLegal() [TestMethod] public void CssFontStyleObliqueWithAngleLegal() { - // CSS Fonts 4's "oblique " form - Converters.FontStyleConverter's second branch - // (Model/Converters.cs), a StartsWithValueConverter over AngleConverter. + // CSS Fonts 4 "oblique " var property = ParseDeclaration("font-style : oblique 10deg"); Assert.AreEqual("font-style", property.Name); Assert.IsFalse(property.IsImportant); @@ -122,6 +111,7 @@ public void CssFontStyleObliqueWithNegativeAngleLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-styling")] public void CssFontStyleNormalImportantLegal() { var property = ParseDeclaration("font-style : normal !important"); @@ -137,6 +127,7 @@ public void CssFontStyleNormalImportantLegal() // ── font-size ───────────────────────────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] public void CssFontSizeAbsoluteImportantXxSmallLegal() { var property = ParseDeclaration("font-size : xx-small !important"); @@ -150,6 +141,7 @@ public void CssFontSizeAbsoluteImportantXxSmallLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] public void CssFontSizeAbsoluteMediumUppercaseLegal() { var property = ParseDeclaration("font-size : medium"); @@ -163,6 +155,7 @@ public void CssFontSizeAbsoluteMediumUppercaseLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] public void CssFontSizeAbsoluteLargeImportantLegal() { var property = ParseDeclaration("font-size : large !important"); @@ -176,6 +169,7 @@ public void CssFontSizeAbsoluteLargeImportantLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] public void CssFontSizeRelativeLargerLegal() { var property = ParseDeclaration("font-size : larger "); @@ -189,9 +183,9 @@ public void CssFontSizeRelativeLargerLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] public void CssFontSizeRelativeLargestIllegal() { - // "largest" isn't a real CSS keyword (the relative pair is larger/smaller) - not in Map.FontSizes. var property = ParseDeclaration("font-size : largest "); Assert.AreEqual("font-size", property.Name); Assert.IsFalse(property.IsImportant); @@ -202,6 +196,7 @@ public void CssFontSizeRelativeLargestIllegal() } [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] public void CssFontSizePercentLegal() { var property = ParseDeclaration("font-size : 120% "); @@ -215,6 +210,7 @@ public void CssFontSizePercentLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] public void CssFontSizeZeroLegal() { var property = ParseDeclaration("font-size : 0 "); @@ -228,6 +224,7 @@ public void CssFontSizeZeroLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] public void CssFontSizeLengthLegal() { var property = ParseDeclaration("font-size : 3.5em "); @@ -241,10 +238,9 @@ public void CssFontSizeLengthLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] public void CssFontSizeNumberIllegal() { - // A bare non-zero number has no unit and isn't a percentage - font-size doesn't treat unitless - // numbers specially (that's line-height's job), so it's rejected. var property = ParseDeclaration("font-size : 120.3 "); Assert.AreEqual("font-size", property.Name); Assert.IsFalse(property.IsImportant); @@ -257,6 +253,7 @@ public void CssFontSizeNumberIllegal() // ── font-weight ─────────────────────────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css21", "fonts.html#font-boldness")] public void CssFontWeightPercentllegal() { var property = ParseDeclaration("font-weight : 100% "); @@ -269,6 +266,7 @@ public void CssFontWeightPercentllegal() } [TestMethod] + [Spec("css21", "fonts.html#font-boldness")] public void CssFontWeightBolderLegalImportant() { var property = ParseDeclaration("font-weight : bolder !important"); @@ -282,6 +280,7 @@ public void CssFontWeightBolderLegalImportant() } [TestMethod] + [Spec("css21", "fonts.html#font-boldness")] public void CssFontWeightBoldLegal() { var property = ParseDeclaration("font-weight : bold"); @@ -295,6 +294,7 @@ public void CssFontWeightBoldLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-boldness")] public void CssFontWeight400Legal() { var property = ParseDeclaration("font-weight : 400 "); @@ -308,12 +308,7 @@ public void CssFontWeight400Legal() } [TestMethod] - [Ignore("This fork's font-weight grammar hasn't picked up CSS Fonts 4's - " + - "ValueExtensions.ToWeightInteger (Extensions/ValueExtensions.cs) delegates to the private " + - "IsWeight(int) helper, which only accepts the nine exact multiples of 100 from 100 to 900 " + - "(the old CSS2.1 grammar), not an arbitrary integer in [1,1000]. PeachPDF's own port already " + - "closed this gap (issue #655, matching its CssFontWeight550Legal); this fork hasn't yet, so " + - "550 is currently rejected rather than accepted.")] + [Ignore("CSS Fonts 4 weights aren't parsed, only the CSS 2.1 multiples of 100")] public void CssFontWeight550Legal() { var property = ParseDeclaration("font-weight : 550 "); @@ -329,8 +324,6 @@ public void CssFontWeight550Legal() [TestMethod] public void CssFontWeight550IsCurrentlyIllegal() { - // Mirrors CSS Fonts 4's font-weight: 550 (see the [Ignore]d CssFontWeight550Legal above) - documents - // this fork's actual current behavior: rejected, because IsWeight only allows multiples of 100. var property = ParseDeclaration("font-weight : 550 "); Assert.AreEqual("font-weight", property.Name); Assert.IsFalse(property.IsImportant); @@ -341,6 +334,7 @@ public void CssFontWeight550IsCurrentlyIllegal() } [TestMethod] + [Spec("css21", "fonts.html#font-boldness")] public void CssFontWeightOutOfRangeIllegal() { var property = ParseDeclaration("font-weight : 1001 "); @@ -353,6 +347,7 @@ public void CssFontWeightOutOfRangeIllegal() } [TestMethod] + [Spec("css21", "fonts.html#font-boldness")] public void CssFontWeightZeroIllegal() { var property = ParseDeclaration("font-weight : 0 "); @@ -395,8 +390,6 @@ public void CssFontStretchExtraCondensedLegal() [TestMethod] public void CssFontStretchSemiExpandedSpaceBetweenIllegal() { - // "semi-expanded" must be a single ident token - the space breaks it into two tokens, and - // Map.FontStretches.ToConverter() (a DictionaryValueConverter) only matches a lone identifier. var property = ParseDeclaration("font-stretch : semi expanded "); Assert.AreEqual("font-stretch", property.Name); Assert.IsFalse(property.IsImportant); @@ -491,8 +484,6 @@ public void CssFontSizeAdjustNumberLegal() [TestMethod] public void CssFontSizeAdjustLengthIllegal() { - // font-size-adjust : Converters.OptionalNumberConverter.OrDefault() - a bare or "none", - // never a . var property = ParseDeclaration("font-size-adjust : 1.1em "); Assert.AreEqual("font-size-adjust", property.Name); Assert.IsFalse(property.IsImportant); diff --git a/Source/Test/HtmlRenderer.Test/Css/FontShorthandPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontShorthandPropertyTests.cs index 2b0ea4992..c1c20d055 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontShorthandPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontShorthandPropertyTests.cs @@ -4,24 +4,11 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/FontProperty.cs (source class CssFontPropertyTests), the font -/// shorthand cases. Exercises -/// (Source/HtmlRenderer/Core/CssEngine/StyleProperties/Font/FontProperty.cs), whose converter is -/// WithOrder(WithAny(style, variant, weight, stretch), WithOrder(size.Required(), -/// lineHeight.StartsWithDelimiter().Option(), family.Required())).Or(SystemFontConverter).OrGlobalValue() -/// (Model/Converters.cs) - so any subset of style/variant/weight/stretch may appear in any order before a -/// required size (optionally "/" line-height) and a required family list, or the whole value may instead be -/// one of the Map.SystemFonts keywords (caption/icon/menu/message-box/small-caption/status-bar) or a -/// CSS-wide keyword. PropertyFactory.CreateFont -/// (Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs) registers all seven longhands -/// (font-family, font-size, font-stretch, font-style, font-variant, font-weight, line-height) under this -/// shorthand, matching PeachPDF's own registration. -/// [TestClass] public sealed class FontShorthandPropertyTests { [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] public void CssFontShorthandWithFractionLegal() { var property = ParseDeclaration("font : 12px/14px sans-serif "); @@ -35,6 +22,7 @@ public void CssFontShorthandWithFractionLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] public void CssFontShorthandPercentLegal() { var property = ParseDeclaration("font : 80% sans-serif "); @@ -48,10 +36,10 @@ public void CssFontShorthandPercentLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] public void CssFontShorthandBoldItalicLargeLegal() { - // The WithAny(style, variant, weight, stretch) group re-serializes in its declared canonical order - // (style, variant, weight, stretch) regardless of input order - "bold italic" round-trips as "italic bold". + // Serialized in canonical order: style, variant, weight, stretch. var property = ParseDeclaration("font : bold italic large serif "); Assert.AreEqual("font", property.Name); Assert.IsFalse(property.IsImportant); @@ -63,6 +51,7 @@ public void CssFontShorthandBoldItalicLargeLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] public void CssFontShorthandPredefinedLegal() { var property = ParseDeclaration("font : status-bar "); @@ -76,6 +65,7 @@ public void CssFontShorthandPredefinedLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] public void CssFontShorthandSizeAndFontListLegal() { var property = ParseDeclaration("font : 15px arial,sans-serif "); @@ -89,6 +79,7 @@ public void CssFontShorthandSizeAndFontListLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] public void CssFontShorthandStyleWeightSizeLineHeightAndFontListLegal() { var property = ParseDeclaration("font : italic bold 12px/30px Georgia, serif"); @@ -102,6 +93,7 @@ public void CssFontShorthandStyleWeightSizeLineHeightAndFontListLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] public void CssFontSizeHeightFamilyLegal() { var property = ParseDeclaration("font: 12pt/14pt sans-serif "); @@ -115,23 +107,9 @@ public void CssFontSizeHeightFamilyLegal() } [TestMethod] - public void CssFontSizeFamilyLegal() - { - var property = ParseDeclaration("font: 80% sans-serif "); - Assert.AreEqual("font", property.Name); - Assert.IsFalse(property.IsImportant); - Assert.IsInstanceOfType(property); - var concrete = (FontProperty)property; - Assert.IsFalse(concrete.IsInherited); - Assert.IsTrue(concrete.HasValue); - Assert.AreEqual("80% sans-serif", concrete.Value); - } - - [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] public void CssFontSizeHeightMultipleFamiliesLegal() { - // Single-quoted family name input round-trips through the string converter as a double-quoted - // literal (StringValueConverter.CssText -> string.StylesheetString()). var property = ParseDeclaration("font: x-large/110% 'New Century Schoolbook', serif "); Assert.AreEqual("font", property.Name); Assert.IsFalse(property.IsImportant); @@ -143,6 +121,7 @@ public void CssFontSizeHeightMultipleFamiliesLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] public void CssFontWeightVariantSizeFamiliesLegal() { var property = ParseDeclaration("font: bold italic large Palatino, serif "); @@ -156,11 +135,9 @@ public void CssFontWeightVariantSizeFamiliesLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] public void CssFontStyleVariantSizeHeightFamilyLegal() { - // A generic family keyword (here "Fantasy") is stored and re-serialized as the literal lowercased - // keyword, not resolved to a concrete face - Converters.DefaultFontFamiliesConverter is a - // DictionaryValueConverter, whose EnumeratedValue.CssText is the matched identifier itself. var property = ParseDeclaration("font: normal small-caps 120%/120% Fantasy "); Assert.AreEqual("font", property.Name); Assert.IsFalse(property.IsImportant); @@ -174,8 +151,7 @@ public void CssFontStyleVariantSizeHeightFamilyLegal() [TestMethod] public void CssFontStyleVariantSizeFamiliesLegal() { - // font-stretch ("condensed") is part of the font shorthand's WithAny(style, variant, weight, stretch) - // group here too, matching PeachPDF. + // font-stretch in the shorthand is CSS Fonts 3. var property = ParseDeclaration("font: condensed oblique 12pt \"Helvetica Neue\", serif "); Assert.AreEqual("font", property.Name); Assert.IsFalse(property.IsImportant); @@ -186,19 +162,6 @@ public void CssFontStyleVariantSizeFamiliesLegal() Assert.AreEqual("oblique condensed 12pt \"Helvetica Neue\", serif", concrete.Value); } - [TestMethod] - public void CssFontSystemFamilyLegal() - { - var property = ParseDeclaration("font: status-bar "); - Assert.AreEqual("font", property.Name); - Assert.IsFalse(property.IsImportant); - Assert.IsInstanceOfType(property); - var concrete = (FontProperty)property; - Assert.IsFalse(concrete.IsInherited); - Assert.IsTrue(concrete.HasValue); - Assert.AreEqual("status-bar", concrete.Value); - } - [TestMethod] public void CssFontInitialAll() { @@ -213,6 +176,7 @@ public void CssFontInitialAll() } [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] public void CssFontInheritAll() { var property = ParseDeclaration("font: inherit "); @@ -237,17 +201,4 @@ public void CssFontUnsetAll() Assert.IsTrue(concrete.HasValue); Assert.AreEqual("unset", concrete.Value); } - - [TestMethod] - public void CssFontStyleWeightSizeHeightFamiliesLegal() - { - var property = ParseDeclaration("font: italic bold 12px/30px Georgia, serif"); - Assert.AreEqual("font", property.Name); - Assert.IsFalse(property.IsImportant); - Assert.IsInstanceOfType(property); - var concrete = (FontProperty)property; - Assert.IsFalse(concrete.IsInherited); - Assert.IsTrue(concrete.HasValue); - Assert.AreEqual("italic bold 12px / 30px Georgia, serif", concrete.Value); - } } diff --git a/Spec/css21.yml b/Spec/css21.yml index 59d6a5982..4e221e335 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -764,49 +764,70 @@ sections: - "'background-attachment: fixed' is ignored, the image scrolls with the element" fonts.html#q15.0: title: 15 Fonts - status: unknown + status: partial fonts.html#fonts-intro: title: 15.1 Introduction - status: unknown + status: n/a fonts.html#algorithm: title: 15.2 Font matching algorithm - status: unknown + status: partial + gaps: + - The font is chosen once per element, a family without a glyph for a character doesn't fall back to the next family fonts.html#font-family-prop: title: 15.3 Font family - status: unknown + status: partial + gaps: + - A quoted generic name such as "monospace" matches the generic family instead of a family of that name fonts.html#generic-font-families: title: 15.3.1 Generic font families - status: unknown + status: partial fonts.html#serif-def: title: 15.3.1.1 serif - status: unknown + status: unsupported + gaps: + - Not mapped to a font, it falls back to the default font Segoe UI, a sans-serif fonts.html#sans-serif-def: title: 15.3.1.2 sans-serif - status: unknown + status: supported fonts.html#cursive-def: title: 15.3.1.3 cursive - status: unknown + status: unsupported + gaps: + - Not mapped to a font, it falls back to the default font Segoe UI fonts.html#fantasy-def: title: 15.3.1.4 fantasy - status: unknown + status: unsupported + gaps: + - Not mapped to a font, it falls back to the default font Segoe UI fonts.html#monospace-def: title: 15.3.1.5 monospace - status: unknown + status: supported fonts.html#font-styling: title: 15.4 Font styling - status: unknown + status: supported fonts.html#small-caps: title: 15.5 Small-caps - status: unknown + status: parsed-only + gaps: + - "'small-caps' is ignored, lowercase letters are rendered as lowercase" fonts.html#font-boldness: title: 15.6 Font boldness - status: unknown + status: partial + gaps: + - "'bolder' and 'lighter' add or subtract 300 instead of following the table" + - "'bolder' and 'lighter' are inherited as keywords, so they compound in descendants" fonts.html#font-size-props: title: 15.7 Font size - status: unknown + status: partial + gaps: + - "'larger' and 'smaller' are inherited as keywords, so they compound in descendants, like percentages" + - A negative size isn't rejected, its absolute value is used + - Sizes of 1pt or less are replaced by 'medium' fonts.html#font-shorthand: title: 15.8 Shorthand font property - status: unknown + status: partial + gaps: + - The system fonts (caption, icon, menu, message-box, small-caption, status-bar) reset the font to its initial values instead of using the system's font text.html#q16.0: title: 16 Text status: unknown From 42823794f7dc15d1665229c949a80e687b3d773f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:12:15 +0100 Subject: [PATCH 021/150] Verify and document CSS 2.1 text compliance --- Source/HtmlRenderer/Core/Dom/CssBox.cs | 7 +- .../HtmlRenderer/Core/Dom/CssLayoutEngine.cs | 20 +- .../Core/Paint/FragmentPainter.cs | 2 - Source/HtmlRenderer/Core/Parse/DomParser.cs | 2 +- .../TextDecorationPaintIntegrationTests.cs | 118 +++++++-- .../Text/TextAlignIntegrationTests.cs | 102 ++++++++ .../Text/TextAlignStartEndIntegrationTests.cs | 40 +-- ...tSpacingAndIndentLayoutIntegrationTests.cs | 151 +++++++---- .../Text/TextTransformIntegrationTests.cs | 22 ++ .../Text/WhiteSpaceLayoutIntegrationTests.cs | 240 ++++++++++++++++-- .../Css/WhiteSpacePropertyTests.cs | 13 +- Spec/css21.yml | 48 +++- 12 files changed, 596 insertions(+), 169 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Text/TextTransformIntegrationTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index a5e6a75c3..149b3f8cd 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -642,7 +642,7 @@ public void SetAllBoxes(CssBox fromBox) } /// - /// Splits the text into words and saves the result + /// Splits the text into words, collapsing white space per 'white-space' (CSS 2.1 §16.6.1). /// public void ParseToWords() { @@ -684,7 +684,7 @@ public void ParseToWords() } } - // create new-line word so it will effect the layout + // A preserved newline becomes its own word, the line layout breaks on it. if (endIdx < _text.Length && _text[endIdx] == '\n') { endIdx++; @@ -1963,8 +1963,7 @@ internal void PaintDecoration(RGraphics g, RRect rectangle, bool isFirst, bool i pen.Width = 1; pen.DashStyle = RDashStyle.Solid; - // text-decoration-line may list several space-separated line keywords (e.g. "underline - // overline") - draw each one present rather than only the first/only value. + // CSS 2.1 §16.3.1, the value can list several lines. foreach (var line in TextDecoration.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)) { double y; diff --git a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs index df07564f2..59d5f3b6b 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs @@ -829,7 +829,7 @@ private static void ApplyVerticalAlignment(RGraphics g, CssLineBox lineBox) } /// - /// Applies centered alignment to the text on the linebox + /// Spreads the words over the line box, except on the last line (CSS 2.1 §16.2). /// /// /// @@ -934,28 +934,12 @@ private static void ApplyRightAlignment(RGraphics g, CssLineBox line) } /// - /// Simplest alignment, just arrange words. + /// Left alignment, the words already are where put them. /// /// /// private static void ApplyLeftAlignment(RGraphics g, CssLineBox line) { - //No alignment needed. - - //foreach (LineBoxRectangle r in line.Rectangles) - //{ - // double curx = r.Left + (r.Index == 0 ? r.OwnerBox.ActualPaddingLeft + r.OwnerBox.ActualBorderLeftWidth / 2 : 0); - - // if (r.SpaceBefore) curx += r.OwnerBox.ActualWordSpacing; - - // foreach (BoxWord word in r.Words) - // { - // word.Left = curx; - // word.Top = r.Top;// +r.OwnerBox.ActualPaddingTop + r.OwnerBox.ActualBorderTopWidth / 2; - - // curx = word.Right + r.OwnerBox.ActualWordSpacing; - // } - //} } /// diff --git a/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs b/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs index ae03396e8..d29a2bb9f 100644 --- a/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs +++ b/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs @@ -219,8 +219,6 @@ private void PaintFragmentContent(RGraphics g, BoxFragment fragment) } } - // Width.Length > 0 gate matches CssBox's own former PaintWords guard - preserved here since - // it's the caller's job now that word painting reads the fragment tree, not the live box. if (box.Width.Length > 0) { foreach (var wordFragment in fragment.Words) diff --git a/Source/HtmlRenderer/Core/Parse/DomParser.cs b/Source/HtmlRenderer/Core/Parse/DomParser.cs index 05bd46098..244cf30e4 100644 --- a/Source/HtmlRenderer/Core/Parse/DomParser.cs +++ b/Source/HtmlRenderer/Core/Parse/DomParser.cs @@ -290,7 +290,7 @@ private void CascadeApplyStyles(CssBox box, CssData cssData, MediaQueryContext m ResolveCustomProperties(box); ResolveDeferredVarProperties(box, pendingVarProperties); - // cascade text decoration only to boxes that actually have text so it will be handled correctly. + // CSS 2.1 §16.3.1, decorations are painted by the boxes that hold the text, so they're pushed down to them. if (box.TextDecoration != String.Empty && box.Text == null) { foreach (var childBox in box.Boxes) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs index 87d12b4b0..b0ed84dc1 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs @@ -4,29 +4,12 @@ namespace HtmlRenderer.IntegrationTest.Painting; -/// -/// Verifies text-decoration actually draws a stroke at a plausible baseline-relative position with the -/// right color - CssBox.PaintDecoration is fully implemented for the single-keyword case -/// (underline/line-through/overline), using 's recording graphics to assert the real -/// draw-call geometry/color, not just that painting completes. -/// -/// -/// PeachPDF.Tests' source file also covers two cases this fork has no equivalent feature for, so they are -/// intentionally dropped rather than ported: -/// -/// Combined decoration lines (text-decoration:underline overline drawing both). HTML-Renderer's -/// CssBoxProperties.TextDecoration is a single string compared with == against one keyword at a -/// time (see CssBox.PaintDecoration) - there is no multi-keyword parsing to draw more than one line. -/// text-decoration-color overriding the element color. PaintDecoration always strokes with -/// ActualColor (the text color) - there is no separate decoration-color property anywhere in -/// CssBoxProperties. -/// -/// [DoNotParallelize] [TestClass] public sealed class TextDecorationPaintIntegrationTests { [TestMethod] + [Spec("css21", "text.html#lining-striking-props")] public void Underline_DrawsLineBelowRectangleTop() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -44,6 +27,7 @@ public void Underline_DrawsLineBelowRectangleTop() } [TestMethod] + [Spec("css21", "text.html#lining-striking-props")] public void Overline_DrawsLineAtRectangleTop() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -59,6 +43,7 @@ public void Overline_DrawsLineAtRectangleTop() } [TestMethod] + [Spec("css21", "text.html#lining-striking-props")] public void LineThrough_DrawsLineNearRectangleMiddle() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -75,6 +60,7 @@ public void LineThrough_DrawsLineNearRectangleMiddle() } [TestMethod] + [Spec("css21", "text.html#lining-striking-props")] public void Underline_OverlineLineThrough_ProduceDifferentYPositions() { var underlineY = GetDecorationY("underline"); @@ -86,6 +72,7 @@ public void Underline_OverlineLineThrough_ProduceDifferentYPositions() } [TestMethod] + [Spec("css21", "text.html#lining-striking-props")] public void TextDecorationNone_DrawsNoLine() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -97,8 +84,103 @@ public void TextDecorationNone_DrawsNoLine() Assert.IsFalse(g.Log.OfType().Any()); } + [TestMethod] + [Spec("css21", "text.html#lining-striking-props")] + public void SeveralLines_AreAllDrawn() + { + var lines = PageLines("text"); + + Assert.AreEqual(2, lines.Select(l => l.Y1).Distinct().Count()); + } + + [TestMethod] + [Spec("css21", "text.html#lining-striking-props")] + public void Blink_DrawsNoLine() + { + Assert.AreEqual(0, PageLines("text").Count); + } + + [TestMethod] + [Spec("css21", "text.html#lining-striking-props")] + public void Block_PropagatesTheDecorationToItsText() + { + Assert.AreEqual(1, PageLines("
        text
        ").Count); + } + + [TestMethod] + [Spec("css21", "text.html#lining-striking-props")] + public void Inline_PropagatesTheDecorationToTheBlockThatSplitsIt() + { + Assert.AreEqual(3, PageLines("a
        b
        c
        ").Count); + } + + [TestMethod] + [Spec("css21", "text.html#lining-striking-props")] + public void Underline_SkipsMarginAndPadding() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "text")); + var s = PaintHarness.FindById(root, "s")!; + var word = LayoutHarness.Descendants(s).SelectMany(b => b.Words).Single(); + + var line = PaintHarness.PaintPage(container).Log.OfType().Single(); + + Assert.AreEqual(word.Left, line.X1, 0.5); + Assert.AreEqual(word.Right, line.X2, 0.5); + } + + [TestMethod] + [Spec("css21", "text.html#lining-striking-props")] + public void Image_IsntUnderlined() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
        a
        ")); + var image = PaintHarness.FindById(root, "i")!.Words.Single(); + + var lines = PaintHarness.PaintPage(container).Log.OfType().ToList(); + + Assert.AreEqual(1, lines.Count); + Assert.IsTrue(lines[0].X2 <= image.Left); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#lining-striking-props")] + public void PropagatedDecoration_KeepsTheColorOfTheElementItIsSetOn() + { + var lines = PageLines("
        a b
        "); + + Assert.AreEqual(2, lines.Count); + Assert.IsTrue(lines.All(l => l.Color == RColor.FromArgb(0, 0, 255))); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#lining-striking-props")] + public void Descendant_CantRemoveTheDecorationOfAnAncestor() + { + Assert.AreEqual(2, PageLines("
        a b
        ").Count); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#lining-striking-props")] + [DataRow("float:left")] + [DataRow("position:absolute;top:100px")] + [DataRow("display:inline-block")] + public void Decoration_IsntPropagatedToFloatsAbsolutesAndInlineBlocks(string style) + { + Assert.AreEqual(1, PageLines($"
        a b
        ").Count); + } + // ─── Helpers ───────────────────────────────────────────────────────────── + private static List PageLines(string bodyHtml) + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap(bodyHtml)); + return PaintHarness.PaintPage(container).Log.OfType().ToList(); + } + private static double GetDecorationY(string decorationLine) { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs new file mode 100644 index 000000000..b28414140 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs @@ -0,0 +1,102 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.Text; + +[DoNotParallelize] +[TestClass] +public sealed class TextAlignIntegrationTests +{ + private const double Delta = 1.0; + + [TestMethod] + [Spec("css21", "text.html#alignment-prop")] + public void Left_PacksTextAgainstTheLeftEdge() + { + var word = Words("

        hi

        ").Single(); + + Assert.AreEqual(0, word.Left, Delta); + } + + [TestMethod] + [Spec("css21", "text.html#alignment-prop")] + public void Right_PacksTextAgainstTheRightEdge_IgnoringTheTrailingSpace() + { + var word = Words("

        hi

        ").Single(); + + Assert.AreEqual(200, word.Right, Delta); + } + + [TestMethod] + [Spec("css21", "text.html#alignment-prop")] + public void Center_CentersTheTextInTheLineBox() + { + var word = Words("

        hi

        ").Single(); + + Assert.AreEqual(200 - word.Right, word.Left, Delta); + } + + [TestMethod] + [Spec("css21", "text.html#alignment-prop")] + public void Right_AppliesToEveryLine() + { + var words = Words("

        AAA BBB CCC DDD

        "); + + foreach (var line in words.GroupBy(w => w.Top)) + Assert.AreEqual(60, line.Max(w => w.Right), Delta); + } + + [TestMethod] + [Spec("css21", "text.html#alignment-prop")] + public void TextAlign_IsInherited() + { + var word = Words("

        hi

        ").Single(); + + Assert.AreEqual(200, word.Right, Delta); + } + + [TestMethod] + [Spec("css21", "text.html#alignment-prop")] + public void Justify_MakesAllButTheLastLineFlushWithBothSides() + { + var lines = Words("

        aa bb cc dd ee ff gg hh ii jj

        ") + .GroupBy(w => w.Top).OrderBy(g => g.Key).ToList(); + + Assert.AreEqual(2, lines.Count); + Assert.AreEqual(0, lines[0].Min(w => w.Left), Delta); + Assert.AreEqual(100, lines[0].Max(w => w.Right), Delta); + Assert.AreEqual(0, lines[1].Min(w => w.Left), Delta); + Assert.IsTrue(lines[1].Max(w => w.Right) < 90, "the last line keeps its natural spacing"); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#alignment-prop")] + public void InitialValue_InRtl_ActsAsRight() + { + var word = Words("

        hi

        ").Single(); + + Assert.AreEqual(200, word.Right, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#alignment-prop")] + public void Justify_DoesntAlterPreservedWhiteSpace() + { + var words = Words("

        a b\nc d

        "); + var a = words.First(w => w.Text == "a"); + var space = words.First(w => w.Text == " "); + + Assert.AreEqual(a.Right, space.Left, Delta); + } + + private static List Words(string bodyHtml) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(bodyHtml)); + return LayoutHarness.Descendants(LayoutHarness.FindById(root, "p")!) + .SelectMany(b => b.Words) + .Where(w => !w.IsLineBreak) + .ToList(); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs index 0e0cc6ac6..e25952af4 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs @@ -4,35 +4,6 @@ namespace HtmlRenderer.IntegrationTest.Text; -/// -/// text-align's CSS-correct initial value is start (CSS Text 3 §7.1), which is meant to resolve -/// against the box's own direction at layout time - not always to left, the legacy/incorrect -/// initial value this replaces. Ported from PeachPDF's TextAlignStartEndIntegrationTests. -/// -/// -/// HTML-Renderer's CssBoxProperties.TextAlign is a plain, unvalidated string property - whatever -/// literal value the stylesheet declares (including "start"/"end") is stored as-is, with no keyword -/// whitelist. But CssLayoutEngine.ApplyHorizontalAlignment's switch only has explicit cases for -/// right/center/justify; everything else - including start, end, -/// left, and unset - falls through to default -> ApplyLeftAlignment (itself a -/// complete no-op: the words are simply left where FlowBox already placed them, which is against the -/// line's own left edge, independent of the box's direction). direction:rtl separately drives -/// ApplyRightToLeft, but that only reorders multiple words' relative positions within a line - for a -/// single-word line (as used below) it is a no-op. -/// -/// Net effect, confirmed by direct execution against the built assembly: a text-align:start or -/// text-align:end box always packs its text against the left edge, regardless of dir="rtl". -/// That happens to match two of the four PeachPDF start/end cases (the ones that expect left-edge packing) -/// and diverge from the other two (which expect right-edge packing) - so only those two are ported as -/// genuinely broken here. -/// -/// -// This fork's CssParser keeps a process-wide, non-thread-safe regex cache -// (RegexParserUtils.GetRegex's static Dictionary) that SetHtml/DefaultCssData populate lazily on first use per -// AppDomain; running HtmlContainerInt.SetHtml from more than one thread at once (as MSTestSettings.cs's -// assembly-wide [Parallelize(Scope = ExecutionScope.MethodLevel)] does by default) can corrupt it and throw -// "A concurrent update was performed on this collection". [DoNotParallelize] avoids tripping that pre-existing -// library race rather than masking it. [DoNotParallelize] [TestClass] public sealed class TextAlignStartEndIntegrationTests @@ -54,10 +25,7 @@ public void Start_InLtrBlock_PacksTextAgainstTheLeftEdge() Assert.AreEqual(p.ClientLeft, word.Left, Delta); } - [Ignore("text-align:start falls through CssLayoutEngine.ApplyHorizontalAlignment's switch to the default " + - "(left-align) case regardless of direction - confirmed by direct execution: a dir='rtl' box with " + - "text-align:start still packs its word against the left edge (word.Left == ClientLeft), not the " + - "right edge this test (correctly, per CSS Text 3) expects.")] + [Ignore("text-align: start is treated as left, regardless of direction")] [TestMethod] public void Start_InRtlBlock_PacksTextAgainstTheRightEdge() { @@ -70,10 +38,7 @@ public void Start_InRtlBlock_PacksTextAgainstTheRightEdge() Assert.AreEqual(p.ClientRight, word.Right, Delta); } - [Ignore("text-align:end falls through CssLayoutEngine.ApplyHorizontalAlignment's switch to the default " + - "(left-align) case - confirmed by direct execution: an LTR box with text-align:end still packs " + - "its word against the left edge (word.Left == ClientLeft), not the right edge this test " + - "(correctly, per CSS Text 3) expects.")] + [Ignore("text-align: end is treated as left, regardless of direction")] [TestMethod] public void End_InLtrBlock_PacksTextAgainstTheRightEdge() { @@ -99,6 +64,7 @@ public void End_InRtlBlock_PacksTextAgainstTheLeftEdge() } [TestMethod] + [Spec("css21", "text.html#alignment-prop")] public void DefaultsToStart_UnsetTextAlign_BehavesLikeLeftInLtr() { var html = LayoutHarness.Wrap("

        hi

        "); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs index e579e9882..ef0abdf54 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs @@ -3,48 +3,15 @@ namespace HtmlRenderer.IntegrationTest.Text; -/// -/// CSS 2.1 §16.1 (text-indent) and §16.4 (word-spacing) layout coverage - both properties were -/// already fully implemented (unlike letter-spacing, which this port found has zero layout wiring at -/// all - see remarks below), just untested at the layout level (only CSS-OM parsing existed). Ported/adapted -/// from PeachPDF's TextIndentLayoutIntegrationTests.cs/LetterWordSpacingLayoutIntegrationTests.cs, restricted -/// to the CSS 2.1 subset: a single text-indent length applied to a block's first line only (no -/// hanging/each-line - CSS Text Level 4 - and no RTL-specific cases, since this fork's -/// text-indent implementation is the plain CSS2.1 one). -/// -/// -/// letter-spacing is a confirmed, real gap found while porting this file: it parses fine -/// (CSS/FontKeywordPropertyTests.cs) but has zero wiring anywhere in Core - no -/// CssBoxProperties.LetterSpacing field, and no case in CssUtils.GetPropertyValue/ -/// SetPropertyValue/_knownPropertyNames (confirmed by grep - contrast with word-spacing, -/// which has all of these). Implementing it is a real, separate word-measurement change (not a small -/// backport - PeachPDF's own fix touched word-box-width reservation, inter-word-gap collapse prevention, and -/// adjacent-inline-run overlap, per that file's own remarks) and is out of scope for this pass; flagged as a -/// follow-up rather than built here. -/// -/// A second, more fundamental gap this file's own tests found: word-spacing, however it's set -/// (directly on the text-owning block, or on an ancestor further up - both fail identically), never -/// actually reaches the inter-word gap for plain adjacent words in normal text flow. Root cause, confirmed -/// by direct inspection: the CssRectWord that owns each word is an anonymous child text box, and that -/// anonymous child's own WordSpacing reads back "normal" regardless of what any ancestor - including -/// the direct parent - has set, so CssRect.FullWidth's OwnerBox.ActualWordSpacing term is -/// always the unspaced default. (CssLayoutEngine.cs has two call sites that DO add -/// ActualWordSpacing correctly - for a stray whitespace-only box between two separately-styled -/// inline elements - plus a third, commented-out block that looks like an abandoned attempt at exactly -/// this normal-adjacent-word case.) Not fixed here - the anonymous text-box creation/inheritance path -/// (CorrectTextBoxes et al. in Parse/DomParser.cs) is a shared, high-traffic piece of the -/// engine, and root-causing this precisely was out of this pass's budget. -/// -/// [DoNotParallelize] [TestClass] public sealed class TextSpacingAndIndentLayoutIntegrationTests { - [Ignore("word-spacing set directly on the block that owns the words has no effect - see this class's own " + - "remarks for the root cause (the anonymous text child that actually owns the words doesn't inherit " + - "its own parent's directly-set word-spacing, confirmed by direct inspection: the child's own " + - "WordSpacing reads back \"normal\").")] + private const string WrappingText = "This text is long enough that it wraps onto a second line for sure."; + + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "text.html#spacing-props")] public void WordSpacing_IncreasesGapBetweenAdjacentWords() { var spacedGap = InterWordGap("

        AA BB

        "); @@ -53,10 +20,9 @@ public void WordSpacing_IncreasesGapBetweenAdjacentWords() Assert.AreEqual(plainGap + 10, spacedGap, 1); } - [Ignore("Same root cause as WordSpacing_IncreasesGapBetweenAdjacentWords above, and fails identically " + - "whether word-spacing is set directly on the text-owning block or (as here) an ancestor further " + - "up - see this class's own remarks.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "text.html#spacing-props")] public void WordSpacing_OnAncestor_AffectsDescendantGap() { var spacedGap = InterWordGap("

        AA BB

        "); @@ -66,6 +32,7 @@ public void WordSpacing_OnAncestor_AffectsDescendantGap() } [TestMethod] + [Spec("css21", "text.html#spacing-props")] public void WordSpacingNormal_LeavesGapUnchanged() { var normalGap = InterWordGap("

        AA BB

        "); @@ -74,9 +41,9 @@ public void WordSpacingNormal_LeavesGapUnchanged() Assert.AreEqual(plainGap, normalGap, 1); } - [Ignore("Same root cause as WordSpacing_IncreasesGapBetweenAdjacentWords above - word-spacing set " + - "directly on the text-owning block has no effect.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "text.html#spacing-props")] public void WordSpacingNegative_ReducesGap() { var negativeGap = InterWordGap("

        AA BB

        "); @@ -85,12 +52,23 @@ public void WordSpacingNegative_ReducesGap() Assert.AreEqual(plainGap - 5, negativeGap, 1); } + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#spacing-props")] + public void LetterSpacing_WidensTheWordByTheSpacingPerCharacter() + { + var spaced = FirstWordWidth("

        AAAA

        "); + var plain = FirstWordWidth("

        AAAA

        "); + + Assert.IsTrue(spaced >= plain + 30, $"expected at least 3 x 10px more than {plain}, got {spaced}"); + } + [TestMethod] + [Spec("css21", "text.html#indentation-prop")] public void TextIndent_IndentsOnlyTheFirstLine() { var html = LayoutHarness.Wrap( - "

        " - + "This text is long enough that it wraps onto a second line for sure.

        "); + "

        " + WrappingText + "

        "); var (root, _) = LayoutHarness.Layout(html); var p = LayoutHarness.FindById(root, "p")!; @@ -105,6 +83,7 @@ public void TextIndent_IndentsOnlyTheFirstLine() } [TestMethod] + [Spec("css21", "text.html#indentation-prop")] public void TextIndentZero_NoIndentOnAnyLine() { var html = LayoutHarness.Wrap("

        Hello world

        "); @@ -115,7 +94,89 @@ public void TextIndentZero_NoIndentOnAnyLine() Assert.AreEqual(0, firstWord.Left, 1); } - // ── Helpers ──────────────────────────────────────────────────────────── + [TestMethod] + [Spec("css21", "text.html#indentation-prop")] + public void NegativeTextIndent_PullsTheFirstLineOutOfTheBlock() + { + Assert.AreEqual(30, FirstWordLeft( + "

        hi

        "), 1); + } + + [TestMethod] + [Spec("css21", "text.html#indentation-prop")] + public void TextIndent_IsInheritedByABlockChild() + { + Assert.AreEqual(40, FirstWordLeft( + "

        hi

        "), 1); + } + + [TestMethod] + [Spec("css21", "text.html#indentation-prop")] + public void TextIndent_DoesntApplyAfterALineBreak() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
        aa
        bb
        ")); + var words = AllWords(LayoutHarness.FindById(root, "p")!).Where(w => !w.IsLineBreak).ToList(); + + Assert.AreEqual(40, words[0].Left, 1); + Assert.AreEqual(0, words[1].Left, 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#indentation-prop")] + public void PercentageTextIndent_ResolvesAgainstTheContainingBlockWidth() + { + Assert.AreEqual(40, FirstWordLeft( + "

        hi

        "), 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#indentation-prop")] + public void TextIndent_DoesntApplyToAnAnonymousBlockThatIsntTheFirstChild() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
        bb
        cc
        ")); + var cc = AllWords(LayoutHarness.FindById(root, "p")!).Single(w => w.Text == "cc"); + + Assert.AreEqual(0, cc.Left, 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#indentation-prop")] + public void TextIndent_InRtl_IndentsFromTheRightEdge() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "

        hi

        ")); + var word = AllWords(LayoutHarness.FindById(root, "p")!).Single(); + + Assert.AreEqual(160, word.Right, 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#indentation-prop")] + public void TextIndent_IndentsTheFirstLineOfAnInlineBlock() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
        bb
        ")); + var p = LayoutHarness.FindById(root, "p")!; + var word = AllWords(p).Single(); + + Assert.AreEqual(p.Location.X + 40, word.Left, 1); + } + + private static double FirstWordLeft(string bodyHtml) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(bodyHtml)); + return AllWords(LayoutHarness.FindById(root, "p")!).First().Left; + } + + private static double FirstWordWidth(string bodyHtml) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(bodyHtml)); + return AllWords(LayoutHarness.FindById(root, "p")!).First().Width; + } private static double InterWordGap(string bodyHtml) { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextTransformIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextTransformIntegrationTests.cs new file mode 100644 index 000000000..45651ab8d --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextTransformIntegrationTests.cs @@ -0,0 +1,22 @@ +using HtmlRenderer.IntegrationTest.TestSupport; + +namespace HtmlRenderer.IntegrationTest.Text; + +[DoNotParallelize] +[TestClass] +public sealed class TextTransformIntegrationTests +{ + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#caps-prop")] + [DataRow("uppercase", "abc dEF", "ABC DEF")] + [DataRow("lowercase", "abc dEF", "abc def")] + [DataRow("capitalize", "abc dEF", "Abc DEF")] + public void TextTransform_ChangesTheCapitalization(string transform, string text, string expected) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap($"

        {text}

        ")); + var words = LayoutHarness.Descendants(LayoutHarness.FindById(root, "p")!).SelectMany(b => b.Words).Select(w => w.Text); + + Assert.AreEqual(expected, string.Join(" ", words)); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs index 8a2829e4d..6d9475e14 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs @@ -1,17 +1,17 @@ using System.Linq; using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; namespace HtmlRenderer.IntegrationTest.Text; -/// -/// Verifies white-space actually affects whitespace-collapsing and line-wrapping - -/// CssBox.ParseToWords/CssLayoutEngine.FlowBox fully implement it. -/// [DoNotParallelize] [TestClass] public sealed class WhiteSpaceLayoutIntegrationTests { + private const double Delta = 0.5; + [TestMethod] + [Spec("css21", "text.html#white-space-prop")] public void Pre_PreservesMultipleConsecutiveSpacesAsLiteralWord() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

        A B

        ")); @@ -22,6 +22,7 @@ public void Pre_PreservesMultipleConsecutiveSpacesAsLiteralWord() } [TestMethod] + [Spec("css21", "text.html#white-space-prop")] public void Normal_CollapsesConsecutiveSpaces_NoLiteralSpaceWord() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

        A B

        ")); @@ -32,6 +33,7 @@ public void Normal_CollapsesConsecutiveSpaces_NoLiteralSpaceWord() } [TestMethod] + [Spec("css21", "text.html#white-space-prop")] public void Pre_TreatsExplicitNewlineAsForcedLineBreak() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

        A\nB

        ")); @@ -41,6 +43,38 @@ public void Pre_TreatsExplicitNewlineAsForcedLineBreak() } [TestMethod] + [Spec("css21", "text.html#white-space-prop")] + public void Pre_TreatsCrLfAsASingleNewline() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

        A\r\nB

        ")); + var p = LayoutHarness.FindById(root, "p")!; + + Assert.AreEqual(2, p.LineBoxes.Count); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#white-space-prop")] + public void Pre_TreatsALoneCarriageReturnAsANewline() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

        A\rB

        ")); + var p = LayoutHarness.FindById(root, "p")!; + + Assert.AreEqual(2, p.LineBoxes.Count); + } + + [TestMethod] + [Spec("css21", "text.html#white-space-prop")] + public void Pre_DoesntWrapAtTheLineBoxEdge() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

        AAAA BBBB

        ")); + var p = LayoutHarness.FindById(root, "p")!; + + Assert.AreEqual(1, p.LineBoxes.Count); + } + + [TestMethod] + [Spec("css21", "text.html#white-space-prop")] public void Normal_IgnoresEmbeddedNewline_NoForcedBreak() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

        A\nB

        ")); @@ -50,6 +84,7 @@ public void Normal_IgnoresEmbeddedNewline_NoForcedBreak() } [TestMethod] + [Spec("css21", "text.html#white-space-prop")] public void NoWrap_PreventsWrapping_EvenWhenNarrowerThanContent() { var html = LayoutHarness.Wrap("

        a long run of unwrapped text here

        "); @@ -60,6 +95,7 @@ public void NoWrap_PreventsWrapping_EvenWhenNarrowerThanContent() } [TestMethod] + [Spec("css21", "text.html#white-space-prop")] public void Normal_WrapsAtNarrowWidth_ForContrastWithNoWrap() { var html = LayoutHarness.Wrap("

        a long run of unwrapped text here

        "); @@ -69,14 +105,120 @@ public void Normal_WrapsAtNarrowWidth_ForContrastWithNoWrap() Assert.IsTrue(p.LineBoxes.Count > 1); } - // ───   (U+00A0) is significant, non-collapsible, non-breaking content - unlike ordinary - // whitespace, which stays collapsible/breakable (CSS2.1 §16.4.1) ─────────── + [TestMethod] + [Spec("css21", "text.html#white-space-prop")] + public void PreWrap_PreservesSpacesAndWraps() + { + var words = Words("

        AAAA BBBB CCCC

        "); + + Assert.IsTrue(words.Any(w => w.Text == " ")); + Assert.AreEqual(3, words.Where(w => !w.IsSpaces).Select(w => w.Top).Distinct().Count()); + } + + [TestMethod] + [Spec("css21", "text.html#white-space-prop")] + public void PreLine_CollapsesSpacesButKeepsNewlines() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

        A B\nC

        ")); + var p = LayoutHarness.FindById(root, "p")!; + var words = AllWords(p).ToList(); + + Assert.AreEqual(2, p.LineBoxes.Count); + Assert.IsFalse(words.Any(w => w.IsSpaces && !w.IsLineBreak)); + Assert.AreEqual(GapBetween("

        A B

        ", "A", "B"), Gap(words, "A", "B"), Delta); + } + + [TestMethod] + [Spec("css21", "text.html#white-space-prop")] + public void PreLine_WrapsAtTheLineBoxEdge() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

        AAAA BBBB CCCC

        ")); + var p = LayoutHarness.FindById(root, "p")!; + + Assert.AreEqual(3, p.LineBoxes.Count); + } + + [TestMethod] + [Spec("css21", "text.html#white-space-prop")] + public void WhiteSpace_IsInherited() + { + var words = Words("

        A B

        "); + + Assert.IsTrue(words.Any(w => w.Text == " ")); + } + + [TestMethod] + [Spec("css21", "text.html#white-space-model")] + public void Normal_TransformsALinefeedAndTheSpacesAroundItIntoOneSpace() + { + var expected = GapBetween("

        AA BB

        ", "AA", "BB"); + + Assert.AreEqual(expected, GapBetween("

        AA \n BB

        ", "AA", "BB"), Delta); + } + + [TestMethod] + [Spec("css21", "text.html#white-space-model")] + public void Normal_ConvertsATabToASpace() + { + var expected = GapBetween("

        AA BB

        ", "AA", "BB"); + + Assert.AreEqual(expected, GapBetween("

        AA\tBB

        ", "AA", "BB"), Delta); + } + + [TestMethod] + [Spec("css21", "text.html#white-space-model")] + public void Normal_RemovesASpaceAtTheBeginningOfALine() + { + var word = Words("

        AA

        ").Single(); + + Assert.AreEqual(0, word.Left, Delta); + } + + [TestMethod] + [Spec("css21", "text.html#white-space-model")] + public void Normal_CollapsesASpaceAtTheStartOfAnInlineWithOneBeforeIt_WhenTheFirstHasNoSpaceAfter() + { + var expected = GapBetween("

        AA BB

        ", "AA", "BB"); + + Assert.AreEqual(expected, GapBetween("

        AA BB

        ", "AA", "BB"), Delta); + } - [Ignore("HtmlUtils.DecodeHtml decodes   to a plain U+0020 space rather than U+00A0 (non-breaking " + - "space), so it is collapsed away like ordinary whitespace-only content instead of surviving as " + - "significant content with real height. Confirmed real engine behavior, not a porting mistake - " + - "same root cause tracked for HtmlEntityDecodingIntegrationTests.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "text.html#white-space-model")] + public void Normal_RemovesASpaceFollowingAnotherSpaceAcrossAnElementBoundary() + { + var expected = GapBetween("

        AA BB

        ", "AA", "BB"); + + Assert.AreEqual(expected, GapBetween("

        AA BB

        ", "AA", "BB"), Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#white-space-model")] + public void Pre_RendersATabUpToTheNextTabStop() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "

        A\tB

        ")); + var b = AllWords(LayoutHarness.FindById(root, "p")!).Single(w => w.Text == "B"); + var eightSpaces = AllWords(LayoutHarness.FindById(root, "s")!).Single(); + + Assert.AreEqual(eightSpaces.Width, b.Left, 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#white-space-model")] + public void NoBreakSpaces_ArentCollapsed() + { + var expected = GapBetween("

        A B

        ", "A", "B"); + + Assert.IsTrue(GapBetween("

        A  B

        ", "A", "B") > expected + 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#white-space-model")] public void Nbsp_OnlyContent_ProducesNonZeroHeight_MatchingRealText() { var (nbspRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
         
        ")); @@ -92,6 +234,7 @@ public void Nbsp_OnlyContent_ProducesNonZeroHeight_MatchingRealText() } [TestMethod] + [Spec("css21", "text.html#white-space-model")] public void OrdinaryWhitespaceOnlyContent_StillProducesZeroHeight_NoRegression() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
        ")); @@ -101,15 +244,12 @@ public void OrdinaryWhitespaceOnlyContent_StillProducesZeroHeight_NoRegression() Assert.IsTrue(height >= 0 && height <= 0.5); } - [Ignore("HtmlUtils.DecodeHtml decodes   to a plain U+0020 space rather than U+00A0, so it is treated " + - "as an ordinary breakable/collapsible space instead of a non-breaking one - the narrow-width case " + - "wraps just like the plain-space case instead of staying on one line. Confirmed real engine " + - "behavior, not a porting mistake - same root cause tracked for HtmlEntityDecodingIntegrationTests.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "text.html#white-space-model")] public void Nbsp_BetweenTokens_PreventsLineWrap_ContrastOrdinarySpace() { - // Narrow enough that an ordinary space between "10" and "km" wraps to two lines, but a - // non-breaking space between them must never be treated as a break opportunity. + // Narrow enough that an ordinary space between "10" and "km" wraps. var (nbspRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

        10 km

        ")); var pNbsp = LayoutHarness.FindById(nbspRoot, "p")!; @@ -121,13 +261,54 @@ public void Nbsp_BetweenTokens_PreventsLineWrap_ContrastOrdinarySpace() "expected ordinary space to still allow wrapping, for contrast with nbsp"); } - // ─── word-break: break-all forces a mid-word break normal cannot find ────── + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#white-space-model")] + public void ElementBoundaryWithoutWhiteSpace_IsntALineBreakingOpportunity() + { + var words = Words("

        AAAABBBB

        "); + + Assert.AreEqual(1, words.Select(w => w.Top).Distinct().Count()); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#white-space-model")] + public void Float_DoesntIntroduceALineBreakingOpportunity() + { + var words = Words("

        AAAAxBBBB

        "); + + Assert.AreEqual( + words.Single(w => w.Text == "AAAA").Top, + words.Single(w => w.Text == "BBBB").Top, Delta); + } + + [TestMethod] + [Spec("css21", "text.html#ctrlchars")] + public void CombiningCharacter_StaysWithItsBaseCharacter() + { + var word = Words("

        öx

        ").Single(); + + Assert.AreEqual("öx", word.Text); + } + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "text.html#ctrlchars")] + [DataRow("\u000B")] + [DataRow("\u0085")] + public void ControlCharacter_IsRenderedLikeANormalCharacter(string control) + { + var words = Words($"

        A{control}B

        "); + + Assert.AreEqual(1, words.Count); + } + + // word-break is CSS Text 3, not CSS 2.1. [TestMethod] public void BreakAll_ForcesMidWordBreak_ContrastNormal() { - // A single unbroken run with no space anywhere: "normal" has no break opportunity at all - // and must lay the whole word out on one (overflowing) line, while "break-all" must wrap it. + // No space anywhere: "normal" has no break opportunity and overflows, "break-all" wraps. const string longWord = "abcdefghijklmnopqrstuvwxyz"; var (normalRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap($"

        {longWord}

        ")); @@ -137,12 +318,27 @@ public void BreakAll_ForcesMidWordBreak_ContrastNormal() LayoutHarness.Wrap($"

        {longWord}

        ")); var pBreakAll = LayoutHarness.FindById(breakAllRoot, "p")!; - // An overflowing word can push a leading empty line box ahead of it regardless of - // word-break - count only the lines that actually carry part of the word. + // An overflowing word pushes an empty line box ahead of it, count only lines with text. Assert.AreEqual(1, LinesWithWordContent(pNormal)); Assert.IsTrue(LinesWithWordContent(pBreakAll) > 1, "expected break-all to force a mid-word break"); } - private static int LinesWithWordContent(TheArtOfDev.HtmlRenderer.Core.Dom.CssBox box) => + private static int LinesWithWordContent(CssBox box) => box.LineBoxes.Count(lb => lb.Words.Any(w => !string.IsNullOrEmpty(w.Text))); + + private static List Words(string bodyHtml) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(bodyHtml)); + return AllWords(LayoutHarness.FindById(root, "p")!).Where(w => !w.IsLineBreak).ToList(); + } + + private static double GapBetween(string bodyHtml, string first, string second) => Gap(Words(bodyHtml), first, second); + + private static double Gap(IEnumerable words, string first, string second) + { + var list = words.ToList(); + return list.Single(w => w.Text == second).Left - list.Single(w => w.Text == first).Right; + } + + private static IEnumerable AllWords(CssBox box) => LayoutHarness.Descendants(box).SelectMany(b => b.Words); } diff --git a/Source/Test/HtmlRenderer.Test/Css/WhiteSpacePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/WhiteSpacePropertyTests.cs index a8ca3e1fb..adfa20764 100644 --- a/Source/Test/HtmlRenderer.Test/Css/WhiteSpacePropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/WhiteSpacePropertyTests.cs @@ -3,17 +3,6 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/WhiteSpaceProperty.cs. -/// PeachPDF.CSS models "white-space" as a typed, validating WhiteSpaceProperty (legal keywords are -/// stored, illegal ones are rejected). HTML-Renderer's CSS engine port added the same real, validating -/// WhiteSpaceProperty (see Source/HtmlRenderer/Core/CssEngine/StyleProperties/Text/WhiteSpaceProperty.cs) - -/// the old raw-string, unfiltered CssData.GetCssBlock pass-through this file previously described no -/// longer exists at all. Exercised here through , whose resulting -/// StyleDeclaration only stores a value that actually parsed as a legal keyword. -/// WhiteSpaceInvalidKeywordIllegal, previously [Ignore]d because the old parser stored any raw token -/// verbatim, now genuinely passes against the real validating property and is un-ignored. -/// [TestClass] public sealed class WhiteSpacePropertyTests { @@ -25,6 +14,7 @@ private static string GetWhiteSpace(string declaration) } [TestMethod] + [Spec("css21", "text.html#white-space-prop")] [DataRow("normal")] [DataRow("pre")] [DataRow("nowrap")] @@ -36,6 +26,7 @@ public void WhiteSpaceKeywordLegal(string keyword) } [TestMethod] + [Spec("css21", "text.html#white-space-prop")] public void WhiteSpaceInvalidKeywordIllegal() { Assert.IsTrue(string.IsNullOrEmpty(GetWhiteSpace("white-space: wavy"))); diff --git a/Spec/css21.yml b/Spec/css21.yml index 4e221e335..48330cbe8 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -830,37 +830,63 @@ sections: - The system fonts (caption, icon, menu, message-box, small-caption, status-bar) reset the font to its initial values instead of using the system's font text.html#q16.0: title: 16 Text - status: unknown + status: partial text.html#indentation-prop: title: 16.1 Indentation - status: unknown + status: partial + gaps: + - A percentage resolves against the block's own width instead of the containing block's width + - An anonymous block that isn't the first child is indented too + - In right-to-left text the indentation is applied at the left edge + - The first line of an inline-block isn't indented text.html#alignment-prop: title: 16.2 Alignment - status: unknown + status: partial + gaps: + - "The initial value acts as 'left' also when 'direction' is 'rtl'" + - "'justify' stretches preserved white space in 'pre' and 'pre-wrap' text" text.html#decoration: title: 16.3 Decoration - status: unknown + status: partial text.html#lining-striking-props: title: 16.3.1 Underlining, overlining, striking, and blinking - status: unknown + status: partial + gaps: + - A propagated decoration is drawn in the color of the descendant instead of the element it's set on + - "A descendant's 'text-decoration: none' removes the decoration of its ancestor" + - Decorations propagate to floats, absolutely positioned boxes and inline-blocks text.html#spacing-props: title: 16.4 Letter and word spacing - status: unknown + status: parsed-only + gaps: + - "'letter-spacing' is ignored" + - "'word-spacing' has no effect, the anonymous boxes that hold the text don't inherit it, see 6.2" text.html#caps-prop: title: 16.5 Capitalization - status: unknown + status: parsed-only + gaps: + - "'text-transform' is ignored" text.html#white-space-prop: title: 16.6 White space - status: unknown + status: partial + gaps: + - A lone carriage return is dropped instead of being treated as a newline text.html#white-space-model: title: 16.6.1 The 'white-space' processing model - status: unknown + status: partial + gaps: + - A space following another space isn't removed when an element boundary is between them + - Tabs in 'pre' and 'pre-wrap' are as wide as their glyph instead of reaching the next tab stop + - U+00A0 (and  ) is collapsed and breakable like a space + - A line can break at an element boundary or a float without white space in the text text.html#egbidiwscollapse: title: 16.6.2 Example of bidirectionality with white space collapsing - status: unknown + status: n/a text.html#ctrlchars: title: 16.6.3 Control and combining characters' details - status: unknown + status: partial + gaps: + - Control characters such as U+000B and U+0085 are treated as white space instead of being rendered tables.html#q17.0: title: 17 Tables status: unknown From af8201e9f4683cd71fea147b79e1341f628853bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:00:34 +0100 Subject: [PATCH 022/150] Verify and document CSS 2.1 tables compliance --- .../HtmlRenderer/Core/Dom/CssLayoutEngine.cs | 27 +- .../Core/Dom/CssLayoutEngineTable.cs | 152 ++----- Source/HtmlRenderer/Core/Dom/CssSpacingBox.cs | 2 +- Source/HtmlRenderer/Core/Parse/DomParser.cs | 27 +- .../Tables/Acid2TableIntegrationTests.cs | 6 +- .../AnonymousTableBoxIntegrationTests.cs | 68 +--- .../Tables/TableBorderIntegrationTests.cs | 212 ++++++++++ .../Tables/TableModelIntegrationTests.cs | 372 ++++++++++++++++++ .../Tables/TableSizingIntegrationTests.cs | 264 +++++++++++++ ...TableVisibilityCollapseIntegrationTests.cs | 88 ++--- .../Text/VerticalAlignIntegrationTests.cs | 8 +- .../Dom/CssLayoutEngineTableTests.cs | 65 ++- Spec/css21.yml | 81 +++- 13 files changed, 1029 insertions(+), 343 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Tables/TableBorderIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Tables/TableModelIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Tables/TableSizingIntegrationTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs index 59d5f3b6b..fb5523c32 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs @@ -201,10 +201,8 @@ public static void FloatBox(CssBox box) } /// - /// Applies special vertical alignment for table-cells + /// Moves the content of a cell down for 'vertical-align' middle and bottom (CSS 2.1 §17.5.3). /// - /// - /// public static void ApplyCellVerticalAlignment(RGraphics g, CssBox cell) { ArgChecker.AssertArgNotNull(g, "g"); @@ -230,29 +228,6 @@ public static void ApplyCellVerticalAlignment(RGraphics g, CssBox cell) { b.OffsetTop(dist); } - - //float top = cell.ClientTop; - //float bottom = cell.ClientBottom; - //bool middle = cell.VerticalAlign == CssConstants.Middle; - - //foreach (LineBox line in cell.LineBoxes) - //{ - // for (int i = 0; i < line.RelatedBoxes.Count; i++) - // { - - // double diff = bottom - line.RelatedBoxes[i].Rectangles[line].Bottom; - // if (middle) diff /= 2f; - // RectangleF r = line.RelatedBoxes[i].Rectangles[line]; - // line.RelatedBoxes[i].Rectangles[line] = new RectangleF(r.X, r.Y + diff, r.Width, r.Height); - - // } - - // foreach (BoxWord word in line.Words) - // { - // double gap = word.Top - top; - // word.Top = bottom - gap - word.Height; - // } - //} } diff --git a/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs b/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs index 07f10318f..514a3d888 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs @@ -22,7 +22,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Dom { /// - /// Layout engine for tables executing the complex layout of tables with rows/columns/headers/etc. + /// Lays out a table's rows and cells (CSS 2.1 §17). /// internal sealed class CssLayoutEngineTable { @@ -33,9 +33,6 @@ internal sealed class CssLayoutEngineTable ///
        private readonly CssBox _tableBox; - /// - /// - /// private CssBox _caption; private CssBox _headerBox; @@ -53,7 +50,7 @@ internal sealed class CssLayoutEngineTable private readonly List _columns = new List(); /// - /// + /// header rows, body rows and footer rows, in the order they're laid out /// private readonly List _allRows = new List(); @@ -118,10 +115,8 @@ public static double GetTableSpacing(CssBox tableBox) } /// - /// + /// Lays out the rows and cells of . /// - /// - /// public static void PerformLayout(RGraphics g, CssBox tableBox) { ArgChecker.AssertArgNotNull(g, "g"); @@ -142,8 +137,7 @@ public static void PerformLayout(RGraphics g, CssBox tableBox) #region Private Methods /// - /// Analyzes the Table and assigns values to this CssTable object. - /// To be called from the constructor + /// Column widths first (CSS 2.1 §17.5.2), then the rows (§17.5.3). /// private void Layout(RGraphics g) { @@ -174,7 +168,8 @@ private void Layout(RGraphics g) } /// - /// Get the table boxes into the proper fields. + /// Get the table boxes into the proper fields. Only the first header and footer group count as + /// such (CSS 2.1 §17.2). /// private void AssignBoxKinds() { @@ -360,9 +355,8 @@ private double CalculateCountAndWidth() } /// - /// + /// Gives the columns without a width their share of the available width (CSS 2.1 §17.5.2.2). /// - /// private void DetermineMissingColumnWidths(double availCellSpace) { double occupedSpace = 0f; @@ -614,7 +608,7 @@ private void LayoutCells(RGraphics g) double maxBottom = 0f; int currentrow = 0; - // change start X by if the table should align to center or right + // HTML's align attribute on the table ends up in text-align if (_tableBox.TextAlign == CssConstants.Center || _tableBox.TextAlign == CssConstants.Right) { double maxRightCalc = GetWidthSum(); @@ -625,24 +619,12 @@ private void LayoutCells(RGraphics g) _tableBox.Location = new RPoint(startx - _tableBox.ActualBorderLeftWidth - _tableBox.ActualPaddingLeft - GetHorizontalSpacing(), _tableBox.Location.Y); } - // css-tables-3 6.2: a repeats on every page the table's body/footer spans, where - // the group carries an avoiding break-inside (the UA default stylesheet sets this). - // Reserving the room here, before the first row of each continuation page is positioned, - // is what keeps that row from being drawn underneath the repeated header instead of below - // it - a fragment-tree-only repeat (no reservation) would just overlap real content. + // css-tables-3 6.2: a with an avoiding break-inside repeats on every page the table + // spans. The room for it is reserved here, before the first row of each continuation page is + // placed, so the row ends up below the repeated header instead of underneath it. // - // KNOWN LIMITATION (confirmed via direct testing, not yet fixed - fragmentation-engine-parity - // plan's R8 stage): this check runs once per ROW (below, gated on `i`), reading `cury`'s slot - // only at that row's own start. A row whose own cell content spans MULTIPLE pages by itself - // (one cell vastly longer than its siblings) only gets a repeat inserted for the FIRST page - // it crosses onto - the header does not repeat on further intermediate pages that same row's - // content continues to span, only reappearing once a LATER row's own start advances the slot - // again. Not data loss or a crash, just a missing header repeat on some pages of a fairly - // exotic table shape. A real fix needs to know how many pages a row spans before deciding how - // much room to reserve for it, which this single-pass-per-row model doesn't have without - // relaying the row out a second time once its true span is known - tractable, but out of - // scope for now given how rare the shape is (the far more common case - many ordinary rows, - // table spans many pages - already repeats correctly, verified by ThreadRepeatsOnEveryPageTheTableSpans). + // The check runs once per row, at the row's start. A single row spanning several pages only + // gets the header on the first page it crosses onto. var pageGridContainer = _tableBox.HtmlContainer; var repeatsHeader = pageGridContainer != null && pageGridContainer.HasRealPageGrid && _headerBox != null && BreakValues.AvoidsBreak(_headerBox.BreakInside); @@ -655,9 +637,8 @@ private void LayoutCells(RGraphics g) { if (repeatsHeader && i == headerRowCount) { - // The header's own rows (i = 0..headerRowCount-1) just finished; maxBottom is - // still theirs. Its own page is never itself a "repeat" - the header is already - // there once, in flow. + // The header rows just finished, maxBottom is still theirs. The header's own page + // isn't a repeat, it's already there in flow. headerHeight = maxBottom - starty; lastRepeatSlot = PageSlotOf(pageGridContainer, starty); } @@ -675,9 +656,7 @@ private void LayoutCells(RGraphics g) for (var hi = 0; hi < headerRowCount; hi++) { var sourceRow = _allRows[hi]; - // A box's own Location is never assigned by this row loop (only its - // cells' is) - the first cell is the real reference point for "where this - // header row actually renders". + // The row loop only positions cells, not the row box itself var sourceRenderedTop = sourceRow.Boxes.Count > 0 ? sourceRow.Boxes[0].Location.Y : starty; var targetTop = pageTop + (sourceRenderedTop - starty); _tableBox.RepeatedHeaderRows.Add(TableHeaderRepeat.CloneAndPosition(sourceRow, sourceRenderedTop, targetTop)); @@ -736,14 +715,9 @@ private void LayoutCells(RGraphics g) } } - // css-tables-3 §6.1: "user agents must attempt to preserve the table rows unfragmented - // if the cells spanning the row do not span any subsequent row, and their height is at - // least twice smaller than both the fragmentainer height and width" - a UA-default - // requirement, not something an author has to opt into. If this row straddles a page - // boundary and isn't "freely fragmentable" by that rule, shift the whole row - not just - // one cell - down to the next page's content top. The table's own break-inside:avoid - // still forces the attempt even for an otherwise-freely-fragmentable row (an author's - // explicit, stronger request), matching this port's existing behavior for that case. + // css-tables-3 §6.1: a row straddling a page boundary that the UA has to try to keep in + // one piece moves down to the next page as a whole. The table's own break-inside:avoid + // forces that for every row. if (pageGridContainer != null && pageGridContainer.HasRealPageGrid && maxBottom > cury) { var topSlot = pageGridContainer.PageIndexOf(cury); @@ -758,25 +732,10 @@ private void LayoutCells(RGraphics g) { var delta = pageGridContainer.PageTopOf(topSlot + 1) - cury; - // cury == starty means nothing has been drawn above this row within the table yet - // (no earlier row consumed space on the table's original page) - so this row moving - // IS the table's own content moving wholesale, not one row among several straddling - // independently. The table's own Location was set once, before this method ever - // ran, by its parent's child loop - row-atomicity shifting cell rectangles alone - // left it stale, so EnforceKeepWithNext(table) (called on the table exactly like any - // other child) never saw the boundary crossing and could never pull an avoid-chained - // heading along. Only the table's own Location follows here - deliberately NOT - // BlockFragmentation.PropagateContainerRelocation's further climb into an ancestor: - // this method can run more than once per overall document pass whenever an ancestor - // is independently relocated by RelocateIfNeeded (which re-lays the whole subtree - // out fresh at its own target) - climbing here too would double-count that ancestor's - // own already-correct shift on top of RelocateIfNeeded's (confirmed: caused a real - // regression in BoxContainingARepeatingTable_IsStillRelocated, a table inside its own - // break-inside:avoid card, off by the same few pixels PageSlotOf's collapsed-border - // tolerance allows). A plain, non-avoid wrapper around a table whose first row alone - // triggers this path is not climbed to - a narrower fix than full css-break-3 3.1 - // propagation, matching what the two tests this fixes actually exercise (the table - // itself as EnforceKeepWithNext's own child, not a further-wrapped one). + // The first row moving means the whole table moves, so its Location has to follow, + // or EnforceKeepWithNext never sees the table cross the page boundary. Ancestors + // aren't moved: RelocateIfNeeded lays a relocated ancestor out again anyway, moving + // it here as well would apply that shift twice. if (Math.Abs(cury - starty) < 0.01) { _tableBox.Location = new RPoint(_tableBox.Location.X, _tableBox.Location.Y + delta); @@ -784,15 +743,8 @@ private void LayoutCells(RGraphics g) foreach (CssBox cell in row.Boxes) { - // A rowspan-crossing cell's real content lives on CssSpacingBox.ExtendedBox, - // not on the placeholder itself (Display:none, no children/words/rectangles - - // OffsetTop on it was a silent no-op, leaving the spanning cell's actual - // bottom edge stale while the rest of the row moved on). Unlike an ordinary - // cell, the spanning cell's own top and content are already anchored to - // whichever earlier row it started in (laid out there, unaffected by this - // row's shift) - so rather than OffsetTop-ing the whole subtree (which would - // incorrectly drag its top and content away from that row too), only its - // bottom edge is extended to cover the gap this row's move just opened up. + // A cell spanning in from an earlier row stays anchored there, only its bottom + // edge grows to cover the gap if (cell is CssSpacingBox spacer) { spacer.ExtendedBox.ActualBottom += delta; @@ -876,7 +828,7 @@ private double GetCellWidth(int column, CssBox b) } /// - /// Gets the colspan of the specified box + /// Gets the colspan of the specified box. CSS 2.1 §17.5 leaves spans to the document language. /// /// private static int GetColSpan(CssBox b) @@ -910,42 +862,14 @@ private static int GetRowSpan(CssBox b) } /// - /// The pagination slot falls in, for the repeated-header loop above - which - /// needs to know which page the row cursor is really on, as opposed to - /// 's raw arithmetic. + /// The page slot falls in, for the repeated-header loop. /// /// - /// A confirmed, real bug found while porting this port's own table-fragmentation test suite: for a - /// border-collapse:collapse table ( is -1, a - /// deliberate one-pixel overlap between the first row and the table's own top border), starty - /// is one pixel LESS than whenever the table sits flush at a page's - /// own content top (the common case: the table is the first thing on a page, or was just relocated - /// to PageTopOf(slot) by ). - /// Fed straight into , that one pixel is enough to floor - /// into the SLOT BEFORE the one the table's box actually starts in (observed directly: a 200px-tall - /// page grid with MarginTop=10, table starting at ClientTop=10, gives - /// starty=9 and PageIndexOf(9)=-1, not 0). Seeding lastRepeatSlot from - /// that value made the repeated-header loop see a spurious "transition" into slot 0 at the very - /// first body row, consuming its first repeat on a duplicate drawn almost exactly on top of the - /// header the table already has in flow there (confirmed: before this fix, a table's own first page - /// painted its header twice). itself is never subject to the - /// collapsed-border overlap (it is the table's plain border/padding-resolved box edge), so clamping - /// to it here is a safe floor: every legitimate use of cury for this loop's slot arithmetic - /// is asking "which page is the table's own row cursor on", and that can never sensibly be a page - /// before the table's own top. - /// - /// - /// Deliberately NOT applied to the row-preservation straddle check a few lines below (which still - /// calls directly, unclamped) - confirmed by running the - /// existing regression suite both ways: that check's own reaction to this exact -1/0 misread is a - /// harmless, arguably-correct 1px nudge (shifting a row that starts 1px into the "previous" slot - /// down to that slot's real top), and two pre-existing tests - /// (CssLayoutEngineTablePageBreakTests.AvailableHeight_PageBreakFiringPoint_RowDoesNotBleedIntoBottomMargin/ - /// TableLayout_MultiPageTable_RowsDoNotOverlapPageMargins) depend on that nudge keeping a - /// collapsed-border table's very first row flush with its page's own content top rather than - /// poking one pixel above it. Clamping there too would remove a real, useful correction to fix a - /// bug in a different, unrelated caller (the header-repeat loop, which reacts to the same misread - /// by inserting visible duplicate content rather than by a sub-pixel nudge). + /// Clamped to the table's top: with collapsed borders the spacing is -1, so the first row starts 1px + /// above . For a table at the top of a page that floors into the + /// previous slot and repeats the header on the table's own first page. + /// The row preservation check uses the unclamped on + /// purpose, there the misread moves the first row 1px down, flush with the page's content top. /// private int PageSlotOf(HtmlContainerInt container, double y) => container.PageIndexOf(Math.Max(y, _tableBox.ClientTop)); @@ -1048,14 +972,9 @@ private double GetAvailableTableWidth() } /// - /// Gets the available width for the whole table. + /// Gets the table's max-width, 9999 when it has none. /// It also sets the value of WidthSpecified /// - /// - /// - /// The table's width can be larger than the result of this method, because of the minimum - /// size that individual boxes. - /// private double GetMaxTableWidth() { var tblen = new CssLength(_tableBox.MaxWidth); @@ -1183,7 +1102,8 @@ private double[] GetColumnMinWidths() } /// - /// Gets the actual horizontal spacing of the table + /// Gets the actual horizontal spacing of the table. With collapsed borders it's -1, so adjacent + /// cell borders overlap by one pixel (CSS 2.1 §17.6.2). /// private double GetHorizontalSpacing() { @@ -1191,7 +1111,7 @@ private double GetHorizontalSpacing() } /// - /// Gets the actual horizontal spacing of the table + /// Gets the actual horizontal spacing of /// private static double GetHorizontalSpacing(CssBox box) { diff --git a/Source/HtmlRenderer/Core/Dom/CssSpacingBox.cs b/Source/HtmlRenderer/Core/Dom/CssSpacingBox.cs index 0ea65d99b..78668ac26 100644 --- a/Source/HtmlRenderer/Core/Dom/CssSpacingBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssSpacingBox.cs @@ -5,7 +5,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Dom { /// - /// Used to make space on vertical cell combination + /// Placeholder in a row for a cell spanning in from an earlier row /// internal sealed class CssSpacingBox : CssBox { diff --git a/Source/HtmlRenderer/Core/Parse/DomParser.cs b/Source/HtmlRenderer/Core/Parse/DomParser.cs index 244cf30e4..7c61561fe 100644 --- a/Source/HtmlRenderer/Core/Parse/DomParser.cs +++ b/Source/HtmlRenderer/Core/Parse/DomParser.cs @@ -865,7 +865,7 @@ private static bool SelectorIsSelectionPseudoElement(ISelector selector) /// /// Check if the given style is allowed to be set on the given css box.
        - /// Used to prevent invalid CssBoxes creation like table with inline display style. + /// Keeps the display of HTML table elements, which CSS 2.1 §17.2 allows user agents to do. ///
        /// the css box to assign css to /// the style key to cehck @@ -1033,10 +1033,8 @@ private static void ApplyTableBorder(CssBox table, string border) } /// - /// Cascades to the TD's the border spacified in the TABLE tag. + /// Cascades the cellpadding of the TABLE tag to its TDs. /// - /// - /// private static void ApplyTablePadding(CssBox table, string padding) { var length = TranslateLength(padding); @@ -1389,7 +1387,7 @@ private static bool ContainsVariantBoxes(CssBox box) } /// - /// Corrects the missing elements in tables per https://www.w3.org/TR/CSS2/tables.html#anonymous-boxes + /// Generates the anonymous table objects (CSS 2.1 §17.2.1), the comments refer to its rules. /// /// the current box to correct its sub-tree private static void CorrectAnonymousTables(CssBox box) @@ -1426,7 +1424,7 @@ private static void CorrectAnonymousTables(CssBox box) private static void CorrectAnonymousTablesRemoveIrrelevantBoxes(CssBox box) { - // 1.1 All child boxes of a 'table-column' parent are treated as if they had 'display: none' + // 1.1 if (box.Display == CssConstants.TableColumn) { foreach (var childBox in box.Boxes) @@ -1435,7 +1433,7 @@ private static void CorrectAnonymousTablesRemoveIrrelevantBoxes(CssBox box) } } - // 1.2 If a child of a 'table-column-group' parent is not a 'table-column' box, it is treated as if it had 'display: none'. + // 1.2 if (box.ParentBox != null && box.ParentBox.Display == CssConstants.TableColumnGroup && box.Display != CssConstants.TableColumn) { box.Display = CssConstants.None; @@ -1444,8 +1442,7 @@ private static void CorrectAnonymousTablesRemoveIrrelevantBoxes(CssBox box) private static void CorrectAnonymousTablesGenerateMissingChildWrappers(CssBox box) { - // 2.1 If a child of a 'table'/'inline-table' box is not a proper table child, generate an anonymous 'table-row' box around it - // and all consecutive siblings that are not proper table children. + // 2.1 if (box.ParentBox != null && box.ParentBox.Display == CssConstants.Table) { if (!DomUtils.IsProperTableChild(box)) @@ -1456,8 +1453,7 @@ private static void CorrectAnonymousTablesGenerateMissingChildWrappers(CssBox bo } } - // 2.2 If a child of a row group box is not a 'table-row' box, generate an anonymous 'table-row' box around it - // and all consecutive siblings that are not 'table-row' boxes. + // 2.2 if (box.ParentBox != null && box.ParentBox.IsTableRowGroupBox) { if (box.Display != CssConstants.TableRow) @@ -1468,8 +1464,7 @@ private static void CorrectAnonymousTablesGenerateMissingChildWrappers(CssBox bo } } - // 2.3 If a child of a 'table-row' box is not a 'table-cell', generate an anonymous 'table-cell' box around it - // and all consecutive siblings that are not 'table-cell' boxes. + // 2.3 if (box.ParentBox != null && box.ParentBox.Display == CssConstants.TableRow) { if (box.Display != CssConstants.TableCell) @@ -1487,8 +1482,7 @@ private static void CorrectAnonymousTablesGenerateMissingChildWrappers(CssBox bo private static void CorrectAnonymousTablesGenerateMissingParents(CssBox box) { - // 3.1 For each 'table-cell' box in a sequence of consecutive internal table and 'table-caption' siblings, if its parent - // is not a 'table-row' then generate an anonymous 'table-row' box around it and all consecutive 'table-cell' siblings. + // 3.1 if (box.Display == CssConstants.TableCell) { if (box.ParentBox == null || box.ParentBox.Display != CssConstants.TableRow) @@ -1503,8 +1497,7 @@ private static void CorrectAnonymousTablesGenerateMissingParents(CssBox box) } } - // 3.2 For each proper table child in a sequence of consecutive proper table children, if it is misparented then generate - // an anonymous 'table'/'inline-table' box around it and all consecutive proper-table-child siblings. + // 3.2 if (DomUtils.IsProperTableChild(box)) { var isMissingParent = box.ParentBox == null; diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/Acid2TableIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/Acid2TableIntegrationTests.cs index b3d72a8cc..13a63a529 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/Acid2TableIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/Acid2TableIntegrationTests.cs @@ -2,10 +2,6 @@ namespace HtmlRenderer.IntegrationTest.Tables; -/// -/// Ported from PeachPDF.Tests' Acid2 table regression cases (CSS 2.1 §17.5.3 row-stretch, §17.6.1 -/// border-spacing). -/// [DoNotParallelize] [TestClass] public sealed class Acid2TableIntegrationTests @@ -13,6 +9,7 @@ public sealed class Acid2TableIntegrationTests private const double Delta = 0.5; [TestMethod] + [Spec("css21", "tables.html#height-layout")] public void TableCell_ExplicitHeightShorterThanRow_StretchesToRowHeight() { var html = LayoutHarness.Wrap( @@ -29,6 +26,7 @@ public void TableCell_ExplicitHeightShorterThanRow_StretchesToRowHeight() } [TestMethod] + [Spec("css21", "tables.html#separated-borders")] public void BorderSpacingZero_AdjacentCells_HaveNoGapBetweenThem() { var html = LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/AnonymousTableBoxIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/AnonymousTableBoxIntegrationTests.cs index 33cc7a14c..8b9799ef8 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/AnonymousTableBoxIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/AnonymousTableBoxIntegrationTests.cs @@ -5,33 +5,13 @@ namespace HtmlRenderer.IntegrationTest.Tables; -/// -/// Coverage for CSS2.1 §17.2.1 anonymous table-object generation -/// (DomParser.CorrectAnonymousTablesGenerateMissingChildWrappers / -/// CorrectAnonymousTablesGenerateMissingParents) against the shape the real Acid2 test's table line -/// exercises: a display:table parent whose non-row children have mixed display values, and stray -/// table-cell boxes with no table ancestor at all. -/// -/// -/// Direct reads of DomParser.cs confirm this fork's anonymous-table-object pass diverges from the -/// PeachPDF-fixed behavior these tests were written against in three separate, independently-confirmed ways - -/// see each test's own for the specific one it exercises. -/// [DoNotParallelize] [TestClass] public sealed class AnonymousTableBoxIntegrationTests { - [Ignore("Confirmed real bug, not a porting mistake: DomParser.CorrectAnonymousTablesGenerateMissingChildWrappers " + - "rule 2.1 (~line 940) - 'a child of a display:table box that isn't a proper table child gets an " + - "anonymous table-row wrapper' - only wraps the single box being visited (`box.ParentBox = tableRowBox`) " + - "and never gathers the run of consecutive non-proper-table-child siblings the way rule 2.3 a few lines " + - "below does (via DomUtils.GetFollowingSiblings). Also, DomUtils.IsProperTableChild (Utils/DomUtils.cs " + - "~line 152) does NOT list table-cell as a proper table child (only row-groups/table-row/table-column/" + - "table-column-group/table-caption), so ALL FOUR
      • children here (including the two already " + - "display:table-cell ones) fail the proper-table-child check and each gets its OWN separate anonymous " + - "table-row - the
          ends up with 4 child rows, not 1, and 'first'/'third' are not left as direct " + - "children of a shared row at all.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "tables.html#anonymous-boxes")] public void MixedNonRowChildren_AllGroupIntoOneAnonymousRow_WithOnlyNonCellsWrapped() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -69,8 +49,7 @@ public void MixedNonRowChildren_AllGroupIntoOneAnonymousRow_WithOnlyNonCellsWrap Assert.AreSame(row, fourth.ParentBox!.ParentBox); Assert.AreEqual(1, fourth.ParentBox!.Boxes.Count); - // The nested display:table on "second" establishes its own table, unaffected by the anonymous-cell - // wrapper around it. + // The nested table keeps its own display inside the anonymous cell Assert.AreEqual(CssConstants.Table, second.Display); // Row order must match document order. @@ -79,14 +58,9 @@ public void MixedNonRowChildren_AllGroupIntoOneAnonymousRow_WithOnlyNonCellsWrap row.Boxes); } - [Ignore("Confirmed real bug, not a porting mistake: DomParser.CorrectAnonymousTablesGenerateMissingChildWrappers " + - "rule 2.2 (~line 952) - 'a child of a row-group box that isn't table-row gets an anonymous table-row " + - "wrapper' - has the exact same non-grouping shape as rule 2.1 above: it only reparents the single box " + - "being visited and never consults DomUtils.GetFollowingSiblings, unlike rule 2.3's correctly-grouping " + - "sibling logic a few lines further down in the same method. So 'a' and 'b' (both bare s) each " + - "get their OWN separate anonymous table-row instead of sharing one - rowGroup.Boxes.Count ends up 3 " + - "(anonRow-for-a, anonRow-for-b, realrow), not 2.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "tables.html#anonymous-boxes")] public void RowGroupWithNonRowChildren_GroupsConsecutiveSiblingsIntoOneAnonymousRow() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -101,11 +75,8 @@ public void RowGroupWithNonRowChildren_GroupsConsecutiveSiblingsIntoOneAnonymous var rowGroup = FindByClass(root, "rowgroup")!; Assert.AreEqual(2, rowGroup.Boxes.Count); - // The anonymous row rule 2.2 wraps "a"/"b" in immediately cascades into rule 2.3 as well (the new row's - // own children, "a"/"b", still aren't table-cells) - so the row ends up with a single anonymous - // table-cell child wrapping both (via a further anonymous block box, the usual - // inline-content-needs-a-block-wrapper mechanism, unrelated to the table rules under test here), not - // "a"/"b" as its direct children. + // The anonymous row's children aren't cells either, so they share one anonymous cell, with the + // usual anonymous block box around the inline content in between var anonRow = rowGroup.Boxes[0]; Assert.AreEqual(CssConstants.TableRow, anonRow.Display); Assert.AreEqual(1, anonRow.Boxes.Count); @@ -120,27 +91,14 @@ public void RowGroupWithNonRowChildren_GroupsConsecutiveSiblingsIntoOneAnonymous Assert.AreSame(anonCell, a.ParentBox!.ParentBox); Assert.AreSame(anonCell, b.ParentBox!.ParentBox); - // Document order preserved: the anonymous row takes "a"'s original position, ahead of the real - // table-row that follows it. + // The anonymous row takes the place of "a", ahead of the real row Assert.AreSame(rowGroup, realRow.ParentBox); CollectionAssert.AreEqual(new[] { anonRow, realRow }, rowGroup.Boxes); } - [Ignore("Confirmed real bug, not a porting mistake, via two separate issues in " + - "DomParser.CorrectAnonymousTablesGenerateMissingParents (~line 979): " + - "(1) rule 3.2 - 'wrap a misparented proper table child in an anonymous table' - computes " + - "`isMisparented = isMissingParent && isParentNotTable && isParentNotInlineTable` (~line 1005): ANDing " + - "in `isMissingParent` (box.ParentBox == null) means the rule can only ever fire for a box with " + - "literally no parent at all, which practically never happens once rule 3.1 has already reparented the " + - "cell run under a real (non-null) anonymous table-row box - so the anonymous table-row here is never " + - "wrapped in an anonymous table at all; anonRow.ParentBox is just the , not a synthesized " + - "display:table box, so anonTable.HtmlTag is not null and anonTable.Display is 'block', not 'table'. " + - "(2) CssBox.ParentBox's setter (Dom/CssBox.cs ~line 117) unconditionally Boxes.Remove()s from the old " + - "parent then Boxes.Add()s (appends) to the new one - there is no SetBeforeBox-style reinsertion at the " + - "original sibling index (SetBeforeBox exists and IS used elsewhere in DomParser.cs for inline-box " + - "splitting, just not here) - so even the row itself lands at the END of body's children, after " + - "'after', not between 'before' and 'after' as document order requires.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "tables.html#anonymous-boxes")] public void MisparentedTableCell_WrappedInAnonymousRowThenTable_AtItsOriginalDocumentPosition() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -160,14 +118,12 @@ public void MisparentedTableCell_WrappedInAnonymousRowThenTable_AtItsOriginalDoc Assert.AreEqual(CssConstants.TableRow, anonRow.Display); Assert.AreSame(anonRow, cell2.ParentBox); - // 3.2: the anonymous row is wrapped in an anonymous table (the table formatting context the stray - // cells require per CSS2.1 §17.2.1) - without it the row would render outside any table. + // 3.2: the anonymous row is wrapped in an anonymous table var anonTable = anonRow.ParentBox!; Assert.IsNull(anonTable.HtmlTag); Assert.AreEqual(CssConstants.Table, anonTable.Display); - // The synthesized table must sit between "before" and "after" in document order, not after "after" - // (which is what a naive append-at-the-end would produce). + // The anonymous table takes the place of the cells, between "before" and "after" CollectionAssert.AreEqual(new[] { before, anonTable, after }, body.Boxes); } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableBorderIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableBorderIntegrationTests.cs new file mode 100644 index 000000000..0aebac4fa --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableBorderIntegrationTests.cs @@ -0,0 +1,212 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.Tables; + +[DoNotParallelize] +[TestClass] +public sealed class TableBorderIntegrationTests +{ + private const double Delta = 0.5; + + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + private static readonly RColor Blue = RColor.FromArgb(0, 0, 255); + + private static List VerticalLines(RecordingGraphics g, RColor color) => + g.Log.OfType().Where(l => l.Color == color && Math.Abs(l.X1 - l.X2) < 0.01).ToList(); + + [TestMethod] + [Spec("css21", "tables.html#borders")] + public void BorderCollapse_IsSeparateByDefault() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          ab
          ")); + + Assert.AreEqual(10, LayoutHarness.FindById(root, "b")!.Location.X - LayoutHarness.FindById(root, "a")!.ActualRight, Delta); + } + + [TestMethod] + [Spec("css21", "tables.html#separated-borders")] + public void BorderSpacing_SeparatesAdjoiningCellsAndTheTableBorder() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          ab
          cd
          ")); + + var t = LayoutHarness.FindById(root, "t")!; + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + var c = LayoutHarness.FindById(root, "c")!; + + Assert.AreEqual(10, b.Location.X - a.ActualRight, Delta); + Assert.AreEqual(10, c.Location.Y - a.ActualBottom, Delta); + Assert.AreEqual(t.Location.X + 3 + 10, a.Location.X, Delta); + Assert.AreEqual(t.Location.Y + 3 + 10, a.Location.Y, Delta); + Assert.AreEqual(b.ActualRight + 10 + 3, t.ActualRight, Delta); + } + + [TestMethod] + [Spec("css21", "tables.html#separated-borders")] + public void BorderSpacing_TwoLengthsAreHorizontalThenVertical() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          ab
          cd
          ")); + + var a = LayoutHarness.FindById(root, "a")!; + + Assert.AreEqual(4, LayoutHarness.FindById(root, "b")!.Location.X - a.ActualRight, Delta); + Assert.AreEqual(12, LayoutHarness.FindById(root, "c")!.Location.Y - a.ActualBottom, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#separated-borders")] + public void TablePadding_AddsToTheBorderSpacingAtTheEdges() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          a
          ")); + + var t = LayoutHarness.FindById(root, "t")!; + + Assert.AreEqual(t.Location.X + 7 + 10, LayoutHarness.FindById(root, "a")!.Location.X, Delta); + } + + [TestMethod] + [Spec("css21", "tables.html#separated-borders")] + public void RowBorders_AreIgnored() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
          ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsFalse(g.Log.OfType().Any(l => l.Color == Red)); + Assert.IsFalse(g.Log.OfType().Any(p => p.Color == Red)); + } + + [TestMethod] + [Spec("css21", "tables.html#empty-cells")] + public void EmptyCellsHide_PaintsNoBackgroundOrBorderAroundAnEmptyCell() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
          ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsFalse(g.Log.OfType().Any(r => r.Color == Red)); + Assert.IsFalse(g.Log.OfType().Any(l => l.Color == Red)); + } + + [TestMethod] + [Spec("css21", "tables.html#empty-cells")] + public void EmptyCellsShow_PaintsTheBackgroundOfAnEmptyCell() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
          ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsTrue(g.Log.OfType().Any(r => r.Color == Red)); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#empty-cells")] + public void EmptyCellsHide_StillPaintsTheBackgroundOfACellWithText() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
          x
          ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsTrue(g.Log.OfType().Any(r => r.Color == Blue)); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#empty-cells")] + public void EmptyCellsHide_RowOfOnlyEmptyCellsHasZeroHeight() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          a
          c
          ")); + + var a = LayoutHarness.FindById(root, "a")!; + + Assert.AreEqual(a.ActualBottom + 10, LayoutHarness.FindById(root, "c")!.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#collapsing-borders")] + public void CollapsedBorders_AreCenteredOnTheGridLineBetweenCells() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "" + + "" + + "
          ab
          ")); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(4, b.ClientLeft - a.ClientRight, Delta); + } + + [TestMethod] + [Spec("css21", "tables.html#collapsing-borders")] + public void Collapse_IgnoresTheBorderSpacing() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "" + + "" + + "
          ab
          ")); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.IsLessThanOrEqualTo(1, Math.Abs(b.Location.X - a.ActualRight)); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#border-conflict-resolution")] + public void WiderBorder_WinsTheSharedEdge() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "" + + "" + + "
          ")); + + var b = PaintHarness.FindById(root, "b")!; + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsFalse(VerticalLines(g, Red).Any(l => l.X1 > b.Location.X - 3)); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#border-conflict-resolution")] + public void TableBorder_WinsOverANarrowerCellBorder() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
          ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsFalse(g.Log.OfType().Any(l => l.Color == Red)); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#table-border-styles")] + public void HiddenBorder_SuppressesTheNeighboursBorder() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "" + + "" + + "
          ")); + + var b = PaintHarness.FindById(root, "b")!; + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsFalse(VerticalLines(g, Red).Any(l => l.X1 < b.Location.X + 5)); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableModelIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableModelIntegrationTests.cs new file mode 100644 index 000000000..3a1694cc8 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableModelIntegrationTests.cs @@ -0,0 +1,372 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Utils; + +namespace HtmlRenderer.IntegrationTest.Tables; + +[DoNotParallelize] +[TestClass] +public sealed class TableModelIntegrationTests +{ + private const double Delta = 0.5; + + [TestMethod] + [Spec("css21", "tables.html#table-display")] + public void DisplayValues_TurnArbitraryElementsIntoATable() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          " + + "
          a
          b
          " + + "
          c
          d
          " + + "
          ")); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + var c = LayoutHarness.FindById(root, "c")!; + var d = LayoutHarness.FindById(root, "d")!; + + Assert.AreEqual(a.Location.Y, b.Location.Y, Delta); + Assert.AreEqual(a.ActualRight, b.Location.X, Delta); + Assert.AreEqual(a.Location.X, c.Location.X, Delta); + Assert.AreEqual(b.Location.X, d.Location.X, Delta); + Assert.AreEqual(a.ActualBottom, c.Location.Y, Delta); + } + + [TestMethod] + [Spec("css21", "tables.html#table-display")] + public void HeaderGroup_IsDisplayedBeforeAndFooterGroupAfterTheBody() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "" + + "" + + "" + + "" + + "
          body
          ")); + + var head = LayoutHarness.FindById(root, "head")!; + var body = LayoutHarness.FindById(root, "body")!; + var foot = LayoutHarness.FindById(root, "foot")!; + + Assert.IsLessThan(body.Location.Y, head.Location.Y); + Assert.IsLessThan(foot.Location.Y, body.Location.Y); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#table-display")] + public void SecondHeaderGroup_IsTreatedAsARowGroupInSourceOrder() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          " + + "
          " + + "
          body
          " + + "
          second
          " + + "
          ")); + + var head = LayoutHarness.FindById(root, "head")!; + var body = LayoutHarness.FindById(root, "body")!; + var second = LayoutHarness.FindById(root, "second")!; + + Assert.IsLessThan(body.Location.Y, head.Location.Y); + Assert.IsGreaterThan(body.Location.Y, second.Location.Y); + Assert.AreEqual(head.Location.X, second.Location.X, Delta); + } + + [TestMethod] + [Spec("css21", "tables.html#table-display")] + public void ColumnElements_AreNotRendered() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          " + + "
          column text
          " + + "
          cell
          " + + "
          ")); + + var words = LayoutHarness.Descendants(LayoutHarness.FindById(root, "t")!) + .Where(b => b.Display != CssConstants.None) + .SelectMany(b => b.Words) + .Select(w => w.Text.Trim()) + .ToList(); + + CollectionAssert.DoesNotContain(words, "column"); + CollectionAssert.Contains(words, "cell"); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#table-display")] + public void InlineTable_SitsOnTheLineBetweenTheSurroundingText() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          beforecellafter
          ")); + + var before = LayoutHarness.Descendants(LayoutHarness.FindById(root, "before")!).SelectMany(b => b.Words).First(); + var after = LayoutHarness.Descendants(LayoutHarness.FindById(root, "after")!).SelectMany(b => b.Words).First(); + var table = LayoutHarness.FindById(root, "t")!; + + Assert.AreEqual(before.Top, after.Top, Delta); + Assert.IsGreaterThanOrEqualTo(before.Right - Delta, table.Location.X); + Assert.IsLessThanOrEqualTo(after.Left + Delta, table.ActualRight); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#anonymous-boxes")] + public void CellsWithoutARow_ShareOneAnonymousRow() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          a
          b
          ")); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreSame(a.ParentBox, b.ParentBox); + Assert.AreEqual(CssConstants.TableRow, a.ParentBox!.Display); + Assert.IsNull(a.ParentBox.HtmlTag); + Assert.AreEqual(a.Location.Y, b.Location.Y, Delta); + Assert.IsGreaterThan(a.Location.X, b.Location.X); + } + + [TestMethod] + [Spec("css21", "tables.html#anonymous-boxes")] + public void NonCellContentOfARow_GetsAnAnonymousCell() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          text
          ")); + + var s = LayoutHarness.FindById(root, "s")!; + var row = LayoutHarness.FindById(root, "r")!; + + var cell = row.Boxes.Single(); + Assert.AreEqual(CssConstants.TableCell, cell.Display); + Assert.IsNull(cell.HtmlTag); + Assert.IsTrue(LayoutHarness.Descendants(cell).Contains(s)); + } + + [TestMethod] + [Spec("css21", "tables.html#anonymous-boxes")] + public void ChildrenOfAColumn_AreNotRendered() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          " + + "
          group text
          column text
          " + + "
          cell
          " + + "
          ")); + + Assert.IsTrue(IsHidden(LayoutHarness.FindById(root, "g")!)); + Assert.IsTrue(IsHidden(LayoutHarness.FindById(root, "c")!)); + } + + private static bool IsHidden(CssBox box) + { + for (var b = box; b != null; b = b.ParentBox) + { + if (b.Display == CssConstants.None) + return true; + } + + return false; + } + + [TestMethod] + [Spec("css21", "tables.html#anonymous-boxes")] + public void WhiteSpaceBetweenRows_DoesNotCreateARow() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          \n" + + "
          a
          \n" + + "
          b
          \n" + + "
          ")); + + var table = LayoutHarness.FindById(root, "t")!; + + Assert.AreEqual(2, table.Boxes.Count(b => b.Display == CssConstants.TableRow)); + } + + [TestMethod] + [Spec("css21", "tables.html#columns")] + public void ColumnWidth_IsAMinimumForTheColumn() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "" + + "
          ab
          ")); + + var a = LayoutHarness.FindById(root, "a")!; + + Assert.AreEqual(120, a.Size.Width, 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#columns")] + public void ColumnBackground_IsPaintedBehindItsCells() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "" + + "
          ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsTrue(g.Log.OfType().Any(r => r.Color == RColor.FromArgb(0, 0, 255))); + } + + [TestMethod] + [Spec("css21", "tables.html#model")] + public void Margin_IsAppliedOutsideTheTable() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          a
          ")); + + var t = LayoutHarness.FindById(root, "t")!; + + Assert.AreEqual(30, t.Location.X, Delta); + Assert.AreEqual(30, t.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#model")] + public void InheritedTextAlign_DoesNotMoveTheTable() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          a
          ")); + + Assert.AreEqual(0, LayoutHarness.FindById(root, "t")!.Location.X, Delta); + } + + [TestMethod] + [Spec("css21", "tables.html#model")] + public void PercentageWidth_IsRelativeToTheContainingBlock() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          a
          ")); + + var t = LayoutHarness.FindById(root, "t")!; + + Assert.AreEqual(200, t.ActualRight - t.Location.X, 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#model")] + public void Caption_IsRenderedAboveTheTable() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          caption
          a
          ")); + + var c = LayoutHarness.FindById(root, "c")!; + var a = LayoutHarness.FindById(root, "a")!; + var next = LayoutHarness.FindById(root, "next")!; + + Assert.IsGreaterThan(c.Location.Y, c.ActualBottom); + Assert.IsLessThanOrEqualTo(a.Location.Y + Delta, c.ActualBottom); + Assert.IsLessThanOrEqualTo(next.Location.Y + Delta, c.ActualBottom); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#caption-position")] + public void CaptionSideBottom_PutsTheCaptionBelowTheTable() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          caption
          a
          ")); + + var c = LayoutHarness.FindById(root, "c")!; + var a = LayoutHarness.FindById(root, "a")!; + + Assert.IsGreaterThanOrEqualTo(a.ActualBottom - Delta, c.Location.Y); + } + + [TestMethod] + [Spec("css21", "tables.html#table-layout")] + public void RowspanCell_PushesTheNextRowsCellIntoTheFollowingColumn() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "" + + "" + + "
          xb
          c
          ")); + + var span = LayoutHarness.FindById(root, "span")!; + var b = LayoutHarness.FindById(root, "b")!; + var c = LayoutHarness.FindById(root, "c")!; + + Assert.AreEqual(b.Location.X, c.Location.X, Delta); + Assert.AreEqual(c.ActualBottom, span.ActualBottom, Delta); + } + + [TestMethod] + [Spec("css21", "tables.html#table-layout")] + public void ColspanCell_CoversTheColumnsItSpans() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "" + + "" + + "
          wide
          ab
          ")); + + var wide = LayoutHarness.FindById(root, "wide")!; + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(a.Location.X, wide.Location.X, Delta); + Assert.AreEqual(b.ActualRight, wide.ActualRight, Delta); + } + + [TestMethod] + [Spec("css21", "tables.html#table-layout")] + public void CellMargins_AreIgnored() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          ab
          ")); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(a.ActualRight, b.Location.X, Delta); + Assert.AreEqual(a.ActualRight, LayoutHarness.Descendants(b).SelectMany(x => x.Words).First().Left - b.ActualPaddingLeft - b.ActualBorderLeftWidth, Delta); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#table-layers")] + public void RowBackground_IsPaintedBehindItsCells() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
          ")); + + var a = PaintHarness.FindById(root, "a")!; + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + var red = g.Log.OfType().Where(r => r.Color == RColor.FromArgb(255, 0, 0)).ToList(); + Assert.IsTrue(red.Any(r => Math.Abs(r.X - a.Location.X) < 1 && Math.Abs(r.Y - a.Location.Y) < 1 && r.Width >= a.Size.Width - 1)); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#table-layers")] + public void RowGroupBackground_IsPaintedBehindItsCells() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
          ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsTrue(g.Log.OfType().Any(r => r.Color == RColor.FromArgb(255, 0, 0) && r.Width >= 39 && r.Height >= 19)); + } + + [TestMethod] + [Spec("css21", "tables.html#table-layers")] + public void CellBackground_IsPaintedOverTheTableBackground() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
          ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + var rects = g.Log.OfType().ToList(); + + var table = rects.FindIndex(r => r.Color == RColor.FromArgb(255, 255, 0)); + var cell = rects.FindIndex(r => r.Color == RColor.FromArgb(0, 128, 0)); + Assert.IsGreaterThanOrEqualTo(0, table); + Assert.IsGreaterThan(table, cell); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableSizingIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableSizingIntegrationTests.cs new file mode 100644 index 000000000..ba12df841 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableSizingIntegrationTests.cs @@ -0,0 +1,264 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.Tables; + +[DoNotParallelize] +[TestClass] +public sealed class TableSizingIntegrationTests +{ + private const double Delta = 0.5; + + private static double Width(CssBox box) => box.ActualRight - box.Location.X; + + private static double Height(CssBox box) => box.ActualBottom - box.Location.Y; + + private static double FirstWordTop(CssBox box) => LayoutHarness.Descendants(box).SelectMany(b => b.Words).First().Top; + + private static double FirstWordBottom(CssBox box) => LayoutHarness.Descendants(box).SelectMany(b => b.Words).First().Bottom; + + [TestMethod] + [Spec("css21", "tables.html#width-layout")] + public void AutoWidth_ShrinksToTheContent() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          short
          ")); + + Assert.IsLessThan(100, Width(LayoutHarness.FindById(root, "t")!)); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#width-layout")] + public void AutoMargins_CenterTheTable() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          a
          ")); + + Assert.AreEqual(100, LayoutHarness.FindById(root, "t")!.Location.X, 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#fixed-table-layout")] + public void FixedLayout_ColumnWidthsComeFromTheFirstRowOnly() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "" + + "" + + "
          ab
          widec
          ")); + + Assert.AreEqual(100, Width(LayoutHarness.FindById(root, "a")!), 1); + Assert.AreEqual(200, Width(LayoutHarness.FindById(root, "b")!), 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#fixed-table-layout")] + public void FixedLayout_ContentDoesNotWidenAColumn() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "" + + "
          Supercalifragilisticexpialidociousb
          ")); + + Assert.AreEqual(100, Width(LayoutHarness.FindById(root, "a")!), 1); + Assert.AreEqual(100, Width(LayoutHarness.FindById(root, "b")!), 1); + } + + [TestMethod] + [Spec("css21", "tables.html#auto-table-layout")] + public void ColumnWidth_IsTheWidestCellInTheColumn() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "" + + "" + + "
          a
          a much longer cell
          ")); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(Width(b), Width(a), Delta); + Assert.IsGreaterThan(50, Width(a)); + } + + [TestMethod] + [Spec("css21", "tables.html#auto-table-layout")] + public void SpecifiedTableWidth_IsDistributedOverTheColumns() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          ab
          ")); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(400, Width(a) + Width(b), 1); + Assert.AreEqual(400, Width(LayoutHarness.FindById(root, "t")!), 1); + } + + [TestMethod] + [Spec("css21", "tables.html#auto-table-layout")] + public void SpecifiedTableWidth_GrowsToTheMinimumContentWidth() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          Supercalifragilisticexpialidocious
          ")); + + var a = LayoutHarness.FindById(root, "a")!; + var word = LayoutHarness.Descendants(a).SelectMany(b => b.Words).First(); + + Assert.IsGreaterThanOrEqualTo(word.Width - 1, Width(a)); + Assert.IsGreaterThanOrEqualTo(Width(a) - 1, Width(LayoutHarness.FindById(root, "t")!)); + } + + [TestMethod] + [Spec("css21", "tables.html#auto-table-layout")] + public void CellWidth_IsAMinimumForItsColumn() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          ab
          ")); + + Assert.AreEqual(150, Width(LayoutHarness.FindById(root, "a")!), 1); + } + + [TestMethod] + [Spec("css21", "tables.html#auto-table-layout")] + public void PercentageColumnWidth_IsRelativeToTheTableWidth() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          ab
          ")); + + Assert.AreEqual(100, Width(LayoutHarness.FindById(root, "a")!), 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#auto-table-layout")] + public void ColumnGroupWidth_IsAMinimumForItsColumns() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "" + + "
          ab
          ")); + + Assert.AreEqual(200, Width(LayoutHarness.FindById(root, "a")!) + Width(LayoutHarness.FindById(root, "b")!), 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#auto-table-layout")] + public void CaptionMinimumWidth_WidensTheTable() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          Supercalifragilisticexpialidocious
          a
          ")); + + Assert.IsGreaterThan(100, Width(LayoutHarness.FindById(root, "t")!)); + } + + [TestMethod] + [Spec("css21", "tables.html#height-layout")] + public void TableHeight_IsAMinimumHeight() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          a
          ")); + + Assert.AreEqual(200, Height(LayoutHarness.FindById(root, "t")!), 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#height-layout")] + public void RowHeight_IsAMinimumHeight() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          a
          ")); + + Assert.AreEqual(80, Height(LayoutHarness.FindById(root, "a")!), 1); + } + + [TestMethod] + [Spec("css21", "tables.html#height-layout")] + public void CellHeight_SetsTheRowHeight() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          ab
          ")); + + Assert.AreEqual(80, Height(LayoutHarness.FindById(root, "a")!), 1); + Assert.AreEqual(80, Height(LayoutHarness.FindById(root, "b")!), 1); + } + + [TestMethod] + [Spec("css21", "tables.html#height-layout")] + public void RowspanCell_IsCoveredByTheRowsItSpans() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "" + + "" + + "
          xa
          b
          ")); + + var span = LayoutHarness.FindById(root, "span")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.IsGreaterThanOrEqualTo(span.ActualBottom - Delta, b.ActualBottom); + } + + [TestMethod] + [Spec("css21", "tables.html#height-layout")] + public void VerticalAlignTop_KeepsTheContentAtTheTopOfTheRow() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          tallshort
          ")); + + var v = LayoutHarness.FindById(root, "v")!; + + Assert.AreEqual(v.Location.Y, FirstWordTop(v), 1); + } + + [TestMethod] + [Spec("css21", "tables.html#height-layout")] + public void VerticalAlignBottom_PutsTheContentAtTheBottomOfTheRow() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
          tallshort
          ")); + + var v = LayoutHarness.FindById(root, "v")!; + + Assert.AreEqual(v.ActualBottom, FirstWordBottom(v), 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "tables.html#height-layout")] + public void VerticalAlignBaseline_AlignsTheFirstLinesOfTheCells() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "" + + "" + + "
          Bigsmall
          ")); + + Assert.AreEqual(FirstWordBottom(LayoutHarness.FindById(root, "big")!), FirstWordBottom(LayoutHarness.FindById(root, "small")!), 3); + } + + [TestMethod] + [Spec("css21", "tables.html#height-layout")] + public void VerticalAlignSub_IsTreatedAsBaseline() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "" + + "
          tallshortshort
          ")); + + Assert.AreEqual(FirstWordTop(LayoutHarness.FindById(root, "w")!), FirstWordTop(LayoutHarness.FindById(root, "v")!), Delta); + } + + [TestMethod] + [Spec("css21", "tables.html#column-alignment")] + public void TextAlign_AlignsTheContentInsideTheCell() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "" + + "
          x
          x
          ")); + + var l = LayoutHarness.FindById(root, "l")!; + var r = LayoutHarness.FindById(root, "r")!; + + Assert.AreEqual(l.Location.X, LayoutHarness.Descendants(l).SelectMany(b => b.Words).First().Left, 1); + Assert.AreEqual(r.ActualRight, LayoutHarness.Descendants(r).SelectMany(b => b.Words).First().Right, 1); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableVisibilityCollapseIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableVisibilityCollapseIntegrationTests.cs index 2e37d998b..62325d258 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableVisibilityCollapseIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableVisibilityCollapseIntegrationTests.cs @@ -4,38 +4,16 @@ namespace HtmlRenderer.IntegrationTest.Tables; -/// -/// CSS 2.1 §17.6.1: visibility: collapse on a table row/row-group/column/column-group removes it -/// from the table's geometry entirely - as if it had display: none - so the rows/columns after it -/// shift up/left to fill the gap. Distinct from visibility: hidden, which reserves the element's -/// layout space and only omits painting it. -/// -/// -/// HTML-Renderer fact (confirmed, Dom\CssLayoutEngineTable.cs): zero awareness of -/// visibility:collapse anywhere in the table layout engine (only unrelated border-collapse/ -/// PageBreakInside hits exist for the word "collapse") - a <tr>/<col>/etc. with -/// visibility:collapse is NOT removed from the table's geometry/height/width calculations at all, so -/// every test below that actually exercises collapse-driven removal is [Ignore]d. The one test that does -/// NOT depend on collapse-specific logic (only on plain visibility:hidden reserving space, a much -/// more basic/general mechanism most CSS engines implement) is left active. -/// [DoNotParallelize] [TestClass] public sealed class TableVisibilityCollapseIntegrationTests { - private const string CollapseNotImplemented = - "This fork's CssLayoutEngineTable has zero awareness of visibility:collapse (confirmed: no " + - "collapse/visibility handling anywhere in Dom/CssLayoutEngineTable.cs beyond unrelated " + - "border-collapse/PageBreakInside hits) - a collapsed row/row-group/column/column-group is not " + - "removed from the table's geometry, so it keeps taking up its normal space instead of being " + - "skipped like a genuinely absent row/column."; - - [Ignore(CollapseNotImplemented)] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "tables.html#dynamic-effects")] public void CollapsedRow_DirectTableChild_TakesNoSpace() { - // as a direct child of (no row group) - CssLayoutEngineTable.AssignBoxKinds' - // Keywords.TableRow branch. + // as a direct child of
          , no row group var (experiment, _) = LayoutHarness.Layout("""
          @@ -58,11 +36,12 @@ public void CollapsedRow_DirectTableChild_TakesNoSpace() AssertSameLocation(FindByClass(control, "c")!, FindByClass(experiment, "c")!); } - [Ignore(CollapseNotImplemented)] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "tables.html#dynamic-effects")] public void CollapsedRow_InsideRowGroup_TakesNoSpace() { - // A inside a - the Keywords.TableRowGroup branch. + // A inside a var (experiment, _) = LayoutHarness.Layout("""
          @@ -120,12 +99,12 @@ public void RowspanCrossingCollapsedRow_DoesNotMisalignLaterRow() AssertSameLocation(FindByClass(control, "after2")!, FindByClass(experiment, "after2")!); } - [Ignore(CollapseNotImplemented)] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "tables.html#dynamic-effects")] public void CollapsedRowGroup_CollapsesEveryRowInsideIt() { - // visibility is an inherited property, so visibility:collapse on the itself must collapse - // every row inside it without the row declaring anything. + // visibility is inherited, so the rows collapse with their group var (experiment, _) = LayoutHarness.Layout("""
          @@ -154,10 +133,7 @@ public void CollapsedRowGroup_CollapsesEveryRowInsideIt() [TestMethod] public void HiddenRow_StillReservesSpace_UnlikeCollapse() { - // Guard distinguishing the two values: visibility:hidden must keep reserving the row's space (only - // paint is skipped), so the row after it must NOT land where it would if the hidden row had been - // collapsed instead. Unlike the rest of this file, this does not depend on collapse-specific - // removal logic at all - only on generic visibility:hidden still occupying layout space. + // visibility:hidden keeps the row's space, unlike collapse var (hidden, _) = LayoutHarness.Layout("""
          @@ -183,12 +159,12 @@ public void HiddenRow_StillReservesSpace_UnlikeCollapse() $"a visibility:hidden row must still reserve its space, but hiddenC.Y={hiddenC.Location.Y} controlC.Y={controlC.Location.Y}"); } - [Ignore(CollapseNotImplemented)] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "tables.html#dynamic-effects")] public void CollapsedRow_InsideDetachedHeader_TakesNoSpace() { - // A is detached from the row-loop and measured separately - a collapsed row inside it has - // its own skip there, if collapse were implemented at all. + // The header group is laid out separately from the body rows var (experiment, _) = LayoutHarness.Layout("""
          @@ -215,8 +191,9 @@ public void CollapsedRow_InsideDetachedHeader_TakesNoSpace() AssertSameLocation(FindByClass(control, "body")!, FindByClass(experiment, "body")!); } - [Ignore(CollapseNotImplemented)] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "tables.html#dynamic-effects")] public void CollapsedRow_InsideDetachedFooter_TakesNoSpace() { var (experiment, _) = LayoutHarness.Layout(""" @@ -245,8 +222,9 @@ public void CollapsedRow_InsideDetachedFooter_TakesNoSpace() Assert.AreEqual(FindByClass(control, "t")!.ActualBottom, FindByClass(experiment, "t")!.ActualBottom, 3); } - [Ignore(CollapseNotImplemented)] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "tables.html#dynamic-effects")] public void CollapsedColumn_TakesNoWidth() { var (experiment, _) = LayoutHarness.Layout(""" @@ -288,13 +266,12 @@ public void CollapsedColumn_TakesNoWidth() Assert.AreEqual(controlTable.ActualRight, experimentTable.ActualRight, 3); } - [Ignore(CollapseNotImplemented)] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "tables.html#dynamic-effects")] public void CollapsedColumn_WideContentDoesNotStarveNextColumn() { - // An auto-width table's content-based column sizing spreads whatever width is left over toward - // each column's own max-content width. A collapsed column's own (invisible) unbreakable content - // must not count against that leftover, if collapse were implemented. + // The collapsed column's unbreakable content must not take width from the visible column var (experiment, _) = LayoutHarness.Layout("""
          @@ -337,13 +314,12 @@ public void CollapsedColumn_WideContentDoesNotStarveNextColumn() Assert.AreEqual(controlC.ActualWidth, experimentC.ActualWidth, 3); } - [Ignore(CollapseNotImplemented)] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "tables.html#dynamic-effects")] public void CollapsedColumn_WithBorderSpacing_LeavesNoResidualGap() { - // Regression guard: a collapsed column must not leave one border-spacing unit behind, which only - // shows once border-spacing is non-zero - the default UA stylesheet sets border-spacing:2px on - // every table, so this is the common case, if collapse were implemented. + // A collapsed column must not leave its border-spacing behind var (experiment, _) = LayoutHarness.Layout("""
          @@ -420,13 +396,12 @@ public void CollapsedColumnGroup_CollapsesEveryColumnInsideIt() Assert.AreEqual(controlA.ActualWidth, experimentA.ActualWidth, 3); } - [Ignore(CollapseNotImplemented)] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "tables.html#dynamic-effects")] public void ColspanCell_StraddlingVisibleThenCollapsedColumn_NoResidualBorderSpacing() { - // A colspan cell spanning a visible column followed by a collapsed one has its trailing edge land - // on the collapsed column - like a cell confined entirely to collapsed column(s), the - // border-spacing slot after it must be omitted too, if collapse were implemented. + // A colspan cell ending in a collapsed column must not keep the border-spacing after it var (experiment, _) = LayoutHarness.Layout("""
          @@ -461,13 +436,12 @@ public void ColspanCell_StraddlingVisibleThenCollapsedColumn_NoResidualBorderSpa AssertSameLocation(FindByClass(control, "c")!, FindByClass(experiment, "c")!); } - [Ignore(CollapseNotImplemented)] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "tables.html#dynamic-effects")] public void ColspanCell_StraddlingCollapsedThenVisibleColumn_NoExtraInteriorSpacing() { - // Mirror case: a colspan cell spanning a collapsed column followed by a visible one. The boundary - // strictly between the cell's own two spanned columns must be omitted, if collapse were - // implemented. + // A colspan cell starting in a collapsed column must not keep the border-spacing inside it var (experiment, _) = LayoutHarness.Layout("""
          @@ -505,9 +479,7 @@ public void ColspanCell_StraddlingCollapsedThenVisibleColumn_NoExtraInteriorSpac [TestMethod] public void ColspanCell_StraddlingCollapsedColumn_ContentDoesNotShareWidthWithIt() { - // A straddling cell's content-based min-width must not be divided evenly across every column its - // colspan reaches when one of them is collapsed and will never carry any of it, if collapse were - // implemented. + // The spanning cell's min-width must not be shared with the collapsed column var (experiment, _) = LayoutHarness.Layout("""
          diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs index 811dc3d72..6dbc904df 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs @@ -123,12 +123,9 @@ public void TextTop_ReferencesParentFontAscent_NotJustLineTop() } [TestMethod] + [Spec("css21", "tables.html#height-layout")] public void Bottom_OnATableCell_PushesShortContentLowerThanTopAligned() { - // CssLayoutEngine.ApplyCellVerticalAlignment's table-specific alignment algorithm (distinct from the - // inline ApplyVerticalAlignment exercised by the other tests in this file) - a short cell in a taller - // row must be pushed all the way to the row's bottom under vertical-align:bottom, unlike - // vertical-align:top where it stays put. var htmlTop = LayoutHarness.Wrap( "
          " + "" @@ -151,6 +148,7 @@ public void Bottom_OnATableCell_PushesShortContentLowerThanTopAligned() } [TestMethod] + [Spec("css21", "tables.html#height-layout")] public void Middle_OnATableCellWithExplicitHeight_CentersShortContent() { var htmlTop = LayoutHarness.Wrap( @@ -180,8 +178,6 @@ public void Middle_OnATableCellWithExplicitHeight_CentersShortContent() Assert.IsTrue(middleY > topY && middleY < bottomY, $"vertical-align:middle ({middleY}) should land strictly between vertical-align:top ({topY}) and vertical-align:bottom ({bottomY}) even with an explicit cell height"); - // ApplyCellVerticalAlignment splits the leftover room evenly for `middle` (half of what `bottom` - // moves it by), so middle must sit at the exact midpoint. Assert.AreEqual((topY + bottomY) / 2, middleY, Delta); } diff --git a/Source/Test/HtmlRenderer.Test/Dom/CssLayoutEngineTableTests.cs b/Source/Test/HtmlRenderer.Test/Dom/CssLayoutEngineTableTests.cs index df106051e..27aa9d48b 100644 --- a/Source/Test/HtmlRenderer.Test/Dom/CssLayoutEngineTableTests.cs +++ b/Source/Test/HtmlRenderer.Test/Dom/CssLayoutEngineTableTests.cs @@ -4,36 +4,36 @@ namespace HtmlRenderer.Test.Dom; -/// -/// Basic table layout tests against , -/// ported from PeachPDF's (much larger) CssLayoutEngineTableTests. HTML-Renderer's table engine is a -/// simpler fork ancestor -- it has no header/footer repeat-across-pages proxies, no caption -/// grid-decoration box, and (unlike PeachPDF's CssBox.Display enum) represents "Display" as a -/// plain string compared against . Only the parts of the original suite that -/// exercise basic dimension/colspan/rowspan layout -- functionality this engine actually has -- are -/// ported here. -/// [TestClass] public sealed class CssLayoutEngineTableTests { [TestMethod] + [Spec("css21", "tables.html#table-layout")] public void TableLayout_CalculatesCorrectDimensions() { var html = LayoutHarness.Wrap( "
          Tall
          " + - "" + - "" + + "" + + "" + "
          Cell 1Cell 2
          Cell 3Cell 4
          Cell 1Cell 2
          Cell 3Cell 4
          "); var (root, _) = LayoutHarness.Layout(html); - var table = LayoutHarness.FindById(root, "tbl"); - - Assert.IsNotNull(table); - Assert.IsTrue(table!.ActualRight > table.Location.X, "Table should have width"); - Assert.IsTrue(table.ActualBottom > table.Location.Y, "Table should have height"); + var c1 = LayoutHarness.FindById(root, "c1")!; + var c2 = LayoutHarness.FindById(root, "c2")!; + var c3 = LayoutHarness.FindById(root, "c3")!; + var c4 = LayoutHarness.FindById(root, "c4")!; + + // The cells form a grid: rows share a top, columns share a left edge. + Assert.AreEqual(c1.Location.Y, c2.Location.Y, 0.5); + Assert.AreEqual(c3.Location.Y, c4.Location.Y, 0.5); + Assert.AreEqual(c1.Location.X, c3.Location.X, 0.5); + Assert.AreEqual(c2.Location.X, c4.Location.X, 0.5); + Assert.IsGreaterThanOrEqualTo(c1.ActualRight - 1, c2.Location.X); + Assert.IsGreaterThanOrEqualTo(c1.ActualBottom - 1, c3.Location.Y); } [TestMethod] + [Spec("css21", "tables.html#table-layout")] public void TableLayout_WithColspan_CalculatesCorrectWidth() { var html = LayoutHarness.Wrap( @@ -63,24 +63,28 @@ public void TableLayout_WithColspan_CalculatesCorrectWidth() } [TestMethod] + [Spec("css21", "tables.html#height-layout")] public void TableLayout_WithRowspan_CalculatesCorrectHeight() { var html = LayoutHarness.Wrap( "" + - "" + - "" + + "" + + "" + "" + "
          Tall CellCell 2
          Cell 3
          Tall CellCell 2
          Cell 3
          Cell 4Cell 5
          "); var (root, _) = LayoutHarness.Layout(html); - var tallCell = LayoutHarness.FindById(root, "tall"); + var tallCell = LayoutHarness.FindById(root, "tall")!; + var c2 = LayoutHarness.FindById(root, "c2")!; + var c3 = LayoutHarness.FindById(root, "c3")!; - Assert.IsNotNull(tallCell); - var tallCellHeight = tallCell!.ActualBottom - tallCell.Location.Y; - Assert.IsTrue(tallCellHeight > 0, "Rowspan cell should have height"); + // A cell spanning two rows is as tall as both rows together. + Assert.AreEqual(c2.Location.Y, tallCell.Location.Y, 0.5); + Assert.AreEqual(c3.ActualBottom, tallCell.ActualBottom, 0.5); } [TestMethod] + [Spec("css21", "tables.html#auto-table-layout")] public void TableLayout_DistributesWidthEqually_WhenNoWidthsSpecified() { var html = LayoutHarness.Wrap( @@ -110,11 +114,9 @@ public void TableLayout_DistributesWidthEqually_WhenNoWidthsSpecified() } [TestMethod] + [Spec("css21", "tables.html#auto-table-layout")] public void TableLayout_RespectsSpecifiedColumnWidths() { - // Adapted from the source test: HTML-Renderer's CssParser has no pseudo-class selector support - // (no ":first-child"), so the explicit width is applied directly on the first cell via an - // inline style rather than through a "td:first-child { width: ... }" rule. var html = LayoutHarness.Wrap( "" + "" + @@ -136,22 +138,9 @@ public void TableLayout_RespectsSpecifiedColumnWidths() var auto1Width = auto1Cell!.ActualRight - auto1Cell.Location.X; Assert.IsTrue(wideWidth >= 180, $"First cell should be approximately 200px wide (accounting for borders), but was {wideWidth}"); - // The remaining table width (600pt minus the 200pt explicit column) is distributed across the - // three auto columns - via their content-based max width plus an equal share of the leftover - // space (see CssLayoutEngineTable.DetermineMissingColumnWidths) - rather than split evenly by a - // fixed pixel budget, so assert the semantically-intended relationship (narrower than the - // explicitly-widened column) instead of an arbitrary absolute threshold. Assert.IsTrue(auto1Width < wideWidth, $"Auto cell ({auto1Width}) should be narrower than the explicitly-widened first cell ({wideWidth})"); } - // Cherry-picked from PeachPDF's CssLayoutEngineTableTests (the rest of that file is general table - // layout, already out of scope for this port - see Fragmentation/CssLayoutEngineTablePageBreakTests.cs - // for the dedicated pagination-focused port). Adapted: PeachPDF's own version asserts against - // table.Boxes.OfType() (its in-tree header-repeat proxy mechanism); this fork instead - // detaches repeated header clones onto CssBox.RepeatedHeaderRows (Core/Fragmentation/TableHeaderRepeat.cs) - // rather than inserting them into the live tree, so the assertions are rewritten onto that. Also drops - // the source's "@page { size: A4; margin: 20mm }" CSS rule (this fork's page grid is set on the - // container directly, via LayoutHarness's pageHeight parameter, not through @page). [TestMethod] public void TableLayout_DetectsPageBreaksCorrectly() { diff --git a/Spec/css21.yml b/Spec/css21.yml index 48330cbe8..e7479f78b 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -889,67 +889,106 @@ sections: - Control characters such as U+000B and U+0085 are treated as white space instead of being rendered tables.html#q17.0: title: 17 Tables - status: unknown + status: partial tables.html#tables-intro: title: 17.1 Introduction to tables - status: unknown + status: n/a tables.html#table-display: title: 17.2 The CSS table model - status: unknown + status: partial + gaps: + - A second 'table-header-group' or 'table-footer-group' is laid out as one row with its rows as cells, which loses their content + - "'inline-table' is laid out like a block-level table and breaks the line" tables.html#anonymous-boxes: title: 17.2.1 Anonymous table objects - status: unknown + status: partial + gaps: + - Consecutive children of a table or row group that aren't rows each get their own anonymous row instead of sharing one + - Misparented rows, row groups, columns and captions don't get an anonymous table + - Anonymous wrappers are appended as the parent's last child instead of taking the place of the boxes they wrap tables.html#columns: title: 17.3 Columns - status: unknown + status: partial + gaps: + - Column and column group backgrounds aren't painted tables.html#model: title: 17.4 Tables in the visual formatting model - status: unknown + status: partial + gaps: + - Captions aren't laid out or painted + - "An inherited 'text-align: center' or 'right' moves an auto-width table itself" tables.html#caption-position: title: 17.4.1 Caption position and alignment - status: unknown + status: unsupported + gaps: + - "'caption-side' has no effect, captions aren't rendered at all" tables.html#table-layout: title: 17.5 Visual layout of table contents - status: unknown + status: partial tables.html#table-layers: title: 17.5.1 Table layers and transparency - status: unknown + status: partial + gaps: + - Row and row group backgrounds aren't painted tables.html#width-layout: title: 17.5.2 Table width algorithms - status: unknown + status: partial + gaps: + - "'auto' margins don't center a table" tables.html#fixed-table-layout: title: 17.5.2.1 Fixed table layout - status: unknown + status: parsed-only + gaps: + - "'table-layout: fixed' is ignored, column widths always come from the automatic layout" tables.html#auto-table-layout: title: 17.5.2.2 Automatic table layout - status: unknown + status: partial + gaps: + - A column group's 'width' doesn't widen its columns + - The caption's minimum width doesn't widen the table tables.html#height-layout: title: 17.5.3 Table height algorithms - status: unknown + status: partial + gaps: + - A row's 'height' is ignored + - "'vertical-align: baseline' aligns like 'top', cells aren't aligned on a shared row baseline" tables.html#column-alignment: title: 17.5.4 Horizontal alignment in a column - status: unknown + status: supported tables.html#dynamic-effects: title: 17.5.5 Dynamic row and column effects - status: unknown + status: unsupported + gaps: + - "Rows, row groups, columns and column groups with 'visibility: collapse' keep their space" tables.html#borders: title: 17.6 Borders - status: unknown + status: partial tables.html#separated-borders: title: 17.6.1 The separated borders model - status: unknown + status: partial + gaps: + - The table's padding is dropped, the edge cells are only inset by the border spacing tables.html#empty-cells: title: 17.6.1.1 Borders and Backgrounds around empty cells - status: unknown + status: partial + gaps: + - "'hide' also hides cells whose content is in child boxes, i.e. any cell with text" + - A row of only hidden empty cells keeps its height and its border spacing on both sides tables.html#collapsing-borders: title: 17.6.2 The collapsing border model - status: unknown + status: partial + gaps: + - Adjacent cell borders are drawn side by side with a 1px overlap instead of as one border centered on the grid line tables.html#border-conflict-resolution: title: 17.6.2.1 Border conflict resolution - status: unknown + status: unsupported + gaps: + - Borders aren't resolved, every cell and the table paint their own tables.html#table-border-styles: title: 17.6.3 Border styles - status: unknown + status: unsupported + gaps: + - "'hidden' doesn't suppress the other borders at the same edge" ui.html#q18.0: title: 18 User interface status: unknown From db0b5ac7c1a8cba6d64f0250042845048b66c903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:09:54 +0100 Subject: [PATCH 023/150] Verify and document CSS 2.1 user interface compliance --- .../HtmlRenderer/Core/Dom/CssBoxProperties.cs | 15 +-- .../Core/Handlers/OutlineDrawHandler.cs | 11 +- .../OutlineStylePaintIntegrationTests.cs | 104 ++++++++++++++---- .../SystemColorPaintIntegrationTests.cs | 67 +++++++++++ .../Css/OutlinePropertyTests.cs | 32 +++--- Spec/css21.yml | 23 ++-- 6 files changed, 187 insertions(+), 65 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/SystemColorPaintIntegrationTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index ec296f2f3..3df953e83 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs @@ -753,10 +753,7 @@ public string OutlineWidth } /// - /// The resolved outline-width in pixels ("thin"/"medium"/"thick" keywords resolved, like - /// ), or 0 when outline-style is none/hidden - /// (an outline with a style of none/hidden never paints, regardless of width - CSS 2.1 §18.1's - /// invert/no-op default matches border's own "no style, no width" rule). + /// The resolved outline-width in pixels, 0 without an outline style, CSS 2.1 §18.4. /// public double ActualOutlineWidth { @@ -771,18 +768,14 @@ public double ActualOutlineWidth } /// - /// The resolved paint color for outline-color - falls back to this box's own - /// (i.e. currentColor) when outline-color is unset, which - /// is what every real browser does today for the property's nominal invert initial value - /// (true color inversion is not implemented here). + /// The resolved paint color for outline-color. Without support for invert the initial + /// value is the element's color, CSS 2.1 §18.4. /// public RColor ActualOutlineColor { get { - // "transparent" is OutlineColorProperty's own cascaded initial value (Color.Transparent) - - // treated the same as unset, since an invisible-by-default outline would defeat the point - // of the property entirely. + // The engine's initial value of outline-color is transparent return string.IsNullOrEmpty(OutlineColor) || OutlineColor == "transparent" || OutlineColor.Equals("invert", StringComparison.OrdinalIgnoreCase) ? ActualColor diff --git a/Source/HtmlRenderer/Core/Handlers/OutlineDrawHandler.cs b/Source/HtmlRenderer/Core/Handlers/OutlineDrawHandler.cs index 87002924f..426bb294b 100644 --- a/Source/HtmlRenderer/Core/Handlers/OutlineDrawHandler.cs +++ b/Source/HtmlRenderer/Core/Handlers/OutlineDrawHandler.cs @@ -18,16 +18,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Handlers { /// - /// Paints outline (CSS 2.1 §18.1) as a plain rectangular ring around the box's own border-box - /// edge - unlike , an outline has no per-side bevel/corner-joining to - /// account for (it isn't part of the box model and doesn't affect layout), so this is a single flat - /// four-rectangle fill rather than a per-side polygon.
          - /// Deliberately a subset of the full property: only solid (and auto, treated the same - /// as solid per spec) is painted - dotted/dashed/double/groove/ - /// ridge/inset/outset are not (a box with one of those styles simply paints no - /// outline, same as none). outline-offset is not implemented either (the CSS-OM here has - /// no outline-offset property at all to read a value from), so the ring always sits flush - /// against the border-box edge. + /// Paints outline, CSS 2.1 §18.4, as a ring of four rectangles just outside the border box. ///
          internal static class OutlineDrawHandler { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs index 45db4d71b..1c0f2959a 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs @@ -4,18 +4,12 @@ namespace HtmlRenderer.IntegrationTest.Painting; -/// -/// Ported from PeachPDF.Tests' OutlineStylePaintIntegrationTests.cs, adapted to this fork's new (and more -/// limited) OutlineDrawHandler - see that class's remarks for exactly what's implemented: a plain -/// rectangular ring for solid/auto only, no outline-offset (this fork's CSS-OM has no -/// such property to read a value from), and every other style (dotted/dashed/double/ -/// groove/ridge/inset/outset) paints nothing, same as none. -/// [DoNotParallelize] [TestClass] public sealed class OutlineStylePaintIntegrationTests { [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void SolidOutline_PaintsAFourSidedRingAroundTheBorderBox() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -28,19 +22,12 @@ public void SolidOutline_PaintsAFourSidedRingAroundTheBorderBox() Assert.AreEqual(4, rects.Count, "expected one filled rect per side of the outline ring"); - // Two of the four bands are 5px tall (top/bottom, spanning the full outer width including - // corners) and two are 5px wide (left/right, spanning just the box's own height) - regardless of - // which order OutlineDrawHandler emits them in. + // Top and bottom bands are 5px tall, left and right bands 5px wide Assert.AreEqual(2, rects.Count(r => System.Math.Abs(r.Height - 5) < 0.1)); Assert.AreEqual(2, rects.Count(r => System.Math.Abs(r.Width - 5) < 0.1)); } - [Ignore("outline-style:auto cannot be parsed at all on this fork: Map.LineStyles (Core/CssEngine/Model/" + - "Map.cs), the shared keyword table outline-style's converter reuses from border-style, has no " + - "\"auto\" entry - adding one there would also make it a legal (but spec-invalid) border-style " + - "value, so this needs its own dedicated converter rather than a shared-map edit, which is out of " + - "scope for this pass. OutlineDrawHandler.Draw's own \"style == Auto\" check is consequently dead " + - "code today - reachable only by setting the property programmatically, never via CSS text.")] + [Ignore("outline-style: auto (CSS UI 3) isn't parsed")] [TestMethod] public void OutlineAuto_PaintsTheSameAsSolid() { @@ -56,6 +43,7 @@ public void OutlineAuto_PaintsTheSameAsSolid() } [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void OutlineColorUnset_FallsBackToCurrentColor() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -70,10 +58,9 @@ public void OutlineColorUnset_FallsBackToCurrentColor() } [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void Outline_PaintsAfterBorder() { - // Paint-order: DrawBoxBorders runs, then OutlineDrawHandler - the outline's draw calls must come - // after the border's in the recording. var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( "
          x
          ")); var div = PaintHarness.FindById(root, "b")!; @@ -90,6 +77,7 @@ public void Outline_PaintsAfterBorder() [TestMethod] [DataRow("none")] [DataRow("hidden")] + [Spec("css21", "ui.html#dynamic-outlines")] public void OutlineStyleNoneOrHidden_PaintsNothing(string style) { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -102,6 +90,7 @@ public void OutlineStyleNoneOrHidden_PaintsNothing(string style) } [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void ZeroWidthOutline_PaintsNothing() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -113,10 +102,7 @@ public void ZeroWidthOutline_PaintsNothing() Assert.IsFalse(g.Log.OfType().Any(r => r.Color == RColor.FromArgb(51, 51, 51))); } - [Ignore("outline-offset is not implemented on this fork: the CSS-OM here has no OutlineOffsetProperty at " + - "all (confirmed - no PropertyNames.OutlineOffset, no case in PropertyFactory), so there is no value " + - "for OutlineDrawHandler to read; the ring always sits flush against the border-box edge instead of " + - "the requested 10px further out.")] + [Ignore("outline-offset (CSS UI 3) isn't parsed")] [TestMethod] public void OutlineOffset_PushesTheRingFurtherFromTheBorderBox() { @@ -132,6 +118,7 @@ public void OutlineOffset_PushesTheRingFurtherFromTheBorderBox() } [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void OutlineColorInvert_FallsBackToCurrentColor() { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -147,6 +134,7 @@ public void OutlineColorInvert_FallsBackToCurrentColor() [TestMethod] [DataRow("")] [DataRow("
          ")] + [Spec("css21", "ui.html#dynamic-outlines")] public void ReplacedAndRuleElements_PaintTheirOutline(string element) { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap(element)); @@ -171,4 +159,76 @@ public void InlineSplitAroundABlock_KeepsTheOutlineOnEveryPart() Assert.IsGreaterThanOrEqualTo(8, rects.Count, "both the part before and the part after the block need a ring"); } + + [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] + public void Outline_DoesNotTakeUpSpace() + { + var (root, _) = PaintHarness.Layout(PaintHarness.Wrap( + "
          ")); + + var a = PaintHarness.FindById(root, "a")!; + var b = PaintHarness.FindById(root, "b")!; + + Assert.AreEqual(0, a.Location.X, 0.1); + Assert.AreEqual(0, a.Location.Y, 0.1); + Assert.AreEqual(50, b.Location.Y, 0.1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [DataRow("dotted")] + [DataRow("dashed")] + [DataRow("double")] + [DataRow("groove")] + [DataRow("ridge")] + [DataRow("inset")] + [DataRow("outset")] + [Spec("css21", "ui.html#dynamic-outlines")] + public void OutlineStyle_AcceptsEveryBorderStyle(string style) + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + $"
          x
          ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + + // The box has no background or border, every shape painted belongs to the outline + var shapes = g.Log.Count(c => c is RecordingGraphics.DrawRectCall or RecordingGraphics.DrawLineCall or RecordingGraphics.DrawPolygonCall); + Assert.IsGreaterThan(0, shapes, $"'{style}' outline painted nothing"); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] + public void Outline_IsPaintedOnTopOfTheContent() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
          " + + "
          ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + + var outlineIndex = g.Log.FindLastIndex(c => c is RecordingGraphics.DrawRectCall r && r.Color == RColor.FromArgb(51, 51, 51)); + var childIndex = g.Log.FindIndex(c => c is RecordingGraphics.DrawRectCall r && r.Color == RColor.FromArgb(10, 20, 30)); + + Assert.IsTrue(outlineIndex >= 0 && childIndex >= 0); + Assert.IsTrue(outlineIndex > childIndex, "the child's background covers the outline"); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] + public void InlineBrokenAcrossLines_GetsOneOutlineEnclosingAllItsBoxes() + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
          aaaa bbbb cccc
          ")); + + var g = PaintHarness.PaintPage(container); + + // Horizontal bands of the outline, the lines are equally wide so only the top and bottom edge remain + var bandTops = g.Log.OfType() + .Where(r => r.Color == RColor.FromArgb(51, 51, 51) && r.Width > 2) + .Select(r => r.Y).Distinct().ToList(); + Assert.AreEqual(2, bandTops.Count); + } } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/SystemColorPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/SystemColorPaintIntegrationTests.cs new file mode 100644 index 000000000..0e77b76f4 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/SystemColorPaintIntegrationTests.cs @@ -0,0 +1,67 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[TestClass] +public sealed class SystemColorPaintIntegrationTests +{ + [TestMethod] + [DataRow("ActiveBorder")] + [DataRow("ActiveCaption")] + [DataRow("AppWorkspace")] + [DataRow("Background")] + [DataRow("ButtonFace")] + [DataRow("ButtonHighlight")] + [DataRow("ButtonShadow")] + [DataRow("ButtonText")] + [DataRow("CaptionText")] + [DataRow("GrayText")] + [DataRow("Highlight")] + [DataRow("HighlightText")] + [DataRow("InactiveBorder")] + [DataRow("InactiveCaption")] + [DataRow("InactiveCaptionText")] + [DataRow("InfoBackground")] + [DataRow("InfoText")] + [DataRow("Menu")] + [DataRow("MenuText")] + [DataRow("Scrollbar")] + [DataRow("ThreeDDarkShadow")] + [DataRow("ThreeDFace")] + [DataRow("ThreeDHighlight")] + [DataRow("ThreeDLightShadow")] + [DataRow("ThreeDShadow")] + [DataRow("Window")] + [DataRow("WindowFrame")] + [DataRow("WindowText")] + [Spec("css21", "ui.html#system-colors")] + public void SystemColor_IsAValidBackgroundColor(string name) + { + var placeholder = RColor.FromArgb(1, 2, 3); + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + $"
          ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + + var fill = g.Log.OfType().Single(); + Assert.AreNotEqual(placeholder, fill.Color); + Assert.AreEqual(255, fill.Color.A); + } + + [TestMethod] + [DataRow("windowtext")] + [DataRow("WINDOWTEXT")] + [Spec("css21", "ui.html#system-colors")] + public void SystemColor_IsCaseInsensitive(string name) + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + $"
          x
          ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + + Assert.AreNotEqual(RColor.FromArgb(1, 2, 3), g.DrawStringCalls.Single().Color); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs index d2b158037..abbeeb764 100644 --- a/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs @@ -3,25 +3,11 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/OutlineProperty.cs (source class -/// CssOutlinePropertyTests). -/// , , , -/// and the shorthand all exist and mirror PeachPDF's grammar -/// (outline-width || outline-style || outline-color, CSS-UI-4 §4). -/// Dropped entirely (not ported, not [Ignore]d): CssOutlineOffsetPositiveLengthLegal, -/// CssOutlineOffsetNegativeLengthLegal, CssOutlineOffsetKeywordIllegal, and -/// CssOutlineShorthandDoesNotExpandOffset. All four require an OutlineOffsetProperty type and a -/// StyleDeclaration.OutlineOffset accessor - outline-offset has no representation anywhere in -/// HTML-Renderer's CSS engine port (confirmed zero hits for "OutlineOffset" under -/// Source/HtmlRenderer/Core/CssEngine, unlike outline-width/-style/-color which all exist under -/// StyleProperties/Outline/): a newly-discovered gap beyond the ones the triage brief called out, and one -/// with no existing type to attach an [Ignore] to - there is nothing to instantiate or assert against. -/// [TestClass] public sealed class OutlinePropertyTests { [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void CssOutlineStyleDottedLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-style : dotTED"); @@ -35,6 +21,7 @@ public void CssOutlineStyleDottedLegal() } [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void CssOutlineStyleSolidLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-style : solid"); @@ -59,7 +46,18 @@ public void CssOutlineStyleNoIllegal() Assert.IsFalse(concrete.HasValue); } + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] + public void CssOutlineStyleHiddenIllegal() + { + var property = CssConstructionFunctions.ParseDeclaration("outline-style : hidden"); + Assert.IsInstanceOfType(property); + Assert.IsFalse(((OutlineStyleProperty)property).HasValue); + } + + [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void CssOutlineColorInvertLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-color : invert "); @@ -163,6 +161,7 @@ public void CssOutlineWidthLengthLegal() } [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void CssOutlineSingleLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline : thin"); @@ -176,6 +175,7 @@ public void CssOutlineSingleLegal() } [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void CssOutlineDualLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline : thin invert"); @@ -189,6 +189,7 @@ public void CssOutlineDualLegal() } [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void CssOutlineAllDottedLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline : dotted 0.3em rgb(255, 255, 255)"); @@ -214,6 +215,7 @@ public void CssOutlineDoubleColorIllegal() } [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void CssOutlineAllSolidLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline : 1px solid #000"); diff --git a/Spec/css21.yml b/Spec/css21.yml index e7479f78b..dd04ff65d 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -991,25 +991,34 @@ sections: - "'hidden' doesn't suppress the other borders at the same edge" ui.html#q18.0: title: 18 User interface - status: unknown + status: partial ui.html#cursor-props: title: 18.1 Cursors - status: unknown + status: parsed-only + gaps: + - The controls ignore 'cursor' and show a hand over links, an I-beam over text and the default cursor elsewhere ui.html#system-colors: title: 18.2 System Colors - status: unknown + status: supported ui.html#system-fonts: title: 18.3 User preferences for fonts - status: unknown + status: n/a ui.html#dynamic-outlines: title: 18.4 Dynamic outlines - status: unknown + status: partial + gaps: + - Only solid outlines are painted, the other border styles paint nothing + - "'hidden' is accepted as an outline style instead of being rejected" + - The outline is painted right after the box's border, so its content and later boxes paint over it + - An inline broken across lines gets a separate ring per line box instead of one outline enclosing all its boxes ui.html#outline-focus: title: 18.4.1 Outlines and the focus - status: unknown + status: unsupported + gaps: + - The focus isn't tracked or represented ui.html#magnification: title: 18.5 Magnification - status: unknown + status: n/a aural.html#q19.0: title: A Aural style sheets status: unknown From 586b9e075295126f718b724021594db7e1f102d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:10:55 +0100 Subject: [PATCH 024/150] Verify and document CSS 2.1 aural style sheets compliance --- Spec/css21.yml | 41 +++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/Spec/css21.yml b/Spec/css21.yml index dd04ff65d..4d164aac7 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -1021,58 +1021,51 @@ sections: status: n/a aural.html#q19.0: title: A Aural style sheets - status: unknown + status: unsupported + gaps: + - None of the aural properties are parsed aural.html#aural-media-group: title: A.1 The media types 'aural' and 'speech' - status: unknown + status: n/a aural.html#aural-intro: title: A.2 Introduction to aural style sheets - status: unknown - aural.html#angles: - title: A.2.1 Angles - status: unknown - aural.html#times: - title: A.2.2 Times - status: unknown - aural.html#frequencies: - title: A.2.3 Frequencies - status: unknown + status: n/a aural.html#volume-props: title: A.3 Volume properties - status: unknown + status: unsupported aural.html#speaking-props: title: A.4 Speaking properties - status: unknown + status: unsupported aural.html#pause-props: title: A.5 Pause properties - status: unknown + status: unsupported aural.html#cue-props: title: A.6 Cue properties - status: unknown + status: unsupported aural.html#mixing-props: title: A.7 Mixing properties - status: unknown + status: unsupported aural.html#spatial-props: title: A.8 Spatial properties - status: unknown + status: unsupported aural.html#voice-char-props: title: A.9 Voice characteristic properties - status: unknown + status: unsupported aural.html#speech-props: title: A.10 Speech properties - status: unknown + status: unsupported aural.html#aural-tables: title: A.11 Audio rendering of tables - status: unknown + status: unsupported aural.html#speak-headers: title: A.11.1 Speaking headers - status: unknown + status: unsupported aural.html#sample: title: A.12 Sample style sheet for HTML - status: unknown + status: n/a aural.html#Emacspeak: title: A.13 Emacspeak - status: unknown + status: n/a sample.html#q22.0: title: D Default style sheet for HTML 4 status: unknown From 1d068afaa491410d6a524e31e0e645656f39760e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 17:53:47 +0100 Subject: [PATCH 025/150] Verify and document CSS 2.1 default style sheet compliance --- Source/HtmlRenderer/Core/CssData.cs | 2 +- Source/HtmlRenderer/Core/CssDefaults.cs | 5 +---- Source/HtmlRenderer/Core/Parse/CssParser.cs | 4 ++-- Spec/css21.yml | 2 +- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Source/HtmlRenderer/Core/CssData.cs b/Source/HtmlRenderer/Core/CssData.cs index 8be4b08b8..2c9096b8d 100644 --- a/Source/HtmlRenderer/Core/CssData.cs +++ b/Source/HtmlRenderer/Core/CssData.cs @@ -44,7 +44,7 @@ internal CssData() /// /// Parse the given stylesheet to object.
          /// If is true the parsed css blocks are added to the - /// default css data (as defined by W3), merged if class name already exists. If false only the data in the given stylesheet is returned. + /// default (user agent) css data. If false only the data in the given stylesheet is returned. ///
          /// /// Platform adapter diff --git a/Source/HtmlRenderer/Core/CssDefaults.cs b/Source/HtmlRenderer/Core/CssDefaults.cs index d2f083859..624830806 100644 --- a/Source/HtmlRenderer/Core/CssDefaults.cs +++ b/Source/HtmlRenderer/Core/CssDefaults.cs @@ -15,11 +15,8 @@ namespace TheArtOfDev.HtmlRenderer.Core internal static class CssDefaults { /// - /// CSS Specification's Default Style Sheet for HTML 4 + /// The user agent style sheet, based on CSS 2.1 Appendix D. /// - /// - /// http://www.w3.org/TR/CSS21/sample.html - /// public const string DefaultStyleSheet = @" html, address, blockquote, diff --git a/Source/HtmlRenderer/Core/Parse/CssParser.cs b/Source/HtmlRenderer/Core/Parse/CssParser.cs index 18b3799ab..39a7b32e5 100644 --- a/Source/HtmlRenderer/Core/Parse/CssParser.cs +++ b/Source/HtmlRenderer/Core/Parse/CssParser.cs @@ -70,8 +70,8 @@ public CssParser(RAdapter adapter, HtmlContainerInt htmlContainer) /// /// Parse the given stylesheet source to a .
          /// If is true the parsed stylesheet is combined with a - /// clone of the default (UA) css data (as defined by W3) - a clone so the adapter's cached - /// default data is never mutated by this call. If false only the data in the given stylesheet is returned. + /// clone of the default (user agent) css data, a clone so the adapter's cached default data is + /// never mutated by this call. If false only the data in the given stylesheet is returned. ///
          /// /// raw css stylesheet to parse diff --git a/Spec/css21.yml b/Spec/css21.yml index 4d164aac7..e57de1fff 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -1068,7 +1068,7 @@ sections: status: n/a sample.html#q22.0: title: D Default style sheet for HTML 4 - status: unknown + status: n/a zindex.html#q23.0: title: E Elaborate description of Stacking Contexts status: unknown From dbc7140795e6b8288e49836fc2e7431fc00e6a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:04:34 +0100 Subject: [PATCH 026/150] Verify and document CSS 2.1 stacking context compliance --- .../HtmlRenderer/Core/Fragments/Fragment.cs | 9 +- .../Core/Paint/FragmentPainter.cs | 96 ++-------- .../BackgroundPaintIntegrationTests.cs | 1 + .../Painting/StackingContextTests.cs | 170 ++++++++++++++---- Spec/css21.yml | 16 +- 5 files changed, 167 insertions(+), 125 deletions(-) diff --git a/Source/HtmlRenderer/Core/Fragments/Fragment.cs b/Source/HtmlRenderer/Core/Fragments/Fragment.cs index a6e20ac01..f1ce2e98e 100644 --- a/Source/HtmlRenderer/Core/Fragments/Fragment.cs +++ b/Source/HtmlRenderer/Core/Fragments/Fragment.cs @@ -45,12 +45,9 @@ internal sealed record TextFragment(RRect Rect, CssRect Word) : Fragment(Rect); /// /// The portion of one living in one fragmentainer. A box spanning a page boundary - /// produces one per page. // - /// mirror what the old live-tree paint walk painted, in the same order: own decoration rects, own words, - /// then stacking-ordered child box fragments. (a list item's marker, if any) - /// is kept separate from rather than folded in, matching the old live-tree - /// walk's own paint order - the marker paints last, after this fragment's own overflow clip is popped, since a - /// list-style-position: outside marker can legitimately hang outside the content box's clip. + /// produces one per page. are in tree order. + /// (a list item's marker, if any) is kept out of because + /// it's painted after the box's overflow clip is popped, an outside marker hangs outside the content box. /// internal sealed record BoxFragment( RRect Rect, diff --git a/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs b/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs index d29a2bb9f..f64c93585 100644 --- a/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs +++ b/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs @@ -10,51 +10,24 @@ namespace TheArtOfDev.HtmlRenderer.Core.Paint { /// - /// Paints a fragmentainer from the immutable fragment tree - the sole paint path now that the old - /// live-tree walk (formerly CssBox.Paint/PaintImp) has been deleted. Every geometric - /// decision reads from the being painted, including text: each word paints - /// at its own , not CssRect.Rectangle read off the live box. - /// The box back-reference () is consulted only for computed style and - /// paint primitives themselves (// - /// - widened from protected/private to internal - /// rather than duplicated here, so this stays a faithful re-shaping of the existing, tested paint code - /// rather than a parallel reimplementation). + /// Paints a fragmentainer from the fragment tree. Geometry comes from the fragments, the + /// only provides the computed style and the paint primitives. + /// Replaced and other leaf boxes are painted by their . /// - /// - /// Leaf/replaced types dispatch to their own (matching - /// PeachPDF's IFragmentContentPainter/FragmentContentPainters shape, see - /// ); everything else uses the generic box-fragment - /// path below. Stacking-context paint order and box-decoration-break slicing are follow-on - /// work once real fragmentation (multiple fragments per box) exists for them to matter. - /// internal sealed class FragmentPainter { private readonly HtmlContainerInt _container; /// - /// Added to every painted rect on top of - zero for - /// every ordinary caller (one fragmentainer already in its own native coordinate system: a PDF - /// page's own XGraphics, or the single always-page-local-zero fragmentainer WinForms/WPF's - /// continuous document produces). Non-zero only when - /// paints several fragmentainers onto one continuous surface (its multi-fragmentainer branch) - - /// there each fragmentainer's content is fragment-tree-local (translated so the band's own top is - /// Y=0) and must be translated back by the band's real document-Y top to land in the right place - /// on the shared surface, matching what painting the old, unfragmented box tree once did directly. + /// Added to every painted rect on top of the scroll offset. Only non-zero when several + /// fragmentainers are painted onto one continuous surface, there it moves each band back to its + /// document position. /// private readonly RPoint _pageOrigin; /// - /// The real document-Y top of the fragmentainer currently being painted (), - /// set once per call. Geometry sourced from the fragment tree (/ - /// /) is already local to this band - /// ( subtracts it at build time) and needs no further - /// adjustment for it. Geometry read straight off the live tree instead - /// ('s image-word rect, the visibility cull below) is - /// still absolute document-Y and must have this subtracted to land in the same target frame - - /// missing this distinction for text was a real bug (found while building the continuous-surface - /// paint path this field supports, since fixed by moving word painting onto the fragment tree - /// entirely rather than reconciling it): every page after the first silently painted zero text, - /// since a fresh per-page surface's origin is this band's top, not the document's. + /// Document Y of the top of the fragmentainer being painted. Fragment geometry is already relative + /// to it, geometry read from the live tree is not and has to subtract it. /// private double _bandTop; @@ -65,12 +38,8 @@ internal FragmentPainter(HtmlContainerInt container, RPoint pageOrigin = default } /// - /// The offset to apply to a box's fragment-local rect (already local to the fragmentainer being - /// painted) to reach its paint position: scroll offset (suppressed for a fixed-position box, - /// matching the old live-tree walk's behavior) plus (applies regardless - /// of - the old, single continuous-surface paint path this replaced - /// never gave "fixed" boxes special treatment with respect to which page's content they belonged - /// to, only whether scroll offset applied to them). + /// Offset from fragment geometry to paint position: scroll offset (not for fixed boxes) plus + /// . /// internal RPoint FragmentLocalOffset(bool isFixed) { @@ -79,29 +48,9 @@ internal RPoint FragmentLocalOffset(bool isFixed) } /// - /// The offset to apply to a rect read straight off the live tree (still - /// absolute document-Y, unlike fragment-tree geometry) to reach the same paint position - /// gives fragment-local geometry: undoes - /// - except for a fixed (or fixed-ancestor) box, whose live geometry is already page-relative - /// (see the remark below), where undoing this painter's current band top would double-subtract - /// it, pushing the box far outside every page except the one whose band top happens to equal its - /// own small top offset. + /// Offset from live box tree geometry to paint position. A fixed box is laid out relative to the + /// page already, so the band top isn't subtracted for it. /// - /// - /// A real bug found while confirming 's fixed-position repeat-per-page - /// support through actual PDF output: CssBox.PerformLayoutImp never routes a - /// Position==Fixed box through normal top-computing flow at all (its Left/Top - /// property setters assign Location directly, from GetActualLocation, resolved - /// against the page size) - so unlike ordinary content, whose live Location genuinely is an - /// absolute document-Y this painter's current band top needs undoing from, a fixed box's live - /// Location already IS the small, page-relative offset the fragment tree also uses. This - /// only affected the containing-block visibility/overflow-clip checks below ('s - /// own check, and via ) - /// - the fragment tree's own already-correct geometry (fragment.Lines/fragment.Words, - /// via alone) was never affected, which is why the fixed content - /// was confirmed correctly PRESENT in the fragment tree on every page before this was found - it - /// was being computed correctly and then clipped away on every page except one. - /// internal RPoint LiveTreeOffset(bool isFixed) { var offset = FragmentLocalOffset(isFixed); @@ -109,10 +58,8 @@ internal RPoint LiveTreeOffset(bool isFixed) } /// - /// The portion of that - /// doesn't already add itself (it applies gating - /// internally) - pass as its extraOffset parameter. See 's own - /// remark for why must gate the band-top term here too. + /// without the scroll offset, which + /// applies itself. /// internal RPoint LiveTreeExtraOffset(bool isFixed) => isFixed ? new RPoint(_pageOrigin.X, _pageOrigin.Y) : new RPoint(_pageOrigin.X, _pageOrigin.Y - _bandTop); @@ -124,11 +71,8 @@ internal void Paint(RGraphics g, FragmentainerFragment fragmentainer) } /// - /// Test-support entry point: paints one box fragment (and its descendants) directly, without - /// painting the rest of its fragmentainer - mirrors what the old live-tree CssBox.Paint(g) - /// did for an arbitrary box, for tests that want the draw-call log of just one subtree. - /// should be the owning fragmentainer's own - /// . + /// Paints one fragment and its descendants without the rest of its fragmentainer, for tests. + /// is the owning fragmentainer's . /// internal void PaintFragmentSubtree(RGraphics g, BoxFragment fragment, double bandTop = 0) { @@ -181,7 +125,8 @@ private void PaintFragment(RGraphics g, BoxFragment fragment) } /// - /// Paints one box fragment's own decorations, words, and children. + /// Paints the box's background, border and outline per line, then its text and text decorations, + /// then its children (CSS 2.1 Appendix E, within this box only). /// private void PaintFragmentContent(RGraphics g, BoxFragment fragment) { @@ -202,8 +147,6 @@ private void PaintFragmentContent(RGraphics g, BoxFragment fragment) var clipped = RenderUtils.ClipGraphicsByOverflow(g, box, LiveTreeExtraOffset(box.IsFixed)); var clip = g.GetClip(); - // fragment.Lines/fragment.Words are already fragment-local (FragmentEmitter subtracted the - // band top at build time) - only FragmentLocalOffset (scroll + page-origin) applies to either. var offset = FragmentLocalOffset(box.IsFixed); var lines = fragment.Lines; @@ -259,8 +202,7 @@ private void PaintFragmentContent(RGraphics g, BoxFragment fragment) if (clipped) g.PopClip(); - // Marker paints last, after this fragment's own overflow clip is popped - see - // BoxFragment.MarkerFragment's doc comment for why it's kept separate from Children. + // The marker is outside the principal box, so the box's overflow clip doesn't apply to it. if (fragment.MarkerFragment != null) PaintFragment(g, fragment.MarkerFragment); } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs index 92e489a1a..5823eb7b7 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs @@ -61,6 +61,7 @@ public void BackgroundColorDefault_PaintsNothing() } [TestMethod] + [Spec("css21", "zindex.html#painting-order")] public void BackgroundColor_PaintsBeforeBorderAndOutline() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs index b6c76a750..68b696642 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs @@ -1,88 +1,182 @@ using System.Linq; using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; namespace HtmlRenderer.IntegrationTest.Painting; +/// +/// Painting order within a stacking context, checked by the order of the recorded draw calls. +/// [DoNotParallelize] [TestClass] public sealed class StackingContextTests { + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + private static readonly RColor Blue = RColor.FromArgb(0, 0, 255); + [TestMethod] + [Spec("css21", "zindex.html#painting-order")] + public void BlockBackgrounds_PaintInTreeOrder() + { + var g = Paint("
          "); + + Assert.IsLessThan(RectIndex(g, Blue), RectIndex(g, Red)); + } + + [TestMethod] + [Spec("css21", "zindex.html#painting-order")] + public void Background_PaintsBeforeText() + { + var g = Paint("
          Text
          "); + + Assert.IsGreaterThan(RectIndex(g, Blue), TextIndex(g, "Text")); + } + + [TestMethod] + [Spec("css21", "zindex.html#painting-order")] [Spec("css21", "visuren.html#z-index")] - public void AbsolutelyPositionedBox_PaintsAfterALaterInFlowBlock() + public void AbsoluteBox_PaintsAfterLaterInFlowSiblings() { - var html = PaintHarness.Wrap( - "
          " - + "
          "); + var g = Paint("
          " + + "
          " + + "
          "); - Assert.IsGreaterThan(PaintIndex(html, 0, 0, 255), PaintIndex(html, 255, 0, 0)); + Assert.IsGreaterThan(RectIndex(g, Blue), RectIndex(g, Red)); } [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#z-index")] - public void RelativelyPositionedBox_PaintsAfterALaterInFlowBlock() + public void HigherZIndex_PaintsInFrontRegardlessOfDocumentOrder() { - var html = PaintHarness.Wrap( - "
          " - + "
          "); + var g = Paint("
          " + + "
          "); - Assert.IsGreaterThan(PaintIndex(html, 0, 0, 255), PaintIndex(html, 255, 0, 0)); + Assert.IsGreaterThan(RectIndex(g, Blue), RectIndex(g, Red)); } [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "zindex.html#painting-order")] [Spec("css21", "visuren.html#z-index")] - public void HigherZIndex_PaintsInFrontRegardlessOfDocumentOrder() + public void PositionedZIndex_PaintsOverFixedPositionedContent() { - var html = PaintHarness.Wrap( - "
          " - + "
          "); + var g = Paint("
          " + + "
          "); - Assert.IsGreaterThan(PaintIndex(html, 0, 0, 255), PaintIndex(html, 255, 0, 0)); + Assert.IsGreaterThan(RectIndex(g, Red), RectIndex(g, Blue)); } [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "zindex.html#painting-order")] [Spec("css21", "visuren.html#z-index")] - public void NegativeZIndex_PaintsBehindInFlowBlocks() + public void NegativeZIndex_PaintsBelowInFlowBlocks() { - var html = PaintHarness.Wrap( - "
          " - + "
          "); + var g = Paint("
          " + + "
          "); - Assert.IsLessThan(PaintIndex(html, 0, 0, 255), PaintIndex(html, 255, 0, 0)); + Assert.IsLessThan(RectIndex(g, Blue), RectIndex(g, Red)); } [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "zindex.html#painting-order")] [Spec("css21", "visuren.html#z-index")] - public void PositionedZIndex_PaintsOverFixedPositionedContent() + public void RelativeBox_PaintsAfterLaterInFlowSiblings() { - var html = LayoutHarness.Wrap( - "
          " - + "
          "); + var g = Paint("
          " + + "
          "); - var (_, container) = PaintHarness.Layout(html); - var recorder = PaintHarness.PaintPage(container); + Assert.IsGreaterThan(RectIndex(g, Blue), RectIndex(g, Red)); + } - var drawRectCalls = recorder.Log.OfType().ToList(); - var fixedBarPaintIndex = drawRectCalls.FindIndex(c => c.Color.R == 0 && c.Color.G == 0 && c.Color.B == 0); - var introPaintIndex = drawRectCalls.FindIndex(c => c.Color.R == 255 && c.Color.G == 255 && c.Color.B == 255); + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "zindex.html#painting-order")] + public void AbsoluteBox_PaintsAfterInFlowContentOutsideItsParent() + { + var g = Paint("
          " + + "
          "); - Assert.IsGreaterThanOrEqualTo(0, fixedBarPaintIndex); - Assert.IsGreaterThanOrEqualTo(0, introPaintIndex); - Assert.IsTrue(fixedBarPaintIndex < introPaintIndex, - "the z-index:2 box must paint after (on top of) the fixed bar, regardless of document order"); + Assert.IsGreaterThan(RectIndex(g, Blue), RectIndex(g, Red)); } - private static int PaintIndex(string html, int r, int g, int b) + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "zindex.html#painting-order")] + public void Float_PaintsAfterLaterBlockBackgrounds() { - var (_, container) = PaintHarness.Layout(html); - var rects = PaintHarness.PaintPage(container).Log.OfType().ToList(); - var index = rects.FindIndex(c => c.Color.R == r && c.Color.G == g && c.Color.B == b); + var g = Paint("
          " + + "
          "); + + Assert.IsGreaterThan(RectIndex(g, Blue), RectIndex(g, Red)); + } - Assert.IsGreaterThanOrEqualTo(0, index); + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "zindex.html#painting-order")] + public void BlockBackgrounds_PaintBeforeInlineContent() + { + var g = Paint("
          Text
          " + + "
          "); + + Assert.IsGreaterThan(RectIndex(g, Blue), TextIndex(g, "Text")); + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "zindex.html#painting-order")] + public void UnderlineAndOverline_PaintBeforeTheText_LineThroughAfter() + { + var g = Paint("
          Deco
          "); + + var text = TextIndex(g, "Deco"); + var lines = g.Log.Select((e, i) => (e, i)).Where(x => x.e is RecordingGraphics.DrawLineCall).ToList(); + Assert.HasCount(3, lines); + + var lineThroughY = lines.Select(x => ((RecordingGraphics.DrawLineCall)x.e).Y1).OrderBy(y => y).ElementAt(1); + foreach (var (e, i) in lines) + { + if (((RecordingGraphics.DrawLineCall)e).Y1 == lineThroughY) + Assert.IsGreaterThan(text, i); + else + Assert.IsLessThan(text, i); + } + } + + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "zindex.html#painting-order")] + public void RootBackground_CoversTheWholeCanvas() + { + var (_, container) = PaintHarness.Layout( + "x", 300, 300); + var g = PaintHarness.PaintPage(container); + + var background = g.Log.OfType().Single(r => r.Color == Red); + + Assert.AreEqual(0, background.Y, 0.5); + Assert.AreEqual(300, background.Height, 0.5); + } + + private static RecordingGraphics Paint(string body) + { + var (_, container) = PaintHarness.Layout($"{body}"); + return PaintHarness.PaintPage(container); + } + + private static int RectIndex(RecordingGraphics g, RColor color) + { + var index = g.Log.FindIndex(e => e is RecordingGraphics.DrawRectCall r && r.Color == color); + Assert.IsGreaterThanOrEqualTo(0, index, $"No rectangle painted in {color}"); + return index; + } + + private static int TextIndex(RecordingGraphics g, string text) + { + var index = g.Log.FindIndex(e => e is RecordingGraphics.DrawStringCall s && s.Text == text); + Assert.IsGreaterThanOrEqualTo(0, index, $"'{text}' wasn't painted"); return index; } } diff --git a/Spec/css21.yml b/Spec/css21.yml index e57de1fff..868e391fa 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -1071,16 +1071,24 @@ sections: status: n/a zindex.html#q23.0: title: E Elaborate description of Stacking Contexts - status: unknown + status: partial zindex.html#stacking-defs: title: E.1 Definitions - status: unknown + status: n/a zindex.html#painting-order: title: E.2 Painting order - status: unknown + status: partial + gaps: + - "'z-index' is ignored, no stacking contexts are created and negative z-index boxes paint over the in-flow content" + - Relatively positioned boxes paint in tree order with the normal flow instead of after it + - Absolutely positioned and fixed boxes paint after their parent's children instead of after all in-flow content of the stacking context + - Floats paint in tree order with the blocks, so a later block's background covers a preceding float + - Each block paints its inline content before the backgrounds of later blocks + - Underline and overline paint after the text instead of before it + - The root element's background covers only the root's border box instead of the whole canvas zindex.html#stacking-notes: title: E.3 Notes - status: unknown + status: n/a grammar.html#q25.0: title: G Grammar of CSS 2.1 status: unknown From 677acace83b5839d59134b4de090acb778fca4cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:02:02 +0100 Subject: [PATCH 027/150] Verify and document CSS 2.1 grammar compliance --- .../HtmlRenderer.Test/Css/GrammarTests.cs | 201 ++++++++++++++++++ .../Css/TokenizationTests.cs | 69 +----- Spec/css21.yml | 12 +- 3 files changed, 219 insertions(+), 63 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Css/GrammarTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Css/GrammarTests.cs b/Source/Test/HtmlRenderer.Test/Css/GrammarTests.cs new file mode 100644 index 000000000..08b1afe43 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/GrammarTests.cs @@ -0,0 +1,201 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using static HtmlRenderer.Test.CssEngineSupport.CssConstructionFunctions; + +namespace HtmlRenderer.Test.Css; + +[TestClass] +public sealed class GrammarTests +{ + private const string Green = "rgb(0, 128, 0)"; + private const double Delta = 0.01; + + [TestMethod] + [Spec("css21", "grammar.html#grammar")] + public void StyleSheet_IsCharsetImportsThenRuleSetsMediaAndPage() + { + var sheet = ParseStyleSheet("@charset \"utf-8\";\n\np { color: red } @page :first { margin: 1cm }"); + + CollectionAssert.AreEqual( + new[] { RuleType.Charset, RuleType.Import, RuleType.Style, RuleType.Media, RuleType.Page }, + sheet.Rules.Select(r => r.Type).ToArray()); + Assert.AreEqual("print, screen", ((ImportRule)sheet.Rules[1]).Media.MediaText); + Assert.AreEqual(":first", ((PageRule)sheet.Rules[4]).SelectorText); + } + + [TestMethod] + [Spec("css21", "grammar.html#grammar")] + public void MediaList_IsACommaSeparatedListOfMedia() + { + Assert.AreEqual(Green, Styled("@media print, screen { #x { color: green } }").Color); + } + + [TestMethod] + [Spec("css21", "grammar.html#grammar")] + [DataRow("div p")] + [DataRow("div > p")] + [DataRow("h1 + p")] + [DataRow("*.a#b")] + [DataRow("[title]")] + [DataRow("a[title=x]")] + [DataRow("a[title = \"x y\"]")] + [DataRow("a[class~=x]")] + [DataRow("a[lang|=en]")] + [DataRow("a:first-child")] + [DataRow("p:lang(en)")] + [DataRow("p:first-line")] + [DataRow("h1, h2 > p, .a .b")] + public void Selector_FollowsTheGrammar(string selector) + { + var sheet = ParseStyleSheet(selector + " { color: green }"); + + Assert.AreEqual(1, sheet.Rules.Length); + Assert.AreEqual(RuleType.Style, sheet.Rules[0].Type); + } + + [TestMethod] + [Spec("css21", "grammar.html#grammar")] + public void Declaration_TakesImportantWithWhitespaceAndComments() + { + Assert.AreEqual(Green, Styled("#x { color: green ! /* c */ important } #x { color: red }").Color); + } + + [TestMethod] + [Spec("css21", "grammar.html#grammar")] + [DataRow("-10px", -10)] + [DataRow("+10px", 10)] + [DataRow("-.5em", -10)] + public void Term_TakesAUnaryOperator(string value, double expected) + { + Assert.AreEqual(expected, Styled($"#x {{ font-size: 20px; margin-left: {value} }}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css21", "grammar.html#grammar")] + public void Expression_TakesSlashAndCommaOperators() + { + var style = ParseDeclarations("font: italic 12px/30px Georgia, serif"); + + Assert.AreEqual("12px", style.FontSize); + Assert.AreEqual("30px", style.LineHeight); + Assert.AreEqual("Georgia, serif", style.FontFamily); + } + + [TestMethod] + [Spec("css21", "grammar.html#scanner")] + [DataRow("~=", "Match")] + [DataRow("|=", "Match")] + [DataRow("", "Cdc")] + [DataRow("@media", "AtKeyword")] + [DataRow("#a1", "Hash")] + [DataRow("-a_b", "Ident")] + [DataRow("rgb(", "Function")] + [DataRow("'a'", "String")] + [DataRow("1.5", "Number")] + [DataRow(".5", "Number")] + [DataRow("50%", "Percentage")] + [DataRow("10px", "Dimension")] + [DataRow("10foo", "Dimension")] + [DataRow("url( a.png )", "Url")] + public void Scanner_ProducesTheToken(string input, string expected) + { + var lexer = new Lexer(new TextSource(input)); + + Assert.AreEqual(expected, lexer.Get().Type.ToString()); + Assert.AreEqual(TokenType.EndOfFile, lexer.Get().Type); + } + + [TestMethod] + [Spec("css21", "grammar.html#scanner")] + public void SpaceTabLineBreaksAndFormFeed_AreWhitespace() + { + var lexer = new Lexer(new TextSource(" \t\r\n\fa")); + + var types = new List(); + for (var token = lexer.Get(); token.Type != TokenType.EndOfFile; token = lexer.Get()) + types.Add(token.Type); + + Assert.AreEqual(TokenType.Ident, types[^1]); + Assert.IsTrue(types.Take(types.Count - 1).All(t => t == TokenType.Whitespace)); + } + + [TestMethod] + [Spec("css21", "grammar.html#scanner")] + public void UnclosedCommentAtTheEnd_IsIgnored() + { + Assert.AreEqual(Green, Styled("#x { color: green } /* #x { color: red }").Color); + } + + [TestMethod] + [Spec("css21", "grammar.html#scanner")] + [DataRow(@"@med\ia screen { #x { color: green } }")] + [DataRow(@"@\6D edia screen { #x { color: green } }")] + [DataRow(@"#x { color: green !imp\ortant } #x { color: red }")] + public void EscapedKeywordLetters_AreTheLetters(string css) + { + Assert.AreEqual(Green, Styled(css).Color); + } + + [TestMethod] + [Spec("css21", "grammar.html#scanner")] + public void EscapedUnitLetters_AreTheUnit() + { + Assert.AreEqual(10, Styled(@"#x { margin-left: 10p\x }").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "grammar.html#scanner")] + [DataRow("#x { margin-left: 10PX }")] + [DataRow("#x { margin-left: 10Px }")] + [DataRow("#x { font-size: 20px; margin-left: 0.5EM }")] + [DataRow("@MEDIA screen { #x { margin-left: 10px } }")] + [DataRow("@Media screen { #x { margin-left: 10px } }")] + [DataRow("#x { margin-left: 10px !IMPORTANT } #x { margin-left: 0 }")] + public void Scanner_IsCaseInsensitive(string css) + { + Assert.AreEqual(10, Styled(css).ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css21", "grammar.html#tokenizer-diffs")] + public void SixHexDigits_AreOneEscape() + { + Assert.AreEqual(1, Before(@"#x::before { content: '\abcdef' }").Length); + Assert.AreEqual("઼" + "def", Before(@"#x::before { content: '\abc def' }")); + } + + [TestMethod] + [Spec("css21", "grammar.html#tokenizer-diffs")] + public void NumberFollowedByAnIdentifier_IsADimension() + { + var box = Styled("#x { font-size: 20px; margin-left: 1em } #x { font: 10pt/1.2serif }"); + + Assert.AreEqual(20, box.ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css21", "grammar.html#tokenizer-diffs")] + public void ClassStartingWithADigit_NeedsAnEscape() + { + Assert.AreNotEqual(Green, Styled(".55ft { color: green }", "
          a
          ").Color); + Assert.AreEqual(Green, Styled(@".\35 5ft { color: green }", "
          a
          ").Color); + } + + private static CssBox Styled(string css, string body = "
          a
          ") + { + var (root, _) = LayoutHarness.Layout($"{body}"); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } + + private static string Before(string css) + { + var before = Styled(css).Boxes.FirstOrDefault(b => b.IsBeforePseudoElement); + Assert.IsNotNull(before, "No ::before box"); + return before.Text ?? string.Concat(LayoutHarness.Descendants(before).SelectMany(b => b.Words).Select(w => w.Text)); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/TokenizationTests.cs b/Source/Test/HtmlRenderer.Test/Css/TokenizationTests.cs index e9a444573..3cb2c16c9 100644 --- a/Source/Test/HtmlRenderer.Test/Css/TokenizationTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/TokenizationTests.cs @@ -2,15 +2,11 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/Tokenization.cs (CssTokenizationTests). Lexer, hash-token -/// classification (incl. escape-forces-id-hash), and CR/CRLF/LF normalization all match HTML-Renderer's -/// Parser/Lexer.cs / Parser/LexerBase.cs line-for-line. -/// [TestClass] public sealed class TokenizationTests { [TestMethod] + [Spec("css21", "grammar.html#scanner")] public void CssParserIdentifier() { var teststring = "h1 { background: blue; }"; @@ -20,6 +16,7 @@ public void CssParserIdentifier() } [TestMethod] + [Spec("css21", "grammar.html#scanner")] public void CssParserAtRule() { var teststring = "@media { background: blue; }"; @@ -29,6 +26,7 @@ public void CssParserAtRule() } [TestMethod] + [Spec("css21", "grammar.html#scanner")] public void CssParserUrlUnquoted() { var url = "http://someurl"; @@ -39,6 +37,7 @@ public void CssParserUrlUnquoted() } [TestMethod] + [Spec("css21", "grammar.html#scanner")] public void CssParserUrlDoubleQuoted() { var url = "http://someurl"; @@ -49,6 +48,7 @@ public void CssParserUrlDoubleQuoted() } [TestMethod] + [Spec("css21", "grammar.html#scanner")] public void CssParserUrlSingleQuoted() { var url = "http://someurl"; @@ -58,10 +58,8 @@ public void CssParserUrlSingleQuoted() Assert.AreEqual(url, token.Data); } - // Exercises Lexer.UrlBad, the CSS Syntax bad-url-token recovery state: consume and discard - // characters until the url()'s matching close-paren (or an earlier ';'/unmatched '}') so the - // tokenizer resynchronizes rather than getting stuck on malformed input. [TestMethod] + [Spec("css21", "grammar.html#scanner")] public void CssParserUrlBad_UnexpectedQuoteInUnquotedUrl_RecoversAtClosingParen() { var teststring = "url(bad\"value) next"; @@ -77,57 +75,9 @@ public void CssParserUrlBad_UnexpectedQuoteInUnquotedUrl_RecoversAtClosingParen( Assert.AreEqual("next", nextToken.Data); } + // In a value, an all-hex hash is a color literal, any other name stays an id hash. [TestMethod] - public void CssParserUrlBad_LineBreakInsideQuotedUrl_RecoversAtClosingParen() - { - var teststring = "url(\"unterminated\nstring\") next"; - var tokenizer = new Lexer(new TextSource(teststring)); - - var urlToken = tokenizer.Get(); - Assert.AreEqual(TokenType.Url, urlToken.Type); - - Assert.AreEqual(TokenType.Whitespace, tokenizer.Get().Type); - - var nextToken = tokenizer.Get(); - Assert.AreEqual(TokenType.Ident, nextToken.Type); - Assert.AreEqual("next", nextToken.Data); - } - - [TestMethod] - public void CssParserUrlBad_SemicolonInsideBadUrl_StopsAtSemicolon() - { - var teststring = "url(bad\"value; next"; - var tokenizer = new Lexer(new TextSource(teststring)); - - var urlToken = tokenizer.Get(); - Assert.AreEqual(TokenType.Url, urlToken.Type); - - var nextToken = tokenizer.Get(); - Assert.AreEqual(TokenType.Semicolon, nextToken.Type); - } - - [TestMethod] - public void CssParserUrlBad_NestedParenInsideBadUrl_RequiresMatchingCloseParen() - { - // The stray '(' bumps UrlBad's paren-depth counter, so the first ')' just closes the - // nested paren rather than ending the url() - only the second ')' does. - var teststring = "url(bad\"va(lue)) next"; - var tokenizer = new Lexer(new TextSource(teststring)); - - var urlToken = tokenizer.Get(); - Assert.AreEqual(TokenType.Url, urlToken.Type); - - Assert.AreEqual(TokenType.Whitespace, tokenizer.Get().Type); - - var nextToken = tokenizer.Get(); - Assert.AreEqual(TokenType.Ident, nextToken.Type); - Assert.AreEqual("next", nextToken.Data); - } - - // In a value context, '#' begins a (CSS Syntax §4.3.4): an all-hex name is a color - // literal, any other name stays an id hash-token (e.g. the '#id' inside element()). Previously a - // non-hex hash was truncated at the first non-hex char into an empty color + a stray ident. - [TestMethod] + [Spec("css21", "grammar.html#scanner")] public void ValueContextHash() { static void Check(string input, TokenType expectedType, string expectedData) @@ -179,6 +129,7 @@ public void LexerInvalidEscapedCodePointInIdent(string input) } [TestMethod] + [Spec("css21", "grammar.html#scanner")] public void LexerOnlyCarriageReturn() { var teststring = "\r"; @@ -188,6 +139,7 @@ public void LexerOnlyCarriageReturn() } [TestMethod] + [Spec("css21", "grammar.html#scanner")] public void LexerCarriageReturnLineFeed() { var teststring = "\r\n"; @@ -197,6 +149,7 @@ public void LexerCarriageReturnLineFeed() } [TestMethod] + [Spec("css21", "grammar.html#scanner")] public void LexerOnlyLineFeed() { var teststring = "\n"; diff --git a/Spec/css21.yml b/Spec/css21.yml index 868e391fa..0b5508c50 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -1091,16 +1091,18 @@ sections: status: n/a grammar.html#q25.0: title: G Grammar of CSS 2.1 - status: unknown + status: partial grammar.html#grammar: title: G.1 Grammar - status: unknown + status: supported grammar.html#scanner: title: G.2 Lexical scanner - status: unknown + status: partial + gaps: + - Uppercase units, at-keywords and '!important' aren't recognized, e.g. 10PX, @MEDIA and !IMPORTANT grammar.html#tokenizer-diffs: title: G.3 Comparison of tokenization in CSS 2.1 and CSS1 - status: unknown + status: supported grammar.html#q25.4: title: G.4 Implementation note - status: unknown + status: n/a From 02abcbb13f61ba3d7a3b0bb1feb023669a670b67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:21:53 +0100 Subject: [PATCH 028/150] Verify and document HTML common microsyntaxes compliance --- Source/HtmlRenderer/Core/Parse/DomParser.cs | 4 +- .../Html/CommonMicrosyntaxesTests.cs | 195 ++++++++++++++++++ Spec/html.yml | 84 ++++---- 3 files changed, 231 insertions(+), 52 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/CommonMicrosyntaxesTests.cs diff --git a/Source/HtmlRenderer/Core/Parse/DomParser.cs b/Source/HtmlRenderer/Core/Parse/DomParser.cs index 7c61561fe..e5c0f721d 100644 --- a/Source/HtmlRenderer/Core/Parse/DomParser.cs +++ b/Source/HtmlRenderer/Core/Parse/DomParser.cs @@ -1002,10 +1002,8 @@ private void TranslateAttributes(HtmlTag tag, CssBox box) } /// - /// Converts an HTML length into a Css length + /// Converts a dimension attribute value to a CSS length, HTML §2.3.4.4. /// - /// - /// private static string TranslateLength(string htmlLength) { CssLength len = new CssLength(htmlLength); diff --git a/Source/Test/HtmlRenderer.Test/Html/CommonMicrosyntaxesTests.cs b/Source/Test/HtmlRenderer.Test/Html/CommonMicrosyntaxesTests.cs new file mode 100644 index 000000000..a07a44494 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/CommonMicrosyntaxesTests.cs @@ -0,0 +1,195 @@ +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class CommonMicrosyntaxesTests +{ + [TestMethod] + [Spec("html", "common-microsyntaxes.html#boolean-attributes")] + [DataRow("nowrap")] + [DataRow("nowrap=''")] + [DataRow("nowrap=nowrap")] + [DataRow("nowrap=NoWrap")] + public void BooleanAttribute_IsTrueWhenPresent(string attribute) + { + Assert.AreEqual("nowrap", Box($"
          a
          ").WhiteSpace); + } + + [TestMethod] + [Spec("html", "common-microsyntaxes.html#keywords-and-enumerated-attributes")] + public void Keyword_MatchesCaseInsensitively() + { + Assert.AreEqual("top", Box("
          a
          ").VerticalAlign); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "common-microsyntaxes.html#keywords-and-enumerated-attributes")] + public void AlignKeyword_MatchesCaseInsensitively() + { + Assert.AreEqual("center", Box("

          a

          ").TextAlign); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "common-microsyntaxes.html#keywords-and-enumerated-attributes")] + public void InvalidKeyword_IsIgnored() + { + Assert.AreEqual("rtl", Box("

          a

          ").Direction); + } + + [TestMethod] + [Spec("html", "common-microsyntaxes.html#signed-integers")] + [DataRow("5", "6.")] + [DataRow(" 5", "6.")] + [DataRow("+5", "6.")] + [DataRow("-5", "-4.")] + public void Integer_IsParsed(string start, string marker) + { + Assert.AreEqual(marker, Marker($"
          1. a
          2. b
          ")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "common-microsyntaxes.html#signed-integers")] + [DataRow("5th", "6.")] + [DataRow("5.9", "6.")] + public void Integer_IgnoresTrailingCharacters(string start, string marker) + { + Assert.AreEqual(marker, Marker($"
          1. a
          2. b
          ")); + } + + [TestMethod] + [Spec("html", "common-microsyntaxes.html#non-negative-integers")] + [DataRow("2")] + [DataRow(" 2")] + public void NonNegativeInteger_IsParsed(string colspan) + { + Assert.AreEqual(200, SpannedCellWidth(colspan), 1); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "common-microsyntaxes.html#non-negative-integers")] + public void NonNegativeInteger_IgnoresTrailingCharacters() + { + Assert.AreEqual(200, SpannedCellWidth("2cols"), 1); + } + + [TestMethod] + [Spec("html", "common-microsyntaxes.html#percentages-and-dimensions")] + [DataRow("100", 100)] + [DataRow(" 100", 100)] + [DataRow("100px", 100)] + [DataRow("100.5", 100.5)] + [DataRow("50%", 500)] + [DataRow("50.%", 500)] + public void Dimension_IsParsed(string width, double expected) + { + Assert.AreEqual(expected, RuleWidth(width), 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "common-microsyntaxes.html#percentages-and-dimensions")] + [DataRow("100abc", 100)] + [DataRow("10em", 10)] + [DataRow("-5", 1000)] + [DataRow("abc", 1000)] + public void Dimension_IgnoresTrailingCharactersAndInvalidValues(string width, double expected) + { + Assert.AreEqual(expected, RuleWidth(width), 0.01); + } + + [TestMethod] + [Spec("html", "common-microsyntaxes.html#nonzero-percentages-and-lengths")] + public void NonzeroDimension_IsParsed() + { + Assert.AreEqual(100, Box("
          a
          ").Size.Width, 0.01); + } + + [TestMethod] + [Spec("html", "common-microsyntaxes.html#nonzero-percentages-and-lengths")] + public void ZeroDimension_IsIgnored() + { + const string table = "
          aaaa bbbb cccc
          "; + + Assert.AreEqual(Box(string.Format(table, "")).Size.Width, Box(string.Format(table, " width=0")).Size.Width, 0.01); + } + + [TestMethod] + [Spec("html", "common-microsyntaxes.html#colours")] + [DataRow("red", 255, 0, 0)] + [DataRow("RED", 255, 0, 0)] + [DataRow(" red ", 255, 0, 0)] + [DataRow("#f00", 255, 0, 0)] + [DataRow("#00ff00", 0, 255, 0)] + public void LegacyColor_IsParsed(string bgcolor, int r, int g, int b) + { + AssertColor(r, g, b, Box($"
          a
          ").ActualBackgroundColor); + AssertColor(r, g, b, Box($"a").ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "common-microsyntaxes.html#colours")] + [DataRow("ff0000", 255, 0, 0)] + [DataRow("#ff000080", 255, 0, 128)] + [DataRow("chucknorris", 192, 0, 0)] + [DataRow("rgb(255,0,0)", 0, 37, 0)] + public void LegacyColor_ParsesAnyStringAsHexDigits(string bgcolor, int r, int g, int b) + { + AssertColor(r, g, b, Box($"
          a
          ").ActualBackgroundColor); + } + + [TestMethod] + [Spec("html", "common-microsyntaxes.html#space-separated-tokens")] + [DataRow("a b")] + [DataRow(" a b ")] + public void ClassTokens_AreSeparatedBySpaces(string classes) + { + AssertClassesMatch(classes); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "common-microsyntaxes.html#space-separated-tokens")] + [DataRow("a\tb")] + [DataRow("a\nb")] + [DataRow("a\fb")] + [DataRow("a\r\nb")] + public void ClassTokens_AreSeparatedByAnyAsciiWhitespace(string classes) + { + AssertClassesMatch(classes); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "common-microsyntaxes.html#mq")] + public void StyleElement_OnlyAppliesWhenItsMediaMatches() + { + AssertColor(0, 0, 0, Box("

          a

          ").ActualColor); + } + + private static double RuleWidth(string width) => + Box($"
          ").Size.Width; + + private static double SpannedCellWidth(string colspan) => + Box($"" + + "
          ").Size.Width; + + private static void AssertClassesMatch(string classes) + { + var box = Box($"

          a

          "); + + AssertColor(0, 255, 0, box.ActualColor); + AssertColor(0, 0, 255, box.ActualBackgroundColor); + } + + private static void AssertColor(int r, int g, int b, RColor actual) + { + Assert.AreEqual((r, g, b), (actual.R, actual.G, actual.B), $"Actual {actual}"); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 2b2c5179a..1b24a3a25 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -5,91 +5,77 @@ snapshot: 7a1d2b8107ef368c4951f15f091d82fb62e59dde sections: common-microsyntaxes.html#common-microsyntaxes: title: 2.3 Common microsyntaxes - status: unknown + status: partial common-microsyntaxes.html#common-parser-idioms: title: 2.3.1 Common parser idioms - status: unknown + status: n/a common-microsyntaxes.html#boolean-attributes: title: 2.3.2 Boolean attributes - status: unknown + status: supported common-microsyntaxes.html#keywords-and-enumerated-attributes: title: 2.3.3 Keywords and enumerated attributes - status: unknown + status: partial + gaps: + - "The align keywords are matched case-sensitively, align=CENTER becomes 'vertical-align: center'" + - An invalid keyword is passed on to CSS instead of falling back to the invalid value default, e.g. dir=sideways doesn't take the parent's direction common-microsyntaxes.html#numbers: title: 2.3.4 Numbers - status: unknown + status: partial common-microsyntaxes.html#signed-integers: title: 2.3.4.1 Signed integers - status: unknown + status: partial + gaps: + - Characters after the digits make the value an error instead of being ignored, e.g. start=5th common-microsyntaxes.html#non-negative-integers: title: 2.3.4.2 Non-negative integers - status: unknown + status: partial + gaps: + - Characters after the digits make the value an error instead of being ignored, e.g. colspan=2cols common-microsyntaxes.html#floating-point-numbers: title: 2.3.4.3 Floating-point numbers - status: unknown + status: unsupported common-microsyntaxes.html#percentages-and-dimensions: title: 2.3.4.4 Percentages and lengths - status: unknown + status: partial + gaps: + - Characters after the number aren't ignored, width=10em is 10em instead of 10px and width=100abc is invalid + - Invalid values aren't ignored, hr width=-5 or width=abc makes the rule 0 wide instead of full width common-microsyntaxes.html#nonzero-percentages-and-lengths: title: 2.3.4.5 Nonzero percentages and lengths - status: unknown + status: supported common-microsyntaxes.html#lists-of-floating-point-numbers: title: 2.3.4.6 Lists of floating-point numbers - status: unknown + status: unsupported common-microsyntaxes.html#lists-of-dimensions: title: 2.3.4.7 Lists of dimensions - status: unknown + status: unsupported common-microsyntaxes.html#dates-and-times: title: 2.3.5 Dates and times - status: unknown - common-microsyntaxes.html#months: - title: 2.3.5.1 Months - status: unknown - common-microsyntaxes.html#dates: - title: 2.3.5.2 Dates - status: unknown - common-microsyntaxes.html#yearless-dates: - title: 2.3.5.3 Yearless dates - status: unknown - common-microsyntaxes.html#times: - title: 2.3.5.4 Times - status: unknown - common-microsyntaxes.html#local-dates-and-times: - title: 2.3.5.5 Local dates and times - status: unknown - common-microsyntaxes.html#time-zones: - title: 2.3.5.6 Time zones - status: unknown - common-microsyntaxes.html#global-dates-and-times: - title: 2.3.5.7 Global dates and times - status: unknown - common-microsyntaxes.html#weeks: - title: 2.3.5.8 Weeks - status: unknown - common-microsyntaxes.html#durations: - title: 2.3.5.9 Durations - status: unknown - common-microsyntaxes.html#vaguer-moments-in-time: - title: 2.3.5.10 Vaguer moments in time - status: unknown + status: n/a common-microsyntaxes.html#colours: title: 2.3.6 Legacy colors - status: unknown + status: partial + gaps: + - Values other than named colors, #rgb and #rrggbb are parsed as CSS colors instead of as hex digits, e.g. ff0000 and chucknorris aren't colors and rgb(255,0,0) is red common-microsyntaxes.html#space-separated-tokens: title: 2.3.7 Space-separated tokens - status: unknown + status: partial + gaps: + - class is only split on spaces, not on tabs, line feeds, form feeds or carriage returns common-microsyntaxes.html#comma-separated-tokens: title: 2.3.8 Comma-separated tokens - status: unknown + status: n/a common-microsyntaxes.html#syntax-references: title: 2.3.9 References - status: unknown + status: unsupported common-microsyntaxes.html#mq: title: 2.3.10 Media queries - status: unknown + status: unsupported + gaps: + - The media attribute of style and link is ignored common-microsyntaxes.html#unique-values: title: 2.3.11 Unique internal values - status: unknown + status: n/a dom.html#elements: title: 3.2 Elements status: unknown From f7ba31df99dce9f1e5f038873b1be4b543df2ddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:25:34 +0100 Subject: [PATCH 029/150] Verify and document HTML elements and global attributes compliance --- Source/HtmlRenderer/Core/Parse/CssParser.cs | 6 +- .../Html/CommonMicrosyntaxesTests.cs | 1 + .../Test/HtmlRenderer.Test/Html/DomTests.cs | 102 ++++++++++++++++++ Spec/html.yml | 87 +++++---------- 4 files changed, 132 insertions(+), 64 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/DomTests.cs diff --git a/Source/HtmlRenderer/Core/Parse/CssParser.cs b/Source/HtmlRenderer/Core/Parse/CssParser.cs index 39a7b32e5..d9cd7b8c9 100644 --- a/Source/HtmlRenderer/Core/Parse/CssParser.cs +++ b/Source/HtmlRenderer/Core/Parse/CssParser.cs @@ -118,10 +118,8 @@ public void ParseStyleSheet(CssData cssData, string stylesheet, Uri baseUri = nu } /// - /// Parse a single inline style="" attribute value into its one resulting style rule, by - /// wrapping it in a synthetic universal-selector rule and parsing it through the same - /// tokenizer/grammar as a full stylesheet - so inline style values get exactly the same value - /// parsing (shorthands, calc(), var(), etc.) as anything from a <style> block. + /// Parses the value of a style attribute, HTML §3.2.6.5. The declarations are wrapped in a + /// universal rule to go through the same parser as a stylesheet. /// /// the raw declaration list, e.g. "color: red; font-weight: bold" /// the single parsed style rule, or null if the declaration block was empty/invalid diff --git a/Source/Test/HtmlRenderer.Test/Html/CommonMicrosyntaxesTests.cs b/Source/Test/HtmlRenderer.Test/Html/CommonMicrosyntaxesTests.cs index a07a44494..03236a5a0 100644 --- a/Source/Test/HtmlRenderer.Test/Html/CommonMicrosyntaxesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Html/CommonMicrosyntaxesTests.cs @@ -35,6 +35,7 @@ public void AlignKeyword_MatchesCaseInsensitively() [TestMethod] [Ignore("Gap, see Spec/html.yml")] [Spec("html", "common-microsyntaxes.html#keywords-and-enumerated-attributes")] + [Spec("html", "dom.html#the-dir-attribute")] public void InvalidKeyword_IsIgnored() { Assert.AreEqual("rtl", Box("

          a

          ").Direction); diff --git a/Source/Test/HtmlRenderer.Test/Html/DomTests.cs b/Source/Test/HtmlRenderer.Test/Html/DomTests.cs new file mode 100644 index 000000000..6d3bf563f --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/DomTests.cs @@ -0,0 +1,102 @@ +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class DomTests +{ + [TestMethod] + [Spec("html", "dom.html#global-attributes")] + public void GlobalAttributes_ApplyToUnknownElements() + { + var box = Box("a"); + + AssertColor(0, 255, 0, box.ActualColor); + AssertColor(0, 0, 255, box.ActualBackgroundColor); + } + + [TestMethod] + [Spec("html", "dom.html#global-attributes")] + public void Id_CanStartWithADigit() + { + AssertColor(0, 255, 0, Box("

          a

          ", "1a").ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "dom.html#the-lang-and-xml:lang-attributes")] + public void Language_IsInheritedFromTheParent() + { + AssertColor(0, 255, 0, Box("

          a

          ").ActualColor); + } + + [TestMethod] + [Spec("html", "dom.html#the-dir-attribute")] + [DataRow("

          a

          ", "rtl")] + [DataRow("

          a

          ", "rtl")] + [DataRow("

          a

          ", "ltr")] + public void Dir_SetsTheDirectionality(string html, string direction) + { + Assert.AreEqual(direction, Box(html).Direction); + } + + [TestMethod] + [Spec("html", "dom.html#the-dir-attribute")] + public void ElementWithoutDir_HasItsParentsDirectionality() + { + Assert.AreEqual("rtl", Box("

          a

          ").Direction); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "dom.html#the-dir-attribute")] + [DataRow("

          שלום abc

          ", "rtl")] + [DataRow("

          123 שלום

          ", "rtl")] + [DataRow("

          abc שלום

          ", "ltr")] + [DataRow("

          123

          ", "ltr")] + public void DirAuto_TakesTheDirectionOfTheFirstStrongCharacter(string html, string direction) + { + Assert.AreEqual(direction, Box(html).Direction); + } + + [TestMethod] + [Spec("html", "dom.html#the-style-attribute")] + public void StyleAttribute_OverridesStylesheets() + { + AssertColor(0, 255, 0, Box("

          a

          ").ActualColor); + } + + [TestMethod] + [Spec("html", "dom.html#the-style-attribute")] + public void StyleAttribute_SkipsInvalidDeclarations() + { + var box = Box("

          a

          "); + + AssertColor(0, 255, 0, box.ActualColor); + AssertColor(0, 0, 255, box.ActualBackgroundColor); + } + + [TestMethod] + [Spec("html", "dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes")] + public void DataAttribute_CanBeUsedFromCss() + { + var box = Box("" + + "

          a

          "); + + AssertColor(0, 255, 0, box.ActualColor); + Assert.AreEqual("Hi", string.Concat(box.Boxes.Single(b => b.IsBeforePseudoElement).Words.Select(w => w.Text))); + } + + [TestMethod] + [Spec("html", "dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes")] + public void DataAttributeName_IsLowercased() + { + AssertColor(0, 255, 0, Box("

          a

          ").ActualColor); + } + + private static void AssertColor(int r, int g, int b, RColor actual) + { + Assert.AreEqual((r, g, b), (actual.R, actual.G, actual.B), $"Actual {actual}"); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 1b24a3a25..598926d19 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -78,100 +78,67 @@ sections: status: n/a dom.html#elements: title: 3.2 Elements - status: unknown + status: partial dom.html#semantics-2: title: 3.2.1 Semantics - status: unknown + status: n/a dom.html#elements-in-the-dom: title: 3.2.2 Elements in the DOM - status: unknown + status: n/a dom.html#html-element-constructors: title: 3.2.3 HTML element constructors - status: unknown + status: n/a dom.html#element-definitions: title: 3.2.4 Element definitions - status: unknown - dom.html#attributes: - title: 3.2.4.1 Attributes - status: unknown + status: n/a dom.html#content-models: title: 3.2.5 Content models - status: unknown - dom.html#the-nothing-content-model: - title: 3.2.5.1 The "nothing" content model - status: unknown - dom.html#kinds-of-content: - title: 3.2.5.2 Kinds of content - status: unknown - dom.html#metadata-content: - title: 3.2.5.2.1 Metadata content - status: unknown - dom.html#flow-content: - title: 3.2.5.2.2 Flow content - status: unknown - dom.html#sectioning-content: - title: 3.2.5.2.3 Sectioning content - status: unknown - dom.html#heading-content: - title: 3.2.5.2.4 Heading content - status: unknown - dom.html#phrasing-content: - title: 3.2.5.2.5 Phrasing content - status: unknown - dom.html#embedded-content-2: - title: 3.2.5.2.6 Embedded content - status: unknown - dom.html#interactive-content: - title: 3.2.5.2.7 Interactive content - status: unknown - dom.html#palpable-content: - title: 3.2.5.2.8 Palpable content - status: unknown - dom.html#script-supporting-elements: - title: 3.2.5.2.9 Script-supporting elements - status: unknown - dom.html#transparent-content-models: - title: 3.2.5.3 Transparent content models - status: unknown - dom.html#paragraphs: - title: 3.2.5.4 Paragraphs - status: unknown + status: n/a dom.html#global-attributes: title: 3.2.6 Global attributes - status: unknown + status: partial dom.html#the-title-attribute: title: 3.2.6.1 The title attribute - status: unknown + status: unsupported + gaps: + - Advisory information isn't shown to the user dom.html#the-lang-and-xml:lang-attributes: title: 3.2.6.2 The lang and xml:lang attributes - status: unknown + status: unsupported + gaps: + - The language of an element isn't determined, :lang() never matches, see CSS 2.1 5.11.4 dom.html#the-translate-attribute: title: 3.2.6.3 The translate attribute - status: unknown + status: n/a dom.html#the-dir-attribute: title: 3.2.6.4 The dir attribute - status: unknown + status: partial + gaps: + - dir=auto doesn't determine the direction from the text + - An invalid value doesn't fall back to the parent directionality dom.html#the-style-attribute: title: 3.2.6.5 The style attribute - status: unknown + status: supported dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes: title: 3.2.6.6 Embedding custom non-visible data with the data-* attributes - status: unknown + status: supported dom.html#the-innertext-idl-attribute: title: 3.2.7 The innerText and outerText properties - status: unknown + status: n/a dom.html#requirements-relating-to-the-bidirectional-algorithm: title: 3.2.8 Requirements relating to the bidirectional algorithm - status: unknown + status: unsupported dom.html#authoring-conformance-criteria-for-bidirectional-algorithm-formatting-characters: title: 3.2.8.1 Authoring conformance criteria for bidirectional-algorithm formatting characters - status: unknown + status: n/a dom.html#user-agent-conformance-criteria: title: 3.2.8.2 User agent conformance criteria - status: unknown + status: unsupported + gaps: + - No Unicode bidirectional algorithm, see CSS 2.1 9.10 dom.html#wai-aria: title: 3.2.9 Requirements related to ARIA and to platform accessibility APIs - status: unknown + status: n/a semantics.html#semantics: title: 4 The elements of HTML status: partial From 61ab1c798071410f94cd273999152bd502046cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:21:55 +0100 Subject: [PATCH 030/150] Verify and document HTML document metadata compliance --- .../Core/Handlers/StylesheetLoadHandler.cs | 2 +- Source/HtmlRenderer/Core/Parse/DomParser.cs | 6 +- Source/HtmlRenderer/Core/Utils/CommonUtils.cs | 7 +- .../StyleElementTextConcatenationTests.cs | 49 +--- .../HtmlRenderer.Test/Css/ConformanceTests.cs | 3 + .../HtmlRenderer.Test/Css/MediaTypeTests.cs | 2 + .../Html/DocumentMetadataTests.cs | 277 ++++++++++++++++++ Spec/html.yml | 65 ++-- 8 files changed, 334 insertions(+), 77 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/DocumentMetadataTests.cs diff --git a/Source/HtmlRenderer/Core/Handlers/StylesheetLoadHandler.cs b/Source/HtmlRenderer/Core/Handlers/StylesheetLoadHandler.cs index dd2167fee..df9df2c5f 100644 --- a/Source/HtmlRenderer/Core/Handlers/StylesheetLoadHandler.cs +++ b/Source/HtmlRenderer/Core/Handlers/StylesheetLoadHandler.cs @@ -100,7 +100,7 @@ private static async Task LoadStylesheet(HtmlContainerInt htmlContainer, return string.Empty; } - // CSS 2.1 §3.4. A response of another type is skipped like a missing one. A response without + // CSS 2.1 §3.4, HTML §4.2.4.2. A response of another type is skipped like a missing one. A response without // a Content-Type, e.g. a local file, is trusted. if (networkResponse.ResponseHeaders != null && networkResponse.ResponseHeaders.TryGetValue("Content-Type", out var contentTypeValues) && diff --git a/Source/HtmlRenderer/Core/Parse/DomParser.cs b/Source/HtmlRenderer/Core/Parse/DomParser.cs index e5c0f721d..d1c662572 100644 --- a/Source/HtmlRenderer/Core/Parse/DomParser.cs +++ b/Source/HtmlRenderer/Core/Parse/DomParser.cs @@ -103,9 +103,9 @@ public DomParser(CssParser cssParser) #region Private methods /// - /// Read styles defined inside the dom structure in links and style elements.
          - /// If the html tag is "style" tag parse it content and add to the css data for all future tags parsing.
          - /// If the html tag is "link" that point to style data parse it content and add to the css data for all future tags parsing.
          + /// Collects the style sheets of link rel=stylesheet (HTML §4.2.4) and style (HTML §4.2.6) + /// elements in tree order. Runs before is set, so a base + /// element doesn't affect the hrefs resolved here. ///
          /// the box to parse style data in /// the html container to use for reference resolve diff --git a/Source/HtmlRenderer/Core/Utils/CommonUtils.cs b/Source/HtmlRenderer/Core/Utils/CommonUtils.cs index 13f8ae88a..1e97811bb 100644 --- a/Source/HtmlRenderer/Core/Utils/CommonUtils.cs +++ b/Source/HtmlRenderer/Core/Utils/CommonUtils.cs @@ -178,11 +178,8 @@ public static Uri TryGetUri(string path) } /// - /// Resolves (a src/href/CSS url() reference, absolute - /// or relative) against the document's base URL: when given (a - /// stylesheet's own URL, for resolving a nested @import or @font-face src relative to - /// where that stylesheet was fetched from, not the document's own base), else the document's - /// <base href> element if it has one, else the adapter's own + /// Resolves against when given, else the + /// document base URL (HTML §4.2.3): the href of the first base element, else /// . /// /// the html container to resolve the base URI against diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/StyleElementTextConcatenationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/StyleElementTextConcatenationTests.cs index dd247cd1d..da7d65a4d 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/StyleElementTextConcatenationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/StyleElementTextConcatenationTests.cs @@ -2,43 +2,6 @@ namespace HtmlRenderer.IntegrationTest.Text; -/// -/// Regression coverage for a <style> element whose CSS contains a < character (e.g. a -/// content: "<"-style value). Ported from PeachPDF's StyleElementTextConcatenationTests, which -/// exists because PeachPDF's HTML tokenizer splits such raw text into several data tokens at each <, -/// and PeachPDF's DomParser used to parse each fragment as an independent (possibly syntactically -/// incomplete) stylesheet instead of concatenating them first - breaking any rule after the split point. -/// -/// -/// -/// The same split premise is still real. This fork's DomParser.CascadeParseStyles -/// (Core/Parse/DomParser.cs, ~line 134-135) still parses a <style> element's child text -/// nodes independently in a foreach loop - _cssParser.ParseStyleSheet(cssData, child.Text) - -/// with no concatenation, exactly like PeachPDF's bug. And this fork's HTML tokenizer (HtmlKit's -/// HtmlTokenizer, used by HtmlParser.ParseDocument) DOES split a <style> element's -/// raw text into multiple data tokens at an embedded <, becoming two separate anonymous child -/// text nodes under the <style> box. -/// -/// -/// Verified against the CSS engine port: the previously-recorded "does not reproduce" conclusion is now -/// stale and wrong. That conclusion relied on the OLD CssParser's brace-matching scanner, which has -/// been replaced entirely by the vendored ExCSS-derived tokenizer/grammar (same lineage as PeachPDF's own). -/// Confirmed by direct execution against the built assembly: parsing the second text-node fragment -/// ("<"; }\n #b { color: green; }) alone now throws a real, unhandled -/// System.NullReferenceException from StyleRule.SelectorText's getter, via -/// StylesheetComposer.CreateNestedStyleRule/TryCreateNestedRule (the new engine's CSS-Nesting -/// support tries to parse the malformed leading fragment as an incomplete nested rule and dereferences a null -/// selector while doing so). Because HtmlContainerInt.SetHtml is async Task and -/// does not await it, this exception is thrown into an unobserved task and -/// silently discarded - the outward symptom is container.Root staying null after -/// Clear(), which is what 's own Assert.IsNotNull(container.Root) -/// catches. So the underlying "no concatenation" premise is still real, and now manifests as a genuine crash -/// bug in the new engine's CSS-Nesting parse path (not a silent "rule doesn't apply" the way PeachPDF's own -/// bug read) - out of scope for this test-porting pass to fix in Core, so the regression test is left in and -/// marked [Ignore] below with this freshly-verified reason, rather than silently deleted or left -/// falsely documented as passing. -/// -/// // This fork's CssParser keeps a process-wide, non-thread-safe regex cache // (RegexParserUtils.GetRegex's static Dictionary) that SetHtml/DefaultCssData populate lazily on first use per // AppDomain; running HtmlContainerInt.SetHtml from more than one thread at once (as MSTestSettings.cs's @@ -49,15 +12,8 @@ namespace HtmlRenderer.IntegrationTest.Text; [TestClass] public sealed class StyleElementTextConcatenationTests { - [Ignore("CSS engine port regression, freshly verified: parsing the split text-node fragment " + - "'\"<\"; }\\n #b { color: green; }' now throws System.NullReferenceException from " + - "StyleRule.SelectorText via StylesheetComposer.CreateNestedStyleRule/TryCreateNestedRule (the " + - "new CSS-Nesting support dereferences a null selector on this malformed input). Because SetHtml " + - "is unawaited by LayoutHarness.Layout, the exception is silently swallowed and container.Root " + - "stays null - see this class's remarks for the full trace. Out of scope for this test-porting " + - "pass to fix in Core; left in and ignored so the regression stays visible rather than silently " + - "deleted.")] [TestMethod] + [Spec("html", "semantics.html#the-style-element")] public void RuleAfterLessThanInDeclaration_StillApplies() { const string html = """ @@ -73,9 +29,6 @@ public void RuleAfterLessThanInDeclaration_StillApplies() var b = LayoutHarness.FindById(root, "b")!; Assert.IsNotNull(b); - // Without concatenation, "#b { color: green }" would need to land in a mis-parsed fragment starting - // at '<' and never apply, leaving the default "black" - see class remarks for what actually happens - // now (a crash, not a silent non-application). Assert.AreEqual("green", b.Color); } } diff --git a/Source/Test/HtmlRenderer.Test/Css/ConformanceTests.cs b/Source/Test/HtmlRenderer.Test/Css/ConformanceTests.cs index eaa5d484a..9cbc5b669 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ConformanceTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ConformanceTests.cs @@ -24,6 +24,7 @@ private static string DataUri(string mediaType, string content) => [TestMethod] [Spec("css21", "conform.html#conformance")] + [Spec("html", "semantics.html#fetching-and-processing-a-resource-from-a-link-element")] public void MissingStyleSheet_TheOthersStillApply() { Assert.AreEqual(Green, ColorOf("")); @@ -61,6 +62,7 @@ public void UserStyleSheet_ImportantBeatsAuthorImportant() [TestMethod] [Spec("css21", "conform.html#text-css")] + [Spec("html", "semantics.html#processing-the-type-attribute")] public void TextCssResource_IsUsedAsAStyleSheet() { Assert.AreEqual(Green, ColorOf($"")); @@ -68,6 +70,7 @@ public void TextCssResource_IsUsedAsAStyleSheet() [TestMethod] [Spec("css21", "conform.html#text-css")] + [Spec("html", "semantics.html#processing-the-type-attribute")] public void ResourceOfAnotherContentType_IsNotUsedAsAStyleSheet() { Assert.AreNotEqual(Green, ColorOf($"")); diff --git a/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs index 49c501a7b..399c1e161 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs @@ -34,6 +34,7 @@ public void LinkWithMatchingMedia_Applies() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "media.html#media-sheets")] + [Spec("html", "semantics.html#processing-the-media-attribute")] public void LinkWithOtherMedia_IsIgnored() { Assert.AreNotEqual(Green, ColorOf($"")); @@ -42,6 +43,7 @@ public void LinkWithOtherMedia_IsIgnored() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "media.html#media-sheets")] + [Spec("html", "semantics.html#the-style-element")] public void StyleWithOtherMedia_IsIgnored() { Assert.AreNotEqual(Green, ColorOf("")); diff --git a/Source/Test/HtmlRenderer.Test/Html/DocumentMetadataTests.cs b/Source/Test/HtmlRenderer.Test/Html/DocumentMetadataTests.cs new file mode 100644 index 000000000..56072a78b --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/DocumentMetadataTests.cs @@ -0,0 +1,277 @@ +using System.Text; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Network; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class DocumentMetadataTests +{ + private const string DocumentUrl = "https://example.com/doc/index.html"; + + private static readonly RColor Green = RColor.FromArgb(0, 128, 0); + + [TestMethod] + [Spec("html", "semantics.html#the-html-element")] + public void Html_IsTheRootOfTheDocument() + { + var html = Load("

          a

          ").Box("x"); + + Assert.IsNull(html.ParentBox?.HtmlTag); + CollectionAssert.AreEqual(new[] { "head", "body" }, html.Boxes.Select(b => b.HtmlTag?.Name).ToArray()); + } + + [TestMethod] + [Spec("html", "semantics.html#the-head-element")] + public void MetadataInTheHead_IsProcessed() + { + var doc = Load( + "t", + "

          a

          ", + new() { ["https://example.com/doc/a.css"] = "p { color: green }" }); + + Assert.AreEqual(Green, doc.Box("x").ActualColor); + Assert.AreEqual("bold", doc.Box("x").FontWeight); + } + + [TestMethod] + [Spec("html", "semantics.html#the-base-element")] + public void BaseHref_IsTheBaseForRelativeUrls() + { + var doc = Load("", ""); + + CollectionAssert.Contains(doc.Requested, "https://example.com/img/a.png"); + } + + [TestMethod] + [Spec("html", "semantics.html#the-base-element")] + public void WithoutABase_RelativeUrlsResolveAgainstTheDocumentUrl() + { + var doc = Load("", ""); + + CollectionAssert.Contains(doc.Requested, "https://example.com/doc/a.css"); + CollectionAssert.Contains(doc.Requested, "https://example.com/doc/a.png"); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "semantics.html#the-base-element")] + public void BaseHref_IsTheBaseForStyleSheetLinks() + { + var doc = Load("", "

          a

          "); + + CollectionAssert.Contains(doc.Requested, "https://example.com/css/a.css"); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "semantics.html#the-base-element")] + public void RelativeBaseHref_IsResolvedAgainstTheDocumentUrl() + { + var doc = Load("", ""); + + CollectionAssert.Contains(doc.Requested, "https://example.com/doc/img/a.png"); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "semantics.html#the-base-element")] + public void FirstBaseWithAnHref_IsUsed() + { + var doc = Load("", ""); + + CollectionAssert.Contains(doc.Requested, "https://example.com/img/a.png"); + } + + [TestMethod] + [Spec("html", "semantics.html#the-link-element")] + [Spec("html", "semantics.html#fetching-and-processing-a-resource-from-a-link-element")] + public void StyleSheetLink_IsFetchedAndApplied() + { + var doc = Load( + "", + "

          a

          ", + new() { ["https://example.com/doc/a.css"] = "p { color: green }", ["https://example.com/doc/b.css"] = "p { font-weight: bold }" }); + + Assert.AreEqual(Green, doc.Box("x").ActualColor); + Assert.AreEqual("bold", doc.Box("x").FontWeight); + } + + [TestMethod] + [Spec("html", "semantics.html#the-link-element")] + public void LinkWithAnotherRelation_IsNotFetched() + { + var doc = Load("", "

          a

          "); + + CollectionAssert.AreEqual(Array.Empty(), doc.Requested); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "semantics.html#the-link-element")] + public void RelWithSeveralKeywords_StillLinksTheStyleSheet() + { + var doc = Load( + "", + "

          a

          ", + new() { ["https://example.com/doc/a.css"] = "p { color: green }" }); + + Assert.AreEqual(Green, doc.Box("x").ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "semantics.html#the-link-element")] + [Spec("html", "semantics.html#the-style-element")] + [DataRow("")] + [DataRow("")] + public void StyleSheetWithAnotherTitle_ThanThePreferredSet_IsDisabled(string head) + { + var doc = Load( + head, + "

          a

          ", + new() { ["https://example.com/doc/a.css"] = "p { color: green }", ["https://example.com/doc/b.css"] = "p { color: red }" }); + + Assert.AreEqual(Green, doc.Box("x").ActualColor); + } + + [TestMethod] + [Spec("html", "semantics.html#processing-the-type-attribute")] + public void LinkedResourceOfAnotherContentType_IsNotApplied() + { + var doc = Load( + "", + "

          a

          ", + new() { ["https://example.com/doc/a.txt"] = "p { color: green }" }, + contentType: "text/plain"); + + Assert.AreNotEqual(Green, doc.Box("x").ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "semantics.html#processing-the-type-attribute")] + public void LinkWithAnUnsupportedType_IsNotFetched() + { + var doc = Load( + "", + "

          a

          ", + new() { ["https://example.com/doc/a.css"] = "p { color: green }" }); + + CollectionAssert.AreEqual(Array.Empty(), doc.Requested); + } + + [TestMethod] + [Spec("html", "semantics.html#fetching-and-processing-a-resource-from-a-link-element")] + public void MissingStyleSheet_IsSkipped() + { + var doc = Load("", "

          a

          "); + + Assert.AreEqual(Green, doc.Box("x").ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "semantics.html#fetching-and-processing-a-resource-from-a-link-element")] + [Spec("html", "semantics.html#the-style-element")] + [DataRow("")] + [DataRow("")] + public void ImportedStyleSheet_IsLoaded(string head) + { + var doc = Load( + head, + "

          a

          ", + new() { ["https://example.com/doc/a.css"] = "@import url(b.css);", ["https://example.com/doc/b.css"] = "p { color: green }" }); + + Assert.AreEqual(Green, doc.Box("x").ActualColor); + } + + [TestMethod] + [Spec("html", "semantics.html#the-style-element")] + public void StyleElement_AppliesWhereverItIs() + { + var doc = Load( + "", + "

          a

          "); + + Assert.AreEqual(Green, doc.Box("x").ActualColor); + Assert.AreEqual("bold", doc.Box("x").FontWeight); + } + + [TestMethod] + [Spec("html", "semantics.html#the-style-element")] + public void StyleElement_TextWithALessThanSign_IsOneStyleSheet() + { + var doc = Load("", "

          a

          "); + + Assert.AreEqual(Green, doc.Box("x").ActualColor); + } + + [TestMethod] + [Spec("html", "semantics.html#the-style-element")] + [DataRow("")] + [DataRow("text/css")] + [DataRow("TEXT/CSS")] + public void StyleElement_WithACssType_Applies(string type) + { + Assert.AreEqual(Green, Load($"", "

          a

          ").Box("x").ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "semantics.html#the-style-element")] + [DataRow("text/plain")] + [DataRow("text/css; charset=utf-8")] + public void StyleElement_WithAnotherType_IsIgnored(string type) + { + Assert.AreNotEqual(Green, Load($"", "

          a

          ").Box("x").ActualColor); + } + + private static Document Load(string head, string body, Dictionary? resources = null, string contentType = "text/css") => + Load($"{head}{body}", resources, contentType); + + private static Document Load(string html, Dictionary? resources = null, string contentType = "text/css") + { + var loader = new StubLoader(resources ?? [], contentType); + var container = new HtmlContainerInt(new MockAdapter { NetworkLoader = loader }) { MaxSize = new RSize(1000, 4000), AvoidAsyncImagesLoading = true }; + container.SetHtml(html).GetAwaiter().GetResult(); + using (var graphics = new RecordingGraphics()) + container.PerformLayout(graphics); + + Assert.IsNotNull(container.Root); + return new Document(container.Root!, loader.Requested); + } + + private sealed record Document(CssBox Root, List Requested) + { + public CssBox Box(string id) + { + var box = LayoutHarness.FindById(Root, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box; + } + } + + /// Serves DocumentUrl as the document's URL and the given resources from memory. + private sealed class StubLoader(Dictionary resources, string contentType) : RNetworkLoader + { + public List Requested { get; } = []; + + public override RUri? BaseUri => new(DocumentUrl); + + public override Task GetPrimaryContents() => Task.FromResult(string.Empty); + + public override Task GetResourceStream(RUri uri) + { + Requested.Add(uri.AbsoluteUri); + if (!resources.TryGetValue(uri.AbsoluteUri, out var content)) + return Task.FromResult(null); + + var headers = new Dictionary { ["Content-Type"] = [contentType] }; + return Task.FromResult(new RNetworkResponse(new MemoryStream(Encoding.UTF8.GetBytes(content)), headers)); + } + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 598926d19..5520c1970 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -144,64 +144,89 @@ sections: status: partial semantics.html#the-root-element: title: 4.1 The document element - status: unknown + status: supported semantics.html#the-html-element: title: 4.1.1 The html element - status: unknown + status: supported semantics.html#document-metadata: title: 4.2 Document metadata - status: unknown + status: partial semantics.html#the-head-element: title: 4.2.1 The head element - status: unknown + status: supported semantics.html#the-title-element: title: 4.2.2 The title element - status: unknown + status: unsupported + gaps: + - The document title isn't used anywhere, e.g. not as the title of a generated PDF semantics.html#the-base-element: title: 4.2.3 The base element - status: unknown + status: partial + gaps: + - Doesn't apply to style sheet links, their href resolves against the adapter's base URL + - A relative href isn't resolved against the document URL, the references then fail to load + - The first base element is used even if it has no href semantics.html#the-link-element: title: 4.2.4 The link element - status: unknown + status: partial + gaps: + - rel is compared as a whole, a stylesheet keyword among others doesn't link a style sheet + - title doesn't define style sheet sets, sheets with different titles all apply semantics.html#processing-the-media-attribute: title: 4.2.4.1 Processing the media attribute - status: unknown + status: unsupported + gaps: + - media is ignored, a style sheet for other media still applies semantics.html#processing-the-type-attribute: title: 4.2.4.2 Processing the type attribute - status: unknown + status: partial + gaps: + - type is ignored, a link to an unsupported type is still fetched semantics.html#fetching-and-processing-a-resource-from-a-link-element: title: 4.2.4.3 Fetching and processing a resource from a link element - status: unknown + status: partial + gaps: + - Critical subresources aren't loaded, @import never loads semantics.html#processing-link-headers: title: 4.2.4.4 Processing `Link` headers - status: unknown + status: n/a semantics.html#early-hints: title: 4.2.4.5 Early hints - status: unknown + status: n/a semantics.html#providing-users-with-a-means-to-follow-hyperlinks-created-using-the-link-element: title: 4.2.4.6 Providing users with a means to follow hyperlinks created using the link element - status: unknown + status: n/a semantics.html#the-meta-element: title: 4.2.5 The meta element - status: unknown + status: unsupported semantics.html#standard-metadata-names: title: 4.2.5.1 Standard metadata names - status: unknown + status: unsupported + gaps: + - color-scheme is ignored semantics.html#other-metadata-names: title: 4.2.5.2 Other metadata names - status: unknown + status: n/a semantics.html#pragma-directives: title: 4.2.5.3 Pragma directives - status: unknown + status: unsupported + gaps: + - default-style doesn't select the preferred style sheet set + - content-language doesn't set the document's default language semantics.html#charset: title: 4.2.5.4 Specifying the document's character encoding - status: unknown + status: n/a semantics.html#the-style-element: title: 4.2.6 The style element - status: unknown + status: partial + gaps: + - media is ignored, a style sheet for other media still applies + - type is ignored, a style element of another type still applies + - title doesn't define style sheet sets, sheets with different titles all apply + - Critical subresources aren't loaded, @import never loads semantics.html#interactions-of-styling-and-scripting: title: 4.2.7 Interactions of styling and scripting - status: unknown + status: n/a sections.html#sections: title: 4.3 Sections status: unknown From e9f5b5b513b9aabd0ef0d805cd135c1d0aeb751a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:24:31 +0100 Subject: [PATCH 031/150] Verify and document HTML sections compliance --- .../HtmlRenderer.Test/Html/SectionsTests.cs | 74 +++++++++++++++++++ Spec/html.yml | 51 ++++++++----- 2 files changed, 106 insertions(+), 19 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/SectionsTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Html/SectionsTests.cs b/Source/Test/HtmlRenderer.Test/Html/SectionsTests.cs new file mode 100644 index 000000000..4c7baca18 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/SectionsTests.cs @@ -0,0 +1,74 @@ +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class SectionsTests +{ + [TestMethod] + [Spec("html", "sections.html#the-body-element")] + public void Body_HoldsTheContent() + { + var body = Box("

          a

          ").ParentBox!; + + Assert.AreEqual("body", body.HtmlTag?.Name); + Assert.AreEqual("block", body.Display); + } + + [TestMethod] + [Spec("html", "sections.html#the-h1,-h2,-h3,-h4,-h5,-and-h6-elements")] + [Spec("html", "sections.html#the-address-element")] + [DataRow("h1")] + [DataRow("h2")] + [DataRow("h3")] + [DataRow("h4")] + [DataRow("h5")] + [DataRow("h6")] + [DataRow("address")] + public void Element_IsABlock(string tag) + { + Assert.AreEqual("block", Box($"<{tag} id=x>a").Display); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "sections.html#the-article-element")] + [Spec("html", "sections.html#the-section-element")] + [Spec("html", "sections.html#the-nav-element")] + [Spec("html", "sections.html#the-aside-element")] + [Spec("html", "sections.html#the-hgroup-element")] + [Spec("html", "sections.html#the-header-element")] + [Spec("html", "sections.html#the-footer-element")] + [DataRow("article")] + [DataRow("section")] + [DataRow("nav")] + [DataRow("aside")] + [DataRow("hgroup")] + [DataRow("header")] + [DataRow("footer")] + public void SectioningElement_IsABlock(string tag) + { + Assert.AreEqual("block", Box($"<{tag} id=x>a").Display); + } + + [TestMethod] + [Spec("html", "sections.html#heading-levels-&-offsets")] + public void HigherHeadingLevel_IsSmaller() + { + var sizes = Enumerable.Range(1, 6).Select(level => Box($"a").ActualFont.Size).ToArray(); + + CollectionAssert.AreEqual(sizes.OrderByDescending(s => s).ToArray(), sizes); + Assert.AreEqual(6, sizes.Distinct().Count()); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "sections.html#heading-levels-&-offsets")] + public void HeadingOffset_RaisesTheHeadingLevel() + { + var h2 = Box("

          a

          ").ActualFont.Size; + + Assert.AreEqual(h2, Box("

          a

          ").ActualFont.Size); + Assert.AreEqual(h2, Box("

          a

          ").ActualFont.Size); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 5520c1970..b06a1f60c 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -229,55 +229,68 @@ sections: status: n/a sections.html#sections: title: 4.3 Sections - status: unknown + status: partial sections.html#the-body-element: title: 4.3.1 The body element - status: unknown + status: supported sections.html#the-article-element: title: 4.3.2 The article element - status: unknown + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 sections.html#the-section-element: title: 4.3.3 The section element - status: unknown + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 sections.html#the-nav-element: title: 4.3.4 The nav element - status: unknown + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 sections.html#the-aside-element: title: 4.3.5 The aside element - status: unknown + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 sections.html#the-h1,-h2,-h3,-h4,-h5,-and-h6-elements: title: 4.3.6 The h1, h2, h3, h4, h5, and h6 elements - status: unknown + status: supported sections.html#the-hgroup-element: title: 4.3.7 The hgroup element - status: unknown + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 sections.html#the-header-element: title: 4.3.8 The header element - status: unknown + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 sections.html#the-footer-element: title: 4.3.9 The footer element - status: unknown + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 sections.html#the-address-element: title: 4.3.10 The address element - status: unknown + status: supported sections.html#headings-and-outlines-2: title: 4.3.11 Headings and outlines - status: unknown + status: partial sections.html#heading-levels-&-offsets: title: 4.3.11.1 Heading levels & offsets - status: unknown + status: partial + gaps: + - headingoffset and headingreset are ignored, the heading level is always the one of the tag sections.html#sample-outlines: title: 4.3.11.2 Sample outlines - status: unknown + status: n/a sections.html#exposing-outlines-to-users: title: 4.3.11.3 Exposing outlines to users - status: unknown + status: n/a sections.html#usage-summary-2: title: 4.3.12 Usage summary - status: unknown - sections.html#article-or-section: - title: 4.3.12.1 Article or section? - status: unknown + status: n/a grouping-content.html#grouping-content: title: 4.4 Grouping content status: partial From efd283bb4190c0090685a7e4734cb75a8d4e0be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:23:55 +0100 Subject: [PATCH 032/150] Verify and document HTML text-level semantics compliance --- .../HtmlRenderingRegressionTests.cs | 1 + .../Text/LineBreakLayoutIntegrationTests.cs | 2 + .../TextLevelSemanticsIntegrationTests.cs | 42 ++++ .../Html/TextLevelSemanticsTests.cs | 184 ++++++++++++++++++ Spec/html.yml | 78 +++++--- 5 files changed, 277 insertions(+), 30 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Text/TextLevelSemanticsIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Html/TextLevelSemanticsTests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/HtmlRenderingRegressionTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/HtmlRenderingRegressionTests.cs index 05d58bd36..6c53f201d 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/HtmlRenderingRegressionTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/HtmlRenderingRegressionTests.cs @@ -23,6 +23,7 @@ public sealed class HtmlRenderingRegressionTests private static bool _samplesLoaded; [TestMethod] + [Spec("html", "text-level-semantics.html#the-br-element")] public async Task Render_ConsecutiveBreaks_LeaveAnEmptyLine() { EnsureSamplesLoaded(); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBreakLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBreakLayoutIntegrationTests.cs index 20315ed29..60a4374db 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBreakLayoutIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBreakLayoutIntegrationTests.cs @@ -13,6 +13,7 @@ public sealed class LineBreakLayoutIntegrationTests private const double Delta = 0.5; [TestMethod] + [Spec("html", "text-level-semantics.html#the-br-element")] [DataRow("
          first
          second
          ", 40)] [DataRow("
          first

          second
          ", 60)] [DataRow("
          first

          second
          ", 60)] @@ -28,6 +29,7 @@ public void BlockHeight_MatchesTheNumberOfLines(string html, double expectedHeig } [TestMethod] + [Spec("html", "text-level-semantics.html#the-br-element")] public void BreakBetweenBlocks_LeavesOneEmptyLine() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
          a

          b
          ")); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextLevelSemanticsIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextLevelSemanticsIntegrationTests.cs new file mode 100644 index 000000000..ae1b27aa0 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextLevelSemanticsIntegrationTests.cs @@ -0,0 +1,42 @@ +using System.Drawing; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace HtmlRenderer.IntegrationTest.Text; + +// Parallel full layout passes race on the adapter singletons. +[TestClass] +[DoNotParallelize] +public sealed class TextLevelSemanticsIntegrationTests +{ + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "text-level-semantics.html#the-bdo-element")] + public async Task Bdo_Rtl_ReversesTheCharacters() + { + using var overridden = await Render("abc"); + using var reversed = await Render("cba"); + + Assert.IsTrue(SamePixels(overridden, reversed)); + } + + private static async Task Render(string body) => + (Bitmap)await HtmlRender.RenderToImageAsync( + $"{body}", + new Size(200, 40), + Color.White); + + private static bool SamePixels(Bitmap a, Bitmap b) + { + if (a.Size != b.Size) + return false; + + for (var y = 0; y < a.Height; y++) + for (var x = 0; x < a.Width; x++) + { + if (a.GetPixel(x, y) != b.GetPixel(x, y)) + return false; + } + + return true; + } +} diff --git a/Source/Test/HtmlRenderer.Test/Html/TextLevelSemanticsTests.cs b/Source/Test/HtmlRenderer.Test/Html/TextLevelSemanticsTests.cs new file mode 100644 index 000000000..2ec0b2c9c --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/TextLevelSemanticsTests.cs @@ -0,0 +1,184 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class TextLevelSemanticsTests +{ + [TestMethod] + [Spec("html", "text-level-semantics.html#the-a-element")] + public void A_WithHref_IsAHyperlink() + { + Assert.IsTrue(Box("
          a").IsClickable); + Assert.IsFalse(Box("a").IsClickable); + } + + [TestMethod] + [Spec("html", "text-level-semantics.html#the-em-element")] + [Spec("html", "text-level-semantics.html#the-cite-element")] + [Spec("html", "text-level-semantics.html#the-var-element")] + [Spec("html", "text-level-semantics.html#the-i-element")] + [DataRow("em")] + [DataRow("cite")] + [DataRow("var")] + [DataRow("i")] + public void Element_IsItalic(string tag) + { + var box = Box($"<{tag} id=x>a"); + + Assert.AreEqual("inline", box.Display); + Assert.AreEqual("italic", box.FontStyle); + } + + [TestMethod] + [Spec("html", "text-level-semantics.html#the-strong-element")] + [Spec("html", "text-level-semantics.html#the-b-element")] + [DataRow("strong")] + [DataRow("b")] + public void Element_IsBold(string tag) + { + var box = Box($"<{tag} id=x>a"); + + Assert.AreEqual("inline", box.Display); + Assert.AreEqual("bolder", box.FontWeight); + } + + [TestMethod] + [Spec("html", "text-level-semantics.html#the-code-element")] + [Spec("html", "text-level-semantics.html#the-samp-element")] + [Spec("html", "text-level-semantics.html#the-kbd-element")] + [DataRow("code")] + [DataRow("samp")] + [DataRow("kbd")] + public void Element_IsMonospace(string tag) + { + var adapter = new MockAdapter(); + adapter.AddFontFamily(new MockFontFamily("Courier New")); + adapter.AddFontFamilyMapping("monospace", "Courier New"); + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap($"<{tag} id=x>a"), adapter: adapter); + var box = LayoutHarness.FindById(root, "x")!; + + Assert.AreEqual("inline", box.Display); + Assert.AreEqual("monospace", box.FontFamily); + } + + [TestMethod] + [Spec("html", "text-level-semantics.html#the-small-element")] + public void Small_IsSmaller() + { + var box = Box("
          a
          "); + + Assert.AreEqual("inline", box.Display); + Assert.IsTrue(box.ActualFont.Size < Box("
          a
          ").ActualFont.Size); + } + + [TestMethod] + [Spec("html", "text-level-semantics.html#the-sub-and-sup-elements")] + public void SubAndSup_AreSmallerAndShifted() + { + var normal = Box("
          a
          "); + var sub = Box("
          a
          "); + var sup = Box("
          a
          "); + + Assert.AreEqual("sub", sub.VerticalAlign); + Assert.AreEqual("super", sup.VerticalAlign); + Assert.IsTrue(sub.ActualFont.Size < normal.ActualFont.Size); + Assert.IsTrue(sup.ActualFont.Size < normal.ActualFont.Size); + } + + [TestMethod] + [Spec("html", "text-level-semantics.html#the-s-element")] + [Spec("html", "text-level-semantics.html#the-u-element")] + [DataRow("s", "line-through")] + [DataRow("u", "underline")] + public void Element_IsDecorated(string tag, string decoration) + { + Assert.AreEqual(decoration, TextDecoration($"<{tag} id=x>a")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "text-level-semantics.html#the-q-element")] + public void Q_IsQuoted() + { + Assert.AreEqual("\u201Ca\u201D", Text(Box("a"))); + } + + [TestMethod] + [Spec("html", "text-level-semantics.html#the-abbr-element")] + public void Abbr_WithoutATitle_IsPlainText() + { + var box = Box("HTML"); + + Assert.AreEqual("inline", box.Display); + Assert.AreEqual("HTML", Text(box)); + Assert.AreEqual("", TextDecoration("HTML")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "text-level-semantics.html#the-abbr-element")] + public void Abbr_WithATitle_IsUnderlined() + { + StringAssert.Contains(TextDecoration("HTML"), "underline"); + } + + [TestMethod] + [Spec("html", "text-level-semantics.html#the-data-element")] + [Spec("html", "text-level-semantics.html#the-time-element")] + [Spec("html", "text-level-semantics.html#the-span-element")] + [DataRow("forty-two")] + [DataRow("")] + [DataRow("forty-two")] + public void Element_ShowsItsContentInline(string html) + { + var box = Box(html); + + Assert.AreEqual("inline", box.Display); + Assert.AreEqual("forty-two", Text(box)); + } + + [TestMethod] + [Spec("html", "text-level-semantics.html#the-bdo-element")] + public void Bdo_Rtl_ReversesTheWordOrder() + { + var box = Box("ab cd"); + var words = Words(box).ToList(); + + Assert.AreEqual("rtl", box.Direction); + Assert.AreEqual("ab", words[0].Text.Trim()); + Assert.IsTrue(words[1].Left < words[0].Left); + } + + [TestMethod] + [Spec("html", "text-level-semantics.html#the-br-element")] + public void Br_EndsTheLine() + { + var box = Box("
          a
          b
          "); + var words = Words(box).Where(w => !w.IsLineBreak).ToList(); + + Assert.AreEqual(words[0].Left, words[1].Left); + Assert.IsTrue(words[1].Top > words[0].Top); + } + + [TestMethod] + [Spec("html", "text-level-semantics.html#the-wbr-element")] + public void Wbr_IsALineBreakOpportunity() + { + var box = Box("
          aaaaaaabbbbbbb
          "); + var words = Words(box).ToList(); + + Assert.AreEqual(2, words.Count); + Assert.AreEqual(words[0].Left, words[1].Left); + Assert.IsTrue(words[1].Top > words[0].Top); + } + + private static string Text(CssBox box) => string.Concat(Words(box).Select(w => w.Text)); + + private static IEnumerable Words(CssBox box) => LayoutHarness.Descendants(box).SelectMany(b => b.Words); + + /// The decoration of the element's text, the cascade moves it from the element to its text boxes. + private static string TextDecoration(string body) => Box(body).Boxes[0].TextDecoration; +} diff --git a/Spec/html.yml b/Spec/html.yml index b06a1f60c..937f398c7 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -354,94 +354,112 @@ sections: status: supported text-level-semantics.html#text-level-semantics: title: 4.5 Text-level semantics - status: unknown + status: partial text-level-semantics.html#the-a-element: title: 4.5.1 The a element - status: unknown + status: supported text-level-semantics.html#the-em-element: title: 4.5.2 The em element - status: unknown + status: supported text-level-semantics.html#the-strong-element: title: 4.5.3 The strong element - status: unknown + status: supported text-level-semantics.html#the-small-element: title: 4.5.4 The small element - status: unknown + status: supported text-level-semantics.html#the-s-element: title: 4.5.5 The s element - status: unknown + status: supported text-level-semantics.html#the-cite-element: title: 4.5.6 The cite element - status: unknown + status: supported text-level-semantics.html#the-q-element: title: 4.5.7 The q element - status: unknown + status: unsupported + gaps: + - No quotation marks around the content, see 15.3.4 text-level-semantics.html#the-dfn-element: title: 4.5.8 The dfn element - status: unknown + status: unsupported + gaps: + - Unknown element, rendered inline without its default style, see 15.3.4 text-level-semantics.html#the-abbr-element: title: 4.5.9 The abbr element - status: unknown + status: partial + gaps: + - abbr with a title isn't underlined, see 15.3.4 text-level-semantics.html#the-ruby-element: title: 4.5.10 The ruby element - status: unknown + status: unsupported + gaps: + - No ruby layout, the annotations are rendered inline after the base text, see 15.3.4 text-level-semantics.html#the-rt-element: title: 4.5.11 The rt element - status: unknown + status: unsupported + gaps: + - No ruby layout, rendered inline after the base text, see 15.3.4 text-level-semantics.html#the-rp-element: title: 4.5.12 The rp element - status: unknown + status: unsupported + gaps: + - Displayed, see 15.3.1 text-level-semantics.html#the-data-element: title: 4.5.13 The data element - status: unknown + status: supported text-level-semantics.html#the-time-element: title: 4.5.14 The time element - status: unknown + status: supported text-level-semantics.html#the-code-element: title: 4.5.15 The code element - status: unknown + status: supported text-level-semantics.html#the-var-element: title: 4.5.16 The var element - status: unknown + status: supported text-level-semantics.html#the-samp-element: title: 4.5.17 The samp element - status: unknown + status: supported text-level-semantics.html#the-kbd-element: title: 4.5.18 The kbd element - status: unknown + status: supported text-level-semantics.html#the-sub-and-sup-elements: title: 4.5.19 The sub and sup elements - status: unknown + status: supported text-level-semantics.html#the-i-element: title: 4.5.20 The i element - status: unknown + status: supported text-level-semantics.html#the-b-element: title: 4.5.21 The b element - status: unknown + status: supported text-level-semantics.html#the-u-element: title: 4.5.22 The u element - status: unknown + status: supported text-level-semantics.html#the-mark-element: title: 4.5.23 The mark element - status: unknown + status: unsupported + gaps: + - Unknown element, rendered inline without its default style, see 15.3.4 text-level-semantics.html#the-bdi-element: title: 4.5.24 The bdi element - status: unknown + status: unsupported + gaps: + - Unknown element, its content isn't isolated from the surrounding text, see 15.3.5 text-level-semantics.html#the-bdo-element: title: 4.5.25 The bdo element - status: unknown + status: partial + gaps: + - dir=rtl only reverses the order of the words, the characters within a word keep their order, see 15.3.5 text-level-semantics.html#the-span-element: title: 4.5.26 The span element - status: unknown + status: supported text-level-semantics.html#the-br-element: title: 4.5.27 The br element - status: unknown + status: supported text-level-semantics.html#the-wbr-element: title: 4.5.28 The wbr element - status: unknown + status: supported text-level-semantics.html#usage-summary: title: 4.5.29 Usage summary - status: unknown + status: n/a links.html#links: title: 4.6 Links status: unknown From 69d505672457cc825d4588e404c7c64d36dad25c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:26:25 +0100 Subject: [PATCH 033/150] Verify and document HTML links compliance --- Source/HtmlRenderer/Core/Dom/CssBox.cs | 4 +- Source/HtmlRenderer/Core/HtmlContainerInt.cs | 3 +- Source/HtmlRenderer/Core/Parse/DomParser.cs | 2 +- .../Text/LinkPseudoClassIntegrationTests.cs | 28 +-- .../Test/HtmlRenderer.Test/Html/LinksTests.cs | 179 ++++++++++++++++++ .../HtmlRenderer.Test/Utils/DomUtilsTests.cs | 7 +- Spec/html.yml | 94 ++++----- 7 files changed, 238 insertions(+), 79 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/LinksTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index 149b3f8cd..39993df74 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -267,9 +267,7 @@ public bool IsOutOfFlow } /// - /// Is the css box clickable (by default only "a" elements that are actual hyperlinks - i.e. - /// have an "href" - are clickable; an "a" used only as a named anchor/target has no href and - /// is not clickable, matching the same "href" gate CSS uses for the :link pseudo-class). + /// Whether the box is a hyperlink, HTML §4.6.2. Also what :link matches. /// public virtual bool IsClickable { diff --git a/Source/HtmlRenderer/Core/HtmlContainerInt.cs b/Source/HtmlRenderer/Core/HtmlContainerInt.cs index a833c5c22..e59399426 100644 --- a/Source/HtmlRenderer/Core/HtmlContainerInt.cs +++ b/Source/HtmlRenderer/Core/HtmlContainerInt.cs @@ -1135,7 +1135,8 @@ internal void ReportError(HtmlRenderErrorType type, string message, Exception ex } /// - /// Handle link clicked going over event and using if not canceled. + /// Follows a hyperlink, HTML §4.6.5: raises and, unless that handled it, scrolls + /// to a "#id" fragment or opens the href with . /// /// the control hosting the html to invalidate /// the location of the mouse diff --git a/Source/HtmlRenderer/Core/Parse/DomParser.cs b/Source/HtmlRenderer/Core/Parse/DomParser.cs index d1c662572..b9d2b3c12 100644 --- a/Source/HtmlRenderer/Core/Parse/DomParser.cs +++ b/Source/HtmlRenderer/Core/Parse/DomParser.cs @@ -116,7 +116,7 @@ public DomParser(CssParser cssParser) { if (box.HtmlTag != null) { - // Check for the tag + // , HTML §4.6.8.23 if (box.HtmlTag.Name.Equals("link", StringComparison.CurrentCultureIgnoreCase) && box.GetAttribute("rel", string.Empty).Equals("stylesheet", StringComparison.CurrentCultureIgnoreCase)) { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs index b2f74ba8d..7bf00779e 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs @@ -4,37 +4,14 @@ namespace HtmlRenderer.IntegrationTest.Text; /// -/// Verifies :link/:hover pseudo-class handling, and locks in the resulting gap that -/// :visited/:active never match. +/// :link matches the hyperlinks, :visited and :active never match. /// -/// -/// -/// The CSS engine port replaced selector matching with a real implementation: -/// 's private DoesSelectorMatch(PseudoClassSelector, CssBox) -/// only special-cases three pseudo-classes - :hover (matched structurally, always true; diverted to -/// HtmlContainerInt.AddHoverBox instead of applied directly, so live mouse state stays out of the -/// cascade), :root (matches the document's <html> element), and :link (matches -/// box.IsClickable) - everything else, including :visited/:active/:focus/ -/// :nth-child, falls through to return false, so a rule using them never matches anything. -/// Unlike the old parser (which dropped the ENTIRE containing css block when it hit an unrecognized -/// pseudo-class), the real engine parses the rule normally and simply never matches it - same observable -/// outcome for :visited/:active here, via a completely different, real mechanism. -/// -/// -/// :link specifically resolves through , -/// which is real, spec-correct, and deliberate (not coincidental): only an <a> element carrying an -/// href attribute is clickable, matching CSS Selectors' own href-gated definition of :link -/// directly - an <a> used only as a named anchor/target (no href) is correctly excluded. -/// -/// [DoNotParallelize] [TestClass] public sealed class LinkPseudoClassIntegrationTests { - // Anchors used to verify :link matching deliberately have no id/name attribute, matching PeachPDF's own - // setup, so FindByTag (not FindById) locates the anchor in these tests. - [TestMethod] + [Spec("html", "links.html#links-created-by-a-and-area-elements")] public void Link_MatchesAnchorWithHref() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -45,6 +22,7 @@ public void Link_MatchesAnchorWithHref() } [TestMethod] + [Spec("html", "links.html#links-created-by-a-and-area-elements")] public void Link_DoesNotMatchAnchorWithoutHref() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs b/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs new file mode 100644 index 000000000..5961a4fe1 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs @@ -0,0 +1,179 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class LinksTests +{ + [TestMethod] + [Spec("html", "links.html#links-created-by-a-and-area-elements")] + public void AWithHref_CreatesAHyperlink() + { + var (_, container) = LayoutHarness.Layout(LayoutHarness.Wrap("a b")); + + CollectionAssert.AreEqual(new[] { "page.html" }, container.GetLinks().Select(l => l.Href).ToArray()); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "links.html#links-created-by-a-and-area-elements")] + public void AreaWithHref_CreatesAHyperlink() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "")); + + Assert.IsTrue(LayoutHarness.FindById(root, "x")!.IsClickable); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "links.html#links-created-by-a-and-area-elements")] + public void IsmapImage_AppendsTheClickCoordinates() + { + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( + "")); + + Assert.AreEqual("map.cgi?3,4", Click(container, LayoutHarness.FindById(root, "x")!, new RPoint(3, 4))); + } + + [TestMethod] + [Spec("html", "links.html#following-hyperlinks")] + public void Click_RaisesLinkClickedWithTheHref() + { + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap("a")); + + Assert.AreEqual("https://example.com/", Click(container, LayoutHarness.FindById(root, "x")!)); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "links.html#following-hyperlinks")] + public void Click_ResolvesTheHrefAgainstTheBaseUrl() + { + var (root, container) = LayoutHarness.Layout( + "a"); + + Assert.AreEqual("https://example.com/dir/page.html", Click(container, LayoutHarness.FindById(root, "x")!)); + } + + [TestMethod] + [Spec("html", "links.html#following-hyperlinks")] + public void FragmentLink_ScrollsToTheElement() + { + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( + "a

          b

          ")); + + Assert.AreEqual(LayoutHarness.FindById(root, "target")!.Location.Y, ScrollTarget(container, LayoutHarness.FindById(root, "x")!)); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "links.html#following-hyperlinks")] + public void FragmentLink_MatchesTheIdCaseSensitively() + { + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( + "a

          b

          c

          ")); + + Assert.AreEqual(LayoutHarness.FindById(root, "Target")!.Location.Y, ScrollTarget(container, LayoutHarness.FindById(root, "x")!)); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "links.html#following-hyperlinks")] + public void FragmentLink_FallsBackToANamedAnchor() + { + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( + "a

          b

          ")); + + Assert.AreEqual(LayoutHarness.FindById(root, "p")!.Location.Y, ScrollTarget(container, LayoutHarness.FindById(root, "x")!)); + } + + [TestMethod] + [Spec("html", "links.html#link-type-stylesheet")] + [DataRow("stylesheet")] + [DataRow("StyleSheet")] + public void Stylesheet_IsApplied(string rel) + { + Assert.AreEqual("rgb(255, 0, 0)", ColorWithStylesheet($"")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "links.html#link-type-stylesheet")] + public void Stylesheet_AmongOtherLinkTypes_IsApplied() + { + Assert.AreEqual("rgb(255, 0, 0)", ColorWithStylesheet("")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "links.html#link-type-stylesheet")] + public void DisabledStylesheet_IsNotApplied() + { + Assert.AreEqual("black", ColorWithStylesheet("")); + } + + [TestMethod] + [Spec("html", "links.html#rel-alternate")] + public void AlternateStylesheet_IsNotApplied() + { + Assert.AreEqual("black", ColorWithStylesheet("")); + } + + /// The color of a paragraph when every linked stylesheet makes it red. + private static string ColorWithStylesheet(string link) + { + var container = new HtmlContainerInt(new MockAdapter()) { MaxSize = new RSize(1000, 1000) }; + container.StylesheetLoad += (_, e) => e.SetStyleSheet = "p { color: red }"; + container.SetHtml($"{link}

          a

          "); + using var graphics = new RecordingGraphics(); + container.PerformLayout(graphics); + + return LayoutHarness.FindById(container.Root!, "x")!.Color; + } + + /// Clicks the link and returns the link the LinkClicked event reports, handling it so nothing gets opened. + private static string? Click(HtmlContainerInt container, CssBox link, RPoint location = default) + { + string? clicked = null; + container.LinkClicked += (_, e) => + { + clicked = e.Link; + e.Handled = true; + }; + container.HandleLinkClicked(new MockControl(container.Adapter), location, link); + return clicked; + } + + /// Clicks the fragment link and returns where the container asks the host to scroll to. + private static double? ScrollTarget(HtmlContainerInt container, CssBox link) + { + double? y = null; + container.ScrollChange += (_, e) => y = e.Y; + container.HandleLinkClicked(new MockControl(container.Adapter), RPoint.Empty, link); + return y; + } + + private sealed class MockControl(RAdapter adapter) : RControl(adapter) + { + public override bool LeftMouseButton => false; + public override bool RightMouseButton => false; + public override RPoint MouseLocation => RPoint.Empty; + public override void SetCursorDefault() { } + public override void SetCursorHand() { } + public override void SetCursorIBeam() { } + public override void DoDragDropCopy(object dragDropData) { } + + public override void MeasureString(string str, RFont font, double maxWidth, out int charFit, out double charFitWidth) + { + charFit = str.Length; + charFitWidth = 0; + } + + public override void Invalidate() { } + } +} diff --git a/Source/Test/HtmlRenderer.Test/Utils/DomUtilsTests.cs b/Source/Test/HtmlRenderer.Test/Utils/DomUtilsTests.cs index 212233183..5fbdfe218 100644 --- a/Source/Test/HtmlRenderer.Test/Utils/DomUtilsTests.cs +++ b/Source/Test/HtmlRenderer.Test/Utils/DomUtilsTests.cs @@ -54,9 +54,7 @@ public void FindParent_ReturnsParentOfAncestorMatchingTagName() [TestMethod] public void FindParent_NullBox_ReturnsRoot() { - // Unlike PeachPDF's FindParent (which returns null when the walk-up never finds a matching - // ancestor, so a stray closing tag can be treated as a no-op), HTML-Renderer's FindParent - // short-circuits a null `box` straight to `root` -- there's no distinct "not found" signal. + // A null box goes straight to root, there's no separate "not found" result. var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
          ")); var parent = DomUtils.FindParent(root, "div", null); @@ -117,8 +115,6 @@ public void ContainsInlinesOnly_HasBlockChild_ReturnsFalse() [TestMethod] public void GetAllLinkBoxes_CollectsClickableVisibleBoxes() { - // Note: HTML-Renderer's CssBox.IsClickable requires the "a" element to have no "id" attribute, - // so this markup deliberately leaves the anchor unidentified. var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
          LinkNot a link
          ")); var links = new System.Collections.Generic.List(); @@ -152,7 +148,6 @@ public void GetCssBox_InvisibleBox_ReturnsNull() [TestMethod] public void GetLinkBox_LocationOnClickableVisibleLink_ReturnsIt() { - // Note: no "id" on the anchor itself -- HTML-Renderer's IsClickable excludes an "a" that has one. var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("Click")); var link = LayoutHarness.Descendants(root).First(b => b.HtmlTag?.Name == "a"); var word = FindFirstWord(link)!; diff --git a/Spec/html.yml b/Spec/html.yml index 937f398c7..f344aaff9 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -462,121 +462,129 @@ sections: status: n/a links.html#links: title: 4.6 Links - status: unknown + status: partial links.html#introduction-2: title: 4.6.1 Introduction - status: unknown + status: n/a links.html#links-created-by-a-and-area-elements: title: 4.6.2 Links created by a and area elements - status: unknown + status: partial + gaps: + - area doesn't create a hyperlink + - Clicking an ismap image inside a link doesn't append the click coordinates to the URL links.html#api-for-hyperlink-elements: title: 4.6.3 API for hyperlink elements - status: unknown + status: n/a links.html#api-for-a-and-area-elements: title: 4.6.4 API for a and area elements - status: unknown + status: n/a links.html#following-hyperlinks: title: 4.6.5 Following hyperlinks - status: unknown + status: partial + gaps: + - The href isn't resolved against the document base URL, LinkClicked and Process.Start get it as written + - A fragment matches ids case-insensitively and doesn't fall back to the name of an a element links.html#downloading-resources: title: 4.6.6 Downloading resources - status: unknown + status: unsupported + gaps: + - The download attribute is ignored, the link is followed instead links.html#hyperlink-auditing: title: 4.6.7 Hyperlink auditing - status: unknown + status: unsupported + gaps: + - The ping attribute is ignored links.html#the-ping-headers: title: 4.6.7.1 The `Ping-From` and `Ping-To` headers - status: unknown + status: unsupported links.html#linkTypes: title: 4.6.8 Link types - status: unknown + status: partial links.html#rel-alternate: title: 4.6.8.1 Link type "alternate" - status: unknown + status: supported links.html#link-type-author: title: 4.6.8.2 Link type "author" - status: unknown + status: n/a links.html#link-type-bookmark: title: 4.6.8.3 Link type "bookmark" - status: unknown + status: n/a links.html#link-type-canonical: title: 4.6.8.4 Link type "canonical" - status: unknown + status: n/a links.html#link-type-dns-prefetch: title: 4.6.8.5 Link type "dns-prefetch" - status: unknown + status: n/a links.html#link-type-expect: title: 4.6.8.6 Link type "expect" - status: unknown + status: n/a links.html#link-type-external: title: 4.6.8.7 Link type "external" - status: unknown + status: n/a links.html#link-type-help: title: 4.6.8.8 Link type "help" - status: unknown + status: n/a links.html#rel-icon: title: 4.6.8.9 Link type "icon" - status: unknown + status: n/a links.html#link-type-license: title: 4.6.8.10 Link type "license" - status: unknown + status: n/a links.html#link-type-manifest: title: 4.6.8.11 Link type "manifest" - status: unknown + status: n/a links.html#link-type-modulepreload: title: 4.6.8.12 Link type "modulepreload" - status: unknown + status: n/a links.html#link-type-nofollow: title: 4.6.8.13 Link type "nofollow" - status: unknown + status: n/a links.html#link-type-noopener: title: 4.6.8.14 Link type "noopener" - status: unknown + status: n/a links.html#link-type-noreferrer: title: 4.6.8.15 Link type "noreferrer" - status: unknown + status: n/a links.html#link-type-opener: title: 4.6.8.16 Link type "opener" - status: unknown + status: n/a links.html#link-type-pingback: title: 4.6.8.17 Link type "pingback" - status: unknown + status: n/a links.html#link-type-preconnect: title: 4.6.8.18 Link type "preconnect" - status: unknown + status: n/a links.html#link-type-prefetch: title: 4.6.8.19 Link type "prefetch" - status: unknown + status: n/a links.html#link-type-preload: title: 4.6.8.20 Link type "preload" - status: unknown + status: n/a links.html#link-type-privacy-policy: title: 4.6.8.21 Link type "privacy-policy" - status: unknown + status: n/a links.html#link-type-search: title: 4.6.8.22 Link type "search" - status: unknown + status: n/a links.html#link-type-stylesheet: title: 4.6.8.23 Link type "stylesheet" - status: unknown + status: partial + gaps: + - rel is compared as a whole, stylesheet among other link types isn't loaded + - The disabled attribute is ignored + - A style sheet whose Content-Type only contains text/css, e.g. text/cssx, is applied, see 2.5.2 links.html#link-type-tag: title: 4.6.8.24 Link type "tag" - status: unknown + status: n/a links.html#link-type-terms-of-service: title: 4.6.8.25 Link Type "terms-of-service" - status: unknown + status: n/a links.html#sequential-link-types: title: 4.6.8.26 Sequential link types - status: unknown - links.html#link-type-next: - title: 4.6.8.26.1 Link type "next" - status: unknown - links.html#link-type-prev: - title: 4.6.8.26.2 Link type "prev" - status: unknown + status: n/a links.html#other-link-types: title: 4.6.8.27 Other link types - status: unknown + status: n/a edits.html#edits: title: 4.7 Edits status: unknown From d5b6af08190425df69bae7b6d3cea5adc86b928e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:27:47 +0100 Subject: [PATCH 034/150] Verify and document HTML edits compliance --- .../Test/HtmlRenderer.Test/Html/EditsTests.cs | 32 +++++++++++++++++++ Spec/html.yml | 14 ++++---- 2 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/EditsTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Html/EditsTests.cs b/Source/Test/HtmlRenderer.Test/Html/EditsTests.cs new file mode 100644 index 000000000..d3ffb57cc --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/EditsTests.cs @@ -0,0 +1,32 @@ +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class EditsTests +{ + [TestMethod] + [Spec("html", "edits.html#the-ins-element")] + [Spec("html", "edits.html#the-del-element")] + [DataRow("ins", "underline")] + [DataRow("del", "line-through")] + public void Edit_IsDecorated(string tag, string decoration) + { + var box = Box($"<{tag} id=x>a"); + + Assert.AreEqual("inline", box.Display); + Assert.AreEqual(decoration, box.Boxes[0].TextDecoration); + } + + [TestMethod] + [Spec("html", "edits.html#the-ins-element")] + [Spec("html", "edits.html#the-del-element")] + [DataRow("ins", "underline")] + [DataRow("del", "line-through")] + public void Edit_AroundAParagraph_DecoratesItsText(string tag, string decoration) + { + var box = Box($"<{tag}>

          a

          "); + + Assert.AreEqual(decoration, box.Boxes[0].TextDecoration); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index f344aaff9..919e76fee 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -587,25 +587,25 @@ sections: status: n/a edits.html#edits: title: 4.7 Edits - status: unknown + status: supported edits.html#the-ins-element: title: 4.7.1 The ins element - status: unknown + status: supported edits.html#the-del-element: title: 4.7.2 The del element - status: unknown + status: supported edits.html#attributes-common-to-ins-and-del-elements: title: 4.7.3 Attributes common to ins and del elements - status: unknown + status: n/a edits.html#edits-and-paragraphs: title: 4.7.4 Edits and paragraphs - status: unknown + status: n/a edits.html#edits-and-lists: title: 4.7.5 Edits and lists - status: unknown + status: n/a edits.html#edits-and-tables: title: 4.7.6 Edits and tables - status: unknown + status: n/a embedded-content.html#embedded-content: title: 4.8 Embedded content status: unknown From 60139fdd0c58df5f2f99c38668df61e2e19e3e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:20:18 +0100 Subject: [PATCH 035/150] Verify and document HTML embedded content and img compliance --- Source/HtmlRenderer/Core/Dom/CssBoxImage.cs | 22 ++---- .../Core/Handlers/ImageLoadHandler.cs | 17 ++--- .../Paint/Content/ReplacedFragmentPainter.cs | 14 ++-- .../Html/EmbeddedContentTests.cs | 73 +++++++++++++++++++ .../Html/EmbeddedTestSupport.cs | 51 +++++++++++++ Spec/html.yml | 15 +++- 6 files changed, 154 insertions(+), 38 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/EmbeddedContentTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Html/EmbeddedTestSupport.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxImage.cs b/Source/HtmlRenderer/Core/Dom/CssBoxImage.cs index e849da63a..dcfa6169b 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxImage.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxImage.cs @@ -22,7 +22,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Dom { /// - /// CSS box for image element. + /// CSS box for the img element, HTML §4.8.3. /// internal sealed class CssBoxImage : CssBox { @@ -67,11 +67,10 @@ public RImage Image } /// - /// Starts loading the image if it hasn't started already. This is the primary load trigger for - /// the common async case (/ - /// both false) - - /// only starts loading when one of those flags is set, so paint is where loading normally begins. - /// Called by . + /// Starts loading the image unless layout already did. Layout only loads it when + /// or + /// is set, otherwise the image is obtained + /// on demand when it's first painted (HTML §4.8.4.3.1). /// internal void EnsureImageLoadStarted() { @@ -162,14 +161,9 @@ public override void Dispose() #region Private methods /// - /// Get the image source to load: a resolved content: url(...) value takes precedence - /// over the "src" attribute (CSS generated content replacing the element's own rendering, same - /// as this engine already gives generated content priority for pseudo-elements), falling back - /// to "src" when content is unset/none/normal or isn't a url().
          - /// holds the literal declared CSS text (e.g. url('data:...'), - /// quotes and all) rather than an unwrapped source string, so the url() token's data has to be - /// pulled out here - only recognizes a bare path or - /// "data:image..." URI, not CSS function syntax. + /// The image to load: a content: url() replaces the element's src (CSS Content 3 §1), else + /// the src attribute (HTML §4.8.4.3.5).
          + /// holds the declared CSS text, so the url() is unwrapped here. ///
          private string GetImageSource() { diff --git a/Source/HtmlRenderer/Core/Handlers/ImageLoadHandler.cs b/Source/HtmlRenderer/Core/Handlers/ImageLoadHandler.cs index 64d8b1e01..c51ab6b03 100644 --- a/Source/HtmlRenderer/Core/Handlers/ImageLoadHandler.cs +++ b/Source/HtmlRenderer/Core/Handlers/ImageLoadHandler.cs @@ -119,13 +119,10 @@ public RRect Rectangle /// Or from URI. /// /// - /// File path and URI image loading is resolved against the document base and fetched through - /// - uniformly for local files, data: URIs, and - /// remote HTTP(S) sources, matching how stylesheets and (eventually) @font-face fonts load. - /// When is set the fetch is awaited inline - /// (blocking this call) so the image is fully resolved before returns; - /// otherwise it runs fire-and-forget and requests a re-layout once - /// it finishes, matching this handler's pre-existing sync/async duality. + /// HTML §4.8.4.3.5. A path or URI is resolved against the document base URL and fetched through + /// . With + /// the fetch blocks until the image is loaded, otherwise it runs in the background and the + /// completion callback requests a re-layout. /// /// the source of the image to load /// the collection of attributes on the element to use in event @@ -208,10 +205,8 @@ private void OnHtmlImageLoadEventCallback(string path, object image, RRect image } /// - /// Resolve (a bare file path/URI, or a data:image... URI) against - /// the document base and fetch it through - the same - /// funnel used for stylesheets, so a data: source needs no separate decode path from a - /// remote or local one. + /// Resolve (a file path, URI or data: URI) against the document base + /// and fetch it through . /// private async Task LoadImageFromPathAsync(string path, bool async) { diff --git a/Source/HtmlRenderer/Core/Paint/Content/ReplacedFragmentPainter.cs b/Source/HtmlRenderer/Core/Paint/Content/ReplacedFragmentPainter.cs index 36e65527f..627584742 100644 --- a/Source/HtmlRenderer/Core/Paint/Content/ReplacedFragmentPainter.cs +++ b/Source/HtmlRenderer/Core/Paint/Content/ReplacedFragmentPainter.cs @@ -8,12 +8,9 @@ namespace TheArtOfDev.HtmlRenderer.Core.Paint.Content { /// - /// Shared clip/background/border sequence for replaced leaf elements (, - /// ) - both paint the same way (clip by overflow, then - /// , then ) before - /// their own type-specific content, matching PeachPDF's ReplacedFragmentPainter base. Uses - /// rather than CssBox.Rectangles directly since replaced - /// elements are monolithic (one fragment always covers the whole box, css-break-3 4.1). + /// Paints a replaced element (, ): clip by overflow, + /// background, border and outline, then the element's own content. Replaced elements are monolithic + /// (CSS Fragmentation 3 §4.1), so covers the whole box. /// internal abstract class ReplacedFragmentPainter : IFragmentContentPainter { @@ -21,9 +18,8 @@ public void Paint(FragmentPainter painter, RGraphics g, BoxFragment fragment) { var box = fragment.Box; - // fragment.PrimaryRect is fragment-local; the image/video word rect DrawContent's - // implementations read is off the live tree (still absolute document-Y) - each needs its own - // offset flavor, see FragmentPainter.FragmentLocalOffset/LiveTreeOffset's doc comments. + // PrimaryRect is fragment-local, the image word DrawContent reads is in document coordinates + // of the live tree, so each gets its own offset. var rect = fragment.PrimaryRect; rect.Offset(painter.FragmentLocalOffset(box.IsFixed)); diff --git a/Source/Test/HtmlRenderer.Test/Html/EmbeddedContentTests.cs b/Source/Test/HtmlRenderer.Test/Html/EmbeddedContentTests.cs new file mode 100644 index 000000000..2e2a3d85d --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/EmbeddedContentTests.cs @@ -0,0 +1,73 @@ +using static HtmlRenderer.Test.Html.EmbeddedTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class EmbeddedContentTests +{ + [TestMethod] + [Spec("html", "embedded-content.html#the-img-element")] + public void AvailableImage_IsRendered() + { + var img = Loaded($""); + + Assert.IsNotNull(ImageOf(img)); + Assert.AreEqual(40, img.Words[0].Width, 0.5); + Assert.AreEqual(30, img.Words[0].Height, 0.5); + } + + [TestMethod] + [Spec("html", "embedded-content.html#the-img-element")] + public void BrokenImage_WithoutAlt_ShowsAnIndicator() + { + var img = Loaded(""); + + Assert.IsNull(ImageOf(img)); + Assert.IsTrue(img.Words[0].Width > 0); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "embedded-content.html#the-img-element")] + [DataRow("Hello")] + [DataRow("Hello")] + public void ImageWithAlt_NotAvailable_RepresentsTheAltText(string body) + { + StringAssert.Contains(Text(Loaded(body)), "Hello"); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "embedded-content.html#the-img-element")] + [DataRow("")] + [DataRow("")] + [DataRow("")] + [DataRow("")] + public void ImageRepresentingNothing_TakesNoSpace(string img) + { + var next = Loaded($"

          {img}a

          "); + + Assert.AreEqual(0, next.Rectangles.Values.Single().X, 0.5); + } + + [TestMethod] + [Spec("html", "embedded-content.html#the-picture-element")] + public void Picture_RepresentsItsImg() + { + var img = Loaded($""); + + Assert.IsNotNull(ImageOf(img)); + Assert.AreEqual(40, img.Words[0].Width, 0.5); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "embedded-content.html#the-picture-element")] + [Spec("html", "embedded-content.html#the-source-element")] + public void Picture_SelectsTheSourceSrcset() + { + var img = Loaded($""); + + Assert.IsNotNull(ImageOf(img)); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Html/EmbeddedTestSupport.cs b/Source/Test/HtmlRenderer.Test/Html/EmbeddedTestSupport.cs new file mode 100644 index 000000000..974d2aeb5 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/EmbeddedTestSupport.cs @@ -0,0 +1,51 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Network; + +namespace HtmlRenderer.Test.Html; + +internal static class EmbeddedTestSupport +{ + /// An image the decodes as 40x30, whatever the bytes. + public const string Png = "data:image/png;base64,iVBORw0KGgo="; + + /// Lays out with images loaded during layout and returns the box with the given id. + public static CssBox Loaded(string body, string id = "x", MockAdapter? adapter = null) + { + var (root, _) = LayoutWithImages(body, adapter); + var box = LayoutHarness.FindById(root, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box; + } + + /// Lays out with images loaded during layout. + public static (CssBox Root, TheArtOfDev.HtmlRenderer.Core.HtmlContainerInt Container) LayoutWithImages(string body, MockAdapter? adapter = null) => + LayoutHarness.Layout(LayoutHarness.Wrap(body), adapter: adapter, prepare: root => + { + root.HtmlContainer!.AvoidAsyncImagesLoading = true; + root.HtmlContainer.AvoidImagesLateLoading = true; + }); + + /// The image of the img box with the given id, null if none was loaded. + public static object? ImageOf(CssBox box) => ((CssBoxImage)box).Image; + + /// The text of every word in the box and its descendants, space separated. + public static string Text(CssBox box) => + string.Join(" ", LayoutHarness.Descendants(box).SelectMany(b => b.Words).Select(w => w.Text).Where(t => !string.IsNullOrEmpty(t))); + + /// Records the URLs the document fetches, and fetches nothing. + public sealed class RecordingLoader(string baseUri) : RNetworkLoader + { + public List Requests { get; } = []; + + public override RUri? BaseUri { get; } = new RUri(baseUri); + + public override Task GetPrimaryContents() => Task.FromResult(""); + + public override Task GetResourceStream(RUri uri) + { + Requests.Add(uri.AbsoluteUri); + return Task.FromResult(null); + } + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 919e76fee..cc9489524 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -608,16 +608,23 @@ sections: status: n/a embedded-content.html#embedded-content: title: 4.8 Embedded content - status: unknown + status: partial embedded-content.html#the-picture-element: title: 4.8.1 The picture element - status: unknown + status: partial + gaps: + - Its source elements are ignored, the img always loads its own src embedded-content.html#the-source-element: title: 4.8.2 The source element - status: unknown + status: unsupported + gaps: + - Ignored, srcset, sizes, media and type of a source in a picture don't select an image embedded-content.html#the-img-element: title: 4.8.3 The img element - status: unknown + status: partial + gaps: + - An image that isn't available is drawn as a broken image icon instead of its alt text + - An img without src, with an empty src, or with alt="" and an unavailable image still takes up space instead of representing nothing images.html#images: title: 4.8.4 Images status: unknown From 54fd3c00c2a7c7997483023395c62d571fbb3c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:21:34 +0100 Subject: [PATCH 036/150] Verify and document HTML images compliance --- .../HtmlRenderer.Test/Html/ImagesTests.cs | 65 +++++++++++ Spec/html.yml | 106 ++++++------------ 2 files changed, 99 insertions(+), 72 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/ImagesTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Html/ImagesTests.cs b/Source/Test/HtmlRenderer.Test/Html/ImagesTests.cs new file mode 100644 index 000000000..64542270b --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/ImagesTests.cs @@ -0,0 +1,65 @@ +using HtmlRenderer.Test.TestSupport; +using static HtmlRenderer.Test.Html.EmbeddedTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class ImagesTests +{ + [TestMethod] + [Spec("html", "images.html#images-processing-model")] + [Spec("html", "images.html#when-to-obtain-images")] + public void Image_IsSizedByItsNaturalDimensions() + { + var img = Loaded($""); + + Assert.AreEqual(40, img.Words[0].Width, 0.5); + Assert.AreEqual(30, img.Words[0].Height, 0.5); + } + + [TestMethod] + [Spec("html", "images.html#updating-the-image-data")] + public void Src_IsResolvedAgainstTheDocumentBaseUrl() + { + var loader = new RecordingLoader("http://example.com/dir/page.html"); + + Loaded("", adapter: new MockAdapter { NetworkLoader = loader }); + LayoutHarness.Layout( + "", + adapter: new MockAdapter { NetworkLoader = loader }, + prepare: root => root.HtmlContainer!.AvoidAsyncImagesLoading = true); + + CollectionAssert.AreEqual(new[] { "http://example.com/dir/a.png", "http://example.org/base/b.png" }, loader.Requests); + } + + [TestMethod] + [Spec("html", "images.html#updating-the-image-data")] + public void EmptySrc_IsNotFetched() + { + var loader = new RecordingLoader("http://example.com/"); + + var img = Loaded("", adapter: new MockAdapter { NetworkLoader = loader }); + + Assert.AreEqual(0, loader.Requests.Count); + Assert.IsNull(ImageOf(img)); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "images.html#selecting-an-image-source")] + public void Srcset_WithoutSrc_IsLoaded() + { + Assert.IsNotNull(ImageOf(Loaded($""))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "images.html#normalizing-the-source-densities")] + public void DensityDescriptor_DividesTheNaturalSize() + { + var img = Loaded($""); + + Assert.AreEqual(20, img.Words[0].Width, 0.5); + Assert.AreEqual(15, img.Words[0].Height, 0.5); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index cc9489524..c8c0231ab 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -627,112 +627,74 @@ sections: - An img without src, with an empty src, or with alt="" and an unavailable image still takes up space instead of representing nothing images.html#images: title: 4.8.4 Images - status: unknown + status: partial images.html#introduction-3: title: 4.8.4.1 Introduction - status: unknown - images.html#adaptive-images: - title: 4.8.4.1.1 Adaptive images - status: unknown + status: n/a images.html#attributes-common-to-source-and-img-elements: title: 4.8.4.2 Attributes common to source, img, and link elements - status: unknown - images.html#srcset-attributes: - title: 4.8.4.2.1 Srcset attributes - status: unknown - images.html#sizes-attributes: - title: 4.8.4.2.2 Sizes attributes - status: unknown + status: n/a images.html#images-processing-model: title: 4.8.4.3 Processing model - status: unknown + status: partial images.html#when-to-obtain-images: title: 4.8.4.3.1 When to obtain images - status: unknown + status: supported images.html#reacting-to-dom-mutations: title: 4.8.4.3.2 Reacting to DOM mutations - status: unknown + status: n/a images.html#the-list-of-available-images: title: 4.8.4.3.3 The list of available images - status: unknown + status: n/a images.html#decoding-images: title: 4.8.4.3.4 Decoding images - status: unknown + status: n/a images.html#updating-the-image-data: title: 4.8.4.3.5 Updating the image data - status: unknown + status: supported images.html#preparing-an-image-for-presentation: title: 4.8.4.3.6 Preparing an image for presentation - status: unknown + status: unsupported + gaps: + - EXIF resolution metadata doesn't set the density-corrected natural size images.html#selecting-an-image-source: title: 4.8.4.3.7 Selecting an image source - status: unknown + status: unsupported + gaps: + - srcset is ignored, only src is loaded images.html#creating-a-source-set-from-attributes: title: 4.8.4.3.8 Creating a source set from attributes - status: unknown + status: unsupported + gaps: + - No source set is created, see 4.8.4.3.7 images.html#updating-the-source-set: title: 4.8.4.3.9 Updating the source set - status: unknown + status: unsupported + gaps: + - The source elements of a picture are ignored images.html#parsing-a-srcset-attribute: title: 4.8.4.3.10 Parsing a srcset attribute - status: unknown + status: unsupported + gaps: + - srcset isn't parsed images.html#parsing-a-sizes-attribute: title: 4.8.4.3.11 Parsing a sizes attribute - status: unknown + status: unsupported + gaps: + - sizes isn't parsed images.html#normalizing-the-source-densities: title: 4.8.4.3.12 Normalizing the source densities - status: unknown + status: unsupported + gaps: + - Density and width descriptors don't scale the natural size images.html#reacting-to-environment-changes: title: 4.8.4.3.13 Reacting to environment changes - status: unknown + status: unsupported + gaps: + - No source selection to redo when the viewport changes, see 4.8.4.3.7 images.html#alt: title: 4.8.4.4 Requirements for providing text to act as an alternative for images - status: unknown - images.html#general-guidelines: - title: 4.8.4.4.1 General guidelines - status: unknown - images.html#a-link-or-button-containing-nothing-but-the-image: - title: 4.8.4.4.2 A link or button containing nothing but the image - status: unknown - images.html#a-phrase-or-paragraph-with-an-alternative-graphical-representation:-charts,-diagrams,-graphs,-maps,-illustrations: - title: '4.8.4.4.3 A phrase or paragraph with an alternative graphical representation: charts, diagrams, graphs, maps, illustrations' - status: unknown - images.html#a-short-phrase-or-label-with-an-alternative-graphical-representation:-icons,-logos: - title: '4.8.4.4.4 A short phrase or label with an alternative graphical representation: icons, logos' - status: unknown - images.html#text-that-has-been-rendered-to-a-graphic-for-typographical-effect: - title: 4.8.4.4.5 Text that has been rendered to a graphic for typographical effect - status: unknown - images.html#a-graphical-representation-of-some-of-the-surrounding-text: - title: 4.8.4.4.6 A graphical representation of some of the surrounding text - status: unknown - images.html#ancillary-images: - title: 4.8.4.4.7 Ancillary images - status: unknown - images.html#a-purely-decorative-image-that-doesn't-add-any-information: - title: 4.8.4.4.8 A purely decorative image that doesn't add any information - status: unknown - images.html#a-group-of-images-that-form-a-single-larger-picture-with-no-links: - title: 4.8.4.4.9 A group of images that form a single larger picture with no links - status: unknown - images.html#a-group-of-images-that-form-a-single-larger-picture-with-links: - title: 4.8.4.4.10 A group of images that form a single larger picture with links - status: unknown - images.html#a-key-part-of-the-content: - title: 4.8.4.4.11 A key part of the content - status: unknown - images.html#an-image-not-intended-for-the-user: - title: 4.8.4.4.12 An image not intended for the user - status: unknown - images.html#an-image-in-an-e-mail-or-private-document-intended-for-a-specific-person-who-is-known-to-be-able-to-view-images: - title: 4.8.4.4.13 An image in an email or private document intended for a specific person who is known to be able to view images - status: unknown - images.html#guidance-for-markup-generators: - title: 4.8.4.4.14 Guidance for markup generators - status: unknown - images.html#guidance-for-conformance-checkers: - title: 4.8.4.4.15 Guidance for conformance checkers - status: unknown + status: n/a iframe-embed-object.html#the-iframe-element: title: 4.8.5 The iframe element status: unknown From 3fa895cffc8bb24cea51a601295a6b6570245fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:22:43 +0100 Subject: [PATCH 037/150] Verify and document HTML iframe, embed and object compliance --- Source/HtmlRenderer/Core/Dom/CssBoxFrame.cs | 33 +++++-------------- .../Html/IframeEmbedObjectTests.cs | 27 +++++++++++++++ Spec/html.yml | 12 +++++-- 3 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/IframeEmbedObjectTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxFrame.cs b/Source/HtmlRenderer/Core/Dom/CssBoxFrame.cs index 05fc4a437..e45eb2f8a 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxFrame.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxFrame.cs @@ -23,8 +23,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.Dom { /// - /// CSS box for iframe element.
          - /// If the iframe is of embedded YouTube or Vimeo video it will show image with play. + /// CSS box for the iframe element, HTML §4.8.5. The nested document isn't rendered, an iframe of a + /// YouTube or Vimeo video shows the video's thumbnail with a play button instead. ///
          internal sealed class CssBoxFrame : CssBox { @@ -66,9 +66,7 @@ internal sealed class CssBoxFrame : CssBox private bool _imageLoadingComplete; /// - /// the resolved YouTube/Vimeo oEmbed API URI to fetch, set in the constructor but not fetched - /// until - see its own doc comment for why the fetch can't start - /// in the constructor. + /// The YouTube/Vimeo API URI to fetch the video data from, fetched in . /// private RUri _videoApiUri; @@ -158,12 +156,9 @@ public override void Dispose() #region Private methods /// - /// Fetch and read as text, fire-and-forget (matching - /// 's pattern - nothing here blocks layout/paint), and hand the - /// result (or null on failure) to . A null/unresolvable response is - /// reported the same way as any other "video not found" outcome - - /// has no HTTP status code to distinguish a 404 from another failure, matching every other - /// resource kind's fail-soft contract through this funnel. + /// Fetches in the background and hands the text to + /// . has no status code, so any + /// missing response is reported as a video that wasn't found. /// private async Task FetchVideoApiDataAsync(RUri apiUri, string source, Action parseResult) { @@ -407,10 +402,8 @@ private void HandlePostApiCall() } /// - /// Starts loading the video thumbnail if the video API call resolved a thumbnail URL and loading - /// hasn't started already - the same paint-time trigger pattern as , see - /// its for why this can't move to measure time. - /// Called by . + /// Starts loading the video thumbnail once the video data has a thumbnail URL, on first paint like + /// . /// internal void EnsureVideoImageLoadStarted() { @@ -520,15 +513,7 @@ private void DrawPlay(RGraphics g, RRect rect) /// the device to use internal override void MeasureWordsSize(RGraphics g) { - // Kicks off the YouTube/Vimeo oEmbed API fetch (through the configured - // RAdapter.NetworkLoader - the same funnel used for images/stylesheets/fonts, so a consumer's - // custom loader applies here too) here rather than from the constructor: HtmlContainer walks - // up to the root box's own HtmlContainer, which DomParser.GenerateCssTree only assigns AFTER - // the whole box tree (including this one) has already been constructed - so at construction - // time it's always null. FetchVideoApiDataAsync dereferences it immediately (before its first - // genuine await), so starting the fetch from the constructor throws a NullReferenceException - // synchronously out of box-tree construction itself. MeasureWordsSize runs during layout, well - // after HtmlContainer is assigned, so it's a safe place to start this instead. + // Not started in the constructor, HtmlContainer is only assigned once the whole box tree is built. if (_isVideo && !_videoDataRequested) { _videoDataRequested = true; diff --git a/Source/Test/HtmlRenderer.Test/Html/IframeEmbedObjectTests.cs b/Source/Test/HtmlRenderer.Test/Html/IframeEmbedObjectTests.cs new file mode 100644 index 000000000..8e3e0a624 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/IframeEmbedObjectTests.cs @@ -0,0 +1,27 @@ +using static HtmlRenderer.Test.Html.EmbeddedTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class IframeEmbedObjectTests +{ + [TestMethod] + [Spec("html", "iframe-embed-object.html#the-object-element")] + [DataRow("fallback")] + [DataRow("fallback")] + public void ObjectWithoutDisplayableData_RepresentsItsFallbackContent(string body) + { + StringAssert.Contains(Text(Loaded(body)), "fallback"); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "iframe-embed-object.html#the-object-element")] + public void ObjectWithImageData_DisplaysTheImage() + { + var obj = Loaded($"fallback"); + + Assert.IsFalse(Text(obj).Contains("fallback")); + Assert.AreEqual(40, obj.Rectangles.Values.Single().Width, 0.5); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index c8c0231ab..bcaa1649f 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -697,13 +697,19 @@ sections: status: n/a iframe-embed-object.html#the-iframe-element: title: 4.8.5 The iframe element - status: unknown + status: unsupported + gaps: + - The nested document isn't rendered, an empty box with a gray border is drawn instead, or a thumbnail for a YouTube or Vimeo src iframe-embed-object.html#the-embed-element: title: 4.8.6 The embed element - status: unknown + status: unsupported + gaps: + - Not rendered, unknown inline element iframe-embed-object.html#the-object-element: title: 4.8.7 The object element - status: unknown + status: partial + gaps: + - The data resource isn't loaded, an image is shown as the fallback content instead media.html#the-video-element: title: 4.8.8 The video element status: unknown From a7db90b21e317ea06f767288e0b2580dafb15f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:23:23 +0100 Subject: [PATCH 038/150] Verify and document HTML media elements compliance --- .../Test/HtmlRenderer.Test/Html/MediaTests.cs | 28 ++++++ Spec/html.yml | 97 ++----------------- 2 files changed, 37 insertions(+), 88 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/MediaTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Html/MediaTests.cs b/Source/Test/HtmlRenderer.Test/Html/MediaTests.cs new file mode 100644 index 000000000..baa7157ae --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/MediaTests.cs @@ -0,0 +1,28 @@ +using static HtmlRenderer.Test.Html.EmbeddedTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class MediaTests +{ + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "media.html#the-video-element")] + [Spec("html", "media.html#the-audio-element")] + [DataRow("video")] + [DataRow("audio")] + public void FallbackContent_IsNotShown(string tag) + { + Assert.AreEqual("", Text(Loaded($"<{tag} id=x controls>fallback"))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "media.html#the-video-element")] + public void Video_RepresentsItsPosterFrame() + { + var video = Loaded($""); + + Assert.AreEqual(40, video.Rectangles.Values.Single().Width, 0.5); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index bcaa1649f..987301381 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -712,100 +712,21 @@ sections: - The data resource isn't loaded, an image is shown as the fallback content instead media.html#the-video-element: title: 4.8.8 The video element - status: unknown + status: unsupported + gaps: + - Not rendered, its fallback content is shown instead + - The poster image isn't shown media.html#the-audio-element: title: 4.8.9 The audio element - status: unknown + status: unsupported + gaps: + - Not rendered, its fallback content is shown instead media.html#the-track-element: title: 4.8.10 The track element - status: unknown + status: n/a media.html#media-elements: title: 4.8.11 Media elements - status: unknown - media.html#error-codes: - title: 4.8.11.1 Error codes - status: unknown - media.html#location-of-the-media-resource: - title: 4.8.11.2 Location of the media resource - status: unknown - media.html#mime-types: - title: 4.8.11.3 MIME types - status: unknown - media.html#network-states: - title: 4.8.11.4 Network states - status: unknown - media.html#loading-the-media-resource: - title: 4.8.11.5 Loading the media resource - status: unknown - media.html#offsets-into-the-media-resource: - title: 4.8.11.6 Offsets into the media resource - status: unknown - media.html#ready-states: - title: 4.8.11.7 Ready states - status: unknown - media.html#playing-the-media-resource: - title: 4.8.11.8 Playing the media resource - status: unknown - media.html#seeking: - title: 4.8.11.9 Seeking - status: unknown - media.html#media-resources-with-multiple-media-tracks: - title: 4.8.11.10 Media resources with multiple media tracks - status: unknown - media.html#audiotracklist-and-videotracklist-objects: - title: 4.8.11.10.1 AudioTrackList and VideoTrackList objects - status: unknown - media.html#selecting-specific-audio-and-video-tracks-declaratively: - title: 4.8.11.10.2 Selecting specific audio and video tracks declaratively - status: unknown - media.html#timed-text-tracks: - title: 4.8.11.11 Timed text tracks - status: unknown - media.html#text-track-model: - title: 4.8.11.11.1 Text track model - status: unknown - media.html#sourcing-in-band-text-tracks: - title: 4.8.11.11.2 Sourcing in-band text tracks - status: unknown - media.html#sourcing-out-of-band-text-tracks: - title: 4.8.11.11.3 Sourcing out-of-band text tracks - status: unknown - media.html#guidelines-for-exposing-cues-in-various-formats-as-text-track-cues: - title: 4.8.11.11.4 Guidelines for exposing cues in various formats as text track cues - status: unknown - media.html#text-track-api: - title: 4.8.11.11.5 Text track API - status: unknown - media.html#cue-events: - title: 4.8.11.11.6 Event handlers for objects of the text track APIs - status: unknown - media.html#best-practices-for-metadata-text-tracks: - title: 4.8.11.11.7 Best practices for metadata text tracks - status: unknown - media.html#identifying-a-track-kind-through-a-url: - title: 4.8.11.12 Identifying a track kind through a URL - status: unknown - media.html#user-interface: - title: 4.8.11.13 User interface - status: unknown - media.html#time-ranges: - title: 4.8.11.14 Time ranges - status: unknown - media.html#the-trackevent-interface: - title: 4.8.11.15 The TrackEvent interface - status: unknown - media.html#mediaevents: - title: 4.8.11.16 Events summary - status: unknown - media.html#security-and-privacy-considerations: - title: 4.8.11.17 Security and privacy considerations - status: unknown - media.html#best-practices-for-authors-using-media-elements: - title: 4.8.11.18 Best practices for authors using media elements - status: unknown - media.html#best-practices-for-implementers-of-media-elements: - title: 4.8.11.19 Best practices for implementers of media elements - status: unknown + status: n/a image-maps.html#the-map-element: title: 4.8.12 The map element status: unknown From 83fde9c988815e80bfe493b10feffaf1d25dd53f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:24:42 +0100 Subject: [PATCH 039/150] Verify and document HTML image maps compliance --- .../HtmlRenderer.Test/Html/ImageMapsTests.cs | 30 +++++++++++++++++++ Spec/html.yml | 14 +++++---- 2 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/ImageMapsTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Html/ImageMapsTests.cs b/Source/Test/HtmlRenderer.Test/Html/ImageMapsTests.cs new file mode 100644 index 000000000..037a62aa6 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/ImageMapsTests.cs @@ -0,0 +1,30 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using static HtmlRenderer.Test.Html.EmbeddedTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class ImageMapsTests +{ + [TestMethod] + [Spec("html", "image-maps.html#the-map-element")] + public void Map_RepresentsItsChildren() + { + StringAssert.Contains(Text(Loaded("

          a b c

          ")), "a b c"); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "image-maps.html#the-area-element")] + [Spec("html", "image-maps.html#image-map-processing-model")] + public void AreaOfAnImageMap_IsAHyperlinkOverTheImage() + { + var (root, container) = LayoutWithImages( + $""); + var image = LayoutHarness.FindById(root, "x")!.Rectangles.Values.Single(); + + Assert.AreEqual("a.html", container.GetLinkAt(new RPoint(image.X + 5, image.Y + 5))); + Assert.IsNull(container.GetLinkAt(new RPoint(image.X + 30, image.Y + 20))); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 987301381..afdcb2ebe 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -729,19 +729,23 @@ sections: status: n/a image-maps.html#the-map-element: title: 4.8.12 The map element - status: unknown + status: supported image-maps.html#the-area-element: title: 4.8.13 The area element - status: unknown + status: unsupported + gaps: + - Not a hyperlink, see 4.8.14.2 image-maps.html#image-maps: title: 4.8.14 Image maps - status: unknown + status: unsupported image-maps.html#authoring: title: 4.8.14.1 Authoring - status: unknown + status: n/a image-maps.html#image-map-processing-model: title: 4.8.14.2 Processing model - status: unknown + status: unsupported + gaps: + - usemap is ignored, the areas of the map aren't hit-tested over the image embedded-content-other.html#mathml: title: 4.8.15 MathML status: unknown From b225b7e76e0e15b722a7ee2de570736daf05a1ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:26:33 +0100 Subject: [PATCH 040/150] Verify and document HTML MathML, SVG and dimension attributes compliance --- .../HtmlRenderer/Core/Dom/CssLayoutEngine.cs | 5 +- .../Html/EmbeddedContentOtherTests.cs | 51 +++++++++++++++++++ Spec/html.yml | 10 ++-- 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/EmbeddedContentOtherTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs index fb5523c32..4b5b0b1cc 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs @@ -24,8 +24,9 @@ namespace TheArtOfDev.HtmlRenderer.Core.Dom internal static class CssLayoutEngine { /// - /// Measure image box size by the width\height set on the box and the actual rendered image size.
          - /// If no image exists for the box error icon will be set. + /// Sizes an image word from the box's width and height, which the width and height attributes map + /// to (HTML §4.8.17), and the image's natural size, keeping its aspect ratio when only one is given. + /// Without an image the word gets the size of the broken image icon. ///
          /// the image word to measure public static void MeasureImageSize(CssRectImage imageWord) diff --git a/Source/Test/HtmlRenderer.Test/Html/EmbeddedContentOtherTests.cs b/Source/Test/HtmlRenderer.Test/Html/EmbeddedContentOtherTests.cs new file mode 100644 index 000000000..29859ab52 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/EmbeddedContentOtherTests.cs @@ -0,0 +1,51 @@ +using static HtmlRenderer.Test.Html.EmbeddedTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class EmbeddedContentOtherTests +{ + [TestMethod] + [Spec("html", "embedded-content-other.html#dimension-attributes")] + public void WidthAndHeight_SetTheImageSize() + { + var img = Loaded($""); + + Assert.AreEqual(80, img.Words[0].Width, 0.5); + Assert.AreEqual(10, img.Words[0].Height, 0.5); + } + + [TestMethod] + [Spec("html", "embedded-content-other.html#dimension-attributes")] + [Spec("html", "rendering.html#attributes-for-embedded-content-and-images")] + [DataRow("80", 80, 60)] + [DataRow("50%", 100, 75)] + public void Width_ScalesTheHeightByTheNaturalAspectRatio(string width, double expectedWidth, double expectedHeight) + { + var img = Loaded($"
          "); + + Assert.AreEqual(expectedWidth, img.Words[0].Width, 0.5); + Assert.AreEqual(expectedHeight, img.Words[0].Height, 0.5); + } + + [TestMethod] + [Spec("html", "embedded-content-other.html#dimension-attributes")] + [Spec("html", "rendering.html#attributes-for-embedded-content-and-images")] + public void Height_ScalesTheWidthByTheNaturalAspectRatio() + { + var img = Loaded($""); + + Assert.AreEqual(80, img.Words[0].Width, 0.5); + Assert.AreEqual(60, img.Words[0].Height, 0.5); + } + + [TestMethod] + [Spec("html", "embedded-content-other.html#dimension-attributes")] + public void WidthAndHeight_SetTheIframeSize() + { + var iframe = Loaded(""); + + Assert.AreEqual(100, iframe.Words[0].Width, 0.5); + Assert.AreEqual(50, iframe.Words[0].Height - iframe.ActualBorderTopWidth - iframe.ActualBorderBottomWidth, 0.5); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index afdcb2ebe..4a083c479 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -748,13 +748,17 @@ sections: - usemap is ignored, the areas of the map aren't hit-tested over the image embedded-content-other.html#mathml: title: 4.8.15 MathML - status: unknown + status: unsupported + gaps: + - MathML isn't laid out, its elements are unknown inline elements and their text runs inline embedded-content-other.html#svg-0: title: 4.8.16 SVG - status: unknown + status: unsupported + gaps: + - Inline svg isn't rendered, its elements are unknown inline elements and their text runs inline embedded-content-other.html#dimension-attributes: title: 4.8.17 Dimension attributes - status: unknown + status: supported tables.html#tables: title: 4.9 Tabular data status: unknown From c8daf771c6524398616ecc9c6cf3b7599f5d1bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:20:21 +0100 Subject: [PATCH 041/150] Verify and document HTML tables compliance --- Source/HtmlRenderer/Core/CssData.cs | 5 +- .../Core/Dom/CssLayoutEngineTable.cs | 8 +- .../HtmlRenderer.Test/Html/TablesTests.cs | 262 ++++++++++++++++++ Spec/html.yml | 49 ++-- 4 files changed, 297 insertions(+), 27 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/TablesTests.cs diff --git a/Source/HtmlRenderer/Core/CssData.cs b/Source/HtmlRenderer/Core/CssData.cs index 2c9096b8d..12a345689 100644 --- a/Source/HtmlRenderer/Core/CssData.cs +++ b/Source/HtmlRenderer/Core/CssData.cs @@ -846,10 +846,7 @@ private static bool DoesColumnSelectorMatch(ChildSelector childSelector, CssBox } /// - /// Reads a box's "colspan" HTML attribute (default 1 if absent/invalid). Deliberately - /// duplicated from 's private equivalent rather than - /// shared - this runs at cascade time, before the layout-phase state that method's column - /// bookkeeping otherwise depends on exists. + /// Reads a cell's colspan, 1 when it's missing or invalid (HTML §4.9.12.1). /// private static int GetColSpan(CssBox box) { diff --git a/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs b/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs index 514a3d888..3648da4a3 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs @@ -238,7 +238,7 @@ private void AssignBoxKinds() } /// - /// Insert EmptyBoxes for vertical cell spanning. + /// Inserts a into each row a rowspan cell covers below its own (HTML §4.9.12.1). /// private void InsertEmptyBoxes() { @@ -845,9 +845,8 @@ private static int GetColSpan(CssBox b) } /// - /// Gets the rowspan of the specified box + /// Gets the rowspan of the specified box (HTML §4.9.12.1) /// - /// private static int GetRowSpan(CssBox b) { string att = b.GetAttribute("rowspan", "1"); @@ -1065,9 +1064,8 @@ private double GetWidthSum() } /// - /// Gets the span attribute of the tag of the specified box + /// Gets the span of a col or colgroup box (HTML §4.9.12.1) /// - /// private static int GetSpan(CssBox b) { double f = CssValueParser.ParseNumber(b.GetAttribute("span"), 1); diff --git a/Source/Test/HtmlRenderer.Test/Html/TablesTests.cs b/Source/Test/HtmlRenderer.Test/Html/TablesTests.cs new file mode 100644 index 000000000..ed1b37b55 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/TablesTests.cs @@ -0,0 +1,262 @@ +using System.Linq; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class TablesTests +{ + /// Lays out in a table without border spacing and returns the boxes with the given ids. + private static CssBox[] Table(string content, params string[] ids) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap($"{content}
          ")); + return ids.Select(id => LayoutHarness.FindById(root, id) ?? throw new AssertFailedException($"No box with id '{id}'")).ToArray(); + } + + [TestMethod] + [Spec("html", "tables.html#the-table-element")] + [Spec("html", "tables.html#the-colgroup-element")] + [Spec("html", "tables.html#the-col-element")] + [Spec("html", "tables.html#the-tbody-element")] + [Spec("html", "tables.html#the-thead-element")] + [Spec("html", "tables.html#the-tfoot-element")] + [Spec("html", "tables.html#the-tr-element")] + [Spec("html", "tables.html#the-td-element")] + [Spec("html", "tables.html#the-th-element")] + [DataRow("
          a
          ", "table")] + [DataRow("
          a
          ", "table-column-group")] + [DataRow("
          a
          ", "table-column")] + [DataRow("
          a
          ", "table-row-group")] + [DataRow("
          a
          ", "table-header-group")] + [DataRow("
          a
          ", "table-footer-group")] + [DataRow("
          a
          ", "table-row")] + [DataRow("
          a
          ", "table-cell")] + [DataRow("
          a
          ", "table-cell")] + public void Element_TakesPartInTheTableModel(string body, string display) + { + Assert.AreEqual(display, Box(body).Display); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "tables.html#the-caption-element")] + public void Caption_IsRenderedAboveTheRows() + { + var b = Table("Titlea", "c", "x"); + Assert.IsGreaterThan(0, b[0].Size.Height); + Assert.IsLessThanOrEqualTo(b[1].Location.Y, b[0].ActualBottom); + } + + [TestMethod] + [Spec("html", "tables.html#the-tfoot-element")] + [Spec("html", "tables.html#forming-a-table")] + public void Tfoot_IsPlacedAfterAllBodies() + { + var b = Table("fab", "f", "x", "y"); + Assert.IsLessThan(b[2].Location.Y, b[1].Location.Y); + Assert.IsLessThan(b[0].Location.Y, b[2].Location.Y); + } + + [TestMethod] + [Spec("html", "tables.html#attributes-common-to-td-and-th-elements")] + [Spec("html", "tables.html#forming-a-table")] + public void Colspan_CoversTheColumnsItSpans() + { + var b = Table("abcde", "a", "x", "y"); + Assert.AreEqual(b[2].Location.X, b[1].Location.X, 0.01); + Assert.AreEqual(b[2].Location.X, b[0].ActualRight, 0.01); + } + + [TestMethod] + [Spec("html", "tables.html#attributes-common-to-td-and-th-elements")] + [Spec("html", "tables.html#forming-a-table")] + public void Rowspan_CoversTheRowsItSpans() + { + var b = Table("abc", "a", "x", "y"); + Assert.AreEqual(b[2].Location.X, b[1].Location.X, 0.01); + Assert.AreEqual(b[1].ActualBottom, b[0].ActualBottom, 0.01); + } + + private static string ColspanTable(string value) => + $"abcde"; + + [TestMethod] + [Spec("html", "tables.html#forming-a-table")] + [DataRow("abc")] + [DataRow("")] + public void Colspan_Invalid_CountsAsOne(string value) + { + var b = Table(ColspanTable(value), "x", "y1"); + Assert.IsGreaterThan(b[0].Location.X, b[0].ActualRight); + Assert.AreEqual(b[1].Location.X, b[0].Location.X, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "tables.html#forming-a-table")] + [DataRow("0")] + [DataRow("-1")] + public void Colspan_ZeroOrNegative_CountsAsOne(string value) + { + var b = Table(ColspanTable(value), "x", "y1"); + Assert.IsGreaterThan(b[0].Location.X, b[0].ActualRight); + Assert.AreEqual(b[1].Location.X, b[0].Location.X, 0.01); + } + + [TestMethod] + [Spec("html", "tables.html#forming-a-table")] + public void Colspan_WithLeadingWhitespace_IsParsed() + { + var b = Table(ColspanTable(" 2"), "x", "y2"); + Assert.AreEqual(b[1].Location.X, b[0].Location.X, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "tables.html#forming-a-table")] + public void Colspan_WithTrailingCharacters_UsesTheLeadingDigits() + { + var b = Table(ColspanTable("2px"), "x", "y2"); + Assert.AreEqual(b[1].Location.X, b[0].Location.X, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "tables.html#forming-a-table")] + public void Colspan_AboveAThousand_IsClampedToAThousand() + { + var cells = string.Concat(Enumerable.Range(0, 1001).Select(i => $"")); + var b = Table($"ab{cells}", "x", "c1000"); + Assert.AreEqual(b[1].Location.X, b[0].Location.X, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "tables.html#forming-a-table")] + public void Colspan_AddsColumns() + { + var b = Table("abcd", "a", "x", "y"); + Assert.IsGreaterThan(b[1].Location.X, b[1].ActualRight); + Assert.IsGreaterThanOrEqualTo(b[2].ActualRight, b[1].Location.X); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "tables.html#forming-a-table")] + public void CellSpanningPastTheOtherRows_IsLaidOut() + { + var b = Table("abc", "a", "x", "y"); + Assert.IsGreaterThan(b[0].Location.X, b[0].ActualRight); + Assert.IsGreaterThanOrEqualTo(b[2].ActualRight, b[0].ActualRight); + } + + private static string RowspanTable(string value) => + $"abc"; + + [TestMethod] + [Spec("html", "tables.html#forming-a-table")] + [DataRow("abc")] + [DataRow("-1")] + public void Rowspan_Invalid_CountsAsOne(string value) + { + var b = Table(RowspanTable(value), "a", "x"); + Assert.AreEqual(b[0].Location.X, b[1].Location.X, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "tables.html#forming-a-table")] + public void Rowspan_WithTrailingCharacters_UsesTheLeadingDigits() + { + var b = Table(RowspanTable("2px"), "x", "y"); + Assert.AreEqual(b[1].Location.X, b[0].Location.X, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "tables.html#attributes-common-to-td-and-th-elements")] + public void RowspanZero_SpansTheRestOfTheRowGroup() + { + var b = Table(RowspanTable("0") + "d", "a", "x", "y", "z"); + Assert.AreEqual(b[2].Location.X, b[1].Location.X, 0.01); + Assert.AreEqual(b[1].ActualBottom, b[0].ActualBottom, 0.01); + Assert.AreEqual(b[0].Location.X, b[3].Location.X, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "tables.html#forming-a-table")] + public void Rowspan_EndsWithItsRowGroup() + { + var b = Table(RowspanTable("3") + "de", "a", "z"); + Assert.AreEqual(b[0].Location.X, b[1].Location.X, 0.01); + Assert.IsLessThanOrEqualTo(b[1].Location.Y, b[0].ActualBottom); + } + + private static CssBox[] ColumnTable(string columns) => + Table($"{columns}abc", "a", "x", "y"); + + [TestMethod] + [Spec("html", "tables.html#the-colgroup-element")] + [Spec("html", "tables.html#the-col-element")] + [Spec("html", "tables.html#forming-a-table")] + [DataRow("")] + [DataRow("")] + public void Span_CoversThatManyColumns(string columns) + { + var b = ColumnTable(columns); + Assert.AreEqual(50, b[0].ActualRight - b[0].Location.X, 0.01); + Assert.AreEqual(50, b[1].ActualRight - b[1].Location.X, 0.01); + Assert.AreEqual(80, b[2].ActualRight - b[2].Location.X, 0.01); + } + + [TestMethod] + [Spec("html", "tables.html#the-colgroup-element")] + [Spec("html", "tables.html#forming-a-table")] + public void ColgroupSpan_IsIgnoredWhenItHasCols() + { + var b = ColumnTable(""); + Assert.AreEqual(80, b[1].ActualRight - b[1].Location.X, 0.01); + } + + [TestMethod] + [Spec("html", "tables.html#forming-a-table")] + [DataRow("0")] + [DataRow("abc")] + public void Span_Invalid_CountsAsOne(string value) + { + var b = ColumnTable($""); + Assert.AreEqual(80, b[1].ActualRight - b[1].Location.X, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "tables.html#forming-a-table")] + public void Span_WithTrailingCharacters_UsesTheLeadingDigits() + { + var b = ColumnTable(""); + Assert.AreEqual(80, b[2].ActualRight - b[2].Location.X, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "tables.html#forming-a-table")] + public void Span_WithAFraction_IsCutOffAtTheDecimalPoint() + { + var b = ColumnTable(""); + Assert.AreEqual(80, b[1].ActualRight - b[1].Location.X, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "tables.html#forming-a-table")] + public void CellsPastTheDeclaredColumns_AreLaidOut() + { + var b = ColumnTable(""); + Assert.AreEqual(b[0].ActualRight, b[1].Location.X, 0.01); + Assert.IsGreaterThan(b[1].Location.X, b[1].ActualRight); + Assert.IsGreaterThan(b[2].Location.X, b[2].ActualRight); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 4a083c479..fecc4644e 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -761,58 +761,71 @@ sections: status: supported tables.html#tables: title: 4.9 Tabular data - status: unknown + status: partial tables.html#the-table-element: title: 4.9.1 The table element - status: unknown + status: supported tables.html#table-descriptions-techniques: title: 4.9.1.1 Techniques for describing tables - status: unknown + status: n/a tables.html#table-layout-techniques: title: 4.9.1.2 Techniques for table design - status: unknown + status: n/a tables.html#the-caption-element: title: 4.9.2 The caption element - status: unknown + status: unsupported + gaps: + - Not rendered, see CSS 2.1 17.4 tables.html#the-colgroup-element: title: 4.9.3 The colgroup element - status: unknown + status: supported tables.html#the-col-element: title: 4.9.4 The col element - status: unknown + status: supported tables.html#the-tbody-element: title: 4.9.5 The tbody element - status: unknown + status: supported tables.html#the-thead-element: title: 4.9.6 The thead element - status: unknown + status: supported tables.html#the-tfoot-element: title: 4.9.7 The tfoot element - status: unknown + status: supported tables.html#the-tr-element: title: 4.9.8 The tr element - status: unknown + status: supported tables.html#the-td-element: title: 4.9.9 The td element - status: unknown + status: supported tables.html#the-th-element: title: 4.9.10 The th element - status: unknown + status: supported tables.html#attributes-common-to-td-and-th-elements: title: 4.9.11 Attributes common to td and th elements - status: unknown + status: partial + gaps: + - rowspan=0 spans a single row instead of the rest of the row group tables.html#table-processing-model: title: 4.9.12 Processing model - status: unknown + status: partial tables.html#forming-a-table: title: 4.9.12.1 Forming a table - status: unknown + status: partial + gaps: + - The column count is the highest number of cells in a row, colspan doesn't add columns, so a cell past it gets no width + - A cell spanning past that column count breaks the layout of the whole table + - Cells past the columns declared by col and colgroup aren't laid out + - A colspan of 0 or below breaks the layout of the whole table instead of counting as 1 + - colspan isn't clamped to 1000 + - span, colspan and rowspan with trailing characters, e.g. 2px, count as 1 instead of using the leading digits + - A span with a fraction is rounded instead of cut off, e.g. 1.5 counts as 2 + - A rowspan reaching past its row group continues into the next one tables.html#header-and-data-cell-semantics: title: 4.9.12.2 Forming relationships between data cells and header cells - status: unknown + status: n/a tables.html#table-examples: title: 4.9.13 Examples - status: unknown + status: n/a forms.html#forms: title: 4.10 Forms status: unknown From 127b0e03f3a6eeb0784676331c877fdfb4db2ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:28:04 +0100 Subject: [PATCH 042/150] Verify and document HTML forms compliance --- .../Test/HtmlRenderer.Test/Html/FormsTests.cs | 116 ++++++++ Spec/html.yml | 250 +++++------------- 2 files changed, 187 insertions(+), 179 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/FormsTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Html/FormsTests.cs b/Source/Test/HtmlRenderer.Test/Html/FormsTests.cs new file mode 100644 index 000000000..5e0ccfe65 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/FormsTests.cs @@ -0,0 +1,116 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class FormsTests +{ + private static string Text(CssBox box) => + string.Concat(LayoutHarness.Descendants(box).SelectMany(b => b.Words).Select(w => w.Text)); + + [TestMethod] + [Spec("html", "forms.html#the-form-element")] + [Spec("html", "form-elements.html#the-fieldset-element")] + [DataRow("form")] + [DataRow("fieldset")] + public void Element_IsABlock(string tag) + { + Assert.AreEqual("block", Box($"<{tag} id=x>a").Display); + } + + [TestMethod] + [Spec("html", "forms.html#the-label-element")] + [Spec("html", "form-elements.html#the-output-element")] + [DataRow("label")] + [DataRow("output")] + public void Element_RendersItsContentInline(string tag) + { + var box = Box($"

          <{tag} id=x>a

          "); + + Assert.AreEqual("inline", box.Display); + Assert.AreEqual("a", Text(box)); + } + + [TestMethod] + public void HiddenInput_TakesNoSpace() + { + Assert.AreEqual(0, Box("
          ").Size.Height); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "input.html#states-of-the-type-attribute")] + [DataRow("text")] + [DataRow("checkbox")] + [DataRow("submit")] + public void Input_RendersAWidget(string type) + { + Assert.IsTrue(Box($"").Size.Width > 0); + } + + [TestMethod] + [Spec("html", "form-elements.html#the-button-element")] + [Spec("html", "form-elements.html#the-textarea-element")] + [DataRow("button")] + [DataRow("textarea")] + public void Element_RendersItsContent(string tag) + { + Assert.AreEqual("a", Text(Box($"<{tag} id=x>a"))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "form-elements.html#the-button-element")] + [Spec("html", "form-elements.html#the-textarea-element")] + [DataRow("button")] + [DataRow("textarea")] + public void Element_HasAFrame(string tag) + { + Assert.AreNotEqual("none", Box($"<{tag} id=x>a").BorderTopStyle); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "form-elements.html#the-select-element")] + public void Select_ShowsOnlyTheSelectedOption() + { + Assert.AreEqual("b", Text(Box(""))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "form-elements.html#the-datalist-element")] + public void Datalist_IsNotRendered() + { + Assert.AreEqual(0, Box("
          ").Size.Height); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "form-elements.html#the-progress-element")] + [Spec("html", "form-elements.html#the-meter-element")] + [DataRow("progress")] + [DataRow("meter")] + public void Gauge_IsAnInlineBlockWidget(string tag) + { + Assert.AreEqual("inline-block", Box($"<{tag} id=x value=0.5>50%").Display); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "form-elements.html#the-fieldset-element")] + public void Fieldset_HasAGrooveBorder() + { + Assert.AreEqual("groove", Box("
          a
          ").BorderTopStyle); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "form-elements.html#the-legend-element")] + public void Legend_IsABlock() + { + Assert.AreEqual("block", Box("
          ab
          ").Display); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index fecc4644e..bdd2cfa6e 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -828,289 +828,181 @@ sections: status: n/a forms.html#forms: title: 4.10 Forms - status: unknown + status: partial forms.html#introduction-4: title: 4.10.1 Introduction - status: unknown - forms.html#writing-a-form's-user-interface: - title: 4.10.1.1 Writing a form's user interface - status: unknown - forms.html#implementing-the-server-side-processing-for-a-form: - title: 4.10.1.2 Implementing the server-side processing for a form - status: unknown - forms.html#configuring-a-form-to-communicate-with-a-server: - title: 4.10.1.3 Configuring a form to communicate with a server - status: unknown - forms.html#client-side-form-validation: - title: 4.10.1.4 Client-side form validation - status: unknown - forms.html#enabling-client-side-automatic-filling-of-form-controls: - title: 4.10.1.5 Enabling client-side automatic filling of form controls - status: unknown - forms.html#improving-the-user-experience-on-mobile-devices: - title: 4.10.1.6 Improving the user experience on mobile devices - status: unknown - forms.html#the-difference-between-the-field-type,-the-autofill-field-name,-and-the-input-modality: - title: 4.10.1.7 The difference between the field type, the autofill field name, and the input modality - status: unknown - forms.html#input-author-notes: - title: 4.10.1.8 Date, time, and number formats - status: unknown + status: n/a forms.html#categories: title: 4.10.2 Categories - status: unknown + status: n/a forms.html#the-form-element: title: 4.10.3 The form element - status: unknown + status: supported forms.html#the-label-element: title: 4.10.4 The label element - status: unknown + status: supported input.html#the-input-element: title: 4.10.5 The input element - status: unknown + status: unsupported input.html#states-of-the-type-attribute: title: 4.10.5.1 States of the type attribute - status: unknown + status: unsupported + gaps: + - Renders as an empty box, no field, value or check mark, see 15.5.6 to 15.5.12 input.html#hidden-state-(type=hidden): title: 4.10.5.1.1 Hidden state (type=hidden) - status: unknown + status: unsupported input.html#text-(type=text)-state-and-search-state-(type=search): title: 4.10.5.1.2 Text (type=text) state and Search state (type=search) - status: unknown + status: unsupported input.html#telephone-state-(type=tel): title: 4.10.5.1.3 Telephone state (type=tel) - status: unknown + status: unsupported input.html#url-state-(type=url): title: 4.10.5.1.4 URL state (type=url) - status: unknown + status: unsupported input.html#email-state-(type=email): title: 4.10.5.1.5 Email state (type=email) - status: unknown + status: unsupported input.html#password-state-(type=password): title: 4.10.5.1.6 Password state (type=password) - status: unknown + status: unsupported input.html#date-state-(type=date): title: 4.10.5.1.7 Date state (type=date) - status: unknown + status: unsupported input.html#month-state-(type=month): title: 4.10.5.1.8 Month state (type=month) - status: unknown + status: unsupported input.html#week-state-(type=week): title: 4.10.5.1.9 Week state (type=week) - status: unknown + status: unsupported input.html#time-state-(type=time): title: 4.10.5.1.10 Time state (type=time) - status: unknown + status: unsupported input.html#local-date-and-time-state-(type=datetime-local): title: 4.10.5.1.11 Local Date and Time state (type=datetime-local) - status: unknown + status: unsupported input.html#number-state-(type=number): title: 4.10.5.1.12 Number state (type=number) - status: unknown + status: unsupported input.html#range-state-(type=range): title: 4.10.5.1.13 Range state (type=range) - status: unknown + status: unsupported input.html#color-state-(type=color): title: 4.10.5.1.14 Color state (type=color) - status: unknown + status: unsupported input.html#checkbox-state-(type=checkbox): title: 4.10.5.1.15 Checkbox state (type=checkbox) - status: unknown + status: unsupported input.html#radio-button-state-(type=radio): title: 4.10.5.1.16 Radio Button state (type=radio) - status: unknown + status: unsupported input.html#file-upload-state-(type=file): title: 4.10.5.1.17 File Upload state (type=file) - status: unknown + status: unsupported input.html#submit-button-state-(type=submit): title: 4.10.5.1.18 Submit Button state (type=submit) - status: unknown + status: unsupported input.html#image-button-state-(type=image): title: 4.10.5.1.19 Image Button state (type=image) - status: unknown + status: unsupported input.html#reset-button-state-(type=reset): title: 4.10.5.1.20 Reset Button state (type=reset) - status: unknown + status: unsupported input.html#button-state-(type=button): title: 4.10.5.1.21 Button state (type=button) - status: unknown + status: unsupported input.html#input-impl-notes: title: 4.10.5.2 Implementation notes regarding localization of form controls - status: unknown + status: n/a input.html#common-input-element-attributes: title: 4.10.5.3 Common input element attributes - status: unknown - input.html#the-maxlength-and-minlength-attributes: - title: 4.10.5.3.1 The maxlength and minlength attributes - status: unknown + status: n/a input.html#the-size-attribute: title: 4.10.5.3.2 The size attribute - status: unknown - input.html#the-readonly-attribute: - title: 4.10.5.3.3 The readonly attribute - status: unknown - input.html#the-required-attribute: - title: 4.10.5.3.4 The required attribute - status: unknown - input.html#the-multiple-attribute: - title: 4.10.5.3.5 The multiple attribute - status: unknown - input.html#the-pattern-attribute: - title: 4.10.5.3.6 The pattern attribute - status: unknown - input.html#the-min-and-max-attributes: - title: 4.10.5.3.7 The min and max attributes - status: unknown - input.html#the-step-attribute: - title: 4.10.5.3.8 The step attribute - status: unknown - input.html#the-list-attribute: - title: 4.10.5.3.9 The list attribute - status: unknown + status: unsupported input.html#the-placeholder-attribute: title: 4.10.5.3.10 The placeholder attribute - status: unknown + status: unsupported input.html#common-input-element-apis: title: 4.10.5.4 Common input element APIs - status: unknown + status: n/a input.html#common-input-element-events: title: 4.10.5.5 Common event behaviors - status: unknown + status: n/a form-elements.html#the-button-element: title: 4.10.6 The button element - status: unknown + status: partial + gaps: + - Rendered without the button frame, see 15.5.4 form-elements.html#the-select-element: title: 4.10.7 The select element - status: unknown + status: unsupported + gaps: + - Shows the text of every option instead of a drop-down box with the selected one, see 15.5.16 form-elements.html#the-datalist-element: title: 4.10.8 The datalist element - status: unknown + status: unsupported + gaps: + - Its options are displayed, see 15.3.1 form-elements.html#the-optgroup-element: title: 4.10.9 The optgroup element - status: unknown + status: unsupported form-elements.html#the-option-element: title: 4.10.10 The option element - status: unknown + status: unsupported form-elements.html#the-textarea-element: title: 4.10.11 The textarea element - status: unknown + status: partial + gaps: + - Rendered as inline text without the text field frame, see 15.5.17 form-elements.html#the-output-element: title: 4.10.12 The output element - status: unknown + status: supported form-elements.html#the-progress-element: title: 4.10.13 The progress element - status: unknown + status: unsupported + gaps: + - Shows its content instead of a progress bar, see 15.5.15 form-elements.html#the-meter-element: title: 4.10.14 The meter element - status: unknown + status: unsupported + gaps: + - Shows its content instead of a gauge, see 15.5.14 form-elements.html#the-fieldset-element: title: 4.10.15 The fieldset element - status: unknown + status: partial + gaps: + - Rendered as a plain block without the border around it, see 15.3.12 form-elements.html#the-legend-element: title: 4.10.16 The legend element - status: unknown + status: unsupported + gaps: + - Rendered inline at the start of the fieldset's content instead of in its border, see 15.3.12 form-elements.html#the-selectedcontent-element: title: 4.10.17 The selectedcontent element - status: unknown + status: unsupported form-control-infrastructure.html#form-control-infrastructure: title: 4.10.18 Form control infrastructure - status: unknown - form-control-infrastructure.html#a-form-control's-value: - title: 4.10.18.1 A form control's value - status: unknown - form-control-infrastructure.html#mutability: - title: 4.10.18.2 Mutability - status: unknown - form-control-infrastructure.html#association-of-controls-and-forms: - title: 4.10.18.3 Association of controls and forms - status: unknown + status: n/a form-control-infrastructure.html#attributes-common-to-form-controls: title: 4.10.19 Attributes common to form controls - status: unknown - form-control-infrastructure.html#naming-form-controls:-the-name-attribute: - title: '4.10.19.1 Naming form controls: the name attribute' - status: unknown - form-control-infrastructure.html#submitting-element-directionality:-the-dirname-attribute: - title: '4.10.19.2 Submitting element directionality: the dirname attribute' - status: unknown - form-control-infrastructure.html#limiting-user-input-length:-the-maxlength-attribute: - title: '4.10.19.3 Limiting user input length: the maxlength attribute' - status: unknown - form-control-infrastructure.html#setting-minimum-input-length-requirements:-the-minlength-attribute: - title: '4.10.19.4 Setting minimum input length requirements: the minlength attribute' - status: unknown + status: n/a form-control-infrastructure.html#enabling-and-disabling-form-controls:-the-disabled-attribute: title: '4.10.19.5 Enabling and disabling form controls: the disabled attribute' - status: unknown - form-control-infrastructure.html#form-submission-attributes: - title: 4.10.19.6 Form submission attributes - status: unknown - form-control-infrastructure.html#autofill: - title: 4.10.19.7 Autofill - status: unknown - form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute: - title: '4.10.19.7.1 Autofilling form controls: the autocomplete attribute' - status: unknown - form-control-infrastructure.html#autofill-processing-model: - title: 4.10.19.7.2 Processing model - status: unknown + status: unsupported form-control-infrastructure.html#textFieldSelection: title: 4.10.20 APIs for the text control selections - status: unknown + status: n/a form-control-infrastructure.html#constraints: title: 4.10.21 Constraints - status: unknown - form-control-infrastructure.html#definitions: - title: 4.10.21.1 Definitions - status: unknown - form-control-infrastructure.html#constraint-validation: - title: 4.10.21.2 Constraint validation - status: unknown - form-control-infrastructure.html#the-constraint-validation-api: - title: 4.10.21.3 The constraint validation API - status: unknown - form-control-infrastructure.html#security-forms: - title: 4.10.21.4 Security - status: unknown + status: n/a form-control-infrastructure.html#form-submission-2: title: 4.10.22 Form submission - status: unknown - form-control-infrastructure.html#introduction-5: - title: 4.10.22.1 Introduction - status: unknown - form-control-infrastructure.html#implicit-submission: - title: 4.10.22.2 Implicit submission - status: unknown - form-control-infrastructure.html#form-submission-algorithm: - title: 4.10.22.3 Form submission algorithm - status: unknown - form-control-infrastructure.html#constructing-form-data-set: - title: 4.10.22.4 Constructing the entry list - status: unknown - form-control-infrastructure.html#selecting-a-form-submission-encoding: - title: 4.10.22.5 Selecting a form submission encoding - status: unknown - form-control-infrastructure.html#converting-an-entry-list-to-a-list-of-name-value-pairs: - title: 4.10.22.6 Converting an entry list to a list of name-value pairs - status: unknown - form-control-infrastructure.html#url-encoded-form-data: - title: 4.10.22.7 URL-encoded form data - status: unknown - form-control-infrastructure.html#multipart-form-data: - title: 4.10.22.8 Multipart form data - status: unknown - form-control-infrastructure.html#plain-text-form-data: - title: 4.10.22.9 Plain text form data - status: unknown - form-control-infrastructure.html#the-submitevent-interface: - title: 4.10.22.10 The SubmitEvent interface - status: unknown - form-control-infrastructure.html#the-formdataevent-interface: - title: 4.10.22.11 The FormDataEvent interface - status: unknown + status: n/a form-control-infrastructure.html#resetting-a-form: title: 4.10.23 Resetting a form - status: unknown + status: n/a interactive-elements.html#interactive-elements: title: 4.11 Interactive elements status: unknown From dffd7dbca11702e629a96353b44731eed1250c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:29:10 +0100 Subject: [PATCH 043/150] Verify and document HTML interactive elements compliance --- .../Html/InteractiveElementsTests.cs | 83 +++++++++++++++++++ Spec/html.yml | 42 ++++------ 2 files changed, 98 insertions(+), 27 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/InteractiveElementsTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Html/InteractiveElementsTests.cs b/Source/Test/HtmlRenderer.Test/Html/InteractiveElementsTests.cs new file mode 100644 index 000000000..84ceeb021 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/InteractiveElementsTests.cs @@ -0,0 +1,83 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class InteractiveElementsTests +{ + private static string Text(CssBox box) => + string.Concat(LayoutHarness.Descendants(box).SelectMany(b => b.Words).Select(w => w.Text)); + + [TestMethod] + [Spec("html", "interactive-elements.html#the-details-element")] + [Spec("html", "interactive-elements.html#the-summary-element")] + public void OpenDetails_ShowsTheSummaryAndTheContent() + { + Assert.AreEqual("sa", Text(Box("
          sa
          "))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "interactive-elements.html#the-details-element")] + public void ClosedDetails_ShowsOnlyTheSummary() + { + var withContent = Box("
          s

          a

          b

          "); + var withoutContent = Box("
          s
          "); + + Assert.AreEqual(withoutContent.Size.Height, withContent.Size.Height); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "interactive-elements.html#the-details-element")] + public void Details_WithoutASummary_GetsADefaultLegend() + { + StringAssert.StartsWith(Text(Box("
          a
          ")), "Details"); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "interactive-elements.html#the-details-element")] + public void Details_InANameGroup_OnlyTheFirstOpenOneStaysOpen() + { + const string html = + "" + + "

          a

          b

          "; + + Assert.AreEqual("rgb(1, 2, 3)", Box(html, "a").Color); + Assert.AreNotEqual("rgb(1, 2, 3)", Box(html, "b").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "interactive-elements.html#the-summary-element")] + public void Summary_HasADisclosureMarker() + { + Assert.AreEqual("disclosure-closed", Box("
          s
          ").ListStyleType); + } + + [TestMethod] + [Spec("html", "interactive-elements.html#the-dialog-element")] + public void OpenDialog_ShowsItsContent() + { + Assert.AreEqual("a", Text(Box("a"))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "interactive-elements.html#the-dialog-element")] + public void ClosedDialog_IsNotShown() + { + Assert.AreEqual(0, Box("
          a
          ").Size.Height); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "interactive-elements.html#the-dialog-element")] + public void OpenDialog_IsAnAbsolutelyPositionedBox() + { + Assert.AreEqual("absolute", Box("a").Position); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index bdd2cfa6e..6c9b8b8df 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1005,43 +1005,31 @@ sections: status: n/a interactive-elements.html#interactive-elements: title: 4.11 Interactive elements - status: unknown + status: partial interactive-elements.html#the-details-element: title: 4.11.1 The details element - status: unknown + status: partial + gaps: + - A closed details shows all its content, not just the summary + - Without a summary there's no default legend + - Of several open details with the same name all stay open instead of only the first interactive-elements.html#the-summary-element: title: 4.11.2 The summary element - status: unknown + status: partial + gaps: + - No disclosure triangle, see 15.5.5 interactive-elements.html#commands: title: 4.11.3 Commands - status: unknown - interactive-elements.html#facets-2: - title: 4.11.3.1 Facets - status: unknown - interactive-elements.html#using-the-a-element-to-define-a-command: - title: 4.11.3.2 Using the a element to define a command - status: unknown - interactive-elements.html#using-the-button-element-to-define-a-command: - title: 4.11.3.3 Using the button element to define a command - status: unknown - interactive-elements.html#using-the-input-element-to-define-a-command: - title: 4.11.3.4 Using the input element to define a command - status: unknown - interactive-elements.html#using-the-option-element-to-define-a-command: - title: 4.11.3.5 Using the option element to define a command - status: unknown - interactive-elements.html#using-the-accesskey-attribute-on-a-legend-element-to-define-a-command: - title: 4.11.3.6 Using the accesskey attribute on a legend element to define a command - status: unknown - interactive-elements.html#using-the-accesskey-attribute-to-define-a-command-on-other-elements: - title: 4.11.3.7 Using the accesskey attribute to define a command on other elements - status: unknown + status: n/a interactive-elements.html#the-dialog-element: title: 4.11.4 The dialog element - status: unknown + status: partial + gaps: + - A dialog without open is shown, see 15.3.1 + - An open dialog is rendered in the flow instead of as a centered, bordered box, see 15.3.3 interactive-elements.html#dialog-light-dismiss: title: 4.11.5 Dialog light dismiss - status: unknown + status: n/a scripting.html#scripting-3: title: 4.12 Scripting status: unknown From 66e93392d2445f5c0435cb07d5ca28de9e8585bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:30:28 +0100 Subject: [PATCH 044/150] Verify and document HTML scripting compliance --- .../HtmlRenderer.Test/Html/ScriptingTests.cs | 85 ++++++++++++ Spec/html.yml | 124 ++++-------------- 2 files changed, 109 insertions(+), 100 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/ScriptingTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Html/ScriptingTests.cs b/Source/Test/HtmlRenderer.Test/Html/ScriptingTests.cs new file mode 100644 index 000000000..542107ded --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/ScriptingTests.cs @@ -0,0 +1,85 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class ScriptingTests +{ + private static string Text(CssBox box) => + string.Concat(LayoutHarness.Descendants(box).SelectMany(b => b.Words).Select(w => w.Text)); + + [TestMethod] + [Spec("html", "scripting.html#the-script-element")] + public void Script_ContentIsNotRendered() + { + var box = Box("
          "); + + Assert.AreEqual(0, box.Size.Height); + Assert.AreEqual("", Text(box)); + } + + [TestMethod] + [Spec("html", "scripting.html#the-noscript-element")] + public void Noscript_ContentIsParsedAsMarkup() + { + const string html = "

          "; + + Assert.AreEqual("noscript", ParentTag(html)); + Assert.AreEqual("a", Text(Box(html))); + } + + [TestMethod] + [Spec("html", "scripting.html#the-noscript-element")] + public void Noscript_TextIsRendered() + { + // Crashes the test host with a stack overflow + Assert.AreEqual("a", Text(Box("

          "))); + } + + [TestMethod] + [Spec("html", "scripting.html#the-noscript-element")] + public void Noscript_ElementKeepsTheContentAfterItsFirstChildElement() + { + Assert.AreEqual("ab", Text(Box(""))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "scripting.html#the-template-element")] + public void Template_ContentIsNotRendered() + { + Assert.AreEqual(0, Box("
          ").Size.Height); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "scripting.html#the-template-element")] + public void Template_WithShadowRootMode_ReplacesTheHostsContent() + { + Assert.AreEqual("a", Text(Box("
          b
          "))); + } + + [TestMethod] + [Spec("html", "scripting.html#the-slot-element")] + public void Slot_OutsideOfAShadowTree_RendersItsContent() + { + Assert.AreEqual("a", Text(Box("

          a

          "))); + } + + [TestMethod] + [Spec("html", "canvas.html#the-canvas-element")] + public void Canvas_RendersItsFallbackContent() + { + Assert.AreEqual("a", Text(Box("a"))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "canvas.html#the-canvas-element")] + public void Canvas_WithFallbackContent_IgnoresItsBitmapSize() + { + Assert.IsTrue(Box("
          a
          ").Size.Height < 50); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 6c9b8b8df..17abfeb34 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1032,142 +1032,66 @@ sections: status: n/a scripting.html#scripting-3: title: 4.12 Scripting - status: unknown + status: partial scripting.html#the-script-element: title: 4.12.1 The script element - status: unknown + status: supported scripting.html#script-processing-model: title: 4.12.1.1 Processing model - status: unknown + status: n/a scripting.html#scriptingLanguages: title: 4.12.1.2 Scripting languages - status: unknown + status: n/a scripting.html#restrictions-for-contents-of-script-elements: title: 4.12.1.3 Restrictions for contents of script elements - status: unknown + status: n/a scripting.html#inline-documentation-for-external-scripts: title: 4.12.1.4 Inline documentation for external scripts - status: unknown + status: n/a scripting.html#scriptTagXSLT: title: 4.12.1.5 Interaction of script elements and XSLT - status: unknown + status: n/a scripting.html#the-noscript-element: title: 4.12.2 The noscript element - status: unknown + status: supported scripting.html#the-template-element: title: 4.12.3 The template element - status: unknown + status: unsupported + gaps: + - Its content is rendered, see 15.3.1 + - shadowrootmode doesn't attach a shadow root, the host's own children render instead of the template's content scripting.html#template-XSLT-XPath: title: 4.12.3.1 Interaction of template elements with XSLT and XPath - status: unknown + status: n/a scripting.html#the-slot-element: title: 4.12.4 The slot element - status: unknown + status: supported canvas.html#the-canvas-element: title: 4.12.5 The canvas element - status: unknown + status: partial + gaps: + - width and height map to the CSS width and height, which sizes the fallback content, see 15.4.3 canvas.html#2dcontext: title: 4.12.5.1 The 2D rendering context - status: unknown - canvas.html#implementation-notes: - title: 4.12.5.1.1 Implementation notes - status: unknown - canvas.html#the-canvas-settings: - title: 4.12.5.1.2 The canvas settings - status: unknown - canvas.html#the-canvas-state: - title: 4.12.5.1.3 The canvas state - status: unknown - canvas.html#line-styles: - title: 4.12.5.1.4 Line styles - status: unknown - canvas.html#text-styles: - title: 4.12.5.1.5 Text styles - status: unknown - canvas.html#building-paths: - title: 4.12.5.1.6 Building paths - status: unknown - canvas.html#path2d-objects: - title: 4.12.5.1.7 Path2D objects - status: unknown - canvas.html#transformations: - title: 4.12.5.1.8 Transformations - status: unknown - canvas.html#image-sources-for-2d-rendering-contexts: - title: 4.12.5.1.9 Image sources for 2D rendering contexts - status: unknown - canvas.html#fill-and-stroke-styles: - title: 4.12.5.1.10 Fill and stroke styles - status: unknown - canvas.html#drawing-rectangles-to-the-bitmap: - title: 4.12.5.1.11 Drawing rectangles to the bitmap - status: unknown - canvas.html#drawing-text-to-the-bitmap: - title: 4.12.5.1.12 Drawing text to the bitmap - status: unknown - canvas.html#drawing-paths-to-the-canvas: - title: 4.12.5.1.13 Drawing paths to the canvas - status: unknown - canvas.html#drawing-focus-rings-and-scrolling-paths-into-view: - title: 4.12.5.1.14 Drawing focus rings - status: unknown - canvas.html#drawing-images: - title: 4.12.5.1.15 Drawing images - status: unknown - canvas.html#pixel-manipulation: - title: 4.12.5.1.16 Pixel manipulation - status: unknown - canvas.html#compositing: - title: 4.12.5.1.17 Compositing - status: unknown - canvas.html#image-smoothing: - title: 4.12.5.1.18 Image smoothing - status: unknown - canvas.html#shadows: - title: 4.12.5.1.19 Shadows - status: unknown - canvas.html#filters: - title: 4.12.5.1.20 Filters - status: unknown - canvas.html#working-with-externally-defined-svg-filters: - title: 4.12.5.1.21 Working with externally-defined SVG filters - status: unknown - canvas.html#drawing-model: - title: 4.12.5.1.22 Drawing model - status: unknown - canvas.html#best-practices: - title: 4.12.5.1.23 Best practices - status: unknown - canvas.html#examples: - title: 4.12.5.1.24 Examples - status: unknown + status: n/a canvas.html#the-imagebitmap-rendering-context: title: 4.12.5.2 The ImageBitmap rendering context - status: unknown - canvas.html#introduction-6: - title: 4.12.5.2.1 Introduction - status: unknown - canvas.html#the-imagebitmaprenderingcontext-interface: - title: 4.12.5.2.2 The ImageBitmapRenderingContext interface - status: unknown + status: n/a canvas.html#the-offscreencanvas-interface: title: 4.12.5.3 The OffscreenCanvas interface - status: unknown - canvas.html#the-offscreen-2d-rendering-context: - title: 4.12.5.3.1 The offscreen 2D rendering context - status: unknown + status: n/a canvas.html#colour-spaces-and-colour-correction: title: 4.12.5.4 Color spaces and color space conversion - status: unknown + status: n/a canvas.html#serialising-bitmaps-to-a-file: title: 4.12.5.5 Serializing bitmaps to a file - status: unknown + status: n/a canvas.html#security-with-canvas-elements: title: 4.12.5.6 Security with canvas elements - status: unknown + status: n/a canvas.html#premultiplied-alpha-and-the-2d-rendering-context: title: 4.12.5.7 Premultiplied alpha and the 2D rendering context - status: unknown + status: n/a custom-elements.html#custom-elements: title: 4.13 Custom elements status: unknown From 577d66fcb54f5e59ea7f556c2600532d77f8a534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:30:50 +0100 Subject: [PATCH 045/150] Verify and document HTML custom elements compliance --- .../Html/CustomElementsTests.cs | 18 ++++++ Spec/html.yml | 58 +++---------------- 2 files changed, 26 insertions(+), 50 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/CustomElementsTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Html/CustomElementsTests.cs b/Source/Test/HtmlRenderer.Test/Html/CustomElementsTests.cs new file mode 100644 index 000000000..a5b5eca7f --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/CustomElementsTests.cs @@ -0,0 +1,18 @@ +using HtmlRenderer.Test.TestSupport; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class CustomElementsTests +{ + [TestMethod] + [Spec("html", "custom-elements.html#custom-elements-core-concepts")] + public void UndefinedCustomElement_RendersItsContentInline() + { + var box = Box("

          a

          "); + + Assert.AreEqual("inline", box.Display); + Assert.AreEqual("a", string.Concat(LayoutHarness.Descendants(box).SelectMany(b => b.Words).Select(w => w.Text))); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 17abfeb34..1b77d2fb7 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1094,70 +1094,28 @@ sections: status: n/a custom-elements.html#custom-elements: title: 4.13 Custom elements - status: unknown + status: supported custom-elements.html#custom-elements-intro: title: 4.13.1 Introduction - status: unknown - custom-elements.html#custom-elements-autonomous-example: - title: 4.13.1.1 Creating an autonomous custom element - status: unknown - custom-elements.html#custom-elements-face-example: - title: 4.13.1.2 Creating a form-associated custom element - status: unknown - custom-elements.html#custom-elements-accessibility-example: - title: 4.13.1.3 Creating a custom element with default accessible roles, states, and properties - status: unknown - custom-elements.html#custom-elements-customized-builtin-example: - title: 4.13.1.4 Creating a customized built-in element - status: unknown - custom-elements.html#custom-elements-autonomous-drawbacks: - title: 4.13.1.5 Drawbacks of autonomous custom elements - status: unknown - custom-elements.html#custom-elements-upgrades-examples: - title: 4.13.1.6 Upgrading elements after their creation - status: unknown - custom-elements.html#scoped-custom-element-registries: - title: 4.13.1.7 Scoped custom element registries - status: unknown - custom-elements.html#exposing-custom-element-states: - title: 4.13.1.8 Exposing custom element states - status: unknown + status: n/a custom-elements.html#custom-element-conformance: title: 4.13.2 Requirements for custom element constructors and reactions - status: unknown - custom-elements.html#preserving-custom-element-state-when-moved: - title: 4.13.2.1 Preserving custom element state when moved - status: unknown + status: n/a custom-elements.html#custom-elements-core-concepts: title: 4.13.3 Core concepts - status: unknown + status: supported custom-elements.html#custom-elements-api: title: 4.13.4 The CustomElementRegistry interface - status: unknown + status: n/a custom-elements.html#upgrades: title: 4.13.5 Upgrades - status: unknown + status: n/a custom-elements.html#custom-element-reactions: title: 4.13.6 Custom element reactions - status: unknown + status: n/a custom-elements.html#element-internals: title: 4.13.7 Element internals - status: unknown - custom-elements.html#the-elementinternals-interface: - title: 4.13.7.1 The ElementInternals interface - status: unknown - custom-elements.html#shadow-root-access: - title: 4.13.7.2 Shadow root access - status: unknown - custom-elements.html#form-associated-custom-elements: - title: 4.13.7.3 Form-associated custom elements - status: unknown - custom-elements.html#accessibility-semantics: - title: 4.13.7.4 Accessibility semantics - status: unknown - custom-elements.html#custom-state-pseudo-class: - title: 4.13.7.5 Custom state pseudo-class - status: unknown + status: n/a semantics-other.html#common-idioms: title: 4.14 Common idioms without dedicated elements status: unknown From 42737d3d30d8182020ed2abdcac4d32827f6ed84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:34:59 +0100 Subject: [PATCH 046/150] Verify and document HTML common idioms and selector matching compliance --- .../Text/LinkPseudoClassIntegrationTests.cs | 5 + .../Html/SelectorMatchingTests.cs | 118 ++++++++++++++++++ Spec/html.yml | 35 +++--- 3 files changed, 140 insertions(+), 18 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/SelectorMatchingTests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs index 7bf00779e..158dbb6b5 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs @@ -12,6 +12,7 @@ public sealed class LinkPseudoClassIntegrationTests { [TestMethod] [Spec("html", "links.html#links-created-by-a-and-area-elements")] + [Spec("html", "semantics-other.html#pseudo-classes")] public void Link_MatchesAnchorWithHref() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -23,6 +24,7 @@ public void Link_MatchesAnchorWithHref() [TestMethod] [Spec("html", "links.html#links-created-by-a-and-area-elements")] + [Spec("html", "semantics-other.html#pseudo-classes")] public void Link_DoesNotMatchAnchorWithoutHref() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -33,6 +35,7 @@ public void Link_DoesNotMatchAnchorWithoutHref() } [TestMethod] + [Spec("html", "semantics-other.html#pseudo-classes")] public void Link_DoesNotMatchNonAnchorElement() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -43,6 +46,7 @@ public void Link_DoesNotMatchNonAnchorElement() } [TestMethod] + [Spec("html", "semantics-other.html#pseudo-classes")] public void Visited_NeverMatches_ByDesign() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -53,6 +57,7 @@ public void Visited_NeverMatches_ByDesign() } [TestMethod] + [Spec("html", "semantics-other.html#pseudo-classes")] public void Active_NeverMatches_ByDesign() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.Test/Html/SelectorMatchingTests.cs b/Source/Test/HtmlRenderer.Test/Html/SelectorMatchingTests.cs new file mode 100644 index 000000000..979536696 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/SelectorMatchingTests.cs @@ -0,0 +1,118 @@ +using HtmlRenderer.Test.TestSupport; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class SelectorMatchingTests +{ + private const string Marked = "rgb(1, 2, 3)"; + + private static bool Matches(string selector, string body) => + Box($"{body}").Color == Marked; + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "semantics-other.html#case-sensitivity-of-the-css-'attr()'-function")] + public void Attr_MatchesAttributeNamesCaseInsensitively() + { + var box = Box("

          a

          "); + + Assert.AreEqual("t", box.Boxes.Single(b => b.IsBeforePseudoElement).Text); + } + + [TestMethod] + [Spec("html", "semantics-other.html#case-sensitivity-of-selectors")] + [DataRow("P", "

          a

          ")] + [DataRow("[TITLE]", "

          a

          ")] + [DataRow("[type=TEXT]", "

          a

          ")] + [DataRow("[align=LEFT]", "

          a

          ")] + public void ElementNames_AttributeNames_AndListedAttributeValues_MatchCaseInsensitively(string selector, string body) + { + Assert.IsTrue(Matches(selector, body)); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "semantics-other.html#case-sensitivity-of-selectors")] + [DataRow("[title=Foo]", "

          a

          ")] + [DataRow("[class=Foo]", "

          a

          ")] + public void OtherAttributeValues_MatchCaseSensitively(string selector, string body) + { + Assert.IsFalse(Matches(selector, body)); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "semantics-other.html#case-sensitivity-of-selectors")] + [DataRow("#X")] + [DataRow(".Foo")] + public void IdsAndClasses_MatchCaseSensitivelyInNoQuirksMode(string selector) + { + var (root, _) = LayoutHarness.Layout( + $"" + + "

          a

          "); + + Assert.AreNotEqual(Marked, LayoutHarness.FindById(root, "x")!.Color); + } + + [TestMethod] + [Spec("html", "semantics-other.html#pseudo-classes")] + public void Link_MatchesAnchorsWithAnHref() + { + Assert.IsTrue(Matches(":link", "a")); + Assert.IsFalse(Matches(":link", "a")); + } + + [TestMethod] + [Spec("html", "semantics-other.html#pseudo-classes")] + [DataRow(":focus", "")] + [DataRow(":target", "

          a

          ")] + [DataRow(":modal", "a")] + [DataRow(":popover-open", "

          a

          ")] + [DataRow(":autofill", "")] + [DataRow(":user-invalid", "")] + [DataRow(":playing", "")] + public void PseudoClassesForUserInteractionAndScripts_NeverMatch(string selector, string body) + { + Assert.IsFalse(Matches(selector, body)); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "semantics-other.html#pseudo-classes")] + public void Link_MatchesAreasWithAnHref() + { + Assert.IsTrue(Matches(":link", "")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "semantics-other.html#pseudo-classes")] + [DataRow(":defined", "

          a

          ")] + [DataRow(":enabled", "")] + [DataRow(":disabled", "")] + [DataRow(":disabled", "
          ")] + [DataRow(":checked", "")] + [DataRow(":checked", "")] + [DataRow(":indeterminate", "")] + [DataRow(":default", "")] + [DataRow(":placeholder-shown", "")] + [DataRow(":required", "")] + [DataRow(":optional", "")] + [DataRow(":read-only", "

          a

          ")] + [DataRow(":read-write", "")] + [DataRow(":valid", "")] + [DataRow(":invalid", "")] + [DataRow(":in-range", "")] + [DataRow(":out-of-range", "")] + [DataRow(":dir(ltr)", "

          a

          ")] + [DataRow(":heading", "

          a

          ")] + [DataRow(":open", "
          a
          ")] + [DataRow(":paused", "")] + [DataRow(":muted", "")] + public void PseudoClassesForTheDocumentsState_Match(string selector, string body) + { + Assert.IsTrue(Matches(selector, body)); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 1b77d2fb7..33eed2e87 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1118,34 +1118,33 @@ sections: status: n/a semantics-other.html#common-idioms: title: 4.14 Common idioms without dedicated elements - status: unknown - semantics-other.html#rel-up: - title: 4.14.1 Breadcrumb navigation - status: unknown - semantics-other.html#tag-clouds: - title: 4.14.2 Tag clouds - status: unknown - semantics-other.html#conversations: - title: 4.14.3 Conversations - status: unknown - semantics-other.html#footnotes: - title: 4.14.4 Footnotes - status: unknown + status: n/a semantics-other.html#disabled-elements: title: 4.15 Disabled elements - status: unknown + status: unsupported semantics-other.html#selectors: title: 4.16 Matching HTML elements using selectors and CSS - status: unknown + status: partial semantics-other.html#case-sensitivity-of-the-css-'attr()'-function: title: 4.16.1 Case-sensitivity of the CSS 'attr()' function - status: unknown + status: unsupported + gaps: + - attr() matches attribute names case-sensitively, attr(TITLE) doesn't find title semantics-other.html#case-sensitivity-of-selectors: title: 4.16.2 Case-sensitivity of selectors - status: unknown + status: partial + gaps: + - Attribute values match case-insensitively for every attribute, not just the listed ones + - ID and class selectors match case-insensitively also in no-quirks mode semantics-other.html#pseudo-classes: title: 4.16.3 Pseudo-classes - status: unknown + status: partial + gaps: + - ":link doesn't match an area with an href" + - ":defined, :enabled, :disabled, :checked, :indeterminate, :default, :placeholder-shown, :required, :optional, :read-only and :read-write never match" + - ":valid, :invalid, :in-range and :out-of-range never match" + - ":dir(), :heading and :open never match" + - ":paused and :muted never match a media element" syntax.html#syntax: title: 13 The HTML syntax status: partial From e9cfa50c3c0e576d9a2975f12ea3b8727936e59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 18:32:46 +0100 Subject: [PATCH 047/150] Verify and document HTML syntax and parsing compliance --- Source/HtmlRenderer/Core/Parse/HtmlParser.cs | 37 +- Source/HtmlRenderer/Core/Utils/DomUtils.cs | 7 +- Source/HtmlRenderer/Core/Utils/HtmlUtils.cs | 31 +- .../HtmlEntityDecodingIntegrationTests.cs | 106 ++-- .../StyleElementTextConcatenationTests.cs | 10 +- .../Html/TokenizationTests.cs | 265 ++++++++++ .../Html/TreeConstructionTests.cs | 451 ++++++++++++++++++ Spec/html.yml | 379 +++++++-------- 8 files changed, 977 insertions(+), 309 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/TokenizationTests.cs diff --git a/Source/HtmlRenderer/Core/Parse/HtmlParser.cs b/Source/HtmlRenderer/Core/Parse/HtmlParser.cs index 19da464dd..9da625f91 100644 --- a/Source/HtmlRenderer/Core/Parse/HtmlParser.cs +++ b/Source/HtmlRenderer/Core/Parse/HtmlParser.cs @@ -23,15 +23,15 @@ namespace TheArtOfDev.HtmlRenderer.Core.Parse { /// - /// + /// Builds the box tree from HtmlKit's tokens (HTML §13.2.5). Tree construction (HTML §13.2.6) is a + /// simplified version: the boxes form the stack of open elements, there are no insertion modes and no + /// list of active formatting elements, see Spec/html.yml for what's missing. /// internal static class HtmlParser { /// - /// Parses the source html to css boxes tree structure. + /// Parses into boxes below , a new anonymous block if null. /// - /// the html source to parse - /// the root box (null for document root) public static CssBox ParseDocument(string source, CssBox root = null) { if (root == null) @@ -101,19 +101,17 @@ public static CssBox ParseDocument(string source, CssBox root = null) #region Private methods /// - /// Add html text anon box to the current box, this box will have the rendered text
          - /// Adding box also for text that contains only whitespaces because we don't know yet if - /// the box is preformatted. At later stage they will be removed if not relevant. + /// Adds the text as an anonymous child box of the current box. Every data token gets its own box, also + /// a whitespace-only one, white space is only collapsed later, once 'white-space' is known. ///
          - /// the html token to parse - /// the current box in html tree parsing private static void AddTextBox(HtmlDataToken token, ref CssBox curBox) { var text = token.Data; if (text == null) return; - // HTML drops a single newline directly after the
          ,  or ")));
          +    }
          +
          +    [TestMethod]
          +    [Spec("html", "parsing.html#rawtext-state")]
          +    [Spec("html", "parsing.html#rawtext-less-than-sign-state")]
          +    [Spec("html", "parsing.html#rawtext-end-tag-open-state")]
          +    [Spec("html", "parsing.html#rawtext-end-tag-name-state")]
          +    public void Rawtext_KeepsTagsAndReferences()
          +    {
          +        Assert.AreEqual("a <  ", Text(Box("a</b> &lt; </xmpx> ")));
          +    }
          +
          +    [TestMethod]
          +    [Ignore("Gap, see Spec/html.yml")]
          +    [Spec("html", "parsing.html#rawtext-state")]
          +    public void Rawtext_RendersReferencesLiterally()
          +    {
          +        Assert.AreEqual("<", Words(Box("&lt;")));
          +    }
          +
          +    [TestMethod]
          +    [Spec("html", "parsing.html#script-data-state")]
          +    [Spec("html", "parsing.html#script-data-less-than-sign-state")]
          +    [Spec("html", "parsing.html#script-data-end-tag-open-state")]
          +    [Spec("html", "parsing.html#script-data-end-tag-name-state")]
          +    [Spec("html", "parsing.html#script-data-escape-start-state")]
          +    [Spec("html", "parsing.html#script-data-escape-start-dash-state")]
          +    [Spec("html", "parsing.html#script-data-escaped-state")]
          +    [Spec("html", "parsing.html#script-data-escaped-dash-state")]
          +    [Spec("html", "parsing.html#script-data-escaped-dash-dash-state")]
          +    [Spec("html", "parsing.html#script-data-escaped-less-than-sign-state")]
          +    [Spec("html", "parsing.html#script-data-escaped-end-tag-open-state")]
          +    [Spec("html", "parsing.html#script-data-escaped-end-tag-name-state")]
          +    [Spec("html", "parsing.html#script-data-double-escape-start-state")]
          +    [Spec("html", "parsing.html#script-data-double-escaped-state")]
          +    [Spec("html", "parsing.html#script-data-double-escaped-dash-state")]
          +    [Spec("html", "parsing.html#script-data-double-escaped-dash-dash-state")]
          +    [Spec("html", "parsing.html#script-data-double-escaped-less-than-sign-state")]
          +    [Spec("html", "parsing.html#script-data-double-escape-end-state")]
          +    public void ScriptData_EndsAtTheScriptEndTag()
          +    {
          +        Assert.AreEqual("abcd", Text(Box(
          +            "

          abcd

          "))); + } + + [TestMethod] + [Spec("html", "parsing.html#plaintext-state")] + public void Plaintext_RunsToTheEndOfTheInput() + { + var (root, _) = LayoutHarness.Layout("a <b></plaintext>"); + Assert.AreEqual("a <b></plaintext>", Text(LayoutHarness.FindById(root, "x")!)); + } + + [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] + [Spec("html", "parsing.html#named-character-reference-state")] + [Spec("html", "parsing.html#ambiguous-ampersand-state")] + [Spec("html", "parsing.html#numeric-character-reference-state")] + [Spec("html", "parsing.html#hexadecimal-character-reference-start-state")] + [Spec("html", "parsing.html#hexadecimal-character-reference-state")] + [Spec("html", "parsing.html#decimal-character-reference-state")] + [Spec("html", "parsing.html#numeric-character-reference-end-state")] + public void CharacterReferences_InText_AreDecoded() + { + Assert.AreEqual("& <> © ∉ ¬it; A B C � € � � &; &foo; a&b &#", + Text(Box("<p id=x>&amp; &lt;&gt; &copy &notin; &notit; &#65; &#x42; &#X43; &#0; &#128; &#x110000; &#xD800; &; &foo; a&b &#</p>"))); + } + + [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] + [Spec("html", "parsing.html#named-character-reference-state")] + [Spec("html", "parsing.html#ambiguous-ampersand-state")] + public void CharacterReferences_InAttributes_AreDecoded() + { + var tag = Box("<p id=x title=\"&amp;&copy;&#65;\" lang='&copyx' dir=&notit; data-a=\"?a=1&lang=2\">b</p>").HtmlTag!; + Assert.AreEqual("&©A", tag.TryGetAttribute("title")); + Assert.AreEqual("&copyx", tag.TryGetAttribute("lang")); + Assert.AreEqual("&notit;", tag.TryGetAttribute("dir")); + Assert.AreEqual("?a=1&lang=2", tag.TryGetAttribute("data-a")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#decimal-character-reference-state")] + [Spec("html", "parsing.html#hexadecimal-character-reference-state")] + public void NumericCharacterReference_WithoutSemicolon_IsDecoded() + { + Assert.AreEqual("ABx", Box("<p id=x title=\"&#65&#x42x\">a</p>").HtmlTag!.TryGetAttribute("title")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#numeric-character-reference-end-state")] + public void NumericCharacterReference_ToCarriageReturn_IsDecoded() + { + Assert.AreEqual("a\rb", Box("<p id=x title=\"a&#13;b\">a</p>").HtmlTag!.TryGetAttribute("title")); + } + + [TestMethod] + [Spec("html", "named-characters.html#named-character-references")] + public void NamedCharacterReferences_CoverTheWholeTable() + { + Assert.AreEqual("✓fj∾̳\n‌Áá&AAcute;", Text(Box("<p id=x>&check;&fjlig;&acE;&NewLine;&zwnj;&Aacute;&aacute;&AAcute;</p>"))); + } + + private static string Text(CssBox box) => + string.Concat(LayoutHarness.Descendants(box).Where(b => b.HtmlTag == null).Select(b => b.Text)); + + private static string Words(CssBox box) => + string.Concat(LayoutHarness.Descendants(box).SelectMany(b => b.Words).Select(w => w.Text)); +} diff --git a/Source/Test/HtmlRenderer.Test/Html/TreeConstructionTests.cs b/Source/Test/HtmlRenderer.Test/Html/TreeConstructionTests.cs index 8e76edd24..bb38d6b57 100644 --- a/Source/Test/HtmlRenderer.Test/Html/TreeConstructionTests.cs +++ b/Source/Test/HtmlRenderer.Test/Html/TreeConstructionTests.cs @@ -1,3 +1,5 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; using static HtmlRenderer.Test.Html.HtmlTestSupport; namespace HtmlRenderer.Test.Html; @@ -123,4 +125,453 @@ public void HeaderCell_ClosesThePreviousCell() { Assert.AreEqual("tr", ParentTag("<table><tr><th>a<th id=x>b</table>")); } + + [TestMethod] + [Spec("html", "parsing.html#the-stack-of-open-elements")] + public void EndTag_ClosesTheElementsOpenedInsideIt() + { + Assert.AreEqual("body", ParentTag("<div><span><b>a</div><p id=x>b</p>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#the-stack-of-open-elements")] + public void EndTag_DoesNotCloseElementsOutsideATableCell() + { + Assert.AreEqual("td", ParentTag("<b><table><tr><td></b><span id=x>a</span></table>")); + } + + [TestMethod] + [Spec("html", "parsing.html#other-parsing-state-flags")] + public void Noscript_ContentIsParsedAsMarkup() + { + Assert.AreEqual("noscript", ParentTag("<noscript><p id=x>a</p></noscript>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#other-parsing-state-flags")] + public void Frameset_AfterBodyContent_IsIgnored() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p>a</p><frameset id=x><frame></frameset>")); + Assert.IsNull(LayoutHarness.FindById(root, "x")); + } + + [TestMethod] + [Spec("html", "parsing.html#creating-and-inserting-nodes")] + public void StartTag_AttributesAreCopiedToTheElement() + { + var tag = Box("<p id=x title='a b' lang=c>d</p>").HtmlTag!; + Assert.AreEqual("a b", tag.TryGetAttribute("title")); + Assert.AreEqual("c", tag.TryGetAttribute("lang")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#creating-and-inserting-nodes")] + public void MisplacedContentInATable_IsFosterParentedBeforeTheTable() + { + Assert.AreEqual("body", ParentTag("<table><tr><td>a</td></tr><span id=x>b</span></table>")); + Assert.AreEqual("b", Box("<div id=x><table><tr><td>a</td></tr>b</table></div>").Boxes[0].Text); + } + + [TestMethod] + [Spec("html", "parsing.html#creating-and-inserting-nodes")] + public void ConsecutiveCharacters_AreInsertedIntoOneTextNode() + { + Assert.AreEqual(1, Box("<style id=x>a { } b < c { }</style>").Boxes.Count); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-elements-that-contain-only-text")] + [Spec("html", "parsing.html#parsing-main-incdata")] + public void TextOnlyElements_KeepMarkupAsText() + { + Assert.AreEqual("<b>a</b> &", Text(Box("<textarea id=x><b>a</b> &amp;</textarea>"))); + Assert.AreEqual("<b>a</b> &amp;", Text(Box("<xmp id=x><b>a</b> &amp;</xmp>"))); + Assert.AreEqual("body", ParentTag("<title>a <b></TITLE ><p id=x>b</p>")); + } + + [TestMethod] + [Spec("html", "parsing.html#closing-elements-that-have-implied-end-tags")] + public void EndTag_ClosesAnOpenListItem() + { + Assert.AreEqual("body", ParentTag("<div><ul><li>a</div><p id=x>b</p>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#closing-elements-that-have-implied-end-tags")] + public void RubyText_ClosesThePreviousRubyText() + { + Assert.AreEqual("ruby", ParentTag("<ruby>a<rt>b<rt id=x>c</ruby>")); + Assert.AreEqual("ruby", ParentTag("<ruby>a<rt>b<rp id=x>c</ruby>")); + } + + [TestMethod] + [Spec("html", "parsing.html#the-initial-insertion-mode")] + public void Doctype_DoesNotCreateABox() + { + var p = Document("<!DOCTYPE html PUBLIC \"-//W3C//DTD HTML 4.01//EN\"><html><body><p id=x>a</p></body></html>"); + Assert.AreEqual("a", Text(p.ParentBox!.ParentBox!.ParentBox!)); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#the-initial-insertion-mode")] + public void DocumentWithoutDoctype_IsInQuirksMode_WhereATableDoesNotCloseAParagraph() + { + Assert.AreEqual("p", ParentTag("<p>a<table id=x><tr><td>b</table>")); + } + + [TestMethod] + [Spec("html", "parsing.html#the-before-html-insertion-mode")] + public void HtmlStartTag_CreatesTheRootElement() + { + Assert.AreEqual("html", Document("<html id=x><body>a</body></html>").HtmlTag!.Name); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#the-before-html-insertion-mode")] + public void MissingHtmlStartTag_IsImplied() + { + Assert.AreEqual("html", Document("<head></head><body id=x>a</body>").ParentBox!.HtmlTag?.Name); + } + + [TestMethod] + [Spec("html", "parsing.html#the-before-head-insertion-mode")] + public void HeadStartTag_CreatesTheHeadElement() + { + Assert.AreEqual("html", Document("<html><head id=x></head><body>a</body></html>").ParentBox!.HtmlTag?.Name); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#the-before-head-insertion-mode")] + public void MissingHeadStartTag_IsImplied() + { + Assert.AreEqual("head", Document("<html><title id=x>t</title><body>a</body></html>").ParentBox!.HtmlTag?.Name); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-inhead")] + public void StyleInHead_Applies() + { + Assert.AreEqual("rgb(255, 0, 0)", Document("<html><head><style>#x { color: red }</style></head><body><p id=x>a</p></body></html>").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inhead")] + public void BodyContentInHead_ClosesTheHead() + { + Assert.AreEqual("body", Document("<html><head><p id=x>a</p></head><body></body></html>").ParentBox!.HtmlTag?.Name); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-inheadnoscript")] + public void StyleInNoscriptInHead_Applies() + { + Assert.AreEqual("rgb(255, 0, 0)", Document("<html><head><noscript><style>#x { color: red }</style></noscript></head><body><p id=x>a</p></body></html>").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inheadnoscript")] + public void BodyContentInNoscriptInHead_ClosesTheHead() + { + Assert.AreEqual("body", Document("<html><head><noscript><p id=x>a</p></noscript></head><body></body></html>").ParentBox!.HtmlTag?.Name); + } + + [TestMethod] + [Spec("html", "parsing.html#the-after-head-insertion-mode")] + public void BodyStartTag_AfterTheHead_CreatesTheBody() + { + Assert.AreEqual("html", Document("<html><head></head><body id=x>a</body></html>").ParentBox!.HtmlTag?.Name); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#the-after-head-insertion-mode")] + public void MissingBodyStartTag_IsImplied() + { + Assert.AreEqual("body", Document("<html><head></head><p id=x>a</p></html>").ParentBox!.HtmlTag?.Name); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void SecondBodyStartTag_AddsItsAttributesToTheBody() + { + var body = Document("<html><head></head><body id=x><body class=c>a</body></html>"); + Assert.AreEqual("c", body.HtmlTag!.TryGetAttribute("class")); + Assert.IsFalse(body.Boxes.Any(b => b.HtmlTag?.Name == "body")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void SelfClosingSlash_OnANonVoidElement_IsIgnored() + { + Assert.AreEqual("a", Text(Box("<div id=\"x\"/>a</div>"))); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-inbody")] + [DataRow("area")] + [DataRow("img")] + [DataRow("input")] + [DataRow("hr")] + [DataRow("param")] + [DataRow("link")] + [DataRow("meta")] + [DataRow("basefont")] + public void VoidElement_HasNoContent(string tag) + { + Assert.AreEqual(0, Box($"<{tag} id=x>a</{tag}>").Boxes.Count); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void ColStartTag_OutsideATable_IsIgnored() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<col id=x>a")); + Assert.IsNull(LayoutHarness.FindById(root, "x")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + [DataRow("embed")] + [DataRow("keygen")] + [DataRow("source")] + [DataRow("track")] + [DataRow("wbr")] + [DataRow("bgsound")] + public void VoidElement_HasNoContent_Missing(string tag) + { + Assert.AreEqual(0, Box($"<p><{tag} id=x>a</p>").Boxes.Count); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void Isindex_IsAnOrdinaryElement() + { + Assert.AreEqual("a", Text(Box("<isindex id=x>a</isindex>"))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void BrEndTag_InsertsABr() + { + Assert.IsTrue(Box("<p id=x>a</br>b</p>").Boxes.Any(b => b.HtmlTag?.Name == "br")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void ImageStartTag_BecomesImg() + { + Assert.AreEqual("img", Box("<image id=x src=a>").HtmlTag!.Name); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void NullCharacter_InText_IsDropped() + { + Assert.AreEqual("ab", Text(Box("<p id=x>a\0b</p>"))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + [DataRow("center")] + [DataRow("dir")] + [DataRow("summary")] + [DataRow("listing")] + [DataRow("xmp")] + [DataRow("plaintext")] + [DataRow("li")] + [DataRow("dd")] + [DataRow("dt")] + public void BlockStartTag_ClosesAnOpenParagraph_Missing(string tag) + { + Assert.AreEqual("body", ParentTag($"<p>a<{tag} id=x>b")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void HeadingStartTag_ClosesAnOpenHeading() + { + Assert.AreEqual("body", ParentTag("<h1>a<h2 id=x>b</h2>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void ButtonStartTag_ClosesAnOpenButton() + { + Assert.AreEqual("body", ParentTag("<button>a<button id=x>b</button>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void FormStartTag_InsideAForm_IsIgnored() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<form><form id=x>a</form>")); + Assert.IsNull(LayoutHarness.FindById(root, "x")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void AnchorStartTag_ClosesAnOpenAnchor() + { + Assert.AreEqual("body", ParentTag("<a href=a>1<a id=x href=b>2</a>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void MisnestedFormattingEndTag_RunsTheAdoptionAgency() + { + Assert.AreEqual("body", ParentTag("<b>1<p id=x>2</b>3</p>")); + Assert.AreEqual("23", Text(Box("<b>1<p id=x>2</b>3</p>"))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void UnclosedFormattingElement_IsReopenedInTheNextBlock() + { + Assert.AreEqual("b", Box("<p><b>a</p><p id=x>b</p>").Boxes[0].HtmlTag?.Name); + Assert.AreEqual("i", ParentTag("<b>1<i>2</b><span id=x>3</span></i>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-intable")] + public void Cell_OutsideARow_GetsAnImpliedTr() + { + Assert.AreEqual("tr", ParentTag("<table><td id=x>a</table>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-intable")] + public void Col_OutsideAColgroup_GetsAnImpliedColgroup() + { + Assert.AreEqual("colgroup", ParentTag("<table><col id=x><tr><td>a</table>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-intable")] + public void TableStartTag_InATable_ClosesTheTable() + { + Assert.AreEqual("body", ParentTag("<table><tr><td>a</td></tr><table id=x><tr><td>b</table>")); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-incaption")] + public void CaptionEndTag_ClosesTheCaption() + { + Assert.AreEqual("tr", ParentTag("<table><caption>a</caption><tr><td id=x>b</table>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-incaption")] + public void TableStructure_ClosesAnOpenCaption() + { + Assert.AreEqual("table", ParentTag("<table><caption>a<tbody id=x><tr><td>b</table>")); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-incolgroup")] + public void Col_IsAChildOfTheColgroup() + { + Assert.AreEqual("colgroup", ParentTag("<table><colgroup><col id=x></colgroup><tr><td>a</table>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-incolgroup")] + public void ContentOtherThanCol_ClosesAnOpenColgroup() + { + Assert.AreEqual("table", ParentTag("<table><colgroup><col><tbody id=x><tr><td>a</table>")); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-intr")] + public void RowEndTag_ClosesTheRow() + { + Assert.AreEqual("tbody", ParentTag("<table><tbody><tr><td>a</td></tr><tr id=x><td>b</table>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-intr")] + public void TableSection_ClosesAnOpenRow() + { + Assert.AreEqual("table", ParentTag("<table><tbody><tr><td>a</td><tbody id=x><tr><td>b</table>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-intd")] + public void TableSection_ClosesAnOpenCell() + { + Assert.AreEqual("table", ParentTag("<table><tbody><tr><td>a<tbody id=x><tr><td>b</table>")); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-afterbody")] + public void TextAfterTheBody_IsKept() + { + Assert.AreEqual("ab", Text(Document("<html id=x><head></head><body>a</body>b</html>"))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-afterbody")] + public void ContentAfterTheBody_GoesIntoTheBody() + { + Assert.AreEqual("body", Document("<html><head></head><body></body><p id=x>a</p></html>").ParentBox!.HtmlTag?.Name); + } + + [TestMethod] + [Spec("html", "parsing.html#the-after-after-body-insertion-mode")] + public void TextAfterTheHtmlElement_IsKept() + { + var (root, _) = LayoutHarness.Layout("<html><head></head><body>a</body></html>b"); + Assert.AreEqual("ab", Text(root)); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#the-after-after-body-insertion-mode")] + public void ContentAfterTheHtmlElement_GoesIntoTheBody() + { + Assert.AreEqual("body", Document("<html><head></head><body></body></html><p id=x>a</p>").ParentBox!.HtmlTag?.Name); + } + + /// <summary>Lays out a whole document, without the <see cref="LayoutHarness.Wrap"/> the other helpers add.</summary> + private static CssBox Document(string html, string id = "x") + { + var (root, _) = LayoutHarness.Layout(html); + var box = LayoutHarness.FindById(root, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box; + } + + private static string Text(CssBox box) => + string.Concat(LayoutHarness.Descendants(box).Where(b => b.HtmlTag == null).Select(b => b.Text)); } diff --git a/Spec/html.yml b/Spec/html.yml index 33eed2e87..405ead357 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1150,408 +1150,422 @@ sections: status: partial syntax.html#writing: title: 13.1 Writing HTML documents - status: unknown - syntax.html#the-doctype: - title: 13.1.1 The DOCTYPE - status: unknown - syntax.html#elements-2: - title: 13.1.2 Elements - status: unknown - syntax.html#start-tags: - title: 13.1.2.1 Start tags - status: unknown - syntax.html#end-tags: - title: 13.1.2.2 End tags - status: unknown - syntax.html#attributes-2: - title: 13.1.2.3 Attributes - status: unknown - syntax.html#optional-tags: - title: 13.1.2.4 Optional tags - status: unknown - syntax.html#element-restrictions: - title: 13.1.2.5 Restrictions on content models - status: unknown - syntax.html#cdata-rcdata-restrictions: - title: 13.1.2.6 Restrictions on the contents of raw text and escapable raw text elements - status: unknown - syntax.html#text-2: - title: 13.1.3 Text - status: unknown - syntax.html#newlines: - title: 13.1.3.1 Newlines - status: unknown - syntax.html#character-references: - title: 13.1.4 Character references - status: unknown - syntax.html#cdata-sections: - title: 13.1.5 CDATA sections - status: unknown - syntax.html#comments: - title: 13.1.6 Comments - status: unknown - syntax.html#processing-instructions: - title: 13.1.7 Processing instructions - status: unknown + status: n/a parsing.html#parsing: title: 13.2 Parsing HTML documents status: partial parsing.html#overview-of-the-parsing-model: title: 13.2.1 Overview of the parsing model - status: unknown + status: n/a parsing.html#parse-errors: title: 13.2.2 Parse errors - status: unknown + status: n/a parsing.html#the-input-byte-stream: title: 13.2.3 The input byte stream - status: unknown + status: partial parsing.html#parsing-with-a-known-character-encoding: title: 13.2.3.1 Parsing with a known character encoding - status: unknown + status: n/a parsing.html#determining-the-character-encoding: title: 13.2.3.2 Determining the character encoding - status: unknown + status: n/a parsing.html#character-encodings: title: 13.2.3.3 Character encodings - status: unknown + status: n/a parsing.html#changing-the-encoding-while-parsing: title: 13.2.3.4 Changing the encoding while parsing - status: unknown + status: n/a parsing.html#preprocessing-the-input-stream: title: 13.2.3.5 Preprocessing the input stream - status: unknown + status: partial + gaps: + - CR and CRLF aren't normalized to LF, a lone CR in text is lost parsing.html#parse-state: title: 13.2.4 Parse state - status: unknown + status: partial parsing.html#the-insertion-mode: title: 13.2.4.1 The insertion mode - status: unknown + status: unsupported parsing.html#the-stack-of-open-elements: title: 13.2.4.2 The stack of open elements - status: unknown + status: partial + gaps: + - There are no element scopes, an end tag also closes elements outside a table cell or other scope boundary parsing.html#the-list-of-active-formatting-elements: title: 13.2.4.3 The list of active formatting elements - status: unknown + status: unsupported parsing.html#the-element-pointers: title: 13.2.4.4 The element pointers - status: unknown + status: unsupported parsing.html#other-parsing-state-flags: title: 13.2.4.5 Other parsing state flags - status: unknown + status: partial + gaps: + - No frameset-ok flag, a frameset start tag after body content isn't ignored parsing.html#tokenization: title: 13.2.5 Tokenization - status: unknown + status: partial parsing.html#data-state: title: 13.2.5.1 Data state - status: unknown + status: supported parsing.html#rcdata-state: title: 13.2.5.2 RCDATA state - status: unknown + status: supported parsing.html#rawtext-state: title: 13.2.5.3 RAWTEXT state - status: unknown + status: partial + gaps: + - Character references in RAWTEXT (xmp, iframe, noembed, noframes) are decoded when the text is split into words parsing.html#script-data-state: title: 13.2.5.4 Script data state - status: unknown + status: supported parsing.html#plaintext-state: title: 13.2.5.5 PLAINTEXT state - status: unknown + status: supported parsing.html#tag-open-state: title: 13.2.5.6 Tag open state - status: unknown + status: supported parsing.html#end-tag-open-state: title: 13.2.5.7 End tag open state - status: unknown + status: supported parsing.html#tag-name-state: title: 13.2.5.8 Tag name state - status: unknown + status: supported parsing.html#rcdata-less-than-sign-state: title: 13.2.5.9 RCDATA less-than sign state - status: unknown + status: supported parsing.html#rcdata-end-tag-open-state: title: 13.2.5.10 RCDATA end tag open state - status: unknown + status: supported parsing.html#rcdata-end-tag-name-state: title: 13.2.5.11 RCDATA end tag name state - status: unknown + status: supported parsing.html#rawtext-less-than-sign-state: title: 13.2.5.12 RAWTEXT less-than sign state - status: unknown + status: supported parsing.html#rawtext-end-tag-open-state: title: 13.2.5.13 RAWTEXT end tag open state - status: unknown + status: supported parsing.html#rawtext-end-tag-name-state: title: 13.2.5.14 RAWTEXT end tag name state - status: unknown + status: supported parsing.html#script-data-less-than-sign-state: title: 13.2.5.15 Script data less-than sign state - status: unknown + status: supported parsing.html#script-data-end-tag-open-state: title: 13.2.5.16 Script data end tag open state - status: unknown + status: supported parsing.html#script-data-end-tag-name-state: title: 13.2.5.17 Script data end tag name state - status: unknown + status: supported parsing.html#script-data-escape-start-state: title: 13.2.5.18 Script data escape start state - status: unknown + status: supported parsing.html#script-data-escape-start-dash-state: title: 13.2.5.19 Script data escape start dash state - status: unknown + status: supported parsing.html#script-data-escaped-state: title: 13.2.5.20 Script data escaped state - status: unknown + status: supported parsing.html#script-data-escaped-dash-state: title: 13.2.5.21 Script data escaped dash state - status: unknown + status: supported parsing.html#script-data-escaped-dash-dash-state: title: 13.2.5.22 Script data escaped dash dash state - status: unknown + status: supported parsing.html#script-data-escaped-less-than-sign-state: title: 13.2.5.23 Script data escaped less-than sign state - status: unknown + status: supported parsing.html#script-data-escaped-end-tag-open-state: title: 13.2.5.24 Script data escaped end tag open state - status: unknown + status: supported parsing.html#script-data-escaped-end-tag-name-state: title: 13.2.5.25 Script data escaped end tag name state - status: unknown + status: supported parsing.html#script-data-double-escape-start-state: title: 13.2.5.26 Script data double escape start state - status: unknown + status: supported parsing.html#script-data-double-escaped-state: title: 13.2.5.27 Script data double escaped state - status: unknown + status: supported parsing.html#script-data-double-escaped-dash-state: title: 13.2.5.28 Script data double escaped dash state - status: unknown + status: supported parsing.html#script-data-double-escaped-dash-dash-state: title: 13.2.5.29 Script data double escaped dash dash state - status: unknown + status: supported parsing.html#script-data-double-escaped-less-than-sign-state: title: 13.2.5.30 Script data double escaped less-than sign state - status: unknown + status: supported parsing.html#script-data-double-escape-end-state: title: 13.2.5.31 Script data double escape end state - status: unknown + status: supported parsing.html#before-attribute-name-state: title: 13.2.5.32 Before attribute name state - status: unknown + status: supported parsing.html#attribute-name-state: title: 13.2.5.33 Attribute name state - status: unknown + status: partial + gaps: + - Attribute names aren't lowercased, CLASS isn't class and ID and id are kept as two attributes parsing.html#after-attribute-name-state: title: 13.2.5.34 After attribute name state - status: unknown + status: supported parsing.html#before-attribute-value-state: title: 13.2.5.35 Before attribute value state - status: unknown + status: supported parsing.html#attribute-value-(double-quoted)-state: title: 13.2.5.36 Attribute value (double-quoted) state - status: unknown + status: supported parsing.html#attribute-value-(single-quoted)-state: title: 13.2.5.37 Attribute value (single-quoted) state - status: unknown + status: supported parsing.html#attribute-value-(unquoted)-state: title: 13.2.5.38 Attribute value (unquoted) state - status: unknown + status: supported parsing.html#after-attribute-value-(quoted)-state: title: 13.2.5.39 After attribute value (quoted) state - status: unknown + status: supported parsing.html#self-closing-start-tag-state: title: 13.2.5.40 Self-closing start tag state - status: unknown + status: supported parsing.html#bogus-comment-state: title: 13.2.5.41 Bogus comment state - status: unknown + status: supported parsing.html#markup-declaration-open-state: title: 13.2.5.42 Markup declaration open state - status: unknown + status: supported parsing.html#comment-start-state: title: 13.2.5.43 Comment start state - status: unknown + status: supported parsing.html#comment-start-dash-state: title: 13.2.5.44 Comment start dash state - status: unknown + status: supported parsing.html#comment-state: title: 13.2.5.45 Comment state - status: unknown + status: supported parsing.html#comment-less-than-sign-state: title: 13.2.5.46 Comment less-than sign state - status: unknown + status: supported parsing.html#comment-less-than-sign-bang-state: title: 13.2.5.47 Comment less-than sign bang state - status: unknown + status: supported parsing.html#comment-less-than-sign-bang-dash-state: title: 13.2.5.48 Comment less-than sign bang dash state - status: unknown + status: supported parsing.html#comment-less-than-sign-bang-dash-dash-state: title: 13.2.5.49 Comment less-than sign bang dash dash state - status: unknown + status: supported parsing.html#comment-end-dash-state: title: 13.2.5.50 Comment end dash state - status: unknown + status: supported parsing.html#comment-end-state: title: 13.2.5.51 Comment end state - status: unknown + status: supported parsing.html#comment-end-bang-state: title: 13.2.5.52 Comment end bang state - status: unknown + status: supported parsing.html#doctype-state: title: 13.2.5.53 DOCTYPE state - status: unknown + status: supported parsing.html#before-doctype-name-state: title: 13.2.5.54 Before DOCTYPE name state - status: unknown + status: supported parsing.html#doctype-name-state: title: 13.2.5.55 DOCTYPE name state - status: unknown + status: supported parsing.html#after-doctype-name-state: title: 13.2.5.56 After DOCTYPE name state - status: unknown + status: supported parsing.html#after-doctype-public-keyword-state: title: 13.2.5.57 After DOCTYPE public keyword state - status: unknown + status: supported parsing.html#before-doctype-public-identifier-state: title: 13.2.5.58 Before DOCTYPE public identifier state - status: unknown + status: supported parsing.html#doctype-public-identifier-(double-quoted)-state: title: 13.2.5.59 DOCTYPE public identifier (double-quoted) state - status: unknown + status: supported parsing.html#doctype-public-identifier-(single-quoted)-state: title: 13.2.5.60 DOCTYPE public identifier (single-quoted) state - status: unknown + status: supported parsing.html#after-doctype-public-identifier-state: title: 13.2.5.61 After DOCTYPE public identifier state - status: unknown + status: supported parsing.html#between-doctype-public-and-system-identifiers-state: title: 13.2.5.62 Between DOCTYPE public and system identifiers state - status: unknown + status: supported parsing.html#after-doctype-system-keyword-state: title: 13.2.5.63 After DOCTYPE system keyword state - status: unknown + status: supported parsing.html#before-doctype-system-identifier-state: title: 13.2.5.64 Before DOCTYPE system identifier state - status: unknown + status: supported parsing.html#doctype-system-identifier-(double-quoted)-state: title: 13.2.5.65 DOCTYPE system identifier (double-quoted) state - status: unknown + status: supported parsing.html#doctype-system-identifier-(single-quoted)-state: title: 13.2.5.66 DOCTYPE system identifier (single-quoted) state - status: unknown + status: supported parsing.html#after-doctype-system-identifier-state: title: 13.2.5.67 After DOCTYPE system identifier state - status: unknown + status: supported parsing.html#bogus-doctype-state: title: 13.2.5.68 Bogus DOCTYPE state - status: unknown + status: supported parsing.html#cdata-section-state: title: 13.2.5.69 CDATA section state - status: unknown + status: parsed-only parsing.html#cdata-section-bracket-state: title: 13.2.5.70 CDATA section bracket state - status: unknown + status: parsed-only parsing.html#cdata-section-end-state: title: 13.2.5.71 CDATA section end state - status: unknown + status: parsed-only parsing.html#processing-instruction-open-state: title: 13.2.5.72 Processing instruction open state - status: unknown + status: supported parsing.html#processing-instruction-target-state: title: 13.2.5.73 Processing instruction target state - status: unknown + status: supported parsing.html#after-processing-instruction-target-state: title: 13.2.5.74 After processing instruction target state - status: unknown + status: supported parsing.html#processing-instruction-data-state: title: 13.2.5.75 Processing instruction data state - status: unknown + status: supported parsing.html#processing-instruction-questionable-state: title: 13.2.5.76 Processing instruction questionable state - status: unknown + status: supported parsing.html#character-reference-state: title: 13.2.5.77 Character reference state - status: unknown + status: partial + gaps: + - Text is decoded a second time when it's split into words, so &amp;lt; renders as < instead of &lt; parsing.html#named-character-reference-state: title: 13.2.5.78 Named character reference state - status: unknown + status: supported parsing.html#ambiguous-ampersand-state: title: 13.2.5.79 Ambiguous ampersand state - status: unknown + status: supported parsing.html#numeric-character-reference-state: title: 13.2.5.80 Numeric character reference state - status: unknown + status: supported parsing.html#hexadecimal-character-reference-start-state: title: 13.2.5.81 Hexadecimal character reference start state - status: unknown + status: supported parsing.html#hexadecimal-character-reference-state: title: 13.2.5.82 Hexadecimal character reference state - status: unknown + status: partial + gaps: + - A hexadecimal character reference without a semicolon stays literal text in an attribute value parsing.html#decimal-character-reference-state: title: 13.2.5.83 Decimal character reference state - status: unknown + status: partial + gaps: + - A decimal character reference without a semicolon stays literal text in an attribute value parsing.html#numeric-character-reference-end-state: title: 13.2.5.84 Numeric character reference end state - status: unknown + status: partial + gaps: + - '&#13; stays literal text in an attribute value' parsing.html#tree-construction: title: 13.2.6 Tree construction status: partial parsing.html#creating-and-inserting-nodes: title: 13.2.6.1 Creating and inserting nodes - status: unknown + status: partial + gaps: + - No foster parenting, content misplaced in a table stays inside the table parsing.html#parsing-elements-that-contain-only-text: title: 13.2.6.2 Parsing elements that contain only text - status: unknown + status: supported parsing.html#closing-elements-that-have-implied-end-tags: title: 13.2.6.3 Closing elements that have implied end tags - status: unknown + status: partial + gaps: + - Only p, td and tr have implied end tags, rb, rp, rt and rtc don't close each other parsing.html#parsing-main-inhtml: title: 13.2.6.4 The rules for parsing tokens in HTML content status: partial parsing.html#the-initial-insertion-mode: title: 13.2.6.4.1 The "initial" insertion mode - status: unknown + status: partial + gaps: + - No quirks mode, a document without DOCTYPE is parsed as no-quirks (a table closes an open p) parsing.html#the-before-html-insertion-mode: title: 13.2.6.4.2 The "before html" insertion mode - status: unknown + status: partial + gaps: + - No html element is created when its start tag is missing parsing.html#the-before-head-insertion-mode: title: 13.2.6.4.3 The "before head" insertion mode - status: unknown + status: partial + gaps: + - No head element is created when its start tag is missing parsing.html#parsing-main-inhead: title: 13.2.6.4.4 The "in head" insertion mode - status: unknown + status: partial + gaps: + - Body content inside head stays in the hidden head instead of closing it parsing.html#parsing-main-inheadnoscript: title: 13.2.6.4.5 The "in head noscript" insertion mode - status: unknown + status: partial + gaps: + - Body content inside a noscript in head stays in the hidden head instead of closing it parsing.html#the-after-head-insertion-mode: title: 13.2.6.4.6 The "after head" insertion mode - status: unknown + status: partial + gaps: + - No body element is created when its start tag is missing parsing.html#parsing-main-inbody: title: 13.2.6.4.7 The "in body" insertion mode status: partial gaps: - A start tag that closes an open p only does so while the p is the current element, an inline element still open inside it keeps the p open + - center, dir, summary, listing, xmp, plaintext, li, dd and dt don't close an open p - li, dt and dd don't close the previous item - option doesn't close the previous option - An end tag without a matching open element closes the current element instead of being ignored - An end tag p without an open p doesn't insert an empty paragraph + - embed, keygen, source, track, wbr and bgsound aren't void, the content that follows ends up inside them + - isindex is treated as a void element + - A col start tag outside a table creates an element instead of being ignored + - A self-closing slash on a non-void element (<div/>) creates an empty element instead of being ignored + - An end tag br is ignored instead of inserting a br + - An image start tag isn't changed to img + - U+0000 in text isn't dropped + - A heading start tag doesn't close an open heading + - A button start tag doesn't close an open button + - A form start tag inside a form isn't ignored + - An a start tag doesn't close an open a + - No adoption agency algorithm, a misnested formatting end tag closes everything opened after it + - Formatting elements closed implicitly aren't reopened in the following content + - A second body start tag creates a nested body instead of adding its attributes to the first parsing.html#parsing-main-incdata: title: 13.2.6.4.8 The "text" insertion mode - status: unknown + status: supported parsing.html#parsing-main-intable: title: 13.2.6.4.9 The "in table" insertion mode status: partial gaps: - A tr outside of a table section doesn't get an implied tbody + - td and th outside a row don't get an implied tr + - col outside a colgroup doesn't get an implied colgroup + - A table start tag inside a table nests the table instead of closing the open one parsing.html#parsing-main-intabletext: title: 13.2.6.4.10 The "in table text" insertion mode - status: unknown + status: unsupported parsing.html#parsing-main-incaption: title: 13.2.6.4.11 The "in caption" insertion mode - status: unknown + status: partial + gaps: + - Table structure start tags (tbody, tr, td, ...) don't close an open caption parsing.html#parsing-main-incolgroup: title: 13.2.6.4.12 The "in column group" insertion mode - status: unknown + status: partial + gaps: + - Content other than col doesn't close an open colgroup parsing.html#parsing-main-intbody: title: 13.2.6.4.13 The "in table body" insertion mode status: partial @@ -1559,72 +1573,61 @@ sections: - thead, tbody and tfoot don't close the previous table section parsing.html#parsing-main-intr: title: 13.2.6.4.14 The "in row" insertion mode - status: unknown + status: partial + gaps: + - tbody, thead and tfoot don't close an open row parsing.html#parsing-main-intd: title: 13.2.6.4.15 The "in cell" insertion mode status: partial gaps: - th doesn't close the previous cell + - tbody, thead, tfoot, caption, col and colgroup don't close an open cell parsing.html#parsing-main-intemplate: title: 13.2.6.4.16 The "in template" insertion mode - status: unknown + status: unsupported parsing.html#parsing-main-afterbody: title: 13.2.6.4.17 The "after body" insertion mode - status: unknown + status: partial + gaps: + - Content after the body end tag goes into html instead of body parsing.html#parsing-main-inframeset: title: 13.2.6.4.18 The "in frameset" insertion mode - status: unknown + status: unsupported parsing.html#parsing-main-afterframeset: title: 13.2.6.4.19 The "after frameset" insertion mode - status: unknown + status: unsupported parsing.html#the-after-after-body-insertion-mode: title: 13.2.6.4.20 The "after after body" insertion mode - status: unknown + status: partial + gaps: + - Content after the html end tag ends up outside html instead of in body parsing.html#the-after-after-frameset-insertion-mode: title: 13.2.6.4.21 The "after after frameset" insertion mode - status: unknown + status: unsupported parsing.html#parsing-main-inforeign: title: 13.2.6.5 The rules for parsing tokens in foreign content - status: unknown + status: unsupported parsing.html#the-end: title: 13.2.7 The end - status: unknown + status: n/a parsing.html#speculative-html-parsing: title: 13.2.8 Speculative HTML parsing - status: unknown + status: n/a parsing.html#coercing-an-html-dom-into-an-infoset: title: 13.2.9 Coercing an HTML DOM into an infoset - status: unknown + status: n/a parsing.html#an-introduction-to-error-handling-and-strange-cases-in-the-parser: title: 13.2.10 An introduction to error handling and strange cases in the parser - status: unknown - parsing.html#misnested-tags:-b-i-/b-/i: - title: '13.2.10.1 Misnested tags: <b><i></b></i>' - status: unknown - parsing.html#misnested-tags:-b-p-/b-/p: - title: '13.2.10.2 Misnested tags: <b><p></b></p>' - status: unknown - parsing.html#unexpected-markup-in-tables: - title: 13.2.10.3 Unexpected markup in tables - status: unknown - parsing.html#scripts-that-modify-the-page-as-it-is-being-parsed: - title: 13.2.10.4 Scripts that modify the page as it is being parsed - status: unknown - parsing.html#the-execution-of-scripts-that-are-moving-across-multiple-documents: - title: 13.2.10.5 The execution of scripts that are moving across multiple documents - status: unknown - parsing.html#unclosed-formatting-elements: - title: 13.2.10.6 Unclosed formatting elements - status: unknown + status: n/a parsing.html#serialising-html-fragments: title: 13.3 Serializing HTML fragments - status: unknown + status: n/a parsing.html#parsing-html-fragments: title: 13.4 Parsing HTML fragments - status: unknown + status: n/a named-characters.html#named-character-references: title: 13.5 Named character references - status: unknown + status: supported rendering.html#rendering: title: 15 Rendering status: partial From f426883e2dd53e3a8facd43a8bfae48022c34e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Mon, 9 Nov 2026 18:35:25 +0100 Subject: [PATCH 048/150] Verify and document HTML rendering compliance --- Source/HtmlRenderer/Core/CssDefaults.cs | 9 +- Source/HtmlRenderer/Core/Parse/DomParser.cs | 16 +- .../PrintMediaTests.cs | 22 ++ .../HtmlRenderer.Test/Css/CascadeTests.cs | 3 + .../Html/FormRenderingTests.cs | 200 +++++++++++ .../Html/PhrasingRenderingTests.cs | 198 +++++++++++ .../HtmlRenderer.Test/Html/RenderingTests.cs | 323 +++++++++++++++++- .../Html/ReplacedElementRenderingTests.cs | 135 ++++++++ .../Html/TableRenderingTests.cs | 275 +++++++++++++++ Spec/html.yml | 174 +++++++--- 10 files changed, 1292 insertions(+), 63 deletions(-) create mode 100644 Source/Test/HtmlRenderer.PdfSharp.Test/PrintMediaTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Html/FormRenderingTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Html/PhrasingRenderingTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Html/ReplacedElementRenderingTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Html/TableRenderingTests.cs diff --git a/Source/HtmlRenderer/Core/CssDefaults.cs b/Source/HtmlRenderer/Core/CssDefaults.cs index 624830806..6e7ebbf93 100644 --- a/Source/HtmlRenderer/Core/CssDefaults.cs +++ b/Source/HtmlRenderer/Core/CssDefaults.cs @@ -15,7 +15,7 @@ namespace TheArtOfDev.HtmlRenderer.Core internal static class CssDefaults { /// <summary> - /// The user agent style sheet, based on CSS 2.1 Appendix D. + /// The user agent style sheet, based on CSS 2.1 Appendix D rather than the HTML §15 one. /// </summary> public const string DefaultStyleSheet = @" html, address, @@ -88,7 +88,7 @@ internal static class CssDefaults :link, :visited { text-decoration: underline } :focus { outline: thin dotted invert } - /* Begin bidirectionality settings (do not change) */ + /* HTML §15.3.5 */ BDO[DIR=""ltr""] { direction: ltr; unicode-bidi: bidi-override } BDO[DIR=""rtl""] { direction: rtl; unicode-bidi: bidi-override } @@ -106,7 +106,8 @@ where the group carries an avoid break-inside. */ thead, tfoot { break-inside: avoid } } - /* Not in the specification but necessary */ + /* Not in CSS 2.1 Appendix D: the hidden elements of HTML §15.3.1 and this renderer's own look + for links, tables, hr and pre */ a { color: #0055BB; text-decoration:underline } table { border-color:#dfdfdf; } td, th { border-color:#dfdfdf; overflow: hidden; } @@ -117,7 +118,7 @@ where the group carries an avoid break-inside. */ hr { border-top-color: #9A9A9A; border-left-color: #9A9A9A; border-bottom-color: #EEEEEE; border-right-color: #EEEEEE; } pre { font-size: 10pt; margin-top: 15px; } - /*This is the background of the HtmlToolTip*/ + /* The look of HtmlToolTip */ .htmltooltip { border:solid 1px #767676; background-color:#E4E5F0; diff --git a/Source/HtmlRenderer/Core/Parse/DomParser.cs b/Source/HtmlRenderer/Core/Parse/DomParser.cs index b9d2b3c12..2eda4eb10 100644 --- a/Source/HtmlRenderer/Core/Parse/DomParser.cs +++ b/Source/HtmlRenderer/Core/Parse/DomParser.cs @@ -915,7 +915,8 @@ private static (CssData cssData, bool cssDataChanged) CloneCssData(CssData cssDa } /// <summary> - /// Translates presentational HTML attributes to CSS, CSS 2.1 §6.4.4. + /// Maps presentational attributes to CSS, HTML §15. The cascade calls this after the author + /// rules, and the attributes are mapped on whatever element carries them. /// </summary> private void TranslateAttributes(HtmlTag tag, CssBox box) { @@ -1017,10 +1018,8 @@ private static string TranslateLength(string htmlLength) } /// <summary> - /// Cascades to the TD's the border spacified in the TABLE tag. + /// HTML §15.3.8, the border attribute of a table also gives its cells a border. /// </summary> - /// <param name="table"></param> - /// <param name="border"></param> private static void ApplyTableBorder(CssBox table, string border) { SetForAllCells(table, cell => @@ -1031,7 +1030,7 @@ private static void ApplyTableBorder(CssBox table, string border) } /// <summary> - /// Cascades the cellpadding of the TABLE tag to its TDs. + /// HTML §15.3.8, cellpadding is the padding of the table's cells. /// </summary> private static void ApplyTablePadding(CssBox table, string padding) { @@ -1040,11 +1039,10 @@ private static void ApplyTablePadding(CssBox table, string padding) } /// <summary> - /// Execute action on all the "td" cells of the table.<br/> - /// Handle if there is "theader" or "tbody" exists. + /// Runs <paramref name="action"/> on the cells of the rows that are children of the table or + /// of its row groups. Only td is recognized as a cell at the row level, anything else is + /// taken as a row group. /// </summary> - /// <param name="table">the table element</param> - /// <param name="action">the action to execute</param> private static void SetForAllCells(CssBox table, ActionInt<CssBox> action) { foreach (var l1 in table.Boxes) diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/PrintMediaTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/PrintMediaTests.cs new file mode 100644 index 000000000..7379686bc --- /dev/null +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/PrintMediaTests.cs @@ -0,0 +1,22 @@ +using PdfSharp; +using TheArtOfDev.HtmlRenderer.PdfSharp; + +namespace HtmlRenderer.PdfSharp.Test; + +// Parallel full layout passes race on the adapter singletons. +[TestClass] +[DoNotParallelize] +public sealed class PrintMediaTests +{ + [TestMethod] + [Spec("html", "rendering.html#print-media")] + public async Task Pdf_IsRenderedForPrintMedia() + { + const string html = "<html><head><style>div { height: 10pt } @media print { div { height: 1000pt } }</style></head>" + + "<body style='margin:0'><div></div></body></html>"; + + using var document = await PdfGenerator.GeneratePdf(html, new PdfGenerateConfig { PageSize = PageSize.A4 }); + + Assert.AreEqual(2, document.Pages.Count); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs b/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs index 09ead29e9..07b906e26 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs @@ -318,6 +318,7 @@ public void PseudoClass_CountsAsAClass() [TestMethod] [Spec("css21", "cascade.html#preshint")] + [Spec("html", "rendering.html#the-css-user-agent-style-sheet-and-presentational-hints")] public void PresentationalHint_BeatsTheDefaultStyleSheet() { var x = Box("<table><tr><th id=x align=left>a</th></tr></table>"); @@ -327,6 +328,7 @@ public void PresentationalHint_BeatsTheDefaultStyleSheet() [TestMethod] [Spec("css21", "cascade.html#preshint")] + [Spec("html", "rendering.html#the-css-user-agent-style-sheet-and-presentational-hints")] public void StyleAttribute_BeatsAPresentationalHint() { var x = Box("<font id=x color=red style='color:green'>a</font>"); @@ -337,6 +339,7 @@ public void StyleAttribute_BeatsAPresentationalHint() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "cascade.html#preshint")] + [Spec("html", "rendering.html#the-css-user-agent-style-sheet-and-presentational-hints")] public void PresentationalHint_LosesToAnAuthorRule() { Assert.AreEqual(Green, Box("<style>* { color: green }</style><font id=x color=red>a</font>").ActualColor); diff --git a/Source/Test/HtmlRenderer.Test/Html/FormRenderingTests.cs b/Source/Test/HtmlRenderer.Test/Html/FormRenderingTests.cs new file mode 100644 index 000000000..add1b6b6e --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/FormRenderingTests.cs @@ -0,0 +1,200 @@ +using HtmlRenderer.Test.TestSupport; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class FormRenderingTests +{ + [TestMethod] + [Spec("html", "rendering.html#form-controls")] + [DataRow("<input id=x>")] + [DataRow("<button id=x>a</button>")] + public void FormControls_AreInlineBlocks(string html) + { + Assert.AreEqual("inline-block", Box(html).Display); + } + + [TestMethod] + [Spec("html", "rendering.html#the-select-element-2")] + [Spec("html", "rendering.html#the-textarea-element-2")] + [DataRow("<select id=x size=4><option>a</option></select>")] + [DataRow("<textarea id=x>a</textarea>")] + public void ListBoxSelectAndTextarea_AreInlineBlocks(string html) + { + Assert.AreEqual("inline-block", Box(html).Display); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#form-controls")] + [DataRow("<button id=x>a</button>")] + [DataRow("<input id=x type=submit value=a>")] + public void Buttons_CenterTheirText(string html) + { + Assert.AreEqual("center", Box(html).TextAlign); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#form-controls")] + public void Textarea_PreservesWhiteSpace() + { + Assert.AreEqual("pre-wrap", Box("<textarea id=x>a b</textarea>").WhiteSpace); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#form-controls")] + [DataRow("<button id=x>a</button>")] + [DataRow("<select id=x><option>a</option></select>")] + public void ButtonsAndSelects_AreBorderBox(string html) + { + Assert.AreEqual("border-box", Box(html).BoxSizing); + } + + [TestMethod] + [Spec("html", "rendering.html#the-fieldset-and-legend-elements")] + public void Fieldset_IsABlock() + { + Assert.AreEqual("block", Box("<fieldset id=x>a</fieldset>").Display); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-fieldset-and-legend-elements")] + public void Fieldset_HasAGrooveBorderAndPadding() + { + var box = Box("<fieldset id=x style='font-size:16px'>a</fieldset>"); + + Assert.AreEqual("groove", box.BorderTopStyle); + Assert.AreEqual(2, box.ActualBorderTopWidth); + Assert.AreEqual(2, box.ActualMarginLeft); + Assert.AreEqual(0, box.ActualMarginTop); + Assert.AreEqual(12, box.ActualPaddingLeft, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-fieldset-and-legend-elements")] + public void Legend_IsRenderedOverTheTopBorder() + { + const string html = "<fieldset id=f style='border:10px solid;padding:0;margin:0'><legend id=x style='padding:0'>L</legend>b</fieldset>"; + var legend = Box(html); + + Assert.AreEqual("block", legend.Display); + Assert.IsTrue(legend.Location.Y < Box(html, "f").Location.Y + 10); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#native-appearance-2")] + [DataRow("<input id=x value=abc>")] + [DataRow("<input id=x type=checkbox>")] + [DataRow("<meter id=x value=0.5></meter>")] + [DataRow("<progress id=x value=0.5></progress>")] + public void Widgets_HaveANativeAppearance(string html) + { + var box = Box($"<div>{html}</div>"); + + Assert.IsTrue(box.Size.Width > 0 && box.Size.Height > 0); + } + + [TestMethod] + [Spec("html", "rendering.html#button-layout")] + [Spec("html", "rendering.html#the-button-element-2")] + public void Button_IsAnInlineBlockAroundItsContent() + { + var button = Box("<div><button id=x>Go</button></div>"); + + Assert.AreEqual("inline-block", button.Display); + Assert.AreEqual("Go", string.Concat(LayoutHarness.Descendants(button).SelectMany(b => b.Words).Select(w => w.Text))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#button-layout")] + public void BlockButton_IsFitContentWide() + { + Assert.IsTrue(Box("<div><button id=x style='display:block'>Go</button></div>").Size.Width < 100); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-button-element-2")] + public void Button_IsDepictedAsAButton() + { + Assert.AreNotEqual("none", Box("<button id=x>Go</button>").BorderTopStyle); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-details-and-summary-elements")] + [DataRow("<details id=x><summary>s</summary>b</details>", "block")] + [DataRow("<details open><summary id=x>s</summary>b</details>", "list-item")] + [DataRow("<details><summary>s</summary><p id=x>b</p></details>", "none")] + public void Details_IsABlockThatHidesItsContentUntilOpened(string html, string display) + { + Assert.AreEqual(display, Box(html).Display); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-input-element-as-a-text-entry-widget")] + [Spec("html", "rendering.html#the-input-element-as-a-button")] + [DataRow("<input id=x value=abc>")] + [DataRow("<input id=x type=submit value=abc>")] + public void Input_ShowsItsValue(string html) + { + var words = LayoutHarness.Descendants(Box(html)).Prepend(Box(html)).SelectMany(b => b.Words).Select(w => w.Text); + + Assert.AreEqual("abc", string.Concat(words)); + } + + [TestMethod] + [Spec("html", "rendering.html#the-marquee-element-2")] + public void MarqueeAttributes_SetItsSizeAndMargins() + { + var box = Box("<marquee id=x width=50 height=20 hspace=5 vspace=6>a</marquee>"); + + Assert.AreEqual("50px", box.Width); + Assert.AreEqual("20px", box.Height); + Assert.AreEqual(5, box.ActualMarginLeft); + Assert.AreEqual(6, box.ActualMarginTop); + } + + [TestMethod] + [Spec("html", "rendering.html#the-marquee-element-2")] + public void MarqueeBgcolor_SetsItsBackgroundColor() + { + Assert.AreEqual("red", Box("<marquee id=x bgcolor=red>a</marquee>").BackgroundColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-marquee-element-2")] + public void Marquee_IsAnInlineBlockThatHidesItsOverflow() + { + var box = Box("<marquee id=x>a</marquee>"); + + Assert.AreEqual("inline-block", box.Display); + Assert.AreEqual("hidden", box.Overflow); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-select-element-2")] + public void SelectOptions_AreBlocks() + { + Assert.AreEqual("block", Box("<select><option id=x>a</option></select>").Display); + Assert.AreEqual("block", Box("<select><optgroup id=x label=g><option>a</option></optgroup></select>").Display); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-textarea-element-2")] + public void TextareaWrapOff_DoesNotWrap() + { + Assert.AreEqual("pre", Box("<textarea id=x wrap=off>a</textarea>").WhiteSpace); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Html/PhrasingRenderingTests.cs b/Source/Test/HtmlRenderer.Test/Html/PhrasingRenderingTests.cs new file mode 100644 index 000000000..f4c7628fb --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/PhrasingRenderingTests.cs @@ -0,0 +1,198 @@ +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class PhrasingRenderingTests +{ + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + + /// <summary>Decorations are pushed down to the text, so they're read from the element's first child.</summary> + private static string Decoration(string html) => Box(html).Boxes[0].TextDecoration; + + private static double WordLeft(string html, string word) => + Box(html).Boxes.SelectMany(b => b.Boxes.Prepend(b)).SelectMany(b => b.Words).First(w => w.Text == word).Left; + + [TestMethod] + [Spec("html", "rendering.html#phrasing-content-3")] + [DataRow("cite")] + [DataRow("em")] + [DataRow("i")] + [DataRow("var")] + public void Emphasis_IsItalic(string tag) + { + Assert.AreEqual("italic", Box($"<{tag} id=x>a</{tag}>").FontStyle); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#phrasing-content-3")] + public void Dfn_IsItalic() + { + Assert.AreEqual("italic", Box("<dfn id=x>a</dfn>").FontStyle); + } + + [TestMethod] + [Spec("html", "rendering.html#phrasing-content-3")] + [DataRow("b")] + [DataRow("strong")] + public void StrongImportance_IsBold(string tag) + { + Assert.IsTrue(Box($"<{tag} id=x>a</{tag}>").ActualFontWeight >= 700); + } + + [TestMethod] + [Spec("html", "rendering.html#phrasing-content-3")] + public void BigAndSmall_ChangeTheFontSize() + { + var parent = Box("<div id=x style='font-size:16px'>a</div>").ActualFont.Size; + + Assert.IsTrue(Box("<div style='font-size:16px'><big id=x>a</big></div>").ActualFont.Size > parent); + Assert.IsTrue(Box("<div style='font-size:16px'><small id=x>a</small></div>").ActualFont.Size < parent); + } + + [TestMethod] + [Spec("html", "rendering.html#phrasing-content-3")] + [DataRow("sub")] + [DataRow("sup")] + public void SubAndSup_AreSmallerAndShifted(string tag) + { + var box = Box($"<div style='font-size:16px'><{tag} id=x>a</{tag}></div>"); + + Assert.AreEqual(tag == "sub" ? "sub" : "super", box.VerticalAlign); + Assert.IsTrue(box.ActualFont.Size < 12); + } + + [TestMethod] + [Spec("html", "rendering.html#phrasing-content-3")] + [DataRow("u", "underline")] + [DataRow("ins", "underline")] + [DataRow("del", "line-through")] + [DataRow("s", "line-through")] + [DataRow("strike", "line-through")] + [DataRow("a href=#", "underline")] + public void Edits_AreDecorated(string tag, string decoration) + { + Assert.AreEqual(decoration, Decoration($"<{tag} id=x>a</{tag.Split(' ')[0]}>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#phrasing-content-3")] + public void Links_AreBlueAndAnchorsWithoutHrefAreNot() + { + Assert.AreEqual(RColor.FromArgb(0, 0, 0xEE), Box("<a id=x href=#>a</a>").ActualColor); + Assert.AreEqual(RColor.FromArgb(0, 0, 0), Box("<a id=x>a</a>").ActualColor); + Assert.AreEqual("", Decoration("<a id=x>a</a>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#phrasing-content-3")] + public void Mark_HasAYellowBackground() + { + Assert.AreEqual(RColor.FromArgb(255, 255, 0), Box("<mark id=x>a</mark>").ActualBackgroundColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#phrasing-content-3")] + [DataRow("abbr")] + [DataRow("acronym")] + public void AbbreviationsWithATitle_AreUnderlined(string tag) + { + Assert.AreEqual("underline", Decoration($"<{tag} id=x title=t>a</{tag}>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#phrasing-content-3")] + public void Q_IsQuoted() + { + var words = Box("<q id=x>a</q>").Boxes.SelectMany(b => b.Words).Select(w => w.Text); + + Assert.AreEqual("“a”", string.Concat(words)); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#phrasing-content-3")] + public void Nobr_DoesNotWrap() + { + Assert.AreEqual("nowrap", Box("<nobr id=x>a b</nobr>").WhiteSpace); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#phrasing-content-3")] + [DataRow("left", "left")] + [DataRow("right", "right")] + [DataRow("all", "both")] + [DataRow("both", "both")] + public void BrClear_ClearsFloats(string value, string clear) + { + Assert.AreEqual(clear, Box($"<div style='float:left;width:10px;height:50px'></div>a<br id=x clear={value}>b").Clear); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#phrasing-content-3")] + public void RubyText_IsParenthesizedWithoutRubySupport() + { + var words = Box("<div id=x><ruby>a<rt>b</rt></ruby></div>").Boxes.SelectMany(b => b.Boxes).SelectMany(b => b.Boxes.Prepend(b)).SelectMany(b => b.Words).Select(w => w.Text); + + Assert.AreEqual("a(b)", string.Concat(words)); + } + + [TestMethod] + [Spec("html", "rendering.html#phrasing-content-3")] + [DataRow("red")] + [DataRow("#f00")] + public void FontColor_SetsTheColor(string color) + { + Assert.AreEqual(Red, Box($"<font id=x color='{color}'>a</font>").ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#phrasing-content-3")] + [DataRow("1", "x-small")] + [DataRow("2", "small")] + [DataRow("6", "xx-large")] + [DataRow("+1", "large")] + [DataRow("-1", "small")] + public void FontSize_IsALegacyFontSize(string size, string keyword) + { + Assert.AreEqual(Box($"<span id=x style='font-size:{keyword}'>a</span>").ActualFont.Size, Box($"<font id=x size='{size}'>a</font>").ActualFont.Size, 0.01); + } + + [TestMethod] + [Spec("html", "rendering.html#bidi-rendering")] + [DataRow("ltr")] + [DataRow("rtl")] + public void Dir_SetsTheDirection(string dir) + { + Assert.AreEqual(dir, Box($"<p id=x dir={dir}>a</p>").Direction); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#bidi-rendering")] + [DataRow("<p id=x dir=auto>&#1513;&#1500;&#1493;&#1501;</p>")] + [DataRow("<bdi id=x>&#1513;&#1500;&#1493;&#1501;</bdi>")] + public void DirAutoAndBdi_TakeTheDirectionOfTheirText(string html) + { + Assert.AreEqual("rtl", Box(html).Direction); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#bidi-rendering")] + public void Bdo_OverridesTheDirectionOfItsText() + { + const string html = "<div id=x style='width:300px' dir=rtl>a <bdo dir=ltr>b c</bdo></div>"; + + Assert.IsTrue(WordLeft(html, "b") < WordLeft(html, "c")); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Html/RenderingTests.cs b/Source/Test/HtmlRenderer.Test/Html/RenderingTests.cs index 5ce9e2717..e9078698a 100644 --- a/Source/Test/HtmlRenderer.Test/Html/RenderingTests.cs +++ b/Source/Test/HtmlRenderer.Test/Html/RenderingTests.cs @@ -1,3 +1,7 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Entities; using static HtmlRenderer.Test.Html.HtmlTestSupport; namespace HtmlRenderer.Test.Html; @@ -5,10 +9,45 @@ namespace HtmlRenderer.Test.Html; [TestClass] public sealed class RenderingTests { + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + + /// <summary>Lays out a whole document, without the zero body margin <see cref="Box"/> adds.</summary> + private static CssBox Body(string html) => + LayoutHarness.Descendants(LayoutHarness.Layout(html).Root).First(b => b.HtmlTag?.Name == "body"); + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-css-user-agent-style-sheet-and-presentational-hints")] + public void Attributes_AreOnlyMappedOnTheElementsTheyBelongTo() + { + Assert.AreEqual("auto", Box("<div id=x width=50>a</div>").Width); + Assert.AreEqual("normal", Box("<span id=x nowrap>a</span>").WhiteSpace); + Assert.AreEqual("transparent", Box("<span id=x bgcolor=red>a</span>").BackgroundColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-css-user-agent-style-sheet-and-presentational-hints")] + public void LegacyColors_WithoutAHash_AreParsed() + { + Assert.AreEqual(Red, Box("<table id=x bgcolor=ff0000><tr><td>a</td></tr></table>").ActualBackgroundColor); + Assert.AreEqual(RColor.FromArgb(192, 0, 0), Box("<table id=x bgcolor=chucknorris><tr><td>a</td></tr></table>").ActualBackgroundColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-css-user-agent-style-sheet-and-presentational-hints")] + public void DimensionValues_IgnoreTrailingGarbage() + { + Assert.AreEqual(100, Box("<table id=x width=100abc><tr><td>a</td></tr></table>").Size.Width, 0.01); + } + [TestMethod] [Spec("html", "rendering.html#hidden-elements")] [DataRow("<div><style id=x>p {}</style></div>")] [DataRow("<div><script id=x>a</script></div>")] + [DataRow("<div><area id=x></div>")] + [DataRow("<div><param id=x></div>")] public void MetadataAndScripts_AreNotDisplayed(string html) { Assert.AreEqual("none", Box(html).Display); @@ -18,16 +57,71 @@ public void MetadataAndScripts_AreNotDisplayed(string html) [Ignore("Gap, see Spec/html.yml")] [Spec("html", "rendering.html#hidden-elements")] [DataRow("<p id=x hidden>a</p>")] + [DataRow("<basefont id=x>")] [DataRow("<template id=x>a</template>")] [DataRow("<datalist id=x>a</datalist>")] [DataRow("<noembed id=x>a</noembed>")] + [DataRow("<noframes id=x>a</noframes>")] [DataRow("<rp id=x>a</rp>")] [DataRow("<dialog id=x>a</dialog>")] + [DataRow("<input id=x type=hidden>")] public void HiddenElements_AreNotDisplayed(string html) { Assert.AreEqual("none", Box(html).Display); } + [TestMethod] + [Spec("html", "rendering.html#the-page")] + public void Body_IsABlockWithAnEightPixelMargin() + { + var body = Body("<html><body>a</body></html>"); + + Assert.AreEqual("block", body.Display); + Assert.AreEqual(8, body.ActualMarginTop); + Assert.AreEqual(8, body.ActualMarginLeft); + } + + [TestMethod] + [Spec("html", "rendering.html#the-page")] + public void BodyBgcolor_SetsItsBackgroundColor() + { + Assert.AreEqual(Red, Body("<html><body bgcolor=red>a</body></html>").ActualBackgroundColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-page")] + public void BodyBackground_SetsItsBackgroundImage() + { + var image = Body("<html><body background=Img/A.png>a</body></html>").ActualBackgroundImage; + + Assert.AreEqual("Img/A.png", (image as CssImage.Url)?.Href); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-page")] + [DataRow("marginwidth=20 marginheight=10")] + [DataRow("leftmargin=20 topmargin=10")] + public void BodyMarginAttributes_SetItsMargins(string attributes) + { + var body = Body($"<html><body {attributes}>a</body></html>"); + + Assert.AreEqual(10, body.ActualMarginTop); + Assert.AreEqual(20, body.ActualMarginLeft); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-page")] + public void BodyTextAndLink_SetTheTextAndLinkColors() + { + var (root, _) = LayoutHarness.Layout("<html><body text=red link=lime><p id=p>a</p><a id=a href=#>b</a></body></html>"); + + Assert.AreEqual(Red, LayoutHarness.FindById(root, "p")!.ActualColor); + Assert.AreEqual(RColor.FromArgb(0, 255, 0), LayoutHarness.FindById(root, "a")!.ActualColor); + } + [TestMethod] [Spec("html", "rendering.html#flow-content-3")] [DataRow("address")] @@ -46,28 +140,32 @@ public void FlowContent_IsABlock(string tag) [TestMethod] [Ignore("Gap, see Spec/html.yml")] [Spec("html", "rendering.html#flow-content-3")] - [DataRow("article")] - [DataRow("aside")] - [DataRow("details")] [DataRow("figcaption")] [DataRow("figure")] [DataRow("footer")] [DataRow("header")] - [DataRow("hgroup")] [DataRow("legend")] [DataRow("listing")] [DataRow("main")] - [DataRow("nav")] [DataRow("plaintext")] [DataRow("search")] - [DataRow("section")] - [DataRow("summary")] [DataRow("xmp")] public void NewerFlowContent_IsABlock(string tag) { Assert.AreEqual("block", Box($"<{tag} id=x>a</{tag}>").Display); } + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#flow-content-3")] + [DataRow("listing")] + [DataRow("plaintext")] + [DataRow("xmp")] + public void PreLikeElements_PreserveWhiteSpace(string tag) + { + Assert.AreEqual("pre", Box($"<{tag} id=x>a</{tag}>").WhiteSpace); + } + [TestMethod] [Spec("html", "rendering.html#flow-content-3")] public void Blockquote_IsIndentedOnBothSides() @@ -93,6 +191,138 @@ public void Paragraphs_HaveOneEmVerticalMargins(string tag) Assert.AreEqual(16, box.ActualMarginBottom, 0.01); } + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#flow-content-3")] + public void Form_HasNoVerticalMargins() + { + var box = Box("<form id=x>a</form>"); + + Assert.AreEqual(0, box.ActualMarginTop); + Assert.AreEqual(0, box.ActualMarginBottom); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#flow-content-3")] + public void OpenDialog_IsAnAbsolutelyPositionedBorderedBlock() + { + var box = Box("<dialog id=x open>a</dialog>"); + + Assert.AreEqual("block", box.Display); + Assert.AreEqual("absolute", box.Position); + Assert.AreEqual("solid", box.BorderTopStyle); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#flow-content-3")] + public void Popover_IsNotDisplayedUntilOpened() + { + Assert.AreEqual("none", Box("<div id=x popover>a</div>").Display); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#flow-content-3")] + public void PreWrapAttribute_WrapsTheText() + { + Assert.AreEqual("pre-wrap", Box("<pre id=x wrap>a</pre>").WhiteSpace); + } + + [TestMethod] + [Spec("html", "rendering.html#flow-content-3")] + [DataRow("<center id=x>a</center>", "center")] + [DataRow("<div id=x align=center>a</div>", "center")] + [DataRow("<div id=x align=left>a</div>", "left")] + [DataRow("<div id=x align=right>a</div>", "right")] + [DataRow("<div id=x align=justify>a</div>", "justify")] + public void CenterAndDivAlign_AlignTheText(string html, string textAlign) + { + Assert.AreEqual(textAlign, Box(html).TextAlign); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#flow-content-3")] + public void DivAlignMiddle_CentersTheText() + { + Assert.AreEqual("center", Box("<div id=x align=middle>a</div>").TextAlign); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#flow-content-3")] + [DataRow("<center><div id=x style='width:100px'>a</div></center>")] + [DataRow("<div align=center><div id=x style='width:100px'>a</div></div>")] + public void CenterAndDivAlign_CenterDescendantBlocks(string html) + { + Assert.AreEqual(450, Box(html).Location.X, 0.01); + } + + [TestMethod] + [Spec("html", "rendering.html#sections-and-headings")] + [DataRow("h1", 32)] + [DataRow("h2", 24)] + [DataRow("h3", 18.72)] + [DataRow("h4", 16)] + [DataRow("h5", 13.28)] + public void Headings_HaveDecreasingFontSizes(string tag, double px) + { + var box = Box($"<div style='font-size:16px'><{tag} id=x>a</{tag}></div>"); + + Assert.AreEqual("block", box.Display); + Assert.AreEqual(px * 0.75, box.ActualFont.Size, 0.05); + Assert.IsTrue(box.ActualFontWeight >= 700); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#sections-and-headings")] + public void H6_IsTwoThirdsOfAnEm() + { + Assert.AreEqual(16 * 0.67 * 0.75, Box("<div style='font-size:16px'><h6 id=x>a</h6></div>").ActualFont.Size, 0.05); + } + + [TestMethod] + [Spec("html", "rendering.html#sections-and-headings")] + public void H1_HasTwoThirdsOfAnEmVerticalMargins() + { + var box = Box("<h1 id=x style='font-size:16px'>a</h1>"); + + Assert.AreEqual(16 * 0.67, box.ActualMarginTop, 0.01); + Assert.AreEqual(16 * 0.67, box.ActualMarginBottom, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#sections-and-headings")] + [DataRow("h2", 0.83)] + [DataRow("h3", 1.00)] + [DataRow("h4", 1.33)] + [DataRow("h5", 1.67)] + [DataRow("h6", 2.33)] + public void Headings_HaveTheirVerticalMargins(string tag, double em) + { + var box = Box($"<{tag} id=x style='font-size:16px'>a</{tag}>"); + + Assert.AreEqual(16 * em, box.ActualMarginTop, 0.01); + Assert.AreEqual(16 * em, box.ActualMarginBottom, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#sections-and-headings")] + [DataRow("article")] + [DataRow("aside")] + [DataRow("hgroup")] + [DataRow("nav")] + [DataRow("section")] + public void Sections_AreBlocks(string tag) + { + Assert.AreEqual("block", Box($"<{tag} id=x>a</{tag}>").Display); + } + [TestMethod] [Spec("html", "rendering.html#lists")] public void NestedUnorderedLists_SwitchToCircleThenSquare() @@ -103,6 +333,29 @@ public void NestedUnorderedLists_SwitchToCircleThenSquare() Assert.AreEqual("decimal", Box("<ol><li><ol><li id=x>a</li></ol></li></ol>").ListStyleType); } + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#lists")] + public void NestedMenusAndDirs_SwitchToCircle() + { + Assert.AreEqual("circle", Box("<menu><li><menu><li id=x>a</li></menu></li></menu>").ListStyleType); + Assert.AreEqual("circle", Box("<ul><li><dir><li id=x>a</li></dir></li></ul>").ListStyleType); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#lists")] + [DataRow("dl")] + [DataRow("dir")] + [DataRow("menu")] + public void ListsNestedInLists_HaveNoVerticalMargins(string tag) + { + var box = Box($"<ul><li><{tag} id=x><li>a</li></{tag}></li></ul>"); + + Assert.AreEqual(0, box.ActualMarginTop); + Assert.AreEqual(0, box.ActualMarginBottom); + } + [TestMethod] [Spec("html", "rendering.html#lists")] public void DescriptionDetails_AreIndented() @@ -110,6 +363,22 @@ public void DescriptionDetails_AreIndented() Assert.AreEqual(40, Box("<dl><dd id=x>a</dd></dl>").ActualMarginLeft); } + [TestMethod] + [Spec("html", "rendering.html#lists")] + public void StartAndValue_SetTheOrdinal() + { + Assert.AreEqual("5.", Marker("<ol start=5><li id=x>a</li></ol>")); + Assert.AreEqual("8.", Marker("<ol><li value=7>a</li><li id=x>b</li></ol>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#lists")] + public void Reversed_CountsDown() + { + Assert.AreEqual("1.", Marker("<ol reversed><li>a</li><li id=x>b</li></ol>")); + } + [TestMethod] [Ignore("Gap, see Spec/html.yml")] [Spec("html", "rendering.html#lists")] @@ -142,6 +411,13 @@ public void Hr_HasAnInsetOnePixelBorder() Assert.AreEqual(1, box.ActualBorderTopWidth); } + [TestMethod] + [Spec("html", "rendering.html#the-hr-element-2")] + public void HrWidth_SetsItsWidth() + { + Assert.AreEqual(50, Box("<hr id=x width=50>").Size.Width, 0.01); + } + [TestMethod] [Ignore("Gap, see Spec/html.yml")] [Spec("html", "rendering.html#the-hr-element-2")] @@ -153,4 +429,37 @@ public void Hr_IsCenteredWithHalfAnEmVerticalMargin() Assert.AreEqual(50, box.ActualMarginLeft, 0.01); Assert.AreEqual("gray", box.Color); } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-hr-element-2")] + [DataRow("center", 50)] + [DataRow("right", 100)] + public void HrAlign_SetsItsMargins(string align, double x) + { + Assert.AreEqual(x, Box($"<div style='width:200px'><hr id=x align={align} width=100></div>").Location.X, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-hr-element-2")] + public void HrSize_SetsTheHeightWithoutTheBorders() + { + Assert.AreEqual("8px", Box("<hr id=x size=10>").Height); + Assert.AreEqual(0, Box("<hr id=x size=1>").ActualBorderBottomWidth); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#the-hr-element-2")] + public void HrNoshadeAndColor_DrawASolidBorder() + { + var noshade = Box("<hr id=x noshade size=10>"); + Assert.AreEqual("solid", noshade.BorderTopStyle); + Assert.AreEqual(5, noshade.ActualBorderTopWidth); + + var colored = Box("<hr id=x color=red>"); + Assert.AreEqual("solid", colored.BorderTopStyle); + Assert.AreEqual(Red, colored.ActualBorderTopColor); + } } diff --git a/Source/Test/HtmlRenderer.Test/Html/ReplacedElementRenderingTests.cs b/Source/Test/HtmlRenderer.Test/Html/ReplacedElementRenderingTests.cs new file mode 100644 index 000000000..eff5b7dbd --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/ReplacedElementRenderingTests.cs @@ -0,0 +1,135 @@ +using HtmlRenderer.Test.TestSupport; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class ReplacedElementRenderingTests +{ + private static string Text(string html) => + string.Concat(LayoutHarness.Descendants(Box(html)).Prepend(Box(html)).SelectMany(b => b.Words).Where(w => !w.IsImage).Select(w => w.Text)); + + [TestMethod] + [Spec("html", "rendering.html#embedded-content-rendering-rules")] + [Spec("html", "rendering.html#attributes-for-embedded-content-and-images")] + public void Iframe_IsSizedByItsAttributes() + { + var box = Box("<div><iframe id=x width=50 height=60 style='display:block'></iframe></div>"); + + Assert.AreEqual(50 + 4, box.Size.Width, 0.01); + Assert.AreEqual(60 + 4, box.Size.Height, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#embedded-content-rendering-rules")] + public void Iframe_HasAnInsetBorderAndTheDefaultObjectSize() + { + var box = Box("<div><iframe id=x style='display:block'></iframe></div>"); + + Assert.AreEqual("inset", box.BorderTopStyle); + Assert.AreEqual(300 + 4, box.Size.Width, 0.01); + Assert.AreEqual(150 + 4, box.Size.Height, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#embedded-content-rendering-rules")] + [DataRow("<video id=x width=50 height=60>fallback</video>")] + [DataRow("<object id=x data=a.png width=50 height=60>fallback</object>")] + public void VideoAndObject_AreReplacedElements(string html) + { + Assert.AreEqual("", Text(html)); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#embedded-content-rendering-rules")] + public void AudioWithoutControls_IsNotDisplayed() + { + Assert.AreEqual("none", Box("<audio id=x></audio>").Display); + } + + [TestMethod] + [Spec("html", "rendering.html#images-3")] + [Spec("html", "rendering.html#attributes-for-embedded-content-and-images")] + public void Img_IsAReplacedElementSizedByItsAttributes() + { + var box = Box("<img id=x width=50 height=60>"); + + Assert.IsInstanceOfType<TheArtOfDev.HtmlRenderer.Core.Dom.CssBoxImage>(box); + Assert.AreEqual("50px", box.Width); + Assert.AreEqual("60px", box.Height); + Assert.AreEqual("50%", Box("<img id=x width=50%>").Width); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#images-3")] + public void BrokenImage_ShowsItsAltText() + { + Assert.AreEqual("hello", Text("<img id=x src=missing.png alt=hello>")); + } + + [TestMethod] + [Spec("html", "rendering.html#attributes-for-embedded-content-and-images")] + public void HspaceAndVspace_SetTheMargins() + { + var box = Box("<img id=x hspace=5 vspace=6 width=5 height=5>"); + + Assert.AreEqual(5, box.ActualMarginLeft); + Assert.AreEqual(5, box.ActualMarginRight); + Assert.AreEqual(6, box.ActualMarginTop); + Assert.AreEqual(6, box.ActualMarginBottom); + } + + [TestMethod] + [Spec("html", "rendering.html#attributes-for-embedded-content-and-images")] + public void ImgBorder_IsASolidBorder() + { + var box = Box("<img id=x border=2 width=5 height=5>"); + + Assert.AreEqual("solid", box.BorderTopStyle); + Assert.AreEqual(2, box.ActualBorderTopWidth); + } + + [TestMethod] + [Spec("html", "rendering.html#attributes-for-embedded-content-and-images")] + [DataRow("top", "top")] + [DataRow("middle", "middle")] + [DataRow("bottom", "bottom")] + [DataRow("baseline", "baseline")] + public void ImgAlign_SetsTheVerticalAlignment(string align, string verticalAlign) + { + Assert.AreEqual(verticalAlign, Box($"<img id=x align={align} width=5 height=5>").VerticalAlign); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#attributes-for-embedded-content-and-images")] + [DataRow("texttop", "text-top")] + [DataRow("absmiddle", "middle")] + [DataRow("abscenter", "middle")] + public void ImgAlignLegacyValues_SetTheVerticalAlignment(string align, string verticalAlign) + { + Assert.AreEqual(verticalAlign, Box($"<img id=x align={align} width=5 height=5>").VerticalAlign); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#attributes-for-embedded-content-and-images")] + [DataRow("left")] + [DataRow("right")] + public void ImgAlignLeftAndRight_FloatTheImage(string align) + { + Assert.AreEqual(align, Box($"<img id=x align={align} width=5 height=5>").Float); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#attributes-for-embedded-content-and-images")] + public void IframeFrameborderZero_RemovesTheBorder() + { + Assert.AreEqual(0, Box("<iframe id=x frameborder=0></iframe>").ActualBorderTopWidth); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Html/TableRenderingTests.cs b/Source/Test/HtmlRenderer.Test/Html/TableRenderingTests.cs new file mode 100644 index 000000000..c0aedaaf9 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/TableRenderingTests.cs @@ -0,0 +1,275 @@ +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class TableRenderingTests +{ + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + + [TestMethod] + [Spec("html", "rendering.html#tables-2")] + [DataRow("<table id=x><tr><td>a</td></tr></table>", "table")] + [DataRow("<table><caption id=x>a</caption><tr><td>a</td></tr></table>", "table-caption")] + [DataRow("<table><colgroup id=x><col></colgroup><tr><td>a</td></tr></table>", "table-column-group")] + [DataRow("<table><col id=x><tr><td>a</td></tr></table>", "table-column")] + [DataRow("<table><thead id=x><tr><td>a</td></tr></thead></table>", "table-header-group")] + [DataRow("<table><tbody id=x><tr><td>a</td></tr></tbody></table>", "table-row-group")] + [DataRow("<table><tfoot id=x><tr><td>a</td></tr></tfoot></table>", "table-footer-group")] + [DataRow("<table><tr id=x><td>a</td></tr></table>", "table-row")] + [DataRow("<table><tr><td id=x>a</td></tr></table>", "table-cell")] + [DataRow("<table><tr><th id=x>a</th></tr></table>", "table-cell")] + public void TableElements_HaveTheirTableDisplay(string html, string display) + { + Assert.AreEqual(display, Box(html).Display); + } + + [TestMethod] + [Spec("html", "rendering.html#tables-2")] + public void Table_HasTwoPixelBorderSpacing() + { + Assert.AreEqual(2, Box("<table id=x><tr><td>a</td></tr></table>").ActualBorderSpacingHorizontal); + } + + [TestMethod] + [Spec("html", "rendering.html#tables-2")] + public void Th_IsBoldAndCentered() + { + var box = Box("<table><tr><th id=x>a</th></tr></table>"); + + Assert.IsTrue(box.ActualFontWeight >= 700); + Assert.AreEqual("center", box.TextAlign); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#tables-2")] + public void Th_IsOnlyCenteredWhenTheRowHasTheInitialTextAlign() + { + Assert.AreEqual("right", Box("<div style='text-align:right'><table><tr><th id=x>a</th></tr></table></div>").TextAlign); + } + + [TestMethod] + [Spec("html", "rendering.html#tables-2")] + public void RowsAndCells_AreVerticallyCentered() + { + Assert.AreEqual("middle", Box("<table><tbody id=x><tr><td>a</td></tr></tbody></table>").VerticalAlign); + Assert.AreEqual("middle", Box("<table><tr><td id=x>a</td></tr></table>").VerticalAlign); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#tables-2")] + public void Cells_HaveOnePixelPaddingAndVisibleOverflow() + { + var box = Box("<table><tr><td id=x>a</td></tr></table>"); + + Assert.AreEqual(1, box.ActualPaddingTop); + Assert.AreEqual(1, box.ActualPaddingLeft); + Assert.AreEqual("visible", box.Overflow); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#tables-2")] + public void TableBorders_UseTheTextColor() + { + var html = "<div style='color:red'><table id=x border=1><tr><td id=c>a</td></tr></table></div>"; + + Assert.AreEqual(Red, Box(html).ActualBorderTopColor); + Assert.AreEqual(Red, Box(html, "c").ActualBorderTopColor); + } + + [TestMethod] + [Spec("html", "rendering.html#tables-2")] + public void Cellspacing_SetsTheBorderSpacing() + { + Assert.AreEqual(5, Box("<table id=x cellspacing=5><tr><td>a</td></tr></table>").ActualBorderSpacingHorizontal); + } + + [TestMethod] + [Spec("html", "rendering.html#tables-2")] + [DataRow("<table cellpadding=7><tr><td id=x>a</td></tr></table>")] + [DataRow("<table cellpadding=7><thead><tr><th id=x>a</th></tr></thead></table>")] + [DataRow("<table cellpadding=7><tbody><tr><td id=x>a</td></tr></tbody></table>")] + public void Cellpadding_SetsThePaddingOfTheCells(string html) + { + var box = Box(html); + + Assert.AreEqual(7, box.ActualPaddingTop); + Assert.AreEqual(7, box.ActualPaddingLeft); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#tables-2")] + public void Cellpadding_ReachesThCellsOutsideARowGroup() + { + Assert.AreEqual(7, Box("<table cellpadding=7><tr><th id=x>a</th></tr></table>").ActualPaddingTop); + } + + [TestMethod] + [Spec("html", "rendering.html#tables-2")] + public void DimensionAttributes_SetTheSizes() + { + var table = Box("<table id=x width=300 height=100><tr><td>a</td></tr></table>"); + Assert.AreEqual("300px", table.Width); + Assert.AreEqual("100px", table.Height); + Assert.AreEqual("50%", Box("<table id=x width=50%><tr><td>a</td></tr></table>").Width); + Assert.AreEqual("80px", Box("<table><col id=x width=80><tr><td>a</td></tr></table>").Width); + Assert.AreEqual("40px", Box("<table><tr id=x height=40><td>a</td></tr></table>").Height); + + var cell = Box("<table><tr><td id=x width=120 height=40>a</td></tr></table>"); + Assert.AreEqual(120, cell.Size.Width, 0.01); + Assert.AreEqual(40, cell.Size.Height, 0.01); + } + + [TestMethod] + [Spec("html", "rendering.html#tables-2")] + [DataRow("<table id=x bgcolor=red><tr><td>a</td></tr></table>")] + [DataRow("<table><tbody id=x bgcolor=red><tr><td>a</td></tr></tbody></table>")] + [DataRow("<table><tr id=x bgcolor=red><td>a</td></tr></table>")] + [DataRow("<table><tr><td id=x bgcolor=red>a</td></tr></table>")] + [DataRow("<table><tr><th id=x bgcolor=red>a</th></tr></table>")] + public void Bgcolor_SetsTheBackgroundColor(string html) + { + Assert.AreEqual(Red, Box(html).ActualBackgroundColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#tables-2")] + public void Background_SetsTheBackgroundImage() + { + Assert.IsNotNull(Box("<table id=x background=a.png><tr><td>a</td></tr></table>").ActualBackgroundImage); + } + + [TestMethod] + [Spec("html", "rendering.html#tables-2")] + public void Bordercolor_SetsTheBorderColor() + { + Assert.AreEqual(Red, Box("<table id=x bordercolor=red border=1><tr><td>a</td></tr></table>").ActualBorderTopColor); + } + + [TestMethod] + [Spec("html", "rendering.html#tables-2")] + public void Border_SetsTheTableBorderWidthAndOnePixelCellBorders() + { + Assert.AreEqual(3, Box("<table id=x border=3><tr><td>a</td></tr></table>").ActualBorderTopWidth); + Assert.AreEqual(1, Box("<table border=3><tr><td id=x>a</td></tr></table>").ActualBorderTopWidth); + Assert.AreEqual(0, Box("<table border=0><tr><td id=x>a</td></tr></table>").ActualBorderTopWidth); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#tables-2")] + public void Border_IsOutsetOnTheTableAndInsetOnTheCells() + { + Assert.AreEqual("outset", Box("<table id=x border=3><tr><td>a</td></tr></table>").BorderTopStyle); + Assert.AreEqual("inset", Box("<table border=3><tr><td id=x>a</td></tr></table>").BorderTopStyle); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#tables-2")] + public void BorderWithoutAValue_IsOnePixel() + { + Assert.AreEqual(1, Box("<table id=x border><tr><td>a</td></tr></table>").ActualBorderTopWidth); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#tables-2")] + public void RulesAndFrame_SetTheBorders() + { + var rules = Box("<table rules=all><tr><td id=x>a</td></tr></table>"); + Assert.AreEqual("solid", rules.BorderTopStyle); + Assert.AreEqual(1, rules.ActualBorderTopWidth); + Assert.AreEqual("collapse", Box("<table id=x rules=all><tr><td>a</td></tr></table>").BorderCollapse); + + Assert.AreEqual("hidden", Box("<table id=x frame=void border=1><tr><td>a</td></tr></table>").BorderTopStyle); + } + + [TestMethod] + [Spec("html", "rendering.html#tables-2")] + [DataRow("<table><tr id=x align=right><td>a</td></tr></table>", "right")] + [DataRow("<table><tr><td id=x align=center>a</td></tr></table>", "center")] + [DataRow("<table><tr><th id=x align=left>a</th></tr></table>", "left")] + [DataRow("<table><tbody id=x align=justify><tr><td>a</td></tr></tbody></table>", "justify")] + public void RowAndCellAlign_AlignsTheText(string html, string textAlign) + { + Assert.AreEqual(textAlign, Box(html).TextAlign); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#tables-2")] + [DataRow("middle")] + [DataRow("absmiddle")] + public void RowAndCellAlignMiddle_CentersTheText(string align) + { + Assert.AreEqual("center", Box($"<table><tr><td id=x align={align}>a</td></tr></table>").TextAlign); + } + + [TestMethod] + [Spec("html", "rendering.html#tables-2")] + [DataRow("<table><tr><td id=x valign=top>a</td></tr></table>", "top")] + [DataRow("<table><tr id=x valign=bottom><td>a</td></tr></table>", "bottom")] + [DataRow("<table><tbody id=x valign=baseline><tr><td>a</td></tr></tbody></table>", "baseline")] + public void Valign_SetsTheVerticalAlignment(string html, string verticalAlign) + { + Assert.AreEqual(verticalAlign, Box(html).VerticalAlign); + } + + [TestMethod] + [Spec("html", "rendering.html#tables-2")] + public void Nowrap_PreventsWrapping() + { + Assert.AreEqual("nowrap", Box("<table><tr><td id=x nowrap>a</td></tr></table>").WhiteSpace); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#tables-2")] + public void TableAlignLeftAndRight_FloatTheTable() + { + Assert.AreEqual("left", Box("<table id=x align=left><tr><td>a</td></tr></table>").Float); + Assert.AreEqual("right", Box("<table id=x align=right><tr><td>a</td></tr></table>").Float); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#tables-2")] + public void TableAlignCenter_CentersTheTable() + { + Assert.AreEqual(100, Box("<div style='width:400px'><table id=x align=center style='width:200px'><tr><td>a</td></tr></table></div>").Location.X, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#tables-2")] + public void Caption_IsRenderedAndCentered() + { + var box = Box("<table><caption id=x>a</caption><tr><td>a</td></tr></table>"); + + Assert.AreEqual("center", box.TextAlign); + Assert.IsTrue(box.Size.Height > 0); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#tables-2")] + public void HiddenRows_AreCollapsed() + { + Assert.AreEqual("collapse", Box("<table><tr id=x hidden><td>a</td></tr></table>").Visibility); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "rendering.html#tables-2")] + public void FormInATable_IsNotDisplayed() + { + Assert.AreEqual("none", Box("<table><form id=x></form><tr><td>a</td></tr></table>").Display); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 405ead357..5a863fea5 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1633,10 +1633,15 @@ sections: status: partial rendering.html#introduction-17: title: 15.1 Introduction - status: unknown + status: n/a rendering.html#the-css-user-agent-style-sheet-and-presentational-hints: title: 15.2 The CSS user agent style sheet and presentational hints - status: unknown + status: partial + gaps: + - Presentational hints override author style sheets, see CSS 2.1 6.4.4 + - Attributes are mapped on every element, e.g. width on div, nowrap on span or bgcolor on span + - Legacy color values are parsed as CSS colors, so bgcolor=ff0000 or bgcolor=chucknorris are ignored + - Dimension values with trailing garbage, e.g. width=100abc, are ignored instead of read as 100 rendering.html#non-replaced-elements: title: 15.3 Non-replaced elements status: partial @@ -1645,141 +1650,224 @@ sections: status: partial gaps: - The hidden attribute is ignored - - template, datalist, noembed, rp and dialog without open are displayed + - basefont, template, datalist, noembed, noframes, rp, dialog without open and input type=hidden are displayed rendering.html#the-page: title: 15.3.2 The page - status: unknown + status: partial + gaps: + - marginwidth, marginheight, leftmargin and topmargin on body are ignored + - text, link, vlink and alink on body are ignored + - The background attribute isn't wrapped in url(), so it shows no image rendering.html#flow-content-3: title: 15.3.3 Flow content status: partial gaps: - - article, aside, details, figcaption, figure, footer, header, hgroup, legend, listing, main, nav, plaintext, search, section, summary and xmp are inline + - figcaption, figure, footer, header, legend, listing, main, plaintext, search and xmp are inline + - listing, plaintext and xmp don't preserve white space - p and blockquote have 1.12em vertical margins instead of 1em, pre has 15px above and nothing below, figure has no margins + - form has 1.12em vertical margins, it has none outside quirks mode + - An open dialog is inline instead of an absolutely positioned, bordered block, popover is ignored + - The wrap attribute of pre is ignored + - div align=middle sets vertical-align instead of centering the text + - center and div align don't align descendant blocks, see CSS 2.1 10.3.3 rendering.html#phrasing-content-3: title: 15.3.4 Phrasing content - status: unknown + status: partial + gaps: + - dfn isn't italic + - "Links are #0055BB instead of #0000EE, also on a without href" + - mark has no yellow background + - abbr and acronym with a title aren't underlined + - q has no quotation marks + - nobr doesn't prevent wrapping + - The clear attribute of br is ignored + - Ruby isn't laid out, rt follows its base inline without parentheses + - The size attribute of font is ignored rendering.html#bidi-rendering: title: 15.3.5 Bidirectional text - status: unknown + status: partial + gaps: + - dir=auto and bdi don't take the direction of their text + - "'unicode-bidi' is ignored, so bdo doesn't override and bdi and dir don't isolate, see CSS 2.1 9.10" rendering.html#sections-and-headings: title: 15.3.6 Sections and headings - status: unknown + status: partial + gaps: + - article, aside, hgroup, nav and section are inline + - h2 to h6 have the CSS 2.1 margins, e.g. .83em instead of 1em on h3, and h6 is .75em instead of .67em rendering.html#lists: title: 15.3.7 Lists status: partial gaps: - ol, ul and menu are indented with margin-left instead of padding-inline-start - The type attribute of ol, ul and li is ignored + - The reversed attribute of ol is ignored + - dir, dl and menu nested in a list keep their vertical margins, nested dir and menu don't switch to circle rendering.html#tables-2: title: 15.3.8 Tables - status: unknown + status: partial + gaps: + - td and th have no 1px padding and get overflow hidden + - "Tables and cells have a #dfdfdf border color instead of the text color" + - th is centered also when the row's text-align isn't the initial value + - Captions aren't rendered, see CSS 2.1 17.4.1 + - cellpadding doesn't reach th cells of a row outside a row group + - The background attribute isn't wrapped in url(), so it shows no image + - border gives the table and its cells solid instead of outset and inset borders, border without a value draws none + - rules and frame are ignored + - align=middle and align=absmiddle on rows and cells set vertical-align instead of centering the text + - table align=left and right set text-align instead of floating, align=center doesn't center a table with a width + - hidden on rows, row groups and columns doesn't collapse them + - A form that's a child of table, thead, tbody, tfoot or tr is displayed rendering.html#margin-collapsing-quirks: title: 15.3.9 Margin collapsing quirks - status: unknown + status: unsupported + gaps: + - There's no quirks mode, neither these rules nor the other quirks-mode rules of 15.3 apply rendering.html#form-controls: title: 15.3.10 Form controls - status: unknown + status: partial + gaps: + - button and input buttons don't center their text + - textarea doesn't preserve white space + - button and select aren't border-box rendering.html#the-hr-element-2: title: 15.3.11 The hr element status: partial gaps: - hr isn't centered, has no vertical margins and isn't gray + - align sets text-align instead of the margins + - size sets the height including the borders, noshade is ignored and color doesn't make the border solid rendering.html#the-fieldset-and-legend-elements: title: 15.3.12 The fieldset and legend elements - status: unknown + status: partial + gaps: + - fieldset has 1.12em vertical margins and no groove border, padding or 2px inline margins + - legend is an inline box in the content instead of being rendered over the top border rendering.html#replaced-elements: title: 15.4 Replaced elements - status: unknown + status: partial rendering.html#embedded-content-rendering-rules: title: 15.4.1 Embedded content - status: unknown + status: partial + gaps: + - iframe content isn't loaded (YouTube and Vimeo get a thumbnail), an iframe is drawn as a small error icon with a solid gray border instead of 300x150 with a 2px inset border + - embed, object, video, audio and canvas aren't replaced elements, object and video show their fallback content + - audio without controls is displayed rendering.html#images-3: title: 15.4.2 Images - status: unknown + status: partial + gaps: + - An unavailable image is drawn as an error icon instead of its alt text rendering.html#attributes-for-embedded-content-and-images: title: 15.4.3 Attributes for embedded content and images - status: unknown + status: partial + gaps: + - align=left and right set text-align instead of floating + - align=texttop, absmiddle and abscenter aren't mapped + - The frameborder attribute of iframe is ignored rendering.html#image-maps-2: title: 15.4.4 Image maps - status: unknown + status: unsupported rendering.html#widgets: title: 15.5 Widgets - status: unknown + status: partial rendering.html#native-appearance-2: title: 15.5.1 Native appearance - status: unknown + status: unsupported + gaps: + - Widgets aren't drawn, only their content is laid out rendering.html#writing-mode: title: 15.5.2 Writing mode - status: unknown + status: n/a rendering.html#button-layout: title: 15.5.3 Button layout - status: unknown + status: partial + gaps: + - A button with display block is as wide as its container instead of fit-content + - The line box doesn't grow to cover the bottom padding of a button rendering.html#the-button-element-2: title: 15.5.4 The button element - status: unknown + status: partial + gaps: + - It isn't depicted as a button, only its content is drawn rendering.html#the-details-and-summary-elements: title: 15.5.5 The details and summary elements - status: unknown + status: unsupported + gaps: + - details and summary are inline, summary has no marker and the content of a closed details is displayed rendering.html#the-input-element-as-a-text-entry-widget: title: 15.5.6 The input element as a text entry widget - status: unknown + status: unsupported + gaps: + - An empty inline-block without the value rendering.html#the-input-element-as-domain-specific-widgets: title: 15.5.7 The input element as domain-specific widgets - status: unknown + status: unsupported rendering.html#the-input-element-as-a-range-control: title: 15.5.8 The input element as a range control - status: unknown + status: unsupported rendering.html#the-input-element-as-a-colour-well: title: 15.5.9 The input element as a color well - status: unknown + status: unsupported rendering.html#the-input-element-as-a-checkbox-and-radio-button-widgets: title: 15.5.10 The input element as a checkbox and radio button widgets - status: unknown + status: unsupported rendering.html#the-input-element-as-a-file-upload-control: title: 15.5.11 The input element as a file upload control - status: unknown + status: unsupported rendering.html#the-input-element-as-a-button: title: 15.5.12 The input element as a button - status: unknown + status: unsupported + gaps: + - An empty inline-block without the value rendering.html#the-marquee-element-2: title: 15.5.13 The marquee element - status: unknown + status: partial + gaps: + - marquee is inline instead of an inline-block with hidden overflow rendering.html#the-meter-element-2: title: 15.5.14 The meter element - status: unknown + status: unsupported rendering.html#the-progress-element-2: title: 15.5.15 The progress element - status: unknown + status: unsupported rendering.html#the-select-element-2: title: 15.5.16 The select element - status: unknown + status: unsupported + gaps: + - An inline-block with all options inline, no drop-down or list box rendering.html#the-textarea-element-2: title: 15.5.17 The textarea element - status: unknown + status: unsupported + gaps: + - An inline-block with the text, white space collapsed and wrap ignored rendering.html#frames-and-framesets: title: 15.6 Frames and framesets - status: unknown + status: unsupported + gaps: + - frameset and frame are empty blocks, frames aren't loaded rendering.html#interactive-media: title: 15.7 Interactive media - status: unknown + status: unsupported rendering.html#links,-forms,-and-navigation: title: 15.7.1 Links, forms, and navigation - status: unknown + status: n/a rendering.html#the-title-attribute-2: title: 15.7.2 The title attribute - status: unknown + status: unsupported rendering.html#editing-hosts: title: 15.7.3 Editing hosts - status: unknown + status: n/a rendering.html#text-rendered-in-native-user-interfaces: title: 15.7.4 Text rendered in native user interfaces - status: unknown + status: n/a rendering.html#print-media: title: 15.8 Print media - status: unknown + status: supported rendering.html#unstyled-xml-documents: title: 15.9 Unstyled XML documents - status: unknown + status: n/a obsolete.html#obsolete: title: 16 Obsolete features status: unknown From 0b0072a49d2310d2d54f63317b89ac27c5cc5780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Mon, 9 Nov 2026 18:35:39 +0100 Subject: [PATCH 049/150] Verify and document HTML obsolete features compliance --- .../Html/ObsoleteFeaturesTests.cs | 47 +++++++++++++++++++ Spec/html.yml | 23 +++++---- 2 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/ObsoleteFeaturesTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Html/ObsoleteFeaturesTests.cs b/Source/Test/HtmlRenderer.Test/Html/ObsoleteFeaturesTests.cs new file mode 100644 index 000000000..709f2fe77 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/ObsoleteFeaturesTests.cs @@ -0,0 +1,47 @@ +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class ObsoleteFeaturesTests +{ + [TestMethod] + [Spec("html", "obsolete.html#other-elements,-attributes-and-apis")] + public void Dir_IsRenderedLikeUl() + { + var dir = Box("<dir id=x><li>a</li></dir>"); + var ul = Box("<ul id=x><li>a</li></ul>"); + + Assert.AreEqual(ul.Display, dir.Display); + Assert.AreEqual(ul.ActualMarginLeft, dir.ActualMarginLeft); + Assert.AreEqual(ul.ActualMarginTop, dir.ActualMarginTop); + Assert.AreEqual(Marker("<ul><li id=x>a</li></ul>"), Marker("<dir><li id=x>a</li></dir>")); + } + + [TestMethod] + [Spec("html", "obsolete.html#other-elements,-attributes-and-apis")] + public void Acronym_IsRenderedLikeAbbr() + { + var acronym = Box("<acronym id=x title=t>a</acronym>"); + var abbr = Box("<abbr id=x title=t>a</abbr>"); + + Assert.AreEqual(abbr.Display, acronym.Display); + Assert.AreEqual(abbr.Boxes[0].TextDecoration, acronym.Boxes[0].TextDecoration); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "obsolete.html#other-elements,-attributes-and-apis")] + [DataRow("listing")] + [DataRow("plaintext")] + [DataRow("xmp")] + public void PreLikeElements_AreRenderedLikePre(string tag) + { + var box = Box($"<{tag} id=x>a</{tag}>"); + var pre = Box("<pre id=x>a</pre>"); + + Assert.AreEqual(pre.Display, box.Display); + Assert.AreEqual(pre.WhiteSpace, box.WhiteSpace); + Assert.AreEqual(pre.ActualFont.Size, box.ActualFont.Size); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 5a863fea5..e9001dc34 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1870,25 +1870,28 @@ sections: status: n/a obsolete.html#obsolete: title: 16 Obsolete features - status: unknown + status: partial obsolete.html#obsolete-but-conforming-features: title: 16.1 Obsolete but conforming features - status: unknown - obsolete.html#warnings-for-obsolete-but-conforming-features: - title: 16.1.1 Warnings for obsolete but conforming features - status: unknown + status: n/a obsolete.html#non-conforming-features: title: 16.2 Non-conforming features - status: unknown + status: n/a obsolete.html#requirements-for-implementations: title: 16.3 Requirements for implementations - status: unknown + status: partial obsolete.html#the-marquee-element: title: 16.3.1 The marquee element - status: unknown + status: unsupported + gaps: + - marquee isn't animated, behavior, direction, loop, scrollamount, scrolldelay and truespeed are ignored obsolete.html#frames: title: 16.3.2 Frames - status: unknown + status: unsupported + gaps: + - frameset doesn't act as the body and frames aren't loaded, see 15.6 obsolete.html#other-elements,-attributes-and-apis: title: 16.3.3 Other elements, attributes and APIs - status: unknown + status: partial + gaps: + - listing, plaintext and xmp aren't rendered like pre, see 15.3.3 From 6ecae33ec6c67183f67824a304160589f5266fe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 07:42:26 +0100 Subject: [PATCH 050/150] Add spec compliance tracking for the CSS modules --- .../PageBreakIntegrationTests.cs | 3 +- .../ResumableBlockLayoutIntegrationTests.cs | 3 +- .../BorderStylePaintIntegrationTests.cs | 3 +- .../Text/SmallCapsIntegrationTests.cs | 6 +- .../HtmlRenderer.SpecCompliance/Compliance.cs | 4 +- .../HtmlRenderer.SpecCompliance/Manifest.cs | 5 +- .../HtmlRenderer.SpecCompliance/Report.cs | 14 +- Spec/README.md | 6 +- Spec/css-backgrounds-3.yml | 194 +++++++++ Spec/css-box-3.yml | 62 +++ Spec/css-break-3.yml | 80 ++++ Spec/css-cascade-5.yml | 173 ++++++++ Spec/css-color-4.yml | 350 +++++++++++++++ Spec/css-color-5.yml | 182 ++++++++ Spec/css-conditional-3.yml | 53 +++ Spec/css-content-3.yml | 89 ++++ Spec/css-display-3.yml | 53 +++ Spec/css-fonts-4.yml | 401 ++++++++++++++++++ Spec/css-images-3.yml | 110 +++++ Spec/css-lists-3.yml | 71 ++++ Spec/css-namespaces-3.yml | 29 ++ Spec/css-nesting-1.yml | 50 +++ Spec/css-overflow-3.yml | 65 +++ Spec/css-page-3.yml | 131 ++++++ Spec/css-position-3.yml | 101 +++++ Spec/css-pseudo-4.yml | 116 +++++ Spec/css-sizing-3.yml | 80 ++++ Spec/css-style-attr.yml | 23 + Spec/css-syntax-3.yml | 233 ++++++++++ Spec/css-tables-3.yml | 233 ++++++++++ Spec/css-text-3.yml | 134 ++++++ Spec/css-text-decor-3.yml | 62 +++ Spec/css-ui-3.yml | 77 ++++ Spec/css-values-4.yml | 260 ++++++++++++ Spec/css-variables-1.yml | 62 +++ Spec/mediaqueries-4.yml | 125 ++++++ Spec/selectors-4.yml | 353 +++++++++++++++ 37 files changed, 3983 insertions(+), 13 deletions(-) create mode 100644 Spec/css-backgrounds-3.yml create mode 100644 Spec/css-box-3.yml create mode 100644 Spec/css-break-3.yml create mode 100644 Spec/css-cascade-5.yml create mode 100644 Spec/css-color-4.yml create mode 100644 Spec/css-color-5.yml create mode 100644 Spec/css-conditional-3.yml create mode 100644 Spec/css-content-3.yml create mode 100644 Spec/css-display-3.yml create mode 100644 Spec/css-fonts-4.yml create mode 100644 Spec/css-images-3.yml create mode 100644 Spec/css-lists-3.yml create mode 100644 Spec/css-namespaces-3.yml create mode 100644 Spec/css-nesting-1.yml create mode 100644 Spec/css-overflow-3.yml create mode 100644 Spec/css-page-3.yml create mode 100644 Spec/css-position-3.yml create mode 100644 Spec/css-pseudo-4.yml create mode 100644 Spec/css-sizing-3.yml create mode 100644 Spec/css-style-attr.yml create mode 100644 Spec/css-syntax-3.yml create mode 100644 Spec/css-tables-3.yml create mode 100644 Spec/css-text-3.yml create mode 100644 Spec/css-text-decor-3.yml create mode 100644 Spec/css-ui-3.yml create mode 100644 Spec/css-values-4.yml create mode 100644 Spec/css-variables-1.yml create mode 100644 Spec/mediaqueries-4.yml create mode 100644 Spec/selectors-4.yml diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageBreakIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageBreakIntegrationTests.cs index a724b454d..b6a55178e 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageBreakIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageBreakIntegrationTests.cs @@ -353,7 +353,8 @@ static string Document(string open, string close) => // css-break-3 §5.2 keeps the margin of a box whose forced break isn't taken. [TestMethod] - [Ignore("The crossing margin is truncated even when the box carries a forced break-before that isn't taken")] + [Ignore("Gap, see Spec/css-break-3.yml")] + [Spec("css-break-3", "#break-margins")] public async Task FirstBoxInTheFlow_CarryingAnUntakenForcedBreak_KeepsItsMargin() { var (root, _) = await BuildAsync( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/ResumableBlockLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/ResumableBlockLayoutIntegrationTests.cs index b67a37de8..0b1d587e2 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/ResumableBlockLayoutIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/ResumableBlockLayoutIntegrationTests.cs @@ -197,7 +197,8 @@ public async Task KeepWithNextRun_MovesWithTheBoxItIsChainedTo() // An out-of-flow box doesn't leave a break token behind. [TestMethod] - [Ignore("An absolutely positioned child of a table cell loses one of its 5 lines from the fragment tree")] + [Ignore("Gap, see Spec/css-position-3.yml")] + [Spec("css-position-3", "#abspos-breaking")] public async Task TallOutOfFlowChildOfATableCell_KeepsAllOfItsContent() { var lines = string.Concat(Enumerable.Range(0, 5).Select(i => $"Line{i}<br>")); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs index 92e44303f..0e78bf4d0 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs @@ -141,8 +141,9 @@ public void BorderColorPerSide_ResolvesDistinctColorPerEdge() Assert.AreEqual(RColor.FromArgb(90, 90, 90), left.Color); } - [Ignore("Gap: currentColor isn't supported in border-color")] + [Ignore("Gap, see Spec/css-color-4.yml")] [TestMethod] + [Spec("css-color-4", "#currentcolor-color")] public void BorderColorPerSide_CurrentColor_ResolvesToElementColor() { var (root, _) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs index 26a699135..3919927fa 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs @@ -102,8 +102,9 @@ public void AllSmallCaps_WordWithNoLetters_IsNotSplit() Assert.AreEqual("123", box.Words[0].Text); } - [Ignore("font-variant-caps isn't supported")] + [Ignore("Gap, see Spec/css-fonts-4.yml")] [TestMethod] + [Spec("css-fonts-4", "#font-variant-caps-prop")] public void AllSmallCaps_MixedCaseWord_WouldSplitIntoThreeRuns() { var (root, _) = LayoutHarness.Layout( @@ -116,8 +117,9 @@ public void AllSmallCaps_MixedCaseWord_WouldSplitIntoThreeRuns() Assert.AreEqual("C", box.Words[2].Text); } - [Ignore("font-variant-caps isn't supported")] + [Ignore("Gap, see Spec/css-fonts-4.yml")] [TestMethod] + [Spec("css-fonts-4", "#font-variant-caps-prop")] public void AllSmallCaps_DigitRunBetweenLowercaseRuns_WouldSplitIntoThreeRuns() { var (root, _) = LayoutHarness.Layout( diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/Compliance.cs b/Source/Tools/HtmlRenderer.SpecCompliance/Compliance.cs index a12622f6c..671896f78 100644 --- a/Source/Tools/HtmlRenderer.SpecCompliance/Compliance.cs +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Compliance.cs @@ -69,8 +69,10 @@ public static string FindRepositoryRoot(string? start = null) public static Compliance Load(string root) { + // CSS 2.1, the CSS modules, then HTML var specs = Directory.EnumerateFiles(SpecDirectory(root), "*.yml") - .Order(StringComparer.Ordinal) + .OrderBy(path => Path.GetFileNameWithoutExtension(path) switch { "css21" => 0, "html" => 2, _ => 1 }) + .ThenBy(path => path, StringComparer.Ordinal) .Select(path => new SpecResult(Manifest.Load(path))) .ToList(); diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs b/Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs index c897a6f4e..9eb69148e 100644 --- a/Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs @@ -37,7 +37,10 @@ public sealed class Manifest /// <summary>Published URL, anchors are appended to it for links in the report.</summary> public string Url { get; set; } = ""; - /// <summary>The commit of a living standard the sections were checked against, none for a fixed version.</summary> + /// <summary> + /// The version the sections were checked against: the commit of a living standard or the dated URL of a + /// published draft, none for a fixed version. + /// </summary> public string? Snapshot { get; set; } public Dictionary<string, ManifestSection> Sections { get; set; } = new(StringComparer.Ordinal); diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs b/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs index 799fb6e69..2591c186c 100644 --- a/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs @@ -24,8 +24,11 @@ public static string Render(Compliance compliance) foreach (var spec in compliance.Specs) { sb.AppendLine().AppendLine($"### {spec.Manifest.Title}").AppendLine(); - if (spec.Manifest.Snapshot != null) - sb.AppendLine($"Checked against commit `{spec.Manifest.Snapshot}`.").AppendLine(); + var snapshot = spec.Manifest.Snapshot; + if (snapshot != null && snapshot.StartsWith("https://", StringComparison.Ordinal)) + sb.AppendLine($"Checked against [{snapshot.TrimEnd('/').Split('/')[^1]}]({snapshot}).").AppendLine(); + else if (snapshot != null) + sb.AppendLine($"Checked against commit `{snapshot}`.").AppendLine(); foreach (var chapter in Chapters(spec)) RenderChapter(sb, spec, chapter); } @@ -55,9 +58,12 @@ private static void RenderChapter(StringBuilder sb, SpecResult spec, List<SpecSe sb.AppendLine().AppendLine("</details>").AppendLine(); } - /// <summary>Groups the sections by the spec page they're on, a chapter in CSS 2.1, a group of elements in HTML.</summary> + /// <summary> + /// Groups the sections by the spec page they're on, a chapter in CSS 2.1, a group of elements in HTML, or by + /// chapter for a spec on a single page ("#id" anchors). + /// </summary> private static IEnumerable<List<SpecSection>> Chapters(SpecResult spec) => - spec.Sections.GroupBy(s => s.Anchor.Split('#')[0]).Select(g => g.ToList()); + spec.Sections.GroupBy(s => s.Anchor.StartsWith('#') ? s.Number.Split('.')[0] : s.Anchor.Split('#')[0]).Select(g => g.ToList()); private static string Escape(string text) => text.Replace("|", "\\|").Replace("<", "&lt;").Replace(">", "&gt;"); } diff --git a/Spec/README.md b/Spec/README.md index ea73bf394..893b2a15c 100644 --- a/Spec/README.md +++ b/Spec/README.md @@ -6,7 +6,9 @@ `<spec>.yml` has the spec's `title` and `url`, and one entry per section, keyed by its anchor, with the numbered title, the `status` and, if anything is missing, the `gaps`. Every section of the spec is listed, except the subsections of an `n/a` section, so the report also shows what hasn't been reviewed yet. For HTML that's limited to the chapters that matter for rendering a static document: 2.3 microsyntaxes, 3.2 elements, 4 the elements of HTML, 13 the HTML syntax, 15 rendering and 16 obsolete features. -HTML is a living standard without versions, so `html.yml` has a `snapshot`, the commit of [whatwg/html](https://github.com/whatwg/html) its sections were checked against. When the snapshot moves, the sections that changed in between need a recheck. +Each CSS module level has its own manifest, named like its /TR URL, e.g. `css-color-4.yml`. Where one level replaces the previous, only the newer one is tracked (`css-cascade-5`, `selectors-4`), a level that only adds to the previous one is tracked next to it (`css-color-4` and `css-color-5`). A module is a single page, so its sections are keyed by the bare anchor, e.g. `#break-between`. + +The specs that change without a new name have a `snapshot`, the version their sections were checked against: for HTML, a living standard, the commit of [whatwg/html](https://github.com/whatwg/html), for a CSS module the dated URL of the published draft. When the snapshot moves, the sections that changed in between need a recheck. Statuses: @@ -27,7 +29,7 @@ HTML spreads a feature over several sections: the element in chapter 4, how it's public void NegativeSiblingMargins_CollapseToTheMostNegative() ``` -The anchor is a section key from the manifest, the test counts for that section and all its parent sections. An `[Ignore]`d test doesn't count, use one to describe a gap with `[Ignore("Gap, see Spec/css21.yml")]`. A test can carry several `[Spec]`, e.g. when its `[DataRow]`s cover different elements. +The anchor is a section key from the manifest, the test counts for that section and all its parent sections. An `[Ignore]`d test doesn't count, use one to describe a gap with `[Ignore("Gap, see Spec/css21.yml")]`. A test can carry several `[Spec]`, e.g. when its `[DataRow]`s cover different elements, or `[Spec("css21", "page.html#page-break-props")]` together with `[Spec("css-break-3", "#page-break-properties")]` when a CSS module restates a CSS 2.1 section. Keep the spec out of code comments, the manifest says what's supported, the tests show how, and the spec explains why. A comment cites the section ("CSS 2.1 §8.3.1", "HTML §4.4.8") and explains what the code can't. diff --git a/Spec/css-backgrounds-3.yml b/Spec/css-backgrounds-3.yml new file mode 100644 index 000000000..5184ac6df --- /dev/null +++ b/Spec/css-backgrounds-3.yml @@ -0,0 +1,194 @@ +spec: css-backgrounds-3 +title: CSS Backgrounds and Borders Module Level 3 +url: https://www.w3.org/TR/css-backgrounds-3/ +snapshot: https://www.w3.org/TR/2024/CRD-css-backgrounds-3-20240311/ +sections: + '#introduction': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#backgrounds': + title: 2 Backgrounds + status: unknown + '#layering': + title: 2.1 Layering Multiple Background Images + status: unknown + '#background-color': + title: '2.2 Base Color: the background-color property' + status: unknown + '#background-image': + title: '2.3 Image Sources: the background-image property' + status: unknown + '#background-repeat': + title: '2.4 Tiling Images: the background-repeat property' + status: unknown + '#background-attachment': + title: '2.5 Affixing Images: the background-attachment property' + status: unknown + '#background-position': + title: '2.6 Positioning Images: the background-position property' + status: unknown + '#bg-position-serialization': + title: 2.6.1 Serialization of background-position values + status: unknown + '#background-clip': + title: '2.7 Painting Area: the background-clip property' + status: unknown + '#background-origin': + title: '2.8 Positioning Area: the background-origin property' + status: unknown + '#background-size': + title: '2.9 Sizing Images: the background-size property' + status: unknown + '#background': + title: '2.10 Backgrounds Shorthand: the background property' + status: unknown + '#special-backgrounds': + title: 2.11 Backgrounds of Special Elements + status: unknown + '#root-background': + title: 2.11.1 The Canvas Background and the Root Element + status: unknown + '#body-background': + title: 2.11.2 The Canvas Background and the HTML <body> Element + status: unknown + '#first-line-background': + title: 2.11.3 The ::first-line Pseudo-element‘s Background + status: unknown + '#borders': + title: 3 Borders + status: unknown + '#border-color': + title: '3.1 Line Colors: the border-color properties' + status: unknown + '#border-style': + title: '3.2 Line Patterns: the border-style properties' + status: unknown + '#border-width': + title: '3.3 Line Thickness: the border-width properties' + status: unknown + '#border-shorthands': + title: 3.4 Border Shorthand Properties + status: unknown + '#corners': + title: 4 Rounded Corners + status: unknown + '#border-radius': + title: '4.1 Curve Radii: the border-radius properties' + status: unknown + '#corner-shaping': + title: 4.2 Corner Shaping + status: unknown + '#corner-clipping': + title: 4.3 Corner Clipping + status: unknown + '#corner-transitions': + title: 4.4 Color and Style Transitions + status: unknown + '#corner-overlap': + title: 4.5 Overlapping Curves + status: unknown + '#border-radius-tables': + title: 4.6 Effect on Tables + status: unknown + '#border-images': + title: 5 Border Images + status: unknown + '#border-image-source': + title: '5.1 Image Source: the border-image-source property' + status: unknown + '#border-image-slice': + title: '5.2 Image Slicing: the border-image-slice property' + status: unknown + '#border-image-width': + title: '5.3 Drawing Areas: the border-image-width property' + status: unknown + '#border-image-outset': + title: '5.4 Edge Overhang: the border-image-outset property' + status: unknown + '#border-image-repeat': + title: '5.5 Image Tiling: the border-image-repeat property' + status: unknown + '#border-image-process': + title: 5.6 Drawing the Border Image + status: unknown + '#border-image': + title: '5.7 Border Image Shorthand: the border-image property' + status: unknown + '#border-image-tables': + title: 5.8 Effect on Tables + status: unknown + '#misc': + title: 6 Miscellaneous Effects + status: unknown + '#box-shadow': + title: '6.1 Drop Shadows: the box-shadow property' + status: unknown + '#shadow-shape': + title: 6.1.1 Shadow Shape, Spread, and Knockout + status: unknown + '#shadow-blur': + title: 6.1.2 Blurring Shadow Edges + status: unknown + '#shadow-layers': + title: 6.1.3 Layering, Layout, and Other Details + status: unknown + '#levels': + title: 7 Levels + status: unknown + '#level-1': + title: 7.1 Level 1 + status: unknown + '#level-2': + title: 7.2 Level 2 + status: unknown + '#level-3': + title: 7.3 Level 3 + status: unknown + '#changes': + title: 8 Changes + status: unknown + '#changes-2023-02': + title: 8.1 Changes since the 14 February 2023 Candidate Recommendation Snapshot + status: unknown + '#changes-2020-12': + title: 8.2 Changes since the 22 December 2020 Candidate Recommendation Snapshot + status: unknown + '#changes-2017-10': + title: 8.3 Changes since the 17 October 2017 Candidate Recommendation + status: unknown + '#changes-2014-09': + title: 8.4 Changes since the 9 September 2014 Candidate Recommendation + status: unknown + '#changes-2014-02': + title: 8.5 Changes since the 4 February 2014 Last Call Working Draft + status: unknown + '#changes-2012-07': + title: 8.6 Changes since the 24 July 2012 Candidate Recommendation + status: unknown + '#changes-2012-04': + title: 8.7 Changes since the 17 April 2012 Candidate Recommendation + status: unknown + '#changes-2012LC': + title: 8.8 Changes since the 14 February 2012 “Last Call” Working Draft + status: unknown + '#changes-2011': + title: 8.9 Changes Since the 15 February 2011 Candidate Recommendation + status: unknown + '#changes-2009': + title: 8.10 Changes Since the 17 December 2009 Candidate Recommendation + status: unknown + '#acknowledgments': + title: 9 Acknowledgments + status: unknown + '#privacy': + title: 10 Privacy Considerations + status: unknown + '#security': + title: 11 Security Considerations + status: unknown diff --git a/Spec/css-box-3.yml b/Spec/css-box-3.yml new file mode 100644 index 000000000..b7f96d82e --- /dev/null +++ b/Spec/css-box-3.yml @@ -0,0 +1,62 @@ +spec: css-box-3 +title: CSS Box Model Module Level 3 +url: https://www.w3.org/TR/css-box-3/ +snapshot: https://www.w3.org/TR/2024/REC-css-box-3-20240411/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#placement': + title: 1.2 Module Interactions + status: unknown + '#box-model': + title: 2 The CSS Box Model + status: unknown + '#box-edges': + title: 2.1 Boxes and Edges + status: unknown + '#fragmentation': + title: 2.2 Fragmentation + status: unknown + '#keywords': + title: 2.3 Box-edge Keywords + status: unknown + '#margins': + title: 3 Margins + status: unknown + '#margin-physical': + title: '3.1 Page-relative (Physical) Margin Properties: the margin-top, margin-right, margin-bottom, and margin-left properties' + status: unknown + '#margin-shorthand': + title: '3.2 Margin Shorthand: the margin property' + status: unknown + '#paddings': + title: 4 Padding + status: unknown + '#padding-physical': + title: '4.1 Page-relative (Physical) Padding Properties: the padding-top, padding-right, padding-bottom, and padding-left properties' + status: unknown + '#padding-shorthand': + title: '4.2 Padding Shorthand: the padding property' + status: unknown + '#borders': + title: 5 Borders + status: unknown + '#changes': + title: 6 Changes + status: unknown + '#changes-recent': + title: 6.1 Recent Changes + status: unknown + '#changes-since-L2': + title: 6.2 Changes Since CSS Level 2 + status: unknown + '#priv': + title: 7 Privacy Considerations + status: unknown + '#sec': + title: 8 Security Considerations + status: unknown diff --git a/Spec/css-break-3.yml b/Spec/css-break-3.yml new file mode 100644 index 000000000..3993e1d27 --- /dev/null +++ b/Spec/css-break-3.yml @@ -0,0 +1,80 @@ +spec: css-break-3 +title: CSS Fragmentation Module Level 3 +url: https://www.w3.org/TR/css-break-3/ +snapshot: https://www.w3.org/TR/2018/CR-css-break-3-20181204/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Values + status: unknown + '#fragmentation-model': + title: 2 Fragmentation Model and Terminology + status: unknown + '#parallel-flows': + title: 2.1 Parallel Fragmentation Flows + status: unknown + '#nested-flows': + title: 2.2 Nested Fragmentation Flows + status: unknown + '#breaking-controls': + title: 3 Controlling Breaks + status: unknown + '#break-between': + title: '3.1 Breaks Between Boxes: the break-before and break-after properties' + status: unknown + '#break-propagation': + title: 3.1.1 Child→Parent Break Propagation + status: unknown + '#break-within': + title: '3.2 Breaks Within Boxes: the break-inside property' + status: unknown + '#widows-orphans': + title: '3.3 Breaks Between Lines: orphans, widows' + status: unknown + '#page-break-properties': + title: '3.4 Page Break Aliases: the page-break-before, page-break-after, and page-break-inside properties' + status: unknown + '#breaking-rules': + title: 4 Rules for Breaking + status: unknown + '#possible-breaks': + title: 4.1 Possible Break Points + status: unknown + '#break-types': + title: 4.2 Types of Breaks + status: unknown + '#forced-breaks': + title: 4.3 Forced Breaks + status: unknown + '#unforced-breaks': + title: 4.4 Unforced Breaks + status: unknown + '#best-breaks': + title: 4.5 Optimizing Unforced Breaks + status: unknown + '#breaking-boxes': + title: 5 Box Model for Breaking + status: unknown + '#varying-size-boxes': + title: 5.1 Breaking into Varying-size Fragmentainers + status: unknown + '#break-margins': + title: 5.2 Adjoining Margins at Breaks + status: unknown + '#box-splitting': + title: 5.3 Splitting Boxes + status: unknown + '#break-decoration': + title: '5.4 Fragmented Borders and Backgrounds: the box-decoration-break property' + status: unknown + '#joining-boxes': + title: 5.4.1 Joining Boxes for slice + status: unknown + '#transforms': + title: 5.5 Transforms, Positioning, and Pagination + status: unknown diff --git a/Spec/css-cascade-5.yml b/Spec/css-cascade-5.yml new file mode 100644 index 000000000..0a9b17867 --- /dev/null +++ b/Spec/css-cascade-5.yml @@ -0,0 +1,173 @@ +spec: css-cascade-5 +title: CSS Cascading and Inheritance Level 5 +url: https://www.w3.org/TR/css-cascade-5/ +snapshot: https://www.w3.org/TR/2022/CR-css-cascade-5-20220113/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#at-import': + title: '2 Importing Style Sheets: the @import rule' + status: unknown + '#conditional-import': + title: 2.1 Conditional @import Rules + status: unknown + '#import-processing': + title: 2.2 Processing Stylesheet Imports + status: unknown + '#content-type': + title: 2.3 Content-Type of CSS Style Sheets + status: unknown + '#shorthand': + title: 3 Shorthand Properties + status: unknown + '#aliasing': + title: 3.1 Property Aliasing + status: unknown + '#all-shorthand': + title: '3.2 Resetting All Properties: the all property' + status: unknown + '#value-stages': + title: 4 Value Processing + status: unknown + '#declared': + title: 4.1 Declared Values + status: unknown + '#value-aliasing': + title: 4.1.1 Value Aliasing + status: unknown + '#cascaded': + title: 4.2 Cascaded Values + status: unknown + '#specified': + title: 4.3 Specified Values + status: unknown + '#computed': + title: 4.4 Computed Values + status: unknown + '#used': + title: 4.5 Used Values + status: unknown + '#applies-to': + title: 4.5.1 Applicable Properties + status: unknown + '#actual': + title: 4.6 Actual Values + status: unknown + '#stages-examples': + title: 4.7 Examples + status: unknown + '#fragments': + title: 4.8 Per-Fragment Value Processing + status: unknown + '#filtering': + title: 5 Filtering + status: unknown + '#cascading': + title: 6 Cascading + status: unknown + '#cascade-sort': + title: 6.1 Cascade Sorting Order + status: unknown + '#cascading-origins': + title: 6.2 Cascading Origins + status: unknown + '#importance': + title: '6.3 Important Declarations: the !important annotation' + status: unknown + '#layering': + title: 6.4 Cascade Layers + status: unknown + '#layer-declaration': + title: 6.4.1 Declaring Cascade Layers + status: unknown + '#layer-names': + title: 6.4.2 Layer Naming and Nesting + status: unknown + '#unnamed-layers': + title: 6.4.2.1 Anonymous Layers + status: unknown + '#layer-ordering': + title: 6.4.3 Layer Ordering + status: unknown + '#at-layer': + title: '6.4.4 Declaring Layers Inline: the @layer rule' + status: unknown + '#layer-block': + title: '6.4.4.1 Assigning Styles Inline: the @layer block at-rule' + status: unknown + '#layer-empty': + title: '6.4.4.2 Declaring Without Styles: the @layer statement at-rule' + status: unknown + '#preshint': + title: 6.5 Precedence of Non-CSS Presentational Hints + status: unknown + '#defaulting': + title: 7 Defaulting + status: unknown + '#initial-values': + title: 7.1 Initial Values + status: unknown + '#inheriting': + title: 7.2 Inheritance + status: unknown + '#defaulting-keywords': + title: 7.3 Explicit Defaulting + status: unknown + '#initial': + title: '7.3.1 Resetting a Property: the initial keyword' + status: unknown + '#inherit': + title: '7.3.2 Explicit Inheritance: the inherit keyword' + status: unknown + '#inherit-initial': + title: '7.3.3 Erasing All Declarations: the unset keyword' + status: unknown + '#default': + title: '7.3.4 Rolling Back Cascade Origins: the revert keyword' + status: unknown + '#revert-layer': + title: '7.3.5 Rolling Back Cascade Layers: the revert-layer keyword' + status: unknown + '#layer-apis': + title: 8 Layer APIs + status: unknown + '#extensions-to-cssimportrule-interface': + title: 8.1 Extensions to the CSSImportRule interface + status: unknown + '#the-csslayerblockrule-interface': + title: 8.2 The CSSLayerBlockRule interface + status: unknown + '#the-csslayerstatementrule-interface': + title: 8.3 The CSSLayerStatementRule interface + status: unknown + '#changes': + title: 9 Changes + status: unknown + '#changes-2021-10': + title: 9.1 Changes since the 15 Oct 2021 Working Draft + status: unknown + '#changes-2021-08': + title: 9.2 Changes since the 29 August 2021 Working Draft + status: unknown + '#changes-2021-06': + title: 9.3 Changes since the 8 June 2021 Working Draft + status: unknown + '#changes-2021-03': + title: 9.4 Changes since the 19 March 2021 Working Draft + status: unknown + '#changes-2021-01': + title: 9.5 Changes since the 19 January 2021 Working Draft + status: unknown + '#additions-l4': + title: 9.6 Additions Since Level 4 + status: unknown + '#additions-l3': + title: 9.7 Additions Since Level 3 + status: unknown + '#changes-2': + title: 9.8 Additions Since Level 2 + status: unknown diff --git a/Spec/css-color-4.yml b/Spec/css-color-4.yml new file mode 100644 index 000000000..ce53b9023 --- /dev/null +++ b/Spec/css-color-4.yml @@ -0,0 +1,350 @@ +spec: css-color-4 +title: CSS Color Module Level 4 +url: https://www.w3.org/TR/css-color-4/ +snapshot: https://www.w3.org/TR/2026/CRD-css-color-4-20261103/ +sections: + '#introduction': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#terminology': + title: 2 Color Terminology + status: unknown + '#applying-color': + title: 3 Applying Color in CSS + status: unknown + '#accessibility': + title: 3.1 Accessibility and Conveying Information By Color + status: unknown + '#the-color-property': + title: '3.2 Foreground Color: the color property' + status: unknown + '#transparency': + title: '3.3 Transparency: the opacity property' + status: unknown + '#tagged-images': + title: 3.4 Color Space of Tagged Images + status: unknown + '#untagged': + title: 3.5 Color Spaces of Untagged Colors + status: unknown + '#color-type': + title: '4 Representing Colors: the <color> type' + status: unknown + '#color-syntax': + title: 4.1 The <color> syntax + status: unknown + '#color-syntax-modern': + title: 4.1.1 Modern (Space-separated) Color Function Syntax + status: unknown + '#color-syntax-legacy': + title: 4.1.2 Legacy (Comma-separated) Color Function Syntax + status: unknown + '#alpha-syntax': + title: '4.2 Representing Transparency in Colors: the <alpha-value> syntax' + status: unknown + '#hue-syntax': + title: '4.3 Representing Cylindrical-coordinate Hues: the <hue> syntax' + status: unknown + '#missing': + title: 4.4 “Missing” Color Components and the none Keyword + status: unknown + '#powerless': + title: 4.4.1 “Powerless” Color Components + status: unknown + '#parse-color': + title: 4.5 Parsing a <color> Value + status: unknown + '#numeric-srgb': + title: 5 sRGB Colors + status: unknown + '#rgb-functions': + title: '5.1 The RGB functions: rgb() and rgba()' + status: unknown + '#hex-notation': + title: '5.2 The RGB Hexadecimal Notations: #RRGGBB' + status: unknown + '#color-keywords': + title: 6 Color Keywords + status: unknown + '#named-colors': + title: 6.1 Named Colors + status: unknown + '#css-system-colors': + title: 6.2 System Colors + status: unknown + '#transparent-color': + title: 6.3 The transparent keyword + status: unknown + '#currentcolor-color': + title: 6.4 The currentcolor keyword + status: unknown + '#the-hsl-notation': + title: '7 HSL Colors: hsl() and hsla() functions' + status: unknown + '#hsl-to-rgb': + title: 7.1 Converting HSL Colors to sRGB + status: unknown + '#rgb-to-hsl': + title: 7.2 Converting sRGB Colors to HSL + status: unknown + '#hsl-examples': + title: 7.3 Examples of HSL Colors + status: unknown + '#the-hwb-notation': + title: '8 HWB Colors: hwb() function' + status: unknown + '#hwb-to-rgb': + title: 8.1 Converting HWB Colors to sRGB + status: unknown + '#rgb-to-hwb': + title: 8.2 Converting sRGB Colors to HWB + status: unknown + '#hwb-examples': + title: 8.3 Examples of HWB Colors + status: unknown + '#lab-colors': + title: '9 Device-independent Colors: CIE Lab and LCH, Oklab and OkLCh' + status: unknown + '#cie-lab': + title: 9.1 CIE Lab and LCH + status: unknown + '#ok-lab': + title: 9.2 Oklab and OkLCh + status: unknown + '#specifying-lab-lch': + title: '9.3 Specifying Lab and LCH: the lab() and lch() functional notations' + status: unknown + '#specifying-oklab-oklch': + title: '9.4 Specifying Oklab and OkLCh: the oklab() and oklch() functional notations' + status: unknown + '#lab-to-lch': + title: 9.5 Converting Lab or Oklab colors to LCH or OkLCh colors + status: unknown + '#lch-to-lab': + title: 9.6 Converting LCH or OkLCh colors to Lab or Oklab colors + status: unknown + '#predefined': + title: 10 Predefined Color Spaces + status: unknown + '#color-function': + title: '10.1 Specifying Predefined Colors: the color() function' + status: unknown + '#predefined-sRGB': + title: '10.2 The Predefined sRGB Color Space: the sRGB keyword' + status: unknown + '#predefined-sRGB-linear': + title: '10.3 The Predefined Linear-Light sRGB Color Space: the srgb-linear keyword' + status: unknown + '#predefined-display-p3': + title: '10.4 The Predefined Display P3 Color Space: the display-p3 keyword' + status: unknown + '#predefined-display-p3-linear': + title: '10.5 The Predefined Linear-Light Display P3 Color Space: the display-p3-linear keyword' + status: unknown + '#predefined-a98-rgb': + title: '10.6 The Predefined A98 RGB Color Space: the a98-rgb keyword' + status: unknown + '#predefined-prophoto-rgb': + title: '10.7 The Predefined ProPhoto RGB Color Space: the prophoto-rgb keyword' + status: unknown + '#predefined-rec2020': + title: '10.8 The Predefined ITU-R BT.2020-2 Color Space: the rec2020 keyword' + status: unknown + '#predefined-xyz': + title: '10.9 The Predefined CIE XYZ Color Spaces: the xyz-d50, xyz-d65, and xyz keywords' + status: unknown + '#predefined-to-lab-oklab': + title: 10.10 Converting Predefined Color Spaces to Lab or Oklab + status: unknown + '#oklab-lab-to-predefined': + title: 10.11 Converting Lab or Oklab to Predefined RGB Color Spaces + status: unknown + '#predefined-to-predefined': + title: 10.12 Converting Between Predefined RGB Color Spaces + status: unknown + '#alpha': + title: 10.13 Simple Alpha Compositing + status: unknown + '#color-conversion': + title: 11 Converting Colors + status: unknown + '#color-convert-intro': + title: 11.1 Introduction to Converting Colors + status: unknown + '#color-convert-algorithm': + title: 11.2 Algorithm for Converting Colors + status: unknown + '#comparing-color-values': + title: 12 Comparing <color> Values + status: unknown + '#interpolation': + title: 13 Color Interpolation + status: unknown + '#interpolate-gamut': + title: 13.1 Interpolation and color gamuts + status: unknown + '#component-wise-linear-interpolation': + title: 13.2 Component-wise Linear Interpolation + status: unknown + '#interpolation-space': + title: 13.3 Color Space for Interpolation + status: unknown + '#interpolation-missing': + title: 13.4 Interpolating with Missing Components + status: unknown + '#interpolation-alpha': + title: 13.5 Interpolating with Alpha + status: unknown + '#hue-interpolation': + title: 13.6 Hue Interpolation + status: unknown + '#hue-shorter': + title: 13.6.1 shorter + status: unknown + '#hue-longer': + title: 13.6.2 longer + status: unknown + '#hue-increasing': + title: 13.6.3 increasing + status: unknown + '#hue-decreasing': + title: 13.6.4 decreasing + status: unknown + '#gamut-mapping': + title: 14 Gamut Mapping + status: unknown + '#gamut-mapping-intro': + title: 14.1 An Introduction to Gamut Mapping + status: unknown + '#GM-clip': + title: 14.1.1 Clipping + status: unknown + '#GM-closest': + title: 14.1.2 Closest Color (MINDE) + status: unknown + '#GM-chroma': + title: 14.1.3 Chroma Reduction + status: unknown + '#GM-excessive-reduction': + title: 14.1.4 Excessive Chroma Reduction + status: unknown + '#GM-chroma-local-MINDE': + title: 14.1.5 Chroma Reduction with Local Clipping + status: unknown + '#GM-hue-curvature': + title: '14.1.6 Deviations from Perceptual Uniformity: Hue Curvature' + status: unknown + '#css-gamut-mapping': + title: 14.2 CSS Gamut Mapping to an RGB Destination + status: unknown + '#GMA-Binary-local-MINDE': + title: 14.2.1 Binary Search Gamut Mapping with Local MINDE + status: unknown + '#pseudo-binsearch': + title: 14.2.2 Sample Pseudocode for the Binary Search Gamut Mapping with Local MINDE + status: unknown + '#GMA-EdgeSeeker': + title: 14.2.3 The EdgeSeeker Gamut Mapping + status: unknown + '#pseudo-edgeseeker': + title: 14.2.4 Sample Pseudocode for the EdgeSeeker Gamut Mapping + status: unknown + '#GMA-Raytrace': + title: 14.2.5 The Ray Trace Gamut Mapping + status: unknown + '#pseudo-raytrace': + title: 14.2.6 Sample Pseudocode for the Ray Trace Gamut Mapping + status: unknown + '#raytrace-footnotes': + title: 14.2.6.1 Footnotes for Ray Trace algorithm + status: unknown + '#resolving-color-values': + title: 15 Resolving <color> Values + status: unknown + '#resolving-sRGB-values': + title: 15.1 Resolving sRGB values + status: unknown + '#resolving-lab-lch-values': + title: 15.2 Resolving Lab and LCH values + status: unknown + '#resolving-oklab-oklch-values': + title: 15.3 Resolving Oklab and OkLCh values + status: unknown + '#resolving-color-function-values': + title: 15.4 Resolving values of the color() function + status: unknown + '#resolving-other-colors': + title: 15.5 Resolving other colors + status: unknown + '#serializing-color-values': + title: 16 Serializing <color> Values + status: unknown + '#serializing-alpha-values': + title: 16.1 Serializing alpha values + status: unknown + '#serializing-legacy-alpha-values': + title: 16.1.1 Serializing legacy alpha values + status: unknown + '#serializing-modern-alpha-values': + title: 16.1.2 Serializing modern alpha values + status: unknown + '#serializing-sRGB-values': + title: 16.2 Serializing sRGB values + status: unknown + '#HTML-compatible-serialization-of-srgb': + title: 16.2.1 HTML-compatible serialization of sRGB values + status: unknown + '#css-serialization-of-srgb': + title: 16.2.2 CSS serialization of sRGB values + status: unknown + '#serializing-lab-lch': + title: 16.3 Serializing Lab and LCH values + status: unknown + '#serializing-oklab-oklch': + title: 16.4 Serializing Oklab and OkLCh values + status: unknown + '#serializing-color-function-values': + title: 16.5 Serializing values of the color() function + status: unknown + '#serializing-other-colors': + title: 16.6 Serializing other colors + status: unknown + '#serializing-opacity-values': + title: 17 Serializing <opacity-value> + status: unknown + '#sample': + title: 18 Default Style Rules + status: unknown + '#color-conversion-code': + title: 19 Sample code for Color Conversions + status: unknown + '#color-difference-code': + title: 20 Color Difference Formulae + status: unknown + '#color-differences-intro': + title: 20.1 Introduction to Color Difference metrics. + status: unknown + '#color-difference-2000': + title: 20.2 ΔE2000 + status: unknown + '#color-difference-OK': + title: 20.3 ΔEOK + status: unknown + '#color-difference-OK2': + title: 20.4 ΔEOK2 + status: unknown + '#color-difference-OK2r': + title: 20.5 ΔEOKr2 + status: unknown + '#security': + title: 21 Security Considerations + status: unknown + '#privacy': + title: 22 Privacy Considerations + status: unknown + '#a11y-sec': + title: 23 Accessibility Considerations + status: unknown diff --git a/Spec/css-color-5.yml b/Spec/css-color-5.yml new file mode 100644 index 000000000..340acef5e --- /dev/null +++ b/Spec/css-color-5.yml @@ -0,0 +1,182 @@ +spec: css-color-5 +title: CSS Color Module Level 5 +url: https://www.w3.org/TR/css-color-5/ +snapshot: https://www.w3.org/TR/2026/WD-css-color-5-20261029/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#color-syntax': + title: 2 The <color> syntax + status: unknown + '#color-mix': + title: '3 Mixing Colors: the color-mix() Function' + status: unknown + '#color-mix-space': + title: 3.1 Colorspace for mixing + status: unknown + '#color-mix-percent-norm': + title: 3.2 Percentage Normalization + status: unknown + '#color-mix-result': + title: 3.3 Calculating the Result of color-mix + status: unknown + '#color-mix-color-space-effect': + title: 3.4 Effect of Mixing Color Space on color-mix + status: unknown + '#color-mix-with-alpha': + title: 3.5 Effect of Non-Unity Alpha on color-mix + status: unknown + '#relative-colors': + title: 4 Relative Colors + status: unknown + '#rcs-intro': + title: 4.1 Processing Model for Relative Colors + status: unknown + '#relative-syntax': + title: 4.2 Relative Color Syntax + status: unknown + '#relative-RGB': + title: 4.3 Relative sRGB Colors + status: unknown + '#relative-HSL': + title: 4.4 Relative HSL Colors + status: unknown + '#relative-HWB': + title: 4.5 Relative HWB Colors + status: unknown + '#relative-Lab': + title: 4.6 Relative Lab Colors + status: unknown + '#relative-Oklab': + title: 4.7 Relative Oklab Colors + status: unknown + '#relative-LCH': + title: 4.8 Relative LCH Colors + status: unknown + '#relative-OkLCh': + title: 4.9 Relative OkLCh Colors + status: unknown + '#relative-alpha': + title: 4.10 Relative Alpha Colors + status: unknown + '#color-function': + title: '5 Specifying Predefined and Custom Color Spaces: the color() Function' + status: unknown + '#relative-color-function': + title: 5.1 Relative Color-Function Colors + status: unknown + '#custom-color': + title: 5.2 Custom Color Spaces + status: unknown + '#at-profile': + title: '5.3 Specifying a Color Profile: the @color-profile at-rule' + status: unknown + '#cal-cmyk': + title: '5.4 CSS and Print: Using Calibrated CMYK and Other Printed Color Spaces' + status: unknown + '#cmyk-to-lab': + title: 5.5 Converting CMYK colors to Lab + status: unknown + '#lab-to-cmyk': + title: 5.6 Converting Lab colors to CMYK + status: unknown + '#device-cmyk': + title: '6 Uncalibrated CMYK Colors: the device-cmyk() Function' + status: unknown + '#cmyk-rgb': + title: 6.1 Naively Converting Between Uncalibrated CMYK and sRGB-Based Color + status: unknown + '#light-dark': + title: '7 Reacting to the used color-scheme: the light-dark() Function' + status: unknown + '#contrast-color': + title: '8 Dynamically specifying a text color with adequate contrast: the contrast-color() Function' + status: unknown + '#interpolation': + title: 9 Color Interpolation + status: unknown + '#interpolation-space': + title: 9.1 Color Space for Interpolation + status: unknown + '#resolving-color-values': + title: 10 Resolving <color> Values + status: unknown + '#resolving-mix': + title: 10.1 Resolving color-mix() Values + status: unknown + '#resolving-rcs': + title: 10.2 Resolving Relative Color Syntax Values + status: unknown + '#resolving-device-cmyk-values': + title: 10.3 Resolving device-cmyk Values + status: unknown + '#serial': + title: 11 Serialization + status: unknown + '#serial-color-mix': + title: 11.1 Serializing color-mix() + status: unknown + '#serial-origin-color': + title: 11.2 Serializing Origin Colors + status: unknown + '#serial-relative-color': + title: 11.3 Serializing Relative Color Functions + status: unknown + '#serial-custom-color': + title: 11.4 Serializing Custom Color Spaces + status: unknown + '#serializing-device-cmyk-values': + title: 11.5 Serializing device-cmyk Values + status: unknown + '#apis': + title: 12 APIs + status: unknown + '#the-csscolorprofilerule-interface': + title: 12.1 The CSSColorProfileRule interface + status: unknown + '#sample': + title: 13 Default Style Rules + status: unknown + '#color-conversion-code': + title: 14 Sample code for Color Conversions + status: unknown + '#security': + title: 15 Security Considerations + status: unknown + '#privacy': + title: 16 Privacy Considerations + status: unknown + '#a11y-sec': + title: 17 Accessibility Considerations + status: unknown + '#changes': + title: 18 Changes + status: unknown + '#changes-20260113': + title: 18.1 Since the Working Draft of 13 January 2026 + status: unknown + '#changes-20250318': + title: 18.2 Since the Working Draft of 18 March 2025 + status: unknown + '#changes-20240229': + title: 18.3 Since the Working Draft of 29 February 2024 + status: unknown + '#changes-20220628': + title: 18.4 Since the Working Draft of 28 June 2022 + status: unknown + '#changes-20220428': + title: 18.5 Since the Working Draft of 28 April 2022 + status: unknown + '#changes-20211215': + title: 18.6 Since the Working Draft of 15 December 2021 + status: unknown + '#changes-20210601': + title: 18.7 Since the Working Draft of 1 June 2021 + status: unknown + '#changes-20200303': + title: 18.8 Since the FPWD of 10 June 2020 + status: unknown + '#changes-from-4': + title: 18.9 Changes from CSS Color 4 + status: unknown diff --git a/Spec/css-conditional-3.yml b/Spec/css-conditional-3.yml new file mode 100644 index 000000000..53747c2c2 --- /dev/null +++ b/Spec/css-conditional-3.yml @@ -0,0 +1,53 @@ +spec: css-conditional-3 +title: CSS Conditional Rules Module Level 3 +url: https://www.w3.org/TR/css-conditional-3/ +snapshot: https://www.w3.org/TR/2024/CRD-css-conditional-3-20240815/ +sections: + '#introduction': + title: 1 Introduction + status: unknown + '#context': + title: 1.1 Background + status: unknown + '#placement': + title: 1.2 Module Interactions + status: unknown + '#processing': + title: 2 Processing of conditional group rules + status: unknown + '#contents-of': + title: 3 Contents of conditional group rules + status: unknown + '#use': + title: 4 Placement of conditional group rules + status: unknown + '#at-media': + title: '5 Media-specific style sheets: the @media rule' + status: unknown + '#at-supports': + title: '6 Feature queries: the @supports rule' + status: unknown + '#support-definition': + title: 6.1 Definition of support + status: unknown + '#apis': + title: 7 APIs + status: unknown + '#extensions-to-cssrule-interface': + title: 7.1 Extensions to the CSSRule interface + status: unknown + '#the-cssconditionrule-interface': + title: 7.2 The CSSConditionRule interface + status: unknown + '#the-cssmediarule-interface': + title: 7.3 The CSSMediaRule interface + status: unknown + '#the-csssupportsrule-interface': + title: 7.4 The CSSSupportsRule interface + status: unknown + '#the-css-namespace': + title: 7.5 The CSS namespace, and the supports() function + status: unknown + '#changes': + title: 8 Changes + status: unknown diff --git a/Spec/css-content-3.yml b/Spec/css-content-3.yml new file mode 100644 index 000000000..f8d5b6afa --- /dev/null +++ b/Spec/css-content-3.yml @@ -0,0 +1,89 @@ +spec: css-content-3 +title: CSS Generated Content Module Level 3 +url: https://www.w3.org/TR/css-content-3/ +snapshot: https://www.w3.org/TR/2025/WD-css-content-3-20251204/ +sections: + '#content-property': + title: '1 Inserting and Replacing Content: the content property' + status: unknown + '#accessibility': + title: 1.1 Accessibility of Generated Content + status: unknown + '#alt': + title: 1.2 Alternative Text for Accessibility + status: unknown + '#content-values': + title: '2 Generated Content Values: the <content-list> type' + status: unknown + '#strings': + title: '2.1 Basic Strings: <string> and <attr()> values' + status: unknown + '#content-uri': + title: '2.2 2D Images: <image> values' + status: unknown + '#element-content': + title: '2.3 Elemental Content: the contents keyword' + status: unknown + '#quotes': + title: 2.4 Quotation Marks + status: unknown + '#quotes-property': + title: '2.4.1 Quotation Mark System: the quotes property' + status: unknown + '#quote-values': + title: '2.4.2 Inserting Quotation Marks: the *-quote keywords' + status: unknown + '#leaders': + title: 2.5 Leaders + status: unknown + '#leader-function': + title: 2.5.1 The leader() function + status: unknown + '#leader-rules': + title: 2.5.2 Rendering leaders + status: unknown + '#leader-alignment': + title: 2.5.3 Procedure for rendering leaders + status: unknown + '#cross-references': + title: 2.6 Cross References + status: unknown + '#target-counter': + title: 2.6.1 The target-counter() function + status: unknown + '#target-counters': + title: 2.6.2 The target-counters() function + status: unknown + '#target-text': + title: 2.6.3 The target-text() function + status: unknown + '#named-strings': + title: 2.7 Named Strings + status: unknown + '#string-set': + title: '2.7.1 Naming Strings: the string-set property' + status: unknown + '#string-function': + title: '2.7.2 Inserting Named Strings: the string() function' + status: unknown + '#content-function': + title: 2.7.3 The content() function + status: unknown + '#counters': + title: 2.8 Automatic Counters and Numbering + status: unknown + '#bookmark-generation': + title: 3 Bookmarks + status: unknown + '#bookmark-level': + title: '3.1 Setting a Bookmark: the bookmark-level property' + status: unknown + '#bookmark-label': + title: '3.2 Labelling a Bookmark: the bookmark-label property' + status: unknown + '#bookmark-state': + title: '3.3 Initial Bookmark Toggle State: the bookmark-state property' + status: unknown + '#changes': + title: 4 Changes + status: unknown diff --git a/Spec/css-display-3.yml b/Spec/css-display-3.yml new file mode 100644 index 000000000..09767dbd5 --- /dev/null +++ b/Spec/css-display-3.yml @@ -0,0 +1,53 @@ +spec: css-display-3 +title: CSS Display Module Level 3 +url: https://www.w3.org/TR/css-display-3/ +snapshot: https://www.w3.org/TR/2026/CRD-css-display-3-20260605/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#the-display-properties': + title: '2 Box Layout Modes: the display property' + status: unknown + '#outer-role': + title: '2.1 Outer Display Roles for Flow Layout: the block, inline, and run-in keywords' + status: unknown + '#inner-model': + title: '2.2 Inner Display Layout Models: the flow, flow-root, table, flex, grid, and ruby keywords' + status: unknown + '#list-items': + title: '2.3 Generating Marker Boxes: the list-item keyword' + status: unknown + '#layout-specific-display': + title: '2.4 Layout-Internal Display Types: the table-* and ruby-* keywords' + status: unknown + '#box-generation': + title: '2.5 Box Generation: the none and contents keywords' + status: unknown + '#legacy-display': + title: 2.6 Precomposed Inline-level Display Values + status: unknown + '#transformations': + title: 2.7 Automatic Box Type Transformations + status: unknown + '#root': + title: 2.8 The Root Element’s Principal Box + status: unknown + '#order-property': + title: '3 Display Order: the order property' + status: unknown + '#order-accessibility': + title: 3.1 Reordering and Accessibility + status: unknown + '#visibility': + title: '4 Invisibility: the visibility property' + status: unknown + '#run-in-layout': + title: 5 Run-In Layout + status: unknown diff --git a/Spec/css-fonts-4.yml b/Spec/css-fonts-4.yml new file mode 100644 index 000000000..f87943f15 --- /dev/null +++ b/Spec/css-fonts-4.yml @@ -0,0 +1,401 @@ +spec: css-fonts-4 +title: CSS Fonts Module Level 4 +url: https://www.w3.org/TR/css-fonts-4/ +snapshot: https://www.w3.org/TR/2026/WD-css-fonts-4-20261104/ +sections: + '#introduction': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#font-relative': + title: 1.2 Interaction With Font-Relative Units + status: unknown + '#basic-font-props': + title: 2 Basic Font Properties + status: unknown + '#font-family-prop': + title: '2.1 Font family: the font-family property' + status: unknown + '#font-family-name-syntax': + title: 2.1.1 Syntax of <font-family-name> + status: unknown + '#generic-family-name-syntax': + title: 2.1.2 Syntax of <generic-font-family> + status: unknown + '#system': + title: 2.1.3 Syntax of <system-font-family-name> + status: unknown + '#font-families': + title: 2.1.4 Relationship Between Faces and Families + status: unknown + '#generic-font-families': + title: 2.1.5 Generic font families + status: unknown + '#font-weight-prop': + title: '2.2 Font weight: the font-weight property' + status: unknown + '#relative-weights': + title: 2.2.1 Relative Weights + status: unknown + '#missing-weights': + title: 2.2.2 Missing weights + status: unknown + '#font-width-prop': + title: '2.3 Font width: the font-width property' + status: unknown + '#font-stretch-prop': + title: '2.3.1 Font width: the font-stretch legacy name alias' + status: unknown + '#font-style-prop': + title: '2.4 Font style: the font-style property' + status: unknown + '#font-size-prop': + title: '2.5 Font size: the font-size property' + status: unknown + '#absolute-size-mapping': + title: 2.5.1 Absolute Size Keyword Mapping Table + status: unknown + '#font-size-adjust-prop': + title: '2.6 Relative sizing: the font-size-adjust property' + status: unknown + '#font-prop': + title: '2.7 Shorthand font property: the font property' + status: unknown + '#font-synthesis-intro': + title: 2.8 Controlling synthetic faces + status: unknown + '#font-synthesis-weight': + title: '2.8.1 Controlling synthesized bold: The font-synthesis-weight property' + status: unknown + '#font-synthesis-style': + title: '2.8.2 Controlling synthesized oblique: The font-synthesis-style property' + status: unknown + '#font-synthesis-small-caps': + title: '2.8.3 Controlling synthesized small caps: The font-synthesis-small-caps property' + status: unknown + '#font-synthesis-position': + title: '2.8.4 Controlling synthesized super- and subscripts: The font-synthesis-position property' + status: unknown + '#font-synthesis': + title: '2.8.5 Controlling synthetic faces: the font-synthesis shorthand' + status: unknown + '#font-rendering-controls': + title: 3 Font Rendering Controls + status: unknown + '#font-rendering-controls-introduction': + title: 3.1 Introduction to Font Rendering Controls + status: unknown + '#font-display-timeline': + title: 3.2 The Font Display Timeline + status: unknown + '#font-resources': + title: 4 Font Resources + status: unknown + '#font-face-rule': + title: 4.1 The @font-face rule + status: unknown + '#font-family-desc': + title: '4.2 Font family: the font-family descriptor' + status: unknown + '#src-desc': + title: '4.3 Font reference: the src descriptor' + status: unknown + '#font-face-src-parsing': + title: 4.3.1 Parsing the src descriptor + status: unknown + '#font-face-src-loading': + title: 4.3.2 Loading an individual item in the src descriptor + status: unknown + '#font-face-src-formats': + title: 4.3.3 Selecting items in the src + status: unknown + '#local-font-fallback': + title: 4.3.3.1 Local font fallback + status: unknown + '#font-prop-desc': + title: '4.4 Font property descriptors: the font-style, font-weight, and font-width descriptors' + status: unknown + '#font-stretch-desc': + title: '4.4.1 Font width: the font-stretch legacy name alias' + status: unknown + '#unicode-range-desc': + title: '4.5 Character range: the unicode-range descriptor' + status: unknown + '#composite-fonts': + title: 4.5.1 Using character ranges to define composite fonts + status: unknown + '#font-rend-desc': + title: '4.6 Font features and variations: the font-feature-settings and font-variation-settings descriptors' + status: unknown + '#font-named-instance': + title: '4.7 Using named instances from variable fonts: the font-named-instance descriptor' + status: unknown + '#font-requests': + title: 4.8 Font request guidelines + status: unknown + '#font-face-loading': + title: 4.8.1 Font loading guidelines + status: unknown + '#font-fetching-requirements': + title: 4.8.2 Font fetching requirements + status: unknown + '#font-display-desc': + title: '4.9 Controlling Font Display Per Font-Face: the font-display descriptor' + status: unknown + '#font-display-font-feature-values': + title: 4.9.1 Controlling Font Display Per Font-Family via @font-feature-values + status: unknown + '#font-language-override-desc': + title: '4.10 Default font language overriding: the font-language-override descriptor' + status: unknown + '#font-metrics-override-desc': + title: '4.11 Default font metrics overriding: the ascent-override, descent-override and line-gap-override descriptors' + status: unknown + '#font-matching-algorithm': + title: 5 Font Matching Algorithm + status: unknown + '#localized-name-matching': + title: 5.1 Localized name matching + status: unknown + '#font-style-matching': + title: 5.2 Matching font styles + status: unknown + '#cluster-matching': + title: 5.3 Cluster matching + status: unknown + '#char-handling-issues': + title: 5.4 Character handling issues + status: unknown + '#font-rend-props': + title: 6 Font Feature Properties + status: unknown + '#glyph-selection-positioning': + title: 6.1 Glyph selection and positioning + status: unknown + '#language-specific-support': + title: 6.2 Language-specific display + status: unknown + '#font-kerning-prop': + title: '6.3 Kerning: the font-kerning property' + status: unknown + '#horizontal-kerning': + title: 6.3.1 Horizontal kerning + status: unknown + '#vertical-kerning': + title: 6.3.2 Vertical kerning + status: unknown + '#font-variant-ligatures-prop': + title: '6.4 Ligatures: the font-variant-ligatures property' + status: unknown + '#font-variant-position-prop': + title: '6.5 Subscript and superscript forms: the font-variant-position property' + status: unknown + '#font-variant-caps-prop': + title: '6.6 Capitalization: the font-variant-caps property' + status: unknown + '#font-variant-numeric-prop': + title: '6.7 Numerical formatting: the font-variant-numeric property' + status: unknown + '#font-variant-alternates-prop': + title: '6.8 Alternates and swashes: the font-variant-alternates property' + status: unknown + '#font-feature-values': + title: '6.9 Defining font specific alternates: the @font-feature-values rule' + status: unknown + '#font-feature-values-syntax': + title: 6.9.1 Basic syntax + status: unknown + '#multi-value-features': + title: 6.9.2 Multi-valued feature value definitions + status: unknown + '#font-variant-east-asian-prop': + title: '6.10 East Asian text rendering: the font-variant-east-asian property' + status: unknown + '#font-variant-prop': + title: '6.11 Overall shorthand for font rendering: the font-variant property' + status: unknown + '#font-feature-settings-prop': + title: '6.12 Low-level font feature settings control: the font-feature-settings property' + status: unknown + '#font-language-override-prop': + title: '6.13 Font language override: the font-language-override property' + status: unknown + '#font-feature-variation-resolution': + title: 7 Font Feature and Variation Resolution + status: unknown + '#default-features': + title: 7.1 Default features + status: unknown + '#feature-variation-precedence': + title: 7.2 Feature and variation precedence + status: unknown + '#feature-precedence-examples': + title: 7.3 Feature precedence examples + status: unknown + '#font-variation-props': + title: 8 Font Variation Properties + status: unknown + '#font-optical-sizing-def': + title: '8.1 Optical sizing control: the font-optical-sizing property' + status: unknown + '#font-variation-settings-def': + title: '8.2 Low-level font variation settings control: the font-variation-settings property' + status: unknown + '#color-font-support': + title: 9 Color Font Support + status: unknown + '#font-palette-prop': + title: '9.1 Controlling Color Font Palettes: The font-palette property' + status: unknown + '#font-palette-values': + title: '9.2 User-defined font color palettes: The @font-palette-values rule' + status: unknown + '#font-family-2-desc': + title: '9.2.1 Font family: the font-family descriptor' + status: unknown + '#base-palette-desc': + title: '9.2.2 Specifying the base palette: the base-palette descriptor' + status: unknown + '#override-color': + title: '9.2.3 Overriding a colors from a palette: The override-colors descriptor' + status: unknown + '#font-variant-emoji-prop': + title: '9.3 Selecting the text presentation style: The font-variant-emoji property' + status: unknown + '#font-taxonomy': + title: 10 Font Taxonomy + status: unknown + '#installed-fonts': + title: 10.1 Installed Fonts + status: unknown + '#web-fonts': + title: 10.2 Web Fonts + status: unknown + '#preinstalled-and-user-installed-fonts': + title: 10.3 Preinstalled Fonts and User-Installed Fonts + status: unknown + '#system-font': + title: 10.4 System Font + status: unknown + '#font-technologies-formats': + title: 11 Font Technologies and Formats + status: unknown + '#font-tech-definitions': + title: 11.1 Font tech + status: unknown + '#font-format-definitions': + title: 11.2 Font formats + status: unknown + '#object-model': + title: 12 Object Model + status: unknown + '#om-fontface': + title: 12.1 The CSSFontFaceRule interface + status: unknown + '#om-fontfeaturevalues': + title: 12.2 The CSSFontFeatureValuesRule interface + status: unknown + '#om-fontpalettevalues': + title: 12.3 The CSSFontPaletteValuesRule interface + status: unknown + '#serializing': + title: 13 Serializing + status: unknown + '#serializing-properties': + title: 13.1 Serializing font-related properties + status: unknown + '#serializing-at-rules': + title: 13.2 Serializing font-related at-rules + status: unknown + '#security': + title: 14 Security Considerations + status: unknown + '#privacy': + title: 15 Privacy Considerations + status: unknown + '#sp201': + title: 15.1 What information might this feature expose to Web sites or other parties, and for what purposes is that exposure necessary? + status: unknown + '#sp202': + title: 15.2 Is this specification exposing the minimum amount of information necessary to power the feature? + status: unknown + '#sp203': + title: 15.3 How does this specification deal with personal information or personally-identifiable information or information derived thereof? + status: unknown + '#sp204': + title: 15.4 How does this specification deal with sensitive information? + status: unknown + '#sp205': + title: 15.5 Does this specification introduce new state for an origin that persists across browsing sessions? + status: unknown + '#sp206': + title: 15.6 What information from the underlying platform, e.g. configuration data, is exposed by this specification to an origin? + status: unknown + '#sp207': + title: 15.7 Does this specification allow an origin access to sensors on a user’s device + status: unknown + '#sp208': + title: 15.8 What data does this specification expose to an origin? Please also document what data is identical to data exposed by other features, in the same or different contexts. + status: unknown + '#sp209': + title: 15.9 Does this specification enable new script execution/loading mechanisms? + status: unknown + '#sp210': + title: 15.10 Does this specification allow an origin to access other devices? + status: unknown + '#sp211': + title: 15.11 Does this specification allow an origin some measure of control over a user agent’s native UI? + status: unknown + '#sp212': + title: 15.12 What temporary identifiers might this specification create or expose to the web? + status: unknown + '#sp213': + title: 15.13 How does this specification distinguish between behavior in first-party and third-party contexts? + status: unknown + '#sp214': + title: 15.14 How does this specification work in the context of a user agent’s Private Browsing or "incognito" mode? + status: unknown + '#sp215': + title: 15.15 Does this specification have a "Security Considerations" and "Privacy Considerations" section? + status: unknown + '#sp216': + title: 15.16 Does this specification allow downgrading default security characteristics? + status: unknown + '#sp217': + title: 15.17 What should this questionnaire have asked? + status: unknown + '#a11y': + title: 16 Accessibility Considerations + status: unknown + '#acknowledgments': + title: 17 Acknowledgments + status: unknown + '#changes': + title: 18 Changes + status: unknown + '#changes-20240201': + title: 18.1 Changes from the 1 February 2024 working draft + status: unknown + '#changes-2021-12-21': + title: 18.2 Changes from the 21 December 2021 Working Draft + status: unknown + '#changes-2021-07-29': + title: 18.3 Changes from the 29 July 2021 Working Draft + status: unknown + '#changes-2020-11-17': + title: 18.4 Changes from the 17 November 2020 Working Draft + status: unknown + '#changes-2019-11-13': + title: 18.5 Changes from the 13 November 2019 Working Draft + status: unknown + '#changes-2018-09-20': + title: 18.6 Changes from the 20 September 2018 Working Draft + status: unknown + '#changes-2018-04-10': + title: 18.7 Changes from the 10 April 2018 Working Draft + status: unknown + '#changes-fonts-3': + title: 18.8 Changes from the 20 September 2018 CSS Fonts 3 Recommendation + status: unknown diff --git a/Spec/css-images-3.yml b/Spec/css-images-3.yml new file mode 100644 index 000000000..04b24063e --- /dev/null +++ b/Spec/css-images-3.yml @@ -0,0 +1,110 @@ +spec: css-images-3 +title: CSS Images Module Level 3 +url: https://www.w3.org/TR/css-images-3/ +snapshot: https://www.w3.org/TR/2023/CRD-css-images-3-20231218/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#image-values': + title: '2 Image Values: the <image> type' + status: unknown + '#url-notation': + title: '2.1 Image References: the url() notation' + status: unknown + '#ambiguous-urls': + title: 2.1.1 Ambiguous Reference-or-Image URLs + status: unknown + '#url-metadata': + title: 2.1.2 Image Metadata + status: unknown + '#gradients': + title: 3 Gradients + status: unknown + '#linear-gradients': + title: '3.1 Linear Gradients: the linear-gradient() notation' + status: unknown + '#linear-gradient-syntax': + title: 3.1.1 linear-gradient() syntax + status: unknown + '#linear-gradient-examples': + title: 3.1.2 Linear Gradient Examples + status: unknown + '#radial-gradients': + title: '3.2 Radial Gradients: the radial-gradient() notation' + status: unknown + '#radial-gradient-syntax': + title: 3.2.1 radial-gradient() Syntax + status: unknown + '#radial-color-stops': + title: 3.2.2 Placing Color Stops + status: unknown + '#degenerate-radials': + title: 3.2.3 Degenerate Radial Gradients + status: unknown + '#radial-gradient-examples': + title: 3.2.4 Radial Gradient Examples + status: unknown + '#repeating-gradients': + title: '3.3 Repeating Gradients: the repeating-linear-gradient() and repeating-radial-gradient() notations' + status: unknown + '#gradient-colors': + title: 3.4 Defining Gradient Color + status: unknown + '#color-stop-syntax': + title: 3.4.1 Color Stop Lists + status: unknown + '#coloring-gradient-line': + title: 3.4.2 Coloring the Gradient Line + status: unknown + '#color-stop-fixup': + title: 3.4.3 Color Stop “Fixup” + status: unknown + '#sizing': + title: 4 Sizing Images and Objects in CSS + status: unknown + '#sizing-terms': + title: 4.1 Object-Sizing Terminology + status: unknown + '#object-negotiation': + title: 4.2 CSS⇋Object Negotiation + status: unknown + '#concrete-size-resolution': + title: 4.3 Concrete Object Size Resolution + status: unknown + '#default-sizing': + title: 4.3.1 Default Sizing Algorithm + status: unknown + '#cover-contain': + title: 4.3.2 Cover and Contain Constraint Sizing + status: unknown + '#object-sizing-examples': + title: 4.4 Examples of CSS Object Sizing + status: unknown + '#the-object-fit': + title: '4.5 Sizing Objects: the object-fit property' + status: unknown + '#the-object-position': + title: '4.6 Positioning Objects: the object-position property' + status: unknown + '#image-processing': + title: 5 Image Processing + status: unknown + '#the-image-orientation': + title: '5.1 Orienting an Image on the Page: the image-orientation property' + status: unknown + '#the-image-rendering': + title: '5.2 Determining How To Scale an Image: the image-rendering property' + status: unknown + '#interpolation': + title: 6 Interpolation + status: unknown + '#serialization': + title: 7 Serialization + status: unknown diff --git a/Spec/css-lists-3.yml b/Spec/css-lists-3.yml new file mode 100644 index 000000000..208fce7fa --- /dev/null +++ b/Spec/css-lists-3.yml @@ -0,0 +1,71 @@ +spec: css-lists-3 +title: CSS Lists and Counters Module Level 3 +url: https://www.w3.org/TR/css-lists-3/ +snapshot: https://www.w3.org/TR/2020/WD-css-lists-3-20201117/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#declaring-a-list-item': + title: 2 Declaring a List Item + status: unknown + '#markers': + title: 3 Markers + status: unknown + '#marker-pseudo': + title: 3.1 The ::marker Pseudo-Element + status: unknown + '#marker-properties': + title: 3.1.1 Properties Applying to ::marker + status: unknown + '#content-property': + title: 3.2 Generating Marker Contents + status: unknown + '#image-markers': + title: '3.3 Image Markers: the list-style-image property' + status: unknown + '#text-markers': + title: '3.4 Text-based Markers: the list-style-type property' + status: unknown + '#list-style-position-property': + title: '3.5 Positioning Markers: The list-style-position property' + status: unknown + '#list-style-property': + title: '3.6 Styling Markers: the list-style shorthand property' + status: unknown + '#marker-side': + title: 3.7 The marker-side property + status: unknown + '#auto-numbering': + title: 4 Automatic Numbering With Counters + status: unknown + '#counter-reset': + title: '4.1 Creating Counters: the counter-reset property' + status: unknown + '#increment-set': + title: '4.2 Manipulating Counter Values: the counter-increment and counter-set properties' + status: unknown + '#nested-counters': + title: 4.3 Nested Counters and Scope + status: unknown + '#creating-counters': + title: 4.4 Creating and Inheriting Counters + status: unknown + '#inheriting-counters': + title: 4.4.1 Inheriting Counters + status: unknown + '#instantiating-counters': + title: 4.4.2 Instantiating Counters + status: unknown + '#counters-without-boxes': + title: 4.5 Counters in elements that do not generate boxes + status: unknown + '#list-item-counter': + title: 4.6 The Implicit list-item Counter + status: unknown + '#counter-functions': + title: '4.7 Outputting Counters: the counter() and counters() functions' + status: unknown diff --git a/Spec/css-namespaces-3.yml b/Spec/css-namespaces-3.yml new file mode 100644 index 000000000..bcb043eb8 --- /dev/null +++ b/Spec/css-namespaces-3.yml @@ -0,0 +1,29 @@ +spec: css-namespaces-3 +title: CSS Namespaces Module Level 3 +url: https://www.w3.org/TR/css-namespaces-3/ +snapshot: https://www.w3.org/TR/2014/REC-css-namespaces-3-20140320/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#conformance': + title: 2 Conformance + status: unknown + '#terminology': + title: 2.1 Terminology + status: unknown + '#declaration': + title: '3 Declaring namespaces: the @namespace rule' + status: unknown + '#syntax': + title: 3.1 Syntax + status: unknown + '#scope': + title: 3.2 Scope + status: unknown + '#prefixes': + title: 3.3 Declaring Prefixes + status: unknown + '#css-qnames': + title: 4 CSS Qualified Names + status: unknown diff --git a/Spec/css-nesting-1.yml b/Spec/css-nesting-1.yml new file mode 100644 index 000000000..0a954e3e2 --- /dev/null +++ b/Spec/css-nesting-1.yml @@ -0,0 +1,50 @@ +spec: css-nesting-1 +title: CSS Nesting Module Level 1 +url: https://www.w3.org/TR/css-nesting-1/ +snapshot: https://www.w3.org/TR/2026/WD-css-nesting-1-20260122/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Values + status: unknown + '#explainer': + title: 2 Explainer + status: unknown + '#nesting': + title: 3 Nesting Style Rules + status: unknown + '#syntax': + title: 3.1 Syntax + status: unknown + '#syntax-examples': + title: 3.2 Examples + status: unknown + '#conditionals': + title: 3.3 Nesting Other At-Rules + status: unknown + '#nesting-at-scope': + title: 3.3.1 Nested @scope Rules + status: unknown + '#mixing': + title: 3.4 Mixing Nesting Rules and Declarations + status: unknown + '#nest-selector': + title: '4 Nesting Selector: the & selector' + status: unknown + '#nested-declarations': + title: 5 The Nested Declarations Rule + status: unknown + '#cssom': + title: 6 CSSOM + status: unknown + '#the-cssnestrule': + title: 6.1 The CSSNestedDeclarations Interface + status: unknown + '#changes': + title: 7 Changes + status: unknown diff --git a/Spec/css-overflow-3.yml b/Spec/css-overflow-3.yml new file mode 100644 index 000000000..bf9b5307c --- /dev/null +++ b/Spec/css-overflow-3.yml @@ -0,0 +1,65 @@ +spec: css-overflow-3 +title: CSS Overflow Module Level 3 +url: https://www.w3.org/TR/css-overflow-3/ +snapshot: https://www.w3.org/TR/2025/WD-css-overflow-3-20251007/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#placement': + title: 1.2 Module Interactions + status: unknown + '#overflow-concepts': + title: 2 Overflow Concepts and Terminology + status: unknown + '#ink': + title: 2.1 Ink Overflow + status: unknown + '#scrollable': + title: 2.2 Scrollable Overflow + status: unknown + '#scrolling': + title: 2.3 Scrolling Overflow + status: unknown + '#overflow-properties': + title: 3 Scrolling and Clipping Overflow + status: unknown + '#overflow-control': + title: '3.1 Managing Overflow: the overflow-x, overflow-y, and overflow properties' + status: unknown + '#scroll-visibility': + title: 3.1.1 Interaction of visibility and overflow + status: unknown + '#corner-clipping': + title: 3.1.2 Interaction of border-radius and overflow + status: unknown + '#static-media': + title: 3.1.3 Overflow in Print and Other Static Media + status: unknown + '#overflow-clip-margin': + title: '3.2 Expanding Clipping Bounds: the overflow-clip-margin property' + status: unknown + '#overflow-propagation': + title: 3.3 Overflow Viewport Propagation + status: unknown + '#smooth-scrolling': + title: '3.4 Smooth Scrolling: the scroll-behavior Property' + status: unknown + '#scrollbar-layout': + title: 4 Scrollbars and Layout + status: unknown + '#scrollbar-sizing': + title: 4.1 Scrollbar Contributions to Sizing + status: unknown + '#scrollbar-gutter-property': + title: '4.2 Reserving space for the scrollbar: the scrollbar-gutter property' + status: unknown + '#auto-ellipsis': + title: 5 Automatic Ellipses + status: unknown + '#text-overflow': + title: '5.1 Overflow Ellipsis: the text-overflow property' + status: unknown diff --git a/Spec/css-page-3.yml b/Spec/css-page-3.yml new file mode 100644 index 000000000..21eb72f1d --- /dev/null +++ b/Spec/css-page-3.yml @@ -0,0 +1,131 @@ +spec: css-page-3 +title: CSS Paged Media Module Level 3 +url: https://www.w3.org/TR/css-page-3/ +snapshot: https://www.w3.org/TR/2023/WD-css-page-3-20230914/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#page-terms': + title: 2 Page Terminology + status: unknown + '#page-model': + title: 3 The Page Model + status: unknown + '#painting': + title: 3.1 Page Backgrounds and Painting Order + status: unknown + '#content-outside-box': + title: 3.2 Content outside the page box + status: unknown + '#progression': + title: 3.3 Page Progression + status: unknown + '#page-selector-and-context': + title: 4 Page Selectors and the Page Context + status: unknown + '#at-page-rule': + title: 4.1 The @page Rule + status: unknown + '#page-selectors': + title: 4.2 Page selectors + status: unknown + '#spread-pseudos': + title: '4.2.1 Spread pseudo-classes: :left, :right' + status: unknown + '#first-pseudo': + title: '4.2.2 First-page pseudo-class: :first' + status: unknown + '#blank-pseudo': + title: '4.2.3 Blank-page pseudo-class: :blank' + status: unknown + '#syntax-page-selector': + title: 4.3 @page rule grammar + status: unknown + '#cascading-and-page-context': + title: 4.4 Cascading in the page context + status: unknown + '#margin-boxes': + title: 5 Page-Margin Boxes + status: unknown + '#margin-at-rules': + title: 5.1 At-rules for page-margin boxes + status: unknown + '#populating-margin-boxes': + title: 5.2 Populating page-margin boxes + status: unknown + '#margin-dimension': + title: 5.3 Computing Page-margin Box Dimensions + status: unknown + '#margin-box-terms': + title: 5.3.1 Page-Margin Box Layout Terminology + status: unknown + '#variable-sizing': + title: 5.3.2 Page-Margin Box Variable Dimension Computation Rules + status: unknown + '#variable-auto-margins': + title: 5.3.2.1 Margins + status: unknown + '#variable-auto-sizing': + title: 5.3.2.2 Resolving auto widths + status: unknown + '#variable-minmax': + title: 5.3.2.3 Handling min-width and max-width + status: unknown + '#variable-position': + title: 5.3.2.4 Positioning + status: unknown + '#variable-mapping': + title: 5.3.2.5 Boxes on other sides + status: unknown + '#fixed-sizing': + title: 5.3.3 Page-Margin Box Fixed Dimension Computation Rules + status: unknown + '#margin-box-ex': + title: 5.4 Page-margin box examples + status: unknown + '#page-properties': + title: 6 Page Properties + status: unknown + '#page-based-counters': + title: 6.1 Page-based counters + status: unknown + '#margin-text-alignment': + title: 6.2 Page-margin boxes and default values + status: unknown + '#page-size': + title: 7 Page Size + status: unknown + '#page-size-prop': + title: '7.1 Page size: the size property' + status: unknown + '#page-size-media-query': + title: 7.1.1 Media Queries + status: unknown + '#page-orientation-prop': + title: '7.1.2 Rotating The Printed Page: the page-orientation property' + status: unknown + '#marks': + title: '7.2 Crop and Registration Marks: the marks property' + status: unknown + '#bleed': + title: '7.3 Bleed Area: the bleed property' + status: unknown + '#renderingpages': + title: 7.4 Rendering page boxes that do not fit a page sheet + status: unknown + '#positioning-page-box': + title: 7.5 Positioning the page box on the sheet + status: unknown + '#page-breaks': + title: 8 Page Breaks + status: unknown + '#using-named-pages': + title: '8.1 Using named pages: page' + status: unknown + '#image-properties': + title: 9 Image Properties + status: unknown diff --git a/Spec/css-position-3.yml b/Spec/css-position-3.yml new file mode 100644 index 000000000..df21609a9 --- /dev/null +++ b/Spec/css-position-3.yml @@ -0,0 +1,101 @@ +spec: css-position-3 +title: CSS Positioned Layout Module Level 3 +url: https://www.w3.org/TR/css-position-3/ +snapshot: https://www.w3.org/TR/2025/WD-css-position-3-20251007/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#position-property': + title: '2 Choosing A Positioning Scheme: position property' + status: unknown + '#def-cb': + title: 2.1 Containing Blocks of Positioned Boxes + status: unknown + '#original-cb': + title: 2.1.1 Further Adjustments to the Containing Block + status: unknown + '#stacking': + title: 2.2 Painting Order and Stacking Contexts + status: unknown + '#coords': + title: 3 Positioning Coordinates + status: unknown + '#insets': + title: '3.1 Box Insets: the top, right, bottom, left, inset-block-start, inset-inline-start, inset-block-end, and inset-inline-end properties' + status: unknown + '#inset-shorthands': + title: '3.2 Box Insets Shorthands: the inset-block, inset-inline, and inset properties' + status: unknown + '#relpos-insets': + title: 3.3 Relative Positioning + status: unknown + '#stickypos-insets': + title: 3.4 Sticky positioning + status: unknown + '#stickypos-scroll': + title: 3.4.1 Scroll Position of Sticky-Positioned Boxes + status: unknown + '#abspos-insets': + title: 3.5 Absolute (and Fixed) Positioning + status: unknown + '#resolving-insets': + title: 3.5.1 Resolvings Insets for the “Inset-Modified Containing Block” + status: unknown + '#staticpos-rect': + title: 3.5.2 Calculating the Static Position and the “Static-Position Rectangle” + status: unknown + '#abspos-breaking': + title: 3.5.3 Fragmenting Absolutely-positioned Elements + status: unknown + '#abspos-layout': + title: 4 Absolute Positioning Layout Model + status: unknown + '#abspos-auto-size': + title: 4.1 Automatic Sizes of Absolutely-Positioned Boxes + status: unknown + '#abspos-margins': + title: 4.2 Auto Margins of Absolutely-Positioned Boxes + status: unknown + '#abspos-alignment': + title: 5 Self-Alignment of Absolutely Positioned Boxes + status: unknown + '#abspos-old': + title: 6 Old Absolute Positioning Layout Model + status: unknown + '#abs-non-replaced-width': + title: 6.1 The Width of Absolutely-Positioned, Non-Replaced Elements + status: unknown + '#abs-replaced-width': + title: 6.2 The width of absolute or fixed positioned, replaced elements + status: unknown + '#abs-non-replaced-height': + title: 6.3 The Height Of Absolutely Positioned, Non-Replaced Elements + status: unknown + '#abs-replaced-height': + title: 6.4 The Height Of Absolutely Positioned, Replaced Elements + status: unknown + '#comparison': + title: 7 Informative Comparison of Normal Flow, Floats, and Positioning + status: unknown + '#comp-normal-flow': + title: 7.1 Normal Flow Example + status: unknown + '#comp-relpos': + title: 7.2 Relative Positioning Example + status: unknown + '#comp-floating': + title: 7.3 Floating Example + status: unknown + '#comp-abspos': + title: 7.4 Absolute Positioning Example + status: unknown + '#ack': + title: 8 Acknowledgments + status: unknown diff --git a/Spec/css-pseudo-4.yml b/Spec/css-pseudo-4.yml new file mode 100644 index 000000000..07b72a788 --- /dev/null +++ b/Spec/css-pseudo-4.yml @@ -0,0 +1,116 @@ +spec: css-pseudo-4 +title: CSS Pseudo-Elements Module Level 4 +url: https://www.w3.org/TR/css-pseudo-4/ +snapshot: https://www.w3.org/TR/2025/WD-css-pseudo-4-20250627/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#typographic-pseudos': + title: 2 Typographic Pseudo-elements + status: unknown + '#first-line-pseudo': + title: '2.1 First-Line Text: the ::first-line pseudo-element' + status: unknown + '#first-text-line': + title: 2.1.1 Finding the First Formatted Line + status: unknown + '#first-line-styling': + title: 2.1.2 Styling the ::first-line Pseudo-element + status: unknown + '#first-line-inheritance': + title: 2.1.3 Inheritance and the ::first-line Pseudo-element + status: unknown + '#first-letter-pseudo': + title: '2.2 First-Letter Text: ::first-letter pseudo-element and its ::prefix and ::suffix children' + status: unknown + '#first-letter-pattern': + title: 2.2.1 First Letters and Associated Punctuation + status: unknown + '#first-letter-application': + title: 2.2.2 Finding the First-Letter Text + status: unknown + '#first-letter-tree': + title: 2.2.3 Inheritance and Box Tree Structure of the First-Letter Pseudo-elements + status: unknown + '#first-letter-styling': + title: 2.2.4 Styling the First-Letter Pseudo-elements + status: unknown + '#highlight-pseudos': + title: 3 Highlight Pseudo-elements + status: unknown + '#highlight-selectors': + title: '3.1 Selecting Highlighted Content: the ::selection, ::search-text, ::target-text, ::spelling-error, ::grammar-error, and ::highlight() pseudo-elements' + status: unknown + '#highlight-styling': + title: 3.2 Styling Highlights + status: unknown + '#highlight-ua-styles': + title: 3.3 Default UA Styles + status: unknown + '#paired-defaults': + title: 3.3.1 Paired Defaults + status: unknown + '#highlight-bounds': + title: 3.4 Area of a Highlight + status: unknown + '#highlight-cascade': + title: 3.5 Cascading and Per-Element Highlight Styles + status: unknown + '#highlight-painting': + title: 3.6 Painting the Highlight + status: unknown + '#highlight-backgrounds': + title: 3.6.1 Backgrounds + status: unknown + '#highlight-shadows': + title: 3.6.2 Shadows + status: unknown + '#highlight-text': + title: 3.6.3 Text and Text Decorations + status: unknown + '#highlight-replaced': + title: 3.6.4 Replaced Elements + status: unknown + '#highlight-security': + title: 3.7 Security Considerations for Highlighting + status: unknown + '#treelike': + title: 4 Tree-Abiding Pseudo-elements + status: unknown + '#generated-content': + title: '4.1 Generated Content Pseudo-elements: ::before and ::after' + status: unknown + '#marker-pseudo': + title: '4.2 List Markers: the ::marker pseudo-element' + status: unknown + '#placeholder-pseudo': + title: '4.3 Placeholder Input: the ::placeholder pseudo-element' + status: unknown + '#element-like': + title: 5 Element-backed Pseudo-Elements + status: unknown + '#file-selector-button-pseudo': + title: '5.1 File Selector Button: the ::file-selector-button pseudo-element' + status: unknown + '#details-content-pseudo': + title: '5.2 Expandable contents of details element: the ::details-content pseudo-element' + status: unknown + '#interactions': + title: 6 Overlapping Pseudo-element Interactions + status: unknown + '#cssom': + title: 7 Additions to the CSS Object Model + status: unknown + '#CSSPseudoElement-interface': + title: 7.1 CSSPseudoElement Interface + status: unknown + '#window-interface': + title: 7.2 pseudo() method of the Element interface + status: unknown + '#getComputedStyle': + title: 7.3 getComputedStyle() + status: unknown + '#css2-compat': + title: 8 Compatibility Syntax + status: unknown diff --git a/Spec/css-sizing-3.yml b/Spec/css-sizing-3.yml new file mode 100644 index 000000000..7e967fcfc --- /dev/null +++ b/Spec/css-sizing-3.yml @@ -0,0 +1,80 @@ +spec: css-sizing-3 +title: CSS Box Sizing Module Level 3 +url: https://www.w3.org/TR/css-sizing-3/ +snapshot: https://www.w3.org/TR/2026/WD-css-sizing-3-20260904/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#terms': + title: 2 Terminology + status: unknown + '#auto-box-sizes': + title: 2.1 Auto Box Sizes + status: unknown + '#contributions': + title: 2.2 Intrinsic Size Contributions + status: unknown + '#constraints': + title: 2.3 Intrinsic Size Constraints + status: unknown + '#specifying-sizes': + title: 3 Specifying Box Sizes + status: unknown + '#sizing-properties': + title: 3.1 Sizing Properties + status: unknown + '#preferred-size-properties': + title: '3.1.1 Preferred Size Properties: the width, height, inline-size, and block-size properties' + status: unknown + '#min-size-properties': + title: '3.1.2 Minimum Size Properties: the min-width, min-height, min-inline-size, and min-block-size properties' + status: unknown + '#max-size-properties': + title: '3.1.3 Maximum Size Properties: the max-width, max-height, max-inline-size, and max-block-size properties' + status: unknown + '#sizing-values': + title: '3.2 Sizing Values: the <length-percentage>, auto | none, stretch, min-content, max-content, and fit-content values' + status: unknown + '#behave-auto': + title: 3.2.1 “Behaving as auto” + status: unknown + '#the-contain-floats-value': + title: 3.2.2 Containing or Excluding Floats + status: unknown + '#box-sizing': + title: '3.3 Box Edges for Sizing: the box-sizing property' + status: unknown + '#column-sizing': + title: '3.4 New Column Sizing Values: the stretch, min-content, max-content, and fit-content values' + status: unknown + '#extrinsic': + title: 4 Extrinsic Size Determination + status: unknown + '#percentage-sizing': + title: 4.1 Percentage Sizing + status: unknown + '#stretch-fit-sizing': + title: '4.2 Stretch-fit Sizing: filling the containing block' + status: unknown + '#intrinsic': + title: 5 Intrinsic Size Determination + status: unknown + '#intrinsic-sizes': + title: 5.1 Intrinsic Sizes + status: unknown + '#intrinsic-contribution': + title: 5.2 Intrinsic Contributions + status: unknown + '#cyclic-percentage-contribution': + title: 5.2.1 Intrinsic Contributions of Percentage-Sized Boxes + status: unknown + '#min-content-zero': + title: 5.2.2 Compressible Replaced Elements + status: unknown diff --git a/Spec/css-style-attr.yml b/Spec/css-style-attr.yml new file mode 100644 index 000000000..05c0bda30 --- /dev/null +++ b/Spec/css-style-attr.yml @@ -0,0 +1,23 @@ +spec: css-style-attr +title: CSS Style Attributes +url: https://www.w3.org/TR/css-style-attr/ +snapshot: https://www.w3.org/TR/2013/REC-css-style-attr-20131107/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#conformance': + title: 2 Conformance + status: unknown + '#syntax': + title: 3 Syntax and Parsing + status: unknown + '#interpret': + title: 4 Cascading and Interpretation + status: unknown + '#ack': + title: 5 Acknowledgments + status: unknown + '#references': + title: 6 References + status: unknown diff --git a/Spec/css-syntax-3.yml b/Spec/css-syntax-3.yml new file mode 100644 index 000000000..47546f712 --- /dev/null +++ b/Spec/css-syntax-3.yml @@ -0,0 +1,233 @@ +spec: css-syntax-3 +title: CSS Syntax Module Level 3 +url: https://www.w3.org/TR/css-syntax-3/ +snapshot: https://www.w3.org/TR/2026/CRD-css-syntax-3-20261001/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module interactions + status: unknown + '#syntax-description': + title: 2 Description of CSS’s Syntax + status: unknown + '#escaping': + title: 2.1 Escaping + status: unknown + '#error-handling': + title: 2.2 Error Handling + status: unknown + '#tokenizing-and-parsing': + title: 3 Tokenizing and Parsing CSS + status: unknown + '#parsing-overview': + title: 3.1 Overview of the Parsing Model + status: unknown + '#input-byte-stream': + title: 3.2 The input byte stream + status: unknown + '#input-preprocessing': + title: 3.3 Preprocessing the input stream + status: unknown + '#tokenization': + title: 4 Tokenization + status: unknown + '#token-diagrams': + title: 4.1 Token Railroad Diagrams + status: unknown + '#tokenizer-definitions': + title: 4.2 Definitions + status: unknown + '#tokenizer-algorithms': + title: 4.3 Tokenizer Algorithms + status: unknown + '#consume-token': + title: 4.3.1 Consume a token + status: unknown + '#consume-comment': + title: 4.3.2 Consume comments + status: unknown + '#consume-numeric-token': + title: 4.3.3 Consume a numeric token + status: unknown + '#consume-ident-like-token': + title: 4.3.4 Consume an ident-like token + status: unknown + '#consume-string-token': + title: 4.3.5 Consume a string token + status: unknown + '#consume-url-token': + title: 4.3.6 Consume a url token + status: unknown + '#consume-escaped-code-point': + title: 4.3.7 Consume an escaped code point + status: unknown + '#starts-with-a-valid-escape': + title: 4.3.8 Check if two code points are a valid escape + status: unknown + '#would-start-an-identifier': + title: 4.3.9 Check if three code points would start an ident sequence + status: unknown + '#starts-with-a-number': + title: 4.3.10 Check if three code points would start a number + status: unknown + '#starts-a-unicode-range': + title: 4.3.11 Check if three code points would start a unicode-range + status: unknown + '#consume-name': + title: 4.3.12 Consume an ident sequence + status: unknown + '#consume-number': + title: 4.3.13 Consume a number + status: unknown + '#consume-unicode-range-token': + title: 4.3.14 Consume a unicode-range token + status: unknown + '#consume-remnants-of-bad-url': + title: 4.3.15 Consume the remnants of a bad url + status: unknown + '#parsing': + title: 5 Parsing + status: unknown + '#parser-diagrams': + title: 5.1 Parser Railroad Diagrams + status: unknown + '#css-tree': + title: 5.2 CSS Parsing Results + status: unknown + '#parser-definitions': + title: 5.3 Token Streams + status: unknown + '#parser-entry-points': + title: 5.4 Parser Entry Points + status: unknown + '#parse-grammar': + title: 5.4.1 Parse something according to a CSS grammar + status: unknown + '#parse-comma-list': + title: 5.4.2 Parse a comma-separated list according to a CSS grammar + status: unknown + '#parse-stylesheet': + title: 5.4.3 Parse a stylesheet + status: unknown + '#parse-stylesheet-contents': + title: 5.4.4 Parse a stylesheet’s contents + status: unknown + '#parse-block-contents': + title: 5.4.5 Parse a block’s contents + status: unknown + '#parse-rule': + title: 5.4.6 Parse a rule + status: unknown + '#parse-declaration': + title: 5.4.7 Parse a declaration + status: unknown + '#parse-component-value': + title: 5.4.8 Parse a component value + status: unknown + '#parse-list-of-component-values': + title: 5.4.9 Parse a list of component values + status: unknown + '#parse-comma-separated-list-of-component-values': + title: 5.4.10 Parse a comma-separated list of component values + status: unknown + '#parser-algorithms': + title: 5.5 Parser Algorithms + status: unknown + '#consume-stylesheet-contents': + title: 5.5.1 Consume a stylesheet’s contents + status: unknown + '#consume-at-rule': + title: 5.5.2 Consume an at-rule + status: unknown + '#consume-qualified-rule': + title: 5.5.3 Consume a qualified rule + status: unknown + '#consume-block': + title: 5.5.4 Consume a block + status: unknown + '#consume-block-contents': + title: 5.5.5 Consume a block’s contents + status: unknown + '#consume-declaration': + title: 5.5.6 Consume a declaration + status: unknown + '#consume-list-of-components': + title: 5.5.7 Consume a list of component values + status: unknown + '#consume-component-value': + title: 5.5.8 Consume a component value + status: unknown + '#consume-simple-block': + title: 5.5.9 Consume a simple block + status: unknown + '#consume-function': + title: 5.5.10 Consume a function + status: unknown + '#consume-unicode-range-value': + title: 5.5.11 Consume a unicode-range value + status: unknown + '#anb-microsyntax': + title: 6 The An+B microsyntax + status: unknown + '#anb-syntax': + title: 6.1 Informal Syntax Description + status: unknown + '#the-anb-type': + title: 6.2 The <a-n-plus-b> type + status: unknown + '#rule-defs': + title: 7 Defining Grammars for Rules and Other Values + status: unknown + '#block-contents': + title: '7.1 Defining Block Contents: the <block-contents>, <declaration-list>, <qualified-rule-list>, <declaration-rule-list>, and <rule-list> productions' + status: unknown + '#any-value': + title: '7.2 Defining Arbitrary Contents: the <declaration-value> and <any-value> productions' + status: unknown + '#css-stylesheets': + title: 8 CSS stylesheets + status: unknown + '#style-rules': + title: 8.1 Style rules + status: unknown + '#at-rules': + title: 8.2 At-rules + status: unknown + '#charset-rule': + title: 8.3 The @charset Rule + status: unknown + '#serialization': + title: 9 Serialization + status: unknown + '#serializing-anb': + title: 9.1 Serializing <a-n-plus-b> + status: unknown + '#privacy': + title: 10 Privacy Considerations + status: unknown + '#security': + title: 11 Security Considerations + status: unknown + '#changes': + title: 12 Changes + status: unknown + '#changes-CR-20211224': + title: 12.1 Changes from the 24 December 2021 Candidate Recommendation Draft + status: unknown + '#changes-CR-20190716': + title: 12.2 Changes from the 16 August 2019 Candidate Recommendation + status: unknown + '#changes-CR-20140220': + title: 12.3 Changes from the 20 February 2014 Candidate Recommendation + status: unknown + '#changes-WD-20131105': + title: 12.4 Changes from the 5 November 2013 Last Call Working Draft + status: unknown + '#changes-WD-20130919': + title: 12.5 Changes from the 19 September 2013 Working Draft + status: unknown + '#changes-css21': + title: 12.6 Changes from CSS 2.1 and Selectors Level 3 + status: unknown diff --git a/Spec/css-tables-3.yml b/Spec/css-tables-3.yml new file mode 100644 index 000000000..06969125c --- /dev/null +++ b/Spec/css-tables-3.yml @@ -0,0 +1,233 @@ +spec: css-tables-3 +title: CSS Table Module Level 3 +url: https://www.w3.org/TR/css-tables-3/ +snapshot: https://www.w3.org/TR/2025/WD-css-tables-3-20251216/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#content-model': + title: 2 Content Model + status: unknown + '#table-structure': + title: 2.1 Table Structure + status: unknown + '#terminology': + title: 2.1.1 Terminology + status: unknown + '#fixup': + title: 2.2 Fixup + status: unknown + '#fixup-algorithm': + title: 2.2.1 Fixup Algorithm + status: unknown + '#fixup-boxes': + title: 2.2.2 Characteristics of fixup boxes + status: unknown + '#fixup-examples': + title: 2.2.3 Examples + status: unknown + '#layout': + title: 3 Layout + status: unknown + '#layout-principles': + title: 3.1 Core layout principles + status: unknown + '#table-layout-algorithm': + title: 3.2 Table layout algorithm + status: unknown + '#dimensioning-the-row-column-grid': + title: 3.3 Dimensioning the row/column grid + status: unknown + '#dimensioning-the-row-column-grid--step1': + title: 3.3.1 HTML Algorithm + status: unknown + '#dimensioning-the-row-column-grid--step2': + title: 3.3.2 Track merging + status: unknown + '#missing-cells-fixup': + title: 3.4 Missing cells fixup + status: unknown + '#layout-modes': + title: 3.5 Table layout modes + status: unknown + '#table-layout-property': + title: 3.5.1 The Table-Layout property + status: unknown + '#border-collapse-property': + title: 3.5.2 The Border-Collapse property + status: unknown + '#border-spacing-property': + title: 3.5.2.1 The Border-Spacing property + status: unknown + '#caption-side-property': + title: 3.5.3 The Caption-Side property + status: unknown + '#style-overrides': + title: 3.6 Style overrides + status: unknown + '#global-style-overrides': + title: 3.6.1 Overrides applying in all modes + status: unknown + '#collapsed-style-overrides': + title: 3.6.2 Overrides applying in collapsed-borders mode + status: unknown + '#border-collapsing': + title: 3.7 Border-collapsing + status: unknown + '#conflict-resolution-for-collapsed-borders': + title: 3.7.1 Conflict Resolution for Collapsed Borders + status: unknown + '#border-conflict-resolution-algorithm': + title: 3.7.1.1 Conflict Resolution Algorithm for Collapsed Borders + status: unknown + '#border-style-harmonization-algorithm': + title: 3.7.1.2 Harmonization Algorithm for Collapsed Borders + status: unknown + '#border-specificity': + title: 3.7.1.3 Specificity of a border style + status: unknown + '#content-measure': + title: 3.8 Computing table measures + status: unknown + '#computing-undistributable-space': + title: 3.8.1 Computing Undistributable Space + status: unknown + '#computing-cell-measures': + title: 3.8.2 Computing Cell Measures + status: unknown + '#computing-column-measures': + title: 3.8.3 Computing Column Measures + status: unknown + '#width-distribution': + title: 3.9 Available Width Distribution + status: unknown + '#computing-the-table-width': + title: 3.9.1 Computing the table width + status: unknown + '#width-distribution-principles': + title: 3.9.2 Core distribution principles + status: unknown + '#width-distribution-principles-rN': + title: 3.9.2.1 Rules + status: unknown + '#width-distribution-principles-rR': + title: 3.9.2.2 Available sizings + status: unknown + '#width-distribution-algorithm': + title: 3.9.3 Distribution algorithm + status: unknown + '#width-distribution-in-fixed-mode': + title: 3.9.3.1 Changes to width distribution in fixed mode + status: unknown + '#distributing-width-to-columns': + title: 3.9.3.2 Distributing excess width to columns + status: unknown + '#height-distribution': + title: 3.10 Available Height Distribution + status: unknown + '#computing-the-table-height': + title: 3.10.1 Computing the table height + status: unknown + '#row-layout': + title: 3.10.2 Row layout (first pass) + status: unknown + '#row-relayout': + title: 3.10.3 Row layout (second pass) + status: unknown + '#height-distribution-principles': + title: 3.10.4 Core distribution principles + status: unknown + '#height-distribution-algorithm': + title: 3.10.5 Distribution algorithm + status: unknown + '#table-cell-content-relayout': + title: 3.10.6 Table-cell content layout (second pass) + status: unknown + '#bounding-box-assignment': + title: 3.11 Positioning of cells, captions and other internal table boxes + status: unknown + '#abspos': + title: 4 Absolute Positioning + status: unknown + '#abspos-boxes-in-table-root': + title: 4.1 With a table-root as containing block + status: unknown + '#abspos-boxes-in-table-internal': + title: 4.2 With a table-internal as containing block + status: unknown + '#static-position': + title: 4.3 With a table-internal box as non-containing block parent + status: unknown + '#rendering': + title: 5 Rendering + status: unknown + '#paint-order': + title: 5.1 Paint order of cells + status: unknown + '#empty-cell-rendering': + title: 5.2 Empty cell rendering (separated-borders mode) + status: unknown + '#drawing-backgrounds-and-borders': + title: 5.3 Drawing backgrounds and borders + status: unknown + '#drawing-table-backgrounds-and-borders': + title: 5.3.1 Drawing table backgrounds and borders + status: unknown + '#drawing-collapsed-borders-1': + title: 5.3.1.1 Changes in collapsed-borders mode + status: unknown + '#drawing-cell-backgrounds': + title: 5.3.2 Drawing cell backgrounds + status: unknown + '#drawing-cell-borders': + title: 5.3.3 Drawing cell borders + status: unknown + '#drawing-collapsed-borders-2': + title: 5.3.3.1 Changes in collapsed-borders mode + status: unknown + '#border-style-overrides': + title: 5.3.4 Border styles (collapsed-borders mode) + status: unknown + '#visibility-collapse-rendering': + title: '5.4 Rendering for visibility: collapse' + status: unknown + '#visibility-collapse-cell-rendering': + title: '5.4.1 Rendering a visibility: collapse table cell' + status: unknown + '#visibility-collapse-track-rendering': + title: '5.4.2 Rendering a visibility: collapse table-track or table-track-group' + status: unknown + '#fragmentation': + title: 6 Fragmentation + status: unknown + '#breaking-rules': + title: 6.1 Breaking across fragmentainers + status: unknown + '#repeated-headers': + title: 6.2 Repeating headers across pages + status: unknown + '#security': + title: 7 Security Considerations + status: unknown + '#privacy': + title: 8 Privacy Considerations + status: unknown + '#bug-list': + title: 9 List of bugs being tracked + status: unknown + '#appendices': + title: 10 Appendices + status: unknown + '#mapping': + title: 10.1 Mapping between CSS & HTML attributes + status: unknown + '#TODO': + title: 11 (link here for missing sections) + status: unknown + '#changes': + title: 12 Changes + status: unknown diff --git a/Spec/css-text-3.yml b/Spec/css-text-3.yml new file mode 100644 index 000000000..4f5b7f563 --- /dev/null +++ b/Spec/css-text-3.yml @@ -0,0 +1,134 @@ +spec: css-text-3 +title: CSS Text Module Level 3 +url: https://www.w3.org/TR/css-text-3/ +snapshot: https://www.w3.org/TR/2026/CRD-css-text-3-20260814/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#languages': + title: 1.3 Languages and Typesetting + status: unknown + '#characters': + title: 1.4 Characters and Letters + status: unknown + '#text-encoding': + title: 1.5 Text Processing + status: unknown + '#transforming': + title: 2 Transforming Text + status: unknown + '#text-transform-property': + title: '2.1 Case Transforms: the text-transform property' + status: unknown + '#text-transform-mapping': + title: 2.1.1 Mapping Rules + status: unknown + '#text-transform-order': + title: 2.1.2 Order of Operations + status: unknown + '#white-space-property': + title: '3 White Space and Wrapping: the white-space property' + status: unknown + '#white-space-processing': + title: 4 White Space Processing & Control Characters + status: unknown + '#white-space-rules': + title: 4.1 The White Space Processing Rules + status: unknown + '#white-space-phase-1': + title: '4.1.1 Phase I: Collapsing and Transformation' + status: unknown + '#white-space-phase-2': + title: '4.1.2 Phase II: Trimming and Positioning' + status: unknown + '#line-break-transform': + title: 4.1.3 Segment Break Transformation Rules + status: unknown + '#tab-size-property': + title: '4.2 Tab Character Size: the tab-size property' + status: unknown + '#line-breaking': + title: 5 Line Breaking and Word Boundaries + status: unknown + '#word-break-property': + title: '5.1 Breaking Rules for Letters: the word-break property' + status: unknown + '#line-break-property': + title: '5.2 Line Breaking Strictness: the line-break property' + status: unknown + '#hyphenation': + title: '5.3 Hyphenation: the hyphens property' + status: unknown + '#overflow-wrap-property': + title: '5.4 Overflow Wrapping: the overflow-wrap (word-wrap) property' + status: unknown + '#line-break-details': + title: 5.5 Line Breaking Details + status: unknown + '#justification': + title: 6 Alignment and Justification + status: unknown + '#text-align-property': + title: '6.1 Text Alignment: the text-align shorthand' + status: unknown + '#text-align-all-property': + title: '6.2 Default Text Alignment: the text-align-all property' + status: unknown + '#text-align-last-property': + title: '6.3 Last Line Alignment: the text-align-last property' + status: unknown + '#text-justify-property': + title: '6.4 Justification Method: the text-justify property' + status: unknown + '#expanding-text': + title: 6.4.1 Expanding and Compressing Text + status: unknown + '#justify-symbols': + title: 6.4.2 Handling Symbols and Punctuation + status: unknown + '#justify-limits': + title: 6.4.3 Unexpandable Text + status: unknown + '#justify-cursive': + title: 6.4.4 Cursive Scripts + status: unknown + '#justify-algos': + title: 6.4.5 Minimum Requirements for auto Justification + status: unknown + '#spacing': + title: 7 Spacing + status: unknown + '#word-spacing-property': + title: '7.1 Word Spacing: the word-spacing property' + status: unknown + '#letter-spacing-property': + title: '7.2 Tracking: the letter-spacing property' + status: unknown + '#cursive-tracking': + title: 7.2.1 Cursive Scripts + status: unknown + '#boundary-shaping': + title: 7.3 Shaping Across Element Boundaries + status: unknown + '#edge-effects': + title: 8 Edge Effects + status: unknown + '#text-indent-property': + title: '8.1 First Line Indentation: the text-indent property' + status: unknown + '#hanging': + title: 8.2 Hanging Glyphs + status: unknown + '#hanging-punctuation-property': + title: '8.2.1 Hanging Punctuation: the hanging-punctuation property' + status: unknown + '#bidi-linebox': + title: 8.3 Bidirectionality and Line Boxes + status: unknown diff --git a/Spec/css-text-decor-3.yml b/Spec/css-text-decor-3.yml new file mode 100644 index 000000000..bbc151bfe --- /dev/null +++ b/Spec/css-text-decor-3.yml @@ -0,0 +1,62 @@ +spec: css-text-decor-3 +title: CSS Text Decoration Module Level 3 +url: https://www.w3.org/TR/css-text-decor-3/ +snapshot: https://www.w3.org/TR/2022/CRD-css-text-decor-3-20220505/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#terms': + title: 1.3 Terminology + status: unknown + '#line-decoration': + title: '2 Line Decoration: Underline, Overline, and Strike-Through' + status: unknown + '#text-decoration-line-property': + title: '2.1 Text Decoration Lines: the text-decoration-line property' + status: unknown + '#text-decoration-style-property': + title: '2.2 Text Decoration Style: the text-decoration-style property' + status: unknown + '#text-decoration-color-property': + title: '2.3 Text Decoration Color: the text-decoration-color property' + status: unknown + '#text-decoration-property': + title: '2.4 Text Decoration Shorthand: the text-decoration property' + status: unknown + '#text-underline-position-property': + title: '2.5 Text Underline Position: the text-underline-position property' + status: unknown + '#emphasis-marks': + title: 3 Emphasis Marks + status: unknown + '#text-emphasis-style-property': + title: '3.1 Emphasis Mark Style: the text-emphasis-style property' + status: unknown + '#text-emphasis-color-property': + title: '3.2 Emphasis Mark Color: the text-emphasis-color property' + status: unknown + '#text-emphasis-property': + title: '3.3 Emphasis Mark Shorthand: the text-emphasis property' + status: unknown + '#text-emphasis-position-property': + title: '3.4 Emphasis Mark Position: the text-emphasis-position property' + status: unknown + '#text-shadow-property': + title: '4 Text Shadows: the text-shadow property' + status: unknown + '#painting': + title: 5 Painting Text Decorations + status: unknown + '#painting-order': + title: 5.1 Painting Order of Text Decorations + status: unknown + '#overflow': + title: 5.2 Overflow of Text Decorations + status: unknown diff --git a/Spec/css-ui-3.yml b/Spec/css-ui-3.yml new file mode 100644 index 000000000..b2fafae8d --- /dev/null +++ b/Spec/css-ui-3.yml @@ -0,0 +1,77 @@ +spec: css-ui-3 +title: CSS Basic User Interface Module Level 3 (CSS3 UI) +url: https://www.w3.org/TR/css-ui-3/ +snapshot: https://www.w3.org/TR/2026/REC-css-ui-3-20260407/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#purpose': + title: 1.1 Purpose + status: unknown + '#interaction': + title: 2 Module Interactions + status: unknown + '#values': + title: 2.1 Value Definitions + status: unknown + '#box-model': + title: 3 Box Model addition + status: unknown + '#box-sizing': + title: '3.1 Changing the Box Model: the box-sizing property' + status: unknown + '#outline-props': + title: 4 Outline properties + status: unknown + '#outline': + title: '4.1 Outlines Shorthand: the outline property' + status: unknown + '#outline-width': + title: '4.2 Outline Thickness: the outline-width property' + status: unknown + '#outline-style': + title: '4.3 Outline Patterns: the outline-style property' + status: unknown + '#outline-color': + title: '4.4 Outline Colors: the outline-color property' + status: unknown + '#outline-offset': + title: '4.5 Offsetting the Outline: the outline-offset property' + status: unknown + '#resizing-and-overflow': + title: 5 Resizing & Overflow + status: unknown + '#resize': + title: '5.1 Resizing Boxes: the resize property' + status: unknown + '#text-overflow': + title: '5.2 Overflow Ellipsis: the text-overflow property' + status: unknown + '#pointing-keyboard': + title: 6 Pointing Devices and Keyboards + status: unknown + '#pointer-interaction': + title: 6.1 Pointer interaction + status: unknown + '#cursor': + title: '6.1.1 Styling the Cursor: the cursor property' + status: unknown + '#predefined-cursors': + title: 6.1.1.1 Predefined Cursors + status: unknown + '#canvas_cursor': + title: 6.1.1.2 Cursor of the canvas + status: unknown + '#insertion-caret': + title: 6.2 Insertion caret + status: unknown + '#caret-color': + title: '6.2.1 Coloring the Insertion Caret: the caret-color property' + status: unknown + '#keyboard': + title: 6.3 Keyboard control + status: unknown + '#input-method-editor': + title: '6.3.1 Obsolete: the ime-mode property' + status: unknown diff --git a/Spec/css-values-4.yml b/Spec/css-values-4.yml new file mode 100644 index 000000000..427082cc2 --- /dev/null +++ b/Spec/css-values-4.yml @@ -0,0 +1,260 @@ +spec: css-values-4 +title: CSS Values and Units Module Level 4 +url: https://www.w3.org/TR/css-values-4/ +snapshot: https://www.w3.org/TR/2024/WD-css-values-4-20240312/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#value-defs': + title: 2 Value Definition Syntax + status: unknown + '#component-types': + title: 2.1 Component Value Types + status: unknown + '#component-combinators': + title: 2.2 Component Value Combinators + status: unknown + '#component-multipliers': + title: 2.3 Component Value Multipliers + status: unknown + '#combinator-multiplier-patterns': + title: 2.4 Combinator and Multiplier Patterns + status: unknown + '#component-whitespace': + title: 2.5 Component Values and White Space + status: unknown + '#component-functions': + title: 2.6 Functional Notation Definitions + status: unknown + '#value-examples': + title: 2.7 Property Value Examples + status: unknown + '#production-blocks': + title: 2.8 Non-Terminal Definitions and Grammar Production Blocks + status: unknown + '#combining-values': + title: '3 Combining Values: Interpolation, Addition, and Accumulation' + status: unknown + '#combining-range': + title: 3.1 Range Checking + status: unknown + '#textual-values': + title: 4 Textual Data Types + status: unknown + '#keywords': + title: 4.1 Pre-defined Keywords + status: unknown + '#common-keywords': + title: '4.1.1 CSS-wide keywords: initial, inherit and unset' + status: unknown + '#custom-idents': + title: '4.2 Unprefixed Author-defined Identifiers: the <custom-ident> type' + status: unknown + '#dashed-idents': + title: '4.3 Prefixed Author-defined Identifiers: the <dashed-ident> type' + status: unknown + '#strings': + title: '4.4 Quoted Strings: the <string> type' + status: unknown + '#urls': + title: '4.5 Resource Locators: the <url> type' + status: unknown + '#relative-urls': + title: 4.5.1 Relative URLs + status: unknown + '#local-urls': + title: 4.5.1.1 Fragment URLs + status: unknown + '#url-empty': + title: 4.5.2 Empty URLs + status: unknown + '#url-modifiers': + title: 4.5.3 URL Modifiers + status: unknown + '#url-processing': + title: 4.5.4 URL Processing Model + status: unknown + '#numeric-types': + title: 5 Numeric Data Types + status: unknown + '#numeric-ranges': + title: 5.1 Range Restrictions and Range Definition Notation + status: unknown + '#integers': + title: '5.2 Integers: the <integer> type' + status: unknown + '#combine-integers': + title: 5.2.1 Computation and Combination of <integer> + status: unknown + '#numbers': + title: '5.3 Real Numbers: the <number> type' + status: unknown + '#combine-numbers': + title: 5.3.1 Computation and Combination of <number> + status: unknown + '#dimensions': + title: '5.4 Numbers with Units: dimension values' + status: unknown + '#compat': + title: 5.4.1 Compatible Units + status: unknown + '#combine-dimensions': + title: 5.4.2 Combination of Dimensions + status: unknown + '#percentages': + title: '5.5 Percentages: the <percentage> type' + status: unknown + '#combine-percentages': + title: 5.5.1 Computation and Combination of <percentage> + status: unknown + '#mixed-percentages': + title: 5.6 Mixing Percentages and Dimensions + status: unknown + '#combine-mixed': + title: 5.6.1 Computation and Combination of Percentage and Dimension Mixes + status: unknown + '#ratios': + title: '5.7 Ratios: the <ratio> type' + status: unknown + '#combine-ratio': + title: 5.7.1 Combination of <ratio> + status: unknown + '#lengths': + title: '6 Distance Units: the <length> type' + status: unknown + '#relative-lengths': + title: 6.1 Relative Lengths + status: unknown + '#font-relative-lengths': + title: '6.1.1 Font-relative Lengths: the em, rem, ex, rex, cap, rcap, ch, rch, ic, ric, lh, rlh units' + status: unknown + '#viewport-relative-lengths': + title: '6.1.2 Viewport-percentage Lengths: the *vw, *vh, *vi, *vb, *vmin, *vmax units' + status: unknown + '#viewport-variants': + title: 6.1.2.1 The Large, Small, and Dynamic Viewport Sizes + status: unknown + '#viewport-relative-units': + title: 6.1.2.2 The Various Viewport-relative Units + status: unknown + '#absolute-lengths': + title: '6.2 Absolute Lengths: the cm, mm, Q, in, pt, pc, px units' + status: unknown + '#other-units': + title: 7 Other Quantities + status: unknown + '#angles': + title: '7.1 Angle Units: the <angle> type and deg, grad, rad, turn units' + status: unknown + '#time': + title: '7.2 Duration Units: the <time> type and s, ms units' + status: unknown + '#frequency': + title: '7.3 Frequency Units: the <frequency> type and Hz, kHz units' + status: unknown + '#resolution': + title: '7.4 Resolution Units: the <resolution> type and dpi, dpcm, dppx units' + status: unknown + '#defined-elsewhere': + title: 8 Data Types Defined Elsewhere + status: unknown + '#colors': + title: '8.1 Colors: the <color> type' + status: unknown + '#combine-colors': + title: 8.1.1 Combination of <color> + status: unknown + '#images': + title: '8.2 Images: the <image> type' + status: unknown + '#combine-images': + title: 8.2.1 Combination of <image> + status: unknown + '#position': + title: '8.3 2D Positioning: the <position> type' + status: unknown + '#position-parsing': + title: 8.3.1 Parsing <position> + status: unknown + '#position-serialization': + title: 8.3.2 Serializing <position> + status: unknown + '#combine-positions': + title: 8.3.3 Combination of <position> + status: unknown + '#functional-notations': + title: 9 Functional Notations + status: unknown + '#math': + title: 10 Mathematical Expressions + status: unknown + '#calc-func': + title: '10.1 Basic Arithmetic: calc()' + status: unknown + '#comp-func': + title: '10.2 Comparison Functions: min(), max(), and clamp()' + status: unknown + '#round-func': + title: '10.3 Stepped Value Functions: round(), mod(), and rem()' + status: unknown + '#round-infinities': + title: 10.3.1 Argument Ranges + status: unknown + '#trig-funcs': + title: '10.4 Trigonometric Functions: sin(), cos(), tan(), asin(), acos(), atan(), and atan2()' + status: unknown + '#trig-infinities': + title: 10.4.1 Argument Ranges + status: unknown + '#exponent-funcs': + title: '10.5 Exponential Functions: pow(), sqrt(), hypot(), log(), exp()' + status: unknown + '#exponent-infinities': + title: 10.5.1 Argument Ranges + status: unknown + '#sign-funcs': + title: '10.6 Sign-Related Functions: abs(), sign()' + status: unknown + '#calc-keywords': + title: 10.7 Numeric Keywords + status: unknown + '#calc-constants': + title: '10.7.1 Numeric Constants: e, pi' + status: unknown + '#calc-error-constants': + title: '10.7.2 Degenerate Numeric Constants: infinity, -infinity, NaN' + status: unknown + '#calc-variables': + title: 10.7.3 Numeric Variables + status: unknown + '#calc-syntax': + title: 10.8 Syntax + status: unknown + '#calc-type-checking': + title: 10.9 Type Checking + status: unknown + '#calc-ieee': + title: 10.9.1 Infinities, NaN, and Signed Zero + status: unknown + '#calc-internal': + title: 10.10 Internal Representation + status: unknown + '#calc-simplification': + title: 10.10.1 Simplification + status: unknown + '#calc-computed-value': + title: 10.11 Computed Value + status: unknown + '#calc-range': + title: 10.12 Range Checking + status: unknown + '#calc-serialize': + title: 10.13 Serialization + status: unknown + '#combine-math': + title: 10.14 Combination of Math Functions + status: unknown diff --git a/Spec/css-variables-1.yml b/Spec/css-variables-1.yml new file mode 100644 index 000000000..6e377551d --- /dev/null +++ b/Spec/css-variables-1.yml @@ -0,0 +1,62 @@ +spec: css-variables-1 +title: CSS Custom Properties for Cascading Variables Module Level 1 +url: https://www.w3.org/TR/css-variables-1/ +snapshot: https://www.w3.org/TR/2022/CR-css-variables-1-20220616/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#defining-variables': + title: '2 Defining Custom Properties: the --* family of properties' + status: unknown + '#syntax': + title: 2.1 Custom Property Value Syntax + status: unknown + '#guaranteed-invalid': + title: 2.2 Guaranteed-Invalid Values + status: unknown + '#cycles': + title: 2.3 Resolving Dependency Cycles + status: unknown + '#using-variables': + title: '3 Using Cascading Variables: the var() notation' + status: unknown + '#invalid-variables': + title: 3.1 Invalid Variables + status: unknown + '#variables-in-shorthands': + title: 3.2 Variables in Shorthand Properties + status: unknown + '#long-variables': + title: 3.3 Safely Handling Overly-Long Variables + status: unknown + '#apis': + title: 4 APIs + status: unknown + '#serializing-custom-props': + title: 4.1 Serializing Custom Properties + status: unknown + '#changes': + title: 5 Changes + status: unknown + '#changes-20211111': + title: 5.1 Changes Since the 11 November 2021 CR Draft + status: unknown + '#changes-20151203': + title: 5.2 Changes Since the 03 December 2015 CR + status: unknown + '#changes-20140506': + title: 5.3 Changes since the May 6 2014 Last Call Working Draft + status: unknown + '#acks': + title: 6 Acknowledgments + status: unknown + '#privacy': + title: 7 Privacy Considerations + status: unknown + '#security': + title: 8 Security Considerations + status: unknown diff --git a/Spec/mediaqueries-4.yml b/Spec/mediaqueries-4.yml new file mode 100644 index 000000000..403744939 --- /dev/null +++ b/Spec/mediaqueries-4.yml @@ -0,0 +1,125 @@ +spec: mediaqueries-4 +title: Media Queries Level 4 +url: https://www.w3.org/TR/mediaqueries-4/ +snapshot: https://www.w3.org/TR/2026/CRD-mediaqueries-4-20260219/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module interactions + status: unknown + '#values': + title: 1.2 Values + status: unknown + '#units': + title: 1.3 Units + status: unknown + '#media': + title: 2 Media Queries + status: unknown + '#mq-list': + title: 2.1 Combining Media Queries + status: unknown + '#mq-prefix': + title: 2.2 Media Query Modifiers + status: unknown + '#mq-not': + title: '2.2.1 Negating a Media Query: the not keyword' + status: unknown + '#mq-only': + title: '2.2.2 Hiding a Media Query From Legacy user agents: the only keyword' + status: unknown + '#media-types': + title: 2.3 Media Types + status: unknown + '#mq-features': + title: 2.4 Media Features + status: unknown + '#mq-ranges': + title: '2.4.1 Media Feature Types: “range” and “discrete”' + status: unknown + '#mq-boolean-context': + title: 2.4.2 Evaluating Media Features in a Boolean Context + status: unknown + '#mq-range-context': + title: 2.4.3 Evaluating Media Features in a Range Context + status: unknown + '#mq-min-max': + title: 2.4.4 Using “min-” and “max-” Prefixes On Range Features + status: unknown + '#media-conditions': + title: 2.5 Combining Media Features + status: unknown + '#mq-syntax': + title: 3 Syntax + status: unknown + '#evaluating': + title: 3.1 Evaluating Media Queries + status: unknown + '#error-handling': + title: 3.2 Error Handling + status: unknown + '#mf-dimensions': + title: 4 Viewport/Page Dimensions Media Features + status: unknown + '#width': + title: '4.1 Width: the width feature' + status: unknown + '#height': + title: '4.2 Height: the height feature' + status: unknown + '#aspect-ratio': + title: '4.3 Aspect-Ratio: the aspect-ratio feature' + status: unknown + '#orientation': + title: '4.4 Orientation: the orientation feature' + status: unknown + '#mf-display-quality': + title: 5 Display Quality Media Features + status: unknown + '#resolution': + title: '5.1 Display Resolution: the resolution feature' + status: unknown + '#scan': + title: '5.2 Display Type: the scan feature' + status: unknown + '#grid': + title: '5.3 Detecting Console Displays: the grid feature' + status: unknown + '#update': + title: '5.4 Display Update Frequency: the update feature' + status: unknown + '#mf-overflow-block': + title: '5.5 Block-Axis Overflow: the overflow-block feature' + status: unknown + '#mf-overflow-inline': + title: '5.6 Inline-Axis Overflow: the overflow-inline feature' + status: unknown + '#mf-colors': + title: 6 Color Media Features + status: unknown + '#color': + title: '6.1 Color Depth: the color feature' + status: unknown + '#color-index': + title: '6.2 Paletted Color Screens: the color-index feature' + status: unknown + '#monochrome': + title: '6.3 Monochrome Screens: the monochrome feature' + status: unknown + '#color-gamut': + title: '6.4 Color Display Quality: the color-gamut feature' + status: unknown + '#mf-interaction': + title: 7 Interaction Media Features + status: unknown + '#pointer': + title: '7.1 Pointing Device Quality: the pointer feature' + status: unknown + '#hover': + title: '7.2 Hover Capability: the hover feature' + status: unknown + '#@media/any-input': + title: '7.3 All Available Interaction Capabilities: the any-pointer and any-hover features' + status: unknown diff --git a/Spec/selectors-4.yml b/Spec/selectors-4.yml new file mode 100644 index 000000000..5ce2fffb7 --- /dev/null +++ b/Spec/selectors-4.yml @@ -0,0 +1,353 @@ +spec: selectors-4 +title: Selectors Level 4 +url: https://www.w3.org/TR/selectors-4/ +snapshot: https://www.w3.org/TR/2026/WD-selectors-4-20260122/ +sections: + '#context': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#overview': + title: 2 Selectors Overview + status: unknown + '#syntax': + title: 3 Selector Syntax and Structure + status: unknown + '#structure': + title: 3.1 Structure and Terminology + status: unknown + '#data-model': + title: 3.2 Data Model + status: unknown + '#featureless-elements': + title: 3.2.1 Featureless Elements + status: unknown + '#scoping': + title: 3.3 Scoped Selectors + status: unknown + '#relative': + title: 3.4 Relative Selectors + status: unknown + '#pseudo-classes': + title: 3.5 Pseudo-classes + status: unknown + '#pseudo-elements': + title: 3.6 Pseudo-elements + status: unknown + '#pseudo-element-syntax': + title: 3.6.1 Syntax + status: unknown + '#pseudo-element-attachment': + title: 3.6.2 Binding to the Document Tree + status: unknown + '#pseudo-element-states': + title: 3.6.3 Pseudo-classing Pseudo-elements + status: unknown + '#sub-pseudo-elements': + title: 3.6.4 Sub-pseudo-elements + status: unknown + '#pseudo-element-structure': + title: 3.6.5 Internal Structure + status: unknown + '#case-sensitive': + title: 3.7 Characters and case sensitivity + status: unknown + '#namespaces': + title: 3.8 Declaring Namespace Prefixes + status: unknown + '#invalid': + title: 3.9 Invalid Selectors and Error Handling + status: unknown + '#legacy-aliasing': + title: 3.10 Legacy Aliases + status: unknown + '#logical-combination': + title: 4 Logical Combinations + status: unknown + '#grouping': + title: 4.1 Selector Lists + status: unknown + '#matches': + title: '4.2 The Matches-Any Pseudo-class: :is()' + status: unknown + '#negation': + title: '4.3 The Negation (Matches-None) Pseudo-class: :not()' + status: unknown + '#zero-matches': + title: '4.4 The Specificity-adjustment Pseudo-class: :where()' + status: unknown + '#relational': + title: '4.5 The Relational Pseudo-class: :has()' + status: unknown + '#elemental-selectors': + title: 5 Elemental selectors + status: unknown + '#type-selectors': + title: 5.1 Type (tag name) selector + status: unknown + '#the-universal-selector': + title: 5.2 Universal selector + status: unknown + '#type-nmsp': + title: 5.3 Namespaces in Elemental Selectors + status: unknown + '#the-defined-pseudo': + title: '5.4 The Defined Pseudo-class: :defined' + status: unknown + '#attribute-selectors': + title: 6 Attribute selectors + status: unknown + '#attribute-representation': + title: 6.1 Attribute presence and value selectors + status: unknown + '#attribute-substrings': + title: 6.2 Substring matching attribute selectors + status: unknown + '#attribute-case': + title: 6.3 Case-sensitivity + status: unknown + '#attrnmsp': + title: 6.4 Attribute selectors and namespaces + status: unknown + '#def-values': + title: 6.5 Default attribute values in DTDs + status: unknown + '#class-html': + title: 6.6 Class selectors + status: unknown + '#id-selectors': + title: 6.7 ID selectors + status: unknown + '#linguistic-pseudos': + title: 7 Linguistic Pseudo-classes + status: unknown + '#the-dir-pseudo': + title: '7.1 The Directionality Pseudo-class: :dir()' + status: unknown + '#the-lang-pseudo': + title: '7.2 The Language Pseudo-class: :lang()' + status: unknown + '#location': + title: 8 Location Pseudo-classes + status: unknown + '#the-any-link-pseudo': + title: '8.1 The Hyperlink Pseudo-class: :any-link' + status: unknown + '#link': + title: '8.2 The Link History Pseudo-classes: :link and :visited' + status: unknown + '#the-target-pseudo': + title: '8.3 The Target Pseudo-class: :target' + status: unknown + '#the-scope-pseudo': + title: '8.4 The Reference Element Pseudo-class: :scope' + status: unknown + '#useraction-pseudos': + title: 9 User Action Pseudo-classes + status: unknown + '#the-hover-pseudo': + title: '9.1 The Pointer Hover Pseudo-class: :hover' + status: unknown + '#the-active-pseudo': + title: '9.2 The Activation Pseudo-class: :active' + status: unknown + '#the-focus-pseudo': + title: '9.3 The Input Focus Pseudo-class: :focus' + status: unknown + '#the-focus-visible-pseudo': + title: '9.4 The Focus-Indicated Pseudo-class: :focus-visible' + status: unknown + '#the-focus-within-pseudo': + title: '9.5 The Focus Container Pseudo-class: :focus-within' + status: unknown + '#resource-pseudos': + title: 10 Resource State Pseudo-classes + status: unknown + '#video-state': + title: '10.1 Media Playback State: the :playing, :paused, and :seeking pseudo-classes' + status: unknown + '#media-loading-state': + title: '10.2 Media Loading State: the :buffering and :stalled pseudo-classes' + status: unknown + '#sound-state': + title: '10.3 Sound State: the :muted and :volume-locked pseudo-classes' + status: unknown + '#display-state-pseudos': + title: 11 Element Display State Pseudo-classes + status: unknown + '#open-state': + title: '11.1 Collapse State: the :open pseudo-class' + status: unknown + '#popover-open-state': + title: '11.2 Popover State: the :popover-open pseudo-class' + status: unknown + '#modal-state': + title: '11.3 Modal (Exclusive Interaction) State: the :modal pseudo-class' + status: unknown + '#fullscreen-state': + title: '11.4 Fullscreen Presentation State: the :fullscreen pseudo-class' + status: unknown + '#pip-state': + title: '11.5 Picture-in-Picture Presentation State: the :picture-in-picture pseudo-class' + status: unknown + '#input-pseudos': + title: 12 The Input Pseudo-classes + status: unknown + '#input-states': + title: 12.1 Input Control States + status: unknown + '#enableddisabled': + title: 12.1.1 The :enabled and :disabled Pseudo-classes + status: unknown + '#rw-pseudos': + title: '12.1.2 The Mutability Pseudo-classes: :read-only and :read-write' + status: unknown + '#placeholder': + title: '12.1.3 The Placeholder-shown Pseudo-class: :placeholder-shown' + status: unknown + '#autofill': + title: '12.1.4 The Automatic Input Pseudo-class: :autofill' + status: unknown + '#the-default-pseudo': + title: '12.1.5 The Default-option Pseudo-class: :default' + status: unknown + '#input-value-states': + title: 12.2 Input Value States + status: unknown + '#checked': + title: '12.2.1 The Selected-option Pseudo-classes: :checked, :unchecked, and :indeterminate' + status: unknown + '#ui-validity': + title: 12.3 Input Value-checking + status: unknown + '#validity-pseudos': + title: '12.3.1 The Validity Pseudo-classes: :valid and :invalid' + status: unknown + '#range-pseudos': + title: '12.3.2 The Range Pseudo-classes: :in-range and :out-of-range' + status: unknown + '#opt-pseudos': + title: '12.3.3 The Optionality Pseudo-classes: :required and :optional' + status: unknown + '#user-pseudos': + title: '12.3.4 The User-interaction Pseudo-classes: :user-valid and :user-invalid' + status: unknown + '#structural-pseudos': + title: 13 Tree-Structural pseudo-classes + status: unknown + '#the-root-pseudo': + title: 13.1 :root pseudo-class + status: unknown + '#the-empty-pseudo': + title: 13.2 :empty pseudo-class + status: unknown + '#child-index': + title: 13.3 Child-indexed Pseudo-classes + status: unknown + '#the-nth-child-pseudo': + title: 13.3.1 :nth-child() pseudo-class + status: unknown + '#the-nth-last-child-pseudo': + title: 13.3.2 :nth-last-child() pseudo-class + status: unknown + '#the-first-child-pseudo': + title: 13.3.3 :first-child pseudo-class + status: unknown + '#the-last-child-pseudo': + title: 13.3.4 :last-child pseudo-class + status: unknown + '#the-only-child-pseudo': + title: 13.3.5 :only-child pseudo-class + status: unknown + '#typed-child-index': + title: 13.4 Typed Child-indexed Pseudo-classes + status: unknown + '#the-nth-of-type-pseudo': + title: 13.4.1 :nth-of-type() pseudo-class + status: unknown + '#the-nth-last-of-type-pseudo': + title: 13.4.2 :nth-last-of-type() pseudo-class + status: unknown + '#the-first-of-type-pseudo': + title: 13.4.3 :first-of-type pseudo-class + status: unknown + '#the-last-of-type-pseudo': + title: 13.4.4 :last-of-type pseudo-class + status: unknown + '#the-only-of-type-pseudo': + title: 13.4.5 :only-of-type pseudo-class + status: unknown + '#combinators': + title: 14 Combinators + status: unknown + '#descendant-combinators': + title: 14.1 Descendant combinator ( ) + status: unknown + '#child-combinators': + title: 14.2 Child combinator (>) + status: unknown + '#adjacent-sibling-combinators': + title: 14.3 Next-sibling combinator (+) + status: unknown + '#general-sibling-combinators': + title: 14.4 Subsequent-sibling combinator (~) + status: unknown + '#specificity-rules': + title: 15 Calculating a selector’s specificity + status: unknown + '#grammar': + title: 16 Grammar + status: unknown + '#forgiving-selector': + title: 16.1 <forgiving-selector-list> and <forgiving-relative-selector-list> + status: unknown + '#api-hooks': + title: 17 API Hooks + status: unknown + '#parse-selector': + title: 17.1 Parse A Selector + status: unknown + '#parse-relative-selector': + title: 17.2 Parse A Relative Selector + status: unknown + '#match-against-element': + title: 17.3 Match a Selector Against an Element + status: unknown + '#match-against-pseudo-element': + title: 17.4 Match a Selector Against a Pseudo-element + status: unknown + '#match-against-tree': + title: 17.5 Match a Selector Against a Tree + status: unknown + '#changes': + title: 18 Changes + status: unknown + '#changes-2022-11': + title: 18.1 Changes since the 11 November 2022 Working Draft + status: unknown + '#changes-2022-05': + title: 18.2 Changes since the 7 May 2022 Working Draft + status: unknown + '#changes-2018-11': + title: 18.3 Changes since the 21 November 2018 Working Draft + status: unknown + '#changes-2018-02': + title: 18.4 Changes since the 2 February 2018 Working Draft + status: unknown + '#changes-2013': + title: 18.5 Changes since the 2 May 2013 Working Draft + status: unknown + '#changes-2012': + title: 18.6 Changes since the 23 August 2012 Working Draft + status: unknown + '#changes-2011': + title: 18.7 Changes since the 29 September 2011 Working Draft + status: unknown + '#changes-level-3': + title: 18.8 Changes Since Level 3 + status: unknown + '#acknowledgements': + title: 19 Acknowledgements + status: unknown From 0568c1651f8234571cf2b15427fbe1a891193334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:03:58 +0100 Subject: [PATCH 051/150] Verify and document CSS Syntax Level 3 compliance --- .../Core/CssEngine/Parser/Lexer.cs | 24 +- .../Core/CssEngine/Parser/LexerBase.cs | 12 +- .../CssEngine/Parser/SelectorConstructor.cs | 11 +- .../CssEngine/Parser/StylesheetComposer.cs | 15 +- .../Core/CssEngine/Parser/StylesheetParser.cs | 2 - .../Core/CssEngine/TextEncoding.cs | 2 - .../Core/CssEngine/Tokens/RangeToken.cs | 3 +- .../HtmlRenderer.Test/Css/FontFaceTests.cs | 7 +- .../HtmlRenderer.Test/Css/GrammarTests.cs | 2 + .../Css/SyntaxAndDataTypesTests.cs | 19 + .../Css/TokenizationTests.cs | 7 +- .../CssModules/CssSyntaxTests.cs | 603 ++++++++++++++++++ Spec/css-syntax-3.yml | 205 +++--- 13 files changed, 750 insertions(+), 162 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssSyntaxTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/Parser/Lexer.cs b/Source/HtmlRenderer/Core/CssEngine/Parser/Lexer.cs index 9e039e85e..b3f3db7e0 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Parser/Lexer.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Parser/Lexer.cs @@ -321,7 +321,7 @@ private Token ColorLiteral() { var current = GetNext(); - // '#' not followed by a name code point or a valid escape is a plain delimiter (CSS Syntax §4.3.4). + // CSS Syntax 3 §4.3.1 if (!current.IsName() && !IsValidEscape(current)) { Back(); @@ -330,8 +330,7 @@ private Token ColorLiteral() Back(); - // CSS Syntax §4.3.4: the hash consumes the whole name. Only an all-hex name ("#f00") is a color - // literal, anything else ("#hero") stays an id hash. + // In a value an all-hex name ("#f00") becomes a color token, anything else ("#hero") stays a hash. var allHex = true; while (true) @@ -548,23 +547,6 @@ private Token IdentRest(char current) current = GetNext(); } } - //private Token TransformFunctionWhitespace(char current) - //{ - // while (true) - // { - // current = GetNext(); - // if (current == Symbols.RoundBracketOpen) - // { - // Back(); - // return NewFunction(FlushBuffer()); - // } - // if (!current.IsSpaceCharacter()) - // { - // Back(2); - // return NewIdent(FlushBuffer()); - // } - // } - //} private Token NumberStart(char current) { @@ -939,7 +921,7 @@ private Token UnicodeRange(char current) current = GetNext(); } - // Wildcards ("U+4??") and a range end ("U+41-5A") exclude each other, the range end is handled below. + // CSS Syntax 3 §4.3.14: wildcards ("U+4??") and a range end ("U+41-5A") exclude each other. if (StringBuffer.Length != 6 && current == Symbols.QuestionMark) { // Captured up front, the loop appends to StringBuffer. diff --git a/Source/HtmlRenderer/Core/CssEngine/Parser/LexerBase.cs b/Source/HtmlRenderer/Core/CssEngine/Parser/LexerBase.cs index c326435f4..8bcd72b0b 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Parser/LexerBase.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Parser/LexerBase.cs @@ -43,18 +43,14 @@ public TextPosition GetCurrentPosition() } /// <summary> - /// Repositions the lexer so the next token is re-lexed from character index - /// <paramref name="sourceIndex"/> (a raw <see cref="Source"/> index, as returned by - /// <see cref="InsertionPoint"/>). Unlike setting <see cref="InsertionPoint"/> (whose - /// <c>BackNative</c> loop is not a faithful inverse across <c>\r\n</c> normalization), this - /// restores the character stream exactly — line/column tracking is not rewound (it only affects - /// reported positions, never token content). Used by <see cref="StylesheetComposer"/> to rewind a - /// CSS-Nesting classification look-ahead so a declaration re-lexes cleanly in value mode. + /// Lexes the next token from the raw <see cref="Source"/> index <paramref name="sourceIndex"/>, e.g. one + /// returned by <see cref="InsertionPoint"/>. Unlike setting <see cref="InsertionPoint"/> this is exact + /// across <c>\r\n</c> normalization. Line and column aren't rewound. /// </summary> public void RewindTo(int sourceIndex) { Source.Index = sourceIndex; - // Non-EOF so the next Advance() actually reads Source[sourceIndex] rather than short-circuiting. + // Anything but EOF, Advance() doesn't read past EOF. Current = Symbols.Null; } diff --git a/Source/HtmlRenderer/Core/CssEngine/Parser/SelectorConstructor.cs b/Source/HtmlRenderer/Core/CssEngine/Parser/SelectorConstructor.cs index 12abeb4b9..9da947e1f 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Parser/SelectorConstructor.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Parser/SelectorConstructor.cs @@ -884,12 +884,7 @@ private bool OnOffset(Token token) case TokenType.Whitespace: return false; case TokenType.Delim when token.Data.IsOneOf("+", "-"): - // The An+B offset's sign is a standalone delim token when whitespace separates it - // from B - the "<n-dimension> ['+' | '-'] <signless-integer>" production of - // <a-n-plus-b> (CSS Syntax 3 §6.2); §6.1 gives `3n + 1` as a valid example. The - // compact form `10n+1` instead arrives as a single signed <number>, handled by the - // Number case below. Without this branch a spaced sign fell through to OnBeforeOf - // and invalidated the selector. + // CSS Syntax 3 §6.2: "3n + 1", the compact "3n+1" is a signed number below. _sign = token.Data == "-" ? -1 : 1; _state = ParseState.AfterOffsetSign; return false; @@ -910,9 +905,7 @@ private bool OnAfterOffsetSign(Token token) case TokenType.Whitespace: return false; case TokenType.Number when !token.Data.StartsWith("+", StringComparison.Ordinal) && !token.Data.StartsWith("-", StringComparison.Ordinal): - // The production requires a <signless-integer> here, defined as "a <number-token> - // with its type flag set to integer, and no sign character" (CSS Syntax 3 §6.2), so - // `10n + -1` and `10n + +1` are invalid - §6.1 lists `3n + -6` as an invalid example. + // CSS Syntax 3 §6.2: a <signless-integer>, "3n + -6" is invalid. _valid = _valid && ((NumberToken)token).IsInteger && int.TryParse(token.Data, out _offset); _offset *= _sign; _state = ParseState.BeforeOf; diff --git a/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetComposer.cs b/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetComposer.cs index d28f5fff8..96fb88601 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetComposer.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetComposer.cs @@ -1403,19 +1403,8 @@ private TextPosition FillRules(GroupingRule group) while (token.IsNot(TokenType.EndOfFile, TokenType.CurlyBracketClose)) { - // "Consume a block's contents" discards a <semicolon-token> outright, exactly as it does a - // <whitespace-token> (CSS Syntax 3 §5.5.5). Passing it on to CreateStyle instead feeds it to - // SelectorConstructor, which has no recovery for an unexpected semicolon: it folds the token - // into the next rule's selector and invalidates it, and because the run-on rule keeps - // consuming, the block's own closing brace is swallowed and the following sibling rule is - // lost with it. - // - // Deliberately NOT done in CreateRules: "consume a stylesheet's contents" (§5.5.1) has no - // <semicolon-token> case, so a stray semicolon at the top level falls to "anything else" and - // is consumed into the next qualified rule's prelude, invalidating it. Only a block passes - // <semicolon-token> as the stop token to "consume a qualified rule" (§5.5.3). The Acid2 - // parser-torture block relies on that: its ".parser { m\argin: 2em; };" is followed by - // ".parser { height: 3em; }", which must NOT apply. + // CSS Syntax 3 §5.5.5. Not in CreateRules, at the top level (§5.5.1) a semicolon starts a + // qualified rule and invalidates it, Acid2 relies on that. if (token.Type == TokenType.Semicolon) { token = NextToken(); diff --git a/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetParser.cs b/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetParser.cs index fa60a6f57..7977e8b95 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetParser.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetParser.cs @@ -120,7 +120,6 @@ internal async Task<Stylesheet> ParseAsync(Stylesheet sheet, TextSource source) var tokenizer = new Lexer(source); var start = tokenizer.GetCurrentPosition(); var builder = new StylesheetComposer(tokenizer, this); - //var tasks = new List<Task>(); var end = builder.CreateRules(sheet); var range = new TextRange(start, end); sheet.StylesheetText = new StylesheetText(range, source); @@ -131,7 +130,6 @@ internal async Task<Stylesheet> ParseAsync(Stylesheet sheet, TextSource source) if (rule.Type != RuleType.Import) break; } - //await TaskEx.WhenAll(tasks).ConfigureAwait(false); return sheet; } diff --git a/Source/HtmlRenderer/Core/CssEngine/TextEncoding.cs b/Source/HtmlRenderer/Core/CssEngine/TextEncoding.cs index bbb3f2af5..17ebb909a 100644 --- a/Source/HtmlRenderer/Core/CssEngine/TextEncoding.cs +++ b/Source/HtmlRenderer/Core/CssEngine/TextEncoding.cs @@ -65,8 +65,6 @@ private static Encoding GetEncoding(string name) { } - - // Catch all since WP8 does throw a different exception than W*. return Utf8; } diff --git a/Source/HtmlRenderer/Core/CssEngine/Tokens/RangeToken.cs b/Source/HtmlRenderer/Core/CssEngine/Tokens/RangeToken.cs index fee373664..66f0cd0fa 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Tokens/RangeToken.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Tokens/RangeToken.cs @@ -16,8 +16,7 @@ public RangeToken(string start, string end, TextPosition position) End = end; } - // Data holds the range without its "U+" prefix, so serializing it bare would emit "41-5A" - not a - // valid <urange> and not what was authored. + // Data is the range without "U+". public override string ToValue() { return string.Concat("U+", Data); diff --git a/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs index 42b8e60d5..af764b11d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs @@ -70,10 +70,9 @@ public void FontFaceOpenSansNoSource() [DataRow("U+4??")] [DataRow("U+000041")] [DataRow("U+0025-00FF, U+4??")] + [Spec("css-syntax-3", "#consume-unicode-range-value")] public void FontFaceUnicodeRangeIsRetained(string range) { - // Data holds the range without its "U+" prefix, so a retained descriptor used to serialize as - // "41-5A" - not a valid <urange> and not what was authored. var src = "@font-face{font-family:'Open Sans';unicode-range:" + range + "}"; var sheet = CssConstructionFunctions.ParseStyleSheet(src); Assert.AreEqual(1, sheet.Rules.Length); @@ -83,8 +82,8 @@ public void FontFaceUnicodeRangeIsRetained(string range) } [TestMethod] - // The wildcard budget is captured up front; re-evaluating "6 - StringBuffer.Length" while appending - // to that same buffer consumed only half the available wildcards, so U+?????? stopped after three. + [Spec("css-syntax-3", "#starts-a-unicode-range")] + [Spec("css-syntax-3", "#consume-unicode-range-token")] [DataRow("U+4??", "400", "4FF")] [DataRow("U+??????", "000000", "FFFFFF")] [DataRow("U+41", "41", "41")] diff --git a/Source/Test/HtmlRenderer.Test/Css/GrammarTests.cs b/Source/Test/HtmlRenderer.Test/Css/GrammarTests.cs index 08b1afe43..ccf1874cc 100644 --- a/Source/Test/HtmlRenderer.Test/Css/GrammarTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/GrammarTests.cs @@ -56,6 +56,7 @@ public void Selector_FollowsTheGrammar(string selector) [TestMethod] [Spec("css21", "grammar.html#grammar")] + [Spec("css-syntax-3", "#consume-declaration")] public void Declaration_TakesImportantWithWhitespaceAndComments() { Assert.AreEqual(Green, Styled("#x { color: green ! /* c */ important } #x { color: red }").Color); @@ -123,6 +124,7 @@ public void SpaceTabLineBreaksAndFormFeed_AreWhitespace() [TestMethod] [Spec("css21", "grammar.html#scanner")] + [Spec("css-syntax-3", "#consume-comment")] public void UnclosedCommentAtTheEnd_IsIgnored() { Assert.AreEqual(Green, Styled("#x { color: green } /* #x { color: red }").Color); diff --git a/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs b/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs index 03d1502fe..374a2d392 100644 --- a/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs @@ -113,6 +113,7 @@ public void PseudoClassAndPseudoElementNames_AreCaseInsensitive() [DataRow(@"#x{color:gr\000065en}")] [DataRow(@"#x{color:gr\000065 en}")] [DataRow("#x{color:gr\\65\r\nen}")] + [Spec("css-syntax-3", "#consume-escaped-code-point")] public void EscapedIdentifier_IsTheSameIdentifier(string css) { Assert.AreEqual(Green, Styled(css).Color); @@ -141,6 +142,7 @@ public void IdentifierStartingWithADigit_IsInvalid() [TestMethod] [Spec("css21", "syndata.html#characters")] + [Spec("css-syntax-3", "#consume-escaped-code-point")] public void OnlyOneWhitespaceAfterAHexEscape_IsConsumed() { Assert.AreEqual("&B & B", Before(@"#x::before{content:'\26 B \26 B'}")); @@ -148,6 +150,7 @@ public void OnlyOneWhitespaceAfterAHexEscape_IsConsumed() [TestMethod] [Spec("css21", "syndata.html#characters")] + [Spec("css-syntax-3", "#consume-escaped-code-point")] public void EscapeOutsideTheUnicodeRange_IsTheReplacementCharacter() { Assert.AreEqual("�", Before(@"#x::before{content:'\110000'}")); @@ -155,6 +158,8 @@ public void EscapeOutsideTheUnicodeRange_IsTheReplacementCharacter() [TestMethod] [Spec("css21", "syndata.html#statements")] + [Spec("css-syntax-3", "#parse-stylesheet")] + [Spec("css-syntax-3", "#at-rules")] public void StyleSheet_IsAListOfAtRulesAndRuleSets() { var sheet = ParseStyleSheet(" @media print { p { color: red } } \n p { color: green } @page { margin: 1cm } "); @@ -167,6 +172,8 @@ public void StyleSheet_IsAListOfAtRulesAndRuleSets() [TestMethod] [Spec("css21", "syndata.html#at-rules")] + [Spec("css-syntax-3", "#consume-at-rule")] + [Spec("css-syntax-3", "#css-stylesheets")] public void AtRule_EndsAtTheNextSemicolonOrBlock() { var box = Styled("@foo bar; #x{color:green} @foo { #x{color:red} } #x{background-color:green}"); @@ -186,6 +193,7 @@ public void MisplacedImport_IsIgnored(string css) [TestMethod] [Spec("css21", "syndata.html#block")] + [Spec("css-syntax-3", "#consume-simple-block")] public void Block_MatchesBracketsAndSkipsBracesInStrings() { Assert.AreEqual(Green, Styled(@"@foo { causta: ""}"" + ({7} * '\'') } #x { color: green }").Color); @@ -195,6 +203,7 @@ public void Block_MatchesBracketsAndSkipsBracesInStrings() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "syndata.html#block")] + [Spec("css-syntax-3", "#consume-simple-block")] public void BlockInADeclarationValue_IsSkippedAsPartOfTheDeclaration() { Assert.AreEqual(Green, Styled(@"#x { causta: ""}"" + ({7} * '\'') ; color: green }").Color); @@ -203,6 +212,7 @@ public void BlockInADeclarationValue_IsSkippedAsPartOfTheDeclaration() [TestMethod] [Spec("css21", "syndata.html#rule-sets")] + [Spec("css-syntax-3", "#style-rules")] public void InvalidSelector_DropsTheWholeRuleSet() { var css = "h1, h2 {color: green} h3, h4 # h5 {color: red} h6 {color: black}"; @@ -248,6 +258,7 @@ public void SemicolonInAString_DoesntEndTheDeclaration() [TestMethod] [Spec("css21", "syndata.html#comments")] + [Spec("css-syntax-3", "#consume-comment")] public void Comments_DontNest() { Assert.AreEqual(Green, Styled("#x { color: red; /* /* */ color: green; /* */ }").Color); @@ -255,6 +266,7 @@ public void Comments_DontNest() [TestMethod] [Spec("css21", "syndata.html#comments")] + [Spec("css-syntax-3", "#consume-stylesheet-contents")] public void SgmlCommentDelimiters_AreIgnoredAroundStatements() { var box = Styled("<!-- #x { color: green } --> <!-- #x { background-color: green } -->"); @@ -280,6 +292,7 @@ public void SgmlCommentDelimiters_AreIgnoredAroundStatements() [DataRow("@media screen { #x { color: green; azimuth: 30deg; } }")] [DataRow("@media screen { #x { color: green")] [DataRow("#x { color: green; font-family: 'Courier New Times\n color: red; }")] + [Spec("css-syntax-3", "#parser-algorithms")] public void ParsingError_IsIgnoredAndParsingResumes(string css) { Assert.AreEqual(Green, Styled(css).Color); @@ -287,6 +300,7 @@ public void ParsingError_IsIgnoredAndParsingResumes(string css) [TestMethod] [Spec("css21", "syndata.html#parsing-errors")] + [Spec("css-syntax-3", "#consume-string-token")] public void UnexpectedEndOfStyleSheet_ClosesTheOpenString() { Assert.AreEqual("Hello", Before("@media screen { #x::before { content: 'Hello")); @@ -583,6 +597,7 @@ public void String_TakesEitherQuoteAndEscapes(string value, string expected) [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "syndata.html#strings")] + [Spec("css-syntax-3", "#consume-string-token")] public void StringBrokenOverLines_IsTheSameString() { var css = "#x[title=\"a not s\\\no very long title\"] { color: green }"; @@ -603,6 +618,7 @@ public void UnsupportedValue_IsIgnored() [DataRow("utf-8")] [DataRow("utf-16")] [DataRow("utf-16BE")] + [Spec("css-syntax-3", "#input-byte-stream")] public void ExternalStyleSheet_IsDecodedFromItsByteOrderMark(string name) { var encoding = Encoding.GetEncoding(name); @@ -613,6 +629,7 @@ public void ExternalStyleSheet_IsDecodedFromItsByteOrderMark(string name) [TestMethod] [Spec("css21", "syndata.html#charset")] + [Spec("css-syntax-3", "#input-byte-stream")] public void ExternalStyleSheet_WithoutBomOrCharset_IsUtf8() { var (box, _) = StyledWithFile("style.css", Encoding.UTF8.GetBytes("#x::before{content:'κ'}")); @@ -623,6 +640,7 @@ public void ExternalStyleSheet_WithoutBomOrCharset_IsUtf8() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "syndata.html#charset")] + [Spec("css-syntax-3", "#input-byte-stream")] public void ExternalStyleSheet_IsDecodedWithItsCharsetRule() { var bytes = Encoding.ASCII.GetBytes("@charset \"ISO-8859-1\"; #x::before{content:'L") @@ -634,6 +652,7 @@ public void ExternalStyleSheet_IsDecodedWithItsCharsetRule() [TestMethod] [Spec("css21", "syndata.html#charset")] + [Spec("css-syntax-3", "#charset-rule")] public void CharsetRuleNotAtTheStart_IsIgnored() { Assert.AreEqual(Green, Styled("#x{color:red} @charset \"ISO-8859-1\"; #x{color:green}").Color); diff --git a/Source/Test/HtmlRenderer.Test/Css/TokenizationTests.cs b/Source/Test/HtmlRenderer.Test/Css/TokenizationTests.cs index 3cb2c16c9..f7aff44bd 100644 --- a/Source/Test/HtmlRenderer.Test/Css/TokenizationTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/TokenizationTests.cs @@ -27,6 +27,7 @@ public void CssParserAtRule() [TestMethod] [Spec("css21", "grammar.html#scanner")] + [Spec("css-syntax-3", "#consume-url-token")] public void CssParserUrlUnquoted() { var url = "http://someurl"; @@ -59,7 +60,7 @@ public void CssParserUrlSingleQuoted() } [TestMethod] - [Spec("css21", "grammar.html#scanner")] + [Spec("css-syntax-3", "#consume-remnants-of-bad-url")] public void CssParserUrlBad_UnexpectedQuoteInUnquotedUrl_RecoversAtClosingParen() { var teststring = "url(bad\"value) next"; @@ -67,6 +68,7 @@ public void CssParserUrlBad_UnexpectedQuoteInUnquotedUrl_RecoversAtClosingParen( var urlToken = tokenizer.Get(); Assert.AreEqual(TokenType.Url, urlToken.Type); + Assert.IsTrue(((UrlToken)urlToken).IsValid, "UrlToken.IsValid holds whether the url is bad"); Assert.AreEqual(TokenType.Whitespace, tokenizer.Get().Type); @@ -130,6 +132,7 @@ public void LexerInvalidEscapedCodePointInIdent(string input) [TestMethod] [Spec("css21", "grammar.html#scanner")] + [Spec("css-syntax-3", "#input-preprocessing")] public void LexerOnlyCarriageReturn() { var teststring = "\r"; @@ -140,6 +143,7 @@ public void LexerOnlyCarriageReturn() [TestMethod] [Spec("css21", "grammar.html#scanner")] + [Spec("css-syntax-3", "#input-preprocessing")] public void LexerCarriageReturnLineFeed() { var teststring = "\r\n"; @@ -150,6 +154,7 @@ public void LexerCarriageReturnLineFeed() [TestMethod] [Spec("css21", "grammar.html#scanner")] + [Spec("css-syntax-3", "#input-preprocessing")] public void LexerOnlyLineFeed() { var teststring = "\n"; diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssSyntaxTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssSyntaxTests.cs new file mode 100644 index 000000000..807b81ee6 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssSyntaxTests.cs @@ -0,0 +1,603 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using static HtmlRenderer.Test.CssEngineSupport.CssConstructionFunctions; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssSyntaxTests +{ + private const string Green = "rgb(0, 128, 0)"; + private const double Delta = 0.01; + private const string Replacement = "\xFFFD"; + + [TestMethod] + [Spec("css-syntax-3", "#input-preprocessing")] + public void FormFeed_IsANewline() + { + var tokens = Tokens("'a\fb'"); + + Assert.AreEqual(TokenType.String, tokens[0].Type); + Assert.AreEqual("a", tokens[0].Data); + Assert.AreEqual(TokenType.Whitespace, tokens[1].Type); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#input-preprocessing")] + public void Null_IsTheReplacementCharacter() + { + var tokens = Tokens("'a\0b' c\0d"); + + Assert.AreEqual("a" + Replacement + "b", tokens[0].Data); + Assert.AreEqual("c" + Replacement + "d", tokens[2].Data); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#tokenizer-definitions")] + [DataRow("a\x00D7b")] + [DataRow("a\x00A0b")] + [DataRow("a\x2028b")] + public void NonAsciiCodePointOutsideTheIdentRanges_EndsTheIdent(string css) + { + var tokens = Tokens(css); + + Assert.AreEqual("a", tokens[0].Data); + Assert.AreEqual(TokenType.Delim, tokens[1].Type); + } + + [TestMethod] + [Spec("css-syntax-3", "#tokenizer-definitions")] + [DataRow("a\x00B7b")] + [DataRow("\x00E9t\x00E9")] + [DataRow("\x4E2D\x6587")] + [DataRow("_a-1")] + [Spec("css-syntax-3", "#consume-name")] + public void IdentCodePoints_FormOneIdent(string css) + { + var tokens = Tokens(css); + + Assert.AreEqual(1, tokens.Count); + Assert.AreEqual(TokenType.Ident, tokens[0].Type); + Assert.AreEqual(css, tokens[0].Data); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-token")] + [DataRow("#a", "Hash", "a")] + [DataRow(@"#\31 a", "Hash", "1a")] + [DataRow("#", "Delim", "#")] + [DataRow("+.5", "Number", "+.5")] + [DataRow("-.5", "Number", "-.5")] + [DataRow(".5", "Number", ".5")] + [DataRow("-->", "Cdc", "-->")] + [DataRow("<!--", "Cdo", "<!--")] + [DataRow("@media", "AtKeyword", "media")] + [DataRow("@-x", "AtKeyword", "-x")] + [DataRow("@", "Delim", "@")] + [DataRow("+", "Delim", "+")] + [DataRow(".", "Delim", ".")] + [DataRow(",", "Comma", ",")] + [DataRow(":", "Colon", ":")] + [DataRow(";", "Semicolon", ";")] + [DataRow("[", "SquareBracketOpen", "[")] + [DataRow("]", "SquareBracketClose", "]")] + [DataRow("{", "CurlyBracketOpen", "{")] + [DataRow("}", "CurlyBracketClose", "}")] + [DataRow("(", "RoundBracketOpen", "(")] + [DataRow(")", "RoundBracketClose", ")")] + public void Token_IsConsumedByItsFirstCodePoint(string css, string type, string data) + { + var tokens = Tokens(css); + + Assert.AreEqual(type, tokens[0].Type.ToString()); + Assert.AreEqual(data, tokens[0].Data); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-token")] + public void BackslashBeforeANewline_IsADelim() + { + var tokens = Tokens("\\\na"); + + Assert.AreEqual(TokenType.Delim, tokens[0].Type); + Assert.AreEqual("\\", tokens[0].Data); + Assert.AreEqual(TokenType.Whitespace, tokens[1].Type); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-token")] + public void AtFollowedByTwoHyphens_IsAnAtKeyword() + { + Assert.AreEqual(TokenType.AtKeyword, Tokens("@--x")[0].Type); + Assert.AreEqual(Green, Styled("@--x; #x { color: green }").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-token")] + public void UPlus_IsNoUnicodeRangeOutsideTheUnicodeRangeDescriptor() + { + Assert.AreEqual(Green, Styled("u+a { color: green }", "<u>u</u><a id=x>a</a>").Color); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-comment")] + public void Comments_ProduceNoTokens() + { + var tokens = Tokens("/* a */b/**/c/* d"); + + Assert.AreEqual("b", tokens.Single(t => t.Type == TokenType.Ident && t.Data == "b").Data); + Assert.AreEqual("c", tokens.Last(t => t.Type == TokenType.Ident).Data); + Assert.AreEqual(Green, Styled("#x/* a */{/**/color/**/:/**/green/* b").Color); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-numeric-token")] + [Spec("css-syntax-3", "#consume-number")] + [DataRow("10", "Number", "10", null)] + [DataRow("+10", "Number", "+10", null)] + [DataRow("-1.5", "Number", "-1.5", null)] + [DataRow("10%", "Percentage", "10", "%")] + [DataRow("10px", "Dimension", "10", "px")] + [DataRow("1.5em", "Dimension", "1.5", "em")] + [DataRow("1e", "Dimension", "1", "e")] + [DataRow(@"10\70x", "Dimension", "10", "px")] + public void NumericToken_IsANumberPercentageOrDimension(string css, string type, string number, string? unit) + { + var token = Tokens(css)[0]; + + Assert.AreEqual(type, token.Type.ToString()); + Assert.AreEqual(number, token.Data); + if (unit != null) Assert.AreEqual(unit, ((UnitToken)token).Unit); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-numeric-token")] + [DataRow("10x2", "x2")] + [DataRow("10--x", "--x")] + [DataRow("10a_b", "a_b")] + [DataRow("1e-", "e-")] + public void DimensionUnit_IsAnIdentSequence(string css, string unit) + { + var tokens = Tokens(css); + + Assert.AreEqual(1, tokens.Count); + Assert.AreEqual(TokenType.Dimension, tokens[0].Type); + Assert.AreEqual(unit, ((UnitToken)tokens[0]).Unit); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-number")] + [DataRow("1e3", "1e3")] + [DataRow("1E-3", "1E-3")] + [DataRow(".5e+2", ".5e+2")] + public void Exponent_IsPartOfTheNumber(string css, string number) + { + var tokens = Tokens(css); + + Assert.AreEqual(1, tokens.Count); + Assert.AreEqual(TokenType.Number, tokens[0].Type); + Assert.AreEqual(number, tokens[0].Data); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-number")] + public void NumberWithExponent_CanHaveAUnit() + { + Assert.AreEqual(100, Styled("#x{width:1e2px}").ActualWidth, Delta); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-ident-like-token")] + [DataRow("foo", "Ident", "foo")] + [DataRow("rgb(", "Function", "rgb")] + [DataRow("url(a.png)", "Url", "a.png")] + [DataRow("URL(a.png)", "Url", "a.png")] + [DataRow(@"u\72l(a.png)", "Url", "a.png")] + public void IdentLikeToken_IsAnIdentFunctionOrUrl(string css, string type, string data) + { + var token = Tokens(css)[0]; + + Assert.AreEqual(type, token.Type.ToString()); + Assert.AreEqual(data, token.Data); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-ident-like-token")] + public void UrlWithAQuotedArgument_IsAFunctionToken() + { + var token = Tokens("url( 'a.png' )")[0]; + + Assert.AreEqual(TokenType.Function, token.Type); + Assert.AreEqual("url", token.Data); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-ident-like-token")] + public void UrlWithALineBreakInTheQuotedArgument_IsAFunctionWithABadString() + { + var tokens = Tokens("url(\"a\nb\") c"); + + Assert.AreEqual(TokenType.Function, tokens[0].Type); + Assert.AreEqual(TokenType.String, tokens[1].Type); + Assert.IsTrue(((StringToken)tokens[1]).IsValid, "StringToken.IsValid holds whether the string is bad"); + Assert.AreEqual(TokenType.Whitespace, tokens[2].Type); + Assert.AreEqual("b", tokens[3].Data); + Assert.AreEqual(TokenType.String, tokens[4].Type); + Assert.AreEqual(") c", tokens[4].Data); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-string-token")] + public void StringAtTheEndOfInput_IsClosed() + { + var token = Tokens("'abc")[0]; + + Assert.AreEqual(TokenType.String, token.Type); + Assert.AreEqual("abc", token.Data); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-string-token")] + public void BackslashAtTheEndOfInput_IsDroppedFromTheString() + { + var token = (StringToken)Tokens("'abc\\")[0]; + + Assert.AreEqual("abc", token.Data); + Assert.IsFalse(token.IsValid, "StringToken.IsValid holds whether the string is bad"); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-string-token")] + public void BadString_InvalidatesTheDeclaration() + { + Assert.AreEqual("a", Before("#x::before{content:'a'} #x::before{content:'b\n}")); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-url-token")] + [DataRow("url(a.png", "a.png")] + [DataRow(@"url(a\).png)", "a).png")] + [DataRow("url(\n a.png \n)", "a.png")] + public void UnquotedUrl_IsAUrlToken(string css, string url) + { + var token = Tokens(css)[0]; + + Assert.AreEqual(TokenType.Url, token.Type); + Assert.AreEqual(url, token.Data); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-url-token")] + [DataRow("url(a b.png)")] + [DataRow("url(a(b.png)")] + [DataRow("url(a\u0001.png)")] + public void BadUrl_InvalidatesTheDeclaration(string url) + { + Assert.AreEqual("none", Styled($"#x{{background-image:{url}}}").BackgroundImage); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-escaped-code-point")] + [DataRow(@"#x::before{content:'\26 B'}", "&B")] + [DataRow(@"#x::before{content:'\000026B'}", "&B")] + [DataRow(@"#x::before{content:'\1F600'}", "\xD83D\xDE00")] + [DataRow(@"#x::before{content:'\110000'}", Replacement)] + [DataRow(@"#x::before{content:'\0'}", Replacement)] + [DataRow(@"#x::before{content:'\g'}", "g")] + public void EscapedCodePoint_IsDecoded(string css, string expected) + { + Assert.AreEqual(expected, Before(css)); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-escaped-code-point")] + public void EscapedSurrogate_IsTheReplacementCharacter() + { + Assert.AreEqual(Replacement + Replacement + Replacement, Tokens(@"'\D800\DABC\DFFF'")[0].Data); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-escaped-code-point")] + [Spec("css-syntax-3", "#starts-with-a-valid-escape")] + public void BackslashAtTheEndOfInput_IsTheReplacementCharacterInAnIdent() + { + var tokens = Tokens("a\\"); + + Assert.AreEqual(1, tokens.Count); + Assert.AreEqual("a" + Replacement, tokens[0].Data); + } + + [TestMethod] + [Spec("css-syntax-3", "#starts-with-a-valid-escape")] + [Spec("css-syntax-3", "#would-start-an-identifier")] + [DataRow("-a", "Ident", "-a")] + [DataRow("--", "Ident", "--")] + [DataRow("--a", "Ident", "--a")] + [DataRow(@"-\31 ", "Ident", "-1")] + [DataRow(@"\31 a", "Ident", "1a")] + [DataRow("-1", "Number", "-1")] + [DataRow("- a", "Delim", "-")] + public void IdentStart_IsALetterHyphenOrEscape(string css, string type, string data) + { + var token = Tokens(css)[0]; + + Assert.AreEqual(type, token.Type.ToString()); + Assert.AreEqual(data, token.Data); + } + + [TestMethod] + [Spec("css-syntax-3", "#starts-with-a-number")] + [DataRow("+1", "Number")] + [DataRow("-1", "Number")] + [DataRow("+.1", "Number")] + [DataRow("-.1", "Number")] + [DataRow(".1", "Number")] + [DataRow("+.a", "Delim")] + [DataRow(".a", "Delim")] + public void NumberStart_IsADigitAfterAnOptionalSignAndDot(string css, string type) + { + Assert.AreEqual(type, Tokens(css)[0].Type.ToString()); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-remnants-of-bad-url")] + public void BadUrl_RunsOverASemicolon() + { + Assert.AreEqual(Green, Styled("#x { color: green; background-image: url(a\"b; color: red; x) }").Color); + Assert.AreEqual("d", Tokens("url(a\"b; c) d").Last().Data); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-remnants-of-bad-url")] + public void BadUrl_EndsAtTheFirstClosingParenthesisEvenAfterAnOpeningOne() + { + Assert.AreEqual("c", Tokens("url(a\"(b) c").Last().Data); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-remnants-of-bad-url")] + public void BadUrl_SkipsAnEscapedClosingParenthesis() + { + var tokens = Tokens(@"url(a""b\) c) d"); + + Assert.AreEqual(3, tokens.Count); + Assert.AreEqual(TokenType.Url, tokens[0].Type); + Assert.AreEqual("d", tokens[2].Data); + } + + [TestMethod] + [Spec("css-syntax-3", "#parse-block-contents")] + public void StyleAttribute_IsParsedAsABlocksContents() + { + var box = Styled("", "<div id=x style='; color: green ;; background-color: green; '>a</div>"); + + Assert.AreEqual(Green, box.Color); + Assert.AreEqual(Green, box.BackgroundColor); + } + + [TestMethod] + [Spec("css-syntax-3", "#parse-declaration")] + public void SupportsCondition_ParsesADeclaration() + { + var supports = (SupportsRule)ParseStyleSheet("@supports ( color : green ) { }").Rules[0]; + + Assert.IsTrue(supports.Condition.Check()); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-at-rule")] + public void TopLevelCloseBraceInAnAtRulePrelude_IsPartOfThePrelude() + { + Assert.AreEqual(Green, Styled("#x { color: green } @foo } #x { color: red }").Color); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-at-rule")] + public void AtRule_EndsAtASemicolonOrAfterItsBlock() + { + var box = Styled("@foo a ( ; ) [ ; ] ; #x { color: green } @foo { ; } #x { background-color: green }"); + + Assert.AreEqual(Green, box.Color); + Assert.AreEqual(Green, box.BackgroundColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-at-rule")] + public void BraceInsideABracketBlockOfAnAtRulePrelude_DoesNotStartTheAtRuleBlock() + { + // The [ block is never closed, it takes the rest of the style sheet into the prelude. + Assert.AreEqual(Green, Styled("#x { color: green } @foo [ { ] ; } #x { color: red }").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-qualified-rule")] + public void TopLevelCloseBraceInAQualifiedRulePrelude_IsPartOfThePrelude() + { + Assert.AreEqual(Green, Styled("#x { color: green } } #x { color: red }").Color); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-qualified-rule")] + public void QualifiedRule_WithoutABlock_IsDropped() + { + Assert.AreEqual(Green, Styled("#x { color: green } #x").Color); + Assert.AreEqual(Green, Styled("#x { color: green } --x: { color: red } #x { background-color: green }").BackgroundColor); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-block")] + [Spec("css-syntax-3", "#consume-block-contents")] + public void SemicolonsAndWhitespaceInABlock_AreDiscarded() + { + Assert.AreEqual(Green, Styled("#x { ; ; color: green ; ; }").Color); + Assert.AreEqual(Green, Styled("@media screen { ; #x { color: green } ; }").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-block-contents")] + [Spec("css-syntax-3", "#style-rules")] + public void UnknownAtRuleInADeclarationBlock_IsDropped() + { + var box = Styled("#x { color: green; @foo { color: red } background-color: green; @bar; border-top-style: solid }"); + + Assert.AreEqual(Green, box.Color); + Assert.AreEqual(Green, box.BackgroundColor); + Assert.AreEqual("solid", box.BorderTopStyle); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-declaration")] + public void Declaration_IsANameColonAndValue() + { + var box = Styled("#x { color : green ! important ; color: red; background-color: red; background-color green; width: 10px }"); + + Assert.AreEqual(Green, box.Color); + Assert.AreEqual("rgb(255, 0, 0)", box.BackgroundColor); + Assert.AreEqual("10px", box.Width); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-declaration")] + public void Important_IsCaseInsensitive() + { + Assert.AreEqual(Green, Styled("#x { color: green !IMPORTANT } #x { color: red }").Color); + } + + [TestMethod] + [Spec("css-syntax-3", "#consume-list-of-components")] + [Spec("css-syntax-3", "#consume-component-value")] + [Spec("css-syntax-3", "#consume-function")] + public void FunctionAtTheEndOfInput_IsClosed() + { + Assert.AreEqual(15, Styled("#x { width: calc(10px + 5px").ActualWidth, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#consume-simple-block")] + [DataRow("(")] + [DataRow("[")] + public void SemicolonInASimpleBlock_DoesntEndTheDeclaration(string open) + { + var close = open == "(" ? ")" : "]"; + + Assert.AreEqual(Green, Styled($"#x {{ color: green; foo: {open} ; color: red; {close} }}").Color); + } + + [TestMethod] + [Spec("css-syntax-3", "#anb-microsyntax")] + [Spec("css-syntax-3", "#the-anb-type")] + [DataRow("odd", "1,3,5")] + [DataRow("EVEN", "2,4,6")] + [DataRow("3", "3")] + [DataRow("+3", "3")] + [DataRow("2n", "2,4,6")] + [DataRow("2N+1", "1,3,5")] + [DataRow("n", "1,2,3,4,5,6")] + [DataRow("+n", "1,2,3,4,5,6")] + [DataRow("-n+3", "1,2,3")] + [DataRow("2n-1", "1,3,5")] + [DataRow("2n-3", "1,3,5")] + [DataRow("3n + 1", "1,4")] + [DataRow(" +3n - 2 ", "1,4")] + [DataRow("-n+ 6", "1,2,3,4,5,6")] + [DataRow("2n- 1", "1,3,5")] + [DataRow("0n+0", "")] + [DataRow("-4n+10", "2,6")] + [DataRow("3 n", "")] + [DataRow("+ 2n", "")] + [DataRow("+ 2", "")] + [DataRow("3n + -6", "")] + [DataRow("2.0n", "")] + public void AnPlusB_SelectsTheElementsAtTheIndices(string anb, string expected) + { + Assert.AreEqual(expected, Matching($"li:nth-child({anb})")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-syntax-3.yml")] + [Spec("css-syntax-3", "#the-anb-type")] + [DataRow("n-1", "1,2,3,4,5,6")] + [DataRow("+n-1", "1,2,3,4,5,6")] + [DataRow("n- 1", "1,2,3,4,5,6")] + [DataRow("N-2", "1,2,3,4,5,6")] + public void AnPlusB_TakesBInTheIdentOrUnit(string anb, string expected) + { + Assert.AreEqual(expected, Matching($"li:nth-child({anb})")); + } + + [TestMethod] + [Spec("css-syntax-3", "#charset-rule")] + public void CharsetRule_HasNoEffect() + { + Assert.AreEqual(Green, Styled("@charset \"utf-8\"; #x { color: green }").Color); + Assert.AreEqual(Green, Styled("#x { color: green } @charset \"utf-8\"; #x { background-color: green }").BackgroundColor); + } + + [TestMethod] + [Spec("css-syntax-3", "#style-rules")] + public void PropertyNames_AreCaseInsensitiveButCustomPropertyNamesArent() + { + var box = Styled("#x { --Foo: red; --foo: green; COLOR: var(--foo); Background-Color: var(--Foo) }"); + + Assert.AreEqual(Green, box.Color); + Assert.AreEqual("rgb(255, 0, 0)", box.BackgroundColor); + } + + private static List<Token> Tokens(string css) + { + var lexer = new Lexer(new TextSource(css)); + var tokens = new List<Token>(); + for (var token = lexer.Get(); token.Type != TokenType.EndOfFile; token = lexer.Get()) + { + tokens.Add(token); + } + + return tokens; + } + + private static CssBox Styled(string css, string body = "<div id=x>a</div>") + { + var (root, _) = LayoutHarness.Layout($"<html><head><style>{css}</style></head><body style='margin:0'>{body}</body></html>"); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } + + private static string Before(string css) + { + var before = Styled(css).Boxes.FirstOrDefault(b => b.IsBeforePseudoElement); + Assert.IsNotNull(before, "No ::before box"); + return before.Text ?? string.Concat(LayoutHarness.Descendants(before).SelectMany(b => b.Words).Select(w => w.Text)); + } + + /// <summary>The 1-based indices of the six list items that <paramref name="selector"/> turns green.</summary> + private static string Matching(string selector) + { + var items = string.Concat(Enumerable.Range(1, 6).Select(i => $"<li id=i{i}>{i}</li>")); + var (root, _) = LayoutHarness.Layout($"<html><head><style>{selector} {{ color: green }}</style></head><body><ul id=x>{items}</ul></body></html>"); + + return string.Join(",", Enumerable.Range(1, 6).Where(i => LayoutHarness.FindById(root, "i" + i)!.Color == Green)); + } +} diff --git a/Spec/css-syntax-3.yml b/Spec/css-syntax-3.yml index 47546f712..1bada8a6d 100644 --- a/Spec/css-syntax-3.yml +++ b/Spec/css-syntax-3.yml @@ -5,229 +5,234 @@ snapshot: https://www.w3.org/TR/2026/CRD-css-syntax-3-20261001/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module interactions - status: unknown + status: n/a '#syntax-description': title: 2 Description of CSS’s Syntax - status: unknown - '#escaping': - title: 2.1 Escaping - status: unknown - '#error-handling': - title: 2.2 Error Handling - status: unknown + status: n/a '#tokenizing-and-parsing': title: 3 Tokenizing and Parsing CSS - status: unknown + status: partial '#parsing-overview': title: 3.1 Overview of the Parsing Model - status: unknown + status: n/a '#input-byte-stream': title: 3.2 The input byte stream - status: unknown + status: partial + gaps: + - The @charset encoding declaration, the HTTP charset and the environment encoding are ignored, see CSS 2.1 4.4 '#input-preprocessing': title: 3.3 Preprocessing the input stream - status: unknown + status: partial + gaps: + - U+0000 NULL isn't replaced with U+FFFD '#tokenization': title: 4 Tokenization - status: unknown + status: partial '#token-diagrams': title: 4.1 Token Railroad Diagrams - status: unknown + status: n/a '#tokenizer-definitions': title: 4.2 Definitions - status: unknown + status: partial + gaps: + - Every non-ASCII code point is an ident code point, not only the listed ranges, e.g. U+00D7 and U+00A0 '#tokenizer-algorithms': title: 4.3 Tokenizer Algorithms - status: unknown + status: partial '#consume-token': title: 4.3.1 Consume a token - status: unknown + status: partial + gaps: + - An at-sign followed by two hyphens is a delim, not an at-keyword, e.g. @--x + - U+ followed by a hex digit or a question mark is a unicode-range everywhere, not only in the unicode-range descriptor, e.g. the selector u+a '#consume-comment': title: 4.3.2 Consume comments - status: unknown + status: supported '#consume-numeric-token': title: 4.3.3 Consume a numeric token - status: unknown + status: partial + gaps: + - The unit of a dimension ends at the first code point that isn't a letter or an escape, e.g. 10x2 and 10--x '#consume-ident-like-token': title: 4.3.4 Consume an ident-like token - status: unknown + status: partial + gaps: + - "'url(' followed by a quoted string gives a url token instead of a function token" '#consume-string-token': title: 4.3.5 Consume a string token - status: unknown + status: partial + gaps: + - A backslash followed by a newline is kept as a line break, see CSS 2.1 4.3.7 + - A backslash at the end of the input makes the string a bad string + - A bad string doesn't invalidate the declaration, the string up to the line break is used '#consume-url-token': title: 4.3.6 Consume a url token - status: unknown + status: partial + gaps: + - A bad url doesn't invalidate the declaration, the url up to the error is used '#consume-escaped-code-point': title: 4.3.7 Consume an escaped code point - status: unknown + status: supported '#starts-with-a-valid-escape': title: 4.3.8 Check if two code points are a valid escape - status: unknown + status: partial + gaps: + - A backslash at the end of the input isn't a valid escape, it's dropped instead of becoming U+FFFD '#would-start-an-identifier': title: 4.3.9 Check if three code points would start an ident sequence - status: unknown + status: supported '#starts-with-a-number': title: 4.3.10 Check if three code points would start a number - status: unknown + status: supported '#starts-a-unicode-range': title: 4.3.11 Check if three code points would start a unicode-range - status: unknown + status: supported '#consume-name': title: 4.3.12 Consume an ident sequence - status: unknown + status: supported '#consume-number': title: 4.3.13 Consume a number - status: unknown + status: partial + gaps: + - An exponent isn't part of the number, 1e3 is the dimension 1e followed by the number 3 '#consume-unicode-range-token': title: 4.3.14 Consume a unicode-range token - status: unknown + status: supported '#consume-remnants-of-bad-url': title: 4.3.15 Consume the remnants of a bad url - status: unknown + status: partial + gaps: + - A bad url also ends at a semicolon or a closing brace, and an opening parenthesis in it needs its own closing one '#parsing': title: 5 Parsing - status: unknown + status: partial '#parser-diagrams': title: 5.1 Parser Railroad Diagrams - status: unknown + status: n/a '#css-tree': title: 5.2 CSS Parsing Results - status: unknown + status: n/a '#parser-definitions': title: 5.3 Token Streams - status: unknown + status: n/a '#parser-entry-points': title: 5.4 Parser Entry Points - status: unknown + status: supported '#parse-grammar': title: 5.4.1 Parse something according to a CSS grammar - status: unknown + status: n/a '#parse-comma-list': title: 5.4.2 Parse a comma-separated list according to a CSS grammar - status: unknown + status: n/a '#parse-stylesheet': title: 5.4.3 Parse a stylesheet - status: unknown + status: supported '#parse-stylesheet-contents': title: 5.4.4 Parse a stylesheet’s contents - status: unknown + status: n/a '#parse-block-contents': title: 5.4.5 Parse a block’s contents - status: unknown + status: supported '#parse-rule': title: 5.4.6 Parse a rule - status: unknown + status: n/a '#parse-declaration': title: 5.4.7 Parse a declaration - status: unknown + status: supported '#parse-component-value': title: 5.4.8 Parse a component value - status: unknown + status: n/a '#parse-list-of-component-values': title: 5.4.9 Parse a list of component values - status: unknown + status: n/a '#parse-comma-separated-list-of-component-values': title: 5.4.10 Parse a comma-separated list of component values - status: unknown + status: n/a '#parser-algorithms': title: 5.5 Parser Algorithms - status: unknown + status: partial '#consume-stylesheet-contents': title: 5.5.1 Consume a stylesheet’s contents - status: unknown + status: supported '#consume-at-rule': title: 5.5.2 Consume an at-rule - status: unknown + status: partial + gaps: + - A closing brace in the prelude of a top-level at-rule ends the at-rule instead of becoming part of the prelude + - A '{' inside a [] block of the prelude starts the at-rule's block instead of being part of the [] block '#consume-qualified-rule': title: 5.5.3 Consume a qualified rule - status: unknown + status: partial + gaps: + - A closing brace at the start of a top-level qualified rule is dropped on its own instead of becoming part of the prelude '#consume-block': title: 5.5.4 Consume a block - status: unknown + status: supported '#consume-block-contents': title: 5.5.5 Consume a block’s contents - status: unknown + status: partial + gaps: + - An unknown at-rule in a style rule isn't dropped, its block applies to the style rule's elements '#consume-declaration': title: 5.5.6 Consume a declaration - status: unknown + status: partial + gaps: + - The important flag is case-sensitive, e.g. !IMPORTANT, see CSS 2.1 4.1.3 '#consume-list-of-components': title: 5.5.7 Consume a list of component values - status: unknown + status: supported '#consume-component-value': title: 5.5.8 Consume a component value - status: unknown + status: supported '#consume-simple-block': title: 5.5.9 Consume a simple block - status: unknown + status: partial + gaps: + - A semicolon inside () or [] in a declaration value ends the declaration + - A {}-block inside a declaration value drops the declarations after it, see CSS 2.1 4.1.6 '#consume-function': title: 5.5.10 Consume a function - status: unknown + status: supported '#consume-unicode-range-value': title: 5.5.11 Consume a unicode-range value - status: unknown + status: supported '#anb-microsyntax': title: 6 The An+B microsyntax - status: unknown + status: partial '#anb-syntax': title: 6.1 Informal Syntax Description - status: unknown + status: n/a '#the-anb-type': title: 6.2 The <a-n-plus-b> type - status: unknown + status: partial + gaps: + - B written as part of the n ident is invalid, e.g. n-1, +n-1 and n- 1 '#rule-defs': title: 7 Defining Grammars for Rules and Other Values - status: unknown - '#block-contents': - title: '7.1 Defining Block Contents: the <block-contents>, <declaration-list>, <qualified-rule-list>, <declaration-rule-list>, and <rule-list> productions' - status: unknown - '#any-value': - title: '7.2 Defining Arbitrary Contents: the <declaration-value> and <any-value> productions' - status: unknown + status: n/a '#css-stylesheets': title: 8 CSS stylesheets - status: unknown + status: partial '#style-rules': title: 8.1 Style rules - status: unknown + status: partial + gaps: + - Unknown at-rules in a style rule aren't ignored, see 5.5.5 '#at-rules': title: 8.2 At-rules - status: unknown + status: supported '#charset-rule': title: 8.3 The @charset Rule - status: unknown + status: supported '#serialization': title: 9 Serialization - status: unknown - '#serializing-anb': - title: 9.1 Serializing <a-n-plus-b> - status: unknown + status: n/a '#privacy': title: 10 Privacy Considerations - status: unknown + status: n/a '#security': title: 11 Security Considerations - status: unknown + status: n/a '#changes': title: 12 Changes - status: unknown - '#changes-CR-20211224': - title: 12.1 Changes from the 24 December 2021 Candidate Recommendation Draft - status: unknown - '#changes-CR-20190716': - title: 12.2 Changes from the 16 August 2019 Candidate Recommendation - status: unknown - '#changes-CR-20140220': - title: 12.3 Changes from the 20 February 2014 Candidate Recommendation - status: unknown - '#changes-WD-20131105': - title: 12.4 Changes from the 5 November 2013 Last Call Working Draft - status: unknown - '#changes-WD-20130919': - title: 12.5 Changes from the 19 September 2013 Working Draft - status: unknown - '#changes-css21': - title: 12.6 Changes from CSS 2.1 and Selectors Level 3 - status: unknown + status: n/a From 45dcfd75c711dc43ed0e8556a28d51cd077301d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:06:48 +0100 Subject: [PATCH 052/150] Verify and document CSS Namespaces compliance --- .../Test/HtmlRenderer.Test/Css/CasesTests.cs | 3 + .../CssModules/CssNamespacesTests.cs | 112 ++++++++++++++++++ Spec/css-namespaces-3.yml | 24 ++-- 3 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssNamespacesTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Css/CasesTests.cs b/Source/Test/HtmlRenderer.Test/Css/CasesTests.cs index 3851decf0..e06e064af 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CasesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CasesTests.cs @@ -13,6 +13,7 @@ private static Stylesheet ParseSheet(string text) } [TestMethod] + [Spec("css-namespaces-3", "#syntax")] public void StyleSheetAtNamespace() { var sheet = ParseSheet(@"@namespace svg ""http://www.w3.org/2000/svg"";"); @@ -865,6 +866,7 @@ public void StyleSheetMessedUp() } [TestMethod] + [Spec("css-namespaces-3", "#syntax")] public void StyleSheetNamespaceLinebreak() { var sheet = ParseSheet(@"@namespace @@ -874,6 +876,7 @@ public void StyleSheetNamespaceLinebreak() } [TestMethod] + [Spec("css-namespaces-3", "#syntax")] public void StyleSheetNamespace() { var sheet = ParseSheet(@"@namespace ""http://www.w3.org/1999/xhtml""; diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssNamespacesTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssNamespacesTests.cs new file mode 100644 index 000000000..bdb067a99 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssNamespacesTests.cs @@ -0,0 +1,112 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using static HtmlRenderer.Test.CssEngineSupport.CssConstructionFunctions; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssNamespacesTests +{ + private const string Green = "rgb(0, 128, 0)"; + private const string Html = "http://www.w3.org/1999/xhtml"; + private const string Svg = "http://www.w3.org/2000/svg"; + + [TestMethod] + [Spec("css-namespaces-3", "#syntax")] + [DataRow("@namespace svg url(" + Svg + ");", "svg", Svg)] + [DataRow("@namespace url(\"" + Svg + "\");", "", Svg)] + [DataRow("@namespace /**/ svg /**/ url( " + Svg + " ) /**/;", "svg", Svg)] + public void NamespaceRule_TakesAnOptionalPrefixAndAUrl(string css, string prefix, string uri) + { + var rule = ParseStyleSheet(css).NamespaceRules.Single(); + + Assert.AreEqual(prefix, rule.Prefix); + Assert.AreEqual(uri, rule.NamespaceUri); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-namespaces-3.yml")] + [Spec("css-namespaces-3", "#syntax")] + [DataRow("@namespace svg \"" + Svg + "\";", "svg", Svg)] + [DataRow("@namespace \"" + Svg + "\";", "", Svg)] + [DataRow("@namespace empty \"\";", "empty", "")] + public void NamespaceRule_TakesAString(string css, string prefix, string uri) + { + var rule = ParseStyleSheet(css).NamespaceRules.Single(); + + Assert.AreEqual(prefix, rule.Prefix); + Assert.AreEqual(uri, rule.NamespaceUri); + } + + [TestMethod] + [Spec("css-namespaces-3", "#syntax")] + public void NamespaceRule_DoesntAffectTheFollowingRules() + { + Assert.AreEqual(Green, Styled($"@namespace url({Html}); @namespace h url({Html}); #x {{ color: green }}").Color); + } + + [TestMethod] + [Spec("css-namespaces-3", "#syntax")] + public void MisplacedNamespaceRule_IsIgnored() + { + Assert.AreEqual(Green, Styled($"#y {{ }} @namespace url({Svg}); div#x {{ color: green }}").Color); + } + + [TestMethod] + [Spec("css-namespaces-3", "#css-qnames")] + public void AttributeWithoutANamespace_Matches() + { + Assert.AreEqual(Green, Styled("#x[|title] { color: green }").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-namespaces-3.yml")] + [Spec("css-namespaces-3", "#css-qnames")] + [DataRow("*|div#x")] + [DataRow("#x[*|title]")] + public void AnyNamespace_Matches(string selector) + { + Assert.AreEqual(Green, Styled($"{selector} {{ color: green }}").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-namespaces-3.yml")] + [Spec("css-namespaces-3", "#css-qnames")] + public void UndeclaredPrefix_InvalidatesTheSelector() + { + Assert.AreEqual(Green, Styled("#x { color: green } foo|div#x, #x { color: red }").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-namespaces-3.yml")] + [Spec("css-namespaces-3", "#css-qnames")] + public void ElementWithoutANamespace_IsntAnHtmlElement() + { + Assert.AreEqual(Green, Styled("#x { color: green } |div#x { color: red }").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-namespaces-3.yml")] + [Spec("css-namespaces-3", "#css-qnames")] + [Spec("css-namespaces-3", "#prefixes")] + public void DeclaredPrefix_MatchesItsNamespace() + { + Assert.AreEqual(Green, Styled($"@namespace h url({Html}); h|div#x {{ color: green }}").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-namespaces-3.yml")] + [Spec("css-namespaces-3", "#prefixes")] + public void DefaultNamespace_AppliesToTypeSelectors() + { + Assert.AreEqual(Green, Styled($"@namespace url({Svg}); #x {{ color: green }} div#x {{ color: red }}").Color); + } + + private static CssBox Styled(string css) + { + var (root, _) = LayoutHarness.Layout($"<html><head><style>{css}</style></head><body style='margin:0'><div id=x title=t>a</div></body></html>"); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } +} diff --git a/Spec/css-namespaces-3.yml b/Spec/css-namespaces-3.yml index bcb043eb8..f00dd37ce 100644 --- a/Spec/css-namespaces-3.yml +++ b/Spec/css-namespaces-3.yml @@ -5,25 +5,29 @@ snapshot: https://www.w3.org/TR/2014/REC-css-namespaces-3-20140320/ sections: '#intro': title: 1 Introduction - status: unknown + status: n/a '#conformance': title: 2 Conformance - status: unknown - '#terminology': - title: 2.1 Terminology - status: unknown + status: n/a '#declaration': title: '3 Declaring namespaces: the @namespace rule' - status: unknown + status: partial '#syntax': title: 3.1 Syntax - status: unknown + status: partial + gaps: + - A namespace name given as a string is dropped, only url() is kept '#scope': title: 3.2 Scope - status: unknown + status: unsupported '#prefixes': title: 3.3 Declaring Prefixes - status: unknown + status: unsupported '#css-qnames': title: 4 CSS Qualified Names - status: unknown + status: partial + gaps: + - A declared prefix never matches, e.g. h|div with h declared as the HTML namespace + - The wildcard prefix never matches, e.g. *|div and [*|title] + - An empty prefix on a type selector matches HTML elements, e.g. |div + - An undeclared prefix doesn't invalidate the selector From eaa4f7b66e4149f6a77b7d329e1191e472ce4465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:09:34 +0100 Subject: [PATCH 053/150] Verify and document CSS Style Attributes compliance --- Source/HtmlRenderer/Core/Parse/CssParser.cs | 4 +- .../HtmlRenderer.Test/Css/CascadeTests.cs | 1 + .../CssModules/CssStyleAttributeTests.cs | 92 +++++++++++++++++++ .../Test/HtmlRenderer.Test/Html/DomTests.cs | 2 + Spec/css-style-attr.yml | 14 +-- 5 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssStyleAttributeTests.cs diff --git a/Source/HtmlRenderer/Core/Parse/CssParser.cs b/Source/HtmlRenderer/Core/Parse/CssParser.cs index d9cd7b8c9..40d8acce2 100644 --- a/Source/HtmlRenderer/Core/Parse/CssParser.cs +++ b/Source/HtmlRenderer/Core/Parse/CssParser.cs @@ -118,8 +118,8 @@ public void ParseStyleSheet(CssData cssData, string stylesheet, Uri baseUri = nu } /// <summary> - /// Parses the value of a style attribute, HTML §3.2.6.5. The declarations are wrapped in a - /// universal rule to go through the same parser as a stylesheet. + /// Parses the value of a style attribute, HTML §3.2.6.5 and CSS Style Attributes §3. The declarations + /// are wrapped in a universal rule to go through the same parser as a stylesheet, so a '}' ends them. /// </summary> /// <param name="declarations">the raw declaration list, e.g. "color: red; font-weight: bold"</param> /// <returns>the single parsed style rule, or null if the declaration block was empty/invalid</returns> diff --git a/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs b/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs index 07b906e26..dc897d526 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs @@ -291,6 +291,7 @@ public void Specificity_MatchesTheSpecExamples(string selector, int ids, int cla [TestMethod] [Spec("css21", "cascade.html#specificity")] + [Spec("css-style-attr", "#interpret")] public void StyleAttribute_BeatsAnIdSelector() { var x = Box("<style>#x { color: red }</style><p id=x style='color: green'>a</p>"); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssStyleAttributeTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssStyleAttributeTests.cs new file mode 100644 index 000000000..12b99ce5e --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssStyleAttributeTests.cs @@ -0,0 +1,92 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssStyleAttributeTests +{ + private const string Green = "rgb(0, 128, 0)"; + + [TestMethod] + [Spec("css-style-attr", "#syntax")] + [DataRow(" color : green ")] + [DataRow("color: green;")] + [DataRow(" ; ; color: green ;; ")] + [DataRow("/* a */ color /* b */ : /* c */ green /* d */")] + [DataRow("color: red; color: green")] + [DataRow("color: red; colour: blue; color: green")] + [DataRow("color: red; color green; color: green")] + public void StyleAttribute_IsADeclarationList(string style) + { + Assert.AreEqual(Green, Styled("", style).Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-style-attr.yml")] + [Spec("css-style-attr", "#syntax")] + public void CloseBrace_DoesntEndTheStyleAttribute() + { + Assert.AreEqual(Green, Styled("", "color: red; } ; color: green").Color); + } + + [TestMethod] + [Spec("css-style-attr", "#syntax")] + public void StyleRuleSyntax_IsInvalid() + { + var box = Styled("", "#x { color: red } background-color: green"); + + Assert.AreNotEqual("rgb(255, 0, 0)", box.Color); + } + + [TestMethod] + [Spec("css-style-attr", "#interpret")] + public void StyleAttribute_BeatsAnyNormalDeclaration() + { + Assert.AreEqual(Green, Styled("#x#x#x.a.b { color: red }", "color: green").Color); + } + + [TestMethod] + [Spec("css-style-attr", "#interpret")] + public void ImportantAuthorDeclaration_BeatsANormalStyleAttribute() + { + Assert.AreEqual(Green, Styled("#x { color: green !important }", "color: red").Color); + } + + [TestMethod] + [Spec("css-style-attr", "#interpret")] + public void ImportantStyleAttribute_BeatsAnImportantAuthorDeclaration() + { + Assert.AreEqual(Green, Styled("#x#x#x { color: red !important }", "color: green !important").Color); + } + + [TestMethod] + [Spec("css-style-attr", "#interpret")] + public void StyleAttribute_IsInterpretedLikeAStyleRule() + { + var box = Styled("", "--c: green; color: var(--c); border: 1px solid; margin: 0 auto; width: 100px", "<div id=p style='font-size: 20px'><div id=x>a</div></div>"); + + Assert.AreEqual(Green, box.Color); + Assert.AreEqual("solid", box.BorderTopStyle); + Assert.AreEqual("100px", box.Width); + } + + [TestMethod] + [Spec("css-style-attr", "#interpret")] + public void RelativeUrl_IsResolvedLikeInAStyleElement() + { + var image = Styled("", "background-image: url(a.png)").BackgroundImage; + + StringAssert.Contains(image, "a.png"); + Assert.AreEqual(Styled("#x { background-image: url(a.png) }", "").BackgroundImage, image); + } + + private static CssBox Styled(string css, string style, string body = "<div id=x class='a b'>a</div>") + { + body = body.Replace("<div id=x", $"<div style=\"{style}\" id=x"); + var (root, _) = LayoutHarness.Layout($"<html><head><style>{css}</style></head><body style='margin:0'>{body}</body></html>"); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } +} diff --git a/Source/Test/HtmlRenderer.Test/Html/DomTests.cs b/Source/Test/HtmlRenderer.Test/Html/DomTests.cs index 6d3bf563f..edee5105d 100644 --- a/Source/Test/HtmlRenderer.Test/Html/DomTests.cs +++ b/Source/Test/HtmlRenderer.Test/Html/DomTests.cs @@ -62,6 +62,7 @@ public void DirAuto_TakesTheDirectionOfTheFirstStrongCharacter(string html, stri [TestMethod] [Spec("html", "dom.html#the-style-attribute")] + [Spec("css-style-attr", "#interpret")] public void StyleAttribute_OverridesStylesheets() { AssertColor(0, 255, 0, Box("<style>#x { color: #ff0000 }</style><p id=x style='color: #00ff00'>a</p>").ActualColor); @@ -69,6 +70,7 @@ public void StyleAttribute_OverridesStylesheets() [TestMethod] [Spec("html", "dom.html#the-style-attribute")] + [Spec("css-style-attr", "#syntax")] public void StyleAttribute_SkipsInvalidDeclarations() { var box = Box("<p id=x style='color: nonsense; background-color: #0000ff; : ; color: #00ff00'>a</p>"); diff --git a/Spec/css-style-attr.yml b/Spec/css-style-attr.yml index 05c0bda30..c9b8c019a 100644 --- a/Spec/css-style-attr.yml +++ b/Spec/css-style-attr.yml @@ -5,19 +5,21 @@ snapshot: https://www.w3.org/TR/2013/REC-css-style-attr-20131107/ sections: '#intro': title: 1 Introduction - status: unknown + status: n/a '#conformance': title: 2 Conformance - status: unknown + status: n/a '#syntax': title: 3 Syntax and Parsing - status: unknown + status: partial + gaps: + - A closing brace ends the style attribute, the declarations after it are dropped '#interpret': title: 4 Cascading and Interpretation - status: unknown + status: supported '#ack': title: 5 Acknowledgments - status: unknown + status: n/a '#references': title: 6 References - status: unknown + status: n/a From 1d8651b2c27b22b030c7c7e4cc51e50786a706e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:14:40 +0100 Subject: [PATCH 054/150] Verify and document CSS Nesting compliance --- Source/HtmlRenderer/Core/CssData.cs | 7 +- .../CssEngine/Parser/StylesheetComposer.cs | 51 ++---- .../Core/CssEngine/Rules/StyleRule.cs | 5 +- .../Css/CssNestingIntegrationTests.cs | 77 ++++----- .../CssModules/CssNestingTests.cs | 151 ++++++++++++++++++ Spec/css-nesting-1.yml | 41 +++-- 6 files changed, 219 insertions(+), 113 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssNestingTests.cs diff --git a/Source/HtmlRenderer/Core/CssData.cs b/Source/HtmlRenderer/Core/CssData.cs index 12a345689..bd7fed080 100644 --- a/Source/HtmlRenderer/Core/CssData.cs +++ b/Source/HtmlRenderer/Core/CssData.cs @@ -294,11 +294,8 @@ private static void IndexRules( } } - // CSS Nesting: a nested rule lives in its parent's NestedRules, deliberately not in - // Children, so it is invisible to Stylesheet.Rules and would otherwise never be - // indexed. Its selector was already resolved against the parent at parse time, so it - // indexes like any other rule - under the same media context, and with a document - // order that continues from the parent so it tie-breaks after it. + // CSS Nesting 1 §3.4: nested rules aren't in Stylesheet.Rules. Indexed right after the + // parent in the same media context, so they come after it in order of appearance. if (styleRule.NestedRules.Count > 0) { IndexRules(styleRule.NestedRules, isUserAgent, enclosingMedia, tagIndex, classIndex, idIndex, universal, keys, ref order); diff --git a/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetComposer.cs b/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetComposer.cs index 96fb88601..abc58ec21 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetComposer.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetComposer.cs @@ -10,10 +10,8 @@ internal sealed class StylesheetComposer private readonly StylesheetParser _parser; private readonly Stack<StylesheetNode> _nodes; - // The source index (raw, into _lexer.Source) immediately before the most recently read token. - // Captured by NextToken so a CSS-Nesting classification look-ahead can rewind to a construct's - // exact start without any position arithmetic (which is unreliable across \r\n normalization and - // unicode escapes) and can slice the nested prelude's source text. + // Raw source index before the token NextToken read last, lets the nested rule look-ahead rewind to + // the start of a construct and slice a nested prelude. TextPosition is off across \r\n and escapes. private int _markBeforeLastToken; public StylesheetComposer(Lexer lexer, StylesheetParser parser) @@ -772,7 +770,6 @@ public TextPosition FillDeclarations(StyleDeclaration style) } else if (TryCreateNestedRule(ref token)) { - // CSS Nesting: a nested style rule was parsed and attached to the enclosing rule. } else { @@ -839,16 +836,11 @@ public TextPosition FillDeclarations(StyleDeclaration style) } /// <summary> - /// CSS Nesting ([CSS Nesting 1](https://www.w3.org/TR/css-nesting-1/)): if the current construct - /// in a declaration block is a nested style rule (a selector prelude followed by a <c>{ }</c> - /// block) rather than a declaration, parses it, resolves its selector against the enclosing rule, - /// attaches it, and returns true (with <paramref name="token"/> advanced past the block). Returns - /// false for an ordinary declaration, having rewound the lexer so the caller re-reads it. + /// CSS Nesting 1 §3.1: parses a nested style rule at <paramref name="token"/> and attaches it to the + /// enclosing rule. Returns false for a declaration, with the lexer rewound so the caller re-reads it. /// </summary> private bool TryCreateNestedRule(ref Token token) { - // Raw source index just before the current (first) token — the construct start, captured by - // NextToken. Faithful across \r\n normalization, unlike deriving it from token.Position. var preludeStart = _markBeforeLastToken; if (!IsNestedRuleAhead(ref token, out var braceStart)) @@ -860,15 +852,10 @@ private bool TryCreateNestedRule(ref Token token) } /// <summary> - /// Looks ahead (bracket-aware) to classify the construct starting at <paramref name="token"/> as a - /// nested style rule vs a declaration: the first top-level <c>{</c> means a nested rule (the stream - /// is left just after it and its source index returned via <paramref name="braceStart"/>); a - /// top-level <c>;</c>/<c>}</c>/EOF means a declaration, in which case the lexer is rewound to the - /// construct start and <paramref name="token"/> re-read so the unchanged declaration path re-lexes - /// it in value mode (avoiding the <c>#</c> value-mode tokenization hazard a token buffer would hit). - /// Custom properties (<c>--x</c>) are always declarations, even with a <c>{</c> in their value. - /// Function tokens (e.g. <c>url(…)</c>) are opaque — the lexer already consumed their parentheses — - /// so only bare round/square brackets contribute to depth. + /// A top-level <c>{</c> before a <c>;</c> or <c>}</c> makes the construct a nested rule, the stream is + /// left after the brace and <paramref name="braceStart"/> is its source index. For a declaration the + /// lexer is rewound and <paramref name="token"/> re-read, so the value is lexed in value mode. Custom + /// properties are always declarations. A function token already holds its parentheses. /// </summary> private bool IsNestedRuleAhead(ref Token token, out int braceStart) { @@ -916,11 +903,8 @@ private bool IsNestedRuleAhead(ref Token token, out int braceStart) } /// <summary> - /// Builds a <see cref="StyleRule"/> from a nested prelude (source span - /// <c>[preludeStart, bracePosition)</c>) resolved against the enclosing rule's selector, consumes - /// its declaration block from the live stream, and attaches it to the parent rule via - /// <see cref="StyleRule.AddNestedRule"/>. The resolved selector is absolute (<c>&amp;</c> → - /// <c>:is(parent)</c>) so the cascade/matcher need no nesting awareness. + /// Parses the block of a nested rule and attaches the rule to its parent with the selector resolved + /// against the parent's, so the cascade needs no nesting support. /// </summary> private void CreateNestedStyleRule(int preludeStart, int braceStart) { @@ -931,8 +915,7 @@ private void CreateNestedStyleRule(int preludeStart, int braceStart) ? null : _parser.ParseSelector(ResolveNestedSelector(preludeText, parent.SelectorText)); - // Always consume the block (via a rule pushed on _nodes so nested-within-nested resolves) to - // keep the parser in sync; only attach it when the selector actually resolved. + // CSS Nesting 1 §3.1: an invalid nested rule is dropped, its block is still consumed. var rule = new StyleRule(_parser); if (selector != null) @@ -947,21 +930,15 @@ private void CreateNestedStyleRule(int preludeStart, int braceStart) } /// <summary> - /// Resolves a nested selector's prelude text against its parent's selector text per CSS Nesting: - /// each <c>&amp;</c> becomes <c>:is(parent)</c> (giving <c>&amp;</c> the parent's specificity); a - /// prelude with no <c>&amp;</c> is made relative to the parent (<c>:is(parent) &lt;prelude&gt;</c>), - /// which correctly yields both the implicit-descendant (<c>.b</c> → <c>:is(parent) .b</c>) and the - /// leading-combinator (<c>&gt; .b</c> → <c>:is(parent) &gt; .b</c>) forms. + /// CSS Nesting 1 §3.1 and §4: <c>&amp;</c> becomes <c>:is(parent)</c>, a prelude without one is + /// prefixed with <c>:is(parent)</c> and a space, which covers both <c>.b</c> and <c>&gt; .b</c>. An + /// <c>&amp;</c> in a string is kept. /// </summary> private static string ResolveNestedSelector(string prelude, string parentText) { var trimmed = prelude.Trim(); var parentIs = ":is(" + (string.IsNullOrEmpty(parentText) ? "*" : parentText) + ")"; - // Substitute `&` token-aware: only a `&` that is a real nesting selector is replaced, never a - // `&` inside a string/attribute value (e.g. `[data-x="a&b"]`), which must be preserved. This - // also decides the "has &" branch correctly, so a prelude whose only `&` is inside a string is - // still scoped under the parent (:is(parent) <prelude>) rather than mis-taking the replace path. var sb = Pool.NewStringBuilder(); var hasNestingSelector = false; var quote = '\0'; diff --git a/Source/HtmlRenderer/Core/CssEngine/Rules/StyleRule.cs b/Source/HtmlRenderer/Core/CssEngine/Rules/StyleRule.cs index 7e38cbda2..9bfaadd37 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Rules/StyleRule.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Rules/StyleRule.cs @@ -6,9 +6,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { internal sealed class StyleRule : Rule, IStyleRule { - // CSS Nesting: rules written inside this rule's block, resolved to absolute selectors against - // this parent. Kept in a dedicated list (not Children) so Selector/Style lookups and ToCss are - // unaffected. Populated by StylesheetComposer.FillDeclarations. + // CSS Nesting 1 §3, with selectors already resolved against this rule. Not in Children, so Selector, + // Style and ToCss don't see them. private readonly List<IStyleRule> _nestedRules = new List<IStyleRule>(); public StyleRule(StylesheetParser parser) : base(RuleType.Style, parser) diff --git a/Source/Test/HtmlRenderer.Test/Css/CssNestingIntegrationTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssNestingIntegrationTests.cs index c4281a31e..d5742a231 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CssNestingIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CssNestingIntegrationTests.cs @@ -5,21 +5,17 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/CssNestingIntegrationTests.cs. -/// CSS Nesting (<see href="https://www.w3.org/TR/css-nesting-1/">CSS Nesting 1</see>): the <c>&amp;</c> -/// nesting selector and nested style rules inside a declaration block. Nested rules are resolved at parse -/// time into ordinary rules with absolute selectors (<c>&amp;</c> → <c>:is(parent)</c>), so these assert -/// the resolved rules actually match and cascade. Includes the regression guards that the declaration path -/// is unaffected (hex colors, custom properties with a brace in their value, a normal declaration after a -/// nested rule). +/// Nested rules are resolved at parse time into rules with absolute selectors (<c>&amp;</c> becomes +/// <c>:is(parent)</c>), these check that the resolved rules match and cascade, and that the look-ahead +/// that tells a nested rule from a declaration leaves declarations intact. /// </summary> [TestClass] public sealed class CssNestingIntegrationTests { [TestMethod] + [Spec("css-nesting-1", "#nest-selector")] public void ImplicitDescendant_And_ParentDeclaration() { - // `& span` == `:is(.box) span` (descendant). The div keeps its own red; the span is blue. var html = Html( ".box { color: #ff0000; & span { color: #0000ff; } }", "<div class='box' id='d'>text<span id='s'>inner</span></div>"); @@ -30,9 +26,9 @@ public void ImplicitDescendant_And_ParentDeclaration() } [TestMethod] + [Spec("css-nesting-1", "#nest-selector")] public void AmpersandCompound_MatchesSameElement() { - // `&.active` == `:is(.box).active` — the same element carrying both classes. var html = Html( ".box { &.active { color: #0000ff; } }", "<div class='box active' id='d'>x</div>"); @@ -40,10 +36,10 @@ public void AmpersandCompound_MatchesSameElement() } [TestMethod] + [Spec("css-nesting-1", "#nesting")] public void TypeSelectorNestedRule_IsNotMistakenForADeclaration() { - // `p { ... }` inside a block starts with an Ident (like a property name) — the classifier must - // still see the `{` before any `;` and treat it as a nested rule. + // Starts with an ident like a declaration, the '{' before any ';' makes it a rule. var html = Html( ".card { p { color: #0000ff; } }", "<div class='card'><p id='p'>x</p></div>"); @@ -51,10 +47,10 @@ public void TypeSelectorNestedRule_IsNotMistakenForADeclaration() } [TestMethod] + [Spec("css-nesting-1", "#syntax")] public void HexColorInNestedValue_SurvivesTheLookahead() { - // Regression: the classify-then-rewind must not corrupt a `#rrggbb` value (value-mode `#` - // tokenization) the way a token buffer would. + // The look-ahead lexes outside value mode, where '#123456' isn't a color. var html = Html( ".card { p { color: #123456; } }", "<div class='card'><p id='p'>x</p></div>"); @@ -64,15 +60,14 @@ public void HexColorInNestedValue_SurvivesTheLookahead() [TestMethod] public void HexColorInTopLevelValue_Unaffected() { - // The whole feature must leave an ordinary (non-nested) declaration block byte-identical. var html = Html(".card { color: #123456; }", "<div class='card' id='d'>x</div>"); Assert.AreEqual("rgb(18, 52, 86)", Box(html, "d").Color); } [TestMethod] + [Spec("css-nesting-1", "#syntax")] public void ChildCombinatorLeading() { - // `> p` == `:is(.card) > p` — only a direct child p matches. var html = Html( ".card { > p { color: #0000ff; } }", "<div class='card'><p id='child'>x</p><section><p id='grandchild'>y</p></section></div>"); @@ -81,10 +76,9 @@ public void ChildCombinatorLeading() } [TestMethod] + [Spec("css-nesting-1", "#mixing")] public void DeclarationAfterNestedRule_StillApplies() { - // A declaration written AFTER a nested rule still applies to the parent (the outer loop resumes - // correctly); here the later blue wins over the earlier red. var html = Html( ".card { color: #ff0000; & span { color: #00ff00; } color: #0000ff; }", "<div class='card' id='d'>t<span id='s'>x</span></div>"); @@ -93,9 +87,9 @@ public void DeclarationAfterNestedRule_StillApplies() } [TestMethod] + [Spec("css-nesting-1", "#nesting")] public void ThreeLevelNesting() { - // `.a { & .b { & .c { } } }` → `:is(:is(.a) .b) .c` — nested `:is()` with complex args. var html = Html( ".a { & .b { & .c { color: #0000ff; } } }", "<div class='a'><div class='b'><div class='c' id='c'>x</div></div></div>"); @@ -103,9 +97,9 @@ public void ThreeLevelNesting() } [TestMethod] + [Spec("css-nesting-1", "#nest-selector")] public void ParentSelectorList_ResolvesToIs() { - // `.x, .y { & span { } }` → `:is(.x, .y) span` — the nested rule applies under either parent. var html = Html( ".x, .y { & span { color: #0000ff; } }", "<div class='x'><span id='sx'>1</span></div><div class='y'><span id='sy'>2</span></div>"); @@ -114,10 +108,10 @@ public void ParentSelectorList_ResolvesToIs() } [TestMethod] + [Spec("css-nesting-1", "#nesting")] public void NestedRule_InheritsEnclosingMedia() { - // A nested rule inside @media print inherits that context (print media applies here - the - // MockAdapter used by Box()/BuildRoot() below reports "print" so this exercises the same path). + // Box() renders with the print media type. var html = Html( "@media print { .card { & p { color: #0000ff; } } }", "<div class='card'><p id='p'>x</p></div>"); @@ -125,37 +119,32 @@ public void NestedRule_InheritsEnclosingMedia() } [TestMethod] + [Ignore("Gap, see Spec/css-cascade-5.yml")] + [Spec("css-cascade-5", "#layering")] public void NestedRule_InheritsEnclosingLayer() { - // A nested rule inside @layer base loses to a later unlayered rule (layer context is inherited). var html = Html( - "@layer base { .card { & p { color: #ff0000; } } } .card p { color: #0000ff; }", + ".card p { color: #0000ff; } @layer base { .card { & p { color: #ff0000; } } }", "<div class='card'><p id='p'>x</p></div>"); Assert.AreEqual("rgb(0, 0, 255)", Box(html, "p").Color); } [TestMethod] + [Spec("css-nesting-1", "#syntax")] public void CustomProperty_CoexistsWithNestedRule() { - // A custom property declared alongside a nested rule still cascades (inherited to descendants). var html = Html( ".card { --c: #0000ff; p { color: var(--c); } }", "<div class='card'><p id='p'>x</p></div>"); Assert.AreEqual("rgb(0, 0, 255)", Box(html, "p").Color); } - // Note: a custom property whose *value* literally contains a `{` (e.g. `--x: { }`) is classified - // as a declaration by the `--` guard (correct — custom properties are always declarations), but the - // declaration value parser has a pre-existing limitation with a brace inside a value. That is - // unrelated to nesting and does not occur in real utility-framework output, so it is not exercised here. - [TestMethod] + [Spec("css-nesting-1", "#syntax")] public void TypePseudoNestedRule_IsConsumed_NotMistakenForADeclaration() { - // `a:hover { }` (type + pseudo, no `&`) is ambiguous with a declaration `a: hover` — the - // `{`-before-`;` scan classifies it as a nested rule. `:hover` never matches against a static - // render, so we assert the FOLLOWING nested rule still applies, proving `a:hover { }` was - // consumed as a rule. + // 'a:hover { }' could be the declaration 'a: hover', :hover never matches, so the following + // nested rule applying shows it was consumed as a rule. var html = Html( ".card { a:hover { color: #ff0000; } p { color: #0000ff; } }", "<div class='card'><p id='p'>x</p></div>"); @@ -163,9 +152,9 @@ public void TypePseudoNestedRule_IsConsumed_NotMistakenForADeclaration() } [TestMethod] + [Spec("css-nesting-1", "#syntax")] public void BraceInsideUrl_DoesNotTriggerNestedRule() { - // A `{` inside a url()/string is part of an opaque function token, not a rule boundary. var html = Html( ".card { background: url(\"a{b}.png\"); & p { color: #0000ff; } }", "<div class='card'><p id='p'>x</p></div>"); @@ -173,27 +162,24 @@ public void BraceInsideUrl_DoesNotTriggerNestedRule() } [TestMethod] + [Spec("css-nesting-1", "#nest-selector")] public void AmpersandInsideAttributeValue_IsPreserved_NotTreatedAsNestingSelector() { - // A `&` inside an attribute-value string must NOT be substituted (it's data, not the nesting - // selector) and must NOT flip the rule to the replace branch — the rule must still be scoped - // under the parent. Here the only `&` is inside the string, so `.item` stays `:is(.card) .item` - // matching the `[data-state="on&off"]` element under .card. + // The only '&' is in a string, so the rule is still relative to .card. var html = Html( ".card { .item[data-state=\"on&off\"] { color: #0000ff; } }", "<div class='card'><span class='item' id='m' data-state='on&off'>x</span>" + "<span class='item' id='n' data-state='off'>y</span></div>" + "<span class='item' id='outside' data-state='on&off'>z</span>"); - Assert.AreEqual("rgb(0, 0, 255)", Box(html, "m").Color); // matches (under .card, value on&off) - Assert.AreNotEqual("rgb(0, 0, 255)", Box(html, "n").Color); // wrong data-state - Assert.AreNotEqual("rgb(0, 0, 255)", Box(html, "outside").Color); // not under .card (rule stays scoped) + Assert.AreEqual("rgb(0, 0, 255)", Box(html, "m").Color); + Assert.AreNotEqual("rgb(0, 0, 255)", Box(html, "n").Color); + Assert.AreNotEqual("rgb(0, 0, 255)", Box(html, "outside").Color); } [TestMethod] + [Spec("css-nesting-1", "#nest-selector")] public void RealAmpersand_WithAmpersandAlsoInsideAttributeValue() { - // A real nesting `&` plus a `&` inside a string: only the real one is replaced (→ `&.on` - // compound), the in-string `&` is preserved. var html = Html( ".card { &[data-state=\"a&b\"] { color: #0000ff; } }", "<div class='card' id='d' data-state='a&b'>x</div>"); @@ -201,18 +187,15 @@ public void RealAmpersand_WithAmpersandAlsoInsideAttributeValue() } [TestMethod] + [Spec("css-nesting-1", "#syntax")] public void EscapedQuoteInsideAttributeValue_DoesNotEndTheStringEarly() { - // An escaped quote (\") inside the attribute-value string must not prematurely close the string - // during `&` substitution; the value `a"b` is preserved and the rule matches the right element. var html = Html( ".card { .item[data-x=\"a\\\"b\"] { color: #0000ff; } }", "<div class='card'><span class='item' id='e' data-x='a\"b'>x</span></div>"); Assert.AreEqual("rgb(0, 0, 255)", Box(html, "e").Color); } - // ── Helpers ──────────────────────────────────────────────────────────────── - private static string Html(string css, string body) => $"<!DOCTYPE html><html><head><style>{css}</style></head><body>{body}</body></html>"; diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssNestingTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssNestingTests.cs new file mode 100644 index 000000000..0a7efa1ac --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssNestingTests.cs @@ -0,0 +1,151 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssNestingTests +{ + private const string Green = "rgb(0, 128, 0)"; + private const string Red = "rgb(255, 0, 0)"; + + [TestMethod] + [Spec("css-nesting-1", "#nesting")] + public void NestedRule_IsRelativeToTheParentsElements() + { + const string body = "<div class=a><p id=x>a</p></div><p id=y>b</p>"; + + Assert.AreEqual(Green, Styled(".a { p { color: green } }", body).Color); + Assert.AreNotEqual(Green, Styled(".a { p { color: green } }", body, "y").Color); + } + + [TestMethod] + [Spec("css-nesting-1", "#syntax")] + [DataRow(".a { > #x { color: green } }")] + [DataRow(".a { + #x { color: green } }")] + [DataRow(".a { ~ #x { color: green } }")] + [DataRow(".a { #x { color: green } }")] + public void RelativeSelector_IsRelativeToTheNestingSelector(string css) + { + var body = css.Contains('>') || !css.Contains('+') && !css.Contains('~') + ? "<div class=a><p id=x>a</p></div>" + : "<div class=a>a</div><p id=x>b</p>"; + + Assert.AreEqual(Green, Styled(css, body).Color); + } + + [TestMethod] + [Spec("css-nesting-1", "#syntax")] + public void SelectorContainingTheNestingSelector_IsntRelative() + { + Assert.AreEqual(Green, Styled(".a { .p & { color: green } }", "<div class=p><div id=x class=a>a</div></div>").Color); + Assert.AreNotEqual(Green, Styled(".a { .p & { color: green } }", "<div class=a><div class=p><div id=x>a</div></div></div>").Color); + } + + [TestMethod] + [Spec("css-nesting-1", "#syntax")] + public void InvalidNestedRule_DoesntInvalidateTheParent() + { + var box = Styled("#x { color: green; ##bad { color: red } background-color: green }"); + + Assert.AreEqual(Green, box.Color); + Assert.AreEqual(Green, box.BackgroundColor); + } + + [TestMethod] + [Spec("css-nesting-1", "#syntax")] + public void RelativeSelector_HasTheSpecificityOfTheNestingSelector() + { + Assert.AreEqual(Green, Styled("#p { > .x { color: green } } div > .x.x { color: red }", "<div id=p><div id=x class=x>a</div></div>").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-nesting-1.yml")] + [Spec("css-nesting-1", "#conditionals")] + [Spec("css-nesting-1", "#nested-declarations")] + public void DeclarationsInANestedGroupRule_ApplyToTheParentsElements() + { + var box = Styled("#x { color: red; @media all { color: green } }"); + + Assert.AreEqual(Green, box.Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-nesting-1.yml")] + [Spec("css-nesting-1", "#conditionals")] + public void StyleRuleInANestedGroupRule_IsNested() + { + Assert.AreEqual(Green, Styled(".a { @media all { & #x { color: green } } }", "<div class=a><p id=x>a</p></div>").Color); + } + + [TestMethod] + [Spec("css-nesting-1", "#mixing")] + public void NestedRule_ComesAfterItsParent() + { + Assert.AreEqual(Green, Styled("#x { color: red; & { color: green } }").Color); + Assert.AreEqual(Green, Styled("#x { color: green; :where(&) { color: red } }").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-nesting-1.yml")] + [Spec("css-nesting-1", "#mixing")] + [Spec("css-nesting-1", "#nested-declarations")] + public void DeclarationsAfterANestedRule_ComeAfterIt() + { + Assert.AreEqual(Green, Styled("#x { color: red; & { color: red } color: green }").Color); + } + + [TestMethod] + [Spec("css-nesting-1", "#nested-declarations")] + public void DeclarationsAfterANestedRule_MatchLikeTheParent() + { + var box = Styled("#x, #x::before { content: 'b'; & .none { color: red } color: green }"); + + Assert.AreEqual(Green, box.Boxes.First(b => b.IsBeforePseudoElement).Color); + } + + [TestMethod] + [Spec("css-nesting-1", "#nest-selector")] + [DataRow(".a { &.b { color: green } }", "<div id=x class='a b'>a</div>")] + [DataRow(".a { div& { color: green } }", "<div id=x class=a>a</div>")] + [DataRow(".a { && { color: green } }", "<div id=x class=a>a</div>")] + [DataRow(".a { & > & { color: green } }", "<div class=a><div id=x class=a>a</div></div>")] + [DataRow(".a { :not(&) > #x { color: green } }", "<div><p id=x>a</p></div>")] + [DataRow(".a { & :is(.b, &.c) { color: green } }", "<div class=a><p id=x class='a c'>a</p></div>")] + [DataRow(".a, .b { & #x { color: green } }", "<div class=b><p id=x>a</p></div>")] + public void NestingSelector_RepresentsTheParentsElements(string css, string body) + { + Assert.AreEqual(Green, Styled(css, body).Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-nesting-1.yml")] + [Spec("css-nesting-1", "#nest-selector")] + public void NestingSelector_IsInvalidBeforeATypeSelector() + { + Assert.AreNotEqual(Red, Styled(".a { &div { color: red } }", "<div id=x class=a>a</div>").Color); + } + + [TestMethod] + [Spec("css-nesting-1", "#nest-selector")] + public void NestingSelector_HasTheLargestSpecificityOfTheParentList() + { + Assert.AreEqual(Green, Styled("#a, b { & c { color: green } } .foo c { color: red }", "<b class=foo><c id=x>a</c></b>").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-nesting-1.yml")] + [Spec("css-nesting-1", "#nest-selector")] + public void TopLevelNestingSelector_IsTheScopingRoot() + { + Assert.AreEqual(Green, Styled("& { color: green }").Color); + } + + private static CssBox Styled(string css, string body = "<div id=x>a</div>", string id = "x") + { + var (root, _) = LayoutHarness.Layout($"<html><head><style>{css}</style></head><body style='margin:0'>{body}</body></html>"); + var box = LayoutHarness.FindById(root, id); + Assert.IsNotNull(box); + return box; + } +} diff --git a/Spec/css-nesting-1.yml b/Spec/css-nesting-1.yml index 0a954e3e2..ff2cc2372 100644 --- a/Spec/css-nesting-1.yml +++ b/Spec/css-nesting-1.yml @@ -5,46 +5,45 @@ snapshot: https://www.w3.org/TR/2026/WD-css-nesting-1-20260122/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Values - status: unknown + status: n/a '#explainer': title: 2 Explainer - status: unknown + status: n/a '#nesting': title: 3 Nesting Style Rules - status: unknown + status: partial '#syntax': title: 3.1 Syntax - status: unknown + status: supported '#syntax-examples': title: 3.2 Examples - status: unknown + status: n/a '#conditionals': title: 3.3 Nesting Other At-Rules - status: unknown + status: unsupported '#nesting-at-scope': title: 3.3.1 Nested @scope Rules - status: unknown + status: unsupported '#mixing': title: 3.4 Mixing Nesting Rules and Declarations - status: unknown + status: partial + gaps: + - Declarations after a nested rule join the parent's declarations instead of coming after the nested rule '#nest-selector': title: '4 Nesting Selector: the & selector' - status: unknown + status: partial + gaps: + - '&div is accepted and matches like div&' + - A top-level & is invalid instead of matching like :scope '#nested-declarations': title: 5 The Nested Declarations Rule - status: unknown + status: partial + gaps: + - Declarations in a nested group rule never apply, see 3.3 + - Declarations after a nested rule don't come after it, see 3.4 '#cssom': title: 6 CSSOM - status: unknown - '#the-cssnestrule': - title: 6.1 The CSSNestedDeclarations Interface - status: unknown + status: n/a '#changes': title: 7 Changes - status: unknown + status: n/a From 6a436add4a1c684fefb869dddf628897ac5db6e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 07:56:58 +0100 Subject: [PATCH 055/150] Verify and document CSS Cascading Level 5 compliance --- Source/HtmlRenderer/Core/CssData.cs | 18 +- .../Core/CssEngine/Rules/LayerRule.cs | 5 +- .../CssEngine/Rules/LayerStatementRule.cs | 6 +- Source/HtmlRenderer/Core/Parse/CssParser.cs | 2 +- Source/HtmlRenderer/Core/Parse/DomParser.cs | 5 +- .../Layout/GlobalKeywordCascadeTests.cs | 15 ++ .../Css/CascadeLayerIntegrationTests.cs | 105 +++--------- .../HtmlRenderer.Test/Css/CascadeTests.cs | 28 +++ .../Css/CssSpecificityOrderingTests.cs | 2 + .../Css/GlobalKeywordPropertyTests.cs | 19 ++ .../HtmlRenderer.Test/Css/ImportRuleTests.cs | 1 + .../CssModules/CssCascadeTests.cs | 162 ++++++++++++++++++ Spec/css-cascade-5.yml | 158 +++++++++-------- 13 files changed, 344 insertions(+), 182 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssCascadeTests.cs diff --git a/Source/HtmlRenderer/Core/CssData.cs b/Source/HtmlRenderer/Core/CssData.cs index bd7fed080..59cc85d49 100644 --- a/Source/HtmlRenderer/Core/CssData.cs +++ b/Source/HtmlRenderer/Core/CssData.cs @@ -174,15 +174,9 @@ internal bool MediaOutcomeChanged(MediaQueryContext before, MediaQueryContext af } /// <summary> - /// Every rule in a rule list, in document order, descending into <c>@layer</c>, <c>@media</c>, - /// <c>@container</c>, and (when its condition evaluates true) <c>@supports</c> blocks - matching - /// PeachPDF's own <c>CssData.FlattenRules</c> exactly (full descent, not the narrower - /// style-rule-only/no-@supports-or-@container predecessor this replaces). Callers that only want - /// style rules (the common case - e.g. scanning for a rule by selector rather than matching - /// against a box, since <see cref="Stylesheet.StyleRules"/> only sees the top level) filter with - /// <c>.OfType&lt;IStyleRule&gt;()</c>, matching PeachPDF's own call-site pattern of filtering the - /// general enumerator per use rather than a dedicated pre-filtered helper. CSS-Nesting child rules - /// are reached because <see cref="IStyleRule"/> is itself yielded before recursing. + /// Every rule in a rule list, in document order, descending into nested style rules, + /// <c>@layer</c>, <c>@media</c>, <c>@container</c> and, when its condition is true, + /// <c>@supports</c>. <c>@media</c> conditions aren't evaluated here. /// </summary> internal static IEnumerable<IRule> FlattenRules(IEnumerable<IRule> rules) { @@ -310,11 +304,7 @@ private static void IndexRules( } else if (rule is ILayerRule layerRule) { - // @layer's contents are indexed as ordinary, unlayered rules: they apply, ordered by - // source position and specificity. Layer precedence, !important layer reversal and - // revert-layer are deliberately not implemented - that needs a layer-banded cascade, - // which is separate work. Without this branch the block would fall through and every - // rule inside a layer would silently stop applying. + // CSS Cascade 5 §6.4: layer order isn't tracked, the rules cascade as if unlayered IndexRules(layerRule.Rules, isUserAgent, enclosingMedia, tagIndex, classIndex, idIndex, universal, keys, ref order); } else if (rule is ISupportsRule supportsRule) diff --git a/Source/HtmlRenderer/Core/CssEngine/Rules/LayerRule.cs b/Source/HtmlRenderer/Core/CssEngine/Rules/LayerRule.cs index 7957e4d24..0ec9e01bb 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Rules/LayerRule.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Rules/LayerRule.cs @@ -3,9 +3,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The block form of <c>@layer</c> (<c>@layer name { rules }</c> or anonymous <c>@layer { rules }</c>). - /// A grouping rule holding the style rules assigned to a cascade layer; cascade precedence between - /// layers is resolved when the rules are indexed (see <c>CssData</c>), not here. + /// The block form of <c>@layer</c>, CSS Cascade 5 §6.4.4.1. <c>CssData</c> indexes its rules as + /// if they were unlayered. /// </summary> internal sealed class LayerRule : GroupingRule, ILayerRule { diff --git a/Source/HtmlRenderer/Core/CssEngine/Rules/LayerStatementRule.cs b/Source/HtmlRenderer/Core/CssEngine/Rules/LayerStatementRule.cs index e0bc077f9..d065656c2 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Rules/LayerStatementRule.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Rules/LayerStatementRule.cs @@ -4,10 +4,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The statement form of <c>@layer</c> (<c>@layer a, b, c;</c>). It declares the relative order of - /// one or more named cascade layers without providing any rules; a later block-form - /// <see cref="LayerRule"/> attaches rules to a layer already ordered by this statement, per - /// <see href="https://www.w3.org/TR/css-cascade-5/#layer-empty">CSS Cascade 5</see>. + /// The statement form of <c>@layer</c>, CSS Cascade 5 §6.4.4.2. Parsed, but the layer order it + /// declares isn't used. /// </summary> internal sealed class LayerStatementRule : Rule { diff --git a/Source/HtmlRenderer/Core/Parse/CssParser.cs b/Source/HtmlRenderer/Core/Parse/CssParser.cs index 40d8acce2..17d813884 100644 --- a/Source/HtmlRenderer/Core/Parse/CssParser.cs +++ b/Source/HtmlRenderer/Core/Parse/CssParser.cs @@ -236,7 +236,7 @@ private void ParseStyle(CssData data, string stylesheetText, Uri baseUri, HashSe } else if (!(rule is LayerStatementRule)) { - // CSS Cascade 5 §6.4.1 allows `@layer a, b;` before @import. + // CSS Cascade 5 §6.4.4.2 allows `@layer a, b;` before @import. hasReachedNonImportRules = true; } } diff --git a/Source/HtmlRenderer/Core/Parse/DomParser.cs b/Source/HtmlRenderer/Core/Parse/DomParser.cs index 2eda4eb10..86faf0096 100644 --- a/Source/HtmlRenderer/Core/Parse/DomParser.cs +++ b/Source/HtmlRenderer/Core/Parse/DomParser.cs @@ -202,8 +202,8 @@ private static async Task RegisterFontFaces(CssData cssData, HtmlContainerInt ht /// <summary> - /// Applies the cascade (CSS 2.1 §6.4) to every box in the tree. Declarations are applied from - /// lowest to highest precedence, so the last write wins: UA normal, author normal, style + /// Applies the cascade (CSS Cascade 5 §6.1) to every box in the tree. Declarations are applied + /// from lowest to highest precedence, so the last write wins: UA normal, author normal, style /// attribute normal, author !important, style attribute !important, UA !important. /// </summary> /// <param name="box">the box to apply the style to</param> @@ -425,6 +425,7 @@ private static void AssignCssBlock( } else if (prop.Value == CssConstants.Revert || prop.Value == CssConstants.RevertLayer) { + // CSS Cascade 5 §7.3.4, layers aren't tracked so revert-layer reverts like revert string rv; value = revertTarget != null && revertTarget.TryGetValue(prop.Name, out rv) ? rv diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Layout/GlobalKeywordCascadeTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Layout/GlobalKeywordCascadeTests.cs index 934544bb7..586abadff 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Layout/GlobalKeywordCascadeTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Layout/GlobalKeywordCascadeTests.cs @@ -10,6 +10,7 @@ public sealed class GlobalKeywordCascadeTests [TestMethod] [Spec("css21", "cascade.html#value-def-inherit")] + [Spec("css-cascade-5", "#inherit")] public void Inherit_ColorFromParent_ChildGetsParentColor() { var html = LayoutHarness.Wrap( @@ -22,6 +23,7 @@ public void Inherit_ColorFromParent_ChildGetsParentColor() [TestMethod] [Spec("css21", "cascade.html#value-def-inherit")] + [Spec("css-cascade-5", "#inherit")] public void Inherit_NonInheritedProperty_ChildGetsParentValue() { var html = LayoutHarness.Wrap( @@ -34,6 +36,7 @@ public void Inherit_NonInheritedProperty_ChildGetsParentValue() [TestMethod] [Spec("css21", "cascade.html#value-def-inherit")] + [Spec("css-cascade-5", "#inherit")] public void Inherit_ColorWithoutExplicitParent_UsesInitialBlack() { var html = LayoutHarness.Wrap("<div id='el' style='color: inherit'>text</div>"); @@ -46,6 +49,7 @@ public void Inherit_ColorWithoutExplicitParent_UsesInitialBlack() // ── initial ─────────────────────────────────────────────────────────── [TestMethod] + [Spec("css-cascade-5", "#initial")] public void Initial_Color_ResetsToBlackIgnoringParent() { var html = LayoutHarness.Wrap( @@ -57,6 +61,7 @@ public void Initial_Color_ResetsToBlackIgnoringParent() } [TestMethod] + [Spec("css-cascade-5", "#initial")] public void Initial_FontSize_ResetsToMedium() { var html = LayoutHarness.Wrap("<h1 id='el' style='font-size: initial'>heading</h1>"); @@ -67,6 +72,7 @@ public void Initial_FontSize_ResetsToMedium() } [TestMethod] + [Spec("css-cascade-5", "#initial")] public void Initial_MarginTop_ResetsToZero() { var html = LayoutHarness.Wrap( @@ -80,6 +86,7 @@ public void Initial_MarginTop_ResetsToZero() // ── unset ───────────────────────────────────────────────────────────── [TestMethod] + [Spec("css-cascade-5", "#inherit-initial")] public void Unset_InheritedProperty_BehavesLikeInherit() { var html = LayoutHarness.Wrap( @@ -91,6 +98,7 @@ public void Unset_InheritedProperty_BehavesLikeInherit() } [TestMethod] + [Spec("css-cascade-5", "#inherit-initial")] public void Unset_NonInheritedProperty_BehavesLikeInitial() { var html = LayoutHarness.Wrap( @@ -102,6 +110,7 @@ public void Unset_NonInheritedProperty_BehavesLikeInitial() } [TestMethod] + [Spec("css-cascade-5", "#inherit-initial")] public void Unset_InheritedPropertyWithNoParentValue_UsesInitial() { var html = LayoutHarness.Wrap("<div id='el' style='color: unset'>text</div>"); @@ -115,6 +124,7 @@ public void Unset_InheritedPropertyWithNoParentValue_UsesInitial() [TestMethod] [Spec("css21", "cascade.html#inheritance")] + [Spec("css-cascade-5", "#inheriting")] public void Regression_ColorInheritsFromParentWithoutKeyword() { var html = LayoutHarness.Wrap( @@ -127,6 +137,7 @@ public void Regression_ColorInheritsFromParentWithoutKeyword() [TestMethod] [Spec("css21", "cascade.html#specificity")] + [Spec("css-cascade-5", "#cascade-sort")] public void Regression_InlineStyleOverridesAuthorRule() { var html = LayoutHarness.Wrap( @@ -139,6 +150,7 @@ public void Regression_InlineStyleOverridesAuthorRule() [TestMethod] [Spec("css21", "cascade.html#cascading-order")] + [Spec("css-cascade-5", "#cascading-origins")] public void Regression_AuthorRuleOverridesUaDefault() { var html = LayoutHarness.Wrap("<style>h1 { font-size: 10px; }</style><h1 id='el'>heading</h1>"); @@ -150,6 +162,7 @@ public void Regression_AuthorRuleOverridesUaDefault() [TestMethod] [Spec("css21", "cascade.html#important-rules")] + [Spec("css-cascade-5", "#importance")] public void Regression_ImportantAuthorRuleBeatsInlineStyle() { var html = LayoutHarness.Wrap( @@ -162,6 +175,7 @@ public void Regression_ImportantAuthorRuleBeatsInlineStyle() [TestMethod] [Spec("css21", "cascade.html#important-rules")] + [Spec("css-cascade-5", "#cascade-sort")] public void InlineImportant_BeatsAuthorImportantRule() { var html = LayoutHarness.Wrap( @@ -174,6 +188,7 @@ public void InlineImportant_BeatsAuthorImportantRule() [TestMethod] [Spec("css21", "cascade.html#cascading-order")] + [Spec("css-cascade-5", "#cascade-sort")] public void Regression_LaterAuthorRuleOverridesEarlierSameSpecificity() { var html = LayoutHarness.Wrap("<style>div { color: red; } div { color: blue; }</style><div id='el'>text</div>"); diff --git a/Source/Test/HtmlRenderer.Test/Css/CascadeLayerIntegrationTests.cs b/Source/Test/HtmlRenderer.Test/Css/CascadeLayerIntegrationTests.cs index ea1e7889d..1a46b370b 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CascadeLayerIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CascadeLayerIntegrationTests.cs @@ -5,35 +5,14 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/CascadeLayerIntegrationTests.cs. -/// Cascade-layer (<c>@layer</c>) support, per CSS Cascade 5. HTML-Renderer's <c>CssData.IndexRules</c> -/// (Core/CssData.cs:321-329) indexes an <c>@layer</c> block's contents as ordinary, unlayered rules - -/// they apply, ordered by source position and specificity - but true layer precedence, the -/// <c>!important</c> layer-order reversal, and layer-aware <c>revert-layer</c> are deliberately not -/// implemented (that needs a layer-banded cascade, tracked as separate work). <c>revert-layer</c> itself -/// parses but is routed through the exact same branch as plain <c>revert</c> (see -/// Core/Parse/DomParser.cs, e.g. lines 404/459/514: <c>prop.Value == CssConstants.Revert || prop.Value == -/// CssConstants.RevertLayer</c>), so it rolls all the way back to the origin rather than to a lower layer. -/// The tests below split accordingly: cases where ordinary specificity/document-order tie-breaking -/// happens to produce the spec-correct answer anyway are ported as real (passing) tests; cases that -/// actually require layer-banded precedence are <c>[Ignore]</c>d. One PeachPDF test -/// (<c>NestedSublayer_VsParentDirectRules_CharacterizesCurrentOrdering</c>) is a characterization test -/// pinning a PeachPDF-specific implementation detail with no HTML-Renderer counterpart and is not ported. +/// <c>@layer</c> rules apply as if they were unlayered, so the passing tests are the ones where +/// specificity and order of appearance give the same answer as layer order. /// </summary> [TestClass] public sealed class CascadeLayerIntegrationTests { - private const string LayerPrecedenceNotImplemented = - "HTML-Renderer's CssData.IndexRules (Core/CssData.cs:321-329) indexes @layer contents as ordinary, " + - "unlayered rules - true layer precedence, the !important layer-order reversal, and layer-aware " + - "revert-layer are deliberately not implemented (needs a layer-banded cascade)."; - - private const string RevertLayerNotImplemented = - "revert-layer parses but is routed through the same branch as plain revert (see " + - "Source/HtmlRenderer/Core/Parse/DomParser.cs, e.g. lines 404/459/514), so it rolls back to the " + - "origin rather than to a lower layer. See also Core/CssData.cs:321-329."; - [TestMethod] + [Spec("css-cascade-5", "#layer-block")] public void LayerBlock_Rules_AreApplied_NotDropped() { var html = Html( @@ -44,11 +23,9 @@ public void LayerBlock_Rules_AreApplied_NotDropped() } [TestMethod] + [Spec("css-cascade-5", "#layering")] public void LaterLayer_Wins_OverEarlierLayer_AtEqualSpecificity() { - // base is declared before components; both rules have identical (type-selector) specificity, so - // the ordinary "last declared wins" tie-break already gives the spec-correct answer here, even - // without real layer precedence. var html = Html( "@layer base { p { color: #ff0000; } } @layer components { p { color: #0000ff; } }", "<p>text</p>"); @@ -57,12 +34,10 @@ public void LaterLayer_Wins_OverEarlierLayer_AtEqualSpecificity() } [TestMethod] - [Ignore(LayerPrecedenceNotImplemented)] + [Ignore("Gap, see Spec/css-cascade-5.yml")] + [Spec("css-cascade-5", "#layering")] public void LaterLayer_Wins_EvenAgainstHigherSpecificityInEarlierLayer() { - // The earlier layer's rule has higher specificity (id), but layer precedence sorts ahead of - // specificity: the later layer's low-specificity rule should still win. Without real layer - // precedence, ordinary specificity lets the higher-specificity id rule win instead. var html = Html( "@layer a { #x { color: #ff0000; } } @layer b { p { color: #0000ff; } }", "<p id='x'>text</p>"); @@ -71,11 +46,10 @@ public void LaterLayer_Wins_EvenAgainstHigherSpecificityInEarlierLayer() } [TestMethod] - [Ignore(LayerPrecedenceNotImplemented)] + [Ignore("Gap, see Spec/css-cascade-5.yml")] + [Spec("css-cascade-5", "#layer-ordering")] public void UnlayeredRule_Wins_OverAnyLayeredRule() { - // An unlayered normal declaration should outrank a layered one regardless of specificity. Without - // real layer precedence, ordinary specificity lets the higher-specificity layered id rule win. var html = Html( "@layer utilities { p#x { color: #ff0000; } } p { color: #0000ff; }", "<p id='x'>text</p>"); @@ -84,14 +58,10 @@ public void UnlayeredRule_Wins_OverAnyLayeredRule() } [TestMethod] - [Ignore(LayerPrecedenceNotImplemented)] + [Ignore("Gap, see Spec/css-cascade-5.yml")] + [Spec("css-cascade-5", "#layer-empty")] public void StatementDeclaresOrder_RegardlessOfBlockOrder() { - // The leading "@layer base, utilities;" statement should fix the order (utilities after base), so - // utilities wins even though its block is written BEFORE base's block in source. Without real - // layer-order tracking, this statement contributes no style rules at all (CssData.IndexRules' - // comment: "@layer statements ... contribute no style rules to the cascade"), so plain document - // order lets the textually-last block (base) win instead. var html = Html( "@layer base, utilities; @layer utilities { p { color: #0000ff; } } @layer base { p { color: #ff0000; } }", "<p>text</p>"); @@ -100,6 +70,7 @@ public void StatementDeclaresOrder_RegardlessOfBlockOrder() } [TestMethod] + [Spec("css-cascade-5", "#cascade-sort")] public void WithinOneLayer_SpecificityStillBreaksTies() { var html = Html( @@ -110,9 +81,9 @@ public void WithinOneLayer_SpecificityStillBreaksTies() } [TestMethod] + [Spec("css-cascade-5", "#layer-names")] public void DottedLayerName_IsParsedAndApplied() { - // A nested/dotted layer name (`@layer framework.utilities`) parses and its rules apply. var html = Html( "@layer framework.utilities { p { color: #0000ff; } }", "<p>text</p>"); @@ -121,11 +92,9 @@ public void DottedLayerName_IsParsedAndApplied() } [TestMethod] + [Spec("css-cascade-5", "#unnamed-layers")] public void AnonymousLayer_Rules_AreApplied_AndBeatenByUnlayered() { - // An anonymous @layer { } is its own distinct layer; its rules apply. Both rules here have equal - // (type-selector) specificity, so ordinary "last declared wins" already gives the spec-correct - // answer (the unlayered rule, written second) without needing real layer precedence. var html = Html( "@layer { p { color: #ff0000; } } p { color: #0000ff; }", "<p>text</p>"); @@ -133,15 +102,11 @@ public void AnonymousLayer_Rules_AreApplied_AndBeatenByUnlayered() Assert.AreEqual("rgb(0, 0, 255)", box.Color); } - // ── !important layer-order reversal (CSS Cascade 5 §6.4.2) ──────────────────── - [TestMethod] - [Ignore(LayerPrecedenceNotImplemented)] + [Ignore("Gap, see Spec/css-cascade-5.yml")] + [Spec("css-cascade-5", "#layering")] public void Important_EarlierLayer_Wins_OverLaterLayer() { - // For NORMAL declarations the later layer wins; among !important declarations the layer order - // should reverse, so the EARLIER layer (base) should win over the later one (utilities). Without - // real layer precedence, plain "last !important declaration wins" lets utilities win instead. var html = Html( "@layer base { p { color: #ff0000 !important; } } @layer utilities { p { color: #0000ff !important; } }", "<p>text</p>"); @@ -150,12 +115,10 @@ public void Important_EarlierLayer_Wins_OverLaterLayer() } [TestMethod] - [Ignore(LayerPrecedenceNotImplemented)] + [Ignore("Gap, see Spec/css-cascade-5.yml")] + [Spec("css-cascade-5", "#layering")] public void Important_LayeredRule_Wins_OverUnlayeredImportant() { - // An unlayered !important declaration should LOSE to a layered !important one (the mirror of the - // normal-declaration rule where unlayered wins). Without real layer precedence, plain "last - // !important declaration wins" lets the unlayered rule (written second) win instead. var html = Html( "@layer base { p { color: #0000ff !important; } } p { color: #ff0000 !important; }", "<p>text</p>"); @@ -164,10 +127,9 @@ public void Important_LayeredRule_Wins_OverUnlayeredImportant() } [TestMethod] + [Spec("css-cascade-5", "#importance")] public void Important_WithinOneLayer_HigherSpecificityStillWins() { - // The layer reversal is about layer order only — within a single layer, ordinary specificity - // still decides among !important declarations, with no layer-precedence machinery involved. var html = Html( "@layer base { p { color: #ff0000 !important; } p.hi { color: #0000ff !important; } }", "<p class='hi'>text</p>"); @@ -175,15 +137,12 @@ public void Important_WithinOneLayer_HigherSpecificityStillWins() Assert.AreEqual("rgb(0, 0, 255)", box.Color); } - // ── Nested sub-layers stay contiguous within their parent's band ────────────── - [TestMethod] - [Ignore(LayerPrecedenceNotImplemented)] + [Ignore("Gap, see Spec/css-cascade-5.yml")] + [Spec("css-cascade-5", "#layer-ordering")] public void NestedSublayers_StayContiguous_WithinParentBand() { - // a first appears (via a.b) before c, so a's WHOLE subtree {a.b, a.d} should rank below c - even - // though a.d is declared last of all. Without real layer-banded precedence, plain document order - // lets a.d (textually last) win instead of c. + // a is ordered by its first appearance (a.b), so a.d still sorts below c var html = Html( "@layer a.b { p { color: #ff0000; } } @layer c { p { color: #00ff00; } } @layer a.d { p { color: #0000ff; } }", "<p>text</p>"); @@ -192,11 +151,9 @@ public void NestedSublayers_StayContiguous_WithinParentBand() } [TestMethod] + [Spec("css-cascade-5", "#layering")] public void NestedSublayer_LaterWithinParent_Wins_OverEarlierSublayer() { - // Within one parent (a), a later-declared sub-layer (a.d) beats an earlier one (a.b). Both rules - // have equal (type-selector) specificity, so ordinary "last declared wins" already gives the - // spec-correct answer here without needing real layer-banded precedence. var html = Html( "@layer a.b { p { color: #ff0000; } } @layer a.d { p { color: #0000ff; } }", "<p>text</p>"); @@ -204,14 +161,11 @@ public void NestedSublayer_LaterWithinParent_Wins_OverEarlierSublayer() Assert.AreEqual("rgb(0, 0, 255)", box.Color); } - // ── Layer-aware revert-layer ──────────────────────────────────────────────── - [TestMethod] - [Ignore(RevertLayerNotImplemented)] + [Ignore("Gap, see Spec/css-cascade-5.yml")] + [Spec("css-cascade-5", "#revert-layer")] public void RevertLayer_RollsBackToLowerLayer_NotToOrigin() { - // revert-layer in the later layer should reveal the earlier layer's value (blue), rather than - // rolling all the way back to the UA/origin default the way plain `revert` does. var html = Html( "@layer base { p { color: #0000ff; } } @layer top { p { color: revert-layer; } }", "<p>text</p>"); @@ -220,11 +174,9 @@ public void RevertLayer_RollsBackToLowerLayer_NotToOrigin() } [TestMethod] + [Spec("css-cascade-5", "#default")] public void Revert_StillRollsBackToOrigin_NotToLowerLayer() { - // Regression guard: plain `revert` is unaffected by any of this — it rolls back past the whole - // author origin, so the earlier layer's blue is NOT revealed (the inherited/initial color applies - // instead). This holds today regardless of layer-precedence support. var html = Html( "@layer base { p { color: #0000ff; } } @layer top { p { color: revert; } }", "<p>text</p>"); @@ -233,11 +185,10 @@ public void Revert_StillRollsBackToOrigin_NotToLowerLayer() } [TestMethod] - [Ignore(RevertLayerNotImplemented)] + [Ignore("Gap, see Spec/css-cascade-5.yml")] + [Spec("css-cascade-5", "#revert-layer")] public void RevertLayer_OnCustomProperty_RollsBackToLowerLayer() { - // revert-layer should work for custom properties too: the consumer resolves to the lower layer's - // value. var html = Html( "@layer base { p { --c: #0000ff; } } @layer top { p { --c: revert-layer; } } p { color: var(--c); }", "<p>text</p>"); @@ -245,8 +196,6 @@ public void RevertLayer_OnCustomProperty_RollsBackToLowerLayer() Assert.AreEqual("rgb(0, 0, 255)", box.Color); } - // ── Helpers ──────────────────────────────────────────────────────────────── - private static string Html(string css, string body) => $"<!DOCTYPE html><html><head><style>{css}</style></head><body>{body}</body></html>"; diff --git a/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs b/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs index dc897d526..617047daf 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs @@ -17,6 +17,8 @@ public sealed class CascadeTests [TestMethod] [Spec("css21", "cascade.html#specified-value")] + [Spec("css-cascade-5", "#cascaded")] + [Spec("css-cascade-5", "#specified")] public void CascadedValue_WinsOverTheInheritedValue() { var x = Box("<div style='color:red'><p id=x style='color:green'>a</p></div>"); @@ -26,6 +28,7 @@ public void CascadedValue_WinsOverTheInheritedValue() [TestMethod] [Spec("css21", "cascade.html#specified-value")] + [Spec("css-cascade-5", "#inheriting")] public void InheritedProperty_WithoutCascadedValue_TakesTheParentsValue() { var x = Box("<div style='font-style:italic;white-space:pre'><p id=x>a</p></div>"); @@ -36,6 +39,7 @@ public void InheritedProperty_WithoutCascadedValue_TakesTheParentsValue() [TestMethod] [Spec("css21", "cascade.html#specified-value")] + [Spec("css-cascade-5", "#initial-values")] public void NonInheritedProperty_WithoutCascadedValue_TakesTheInitialValue() { var x = Box("<div style='margin-top:30px;padding-left:5px;background-color:red'><div id=x>a</div></div>"); @@ -47,6 +51,7 @@ public void NonInheritedProperty_WithoutCascadedValue_TakesTheInitialValue() [TestMethod] [Spec("css21", "cascade.html#computed-value")] + [Spec("css-cascade-5", "#computed")] public void EmLength_IsComputedAgainstTheElementsFontSize() { var x = Box("<div id=x style='font-size:20px;text-indent:2em'>a</div>"); @@ -57,6 +62,7 @@ public void EmLength_IsComputedAgainstTheElementsFontSize() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "cascade.html#computed-value")] + [Spec("css-cascade-5", "#computed")] public void EmLength_DeclaredBeforeTheFontSize_IsComputedAgainstTheElementsFontSize() { var x = Box("<div id=x style='text-indent:2em;font-size:20px'>a</div>"); @@ -66,6 +72,7 @@ public void EmLength_DeclaredBeforeTheFontSize_IsComputedAgainstTheElementsFontS [TestMethod] [Spec("css21", "cascade.html#used-value")] + [Spec("css-cascade-5", "#used")] public void PercentageWidth_IsResolvedAgainstTheContainingBlock() { var x = Box("<div style='width:200px'><div id=x style='width:50%'>a</div></div>"); @@ -79,6 +86,7 @@ public void PercentageWidth_IsResolvedAgainstTheContainingBlock() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "cascade.html#inheritance")] + [Spec("css-cascade-5", "#inheriting")] public void PercentageFontSize_IsInheritedAsTheComputedSize() { var (root, _) = LayoutHarness.Layout( @@ -93,6 +101,7 @@ public void PercentageFontSize_IsInheritedAsTheComputedSize() [TestMethod] [Spec("css21", "cascade.html#inheritance")] + [Spec("css-cascade-5", "#inheriting")] public void EmLength_IsInheritedAsTheComputedLength() { var x = Box("<div style='font-size:20px;text-indent:2em'><div id=x style='font-size:10px'>a</div></div>"); @@ -102,6 +111,7 @@ public void EmLength_IsInheritedAsTheComputedLength() [TestMethod] [Spec("css21", "cascade.html#inheritance")] + [Spec("css-cascade-5", "#inheriting")] public void Inheritance_FollowsTheDocumentTree_NotAnonymousBoxes() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -115,6 +125,7 @@ public void Inheritance_FollowsTheDocumentTree_NotAnonymousBoxes() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "cascade.html#inheritance")] + [Spec("css-cascade-5", "#inheriting")] public void WordSpacing_IsInherited() { var x = Box("<div style='word-spacing:10px'><p id=x>a b</p></div>"); @@ -126,6 +137,7 @@ public void WordSpacing_IsInherited() [TestMethod] [Spec("css21", "cascade.html#value-def-inherit")] + [Spec("css-cascade-5", "#inherit")] public void Inherit_OnANonInheritedProperty_TakesTheParentsValue() { var x = Box("<div style='border-top:3px solid red'><div id=x style='border-top-style:inherit;background-color:inherit'>a</div></div>"); @@ -137,6 +149,7 @@ public void Inherit_OnANonInheritedProperty_TakesTheParentsValue() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "cascade.html#value-def-inherit")] + [Spec("css-cascade-5", "#inherit")] public void Inherit_OnTheRootElement_TakesTheInitialValue() { var (root, _) = LayoutHarness.Layout("<html id=x style='display:inherit'><body>a</body></html>"); @@ -148,6 +161,7 @@ public void Inherit_OnTheRootElement_TakesTheInitialValue() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "cascade.html#value-def-inherit")] + [Spec("css-cascade-5", "#inherit")] public void Inherit_TakesTheParentsComputedEmLength() { var x = Box("<div style='font-size:20px;margin-left:2em'><div id=x style='font-size:10px;margin-left:inherit'>a</div></div>"); @@ -157,6 +171,7 @@ public void Inherit_TakesTheParentsComputedEmLength() [TestMethod] [Spec("css21", "cascade.html#value-def-inherit")] + [Spec("css-cascade-5", "#inherit")] public void InheritImportant_EnforcesTheParentsValue() { var x = Box( @@ -171,6 +186,7 @@ public void InheritImportant_EnforcesTheParentsValue() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "cascade.html#at-import")] + [Spec("css-cascade-5", "#at-import")] public void Import_AppliesTheImportedStyleSheet_BeforeTheImportingOne() { var x = BoxWithStylesheets( @@ -184,6 +200,7 @@ public void Import_AppliesTheImportedStyleSheet_BeforeTheImportingOne() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "cascade.html#at-import")] + [Spec("css-cascade-5", "#conditional-import")] public void Import_WithANonMatchingMediaList_IsIgnored() { var x = BoxWithStylesheets( @@ -198,6 +215,7 @@ public void Import_WithANonMatchingMediaList_IsIgnored() [TestMethod] [Spec("css21", "cascade.html#cascading-order")] + [Spec("css-cascade-5", "#cascading-origins")] public void AuthorRule_BeatsTheDefaultStyleSheet_RegardlessOfSpecificity() { var x = Box("<style>* { font-weight: normal }</style><b id=x>a</b>"); @@ -207,6 +225,7 @@ public void AuthorRule_BeatsTheDefaultStyleSheet_RegardlessOfSpecificity() [TestMethod] [Spec("css21", "cascade.html#cascading-order")] + [Spec("css-cascade-5", "#filtering")] public void RuleInANonMatchingMediaBlock_DoesNotApply() { var x = Box("<style>p { color: green } @media print { p { color: red } }</style><p id=x>a</p>"); @@ -217,6 +236,7 @@ public void RuleInANonMatchingMediaBlock_DoesNotApply() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "cascade.html#cascading-order")] + [Spec("css-cascade-5", "#filtering")] public void StyleSheet_WithANonMatchingMediaAttribute_DoesNotApply() { var x = BoxWithStylesheets( @@ -229,6 +249,7 @@ public void StyleSheet_WithANonMatchingMediaAttribute_DoesNotApply() [TestMethod] [Spec("css21", "cascade.html#cascading-order")] + [Spec("css-cascade-5", "#cascade-sort")] public void LaterStyleSheet_WinsAtEqualSpecificity() { var x = BoxWithStylesheets( @@ -240,6 +261,7 @@ public void LaterStyleSheet_WinsAtEqualSpecificity() [TestMethod] [Spec("css21", "cascade.html#important-rules")] + [Spec("css-cascade-5", "#importance")] public void ImportantDeclaration_BeatsAMoreSpecificNormalOne() { var x = Box("<style>div { color: green !important } #x { color: red }</style><div id=x>a</div>"); @@ -249,6 +271,7 @@ public void ImportantDeclaration_BeatsAMoreSpecificNormalOne() [TestMethod] [Spec("css21", "cascade.html#important-rules")] + [Spec("css-cascade-5", "#shorthand")] public void ImportantShorthand_MakesEverySubPropertyImportant() { var x = Box("<style>div { margin: 5px !important } #x { margin-left: 20px }</style><div id=x>a</div>"); @@ -259,6 +282,7 @@ public void ImportantShorthand_MakesEverySubPropertyImportant() [TestMethod] [Spec("css21", "cascade.html#important-rules")] + [Spec("css-cascade-5", "#importance")] public void ImportantDeclaration_WinsWithinTheAuthorStyleSheet() { var x = Box( @@ -292,6 +316,7 @@ public void Specificity_MatchesTheSpecExamples(string selector, int ids, int cla [TestMethod] [Spec("css21", "cascade.html#specificity")] [Spec("css-style-attr", "#interpret")] + [Spec("css-cascade-5", "#cascade-sort")] public void StyleAttribute_BeatsAnIdSelector() { var x = Box("<style>#x { color: red }</style><p id=x style='color: green'>a</p>"); @@ -320,6 +345,7 @@ public void PseudoClass_CountsAsAClass() [TestMethod] [Spec("css21", "cascade.html#preshint")] [Spec("html", "rendering.html#the-css-user-agent-style-sheet-and-presentational-hints")] + [Spec("css-cascade-5", "#preshint")] public void PresentationalHint_BeatsTheDefaultStyleSheet() { var x = Box("<table><tr><th id=x align=left>a</th></tr></table>"); @@ -330,6 +356,7 @@ public void PresentationalHint_BeatsTheDefaultStyleSheet() [TestMethod] [Spec("css21", "cascade.html#preshint")] [Spec("html", "rendering.html#the-css-user-agent-style-sheet-and-presentational-hints")] + [Spec("css-cascade-5", "#preshint")] public void StyleAttribute_BeatsAPresentationalHint() { var x = Box("<font id=x color=red style='color:green'>a</font>"); @@ -341,6 +368,7 @@ public void StyleAttribute_BeatsAPresentationalHint() [TestMethod] [Spec("css21", "cascade.html#preshint")] [Spec("html", "rendering.html#the-css-user-agent-style-sheet-and-presentational-hints")] + [Spec("css-cascade-5", "#preshint")] public void PresentationalHint_LosesToAnAuthorRule() { Assert.AreEqual(Green, Box("<style>* { color: green }</style><font id=x color=red>a</font>").ActualColor); diff --git a/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs index 884aee928..37a74376d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs @@ -9,6 +9,7 @@ public sealed class CssSpecificityOrderingTests { [TestMethod] [Spec("css21", "cascade.html#specificity")] + [Spec("css-cascade-5", "#cascade-sort")] public void HigherSpecificityRule_WinsEvenWhenDeclaredEarlier() { var html = Html("#el { color: #0000ff; } div { color: #ff0000; }", "<div id='el'>text</div>"); @@ -18,6 +19,7 @@ public void HigherSpecificityRule_WinsEvenWhenDeclaredEarlier() [TestMethod] [Spec("css21", "cascade.html#cascading-order")] + [Spec("css-cascade-5", "#cascade-sort")] public void EqualSpecificity_SameOrigin_StillResolvesByLastDeclared() { var html = Html("div { color: #ff0000; } div { color: #0000ff; }", "<div>text</div>"); diff --git a/Source/Test/HtmlRenderer.Test/Css/GlobalKeywordPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/GlobalKeywordPropertyTests.cs index f11ffd833..ba28b697a 100644 --- a/Source/Test/HtmlRenderer.Test/Css/GlobalKeywordPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/GlobalKeywordPropertyTests.cs @@ -12,6 +12,7 @@ public sealed class GlobalKeywordPropertyTests [TestMethod] [Spec("css21", "cascade.html#value-def-inherit")] + [Spec("css-cascade-5", "#defaulting-keywords")] public void Color_Inherit_IsMarkedAsInherited() { var property = CssConstructionFunctions.ParseDeclaration("color: inherit"); @@ -22,6 +23,7 @@ public void Color_Inherit_IsMarkedAsInherited() [TestMethod] [Spec("css21", "cascade.html#value-def-inherit")] + [Spec("css-cascade-5", "#defaulting-keywords")] public void MarginTop_Inherit_IsMarkedAsInherited() { var property = CssConstructionFunctions.ParseDeclaration("margin-top: inherit"); @@ -32,6 +34,7 @@ public void MarginTop_Inherit_IsMarkedAsInherited() [TestMethod] [Spec("css21", "cascade.html#value-def-inherit")] + [Spec("css-cascade-5", "#defaulting-keywords")] public void FontSize_Inherit_IsMarkedAsInherited() { var property = CssConstructionFunctions.ParseDeclaration("font-size: inherit"); @@ -42,6 +45,7 @@ public void FontSize_Inherit_IsMarkedAsInherited() [TestMethod] [Spec("css21", "cascade.html#value-def-inherit")] + [Spec("css-cascade-5", "#defaulting-keywords")] public void Display_Inherit_IsMarkedAsInherited() { var property = CssConstructionFunctions.ParseDeclaration("display: inherit"); @@ -53,6 +57,7 @@ public void Display_Inherit_IsMarkedAsInherited() // -- initial ----------------------------------------------------------- [TestMethod] + [Spec("css-cascade-5", "#defaulting-keywords")] public void Color_Initial_IsMarkedAsInitial() { var property = CssConstructionFunctions.ParseDeclaration("color: initial"); @@ -62,6 +67,7 @@ public void Color_Initial_IsMarkedAsInitial() } [TestMethod] + [Spec("css-cascade-5", "#defaulting-keywords")] public void MarginTop_Initial_IsMarkedAsInitial() { var property = CssConstructionFunctions.ParseDeclaration("margin-top: initial"); @@ -71,6 +77,7 @@ public void MarginTop_Initial_IsMarkedAsInitial() } [TestMethod] + [Spec("css-cascade-5", "#defaulting-keywords")] public void FontSize_Initial_IsMarkedAsInitial() { var property = CssConstructionFunctions.ParseDeclaration("font-size: initial"); @@ -82,6 +89,7 @@ public void FontSize_Initial_IsMarkedAsInitial() // -- unset --------------------------------------------------------------- [TestMethod] + [Spec("css-cascade-5", "#defaulting-keywords")] public void Color_Unset_HasUnsetValue() { var property = CssConstructionFunctions.ParseDeclaration("color: unset"); @@ -91,6 +99,7 @@ public void Color_Unset_HasUnsetValue() } [TestMethod] + [Spec("css-cascade-5", "#defaulting-keywords")] public void MarginTop_Unset_HasUnsetValue() { var property = CssConstructionFunctions.ParseDeclaration("margin-top: unset"); @@ -100,6 +109,7 @@ public void MarginTop_Unset_HasUnsetValue() } [TestMethod] + [Spec("css-cascade-5", "#defaulting-keywords")] public void FontFamily_Unset_HasUnsetValue() { var property = CssConstructionFunctions.ParseDeclaration("font-family: unset"); @@ -111,6 +121,7 @@ public void FontFamily_Unset_HasUnsetValue() // -- revert ---------------------------------------------------------------- [TestMethod] + [Spec("css-cascade-5", "#defaulting-keywords")] public void Color_Revert_HasRevertValue() { var property = CssConstructionFunctions.ParseDeclaration("color: revert"); @@ -120,6 +131,7 @@ public void Color_Revert_HasRevertValue() } [TestMethod] + [Spec("css-cascade-5", "#defaulting-keywords")] public void FontSize_Revert_HasRevertValue() { var property = CssConstructionFunctions.ParseDeclaration("font-size: revert"); @@ -129,6 +141,7 @@ public void FontSize_Revert_HasRevertValue() } [TestMethod] + [Spec("css-cascade-5", "#defaulting-keywords")] public void MarginTop_Revert_HasRevertValue() { var property = CssConstructionFunctions.ParseDeclaration("margin-top: revert"); @@ -140,6 +153,7 @@ public void MarginTop_Revert_HasRevertValue() // -- revert-layer ------------------------------------------------------------ [TestMethod] + [Spec("css-cascade-5", "#defaulting-keywords")] public void Color_RevertLayer_HasRevertLayerValue() { var property = CssConstructionFunctions.ParseDeclaration("color: revert-layer"); @@ -149,6 +163,7 @@ public void Color_RevertLayer_HasRevertLayerValue() } [TestMethod] + [Spec("css-cascade-5", "#defaulting-keywords")] public void MarginTop_RevertLayer_HasRevertLayerValue() { var property = CssConstructionFunctions.ParseDeclaration("margin-top: revert-layer"); @@ -158,6 +173,7 @@ public void MarginTop_RevertLayer_HasRevertLayerValue() } [TestMethod] + [Spec("css-cascade-5", "#defaulting-keywords")] public void Display_RevertLayer_HasRevertLayerValue() { var property = CssConstructionFunctions.ParseDeclaration("display: revert-layer"); @@ -170,6 +186,7 @@ public void Display_RevertLayer_HasRevertLayerValue() [TestMethod] [Spec("css21", "cascade.html#value-def-inherit")] + [Spec("css-cascade-5", "#importance")] public void Color_InheritImportant_IsImportantAndInherited() { var property = CssConstructionFunctions.ParseDeclaration("color: inherit !important"); @@ -179,6 +196,7 @@ public void Color_InheritImportant_IsImportantAndInherited() } [TestMethod] + [Spec("css-cascade-5", "#importance")] public void MarginTop_UnsetImportant_IsImportantWithUnsetValue() { var property = CssConstructionFunctions.ParseDeclaration("margin-top: unset !important"); @@ -188,6 +206,7 @@ public void MarginTop_UnsetImportant_IsImportantWithUnsetValue() } [TestMethod] + [Spec("css-cascade-5", "#importance")] public void FontSize_RevertImportant_IsImportantWithRevertValue() { var property = CssConstructionFunctions.ParseDeclaration("font-size: revert !important"); diff --git a/Source/Test/HtmlRenderer.Test/Css/ImportRuleTests.cs b/Source/Test/HtmlRenderer.Test/Css/ImportRuleTests.cs index 17242b1b7..654d8caac 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ImportRuleTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ImportRuleTests.cs @@ -45,6 +45,7 @@ public void CssImportWithSingleQuotedUrl() [TestMethod] [Spec("css21", "cascade.html#at-import")] + [Spec("css-cascade-5", "#at-import")] public void CssImportWithDoubleQuotedStringAsUrl() { var source = "@import \"button.css\";"; diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssCascadeTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssCascadeTests.cs new file mode 100644 index 000000000..055dd81e2 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssCascadeTests.cs @@ -0,0 +1,162 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssCascadeTests +{ + private static readonly RColor Green = RColor.FromArgb(0, 128, 0); + + // ── 2 @import ──────────────────────────────────────────────────────── + + [Ignore("Gap, see Spec/css-cascade-5.yml")] + [TestMethod] + [Spec("css-cascade-5", "#at-import")] + public void ImportPrelude_SeparatesLayerAndSupportsFromTheMediaList() + { + var rule = (ImportRule)CssConstructionFunctions.ParseStyleSheet( + "@import url(a.css) layer(base) supports(display: flex) screen;").Rules.Single(); + + Assert.AreEqual("a.css", rule.Href); + Assert.AreEqual("screen", rule.Media.MediaText); + } + + // ── 3 Shorthands ───────────────────────────────────────────────────── + + [TestMethod] + [Spec("css-cascade-5", "#shorthand")] + public void Shorthand_ResetsOmittedSubPropertiesToTheirInitialValue() + { + var x = Box("<style>p { font-style: italic; font-weight: bold } #x { font: 12px serif }</style><p id=x>a</p>"); + + Assert.AreEqual("normal", x.FontStyle); + Assert.AreEqual("normal", x.FontWeight); + Assert.AreEqual("12px", x.FontSize); + } + + [TestMethod] + [Spec("css-cascade-5", "#shorthand")] + public void Shorthand_WithACssWideKeyword_SetsEverySubProperty() + { + var x = Box("<div style='margin: 7px'><div id=x style='margin: inherit'>a</div></div>"); + + Assert.AreEqual("7px", x.MarginLeft); + Assert.AreEqual("7px", x.MarginTop); + } + + // ── 4 Value processing ─────────────────────────────────────────────── + + [TestMethod] + [Spec("css-cascade-5", "#applies-to")] + public void PropertyThatDoesNotApply_StillHasAComputedValueThatInherits() + { + var x = Box("<table><tr style='text-indent: 10px'><td id=x>a</td></tr></table>"); + + Assert.AreEqual("10px", x.TextIndent); + } + + // ── 5 Filtering ────────────────────────────────────────────────────── + + [TestMethod] + [Spec("css-cascade-5", "#filtering")] + [Spec("css-cascade-5", "#declared")] + public void InvalidDeclarations_AreDroppedBeforeTheCascade() + { + var x = Box("<style>p { color: green } p { color: bogus } p { colour: red }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + // ── 6.4 Cascade layers ─────────────────────────────────────────────── + + [Ignore("Gap, see Spec/css-cascade-5.yml")] + [TestMethod] + [Spec("css-cascade-5", "#layer-names")] + public void LayerName_ThatIsACssWideKeyword_InvalidatesTheRule() + { + var x = Box("<style>p { color: green } @layer initial { p { color: red } }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + // ── 7.3 Explicit defaulting ────────────────────────────────────────── + + [TestMethod] + [Spec("css-cascade-5", "#initial")] + public void Initial_IgnoresTheDefaultStyleSheet() + { + var x = Box("<h1 id=x style='font-weight: initial'>a</h1>"); + + Assert.AreEqual("normal", x.FontWeight); + } + + [TestMethod] + [Spec("css-cascade-5", "#inherit-initial")] + public void Unset_ErasesEarlierDeclarations() + { + var x = Box("<div style='color: green'><p id=x style='color: red; color: unset; margin-left: 5px; margin-left: unset'>a</p></div>"); + + Assert.AreEqual(Green, x.ActualColor); + Assert.AreEqual("0", x.MarginLeft); + } + + [TestMethod] + [Spec("css-cascade-5", "#default")] + public void Revert_InAnAuthorRule_RollsBackToTheDefaultStyleSheet() + { + var x = Box("<style>h1 { font-weight: normal } h1 { font-weight: revert }</style><h1 id=x>a</h1>"); + + Assert.AreEqual("bolder", x.FontWeight); + } + + [TestMethod] + [Spec("css-cascade-5", "#default")] + public void Revert_WithoutADefaultStyleSheetValue_InheritsOrTakesTheInitialValue() + { + var x = Box("<style>div { color: green } p { color: red; margin-left: 5px } p { color: revert; margin-left: revert }</style><div><p id=x>a</p></div>"); + + Assert.AreEqual(Green, x.ActualColor); + Assert.AreEqual("0", x.MarginLeft); + } + + [Ignore("Gap, see Spec/css-cascade-5.yml")] + [TestMethod] + [Spec("css-cascade-5", "#default")] + public void Revert_InAStyleAttribute_RollsBackToTheDefaultStyleSheet() + { + var x = Box("<style>h1 { font-weight: normal }</style><h1 id=x style='font-weight: revert'>a</h1>"); + + Assert.AreEqual("bolder", x.FontWeight); + } + + [Ignore("Gap, see Spec/css-cascade-5.yml")] + [TestMethod] + [Spec("css-cascade-5", "#default")] + public void Revert_InAnImportantAuthorRule_RollsBackToTheDefaultStyleSheet() + { + var x = Box("<style>h1 { font-weight: normal } h1 { font-weight: revert !important }</style><h1 id=x>a</h1>"); + + Assert.AreEqual("bolder", x.FontWeight); + } + + [TestMethod] + [Spec("css-cascade-5", "#revert-layer")] + public void RevertLayer_WithoutALowerLayer_RollsBackToTheDefaultStyleSheet() + { + var x = Box("<style>h1 { font-weight: normal } h1 { font-weight: revert-layer }</style><h1 id=x>a</h1>"); + + Assert.AreEqual("bolder", x.FontWeight); + } + + private static CssBox Box(string body, string id = "x") + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(body)); + var box = LayoutHarness.FindById(root, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box; + } +} diff --git a/Spec/css-cascade-5.yml b/Spec/css-cascade-5.yml index 0a9b17867..e5c8ff04f 100644 --- a/Spec/css-cascade-5.yml +++ b/Spec/css-cascade-5.yml @@ -5,169 +5,167 @@ snapshot: https://www.w3.org/TR/2022/CR-css-cascade-5-20220113/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown + status: n/a '#at-import': title: '2 Importing Style Sheets: the @import rule' - status: unknown + status: parsed-only + gaps: + - The imported style sheet is never loaded, see CSS 2.1 6.3 + - layer() and supports() in the prelude are read as part of the media list '#conditional-import': title: 2.1 Conditional @import Rules - status: unknown + status: parsed-only + gaps: + - The import conditions are never evaluated, see CSS 2.1 6.3 '#import-processing': title: 2.2 Processing Stylesheet Imports - status: unknown + status: unsupported '#content-type': title: 2.3 Content-Type of CSS Style Sheets - status: unknown + status: unsupported '#shorthand': title: 3 Shorthand Properties - status: unknown + status: supported '#aliasing': title: 3.1 Property Aliasing - status: unknown + status: n/a '#all-shorthand': title: '3.2 Resetting All Properties: the all property' - status: unknown + status: unsupported '#value-stages': title: 4 Value Processing - status: unknown + status: partial '#declared': title: 4.1 Declared Values - status: unknown + status: supported '#value-aliasing': title: 4.1.1 Value Aliasing - status: unknown + status: n/a '#cascaded': title: 4.2 Cascaded Values - status: unknown + status: supported '#specified': title: 4.3 Specified Values - status: unknown + status: supported '#computed': title: 4.4 Computed Values - status: unknown + status: partial + gaps: + - An em length declared before the element's own 'font-size' is computed against the parent's font size, see CSS 2.1 6.1.2 '#used': title: 4.5 Used Values - status: unknown + status: supported '#applies-to': title: 4.5.1 Applicable Properties - status: unknown + status: supported '#actual': title: 4.6 Actual Values - status: unknown + status: n/a '#stages-examples': title: 4.7 Examples - status: unknown + status: n/a '#fragments': title: 4.8 Per-Fragment Value Processing - status: unknown + status: unsupported '#filtering': title: 5 Filtering - status: unknown + status: partial + gaps: + - The media attribute of link and style elements is ignored, see CSS 2.1 6.4.1 '#cascading': title: 6 Cascading - status: unknown + status: partial '#cascade-sort': title: 6.1 Cascade Sorting Order - status: unknown + status: partial + gaps: + - Cascade layers don't take part in the sort, see 6.4 '#cascading-origins': title: 6.2 Cascading Origins - status: unknown + status: supported '#importance': title: '6.3 Important Declarations: the !important annotation' - status: unknown + status: supported '#layering': title: 6.4 Cascade Layers - status: unknown + status: partial + gaps: + - Rules of different layers, and layered against unlayered rules, are sorted by specificity and order of appearance instead of layer order + - '!important doesn''t reverse the layer order' '#layer-declaration': title: 6.4.1 Declaring Cascade Layers - status: unknown + status: parsed-only + gaps: + - '@import with layer or layer() is never loaded, see 2' '#layer-names': title: 6.4.2 Layer Naming and Nesting - status: unknown + status: parsed-only + gaps: + - A CSS-wide keyword as a layer name doesn't invalidate the rule '#unnamed-layers': title: 6.4.2.1 Anonymous Layers - status: unknown + status: parsed-only '#layer-ordering': title: 6.4.3 Layer Ordering - status: unknown + status: unsupported '#at-layer': title: '6.4.4 Declaring Layers Inline: the @layer rule' - status: unknown + status: partial '#layer-block': title: '6.4.4.1 Assigning Styles Inline: the @layer block at-rule' - status: unknown + status: partial + gaps: + - Its rules apply as unlayered rules, see 6.4 '#layer-empty': title: '6.4.4.2 Declaring Without Styles: the @layer statement at-rule' - status: unknown + status: parsed-only + gaps: + - The statement doesn't establish a layer order '#preshint': title: 6.5 Precedence of Non-CSS Presentational Hints - status: unknown + status: partial + gaps: + - Presentational attributes are applied after the author style sheets and override them, see CSS 2.1 6.4.4 '#defaulting': title: 7 Defaulting - status: unknown + status: partial '#initial-values': title: 7.1 Initial Values - status: unknown + status: supported '#inheriting': title: 7.2 Inheritance - status: unknown + status: partial + gaps: + - A percentage 'font-size' is inherited as the percentage, see CSS 2.1 6.2 + - '''word-spacing'' isn''t inherited, see CSS 2.1 6.2' '#defaulting-keywords': title: 7.3 Explicit Defaulting - status: unknown + status: partial '#initial': title: '7.3.1 Resetting a Property: the initial keyword' - status: unknown + status: supported '#inherit': title: '7.3.2 Explicit Inheritance: the inherit keyword' - status: unknown + status: partial + gaps: + - '''inherit'' takes the parent''s specified value, and on the root element the value of the anonymous box around it, see CSS 2.1 6.2.1' '#inherit-initial': title: '7.3.3 Erasing All Declarations: the unset keyword' - status: unknown + status: supported '#default': title: '7.3.4 Rolling Back Cascade Origins: the revert keyword' - status: unknown + status: partial + gaps: + - revert in a style attribute rolls back to the author style sheets instead of the UA style sheet + - revert in an !important author declaration rolls back to the normal author declarations instead of the UA style sheet '#revert-layer': title: '7.3.5 Rolling Back Cascade Layers: the revert-layer keyword' - status: unknown + status: partial + gaps: + - Behaves like revert, it rolls back past all author layers instead of to the layer below '#layer-apis': title: 8 Layer APIs - status: unknown - '#extensions-to-cssimportrule-interface': - title: 8.1 Extensions to the CSSImportRule interface - status: unknown - '#the-csslayerblockrule-interface': - title: 8.2 The CSSLayerBlockRule interface - status: unknown - '#the-csslayerstatementrule-interface': - title: 8.3 The CSSLayerStatementRule interface - status: unknown + status: n/a '#changes': title: 9 Changes - status: unknown - '#changes-2021-10': - title: 9.1 Changes since the 15 Oct 2021 Working Draft - status: unknown - '#changes-2021-08': - title: 9.2 Changes since the 29 August 2021 Working Draft - status: unknown - '#changes-2021-06': - title: 9.3 Changes since the 8 June 2021 Working Draft - status: unknown - '#changes-2021-03': - title: 9.4 Changes since the 19 March 2021 Working Draft - status: unknown - '#changes-2021-01': - title: 9.5 Changes since the 19 January 2021 Working Draft - status: unknown - '#additions-l4': - title: 9.6 Additions Since Level 4 - status: unknown - '#additions-l3': - title: 9.7 Additions Since Level 3 - status: unknown - '#changes-2': - title: 9.8 Additions Since Level 2 - status: unknown + status: n/a From 395958f115ebca9a678a75165ecfdcac3b1f87fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:00:41 +0100 Subject: [PATCH 056/150] Verify and document CSS Custom Properties compliance --- Source/HtmlRenderer/Core/Parse/DomParser.cs | 54 ++--- .../Css/CustomPropertyTests.cs | 20 +- .../CssModules/CssVariablesTests.cs | 206 ++++++++++++++++++ Spec/css-variables-1.yml | 51 ++--- 4 files changed, 260 insertions(+), 71 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssVariablesTests.cs diff --git a/Source/HtmlRenderer/Core/Parse/DomParser.cs b/Source/HtmlRenderer/Core/Parse/DomParser.cs index 86faf0096..42609f79d 100644 --- a/Source/HtmlRenderer/Core/Parse/DomParser.cs +++ b/Source/HtmlRenderer/Core/Parse/DomParser.cs @@ -454,12 +454,9 @@ private static void AssignCssBlock( } /// <summary> - /// Assigns a single custom property (--foo) declaration to the box's custom-property store. - /// Unlike regular properties, custom properties are always inherited, their names are - /// case-sensitive, and their global keywords resolve against the custom-property dictionary - /// rather than the fixed, known-property switch used by <see cref="AssignCssBlock"/>. The stored - /// value is left unresolved (it may itself contain var(...)) - resolution happens once, later, in - /// <see cref="ResolveDeferredVarProperties"/>. + /// CSS Variables 1 §2. Stores the value with its var() functions unresolved, they're only + /// substituted where a regular property uses it (<see cref="ResolveDeferredVarProperties"/>), + /// so a child inherits the unresolved value. /// </summary> private static void AssignCustomPropertyDeclaration( CssBox box, @@ -477,7 +474,8 @@ private static void AssignCustomPropertyDeclaration( } else if (prop.Value == CssConstants.Initial) { - rawValue = null; // guaranteed-invalid value => property becomes absent + // CSS Variables 1 §2.2, the guaranteed-invalid value is stored as absence + rawValue = null; } else if (prop.Value == CssConstants.Revert || prop.Value == CssConstants.RevertLayer) { @@ -501,9 +499,8 @@ private static void AssignCustomPropertyDeclaration( } /// <summary> - /// Result of attempting to resolve every var() reference in a declaration's value. Success is - /// false only when a reference is "guaranteed-invalid" (no matching custom property, no - /// fallback, or a cyclic reference) - per spec this invalidates the whole value. + /// A value with its var() functions substituted, or no success when one of them made it invalid + /// at computed-value time (CSS Variables 1 §3.1). /// </summary> private struct VarResolution { @@ -543,11 +540,8 @@ private static void ResolveCustomProperties(CssBox box) } /// <summary> - /// Resolves every regular property deferred during the cascade's phases (see - /// <see cref="AssignCssBlock"/>) now that this box's custom properties reflect their FINAL - /// cascaded (though not yet var()-resolved) values. Resolution is graph-based and memoized per - /// box, so multi-hop cyclic references are detected correctly regardless of which pending - /// property triggers the lookup first. + /// CSS Variables 1 §3, substitutes the var() functions of the properties deferred by + /// <see cref="AssignCssBlock"/> once the box's custom properties have their cascaded values. /// </summary> private static void ResolveDeferredVarProperties(CssBox box, Dictionary<string, string> pendingVarProperties) { @@ -570,11 +564,8 @@ private static void ResolveDeferredVarProperties(CssBox box, Dictionary<string, } /// <summary> - /// Applies a fully var()-resolved property value to the box. Shorthand properties are re-parsed - /// through the real CSS-OM shorthand converter and expanded into longhands here, because - /// var()-containing shorthands deliberately skip longhand expansion at parse time (their value - /// can't be split into per-longhand slices until their var() references are resolved, which has - /// only just happened here). + /// Parses the substituted value and applies it, a shorthand is only expanded into its longhands + /// here (CSS Variables 1 §3.2). A value that doesn't parse is dropped. /// </summary> private static void ApplyResolvedPropertyValue(CssBox box, string name, string value) { @@ -600,9 +591,7 @@ private static void ApplyResolvedPropertyValue(CssBox box, string name, string v } /// <summary> - /// Resolves every var(...) occurrence in <paramref name="value"/> to plain text. Quote-aware (so - /// `content: "var(--x)"` is left untouched) and paren-depth-aware (so a fallback containing - /// nested commas splits correctly). + /// Substitutes every var() in <paramref name="value"/> as text, skipping quoted strings. /// </summary> private static VarResolution SubstituteVarReferences(CssBox box, string value, Dictionary<string, string> resolvedCache, HashSet<string> resolving, HashSet<string> cyclic) { @@ -654,12 +643,9 @@ private static VarResolution SubstituteVarReferences(CssBox box, string value, D } /// <summary> - /// Recursive + memoized: resolves box.CustomProperties[name]'s own var() references first (so a - /// custom property may reference another custom property, in any declaration order), using - /// `resolving` as a visited-set for cycle detection. `cyclic` permanently marks a property found - /// to (directly or transitively) reference itself - distinct from plain "not found", since per - /// spec a self-referencing property is guaranteed-invalid regardless of any fallback written - /// inside its own definition. + /// The value of a custom property with its own var() functions substituted, false when it's + /// missing or invalid. <paramref name="resolving"/> detects cycles, a property in one is + /// invalid (CSS Variables 1 §2.3) and stays in <paramref name="cyclic"/>. /// </summary> private static bool TryResolveCustomProperty(CssBox box, string name, Dictionary<string, string> resolvedCache, HashSet<string> resolving, HashSet<string> cyclic, out string value) { @@ -680,7 +666,7 @@ private static bool TryResolveCustomProperty(CssBox box, string name, Dictionary if (!resolving.Add(name)) { - cyclic.Add(name); // name is referenced while already being resolved - a cycle + cyclic.Add(name); value = null; return false; } @@ -743,9 +729,8 @@ private static bool TryMatchVarCall(string value, int start, out int argsStart, } /// <summary> - /// Splits a var() argument list at the first top-level (paren-depth 0, outside quotes) comma. - /// The text before it is the custom property name (trimmed); everything after - commas and all - - /// is the fallback, per spec. + /// Splits var() arguments at the first top-level comma into the name and the fallback (CSS + /// Variables 1 §3). An empty fallback is returned as none. /// </summary> private static void SplitFirstTopLevelComma(string value, int start, int end, out string name, out string fallback) { @@ -804,8 +789,7 @@ private static int SkipQuotedString(string value, int start) } /// <summary> - /// The value a property falls back to when a var() reference in it is guaranteed-invalid - - /// reuses the same expression as the "unset" keyword arm in <see cref="AssignCssBlock"/>. + /// CSS Variables 1 §3.1, a property that's invalid at computed-value time acts as unset. /// </summary> private static string GetGuaranteedInvalidFallback(CssBox box, string propName) { diff --git a/Source/Test/HtmlRenderer.Test/Css/CustomPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/CustomPropertyTests.cs index 491cc8c22..c7a69b98d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CustomPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CustomPropertyTests.cs @@ -7,14 +7,14 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/CustomPropertyTests.cs. Verifies that the CSS module -/// correctly parses custom properties (--foo) and tolerates the var() function inside any property's -/// value. The inheritance tests at the end run the cascade, the rest are pure CSSOM parse tests. +/// Parsing of custom properties and var(), and how custom properties inherit. The rest of the +/// cascade-level behavior is in CssVariablesTests. /// </summary> [TestClass] public sealed class CustomPropertyTests { [TestMethod] + [Spec("css-variables-1", "#defining-variables")] public void CustomProperty_SimpleValue_ParsesAndRoundTrips() { var property = CssConstructionFunctions.ParseDeclaration("--main-color: red"); @@ -25,6 +25,7 @@ public void CustomProperty_SimpleValue_ParsesAndRoundTrips() } [TestMethod] + [Spec("css-variables-1", "#syntax")] public void CustomProperty_ArbitraryTokenSoup_NeverFailsToParse() { var property = CssConstructionFunctions.ParseDeclaration("--x: 1px solid red , foo(bar)"); @@ -36,6 +37,7 @@ public void CustomProperty_ArbitraryTokenSoup_NeverFailsToParse() [TestMethod] [DataRow("--x: #hero", "#hero")] // an id-shaped hash-token value (CSS Syntax accepts it) [DataRow("--x: #f00", "#f00")] // a hex color hash + [Spec("css-variables-1", "#syntax")] public void CustomProperty_HashValue_ParsesAndRoundTrips(string snippet, string expected) { var property = CssConstructionFunctions.ParseDeclaration(snippet); @@ -46,6 +48,7 @@ public void CustomProperty_HashValue_ParsesAndRoundTrips(string snippet, string } [TestMethod] + [Spec("css-variables-1", "#syntax")] public void CustomProperty_WithVarReference_RoundTripsLiterally() { var property = CssConstructionFunctions.ParseDeclaration("--b: var(--a, blue)"); @@ -55,6 +58,7 @@ public void CustomProperty_WithVarReference_RoundTripsLiterally() } [TestMethod] + [Spec("css-variables-1", "#defining-variables")] public void CustomProperty_NameIsCaseSensitive() { var lower = CssConstructionFunctions.ParseDeclaration("--foo: 1px"); @@ -64,6 +68,7 @@ public void CustomProperty_NameIsCaseSensitive() } [TestMethod] + [Spec("css-variables-1", "#defining-variables")] public void CustomProperty_MultiHyphenatedName_ParsesAsSingleIdentifier() { var property = CssConstructionFunctions.ParseDeclaration("--foo-bar-baz: 1px"); @@ -71,6 +76,7 @@ public void CustomProperty_MultiHyphenatedName_ParsesAsSingleIdentifier() } [TestMethod] + [Spec("css-variables-1", "#using-variables")] public void ColorProperty_WithVarFunction_DoesNotFailToParse() { var property = CssConstructionFunctions.ParseDeclaration("color: var(--main-color, red)"); @@ -80,6 +86,7 @@ public void ColorProperty_WithVarFunction_DoesNotFailToParse() } [TestMethod] + [Spec("css-variables-1", "#using-variables")] public void MarginProperty_WithMultipleVarFunctions_DoesNotFailToParse() { var property = CssConstructionFunctions.ParseDeclaration("margin: var(--a) var(--b)"); @@ -88,11 +95,9 @@ public void MarginProperty_WithMultipleVarFunctions_DoesNotFailToParse() } [TestMethod] + [Spec("css-variables-1", "#variables-in-shorthands")] public void MarginShorthandInStyleSheet_WithVar_KeepsShorthandWholeUntilCascadeTime() { - // Shorthand-to-longhand expansion happens at parse time (StyleDeclaration.SetShorthand); - // a var() reference can't be split into per-longhand slices until it's resolved per-element, - // so the shorthand must survive intact for CssUtils.SetPropertyValue to expand post-substitution. var sheet = CssConstructionFunctions.ParseStyleSheet("* { margin: var(--a) var(--b); }"); var rule = sheet.StyleRules.Single(); var names = rule.Style.Select(p => p.Name).ToArray(); @@ -100,6 +105,7 @@ public void MarginShorthandInStyleSheet_WithVar_KeepsShorthandWholeUntilCascadeT } [TestMethod] + [Spec("css-variables-1", "#using-variables")] public void Property_WithNestedVarInsideOtherFunction_DoesNotFailToParse() { var property = CssConstructionFunctions.ParseDeclaration("background-image: linear-gradient(var(--c1), var(--c2))"); @@ -107,7 +113,7 @@ public void Property_WithNestedVarInsideOtherFunction_DoesNotFailToParse() Assert.IsTrue(property.HasValue); } - // ── Lexer regression coverage for the -- identifier-start fix ────────── + // ── Tokenizing -- as an identifier start ───────────────────────────── [TestMethod] public void HtmlCommentClose_StillTokenizesAsCdc_NotAffectedByDashFix() diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssVariablesTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssVariablesTests.cs new file mode 100644 index 000000000..bac22654b --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssVariablesTests.cs @@ -0,0 +1,206 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssVariablesTests +{ + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + private static readonly RColor Green = RColor.FromArgb(0, 128, 0); + + // ── 2 Custom properties ────────────────────────────────────────────── + + [TestMethod] + [Spec("css-variables-1", "#defining-variables")] + public void CustomProperty_IsInheritedAndCascaded() + { + var x = Box( + "<style>:root { --c: red } div { --c: green } #y { --c: red } p { color: var(--c) }</style>" + + "<div><p id=x>a</p></div><p id=y>b</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-variables-1", "#defining-variables")] + public void CustomPropertyNames_AreCaseSensitive() + { + var x = Box("<p id=x style='--c: green; --C: red; color: var(--c)'>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-variables-1", "#defining-variables")] + public void CustomProperty_CanBeImportant() + { + var x = Box("<style>p { --c: green !important; color: var(--c) }</style><p id=x style='--c: red'>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-variables-1", "#defining-variables")] + public void CustomProperty_TakesCssWideKeywords() + { + var x = Box( + "<div style='--c: green'><p id=x style='--c: red; --c: inherit; color: var(--c)'>a</p></div>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-variables-1", "#syntax")] + public void CustomPropertyValue_KeepsArbitraryTokens() + { + var x = Box("<p id=x style='--m: 3px 7px; margin: var(--m)'>a</p>"); + + Assert.AreEqual("3px", x.MarginTop); + Assert.AreEqual("7px", x.MarginLeft); + } + + [TestMethod] + [Spec("css-variables-1", "#guaranteed-invalid")] + public void Initial_ResetsACustomPropertyToTheGuaranteedInvalidValue() + { + var x = Box("<div style='--c: red'><p id=x style='--c: initial; color: var(--c, green)'>a</p></div>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [Ignore("Gap, see Spec/css-variables-1.yml")] + [TestMethod] + [Spec("css-variables-1", "#guaranteed-invalid")] + public void EmptyCustomProperty_IsValidAndSubstitutesNothing() + { + var x = Box("<p id=x style='--e: ; margin-left: var(--e) 5px'>a</p>"); + + Assert.AreEqual("5px", x.MarginLeft); + } + + [TestMethod] + [Spec("css-variables-1", "#cycles")] + public void CustomPropertiesInACycle_AreInvalid() + { + var x = Box("<p id=x style='--a: var(--b); --b: var(--a); color: var(--a, green)'>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-variables-1", "#cycles")] + public void CustomProperty_IsInheritedWithItsVariablesSubstituted() + { + var x = Box( + "<div style='--foo: 10px'><div style='--bar: calc(var(--foo) + 10px)'>" + + "<p id=x style='--foo: calc(var(--bar) + 10px); margin-left: var(--foo)'>a</p></div></div>"); + + Assert.AreEqual(30, x.ActualMarginLeft, 0.01); + } + + // ── 3 var() ────────────────────────────────────────────────────────── + + [TestMethod] + [Spec("css-variables-1", "#using-variables")] + public void Var_UsesTheFallback_WhenThePropertyIsMissing() + { + var x = Box("<p id=x style='color: var(--missing, green)'>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-variables-1", "#using-variables")] + public void VarFallback_CanContainCommasAndVariables() + { + var x = Box("<p id=x style='--c: green; font-family: var(--f, Arial, serif); color: var(--missing, var(--c))'>a</p>"); + var direct = Box("<p id=x style='font-family: Arial, serif'>a</p>"); + + Assert.AreEqual(direct.FontFamily, x.FontFamily); + Assert.AreEqual(Green, x.ActualColor); + } + + [Ignore("Gap, see Spec/css-variables-1.yml")] + [TestMethod] + [Spec("css-variables-1", "#using-variables")] + public void Var_WithAnEmptyFallback_SubstitutesNothing() + { + var x = Box("<p id=x style='margin-left: var(--missing,) 5px'>a</p>"); + + Assert.AreEqual("5px", x.MarginLeft); + } + + [TestMethod] + [Spec("css-variables-1", "#using-variables")] + public void Var_InACustomProperty_IsSubstitutedAtTheUse() + { + var x = Box("<p id=x style='--b: var(--a); --a: green; color: var(--b)'>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [Ignore("Gap, see Spec/css-variables-1.yml")] + [TestMethod] + [Spec("css-variables-1", "#using-variables")] + public void Var_IsSubstitutedAsTokens_NotAsText() + { + var x = Box("<style>p { margin-left: 7px } #x { --gap: 20; margin-left: var(--gap)px }</style><p id=x>a</p>"); + + Assert.AreEqual(0, x.ActualMarginLeft, 0.01); + } + + [Ignore("Gap, see Spec/css-variables-1.yml")] + [TestMethod] + [Spec("css-variables-1", "#using-variables")] + public void Var_ResolvingToACssWideKeyword_ActsAsThatKeyword() + { + var x = Box("<div style='color: red'><p id=x style='color: var(--missing, initial)'>a</p></div>"); + + Assert.AreEqual(RColor.Black, x.ActualColor); + } + + [TestMethod] + [Spec("css-variables-1", "#invalid-variables")] + public void MissingVariableWithoutFallback_MakesThePropertyUnset() + { + var x = Box( + "<style>p { color: red; margin-left: 5px } #x { color: var(--missing); margin-left: var(--missing) }</style>" + + "<div style='color: green'><p id=x>a</p></div>"); + + Assert.AreEqual(Green, x.ActualColor); + Assert.AreEqual("0", x.MarginLeft); + } + + [Ignore("Gap, see Spec/css-variables-1.yml")] + [TestMethod] + [Spec("css-variables-1", "#invalid-variables")] + public void InvalidValueAfterSubstitution_MakesThePropertyUnset() + { + var x = Box( + "<style>:root { --not-a-color: 20px } p { color: red } #x { color: var(--not-a-color) }</style>" + + "<div style='color: green'><p id=x>a</p></div>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-variables-1", "#variables-in-shorthands")] + public void Shorthand_WithVar_IsExpandedAfterSubstitution() + { + var x = Box("<p id=x style='--w: 3px; --c: green; border: var(--w) solid var(--c)'>a</p>"); + + Assert.AreEqual("3px", x.BorderLeftWidth); + Assert.AreEqual("solid", x.BorderLeftStyle); + Assert.AreEqual(Green, x.ActualBorderLeftColor); + } + + private static CssBox Box(string body, string id = "x") + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(body)); + var box = LayoutHarness.FindById(root, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box; + } +} diff --git a/Spec/css-variables-1.yml b/Spec/css-variables-1.yml index 6e377551d..6aa0fe815 100644 --- a/Spec/css-variables-1.yml +++ b/Spec/css-variables-1.yml @@ -5,58 +5,51 @@ snapshot: https://www.w3.org/TR/2022/CR-css-variables-1-20220616/ sections: '#intro': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown + status: n/a '#defining-variables': title: '2 Defining Custom Properties: the --* family of properties' - status: unknown + status: partial '#syntax': title: 2.1 Custom Property Value Syntax - status: unknown + status: supported '#guaranteed-invalid': title: 2.2 Guaranteed-Invalid Values - status: unknown + status: partial + gaps: + - 'An empty custom property, e.g. --foo: ;, is dropped instead of having a valid empty value' '#cycles': title: 2.3 Resolving Dependency Cycles - status: unknown + status: supported '#using-variables': title: '3 Using Cascading Variables: the var() notation' - status: unknown + status: partial + gaps: + - var() is substituted as text instead of tokens, so var(--gap)px forms a length + - An empty fallback, var(--a,), counts as no fallback + - A CSS-wide keyword substituted by var() is applied as a literal value instead of as the keyword '#invalid-variables': title: 3.1 Invalid Variables - status: unknown + status: partial + gaps: + - A value that doesn't parse after substitution is dropped, so the earlier cascaded value stays instead of the property acting as unset '#variables-in-shorthands': title: 3.2 Variables in Shorthand Properties - status: unknown + status: supported '#long-variables': title: 3.3 Safely Handling Overly-Long Variables - status: unknown + status: unsupported '#apis': title: 4 APIs - status: unknown - '#serializing-custom-props': - title: 4.1 Serializing Custom Properties - status: unknown + status: n/a '#changes': title: 5 Changes - status: unknown - '#changes-20211111': - title: 5.1 Changes Since the 11 November 2021 CR Draft - status: unknown - '#changes-20151203': - title: 5.2 Changes Since the 03 December 2015 CR - status: unknown - '#changes-20140506': - title: 5.3 Changes since the May 6 2014 Last Call Working Draft - status: unknown + status: n/a '#acks': title: 6 Acknowledgments - status: unknown + status: n/a '#privacy': title: 7 Privacy Considerations - status: unknown + status: n/a '#security': title: 8 Security Considerations - status: unknown + status: n/a From cacddd620d40859393d901d3228b82f95de5fafb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:04:31 +0100 Subject: [PATCH 057/150] Verify and document CSS Conditional Rules Level 3 compliance --- .../Conditions/DeclarationCondition.cs | 4 + Source/HtmlRenderer/Core/HtmlContainerInt.cs | 13 +- .../Css/CssSpecificityOrderingTests.cs | 1 + .../HtmlRenderer.Test/Css/MediaQueryTests.cs | 3 + .../HtmlRenderer.Test/Css/MediaTypeTests.cs | 5 +- .../ConditionGroupingRuleIntegrationTests.cs | 18 +- .../HtmlRenderer.Test/Css/SupportsTests.cs | 93 +++++----- .../CssModules/CssConditionalTests.cs | 161 ++++++++++++++++++ Spec/css-conditional-3.yml | 45 ++--- 9 files changed, 250 insertions(+), 93 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/Conditions/DeclarationCondition.cs b/Source/HtmlRenderer/Core/CssEngine/Conditions/DeclarationCondition.cs index 35dba9e9b..85e4c74e3 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Conditions/DeclarationCondition.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Conditions/DeclarationCondition.cs @@ -13,6 +13,10 @@ public DeclarationCondition(Property property, TokenValue tokenValue) _tokenValue = tokenValue; } + /// <summary> + /// CSS Conditional 3 §6.1. A value the parser accepts counts as supported, even where layout + /// ignores it. + /// </summary> public bool Check() { return _property is UnknownProperty == false && diff --git a/Source/HtmlRenderer/Core/HtmlContainerInt.cs b/Source/HtmlRenderer/Core/HtmlContainerInt.cs index e59399426..717c82cf9 100644 --- a/Source/HtmlRenderer/Core/HtmlContainerInt.cs +++ b/Source/HtmlRenderer/Core/HtmlContainerInt.cs @@ -617,11 +617,8 @@ public async Task SetHtml(string htmlSource, CssData baseCssData = null) } /// <summary> - /// Re-runs the cascade if the viewport has changed in a way that changes which <c>@media</c> - /// rules apply - a resize crossing a <c>min-width</c> breakpoint, say. Nothing happens for a - /// document whose stylesheets have no viewport-dependent <c>@media</c> (the common case), or for - /// a resize that stays within the same breakpoint, so this costs one cheap condition test per - /// layout rather than a re-parse. + /// Re-runs the cascade when a viewport change flips the outcome of an <c>@media</c> condition + /// (CSS Conditional 3 §2), by parsing the document again. /// </summary> private void RefreshMediaDependentCascade() { @@ -630,11 +627,7 @@ private void RefreshMediaDependentCascade() var media = MediaQueryContext.FromAdapter(_adapter, _maxSize.Width, _maxSize.Height); if (!_cssData.MediaOutcomeChanged(_cascadeMedia, media)) return; - // Bridges into the async SetHtml from this synchronous layout-time caller - PerformLayout - // itself stays synchronous (confirmed: nothing in the layout/paint call graph touches - // RAdapter.GetResourceStream synchronously - image loads are fire-and-forget-then-relayout, - // not awaited inline - so there's no reason for PerformLayout to become async), so this is a - // deliberate, permanent blocking call, not a temporary stopgap. + // PerformLayout is synchronous, so this blocks on the async SetHtml SetHtml(_htmlSource, _baseCssData).GetAwaiter().GetResult(); } diff --git a/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs index 37a74376d..8cd93c22a 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs @@ -29,6 +29,7 @@ public void EqualSpecificity_SameOrigin_StillResolvesByLastDeclared() [TestMethod] [Spec("css21", "cascade.html#cascading-order")] + [Spec("css-conditional-3", "#processing")] public void MediaRuleDeclaredEarlier_LosesToEqualSpecificityPlainRuleDeclaredLater() { var html = Html( diff --git a/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs index 7026a343a..4ef6ebead 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs @@ -24,6 +24,7 @@ private static CssBox LayoutAndFindTag(string css, string bodyHtml, string tag, [TestMethod] [Spec("css21", "media.html#at-media-rule")] + [Spec("css-conditional-3", "#at-media")] public void AtMedia_Print_NestedRuleAppliesUnderPrintMedia() { var box = LayoutAndFindTag("@media print { p { color: red; } }", "<p>text</p>", "p", "print"); @@ -32,6 +33,7 @@ public void AtMedia_Print_NestedRuleAppliesUnderPrintMedia() [TestMethod] [Spec("css21", "media.html#at-media-rule")] + [Spec("css-conditional-3", "#processing")] public void AtMedia_Print_DoesNotApplyUnderScreenMedia() { // the rule is scoped to the "print" media only - it must not leak into "screen". @@ -57,6 +59,7 @@ public void AtMedia_All_NestedRuleAppliesUnderAllMedia() [TestMethod] [Spec("css21", "media.html#at-media-rule")] + [Spec("css-conditional-3", "#at-media")] public void AtMedia_Print_MultipleNestedRulesAreParsed() { const string css = "@media print { p { color: red; } div { font-size: 12pt; } }"; diff --git a/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs index 399c1e161..c456cf172 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs @@ -61,6 +61,7 @@ public void ImportWithMatchingMedia_Applies() [Spec("css21", "media.html#at-media-rule")] [DataRow("print", "10pt")] [DataRow("screen", "13px")] + [Spec("css-conditional-3", "#at-media")] public void AtMedia_SelectsTheRulesForTheMediaType(string mediaType, string fontSize) { var box = Box(""" @@ -77,13 +78,15 @@ public void AtMedia_SelectsTheRulesForTheMediaType(string mediaType, string font [TestMethod] [Spec("css21", "media.html#at-media-rule")] + [Spec("css-conditional-3", "#processing")] public void RulesOutsideAtMedia_ApplyToAllMediaTypes() { - Assert.AreEqual(Green, ColorOf("<style>p { color: green } @media screen { div { color: red } }</style>", "print")); + Assert.AreEqual(Green, ColorOf("<style>p { color: green } @media screen { p { color: red } }</style>", "print")); } [TestMethod] [Spec("css21", "media.html#at-media-rule")] + [Spec("css-conditional-3", "#contents-of")] public void InvalidStatementInsideAtMedia_IsIgnored() { Assert.AreEqual(Green, ColorOf("<style>@media screen { p..x { color: red } p { color: green } }</style>")); diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/ConditionGroupingRuleIntegrationTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/ConditionGroupingRuleIntegrationTests.cs index 4a0180bea..2cbb5a9a6 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/ConditionGroupingRuleIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/ConditionGroupingRuleIntegrationTests.cs @@ -4,17 +4,13 @@ namespace HtmlRenderer.Test.Css.Selectors; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/ConditionGroupingRuleIntegrationTests.cs. -/// <c>Supports_SvgOnlyProperty_AppliesToAnInlineSvgElement</c> is not ported, HTML-Renderer has no SVG -/// rendering pipeline. The cascade doesn't apply <c>@container</c>'s inner rules yet. -/// </summary> [TestClass] public sealed class ConditionGroupingRuleIntegrationTests { private static readonly RColor Blue = RColor.FromArgb(0, 0, 255); [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void Supports_InnerRules_Apply_WhenConditionIsSupported() { var html = Html( @@ -25,6 +21,7 @@ public void Supports_InnerRules_Apply_WhenConditionIsSupported() } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void SupportsNot_Applies_WhenDeclarationIsInvalid() { var html = Html( @@ -35,8 +32,8 @@ public void SupportsNot_Applies_WhenDeclarationIsInvalid() } [TestMethod] - [Ignore("not yet spec compliant - DeclarationCondition.Check() reports animation-name as supported, " + - "see SupportsTests.SupportsAnimationNameRule_NoLongerFalsePositive")] + [Ignore("Gap, see Spec/css-conditional-3.yml")] + [Spec("css-conditional-3", "#support-definition")] public void SupportsNot_Fallback_Applies_WhenFeatureIsUnsupported() { var html = Html( @@ -46,10 +43,8 @@ public void SupportsNot_Fallback_Applies_WhenFeatureIsUnsupported() Assert.AreEqual(Blue, box.ActualColor); } - // The exact scenario the pre-evaluation stopgap PeachPDF#283 was written to avoid: an enhanced - // block and its not-guarded fallback wrapping the same declaration, both present in one - // stylesheet. Real evaluation must let exactly one of them win, never both and never neither. [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void SupportsEnhancedBlockAndNotGuardedFallback_ExactlyOneApplies() { var html = Html( @@ -62,6 +57,7 @@ public void SupportsEnhancedBlockAndNotGuardedFallback_ExactlyOneApplies() } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void Supports_InnerRules_DoNotApply_WhenConditionIsNotSupported() { var html = Html( @@ -72,6 +68,7 @@ public void Supports_InnerRules_DoNotApply_WhenConditionIsNotSupported() } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void Supports_NestedWithMedia_AppliesOnlyWhenAllConditionsHold() { var html = Html( @@ -85,6 +82,7 @@ public void Supports_NestedWithMedia_AppliesOnlyWhenAllConditionsHold() } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void Supports_InsideLayer_AppliesOnlyWhenConditionIsSupported() { var html = Html( diff --git a/Source/Test/HtmlRenderer.Test/Css/SupportsTests.cs b/Source/Test/HtmlRenderer.Test/Css/SupportsTests.cs index 48c9cbad2..675e94b8c 100644 --- a/Source/Test/HtmlRenderer.Test/Css/SupportsTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/SupportsTests.cs @@ -3,20 +3,11 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/Supports.cs. -/// HTML-Renderer's <see cref="DeclarationCondition"/> still uses the old grammar-only oracle -/// (<c>Property.TrySetValue</c>) rather than PeachPDF's newer <c>CssPropertyRegistry</c>-based split -/// between "parses in the CSS-OM" and "genuinely dispatched/rendered by Layer B" - see -/// Source/HtmlRenderer/Core/CssEngine/Conditions/DeclarationCondition.cs:16-20. That oracle still agrees -/// with the spec-correct expectation for most cases here; the handful where it diverges (a false positive -/// or false negative relative to what the layout engine actually does with the value) are marked -/// <c>[Ignore]</c> below with the same citation. -/// </summary> [TestClass] public sealed class SupportsTests { [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void SupportsEmptyRule() { var source = @"@supports () { }"; @@ -25,10 +16,10 @@ public void SupportsEmptyRule() Assert.IsInstanceOfType<SupportsRule>(sheet.Rules[0]); var supports = (SupportsRule)sheet.Rules[0]; Assert.AreEqual(string.Empty, supports.ConditionText); - Assert.IsTrue(supports.Condition.Check()); } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void SupportsBackgroundColorRedRule() { var source = @"@supports (background-color: red) { }"; @@ -41,6 +32,7 @@ public void SupportsBackgroundColorRedRule() } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void SupportsBackgroundColorRedAndColorBlueRule() { var source = @"@supports ((background-color: red) and (color: blue)) { }"; @@ -53,6 +45,7 @@ public void SupportsBackgroundColorRedAndColorBlueRule() } [TestMethod] + [Spec("css-conditional-3", "#support-definition")] public void SupportsBreakAfterPageRule() { var source = @"@supports (break-after: page) { }"; @@ -64,6 +57,7 @@ public void SupportsBreakAfterPageRule() } [TestMethod] + [Spec("css-conditional-3", "#support-definition")] public void SupportsBreakAfterInvalidValueRule() { var source = @"@supports (break-after: not-a-real-value) { }"; @@ -75,6 +69,7 @@ public void SupportsBreakAfterInvalidValueRule() } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void SupportsNotUnsupportedDeclarationRule() { var source = @"@supports (not (background-transparency: half)) { }"; @@ -87,6 +82,7 @@ public void SupportsNotUnsupportedDeclarationRule() } [TestMethod] + [Spec("css-conditional-3", "#support-definition")] public void SupportsUnsupportedDeclarationRule() { var source = @"@supports ((background-transparency: zero)) { }"; @@ -99,6 +95,7 @@ public void SupportsUnsupportedDeclarationRule() } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void SupportsBackgroundRedWithImportantRule() { var source = @"@supports (background: red !important) { }"; @@ -111,6 +108,7 @@ public void SupportsBackgroundRedWithImportantRule() } [TestMethod] + [Spec("css-conditional-3", "#support-definition")] public void SupportsBackgroundInheritRule() { var source = @"@supports (background: inherit) { }"; @@ -120,11 +118,8 @@ public void SupportsBackgroundInheritRule() } [TestMethod] - [Ignore("HTML-Renderer's DeclarationCondition.Check() still uses the old grammar-only oracle " + - "(Property.TrySetValue), which parses 'animation-name' fine in the CSS-OM even though it is " + - "never dispatched by the layout engine - a false positive relative to PeachPDF's newer " + - "CssPropertyRegistry-based distinction. See " + - "Source/HtmlRenderer/Core/CssEngine/Conditions/DeclarationCondition.cs:16-20.")] + [Ignore("Gap, see Spec/css-conditional-3.yml")] + [Spec("css-conditional-3", "#support-definition")] public void SupportsAnimationNameRule_NoLongerFalsePositive() { var source = @"@supports (animation-name: spin) { }"; @@ -134,12 +129,14 @@ public void SupportsAnimationNameRule_NoLongerFalsePositive() } [TestMethod] - public void SupportsSvgFillRule_NoLongerFalseNegative() + [Ignore("Gap, see Spec/css-conditional-3.yml")] + [Spec("css-conditional-3", "#support-definition")] + public void SupportsSvgFillRule_NotGenuinelyRendered() { var source = @"@supports (fill: red) { }"; var sheet = CssConstructionFunctions.ParseStyleSheet(source); var supports = (SupportsRule)sheet.Rules[0]; - Assert.IsTrue(supports.Condition.Check()); + Assert.IsFalse(supports.Condition.Check()); } [TestMethod] @@ -148,6 +145,7 @@ public void SupportsSvgFillRule_NoLongerFalseNegative() [DataRow("unset")] [DataRow("revert")] [DataRow("revert-layer")] + [Spec("css-conditional-3", "#support-definition")] public void SupportsCssWideKeywordRule_AlwaysSupportedForARecognizedProperty(string keyword) { var source = $@"@supports (color: {keyword}) {{ }}"; @@ -157,6 +155,7 @@ public void SupportsCssWideKeywordRule_AlwaysSupportedForARecognizedProperty(str } [TestMethod] + [Spec("css-conditional-3", "#support-definition")] public void SupportsCssWideKeywordRule_StillFailsForAnUnrecognizedProperty() { var source = @"@supports (background-transparency: inherit) { }"; @@ -166,19 +165,19 @@ public void SupportsCssWideKeywordRule_StillFailsForAnUnrecognizedProperty() } [TestMethod] - public void SupportsTransformRotateRule() + [Ignore("Gap, see Spec/css-conditional-3.yml")] + [Spec("css-conditional-3", "#support-definition")] + public void SupportsTransformRotateRule_NotGenuinelyRendered() { var source = @"@supports (transform: rotate(10deg)) { }"; var sheet = CssConstructionFunctions.ParseStyleSheet(source); var supports = (SupportsRule)sheet.Rules[0]; - Assert.IsTrue(supports.Condition.Check()); + Assert.IsFalse(supports.Condition.Check()); } [TestMethod] - [Ignore("HTML-Renderer's DeclarationCondition.Check() still uses the old grammar-only oracle, which " + - "parses perspective() as a valid transform function even though it is not genuinely rendered - " + - "a false positive relative to PeachPDF's CssPropertyRegistry-based split. See " + - "Source/HtmlRenderer/Core/CssEngine/Conditions/DeclarationCondition.cs:16-20.")] + [Ignore("Gap, see Spec/css-conditional-3.yml")] + [Spec("css-conditional-3", "#support-definition")] public void SupportsTransformPerspectiveRule_NotGenuinelyRendered() { var source = @"@supports (transform: perspective(300px)) { }"; @@ -188,6 +187,7 @@ public void SupportsTransformPerspectiveRule_NotGenuinelyRendered() } [TestMethod] + [Spec("css-conditional-3", "#support-definition")] public void SupportsBreakBeforeRegionRule_NotGenuinelyEnforced() { var source = @"@supports (break-before: region) { }"; @@ -197,6 +197,7 @@ public void SupportsBreakBeforeRegionRule_NotGenuinelyEnforced() } [TestMethod] + [Spec("css-conditional-3", "#support-definition")] public void SupportsBreakBeforePageRule() { var source = @"@supports (break-before: page) { }"; @@ -206,15 +207,18 @@ public void SupportsBreakBeforePageRule() } [TestMethod] - public void SupportsColumnSpanAllRule() + [Ignore("Gap, see Spec/css-conditional-3.yml")] + [Spec("css-conditional-3", "#support-definition")] + public void SupportsColumnSpanAllRule_NotGenuinelyEnforced() { var source = @"@supports (column-span: all) { }"; var sheet = CssConstructionFunctions.ParseStyleSheet(source); var supports = (SupportsRule)sheet.Rules[0]; - Assert.IsTrue(supports.Condition.Check()); + Assert.IsFalse(supports.Condition.Check()); } [TestMethod] + [Spec("css-conditional-3", "#support-definition")] public void SupportsColumnSpanNoneRule() { var source = @"@supports (column-span: none) { }"; @@ -224,10 +228,8 @@ public void SupportsColumnSpanNoneRule() } [TestMethod] - [Ignore("HTML-Renderer's DeclarationCondition.Check() still uses the old grammar-only oracle, which " + - "parses 'keep-all' as a valid word-break value even though the line-breaking logic only " + - "special-cases break-all - a false positive relative to PeachPDF's CssPropertyRegistry-based " + - "split. See Source/HtmlRenderer/Core/CssEngine/Conditions/DeclarationCondition.cs:16-20.")] + [Ignore("Gap, see Spec/css-conditional-3.yml")] + [Spec("css-conditional-3", "#support-definition")] public void SupportsWordBreakKeepAllRule_NotGenuinelyEnforced() { var source = @"@supports (word-break: keep-all) { }"; @@ -237,15 +239,18 @@ public void SupportsWordBreakKeepAllRule_NotGenuinelyEnforced() } [TestMethod] - public void SupportsTextTransformFullWidthRule() + [Ignore("Gap, see Spec/css-conditional-3.yml")] + [Spec("css-conditional-3", "#support-definition")] + public void SupportsTextTransformFullWidthRule_NotGenuinelyEnforced() { var source = @"@supports (text-transform: full-width) { }"; var sheet = CssConstructionFunctions.ParseStyleSheet(source); var supports = (SupportsRule)sheet.Rules[0]; - Assert.IsTrue(supports.Condition.Check()); + Assert.IsFalse(supports.Condition.Check()); } [TestMethod] + [Spec("css-conditional-3", "#support-definition")] public void SupportsWordBreakAllRule() { var source = @"@supports (word-break: break-all) { }"; @@ -255,6 +260,7 @@ public void SupportsWordBreakAllRule() } [TestMethod] + [Spec("css-conditional-3", "#support-definition")] public void SupportsDirectionRule() { var source = @"@supports (direction: rtl) { }"; @@ -264,6 +270,7 @@ public void SupportsDirectionRule() } [TestMethod] + [Spec("css-conditional-3", "#support-definition")] public void SupportsDirectionGarbageValueRule_NoLongerAlwaysTrue() { var source = @"@supports (direction: sideways) { }"; @@ -273,6 +280,7 @@ public void SupportsDirectionGarbageValueRule_NoLongerAlwaysTrue() } [TestMethod] + [Spec("css-conditional-3", "#support-definition")] public void SupportsDisplayContentsRule_NotImplemented() { var source = @"@supports (display: contents) { }"; @@ -282,6 +290,7 @@ public void SupportsDisplayContentsRule_NotImplemented() } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void SupportsPaddingTopOrPaddingLeftRule() { var source = @"@supports ((padding-TOP : 0) or (padding-left : 0)) { }"; @@ -294,6 +303,7 @@ public void SupportsPaddingTopOrPaddingLeftRule() } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void SupportsPaddingTopOrPaddingLeftAndPaddingBottomOrPaddingRightRule() { var source = @"@supports (((padding-top: 0) or (padding-left: 0)) and ((padding-bottom: 0) or (padding-right: 0))) { }"; @@ -306,6 +316,7 @@ public void SupportsPaddingTopOrPaddingLeftAndPaddingBottomOrPaddingRightRule() } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void SupportsDisplayFlexWithImportantRule() { var source = @"@supports (display: flex !important) { }"; @@ -318,6 +329,7 @@ public void SupportsDisplayFlexWithImportantRule() } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void SupportsBareDisplayFlexRule() { var source = @"@supports display: flex { }"; @@ -326,6 +338,7 @@ public void SupportsBareDisplayFlexRule() } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void SupportsDisplayFlexMultipleBracketsRule() { var source = @"@supports ((display: flex)) { }"; @@ -338,11 +351,8 @@ public void SupportsDisplayFlexMultipleBracketsRule() } [TestMethod] - [Ignore("HTML-Renderer's DeclarationCondition.Check() still uses the old grammar-only oracle, which " + - "parses transition-property/animation-name fine in the CSS-OM even though neither is genuinely " + - "dispatched by the layout engine - a false positive relative to PeachPDF's " + - "CssPropertyRegistry-based split. See " + - "Source/HtmlRenderer/Core/CssEngine/Conditions/DeclarationCondition.cs:16-20.")] + [Ignore("Gap, see Spec/css-conditional-3.yml")] + [Spec("css-conditional-3", "#support-definition")] public void SupportsTransitionOrAnimationNameAndTransformFrontBracketRule() { var source = @"@supports ((transition-property: color) or @@ -357,11 +367,8 @@ public void SupportsTransitionOrAnimationNameAndTransformFrontBracketRule() } [TestMethod] - [Ignore("HTML-Renderer's DeclarationCondition.Check() still uses the old grammar-only oracle, which " + - "parses transition-property/animation-name fine in the CSS-OM even though neither is genuinely " + - "dispatched by the layout engine - a false positive relative to PeachPDF's " + - "CssPropertyRegistry-based split. See " + - "Source/HtmlRenderer/Core/CssEngine/Conditions/DeclarationCondition.cs:16-20.")] + [Ignore("Gap, see Spec/css-conditional-3.yml")] + [Spec("css-conditional-3", "#support-definition")] public void SupportsTransitionOrAnimationNameAndTransformBackBracketRule() { var source = @"@supports (transition-property: color) or @@ -376,6 +383,7 @@ public void SupportsTransitionOrAnimationNameAndTransformBackBracketRule() } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void SupportsShadowVendorPrefixesRule() { var source = @"@supports ( box-shadow: 0 0 2px black ) or @@ -391,6 +399,7 @@ public void SupportsShadowVendorPrefixesRule() } [TestMethod] + [Spec("css-conditional-3", "#at-supports")] public void SupportsNegatedDisplayFlexRuleWithDeclarations() { var source = @"@supports not ( display: flex ) { diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs new file mode 100644 index 000000000..1822e4954 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs @@ -0,0 +1,161 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssConditionalTests +{ + private static readonly RColor Green = RColor.FromArgb(0, 128, 0); + + // ── 2 Processing ───────────────────────────────────────────────────── + + [TestMethod] + [Spec("css-conditional-3", "#processing")] + public void TrueCondition_AppliesTheRulesInPlace() + { + Assert.AreEqual(Green, Box("<style>@media screen { p { color: green } }</style><p id=x>a</p>").ActualColor); + Assert.AreEqual(Green, Box("<style>@media screen { p { color: red } } p { color: green }</style><p id=x>a</p>").ActualColor); + } + + [TestMethod] + [Spec("css-conditional-3", "#processing")] + [Spec("css-conditional-3", "#use")] + public void NestedConditions_ApplyOnlyWhenAllAreTrue() + { + var x = Box( + "<style>p { color: green } @media screen { @media print { p { color: red } } }" + + " @media screen { @media (min-width: 100px) { p { font-style: italic } } }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + Assert.AreEqual("italic", x.FontStyle); + } + + [TestMethod] + [Spec("css-conditional-3", "#processing")] + public void ChangedCondition_IsReflected() + { + var html = LayoutHarness.Wrap("<style>@media (max-width: 600px) { p { color: green } }</style><p id=x>a</p>"); + var (_, container) = LayoutHarness.Layout(html, maxWidth: 1000); + Assert.AreNotEqual(Green, LayoutHarness.FindById(container.Root!, "x")!.ActualColor); + + container.MaxSize = new RSize(500, 4000); + using (var graphics = new RecordingGraphics()) + container.PerformLayout(graphics); + + Assert.AreEqual(Green, LayoutHarness.FindById(container.Root!, "x")!.ActualColor); + } + + // ── 3 Contents, 4 Placement ────────────────────────────────────────── + + [TestMethod] + [Spec("css-conditional-3", "#contents-of")] + public void InvalidRuleInside_DoesNotInvalidateTheGroupRule() + { + var x = Box("<style>@media screen { @foo bar; p { color: green } }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-conditional-3", "#use")] + public void SupportsRule_InsideAMediaRule_Applies() + { + var x = Box("<style>@media screen { @supports (color: red) { p { color: green } } }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + // ── 5 @media ───────────────────────────────────────────────────────── + + [Ignore("Gap, see Spec/css-conditional-3.yml")] + [TestMethod] + [Spec("css-conditional-3", "#at-media")] + public void MediaRule_WithAnEmptyQueryList_Applies() + { + var x = Box("<style>@media { p { color: green } }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-conditional-3", "#at-media")] + public void MediaRule_AppliesWhenAnyQueryOfTheListMatches() + { + var x = Box("<style>@media print and (min-width: 1px), screen and (min-width: 1px) { p { color: green } }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + // ── 6 @supports ────────────────────────────────────────────────────── + + [TestMethod] + [Spec("css-conditional-3", "#at-supports")] + public void SupportsRule_WithATrueCondition_Applies() + { + var x = Box("<style>@supports (display: block) { p { color: green } }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-conditional-3", "#at-supports")] + public void SupportsRule_WithAFalseCondition_DoesNotApply() + { + var x = Box("<style>p { color: green } @supports not (display: block) { p { color: red } }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-conditional-3", "#at-supports")] + [DataRow("(color: red) and (color: blue)", true)] + [DataRow("(color: red) and (foo: bar)", false)] + [DataRow("(color: red) or (foo: bar)", true)] + [DataRow("not (foo: bar)", true)] + [DataRow("not ((color: red) or (foo: bar))", false)] + public void SupportsCondition_CombinesItsTerms(string condition, bool expected) + { + Assert.AreEqual(expected, Supports(condition).Condition.Check()); + } + + [Ignore("Gap, see Spec/css-conditional-3.yml")] + [TestMethod] + [Spec("css-conditional-3", "#at-supports")] + [DataRow("(foo bar)")] + [DataRow("()")] + [DataRow("foo(bar)")] + public void GeneralEnclosed_IsFalse(string condition) + { + var rule = Supports(condition); + + Assert.IsNotNull(rule, "The rule is valid"); + Assert.IsFalse(rule.Condition.Check()); + } + + [TestMethod] + [Spec("css-conditional-3", "#at-supports")] + public void MixingAndWithOr_WithoutParentheses_IsInvalid() + { + var sheet = CssConstructionFunctions.ParseStyleSheet("@supports (color: red) or (color: blue) and (color: green) { }"); + + Assert.AreEqual(0, sheet.Rules.Length); + } + + private static SupportsRule Supports(string condition) + { + var sheet = CssConstructionFunctions.ParseStyleSheet("@supports " + condition + " { }"); + return sheet.Rules.Length == 1 ? (SupportsRule)sheet.Rules[0] : null!; + } + + private static CssBox Box(string body, string id = "x") + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(body)); + var box = LayoutHarness.FindById(root, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box; + } +} diff --git a/Spec/css-conditional-3.yml b/Spec/css-conditional-3.yml index 53747c2c2..6d6af4401 100644 --- a/Spec/css-conditional-3.yml +++ b/Spec/css-conditional-3.yml @@ -5,49 +5,34 @@ snapshot: https://www.w3.org/TR/2024/CRD-css-conditional-3-20240815/ sections: '#introduction': title: 1 Introduction - status: unknown - '#context': - title: 1.1 Background - status: unknown - '#placement': - title: 1.2 Module Interactions - status: unknown + status: n/a '#processing': title: 2 Processing of conditional group rules - status: unknown + status: supported '#contents-of': title: 3 Contents of conditional group rules - status: unknown + status: supported '#use': title: 4 Placement of conditional group rules - status: unknown + status: supported '#at-media': title: '5 Media-specific style sheets: the @media rule' - status: unknown + status: partial + gaps: + - An empty media query list doesn't match, it should be true '#at-supports': title: '6 Feature queries: the @supports rule' - status: unknown + status: partial + gaps: + - '<general-enclosed> makes the rule invalid, or for an empty () true, instead of evaluating to false' '#support-definition': title: 6.1 Definition of support - status: unknown + status: partial + gaps: + - 'Every value the parser accepts counts as supported, also where layout ignores it, e.g. animation-name, transform: perspective(), word-break: keep-all' '#apis': title: 7 APIs - status: unknown - '#extensions-to-cssrule-interface': - title: 7.1 Extensions to the CSSRule interface - status: unknown - '#the-cssconditionrule-interface': - title: 7.2 The CSSConditionRule interface - status: unknown - '#the-cssmediarule-interface': - title: 7.3 The CSSMediaRule interface - status: unknown - '#the-csssupportsrule-interface': - title: 7.4 The CSSSupportsRule interface - status: unknown - '#the-css-namespace': - title: 7.5 The CSS namespace, and the supports() function - status: unknown + status: n/a '#changes': title: 8 Changes - status: unknown + status: n/a From fa0782186fd29fc3cf8f05ba1379164cf0c144ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:11:49 +0100 Subject: [PATCH 058/150] Verify and document Media Queries Level 4 compliance --- Source/HtmlRenderer/Core/MediaQueryMatcher.cs | 46 +- .../PrintMediaTests.cs | 1 + .../Css/MediaFeaturesTests.cs | 9 +- .../HtmlRenderer.Test/Css/MediaListTests.cs | 7 + .../HtmlRenderer.Test/Css/MediaQueryTests.cs | 4 + .../HtmlRenderer.Test/Css/MediaTypeTests.cs | 6 + .../CssModules/CssConditionalTests.cs | 2 + .../CssModules/MediaQueriesTests.cs | 400 ++++++++++++++++++ Spec/mediaqueries-4.yml | 98 +++-- 9 files changed, 489 insertions(+), 84 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/MediaQueriesTests.cs diff --git a/Source/HtmlRenderer/Core/MediaQueryMatcher.cs b/Source/HtmlRenderer/Core/MediaQueryMatcher.cs index 9d51865b8..71e0a2e92 100644 --- a/Source/HtmlRenderer/Core/MediaQueryMatcher.cs +++ b/Source/HtmlRenderer/Core/MediaQueryMatcher.cs @@ -18,26 +18,18 @@ namespace TheArtOfDev.HtmlRenderer.Core { /// <summary> - /// Evaluates a rule's enclosing <c>@media</c> chain against a <see cref="MediaQueryContext"/>, so - /// feature conditions (<c>min-width</c>, range syntax like <c>width &gt;= 48rem</c>, - /// <c>orientation</c>, <c>resolution</c>, <c>prefers-color-scheme</c>, ...) actually gate which rules - /// apply. Media Queries 4. + /// Evaluates a rule's enclosing <c>@media</c> chain against a <see cref="MediaQueryContext"/>, Media + /// Queries 4 §3.1. /// </summary> /// <remarks> - /// Any feature this matcher cannot actually evaluate causes its <c>@media</c> block to be ignored: a - /// feature name the CSS engine does not register fails to parse and its query becomes <c>not all</c> - /// upstream, and a registered feature this matcher does not model hits the <c>default</c> arm and - /// returns false. Both are Media Queries 4's "unknown feature is false" - rather than apply rules - /// guarded by a condition that cannot be tested, the block is dropped. A feature that IS modelled but - /// whose runtime context is missing (a <c>width</c> query before the viewport is known) stays - /// permissive; that is an unknown *context*, not an unsupported feature. + /// A feature the parser doesn't know turns its query into <c>not all</c>, a known one that isn't + /// evaluated here is false. A viewport feature matches while the viewport size isn't known yet. /// </remarks> internal static class MediaQueryMatcher { /// <summary> - /// True if every level of the enclosing <c>@media</c> chain matches (nesting is conjunctive), or - /// the rule isn't nested in any <c>@media</c> at all. Within one level's comma-separated media - /// query list, at least one query must match. + /// True if every nested <c>@media</c> matches (CSS Conditional 3 §2), each when any query of its + /// list matches (Media Queries 4 §2.1). /// </summary> internal static bool Matches(MediaList[] enclosingMedia, MediaQueryContext context) { @@ -63,8 +55,7 @@ internal static bool Matches(MediaList[] enclosingMedia, MediaQueryContext conte private static bool MediumMatches(Medium medium, MediaQueryContext context) { - // CSS 2.1 §7.3. A query with only features, e.g. `(min-width: 768px)`, has no type and matches - // like `all`. + // Media Queries 4 §2, a query without a type matches any type var typeMatches = string.IsNullOrEmpty(medium.Type) || string.Equals(medium.Type, context.MediaType, StringComparison.OrdinalIgnoreCase) || string.Equals(medium.Type, "all", StringComparison.OrdinalIgnoreCase) @@ -72,7 +63,7 @@ private static bool MediumMatches(Medium medium, MediaQueryContext context) var result = typeMatches && medium.Features.All(feature => FeatureMatches(feature, context)); - // `not` negates the whole query (type + features), per Media Queries 4 §3.1. + // Media Queries 4 §2.2.1, not negates the type and the features return medium.IsInverse ? !result : result; } @@ -96,7 +87,7 @@ private static bool FeatureMatches(MediaFeature feature, MediaQueryContext conte case "orientation": { if (!context.ViewportWidth.HasValue || !context.ViewportHeight.HasValue) return true; - // MQ4 §4.1: portrait is height >= width, so a square viewport is portrait. + // Media Queries 4 §4.4, a square viewport is portrait var orientation = context.ViewportWidth.Value > context.ViewportHeight.Value ? "landscape" : "portrait"; return !feature.HasValue || string.Equals(feature.Value, orientation, StringComparison.OrdinalIgnoreCase); @@ -121,8 +112,7 @@ private static bool FeatureMatches(MediaFeature feature, MediaQueryContext conte case "device-pixel-ratio": { - // Non-standard feature; the value is a plain number of device pixels per CSS px, - // i.e. the same as `resolution` in dppx. + // Non-standard, the same as resolution in dppx var dpr = feature.AsRatio(); if (!dpr.HasValue) return true; return CompareNumeric(context.ResolutionDpi / 96d, dpr.Value, feature.Comparison); @@ -143,10 +133,7 @@ private static bool FeatureMatches(MediaFeature feature, MediaQueryContext conte || string.Equals(feature.Value, scheme, StringComparison.OrdinalIgnoreCase); } - // The rendered output is static: it does not animate, cannot be hovered or pointed at, - // and once painted does not update. The stance for reduced motion is `reduce`, which is - // NOT the `no-preference` resting value, so the boolean form `(prefers-reduced-motion)` - // is true (MQ5 §5.3). + // The output is static, it doesn't animate, update or take pointer input case "prefers-reduced-motion": return MatchesKeyword(feature, "reduce", true); case "hover": @@ -161,9 +148,7 @@ private static bool FeatureMatches(MediaFeature feature, MediaQueryContext conte return MatchesKeyword(feature, "no-preference", false); default: - // A registered feature this matcher does not evaluate (e.g. `scan`): treat as not - // matching so the whole @media block is ignored, rather than applying rules guarded - // by a condition that cannot be tested (see the class remarks). + // e.g. scan, which doesn't apply to the output return false; } } @@ -175,8 +160,7 @@ private static bool CompareLength(MediaFeature feature, double? actualPx) var length = feature.AsLength(); if (!length.HasValue) return true; - // In a media query, `em`/`rem` resolve against the initial font size (16px), NOT the - // document root's font size (Media Queries 4 §1.3 / §6). + // Media Queries 4 §1.3, em and rem are relative to the initial font size const double initialFontPx = 16d; var valuePx = length.Value.ToPixels(initialFontPx, initialFontPx, actualPx.Value); @@ -194,9 +178,7 @@ private static bool CompareCountOrBoolean(MediaFeature feature, int actual) private static bool MatchesKeyword(MediaFeature feature, string deviceValue, bool booleanContextResult) { - // The value form `(feature: value)` matches iff the queried value equals the device's value. - // The boolean form `(feature)` is true iff the device value is not the feature's "none"/ - // "no-preference" resting state. + // Media Queries 4 §2.4.2, the boolean form is true unless the device value is none if (!feature.HasValue) return booleanContextResult; return string.Equals(feature.Value, deviceValue, StringComparison.OrdinalIgnoreCase); } diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/PrintMediaTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/PrintMediaTests.cs index 7379686bc..87f9bab4a 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/PrintMediaTests.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/PrintMediaTests.cs @@ -10,6 +10,7 @@ public sealed class PrintMediaTests { [TestMethod] [Spec("html", "rendering.html#print-media")] + [Spec("mediaqueries-4", "#media-types")] public async Task Pdf_IsRenderedForPrintMedia() { const string html = "<html><head><style>div { height: 10pt } @media print { div { height: 1000pt } }</style></head>" diff --git a/Source/Test/HtmlRenderer.Test/Css/MediaFeaturesTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaFeaturesTests.cs index 0f45414ef..338489f25 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaFeaturesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaFeaturesTests.cs @@ -2,18 +2,11 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/MediaFeatures.cs (<c>CssMediaFeaturesTests</c>). Only -/// <c>CssMediaFeatureFactory</c> has real assertions in the PeachPDF source - the other three facts -/// (<c>CssMediaWidthValidation</c>, <c>CssMediaMaxHeightValidation</c>, <c>CssMediaMinDeviceWidthValidation</c>, -/// <c>CssMediaAspectRatio</c>) have fully commented-out bodies there and are not ported. -/// <c>MediaFeatureFactory.Instance.Create</c> plus <c>IsMaximum</c>/<c>IsMinimum</c> match HTML-Renderer's -/// <c>Factories/MediaFeatureFactory.cs</c>. -/// </summary> [TestClass] public sealed class MediaFeaturesTests { [TestMethod] + [Spec("mediaqueries-4", "#mq-features")] public void CssMediaFeatureFactory() { var aspectRatio = MediaFeatureFactory.Instance.Create(FeatureNames.AspectRatio); diff --git a/Source/Test/HtmlRenderer.Test/Css/MediaListTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaListTests.cs index 0297763c0..e5bc3b23f 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaListTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaListTests.cs @@ -25,6 +25,7 @@ public void SimpleScreenMediaList() } [TestMethod] + [Spec("mediaqueries-4", "#error-handling")] public void MediaListAtIllegal() { var source = @"@media @screen { @@ -194,6 +195,7 @@ public void NoMediaQueryGivenSkip() } [TestMethod] + [Spec("mediaqueries-4", "#error-handling")] public void NotNoMediaTypeOrExpressionSkip() { var source = @"@media not { @@ -222,6 +224,7 @@ public void OnlyNoMediaTypeOrExpressionSkip() } [TestMethod] + [Spec("mediaqueries-4", "#error-handling")] public void MediaFeatureMissingSkip() { var source = @"@media () { @@ -253,6 +256,7 @@ public void MediaFeatureMissingSkipReadNext() } [TestMethod] + [Spec("mediaqueries-4", "#error-handling")] public void FeatureMaxWidthMediaListMissingConnectedAnd() { var source = @"@media (max-width:30px) (min-width:10px) { @@ -267,6 +271,7 @@ public void FeatureMaxWidthMediaListMissingConnectedAnd() } [TestMethod] + [Spec("mediaqueries-4", "#error-handling")] public void TvScreenMediaListMissingComma() { var source = @"@media tv screen { @@ -281,6 +286,7 @@ public void TvScreenMediaListMissingComma() } [TestMethod] + [Spec("mediaqueries-4", "#mq-syntax")] public void AllFeatureMaxWidthMediaListWithAndKeyword() { var source = @"@media all and (max-width:30px) { @@ -397,6 +403,7 @@ public void CssMediaListApiWithAppendDeleteAndTextShouldWork() } [TestMethod] + [Spec("mediaqueries-4", "#mq-range-context")] public void CombinedConditionMediaQueriesLevel4() { const string source = @"/* Traditionelle Syntax */ diff --git a/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs index 4ef6ebead..7d96f841d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs @@ -51,6 +51,7 @@ public void AtMedia_Screen_NestedRuleAppliesUnderScreenMedia() [TestMethod] [Spec("css21", "media.html#at-media-rule")] + [Spec("mediaqueries-4", "#media-types")] public void AtMedia_All_NestedRuleAppliesUnderAllMedia() { var box = LayoutAndFindTag("@media all { p { color: red; } }", "<p>text</p>", "p", "screen"); @@ -75,6 +76,7 @@ public void AtMedia_Print_MultipleNestedRulesAreParsed() // ── "not" / "only" modifiers ───────────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("mediaqueries-4", "#mq-not")] public void AtMedia_NotPrint_ExcludesPrintButAppliesElsewhere() { const string css = "@media not print { p { color: red; } }"; @@ -87,6 +89,7 @@ public void AtMedia_NotPrint_ExcludesPrintButAppliesElsewhere() } [TestMethod] + [Spec("mediaqueries-4", "#mq-only")] public void AtMedia_OnlyPrint_AppliesOnlyUnderPrint() { const string css = "@media only print { p { color: red; } }"; @@ -102,6 +105,7 @@ public void AtMedia_OnlyPrint_AppliesOnlyUnderPrint() [TestMethod] [Spec("css21", "media.html#at-media-rule")] + [Spec("mediaqueries-4", "#mq-list")] public void AtMedia_PrintCommaScreen_AppliesUnderBoth() { const string css = "@media print, screen { p { color: red; } }"; diff --git a/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs index c456cf172..efdd8b782 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs @@ -112,6 +112,7 @@ public void RecognizedMediaType_MatchesItself(string mediaType) [Spec("css21", "media.html#media-types")] [DataRow("screen")] [DataRow("print")] + [Spec("mediaqueries-4", "#media-types")] public void All_MatchesEveryMediaType(string mediaType) { Assert.AreEqual(Green, ColorOf("<style>@media all { p { color: green } }</style>", mediaType)); @@ -121,6 +122,7 @@ public void All_MatchesEveryMediaType(string mediaType) [Spec("css21", "media.html#media-types")] [DataRow("SCREEN")] [DataRow("Screen")] + [Spec("mediaqueries-4", "#mq-syntax")] public void MediaTypeNames_AreCaseInsensitive(string name) { Assert.AreEqual(Green, ColorOf($"<style>@media {name} {{ p {{ color: green }} }}</style>")); @@ -137,6 +139,7 @@ public void MediaType_OtherThanTheRenderedOne_DoesNotMatch() [Spec("css21", "media.html#media-types")] [DataRow("holographic, screen")] [DataRow("screen, holographic")] + [Spec("mediaqueries-4", "#error-handling")] public void UnknownMediaType_IsTreatedAsNotPresent(string mediaList) { Assert.AreEqual(Green, ColorOf($"<style>@media {mediaList} {{ p {{ color: green }} }}</style>")); @@ -145,6 +148,7 @@ public void UnknownMediaType_IsTreatedAsNotPresent(string mediaList) [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "media.html#media-types")] + [Spec("mediaqueries-4", "#error-handling")] public void MalformedEntryInAMediaList_DoesNotDisableTheOthers() { Assert.AreEqual(Green, ColorOf("<style>@media screen, 3D { p { color: green } }</style>")); @@ -152,6 +156,7 @@ public void MalformedEntryInAMediaList_DoesNotDisableTheOthers() [TestMethod] [Spec("css21", "media.html#media-types")] + [Spec("mediaqueries-4", "#error-handling")] public void OnlyUnknownMediaTypes_DoNotMatch() { Assert.AreNotEqual(Green, ColorOf("<style>@media holographic { p { color: green } }</style>")); @@ -159,6 +164,7 @@ public void OnlyUnknownMediaTypes_DoNotMatch() [TestMethod] [Spec("css21", "media.html#media-types")] + [Spec("mediaqueries-4", "#error-handling")] public void MalformedMediaType_InvalidatesTheStatement() { Assert.AreNotEqual(Green, ColorOf("<style>@media @screen { p { color: green } }</style>")); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs index 1822e4954..d57c32abc 100644 --- a/Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs @@ -36,6 +36,7 @@ public void NestedConditions_ApplyOnlyWhenAllAreTrue() [TestMethod] [Spec("css-conditional-3", "#processing")] + [Spec("mediaqueries-4", "#media")] public void ChangedCondition_IsReflected() { var html = LayoutHarness.Wrap("<style>@media (max-width: 600px) { p { color: green } }</style><p id=x>a</p>"); @@ -74,6 +75,7 @@ public void SupportsRule_InsideAMediaRule_Applies() [Ignore("Gap, see Spec/css-conditional-3.yml")] [TestMethod] [Spec("css-conditional-3", "#at-media")] + [Spec("mediaqueries-4", "#mq-list")] public void MediaRule_WithAnEmptyQueryList_Applies() { var x = Box("<style>@media { p { color: green } }</style><p id=x>a</p>"); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/MediaQueriesTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/MediaQueriesTests.cs new file mode 100644 index 000000000..8fdd33f1b --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/MediaQueriesTests.cs @@ -0,0 +1,400 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.CssModules; + +/// <summary> +/// Evaluates media queries in the CSS engine against an 800x600 screen, a &lt;style&gt; element +/// can't hold the '&lt;' of the range syntax. +/// </summary> +[TestClass] +public sealed class MediaQueriesTests +{ + // ── 2 Media queries ────────────────────────────────────────────────── + + [TestMethod] + [Spec("mediaqueries-4", "#media")] + [DataRow("screen and (min-width: 100px)", true)] + [DataRow("print and (min-width: 100px)", false)] + [DataRow("screen and (min-width: 1000px)", false)] + [DataRow("(min-width: 100px) and (min-height: 100px)", true)] + public void MediaQuery_IsTrueWhenTheTypeAndTheConditionMatch(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#mq-list")] + [DataRow("print, screen", true)] + [DataRow("print, (max-width: 100px)", false)] + public void MediaQueryList_IsTrueWhenAnyQueryIs(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#mq-not")] + [DataRow("not print", true)] + [DataRow("not screen", false)] + [DataRow("not screen and (min-width: 100px)", false)] + [DataRow("not screen and (max-width: 100px)", true)] + public void Not_NegatesTheWholeQuery(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#mq-only")] + [DataRow("only screen", true)] + [DataRow("only screen and (min-width: 100px)", true)] + [DataRow("only print", false)] + public void Only_DoesNotChangeTheResult(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#media-types")] + [DataRow("all", "screen", true)] + [DataRow("all", "print", true)] + [DataRow("screen", "screen", true)] + [DataRow("print", "print", true)] + [DataRow("screen", "print", false)] + [DataRow("tv", "screen", false)] + [DataRow("not tv", "screen", true)] + [DataRow("speech", "print", false)] + public void MediaType_MatchesTheDevice(string query, string mediaType, bool expected) + { + Assert.AreEqual(expected, Matches(query, mediaType: mediaType)); + } + + // ── 2.4 Media features ─────────────────────────────────────────────── + + [TestMethod] + [Spec("mediaqueries-4", "#mq-features")] + [DataRow("(max-weight: 3kg)")] + [DataRow("(min-orientation: portrait)")] + [DataRow("(color: 20example)")] + public void UnknownFeatureOrValue_IsFalse(string query) + { + Assert.IsFalse(Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#mq-boolean-context")] + [DataRow("(color)", true)] + [DataRow("(monochrome)", false)] + [DataRow("(grid)", false)] + [DataRow("(update)", false)] + [DataRow("(pointer)", false)] + public void BooleanContext_IsTrueForAnythingButZeroOrNone(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [Ignore("Gap, see Spec/mediaqueries-4.yml")] + [TestMethod] + [Spec("mediaqueries-4", "#mq-boolean-context")] + [DataRow("(width)")] + [DataRow("(height)")] + [DataRow("(orientation)")] + [DataRow("(aspect-ratio)")] + [DataRow("(resolution)")] + public void BooleanContext_IsValidForEveryFeature(string query) + { + Assert.IsTrue(Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#mq-range-context")] + [DataRow("(width >= 800px)", true)] + [DataRow("(width > 800px)", false)] + [DataRow("(width <= 800px)", true)] + [DataRow("(width < 800px)", false)] + [DataRow("(height > 500px)", true)] + public void RangeContext_ComparesTheFeature(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [Ignore("Gap, see Spec/mediaqueries-4.yml")] + [TestMethod] + [Spec("mediaqueries-4", "#mq-range-context")] + [DataRow("(width = 800px)", true)] + [DataRow("(600px <= width)", true)] + [DataRow("(900px < width)", false)] + [DataRow("(400px < width < 1000px)", true)] + [DataRow("(1000px > width > 900px)", false)] + public void RangeContext_AcceptsEqualityValueFirstAndDoubleRanges(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#mq-range-context")] + [DataRow("not all and (width <= -100px)", true)] + [DataRow("(height > -100px)", true)] + [DataRow("(width: -100px)", false)] + public void RangeContext_NegativeValues_AreFalseInTheNegativeRange(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#mq-min-max")] + [DataRow("(min-width: 800px)", true)] + [DataRow("(min-width: 801px)", false)] + [DataRow("(max-width: 800px)", true)] + [DataRow("(max-width: 799px)", false)] + [DataRow("(min-width)", false)] + [DataRow("(min-grid: 0)", false)] + [Spec("mediaqueries-4", "#mq-ranges")] + public void MinMaxPrefixes_AreInclusiveRanges(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [Ignore("Gap, see Spec/mediaqueries-4.yml")] + [TestMethod] + [Spec("mediaqueries-4", "#media-conditions")] + [DataRow("(color) or (grid)", true)] + [DataRow("not (color)", false)] + [DataRow("not (grid)", true)] + [DataRow("(not (color)) or (min-width: 100px)", true)] + [DataRow("screen and ((grid) or (color))", true)] + public void MediaConditions_CombineWithAndOrNot(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#media-conditions")] + [DataRow("(color) and (pointer) or (hover)")] + public void MixingAndWithOr_WithoutParentheses_IsInvalid(string query) + { + Assert.IsFalse(Matches(query)); + } + + // ── 3 Syntax ───────────────────────────────────────────────────────── + + [TestMethod] + [Spec("mediaqueries-4", "#mq-syntax")] + [DataRow("SCREEN AND (WIDTH: 800px)", true)] + [DataRow("screen and(min-width: 100px)", false)] + [DataRow("or and (color)", false)] + public void Syntax_IsCaseInsensitiveAndNeedsWhitespaceBeforeParentheses(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#mq-syntax")] + public void MinMaxPrefixes_AreCaseInsensitive() + { + Assert.IsTrue(Matches("(MIN-WIDTH: 100px)")); + } + + [Ignore("Gap, see Spec/mediaqueries-4.yml")] + [TestMethod] + [Spec("mediaqueries-4", "#mq-syntax")] + public void Units_AreCaseInsensitive() + { + Assert.IsTrue(Matches("(width: 800PX)")); + } + + [TestMethod] + [Spec("mediaqueries-4", "#evaluating")] + [DataRow("not (max-weight: 3kg)")] + [DataRow("not all and (max-weight: 3kg)")] + public void Unknown_IsFalse_EvenNegated(string query) + { + Assert.IsFalse(Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#error-handling")] + [DataRow("unknown", false)] + [DataRow("not unknown", true)] + public void UnknownMediaType_DoesNotMatch(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [Ignore("Gap, see Spec/mediaqueries-4.yml")] + [TestMethod] + [Spec("mediaqueries-4", "#error-handling")] + [DataRow("screen and (max-weight: 3kg) and (color), (color)")] + [DataRow("&test, screen")] + [DataRow("(example, all,), screen")] + public void InvalidQuery_OnlyReplacesThatQueryWithNotAll(string query) + { + Assert.IsTrue(Matches(query)); + } + + // ── 4 Viewport dimensions ──────────────────────────────────────────── + + [TestMethod] + [Spec("mediaqueries-4", "#width")] + [Spec("mediaqueries-4", "#units")] + [DataRow("(min-width: 50em)", true)] + [DataRow("(min-width: 51em)", false)] + [DataRow("(max-width: 21.17cm)", true)] + [DataRow("(width: 800px)", true)] + public void Width_IsTheViewportWidth(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#height")] + [DataRow("(height: 600px)", true)] + [DataRow("(min-height: 601px)", false)] + public void Height_IsTheViewportHeight(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#aspect-ratio")] + [DataRow("(aspect-ratio: 4/3)", true)] + [DataRow("(aspect-ratio: 8/6)", true)] + [DataRow("(min-aspect-ratio: 1/1)", true)] + [DataRow("(max-aspect-ratio: 1/1)", false)] + [DataRow("(aspect-ratio > 2/1)", false)] + public void AspectRatio_IsWidthOverHeight(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#orientation")] + [DataRow(800d, 600d, "landscape")] + [DataRow(600d, 800d, "portrait")] + [DataRow(600d, 600d, "portrait")] + public void Orientation_IsPortraitWhenHeightIsAtLeastWidth(double width, double height, string orientation) + { + Assert.IsTrue(Matches($"(orientation: {orientation})", width, height)); + } + + // ── 5 Display quality ──────────────────────────────────────────────── + + [TestMethod] + [Spec("mediaqueries-4", "#resolution")] + [DataRow("(resolution: 96dpi)", true)] + [DataRow("(resolution: 1dppx)", true)] + [DataRow("(min-resolution: 2dppx)", false)] + [DataRow("(max-resolution: 1dppx)", true)] + public void Resolution_IsOneDevicePixelPerCssPixel(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [Ignore("Gap, see Spec/mediaqueries-4.yml")] + [TestMethod] + [Spec("mediaqueries-4", "#resolution")] + public void Resolution_AcceptsInfinite() + { + Assert.IsTrue(Matches("(resolution < infinite)")); + } + + [TestMethod] + [Spec("mediaqueries-4", "#scan")] + [DataRow("(scan)")] + [DataRow("(scan: interlace)")] + public void Scan_DoesNotApply(string query) + { + Assert.IsFalse(Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#grid")] + [DataRow("(grid: 0)", true)] + [DataRow("(grid: 1)", false)] + public void Grid_IsZero(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#update")] + [DataRow("(update: none)", true)] + [DataRow("(update: fast)", false)] + public void Update_IsNone(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + // ── 6 Color ────────────────────────────────────────────────────────── + + [TestMethod] + [Spec("mediaqueries-4", "#color")] + [DataRow("(min-color: 1)", true)] + [DataRow("(color: 8)", true)] + [DataRow("(color > 8)", false)] + public void Color_IsEightBitsPerComponent(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#color-index")] + [DataRow("(color-index)", false)] + [DataRow("(color-index: 0)", true)] + [DataRow("(min-color-index: 1)", false)] + public void ColorIndex_IsZero(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#monochrome")] + [DataRow("(monochrome: 0)", true)] + [DataRow("(min-monochrome: 1)", false)] + public void Monochrome_IsZero(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + // ── 7 Interaction ──────────────────────────────────────────────────── + + [TestMethod] + [Spec("mediaqueries-4", "#pointer")] + [DataRow("(pointer: none)", true)] + [DataRow("(pointer: fine)", false)] + public void Pointer_IsNone(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#hover")] + [DataRow("(hover: none)", true)] + [DataRow("(hover)", false)] + public void Hover_IsNone(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#@media/any-input")] + [DataRow("(any-pointer: none)", true)] + [DataRow("(any-pointer)", false)] + [DataRow("(any-hover: none)", true)] + [DataRow("(any-hover: hover)", false)] + public void AnyPointerAndAnyHover_AreNone(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + private static bool Matches(string query, double width = 800, double height = 600, string mediaType = "screen") + { + var sheet = CssConstructionFunctions.ParseStyleSheet("@media " + query + " { }"); + if (sheet.Rules.Length != 1 || sheet.Rules[0] is not MediaRule rule) + return false; + + var context = MediaQueryContext.FromAdapter(new MockAdapter { MediaType = mediaType }, width, height); + return MediaQueryMatcher.Matches(new[] { rule.Media }, context); + } +} diff --git a/Spec/mediaqueries-4.yml b/Spec/mediaqueries-4.yml index 403744939..fa4935476 100644 --- a/Spec/mediaqueries-4.yml +++ b/Spec/mediaqueries-4.yml @@ -5,121 +5,131 @@ snapshot: https://www.w3.org/TR/2026/CRD-mediaqueries-4-20260219/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module interactions - status: unknown - '#values': - title: 1.2 Values - status: unknown + status: n/a '#units': title: 1.3 Units - status: unknown + status: supported '#media': title: 2 Media Queries - status: unknown + status: partial '#mq-list': title: 2.1 Combining Media Queries - status: unknown + status: partial + gaps: + - An empty media query list is false instead of true '#mq-prefix': title: 2.2 Media Query Modifiers - status: unknown + status: supported '#mq-not': title: '2.2.1 Negating a Media Query: the not keyword' - status: unknown + status: supported '#mq-only': title: '2.2.2 Hiding a Media Query From Legacy user agents: the only keyword' - status: unknown + status: supported '#media-types': title: 2.3 Media Types - status: unknown + status: supported '#mq-features': title: 2.4 Media Features - status: unknown + status: partial '#mq-ranges': title: '2.4.1 Media Feature Types: “range” and “discrete”' - status: unknown + status: supported '#mq-boolean-context': title: 2.4.2 Evaluating Media Features in a Boolean Context - status: unknown + status: partial + gaps: + - Only features with a zero or none value take the boolean form, e.g. (width) and (orientation) are invalid '#mq-range-context': title: 2.4.3 Evaluating Media Features in a Range Context - status: unknown + status: partial + gaps: + - The = operator, value-first ranges like (600px <= width) and double ranges like (400px < width < 1000px) are invalid '#mq-min-max': title: 2.4.4 Using “min-” and “max-” Prefixes On Range Features - status: unknown + status: supported '#media-conditions': title: 2.5 Combining Media Features - status: unknown + status: partial + gaps: + - or, not in front of a feature and nested parentheses are invalid, only and is supported '#mq-syntax': title: 3 Syntax - status: unknown + status: partial + gaps: + - Uppercase units, e.g. 800PX, aren't recognized '#evaluating': title: 3.1 Evaluating Media Queries - status: unknown + status: supported '#error-handling': title: 3.2 Error Handling - status: unknown + status: partial + gaps: + - An invalid query turns the whole media query list into not all instead of only itself, see CSS 2.1 7.3 '#mf-dimensions': title: 4 Viewport/Page Dimensions Media Features - status: unknown + status: supported '#width': title: '4.1 Width: the width feature' - status: unknown + status: supported '#height': title: '4.2 Height: the height feature' - status: unknown + status: supported '#aspect-ratio': title: '4.3 Aspect-Ratio: the aspect-ratio feature' - status: unknown + status: supported '#orientation': title: '4.4 Orientation: the orientation feature' - status: unknown + status: supported '#mf-display-quality': title: 5 Display Quality Media Features - status: unknown + status: partial '#resolution': title: '5.1 Display Resolution: the resolution feature' - status: unknown + status: partial + gaps: + - infinite isn't accepted, and vector output like PDF reports 96dpi instead of infinite '#scan': title: '5.2 Display Type: the scan feature' - status: unknown + status: supported '#grid': title: '5.3 Detecting Console Displays: the grid feature' - status: unknown + status: supported '#update': title: '5.4 Display Update Frequency: the update feature' - status: unknown + status: supported '#mf-overflow-block': title: '5.5 Block-Axis Overflow: the overflow-block feature' - status: unknown + status: unsupported '#mf-overflow-inline': title: '5.6 Inline-Axis Overflow: the overflow-inline feature' - status: unknown + status: unsupported '#mf-colors': title: 6 Color Media Features - status: unknown + status: partial + gaps: + - color-gamut is an unknown feature, see 6.4 '#color': title: '6.1 Color Depth: the color feature' - status: unknown + status: supported '#color-index': title: '6.2 Paletted Color Screens: the color-index feature' - status: unknown + status: supported '#monochrome': title: '6.3 Monochrome Screens: the monochrome feature' - status: unknown + status: supported '#color-gamut': title: '6.4 Color Display Quality: the color-gamut feature' - status: unknown + status: unsupported '#mf-interaction': title: 7 Interaction Media Features - status: unknown + status: supported '#pointer': title: '7.1 Pointing Device Quality: the pointer feature' - status: unknown + status: supported '#hover': title: '7.2 Hover Capability: the hover feature' - status: unknown + status: supported '#@media/any-input': title: '7.3 All Available Interaction Capabilities: the any-pointer and any-hover features' - status: unknown + status: supported From 71db5877708b92c8d72173d91cd4453a36eb368c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:05:35 +0100 Subject: [PATCH 059/150] Verify and document Selectors Level 4 compliance --- Source/HtmlRenderer/Core/CssData.cs | 29 +- .../CssEngine/Parser/SelectorConstructor.cs | 23 +- .../Core/CssEngine/Selectors/HasSelector.cs | 9 +- .../Core/CssEngine/Selectors/ListSelector.cs | 11 +- .../CssEngine/Selectors/MatchesSelector.cs | 10 +- .../Core/CssEngine/Selectors/NotSelector.cs | 5 +- Source/HtmlRenderer/Core/Parse/DomParser.cs | 6 +- .../HtmlRenderer.Test/Css/CascadeTests.cs | 1 + .../Css/CssSpecificityOrderingTests.cs | 3 +- .../Css/DocumentTreePseudoClassTests.cs | 94 ++-- .../Css/Selectors/AttrSelectorTests.cs | 4 - .../Css/Selectors/ClassSelectorTests.cs | 1 - .../Css/Selectors/Css21SelectorTests.cs | 52 +- .../RelationalPseudoClassSelectorTests.cs | 100 ++-- .../Css/Selectors/SelectorMatchingTests.cs | 14 + .../StructuralPseudoClassSelectorTests.cs | 49 +- .../CssModules/Selectors4Tests.cs | 461 ++++++++++++++++++ .../Html/SelectorMatchingTests.cs | 2 + Spec/selectors-4.yml | 372 ++++++++------ 19 files changed, 897 insertions(+), 349 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/Selectors4Tests.cs diff --git a/Source/HtmlRenderer/Core/CssData.cs b/Source/HtmlRenderer/Core/CssData.cs index 59cc85d49..c67eca76d 100644 --- a/Source/HtmlRenderer/Core/CssData.cs +++ b/Source/HtmlRenderer/Core/CssData.cs @@ -479,8 +479,8 @@ private IEnumerable<IStyleRule> GetStyleRulesByOrigin(MediaQueryContext media, C } /// <summary> - /// The specificity of a matched rule for <paramref name="box"/>, CSS 2.1 §6.4.3. A rule's - /// selector list counts with the highest matching selector, unlike + /// The specificity of a matched rule for <paramref name="box"/>, CSS 2.1 §6.4.3 and Selectors 4 + /// §15. A rule's selector list counts with the highest matching selector, unlike /// <see cref="ListSelector.Specificity"/>, which is the static maximum used inside :is() and :not(). /// </summary> private static Priority GetMatchedSpecificity(ISelector selector, CssBox box) @@ -557,7 +557,9 @@ private static bool DoesSelectorMatch(CompoundSelector compoundSelector, CssBox } // Matching the element creates the ::before/::after box, which then matches on its own - // cascade pass. Other pseudo-elements get no box here. + // cascade pass. Other pseudo-elements get no box here. This runs before a ComplexSelector + // checks the rest of the chain, so the box can be created on an element the rule doesn't + // match, and stays empty. if (pseudoElementSelector.Name == CssConstants.Before && !box.Boxes.Any(b => b.IsBeforePseudoElement)) { var beforePseudoBox = new CssBox(box, null); @@ -738,8 +740,8 @@ private static bool DoesSelectorMatch(PseudoElementSelector pseudoElementSelecto } /// <summary> - /// The An+B pseudo-classes, including the bare forms like :first-child (CSS 2.1 §5.11.1), - /// which PseudoClassSelectorFactory creates with Step 0 and Offset 1. + /// The child-indexed pseudo-classes, Selectors 4 §13.3 and §13.4, including the bare forms like + /// :first-child, which PseudoClassSelectorFactory creates with Step 0 and Offset 1. /// </summary> private static bool DoesSelectorMatch(ChildSelector childSelector, CssBox box) { @@ -785,10 +787,9 @@ private static bool DoesSelectorMatch(ChildSelector childSelector, CssBox box) } /// <summary> - /// Matches :nth-column()/:nth-last-column() against the cell's occupied column position(s) - /// within its own row. Only accounts for colspan of preceding cells in the SAME row. - /// A colspan-N cell occupies N columns; per spec this matches if ANY occupied column position - /// satisfies the An+B formula, not just the cell's starting column. + /// :nth-column()/:nth-last-column() (Selectors 5 :nth-col()). A cell matches if any column it + /// spans does. Only the colspans of the cells in the same row count, rowspans from rows above + /// are ignored. /// </summary> private static bool DoesColumnSelectorMatch(ChildSelector childSelector, CssBox box, bool fromEnd) { @@ -843,9 +844,8 @@ private static int GetColSpan(CssBox box) } /// <summary> - /// The standard CSS "An+B" existence test: does there exist a non-negative integer n such that - /// position == step*n + offset? position is always the 1-based index within whatever sibling - /// scope the caller has already resolved. + /// Whether the 1-based <paramref name="position"/> is step*n + offset for some n &gt;= 0, + /// Selectors 4 §13.3.1. /// </summary> private static bool MatchesAnPlusB(int position, int step, int offset) { @@ -903,9 +903,8 @@ private static bool DoesSelectorMatch(HasSelector hasSelector, CssBox box) } /// <summary> - /// Recursively searches box's descendants (at any depth) for one matching inner, short- - /// circuiting on the first hit. Backs :has()'s default (descendant) relative-selector form; - /// leading-combinator forms (":has(&gt; x)"/"+"/"~") aren't supported. + /// The descendant form of :has(), Selectors 4 §4.5. Walks all boxes, including anonymous and + /// text boxes, which "*" matches. /// </summary> private static bool HasDescendantMatch(CssBox box, ISelector inner) { diff --git a/Source/HtmlRenderer/Core/CssEngine/Parser/SelectorConstructor.cs b/Source/HtmlRenderer/Core/CssEngine/Parser/SelectorConstructor.cs index 9da947e1f..75bc1959b 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Parser/SelectorConstructor.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Parser/SelectorConstructor.cs @@ -386,24 +386,9 @@ private void Insert(ISelector selector) } } - // CssData.DoesSelectorMatch(CompoundSelector, box) is the only place that synthesizes - // ::before/::after (and ::marker) child boxes as a match side effect, and it only runs for a - // pseudo-element that's the last member of a CompoundSelector. A pseudo-element preceded by a - // combinator (e.g. "E :after", with whitespace) would otherwise become a bare, unwrapped _temp - // here - which CssData's ComplexSelector subject-match dispatches straight to the plain - // DoesSelectorMatch(PseudoElementSelector, box) overload (which only checks whether the flag is - // already set, never sets it), so the pseudo-element box would never actually get created. - // Wrapping it in a single-member CompoundSelector as soon as it becomes a standalone selector - // routes it through the one, already-correct synthesis path instead of needing a second - // implementation of the same logic in CssData. - // - // Deliberately NOT applied in the other "_temp == null" branch above (a pseudo-element with no - // combinator at all, i.e. it's the entire selector - "*:before" written as bare ":before"): that - // shape has no ancestor restriction, so synthesizing on every match would mean synthesizing on - // literally every element in the document. The UA stylesheet's own - // ":before, :after { white-space: pre-line }" rule (CssDefaults.cs) is exactly this shape and - // relies on the existing non-synthesizing behavior - it exists to style already-synthesized - // pseudo-element boxes, not to create one on every element. + // CssData creates the ::before/::after box only when matching a CompoundSelector, so a + // pseudo-element after a combinator ("div ::before") is wrapped in one. A whole selector of + // just "::before" isn't, it creates no box, and the UA rule ":before, :after" relies on that. private static ISelector WrapPseudoElement(ISelector selector) => selector is PseudoElementSelector ? new CompoundSelector { selector } : selector; @@ -956,4 +941,4 @@ private enum ParseState : byte } } } -} \ No newline at end of file +} diff --git a/Source/HtmlRenderer/Core/CssEngine/Selectors/HasSelector.cs b/Source/HtmlRenderer/Core/CssEngine/Selectors/HasSelector.cs index 19be53893..43cf86b82 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Selectors/HasSelector.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Selectors/HasSelector.cs @@ -3,11 +3,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The relational pseudo-class ":has(S)" - matches an element that has a descendant matching S. - /// v1 only supports the default (descendant) relative-selector form; leading-combinator forms - /// like ":has(&gt; S)"/":has(+ S)"/":has(~ S)" are not supported (the parser silently discards a - /// leading combinator today - see SelectorConstructor.Insert(ISelector) - and fixing that touches - /// shared parsing code well beyond :has() itself, so it's an explicit, documented follow-up). + /// :has(), Selectors 4 §4.5. Only the descendant form, SelectorConstructor drops a leading + /// combinator. /// </summary> internal sealed class HasSelector : StylesheetNode, ISelector { @@ -18,7 +15,7 @@ public HasSelector(ISelector inner) public ISelector Inner { get; } - // Per spec, :has() takes the specificity of its most specific argument, same as :is()/:not(). + // Selectors 4 §15 public Priority Specificity => Inner.Specificity; public string Text => this.ToCss(); diff --git a/Source/HtmlRenderer/Core/CssEngine/Selectors/ListSelector.cs b/Source/HtmlRenderer/Core/CssEngine/Selectors/ListSelector.cs index d93549ac9..a0c4581b7 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Selectors/ListSelector.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Selectors/ListSelector.cs @@ -7,15 +7,8 @@ internal sealed class ListSelector : Selectors, ISelector { public bool IsInvalid { get; internal set; } - // A comma-separated selector list's specificity is NOT the sum of its alternatives (that's - // only correct for a CompoundSelector, whose members all apply to the SAME element at once). - // Per spec, when used as the "forgiving selector list" argument to :is()/:not()/:has(), it's - // the static max across all alternatives - regardless of which one (if any) actually matches - // a given element. When a ListSelector is a style rule's own top-level selector instead - // (".a, .b { }"), the spec treats it as shorthand for separate rules, so the specificity that - // matters there is whichever ONE alternative matched a given box - that per-match resolution - // is handled separately by CssData.GetMatchedSpecificity, which deliberately does not use this - // property for that case. + // Selectors 4 §15, the most specific selector of an :is()/:not()/:has() argument. A rule's + // own selector list counts with the selector that matched, see CssData.GetMatchedSpecificity. public override Priority Specificity => _selectors.Count == 0 ? Priority.Zero : _selectors.Max(s => s.Specificity); diff --git a/Source/HtmlRenderer/Core/CssEngine/Selectors/MatchesSelector.cs b/Source/HtmlRenderer/Core/CssEngine/Selectors/MatchesSelector.cs index fe5403c58..f3edb4a17 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Selectors/MatchesSelector.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Selectors/MatchesSelector.cs @@ -3,10 +3,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The ":matches(S)"/":is(S)"/":where(S)" pseudo-class - matches an element that matches ANY - /// selector in S. All three keywords share this same class (":is()" is the modern name, - /// ":matches()" the legacy alias, ":where()" the zero-specificity variant); <see cref="Keyword"/> - /// controls how it round-trips back to CSS text and, for ":where()", drops its specificity to zero. + /// :is(), its legacy alias :matches() and :where(), Selectors 4 §4.2 and §4.4. /// </summary> internal sealed class MatchesSelector : StylesheetNode, ISelector { @@ -20,10 +17,7 @@ public MatchesSelector(ISelector inner, string keyword) public string Keyword { get; } - // Per CSS Selectors 4 §16: :is()/:matches() take the specificity of their most specific - // argument (the static max, via ListSelector's own Specificity, when Inner is a - // comma-separated list), while :where() always contributes zero specificity - this is what - // lets a utility-framework reset authored in :where(...) be overridden by any real selector. + // Selectors 4 §15 public Priority Specificity => string.Equals(Keyword, PseudoClassNames.Where, System.StringComparison.OrdinalIgnoreCase) ? Priority.Zero diff --git a/Source/HtmlRenderer/Core/CssEngine/Selectors/NotSelector.cs b/Source/HtmlRenderer/Core/CssEngine/Selectors/NotSelector.cs index 551558773..48986d77f 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Selectors/NotSelector.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Selectors/NotSelector.cs @@ -3,7 +3,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The negation pseudo-class ":not(S)" - matches an element that does NOT match S. + /// :not(), Selectors 4 §4.3. /// </summary> internal sealed class NotSelector : StylesheetNode, ISelector { @@ -14,8 +14,7 @@ public NotSelector(ISelector inner) public ISelector Inner { get; } - // Per spec, :not() takes the specificity of its argument (the most specific selector in a - // comma-separated argument, via ListSelector's own static-max Specificity). + // Selectors 4 §15 public Priority Specificity => Inner.Specificity; public string Text => this.ToCss(); diff --git a/Source/HtmlRenderer/Core/Parse/DomParser.cs b/Source/HtmlRenderer/Core/Parse/DomParser.cs index 42609f79d..c793ef2eb 100644 --- a/Source/HtmlRenderer/Core/Parse/DomParser.cs +++ b/Source/HtmlRenderer/Core/Parse/DomParser.cs @@ -334,6 +334,7 @@ private static void AssignCssBlocks( /// <summary> /// True if the subject of the rule's selector, the rightmost compound of a chain, has :hover. + /// For a selector list, one alternative with :hover is enough. /// </summary> private static bool IsHoverRule(IStyleRule rule) { @@ -799,9 +800,8 @@ private static string GetGuaranteedInvalidFallback(CssBox box, string propName) } /// <summary> - /// Set the selected text style (selection text color and background color) from any "::selection" - /// rule found in the parsed stylesheets. This is a document-level lookup, not a per-box cascade - /// one - there is no box for a "::selection" pseudo-element to match against. + /// Sets the document-wide selection colors from the color and background-color of every + /// ::selection rule, css-pseudo-4 §3. The part of the selector before ::selection is ignored. /// </summary> /// <param name="htmlContainer"> </param> /// <param name="cssData">the style data</param> diff --git a/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs b/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs index 617047daf..b19799407 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CascadeTests.cs @@ -306,6 +306,7 @@ public void ImportantDeclaration_WinsWithinTheAuthorStyleSheet() [DataRow("#x34y", 1, 0, 0)] [DataRow("[id=p33]", 0, 1, 0)] [DataRow("a:link", 0, 1, 1)] + [Spec("selectors-4", "#specificity-rules")] public void Specificity_MatchesTheSpecExamples(string selector, int ids, int classes, int tags) { var rule = CssParser.ParseStyleSheetText(selector + " {}").StyleRules.Single(); diff --git a/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs index 8cd93c22a..5b920e739 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs @@ -33,7 +33,7 @@ public void EqualSpecificity_SameOrigin_StillResolvesByLastDeclared() public void MediaRuleDeclaredEarlier_LosesToEqualSpecificityPlainRuleDeclaredLater() { var html = Html( - "@media print { div { color: #0000ff; } } div { color: #ff0000; }", + "@media screen { div { color: #0000ff; } } div { color: #ff0000; }", "<div>text</div>"); var box = FindBoxByTag(html, "div"); Assert.AreEqual(RColor.FromArgb(255, 0, 0), box.ActualColor); @@ -41,6 +41,7 @@ public void MediaRuleDeclaredEarlier_LosesToEqualSpecificityPlainRuleDeclaredLat [TestMethod] [Spec("css21", "cascade.html#specificity")] + [Spec("selectors-4", "#specificity-rules")] public void CommaListRule_UsesOnlyTheMatchedBranchsSpecificity_NotASum() { // A selector list is equivalent to one rule per selector, so only ".a" counts here. diff --git a/Source/Test/HtmlRenderer.Test/Css/DocumentTreePseudoClassTests.cs b/Source/Test/HtmlRenderer.Test/Css/DocumentTreePseudoClassTests.cs index 4f8616aec..f0ce94e15 100644 --- a/Source/Test/HtmlRenderer.Test/Css/DocumentTreePseudoClassTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/DocumentTreePseudoClassTests.cs @@ -6,67 +6,55 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/DocumentTreePseudoClassTests.cs. -/// The three pseudo-classes that depend only on the document tree - <c>:empty</c> (Selectors 4 §9.5), -/// <c>:any-link</c> (§7.1) and <c>:scope</c> (§6.6) - are genuinely implemented in PeachPDF, but -/// HTML-Renderer's <c>CssData.DoesSelectorMatch(PseudoClassSelector, CssBox)</c> (Core/CssData.cs:739-755) -/// only recognizes "hover", "root" and "link" - every other pseudo-class name (including these three) -/// falls through to "never matches". Every test below is therefore ignored: the assertions encode the -/// PeachPDF-correct behavior this engine doesn't have yet, not today's actual (structurally-registered-but- -/// unmatchable) behavior. The two SVG-specific PeachPDF tests are omitted entirely - there is no SVG -/// pipeline (no <c>ICssDomNode</c>/<c>SvgCssBoxDomNode</c> duality) in this engine to exercise. The two -/// tests that inspected PeachPDF's <c>ICssDomNode</c> facade directly (no HTML-Renderer counterpart) are -/// adapted to check the equivalent <see cref="CssBox"/> state directly instead. -/// </summary> [TestClass] public sealed class DocumentTreePseudoClassTests { private const string Blue = "rgb(0, 0, 255)"; - private const string IgnoreReason = - "HTML-Renderer's CssData.DoesSelectorMatch(PseudoClassSelector, CssBox) only recognizes " + - "\"hover\"/\"root\"/\"link\" - :empty/:any-link/:scope parse but never match. See " + - "Source/HtmlRenderer/Core/CssData.cs:739-755."; + private const string IgnoreReason = "Gap, see Spec/selectors-4.yml"; // ── :empty ───────────────────────────────────────────────────────────────────────────────── [TestMethod] [Ignore(IgnoreReason)] - // "no children at all, other than white space" — including a run of newlines and tabs, which the - // parser does hand to the box tree as a text box. - [DataRow("<p id='p'></p>", true)] - [DataRow("<p id='p'> </p>", true)] - [DataRow("<p id='p'>\n\t\r\n </p>", true)] - // A comment is not a child for :empty's purposes (and never reaches the box tree at all). - [DataRow("<p id='p'><!-- a comment --></p>", true)] - [DataRow("<p id='p'>\n <!-- a comment -->\n</p>", true)] - // Any text that is not white space. - [DataRow("<p id='p'>x</p>", false)] - [DataRow("<p id='p'> x </p>", false)] - // U+00A0 is significant content, not collapsible white space. - [DataRow("<p id='p'>&nbsp;</p>", false)] - // Any element child, even an empty or void one. - [DataRow("<p id='p'><span></span></p>", false)] - [DataRow("<p id='p'><br></p>", false)] - public void Empty_MatchesOnlyAnElementWithNoNonWhitespaceChildren(string body, bool shouldMatch) + // A run of newlines and tabs does reach the box tree as a text box. + [DataRow("<p id='p'></p>")] + [DataRow("<p id='p'> </p>")] + [DataRow("<p id='p'>\n\t\r\n </p>")] + [DataRow("<p id='p'><!-- a comment --></p>")] + [DataRow("<p id='p'>\n <!-- a comment -->\n</p>")] + [Spec("selectors-4", "#the-empty-pseudo")] + public void Empty_MatchesAnElementWithOnlyWhiteSpaceAndComments(string body) { var box = Box(Html("p:empty { color: #0000ff; }", body), "p"); - Assert.AreEqual(shouldMatch, box.Color == Blue); + Assert.AreEqual(Blue, box.Color); + } + + [TestMethod] + [DataRow("<p id='p'>x</p>")] + [DataRow("<p id='p'> x </p>")] + [DataRow("<p id='p'>&nbsp;</p>")] + [DataRow("<p id='p'><span></span></p>")] + [DataRow("<p id='p'><br></p>")] + [Spec("selectors-4", "#the-empty-pseudo")] + public void Empty_DoesNotMatchAnElementWithTextOrElementChildren(string body) + { + var box = Box(Html("p:empty { color: #0000ff; }", body), "p"); + Assert.AreNotEqual(Blue, box.Color); } [TestMethod] [Ignore(IgnoreReason)] + [Spec("selectors-4", "#the-empty-pseudo")] public void Empty_IsUnaffectedByGeneratedContent() { - // ::before/::after synthesize a real child box as a side effect of their own rule matching, in the - // same cascade pass :empty is evaluated in. :empty is defined over the source tree, so none of - // that may count as a child. + // The ::before box is a child in the box tree, not in the document tree. var box = Box(Html("p::before { content: 'x'; } p:empty { color: #0000ff; }", "<p id='p'></p>"), "p"); Assert.AreEqual(Blue, box.Color); } [TestMethod] [Ignore(IgnoreReason)] + [Spec("selectors-4", "#the-empty-pseudo")] public void Empty_IsUnaffectedByASynthesizedMarker() { // An <li>'s ::marker box is generated too, so an empty list item is still :empty. @@ -76,6 +64,7 @@ public void Empty_IsUnaffectedByASynthesizedMarker() [TestMethod] [Ignore(IgnoreReason)] + [Spec("selectors-4", "#the-empty-pseudo")] public void Empty_ComposesWithCombinatorsAndNegation() { var html = Html( @@ -88,10 +77,9 @@ public void Empty_ComposesWithCombinatorsAndNegation() [TestMethod] [Ignore(IgnoreReason)] + [Spec("selectors-4", "#the-empty-pseudo")] public void Empty_SharingAListWithAMatchableSelector_StillApplies() { - // :empty was previously registered-but-unmatchable, so the list stayed valid. Making it match - // must not change that half of the contract. var html = Html("h1, p:empty { color: #0000ff; }", "<h1 id='h'>Title</h1><p id='p'></p>"); Assert.AreEqual(Blue, Box(html, "h").Color); Assert.AreEqual(Blue, Box(html, "p").Color); @@ -99,12 +87,10 @@ public void Empty_SharingAListWithAMatchableSelector_StillApplies() [TestMethod] [Ignore(IgnoreReason)] + [Spec("selectors-4", "#the-empty-pseudo")] public void Empty_SeesThroughAnAnonymousBox() { - // The restructuring passes that run after the cascade wrap element/text children in anonymous - // boxes, which are not source children — the test descends into them rather than stopping there. - // (PeachPDF's ICssDomNode.IsEmpty/TagName facade has no HTML-Renderer counterpart; checked here - // directly against CssBox state instead.) + // After the cascade, the mixed content is wrapped in anonymous boxes. var root = BuildRoot(Html( "p { display: block; }", "<div id='mixed'>loose text<p>block</p></div><div id='blank'></div>")); @@ -118,10 +104,8 @@ public void Empty_SeesThroughAnAnonymousBox() } [TestMethod] - [Ignore(IgnoreReason)] public void Empty_NeverMatchesANonElementBox() { - // Anonymous/text boxes have no tag, so they are not elements and can never be :empty. var root = BuildRoot(Html("p { display: block; }", "<div id='mixed'>loose text<p>block</p></div>")); var anonymous = LayoutHarness.FindById(root, "mixed")!.Boxes.First(b => b.HtmlTag is null); @@ -133,6 +117,7 @@ public void Empty_NeverMatchesANonElementBox() [TestMethod] [Ignore(IgnoreReason)] + [Spec("selectors-4", "#the-any-link-pseudo")] public void AnyLink_MatchesAnAnchorWithAnHref_AndNothingElse() { var html = Html( @@ -146,10 +131,10 @@ public void AnyLink_MatchesAnAnchorWithAnHref_AndNothingElse() [TestMethod] [Ignore(IgnoreReason)] + [Spec("selectors-4", "#the-any-link-pseudo")] public void AnyLink_AndLink_SelectTheSameElements() { - // :any-link is the union of :link and :visited; :visited never matches (no browsing history in a - // static render), so the two must agree everywhere. + // :visited never matches, so :any-link and :link select the same elements. var body = "<a id='link' href='#x'>link</a><a id='anchor'>anchor</a>"; var withAnyLink = Html("a:any-link { color: #0000ff; }", body); var withLink = Html("a:link { color: #0000ff; }", body); @@ -164,6 +149,7 @@ public void AnyLink_AndLink_SelectTheSameElements() [TestMethod] [Ignore(IgnoreReason)] + [Spec("selectors-4", "#the-any-link-pseudo")] public void AnyLink_ComposesWithACompoundSelector() { var html = Html( @@ -178,10 +164,9 @@ public void AnyLink_ComposesWithACompoundSelector() [TestMethod] [Ignore(IgnoreReason)] + [Spec("selectors-4", "#the-scope-pseudo")] public void Scope_MatchesTheRootElement_LikeRoot() { - // With no scoping root in play, :scope is the document's root element — the same answer :root - // gives, on the same element and on no other. var root = BuildRoot(Html(":scope { color: #0000ff; }", "<p id='p'>text</p>")); var htmlBox = LayoutHarness.Descendants(root).First(b => b.HtmlTag != null && b.HtmlTag.Name == "html"); @@ -199,9 +184,9 @@ public void Scope_MatchesTheRootElement_LikeRoot() [TestMethod] [Ignore(IgnoreReason)] + [Spec("selectors-4", "#the-scope-pseudo")] public void Scope_WorksAsTheLeftHandSideOfACombinator() { - // The idiomatic use: `:scope x` / `:scope > x`, which stylesheets write in place of `:root x`. var html = Html( ":scope p { color: #0000ff; } :scope > body > p.direct { font-weight: bold; }", "<p id='deep' class='direct'>text</p>"); @@ -212,19 +197,12 @@ public void Scope_WorksAsTheLeftHandSideOfACombinator() } [TestMethod] - [Ignore(IgnoreReason)] public void Scope_AndRoot_ShareTheirDeclarationsInAList() { var box = Box(Html(":root, :scope { --brand: #0000ff; } p { color: var(--brand); }", "<p id='p'>text</p>"), "p"); Assert.AreEqual(Blue, box.Color); } - // ── Helpers (mirrors CssDataRuleIndexingTests.cs conventions) ──────────────────────────────── - - /// <summary> - /// Does <paramref name="selector"/> match <paramref name="box"/>? Asked through the ordinary - /// author-rule lookup, so the rule index and the matcher both run exactly as they do in a cascade. - /// </summary> private static bool Matches(CssBox box, string selector) { var cssData = new CssParser(new MockAdapter()).ParseStyleSheet($"{selector} {{ color: red; }}", false); diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/AttrSelectorTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/AttrSelectorTests.cs index 5dbc63e08..518b98907 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/AttrSelectorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/AttrSelectorTests.cs @@ -7,7 +7,6 @@ namespace HtmlRenderer.Test.Css.Selectors; public sealed class AttrSelectorTests { [TestMethod] - [Spec("css21", "selector.html#matching-attrs")] public void FindAllAttrMatchSelectorsThatMatchAttributeName() { var sheet = GetAttributeStylesheet(""); @@ -16,7 +15,6 @@ public void FindAllAttrMatchSelectorsThatMatchAttributeName() } [TestMethod] - [Spec("css21", "selector.html#matching-attrs")] public void FindAllAttrInListSelectorsThatMatchAttributeName() { var sheet = GetAttributeStylesheet("~"); @@ -25,7 +23,6 @@ public void FindAllAttrInListSelectorsThatMatchAttributeName() } [TestMethod] - [Spec("css21", "selector.html#matching-attrs")] public void FindAllAttrHyphenSelectorsThatMatchAttributeName() { var sheet = GetAttributeStylesheet("|"); @@ -66,7 +63,6 @@ public void FindAllAttrNotMatchSelectorsThatMatchAttributeName() } [TestMethod] - [Spec("css21", "selector.html#matching-attrs")] public void BareAttributePresenceSelector_ResolvesToAttrAvailableSelector() { // A `[attr]` with no combinator/value falls through the factory's dispatch to the presence diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/ClassSelectorTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/ClassSelectorTests.cs index cb0211bbe..dbe454f71 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/ClassSelectorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/ClassSelectorTests.cs @@ -7,7 +7,6 @@ namespace HtmlRenderer.Test.Css.Selectors; public sealed class ClassSelectorTests { [TestMethod] - [Spec("css21", "selector.html#class-html")] public void FindAllClassSelectorsThatMatchClassName() { var css = diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs index c5219dfcd..030c967e9 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs @@ -14,6 +14,8 @@ public sealed class Css21SelectorTests [TestMethod] [Spec("css21", "selector.html#pattern-matching")] [Spec("css21", "selector.html#type-selectors")] + [Spec("selectors-4", "#type-selectors")] + [Spec("selectors-4", "#case-sensitive")] public void TypeSelector_MatchesElementNamesCaseInsensitively() { Assert.IsTrue(Matches("DIV", "<div id='x'>a</div>")); @@ -22,6 +24,7 @@ public void TypeSelector_MatchesElementNamesCaseInsensitively() [TestMethod] [Spec("css21", "selector.html#type-selectors")] + [Spec("selectors-4", "#type-selectors")] public void TypeSelector_MatchesOnlyThatElementType() { Assert.IsTrue(Matches("p", "<p id='x'>a</p>")); @@ -30,6 +33,7 @@ public void TypeSelector_MatchesOnlyThatElementType() [TestMethod] [Spec("css21", "selector.html#selector-syntax")] + [Spec("selectors-4", "#structure")] public void SimpleSelector_MatchesOnlyWhenAllComponentsMatch() { Assert.IsTrue(Matches("p.a#x[title]:first-child", "<div><p id='x' class='a' title='t'>a</p></div>")); @@ -40,6 +44,8 @@ public void SimpleSelector_MatchesOnlyWhenAllComponentsMatch() [TestMethod] [Spec("css21", "selector.html#selector-syntax")] + [Spec("selectors-4", "#child-combinators")] + [Spec("selectors-4", "#adjacent-sibling-combinators")] public void Combinators_WorkWithAndWithoutSurroundingWhitespace() { Assert.IsTrue(Matches("div>p", "<div><p id='x'>a</p></div>")); @@ -51,6 +57,7 @@ public void Combinators_WorkWithAndWithoutSurroundingWhitespace() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "selector.html#selector-syntax")] + [Spec("selectors-4", "#descendant-combinators")] public void CombinatorChain_TriesFartherAncestors() { Assert.IsTrue(Matches(".a > div span", "<section class='a'><div><div><span id='x'>a</span></div></div></section>")); @@ -58,6 +65,7 @@ public void CombinatorChain_TriesFartherAncestors() [TestMethod] [Spec("css21", "selector.html#grouping")] + [Spec("selectors-4", "#grouping")] public void SelectorGroup_AppliesTheDeclarationsToEveryAlternative() { var html = Document("h1, h2, p { background-color: #ff0000 }", "<h1 id='a'>a</h1><h2 id='b'>b</h2><p id='c'>c</p><div id='d'>d</div>"); @@ -70,6 +78,7 @@ public void SelectorGroup_AppliesTheDeclarationsToEveryAlternative() [TestMethod] [Spec("css21", "selector.html#universal-selector")] + [Spec("selectors-4", "#the-universal-selector")] public void UniversalSelector_MatchesAnyElement() { Assert.IsTrue(Matches("*", "<p id='x'>a</p>")); @@ -78,6 +87,7 @@ public void UniversalSelector_MatchesAnyElement() [TestMethod] [Spec("css21", "selector.html#universal-selector")] + [Spec("selectors-4", "#the-universal-selector")] public void UniversalSelector_CanBeOmittedInFrontOfOtherComponents() { Assert.IsTrue(Matches("*[lang=fr]", "<p id='x' lang='fr'>a</p>")); @@ -88,6 +98,7 @@ public void UniversalSelector_CanBeOmittedInFrontOfOtherComponents() [TestMethod] [Spec("css21", "selector.html#descendant-selectors")] + [Spec("selectors-4", "#descendant-combinators")] public void DescendantSelector_MatchesAtAnyDepth() { Assert.IsTrue(Matches("h1 em", "<h1>This <span class='myclass'>is <em id='x'>very</em></span></h1>")); @@ -96,6 +107,7 @@ public void DescendantSelector_MatchesAtAnyDepth() [TestMethod] [Spec("css21", "selector.html#descendant-selectors")] + [Spec("selectors-4", "#descendant-combinators")] public void DescendantSelector_WithUniversalInBetween_SkipsChildren() { var html = Document("div * p { background-color: #ff0000 }", @@ -107,6 +119,7 @@ public void DescendantSelector_WithUniversalInBetween_SkipsChildren() [TestMethod] [Spec("css21", "selector.html#descendant-selectors")] + [Spec("selectors-4", "#descendant-combinators")] public void DescendantSelector_CombinesWithAttributeSelectors() { Assert.IsTrue(Matches("div p *[href]", "<div><p>a <a id='x' href='#'>link</a></p></div>")); @@ -115,6 +128,7 @@ public void DescendantSelector_CombinesWithAttributeSelectors() [TestMethod] [Spec("css21", "selector.html#child-selectors")] + [Spec("selectors-4", "#child-combinators")] public void ChildSelector_MatchesChildrenOfBody() { var html = Document("body > p { background-color: #ff0000 }", "<p id='a'>a</p><div><p id='b'>b</p></div>"); @@ -125,6 +139,7 @@ public void ChildSelector_MatchesChildrenOfBody() [TestMethod] [Spec("css21", "selector.html#child-selectors")] + [Spec("selectors-4", "#child-combinators")] public void ChildSelector_MixedWithDescendantSelectors() { Assert.IsTrue(Matches("div ol>li p", "<div><section><ol><li><span><p id='x'>a</p></span></li></ol></section></div>")); @@ -133,6 +148,7 @@ public void ChildSelector_MixedWithDescendantSelectors() [TestMethod] [Spec("css21", "selector.html#adjacent-selectors")] + [Spec("selectors-4", "#adjacent-sibling-combinators")] public void AdjacentSelector_IgnoresTextAndComments() { Assert.IsTrue(Matches("h1 + h2", "<div><h1>a</h1> text <!-- c --> <h2 id='x'>b</h2></div>")); @@ -140,6 +156,7 @@ public void AdjacentSelector_IgnoresTextAndComments() [TestMethod] [Spec("css21", "selector.html#adjacent-selectors")] + [Spec("selectors-4", "#adjacent-sibling-combinators")] public void AdjacentSelector_WithClassOnTheFirstElement() { Assert.IsTrue(Matches("h1.opener + h2", "<h1 class='opener'>a</h1><h2 id='x'>b</h2>")); @@ -148,6 +165,7 @@ public void AdjacentSelector_WithClassOnTheFirstElement() [TestMethod] [Spec("css21", "selector.html#matching-attrs")] + [Spec("selectors-4", "#attribute-representation")] public void AttributePresence_MatchesWhateverTheValue() { Assert.IsTrue(Matches("h1[title]", "<h1 id='x' title='t'>a</h1>")); @@ -157,6 +175,7 @@ public void AttributePresence_MatchesWhateverTheValue() [TestMethod] [Spec("css21", "selector.html#matching-attrs")] + [Spec("selectors-4", "#attribute-representation")] public void AttributeEquals_MatchesTheExactValue() { Assert.IsTrue(Matches("span[class=example]", "<span id='x' class='example'>a</span>")); @@ -166,6 +185,7 @@ public void AttributeEquals_MatchesTheExactValue() [TestMethod] [Spec("css21", "selector.html#matching-attrs")] + [Spec("selectors-4", "#attribute-representation")] public void SeveralAttributeSelectors_MustAllMatch() { const string selector = "span[hello=\"Cleveland\"][goodbye=\"Columbus\"]"; @@ -175,6 +195,7 @@ public void SeveralAttributeSelectors_MustAllMatch() [TestMethod] [Spec("css21", "selector.html#matching-attrs")] + [Spec("selectors-4", "#attribute-representation")] public void AttributeIncludes_MatchesOneWordOfAWhitespaceSeparatedList() { Assert.IsTrue(Matches("a[rel~=\"copyright\"]", "<a id='x' rel='copyright copyleft copyeditor'>a</a>")); @@ -183,6 +204,7 @@ public void AttributeIncludes_MatchesOneWordOfAWhitespaceSeparatedList() [TestMethod] [Spec("css21", "selector.html#matching-attrs")] + [Spec("selectors-4", "#attribute-representation")] public void AttributeIncludes_WithWhitespaceOrEmptyValue_NeverMatches() { Assert.IsFalse(Matches("a[rel~=\"a b\"]", "<a id='x' rel='a b'>a</a>")); @@ -191,6 +213,7 @@ public void AttributeIncludes_WithWhitespaceOrEmptyValue_NeverMatches() [TestMethod] [Spec("css21", "selector.html#matching-attrs")] + [Spec("selectors-4", "#attribute-representation")] public void AttributeDashMatch_MatchesTheValueOrTheValueFollowedByAHyphen() { Assert.IsTrue(Matches("*[lang|=\"en\"]", "<p id='x' lang='en'>a</p>")); @@ -201,6 +224,7 @@ public void AttributeDashMatch_MatchesTheValueOrTheValueFollowedByAHyphen() [TestMethod] [Spec("css21", "selector.html#matching-attrs")] + [Spec("selectors-4", "#case-sensitive")] public void AttributeNames_AreCaseInsensitiveInHtml() { Assert.IsTrue(Matches("[TITLE]", "<p id='x' title='t'>a</p>")); @@ -209,6 +233,8 @@ public void AttributeNames_AreCaseInsensitiveInHtml() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "selector.html#matching-attrs")] + [Spec("selectors-4", "#attribute-representation")] + [Spec("selectors-4", "#case-sensitive")] public void AttributeValues_AreCaseSensitiveInHtml() { Assert.IsFalse(Matches("[title=Foo]", "<p id='x' title='foo'>a</p>")); @@ -216,6 +242,7 @@ public void AttributeValues_AreCaseSensitiveInHtml() [TestMethod] [Spec("css21", "selector.html#class-html")] + [Spec("selectors-4", "#class-html")] public void ClassSelector_MatchesOneOfTheClasses() { Assert.IsTrue(Matches(".pastoral", "<h1 id='x' class='pastoral'>a</h1>")); @@ -226,6 +253,7 @@ public void ClassSelector_MatchesOneOfTheClasses() [TestMethod] [Spec("css21", "selector.html#class-html")] + [Spec("selectors-4", "#class-html")] public void SeveralClassSelectors_MustAllMatch() { Assert.IsTrue(Matches("p.marine.pastoral", "<p id='x' class='pastoral blue aqua marine'>a</p>")); @@ -234,6 +262,7 @@ public void SeveralClassSelectors_MustAllMatch() [TestMethod] [Spec("css21", "selector.html#class-html")] + [Spec("selectors-4", "#class-html")] public void ClassSelector_IsTheSameAsAttributeIncludes() { Assert.IsTrue(Matches("div[class~=value]", "<div id='x' class='a value b'>a</div>")); @@ -243,6 +272,7 @@ public void ClassSelector_IsTheSameAsAttributeIncludes() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "selector.html#class-html")] + [Spec("selectors-4", "#class-html")] public void ClassSelector_IsCaseSensitiveInHtml() { Assert.IsFalse(Matches(".Foo", "<p id='x' class='foo'>a</p>")); @@ -251,6 +281,7 @@ public void ClassSelector_IsCaseSensitiveInHtml() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "selector.html#class-html")] + [Spec("selectors-4", "#class-html")] public void ClassSelector_SplitsTheClassAttributeOnAnyWhitespace() { Assert.IsTrue(Matches(".b", "<p id='x' class='a\tb'>a</p>")); @@ -259,6 +290,7 @@ public void ClassSelector_SplitsTheClassAttributeOnAnyWhitespace() [TestMethod] [Spec("css21", "selector.html#id-selectors")] + [Spec("selectors-4", "#id-selectors")] public void IdSelector_MatchesTheElementWithThatId() { Assert.IsTrue(Matches("*#z98y", "<p id='z98y'>a</p>", "z98y")); @@ -268,6 +300,7 @@ public void IdSelector_MatchesTheElementWithThatId() [TestMethod] [Spec("css21", "selector.html#id-selectors")] + [Spec("selectors-4", "#specificity-rules")] public void IdSelector_IsMoreSpecificThanAnAttributeSelector() { var html = Document("#p123 { background-color: #ff0000 } [id=p123] { background-color: #0000ff }", "<p id='p123'>a</p>"); @@ -279,6 +312,7 @@ public void IdSelector_IsMoreSpecificThanAnAttributeSelector() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "selector.html#id-selectors")] + [Spec("selectors-4", "#id-selectors")] public void IdSelector_IsCaseSensitiveInHtml() { Assert.IsFalse(Matches("#Foo", "<p id='foo'>a</p>", "foo")); @@ -286,6 +320,7 @@ public void IdSelector_IsCaseSensitiveInHtml() [TestMethod] [Spec("css21", "selector.html#pseudo-elements")] + [Spec("selectors-4", "#pseudo-classes")] public void PseudoClasses_CanAppearAnywhereInTheSelector() { Assert.IsTrue(Matches("div:first-child > p", "<div><p id='x'>a</p></div>")); @@ -294,6 +329,7 @@ public void PseudoClasses_CanAppearAnywhereInTheSelector() [TestMethod] [Spec("css21", "selector.html#pseudo-elements")] + [Spec("selectors-4", "#pseudo-classes")] public void SeveralPseudoClasses_CanApplyToTheSameElement() { Assert.IsTrue(Matches("a:link:first-child", "<p><a id='x' href='#'>a</a></p>")); @@ -302,6 +338,8 @@ public void SeveralPseudoClasses_CanApplyToTheSameElement() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "selector.html#pseudo-elements")] + [Spec("selectors-4", "#pseudo-classes")] + [Spec("selectors-4", "#case-sensitive")] public void PseudoClassNames_AreCaseInsensitive() { Assert.IsTrue(Matches("p:FIRST-CHILD", "<div><p id='x'>a</p></div>")); @@ -310,6 +348,7 @@ public void PseudoClassNames_AreCaseInsensitive() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "selector.html#pseudo-elements")] + [Spec("selectors-4", "#pseudo-element-syntax")] public void PseudoElementNames_AreCaseInsensitive() { var p = Layout("p:BEFORE { content: 'N' }", "<p id='x'>a</p>"); @@ -319,6 +358,7 @@ public void PseudoElementNames_AreCaseInsensitive() [TestMethod] [Spec("css21", "selector.html#first-child")] + [Spec("selectors-4", "#the-first-child-pseudo")] public void FirstChild_MatchesTheFirstChildElement() { Assert.IsTrue(Matches("div > p:first-child", "<p>before</p><div class='note'><p id='x'>first</p></div>")); @@ -327,6 +367,8 @@ public void FirstChild_MatchesTheFirstChildElement() [TestMethod] [Spec("css21", "selector.html#first-child")] + [Spec("selectors-4", "#the-first-child-pseudo")] + [Spec("selectors-4", "#structural-pseudos")] public void FirstChild_IgnoresLeadingText() { Assert.IsTrue(Matches("em:first-child", "<p>abc <em id='x'>default</em></p>")); @@ -334,6 +376,7 @@ public void FirstChild_IgnoresLeadingText() [TestMethod] [Spec("css21", "selector.html#first-child")] + [Spec("selectors-4", "#the-first-child-pseudo")] public void FirstChild_InADescendantChain() { Assert.IsTrue(Matches("p:first-child em", "<div><p>a <span><em id='x'>b</em></span></p></div>")); @@ -342,6 +385,7 @@ public void FirstChild_InADescendantChain() [TestMethod] [Spec("css21", "selector.html#link-pseudo-classes")] + [Spec("selectors-4", "#link")] public void Link_MatchesAnchorsWithAnHref() { Assert.IsTrue(Matches("a:link", "<a id='x' href='http://out.side/'>a</a>")); @@ -352,6 +396,7 @@ public void Link_MatchesAnchorsWithAnHref() [TestMethod] [Spec("css21", "selector.html#link-pseudo-classes")] + [Spec("selectors-4", "#link")] public void Visited_NeverMatches_AllLinksCountAsUnvisited() { Assert.IsFalse(Matches("a:visited", "<a id='x' href='http://out.side/'>a</a>")); @@ -360,6 +405,7 @@ public void Visited_NeverMatches_AllLinksCountAsUnvisited() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "selector.html#dynamic-pseudo-classes")] + [Spec("selectors-4", "#the-hover-pseudo")] public void Hover_OnANonSubjectElement_DoesntMatchWhileNothingIsHovered() { Assert.IsFalse(Matches("div:hover p", "<div><p id='x'>a</p></div>")); @@ -368,6 +414,7 @@ public void Hover_OnANonSubjectElement_DoesntMatchWhileNothingIsHovered() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "selector.html#dynamic-pseudo-classes")] + [Spec("selectors-4", "#the-hover-pseudo")] public void Hover_AppliesWhileTheMouseIsOverTheElement() { var adapter = new MockAdapter(); @@ -381,7 +428,8 @@ public void Hover_AppliesWhileTheMouseIsOverTheElement() } [TestMethod] - [Spec("css21", "selector.html#lang")] + [Spec("css21", "selector.html#matching-attrs")] + [Spec("selectors-4", "#attribute-representation")] public void AttributeDashMatch_OnlyMatchesTheElementWithTheAttribute() { var html = Document("[lang|=fr] { background-color: #ff0000 }", "<div id='b' lang='fr'><p id='p'>Je suis Français.</p></div>"); @@ -393,6 +441,7 @@ public void AttributeDashMatch_OnlyMatchesTheElementWithTheAttribute() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "selector.html#lang")] + [Spec("selectors-4", "#the-lang-pseudo")] public void Lang_MatchesElementsInThatLanguage() { var html = Document(":lang(fr) { background-color: #ff0000 }", "<div id='b' lang='fr-CA'><p id='p'>Je suis Français.</p></div><p id='en' lang='en'>a</p>"); @@ -426,6 +475,7 @@ public void FirstLetter_StylesTheFirstLetter() [TestMethod] [Spec("css21", "selector.html#before-and-after")] + [Spec("selectors-4", "#pseudo-element-syntax")] public void BeforeAndAfter_WithASingleColon_GenerateContent() { var p = Layout("p:before { content: 'Note: ' } p:after { content: '!' }", "<p id='x'>a</p>"); diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/RelationalPseudoClassSelectorTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/RelationalPseudoClassSelectorTests.cs index 34d638b20..de24fde62 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/RelationalPseudoClassSelectorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/RelationalPseudoClassSelectorTests.cs @@ -7,32 +7,13 @@ namespace HtmlRenderer.Test.Css.Selectors; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/RelationalPseudoClassSelectorTests.cs. :not(), :is()/:matches(), and -/// :where() - plus plain (descendant-form) :has(S) - are all faithful ports with matching specificity -/// semantics (see <see cref="TheArtOfDev.HtmlRenderer.Core.CssEngine.NotSelector"/>, -/// <see cref="TheArtOfDev.HtmlRenderer.Core.CssEngine.MatchesSelector"/>, -/// <see cref="TheArtOfDev.HtmlRenderer.Core.CssEngine.HasSelector"/>), so those are ported here via the -/// <see cref="LayoutHarness"/>/<see cref="MockAdapter"/> pattern (see -/// <see cref="CssSpecificityOrderingTests"/>). -/// -/// The four leading-combinator ":has()" matching tests (":has(&gt; S)"/":has(+ S)"/":has(~ S)" and the -/// mixed-combinator comma-list case) are ported below as <see cref="Ignore"/>d: HTML-Renderer's -/// <c>HasSelector</c> only supports the default (descendant) relative-selector form - there is no -/// <c>Selectors/RelativeSelector.cs</c>. The root cause is -/// <see cref="TheArtOfDev.HtmlRenderer.Core.CssEngine.SelectorConstructor"/>'s private -/// <c>Insert(ISelector)</c> (lines 362-387): when a combinator token arrives before any selector has -/// been accumulated yet (i.e. it's the leading token inside "S" of "&gt;(S)"), <c>_temp</c> is still -/// null, so the "no previous selector" branch runs <c>_combinators.Clear()</c> and simply assigns -/// <c>_temp = selector</c> - silently discarding the leading combinator instead of attaching it. See -/// also <c>HasSelector</c>'s own doc comment, which documents this as a known, intentional v1 gap. -/// </summary> [TestClass] public sealed class RelationalPseudoClassSelectorTests { // ── :not() ───────────────────────────────────────────────────────────────── [TestMethod] + [Spec("selectors-4", "#negation")] public void Not_ExcludesElementsMatchingTheArgument() { var html = Html( @@ -45,21 +26,21 @@ public void Not_ExcludesElementsMatchingTheArgument() } [TestMethod] - public void NotNot_IsRejectedAsInvalid_WholeRuleMatchesNothing() + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#negation")] + public void NotNot_MatchesWhatTheInnerArgumentMatches() { - // The parser has a pre-existing restriction against nesting :not() inside :not() (IsNested - // flag) - the whole enclosing selector becomes invalid, so the rule matches nothing at all, - // even for an element that would satisfy the (nonsensical) double-negation. var html = Html( "p:not(:not(.foo)) { background-color: #ff0000; }", "<div><p class='foo'>1</p></div>"); var box = FindBoxByTag(html, "p"); - Assert.AreEqual(RColor.Transparent, box.ActualBackgroundColor); + Assert.AreNotEqual(RColor.Transparent, box.ActualBackgroundColor); } // ── :is() / :matches() ──────────────────────────────────────────────────── [TestMethod] + [Spec("selectors-4", "#matches")] public void Is_MatchesEitherBranch() { var html = Html( @@ -73,6 +54,8 @@ public void Is_MatchesEitherBranch() } [TestMethod] + [Spec("selectors-4", "#matches")] + [Spec("selectors-4", "#legacy-aliasing")] public void Matches_LegacyAliasBehavesTheSameAsIs() { var html = Html( @@ -85,12 +68,11 @@ public void Matches_LegacyAliasBehavesTheSameAsIs() } [TestMethod] + [Spec("selectors-4", "#matches")] + [Spec("selectors-4", "#specificity-rules")] public void Is_SpecificityIsTheStaticMaxOfItsArguments_NotJustTheMatchedBranch() { - // ":is(.a, #b)" must report specificity = max(.a, #b) = one-id, per spec - even though this - // box only matches via the ".a" branch (never #b). If :is()'s specificity were instead - // computed dynamically from only the matched branch (one-class), ".a.c" (two classes) would - // incorrectly outrank it; with the correct static-max (one-id) behavior, :is(...) wins. + // Only .a matches, but :is() counts as #b, which beats .a.c. var html = Html( ":is(.a, #b) { color: #0000ff; } .a.c { color: #ff0000; }", "<div class='a c'>text</div>"); @@ -101,6 +83,8 @@ public void Is_SpecificityIsTheStaticMaxOfItsArguments_NotJustTheMatchedBranch() // ── :has() ───────────────────────────────────────────────────────────────── [TestMethod] + [Spec("selectors-4", "#relational")] + [Spec("selectors-4", "#relative")] public void Has_MatchesWhenADirectChildSatisfiesTheArgument() { var html = Html( @@ -113,6 +97,7 @@ public void Has_MatchesWhenADirectChildSatisfiesTheArgument() } [TestMethod] + [Spec("selectors-4", "#relational")] public void Has_MatchesWhenADeeplyNestedDescendantSatisfiesTheArgument() { var html = Html( @@ -123,6 +108,7 @@ public void Has_MatchesWhenADeeplyNestedDescendantSatisfiesTheArgument() } [TestMethod] + [Spec("selectors-4", "#relational")] public void Has_CommaSeparatedArgument_MatchesEitherBranch() { var html = Html( @@ -135,8 +121,9 @@ public void Has_CommaSeparatedArgument_MatchesEitherBranch() } [TestMethod] - [Ignore("not yet spec compliant - see class doc comment: HasSelector has no RelativeSelector, so " + - "a leading combinator inside :has(...) is silently discarded by SelectorConstructor.Insert(ISelector).")] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#relational")] + [Spec("selectors-4", "#relative")] public void Has_ChildCombinator_MatchesOnlyDirectChild() { // ":has(> .foo)" must NOT behave like plain ":has(.foo)" - a .foo that's only a grandchild @@ -151,16 +138,11 @@ public void Has_ChildCombinator_MatchesOnlyDirectChild() } [TestMethod] - [Ignore("not yet spec compliant - see class doc comment: HasSelector has no RelativeSelector, so " + - "a leading combinator inside :has(...) is silently discarded by SelectorConstructor.Insert(ISelector).")] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#relative")] public void Has_ChildCombinator_WithMultiCompoundArgument_AnchorsOnlyItsFirstCompoundToTheChild() { - // ":has(> .a .b)" means the DIRECT CHILD must match ".a", and ".b" must be a descendant of - // THAT child - not that the direct child itself must satisfy the whole ".a .b" chain as its - // own subject (which would require the child to literally match ".b"). - // ".b" is nested two levels below ".a" (not a direct child of it) to exercise the recursive - // any-depth descendant search for the chain's internal (non-leading) combinator, not just its - // first level. + // The child has to match .a, and .b has to be anywhere inside that child. var html = Html( "div:has(> .a .b) { background-color: #ff0000; }", "<div id='yes'><section class='a'><em><span class='b'>x</span></em></section></div>" + @@ -175,10 +157,8 @@ public void Has_ChildCombinator_WithMultiCompoundArgument_AnchorsOnlyItsFirstCom } [TestMethod] - [Ignore("not yet spec compliant - see class doc comment: HasSelector has no RelativeSelector, so " + - "a leading combinator inside :has(...) is silently discarded by SelectorConstructor.Insert(ISelector). " + - "Additionally, unlike PeachPDF, plain \":has(*)\" here matches a text-only child (verified empirically), " + - "so this doesn't even hold vacuously once the combinator is dropped.")] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#relational")] public void Has_ChildCombinator_WithUniversalSelector_DoesNotMatchATextOnlyChild() { var html = Html( @@ -191,8 +171,8 @@ public void Has_ChildCombinator_WithUniversalSelector_DoesNotMatchATextOnlyChild } [TestMethod] - [Ignore("not yet spec compliant - see class doc comment: HasSelector has no RelativeSelector, so " + - "a leading combinator inside :has(...) is silently discarded by SelectorConstructor.Insert(ISelector).")] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#relative")] public void Has_AdjacentSiblingCombinator_MatchesOnlyImmediateNextSibling() { var html = Html( @@ -206,8 +186,8 @@ public void Has_AdjacentSiblingCombinator_MatchesOnlyImmediateNextSibling() } [TestMethod] - [Ignore("not yet spec compliant - see class doc comment: HasSelector has no RelativeSelector, so " + - "a leading combinator inside :has(...) is silently discarded by SelectorConstructor.Insert(ISelector).")] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#relative")] public void Has_GeneralSiblingCombinator_MatchesAnyFollowingSibling() { // Same markup shape as the adjacent-sibling test's "no" case (.foo is a later, not immediate, @@ -223,8 +203,8 @@ public void Has_GeneralSiblingCombinator_MatchesAnyFollowingSibling() } [TestMethod] - [Ignore("not yet spec compliant - see class doc comment: HasSelector has no RelativeSelector, so " + - "a leading combinator inside :has(...) is silently discarded by SelectorConstructor.Insert(ISelector).")] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#relative")] public void Has_MixedLeadingCombinatorsInCommaList_MatchesEitherBranch() { // Each comma-separated alternative tracks its own leading combinator independently. @@ -254,8 +234,8 @@ public void Has_LeadingCombinator_RoundTripsThroughSelectorText(string selector) [DataRow("div:has(> .foo)")] [DataRow("div:has(+ .foo)")] [DataRow("div:has(~ .foo)")] - [Ignore("not yet spec compliant - see class doc comment: the leading combinator is discarded during " + - "parsing, so ToCss() re-emits the selector without it and the round-trip text no longer matches.")] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#relative")] public void Has_LeadingCombinator_RoundTripsThroughSelectorText_LeadingCombinatorForms(string selector) { var sheet = ParseStyleSheet($"{selector} {{ }}"); @@ -264,12 +244,11 @@ public void Has_LeadingCombinator_RoundTripsThroughSelectorText_LeadingCombinato } [TestMethod] - [Ignore("not yet spec compliant - see class doc comment: leading combinators on both comma-list " + - "alternatives are discarded during parsing, so the round-trip text no longer matches.")] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#relative")] public void Has_MixedLeadingCombinatorsInCommaList_RoundTripsThroughSelectorText() { - // ListSelector.ToCss doesn't insert a space after its comma (existing, pre-existing behavior - - // see ListSelector.cs), so this asserts separately from the single-alternative cases above. + // ListSelector.ToCss writes no space after the comma. var sheet = ParseStyleSheet("div:has(> .a, + .b) { }"); Assert.AreEqual(1, sheet.Rules.Length); Assert.AreEqual("div:has(> .a,+ .b)", ((StyleRule)sheet.Rules[0]).SelectorText); @@ -278,6 +257,7 @@ public void Has_MixedLeadingCombinatorsInCommaList_RoundTripsThroughSelectorText // ── :where() ─────────────────────────────────────────────────────────────── [TestMethod] + [Spec("selectors-4", "#zero-matches")] public void Where_MatchesEitherBranch_LikeIs() { var html = Html( @@ -291,11 +271,10 @@ public void Where_MatchesEitherBranch_LikeIs() } [TestMethod] + [Spec("selectors-4", "#zero-matches")] + [Spec("selectors-4", "#specificity-rules")] public void Where_ContributesZeroSpecificity_LosesToATypeSelector() { - // ":where(#b)" matches, but per CSS Selectors 4 §16 it contributes ZERO specificity - so a - // bare type selector "p" (0,0,1) outranks it even though ":where(#b)"'s argument is an id. - // Contrast Is_SpecificityIsTheStaticMaxOfItsArguments (where ":is(#b)" WOULD win at one-id). var html = Html( ":where(#b) { color: #0000ff; } p { color: #ff0000; }", "<p id='b'>text</p>"); @@ -304,10 +283,9 @@ public void Where_ContributesZeroSpecificity_LosesToATypeSelector() } [TestMethod] + [Spec("selectors-4", "#zero-matches")] public void Where_StillMatches_WhenUncontested() { - // Proves the zero-specificity rule above lost on specificity, not because :where() failed to - // match: uncontested, the same ":where(#b)" rule applies its color. var html = Html( ":where(#b) { color: #0000ff; }", "<p id='b'>text</p>"); @@ -315,8 +293,6 @@ public void Where_StillMatches_WhenUncontested() Assert.AreEqual(RColor.FromArgb(0, 0, 255), box.ActualColor); } - // ── Helpers (mirrors CssSpecificityOrderingTests.cs conventions) ───────── - private static string Html(string css, string body) => $"<!DOCTYPE html><html><head><style>{css}</style></head><body>{body}</body></html>"; diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorMatchingTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorMatchingTests.cs index 92842709f..f47c27900 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorMatchingTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorMatchingTests.cs @@ -10,6 +10,7 @@ public sealed class SelectorMatchingTests // ── Attribute: starts-with [attr^=value] ───────────────────────────────── [TestMethod] + [Spec("selectors-4", "#attribute-substrings")] public void AttrBegins_Matches_WhenValueStartsWith() { var html = Html("[class^='btn'] { background-color: #ff0000; }", "<span class='btn-primary'>text</span>"); @@ -18,6 +19,7 @@ public void AttrBegins_Matches_WhenValueStartsWith() } [TestMethod] + [Spec("selectors-4", "#attribute-substrings")] public void AttrBegins_DoesNotMatch_WhenValueDoesNotStartWith() { var html = Html("[class^='btn'] { background-color: #ff0000; }", "<span class='input-btn'>text</span>"); @@ -28,6 +30,7 @@ public void AttrBegins_DoesNotMatch_WhenValueDoesNotStartWith() // ── Attribute: ends-with [attr$=value] ─────────────────────────────────── [TestMethod] + [Spec("selectors-4", "#attribute-substrings")] public void AttrEnds_Matches_WhenValueEndsWith() { var html = Html("[lang$='-US'] { background-color: #ff0000; }", "<span lang='en-US'>text</span>"); @@ -36,6 +39,7 @@ public void AttrEnds_Matches_WhenValueEndsWith() } [TestMethod] + [Spec("selectors-4", "#attribute-substrings")] public void AttrEnds_DoesNotMatch_WhenValueDoesNotEndWith() { var html = Html("[lang$='-US'] { background-color: #ff0000; }", "<span lang='US-en'>text</span>"); @@ -47,6 +51,7 @@ public void AttrEnds_DoesNotMatch_WhenValueDoesNotEndWith() [TestMethod] [Spec("css21", "selector.html#matching-attrs")] + [Spec("selectors-4", "#attribute-representation")] public void AttrHyphen_Matches_ExactValue() { var html = Html("[lang|='en'] { background-color: #ff0000; }", "<span lang='en'>text</span>"); @@ -56,6 +61,7 @@ public void AttrHyphen_Matches_ExactValue() [TestMethod] [Spec("css21", "selector.html#matching-attrs")] + [Spec("selectors-4", "#attribute-representation")] public void AttrHyphen_Matches_HyphenPrefixed() { var html = Html("[lang|='en'] { background-color: #ff0000; }", "<span lang='en-US'>text</span>"); @@ -65,6 +71,7 @@ public void AttrHyphen_Matches_HyphenPrefixed() [TestMethod] [Spec("css21", "selector.html#matching-attrs")] + [Spec("selectors-4", "#attribute-representation")] public void AttrHyphen_DoesNotMatch_SubstringOnly() { var html = Html("[lang|='en'] { background-color: #ff0000; }", "<span lang='english'>text</span>"); @@ -76,6 +83,7 @@ public void AttrHyphen_DoesNotMatch_SubstringOnly() [TestMethod] [Spec("css21", "selector.html#adjacent-selectors")] + [Spec("selectors-4", "#adjacent-sibling-combinators")] public void AdjacentSibling_Matches_ImmediatelyFollowingSibling() { var html = Html("div + p { background-color: #ff0000; }", "<div>d</div><p id='t'>first</p>"); @@ -85,6 +93,7 @@ public void AdjacentSibling_Matches_ImmediatelyFollowingSibling() [TestMethod] [Spec("css21", "selector.html#adjacent-selectors")] + [Spec("selectors-4", "#adjacent-sibling-combinators")] public void AdjacentSibling_DoesNotMatch_SecondSibling() { var html = Html("div + p { background-color: #ff0000; }", "<div>d</div><p>first</p><p>second</p>"); @@ -96,6 +105,7 @@ public void AdjacentSibling_DoesNotMatch_SecondSibling() [TestMethod] [Spec("css21", "selector.html#adjacent-selectors")] + [Spec("selectors-4", "#adjacent-sibling-combinators")] public void AdjacentSibling_DoesNotMatch_PrecedingSibling() { var html = Html("div + p { background-color: #ff0000; }", "<p>before</p><div>d</div>"); @@ -106,6 +116,7 @@ public void AdjacentSibling_DoesNotMatch_PrecedingSibling() // ── Combinator: general sibling (div ~ p) ──────────────────────────────── [TestMethod] + [Spec("selectors-4", "#general-sibling-combinators")] public void GeneralSibling_Matches_AllFollowingSiblings() { var html = Html("div ~ p { background-color: #ff0000; }", "<div>d</div><p>first</p><p>second</p>"); @@ -116,6 +127,7 @@ public void GeneralSibling_Matches_AllFollowingSiblings() } [TestMethod] + [Spec("selectors-4", "#general-sibling-combinators")] public void GeneralSibling_DoesNotMatch_PrecedingSibling() { var html = Html("div ~ p { background-color: #ff0000; }", "<p>before</p><div>d</div>"); @@ -127,6 +139,7 @@ public void GeneralSibling_DoesNotMatch_PrecedingSibling() [TestMethod] [Spec("css21", "selector.html#child-selectors")] + [Spec("selectors-4", "#child-combinators")] public void ChildCombinator_StillMatches_DirectChild() { var html = Html("div > p { background-color: #ff0000; }", "<div><p>direct</p><section><p>nested</p></section></div>"); @@ -138,6 +151,7 @@ public void ChildCombinator_StillMatches_DirectChild() [TestMethod] [Spec("css21", "selector.html#descendant-selectors")] + [Spec("selectors-4", "#descendant-combinators")] public void DescendantCombinator_StillMatches_AnyDescendant() { var html = Html("div p { background-color: #ff0000; }", "<div><section><p>deep</p></section></div><p>outside</p>"); diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/StructuralPseudoClassSelectorTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/StructuralPseudoClassSelectorTests.cs index 012190b2e..c227799ec 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/StructuralPseudoClassSelectorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/StructuralPseudoClassSelectorTests.cs @@ -11,6 +11,7 @@ public sealed class StructuralPseudoClassSelectorTests [TestMethod] [Spec("css21", "selector.html#first-child")] + [Spec("selectors-4", "#the-first-child-pseudo")] public void FirstChild_Matches_FirstElement() { var html = Html(":first-child { background-color: #ff0000; }", "<div><p>first</p><p>second</p></div>"); @@ -23,6 +24,7 @@ public void FirstChild_Matches_FirstElement() // ── :last-child ──────────────────────────────────────────────────────────── [TestMethod] + [Spec("selectors-4", "#the-last-child-pseudo")] public void LastChild_Matches_LastElement() { var html = Html(":last-child { background-color: #ff0000; }", "<div><p>first</p><p>second</p></div>"); @@ -35,6 +37,7 @@ public void LastChild_Matches_LastElement() // ── :only-child ──────────────────────────────────────────────────────────── [TestMethod] + [Spec("selectors-4", "#the-only-child-pseudo")] public void OnlyChild_Matches_WhenNoSiblings() { var html = Html(":only-child { background-color: #ff0000; }", "<div><p>alone</p></div>"); @@ -43,6 +46,7 @@ public void OnlyChild_Matches_WhenNoSiblings() } [TestMethod] + [Spec("selectors-4", "#the-only-child-pseudo")] public void OnlyChild_DoesNotMatch_WhenSiblingsPresent() { var html = Html(":only-child { background-color: #ff0000; }", "<div><p>first</p><p>second</p></div>"); @@ -54,6 +58,7 @@ public void OnlyChild_DoesNotMatch_WhenSiblingsPresent() // ── :root ────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("selectors-4", "#the-root-pseudo")] public void Root_Matches_HtmlElement() { var html = Html(":root { background-color: #ff0000; }", "<p>content</p>"); @@ -62,6 +67,7 @@ public void Root_Matches_HtmlElement() } [TestMethod] + [Spec("selectors-4", "#the-root-pseudo")] public void Root_DoesNotMatch_BodyOrDescendants() { var html = Html(":root { background-color: #ff0000; }", "<div><p>content</p></div>"); @@ -72,6 +78,7 @@ public void Root_DoesNotMatch_BodyOrDescendants() } [TestMethod] + [Spec("selectors-4", "#the-root-pseudo")] public void Root_CompoundWithTypeSelector_StillMatches() { var html = Html("html:root { background-color: #ff0000; }", "<p>content</p>"); @@ -80,10 +87,10 @@ public void Root_CompoundWithTypeSelector_StillMatches() } [TestMethod] + [Spec("selectors-4", "#the-root-pseudo")] + [Spec("selectors-4", "#specificity-rules")] public void Root_Specificity_OutranksTypeSelector_RegardlessOfSourceOrder() { - // ":root" is (0,0,1,0), "html" is (0,0,0,1) - :root must win the cascade even though it's - // declared after "html", where a naive source-order-only cascade would pick "html"'s blue. var html = Html( "html { background-color: #0000ff; } :root { background-color: #ff0000; }", "<p>content</p>"); @@ -94,6 +101,7 @@ public void Root_Specificity_OutranksTypeSelector_RegardlessOfSourceOrder() // ── :nth-child() — literal, formula, keywords, negative offset ──────────── [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void NthChild_Literal_MatchesOnlyThatPosition() { var html = Html(":nth-child(2) { background-color: #ff0000; }", "<div><p>1</p><p>2</p><p>3</p></div>"); @@ -105,6 +113,7 @@ public void NthChild_Literal_MatchesOnlyThatPosition() } [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void NthChild_2n_MatchesEvenPositions() { var html = Html(":nth-child(2n) { background-color: #ff0000; }", "<div><p>1</p><p>2</p><p>3</p><p>4</p></div>"); @@ -116,6 +125,7 @@ public void NthChild_2n_MatchesEvenPositions() } [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void NthChild_2nPlus1_MatchesOddPositions() { var html = Html(":nth-child(2n+1) { background-color: #ff0000; }", "<div><p>1</p><p>2</p><p>3</p><p>4</p></div>"); @@ -127,10 +137,10 @@ public void NthChild_2nPlus1_MatchesOddPositions() } [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void NthChild_2nPlus1_WhitespaceSeparatedSign_MatchesSameAsCompactForm() { - // CSS Syntax §5.6: when whitespace separates the sign from B, the '+' arrives as a standalone - // delim token (`2n + 1`) rather than a signed number (`2n+1`). Both must select the same elements. + // CSS Syntax 3 §6.2, the spaced '+' is a delim token instead of a signed number. var html = Html(":nth-child(2n + 1) { background-color: #ff0000; }", "<div><p>1</p><p>2</p><p>3</p><p>4</p></div>"); var boxes = FindAllBoxesByTag(html, "p"); Assert.AreNotEqual(RColor.Transparent, boxes[0].ActualBackgroundColor); @@ -140,10 +150,9 @@ public void NthChild_2nPlus1_WhitespaceSeparatedSign_MatchesSameAsCompactForm() } [TestMethod] + [Spec("selectors-4", "#the-nth-of-type-pseudo")] public void NthOfType_10nPlus1_WhitespaceSeparatedSign_MatchesFirstOfType() { - // 10n + 1 over 3 elements matches only position 1; proves the spaced sign is parsed (not silently - // invalidating the selector, which would leave every element transparent). var html = Html("p:nth-of-type(10n + 1) { background-color: #ff0000; }", "<div><p>1</p><p>2</p><p>3</p></div>"); var boxes = FindAllBoxesByTag(html, "p"); Assert.AreNotEqual(RColor.Transparent, boxes[0].ActualBackgroundColor); @@ -152,6 +161,7 @@ public void NthOfType_10nPlus1_WhitespaceSeparatedSign_MatchesFirstOfType() } [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void NthChild_2nMinus1_WhitespaceSeparatedNegativeSign_MatchesOddPositions() { // A spaced negative sign (`2n - 1`) must also parse: 2n-1 selects positions 1 and 3. @@ -164,6 +174,7 @@ public void NthChild_2nMinus1_WhitespaceSeparatedNegativeSign_MatchesOddPosition } [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void NthChild_Odd_MatchesOddPositions() { var html = Html(":nth-child(odd) { background-color: #ff0000; }", "<div><p>1</p><p>2</p><p>3</p></div>"); @@ -174,6 +185,7 @@ public void NthChild_Odd_MatchesOddPositions() } [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void NthChild_Even_MatchesEvenPositions() { var html = Html(":nth-child(even) { background-color: #ff0000; }", "<div><p>1</p><p>2</p><p>3</p></div>"); @@ -184,6 +196,7 @@ public void NthChild_Even_MatchesEvenPositions() } [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void NthChild_NegativeStep_MatchesFirstNPositions() { // "-n+3" matches positions 1, 2, 3 and nothing beyond. @@ -196,6 +209,7 @@ public void NthChild_NegativeStep_MatchesFirstNPositions() } [TestMethod] + [Spec("selectors-4", "#the-nth-last-child-pseudo")] public void NthLastChild_1_MatchesLastElement() { var html = Html(":nth-last-child(1) { background-color: #ff0000; }", "<div><p>1</p><p>2</p><p>3</p></div>"); @@ -209,6 +223,7 @@ public void NthLastChild_1_MatchesLastElement() // Mixed-tag siblings prove type-filtering isn't just reusing :nth-child's all-elements scope. [TestMethod] + [Spec("selectors-4", "#the-first-of-type-pseudo")] public void FirstOfType_Matches_FirstOfItsOwnTagOnly() { var html = Html("p:first-of-type { background-color: #ff0000; }", "<div><span>s</span><p>1</p><p>2</p></div>"); @@ -219,6 +234,7 @@ public void FirstOfType_Matches_FirstOfItsOwnTagOnly() } [TestMethod] + [Spec("selectors-4", "#the-last-of-type-pseudo")] public void LastOfType_Matches_LastOfItsOwnTagOnly() { var html = Html("p:last-of-type { background-color: #ff0000; }", "<div><p>1</p><p>2</p><span>s</span></div>"); @@ -229,6 +245,7 @@ public void LastOfType_Matches_LastOfItsOwnTagOnly() } [TestMethod] + [Spec("selectors-4", "#the-only-of-type-pseudo")] public void OnlyOfType_Matches_WhenNoOtherSiblingSharesTag() { var html = Html("p:only-of-type { background-color: #ff0000; }", "<div><span>s</span><p>1</p></div>"); @@ -237,6 +254,7 @@ public void OnlyOfType_Matches_WhenNoOtherSiblingSharesTag() } [TestMethod] + [Spec("selectors-4", "#the-only-of-type-pseudo")] public void OnlyOfType_DoesNotMatch_WhenAnotherSameTagSiblingExists() { var html = Html("p:only-of-type { background-color: #ff0000; }", "<div><p>1</p><p>2</p></div>"); @@ -245,6 +263,7 @@ public void OnlyOfType_DoesNotMatch_WhenAnotherSameTagSiblingExists() } [TestMethod] + [Spec("selectors-4", "#the-nth-of-type-pseudo")] public void NthOfType_2_MatchesSecondOfItsOwnTagOnly() { var html = Html("p:nth-of-type(2) { background-color: #ff0000; }", "<div><span>s</span><p>1</p><span>s2</span><p>2</p><p>3</p></div>"); @@ -256,6 +275,7 @@ public void NthOfType_2_MatchesSecondOfItsOwnTagOnly() } [TestMethod] + [Spec("selectors-4", "#the-nth-last-of-type-pseudo")] public void NthLastOfType_1_MatchesLastOfItsOwnTagOnly() { var html = Html("p:nth-last-of-type(1) { background-color: #ff0000; }", "<div><p>1</p><span>s</span><p>2</p></div>"); @@ -268,6 +288,7 @@ public void NthLastOfType_1_MatchesLastOfItsOwnTagOnly() // ── Compound forms ───────────────────────────────────────────────────────── [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void TagPlusNthChild_MatchesOnlyMatchingTagAtThatPosition() { var html = Html("p:nth-child(2n+1) { background-color: #ff0000; }", "<div><p>1</p><span>2</span><p>3</p></div>"); @@ -279,6 +300,7 @@ public void TagPlusNthChild_MatchesOnlyMatchingTagAtThatPosition() [TestMethod] [Spec("css21", "selector.html#first-child")] + [Spec("selectors-4", "#the-first-child-pseudo")] public void ClassPlusFirstChild_MatchesOnlyWhenBothConditionsHold() { var html = Html(".item:first-child { background-color: #ff0000; }", "<div><p class='item'>1</p><p class='item'>2</p></div>"); @@ -290,6 +312,7 @@ public void ClassPlusFirstChild_MatchesOnlyWhenBothConditionsHold() [TestMethod] [Spec("css21", "selector.html#first-child")] + [Spec("selectors-4", "#the-first-child-pseudo")] public void ClassPlusFirstChild_DoesNotMatch_WhenFirstChildLacksClass() { var html = Html(".item:first-child { background-color: #ff0000; }", "<div><p>1</p><p class='item'>2</p></div>"); @@ -331,6 +354,7 @@ public void NthLastColumn_1_MatchesLastColumnOnly() // ── CSS4 "of <selector>" extension (:nth-child()/:nth-last-child() only) ── [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void NthChildOfSelector_1_MatchesOnlyTheFirstMatchingSibling() { // Position is counted among the ".foo"-matching subset only, skipping non-".foo" siblings. @@ -346,6 +370,7 @@ public void NthChildOfSelector_1_MatchesOnlyTheFirstMatchingSibling() } [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void NthChildOfSelector_2nPlus1_AppliesFormulaWithinTheFilteredSubset() { var html = Html( @@ -361,6 +386,7 @@ public void NthChildOfSelector_2nPlus1_AppliesFormulaWithinTheFilteredSubset() } [TestMethod] + [Spec("selectors-4", "#the-nth-last-child-pseudo")] public void NthLastChildOfSelector_1_MatchesTheLastMatchingSibling() { var html = Html( @@ -374,6 +400,7 @@ public void NthLastChildOfSelector_1_MatchesTheLastMatchingSibling() } [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void NthChildOfSelector_ElementNotMatchingS_NeverMatchesRegardlessOfPosition() { // The structurally-first element doesn't itself have class "foo", so it can never satisfy @@ -388,6 +415,7 @@ public void NthChildOfSelector_ElementNotMatchingS_NeverMatchesRegardlessOfPosit } [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void NthChildOfSelector_CommaSeparatedList_MatchesEitherBranch() { var html = Html( @@ -401,13 +429,10 @@ public void NthChildOfSelector_CommaSeparatedList_MatchesEitherBranch() } [TestMethod] + [Spec("selectors-4", "#the-nth-of-type-pseudo")] public void OfSelector_OnDisallowedFunction_InvalidatesTheWholeRule() { - // The parser only allows "of S" on nth-child/nth-last-child - CSS spec has no such clause - // for nth-of-type/nth-last-of-type/nth-column/nth-last-column. Writing it anyway doesn't - // silently drop just the clause: it invalidates the entire enclosing selector (parses to - // UnknownSelector), so the whole rule matches nothing. Locking in this existing, correct - // parser behavior as a regression guard. + // "of S" only exists for :nth-child() and :nth-last-child(). var html = Html( "td:nth-of-type(1 of .foo) { background-color: #ff0000; }", "<table><tr><td class='foo'>A</td></tr></table>"); @@ -415,8 +440,6 @@ public void OfSelector_OnDisallowedFunction_InvalidatesTheWholeRule() Assert.AreEqual(RColor.Transparent, box.ActualBackgroundColor); } - // ── Helpers (mirrors CssSpecificityOrderingTests.cs conventions) ───────── - private static string Html(string css, string body) => $"<!DOCTYPE html><html><head><style>{css}</style></head><body>{body}</body></html>"; diff --git a/Source/Test/HtmlRenderer.Test/CssModules/Selectors4Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/Selectors4Tests.cs new file mode 100644 index 000000000..7447d983b --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/Selectors4Tests.cs @@ -0,0 +1,461 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class Selectors4Tests +{ + private static readonly RColor Red = RColor.FromArgb(255, 255, 0, 0); + private static readonly RColor Blue = RColor.FromArgb(255, 0, 0, 255); + + // ── 3 Selector Syntax and Structure ────────────────────────────────── + + [TestMethod] + [Spec("selectors-4", "#structure")] + public void ComplexSelector_ChainsCompoundSelectorsWithCombinators() + { + const string selector = "section.a > div + p#x[title]:last-child"; + Assert.IsTrue(Matches(selector, "<section class='a'><div></div><p id='x' title='t'>a</p></section>")); + Assert.IsFalse(Matches(selector, "<section><div></div><p id='x' title='t'>a</p></section>")); + Assert.IsFalse(Matches(selector, "<section class='a'><span></span><p id='x' title='t'>a</p></section>")); + Assert.IsFalse(Matches(selector, "<section class='a'><div></div><p id='x'>a</p></section>")); + } + + [TestMethod] + [Spec("selectors-4", "#pseudo-classes")] + [Spec("selectors-4", "#grammar")] + public void FunctionalPseudoClass_AllowsWhitespaceAroundItsArguments() + { + Assert.IsTrue(Matches("p:nth-child( 2 )", "<div><span></span><p id='x'>a</p></div>")); + Assert.IsTrue(Matches("p:not( .a )", "<p id='x'>a</p>")); + Assert.IsTrue(Matches("p:is( .a , .b )", "<p id='x' class='b'>a</p>")); + } + + [TestMethod] + [Spec("selectors-4", "#pseudo-classes")] + [Spec("selectors-4", "#grammar")] + public void WhitespaceAfterTheColon_InvalidatesTheRule() + { + Assert.IsFalse(IsRed(Document("p: first-child, p { background-color: #ff0000 }", "<div><p id='x'>a</p></div>"), "x")); + } + + [TestMethod] + [Spec("selectors-4", "#pseudo-element-syntax")] + [DataRow("p::marker, p")] + [DataRow("p::selection, p")] + public void DoubleColonPseudoElements_AreValid(string selector) + { + Assert.IsTrue(Matches(selector, "<p id='x'>a</p>")); + } + + [TestMethod] + [Spec("selectors-4", "#pseudo-element-syntax")] + [DataRow("p:marker, p")] + [DataRow("p:selection, p")] + public void SingleColonSyntax_IsOnlyAcceptedForTheLevel2PseudoElements(string selector) + { + Assert.IsFalse(Matches(selector, "<p id='x'>a</p>")); + } + + [TestMethod] + [Spec("selectors-4", "#pseudo-element-attachment")] + public void PseudoElement_BindsToTheElementMatchedByTheSelectorBeforeIt() + { + var (root, _) = LayoutHarness.Layout(Document("div a::before { content: 'N' }", + "<div><a id='in' href='#'>a</a></div><a id='out' href='#'>b</a>")); + + Assert.AreEqual("N", BeforeText(LayoutHarness.FindById(root, "in")!)); + Assert.AreEqual("", BeforeText(LayoutHarness.FindById(root, "out")!)); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#pseudo-element-attachment")] + [DataRow("::before { content: 'N' }")] + [DataRow(":before { content: 'N' }")] + public void PseudoElement_WithoutACompoundSelector_BindsToEveryElement(string css) + { + var (root, _) = LayoutHarness.Layout(Document(css, "<p id='x'>a</p>")); + + Assert.IsTrue(HasBefore(LayoutHarness.FindById(root, "x")!)); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#pseudo-element-states")] + public void UserActionPseudoClass_AfterAPseudoElement_IsValid() + { + Assert.IsTrue(Matches("p::before:hover, p", "<p id='x'>a</p>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#pseudo-element-states")] + [DataRow("p::before.a, p")] + [DataRow("p::before:first-child, p")] + public void OtherSimpleSelectors_AfterAPseudoElement_AreInvalid(string selector) + { + Assert.IsFalse(Matches(selector, "<p id='x'>a</p>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#sub-pseudo-elements")] + [DataRow("p::before::before, p")] + [DataRow("p::marker::marker, p")] + public void PseudoElement_CompoundedWithAnUndefinedSubPseudoElement_IsInvalid(string selector) + { + Assert.IsFalse(Matches(selector, "<p id='x'>a</p>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#pseudo-element-structure")] + [DataRow("p::first-letter em, p")] + [DataRow("p::before + span, p")] + public void Combinator_AfterAPseudoElementWithoutInternalStructure_IsInvalid(string selector) + { + Assert.IsFalse(Matches(selector, "<p id='x'>a</p>")); + } + + [TestMethod] + [Spec("selectors-4", "#case-sensitive")] + public void EscapedCharacters_LoseTheirSpecialMeaning() + { + Assert.IsTrue(Matches("#foo\\>a", "<p id='foo>a'>a</p>", "foo>a")); + } + + [TestMethod] + [Spec("selectors-4", "#case-sensitive")] + public void FormFeed_IsWhitespace_EmSpaceIsNot() + { + Assert.IsTrue(Matches("div\fp", "<div><p id='x'>a</p></div>")); + Assert.IsFalse(Matches(".a .b", "<div class='a'><p id='x' class='b'>a</p></div>")); + } + + [TestMethod] + [Spec("selectors-4", "#invalid")] + [Spec("selectors-4", "#grouping")] + [DataRow("h1, h2..foo, p")] + [DataRow("h1, p:foo")] + [DataRow("h1, p::foo")] + [DataRow("h1, p >")] + public void InvalidSelectorInAList_DropsTheWholeRule(string selector) + { + Assert.IsFalse(Matches(selector, "<p id='x'>a</p>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#invalid")] + [DataRow("h1, , p")] + [DataRow(", p")] + public void EmptySelectorInAList_DropsTheWholeRule(string selector) + { + Assert.IsFalse(Matches(selector, "<p id='x'>a</p>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#invalid")] + [Spec("selectors-4", "#type-nmsp")] + [Spec("selectors-4", "#attrnmsp")] + [DataRow("p, foo|p")] + [DataRow("p, [foo|title]")] + public void UndeclaredNamespacePrefix_InvalidatesTheSelector(string selector) + { + Assert.IsFalse(Matches(selector, "<p id='x' title='a'>a</p>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#invalid")] + public void UnknownWebkitPseudoElement_IsValidButMatchesNothing() + { + Assert.IsTrue(Matches("p::-webkit-foo, p", "<p id='x'>a</p>")); + } + + // ── 4 Logical Combinations ─────────────────────────────────────────── + + [TestMethod] + [Spec("selectors-4", "#matches")] + public void Is_TakesComplexSelectors() + { + Assert.IsTrue(Matches(":is(div p, .a)", "<div><p id='x'>a</p></div>")); + Assert.IsFalse(Matches(":is(div p, .a)", "<section><p id='x'>a</p></section>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#matches")] + [Spec("selectors-4", "#zero-matches")] + [Spec("selectors-4", "#forgiving-selector")] + [DataRow(":is(p, :foo)")] + [DataRow(":where(p, :foo)")] + public void IsAndWhere_DropInvalidArguments(string selector) + { + Assert.IsTrue(Matches(selector, "<p id='x'>a</p>")); + } + + [TestMethod] + [Spec("selectors-4", "#negation")] + public void Not_TakesAListOfComplexSelectors() + { + Assert.IsFalse(Matches("p:not(.a, .b)", "<p id='x' class='b'>a</p>")); + Assert.IsTrue(Matches("p:not(.a, .b)", "<p id='x' class='c'>a</p>")); + Assert.IsFalse(Matches("p:not(div p)", "<div><p id='x'>a</p></div>")); + Assert.IsTrue(Matches("p:not(div p)", "<section><p id='x'>a</p></section>")); + } + + [TestMethod] + [Spec("selectors-4", "#negation")] + public void Not_WithAnInvalidArgument_InvalidatesTheRule() + { + Assert.IsFalse(Matches("p:not(:foo), p", "<p id='x'>a</p>")); + Assert.IsFalse(Matches("p:not(::before), p", "<p id='x'>a</p>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#relational")] + public void Has_IsNotValidInsideHas() + { + Assert.IsFalse(Matches("div:has(:has(p)), div:has(p)", "<div id='x'><section><p>a</p></section></div>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#relational")] + public void PseudoElements_AreNotValidInsideHas() + { + Assert.IsFalse(Matches("div:has(::before), div:has(p)", "<div id='x'><p>a</p></div>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#relational")] + public void Has_IgnoresTextChildren() + { + Assert.IsFalse(Matches("div:has(*)", "<div id='x'>just text</div>")); + } + + // ── 5 Elemental selectors ──────────────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#type-nmsp")] + [DataRow("*|p", true)] + [DataRow("*|*", true)] + [DataRow("|p", false)] + public void NamespacePrefixes_OnTypeSelectors(string selector, bool matches) + { + Assert.AreEqual(matches, Matches(selector, "<p id='x'>a</p>")); + } + + // ── 6 Attribute selectors ──────────────────────────────────────────── + + [TestMethod] + [Spec("selectors-4", "#attribute-substrings")] + public void AttributeContains_MatchesASubstring() + { + Assert.IsTrue(Matches("[title*=b]", "<p id='x' title='abc'>a</p>")); + Assert.IsFalse(Matches("[title*=x]", "<p id='x' title='abc'>a</p>")); + Assert.IsFalse(Matches("[title*=b]", "<p id='x'>a</p>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#attribute-substrings")] + [DataRow("[title^='']")] + [DataRow("[title$='']")] + [DataRow("[title*='']")] + public void SubstringSelector_WithAnEmptyValue_MatchesNothing(string selector) + { + Assert.IsFalse(Matches(selector, "<p id='x' title='a'>a</p>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#attribute-substrings")] + [DataRow("[title^=AB]")] + [DataRow("[title$=BC]")] + [DataRow("[title*=B]")] + public void SubstringSelector_IsCaseSensitive(string selector) + { + Assert.IsFalse(Matches(selector, "<p id='x' title='abc'>a</p>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#attribute-case")] + [DataRow("[title=foo i]", "FOO")] + [DataRow("[title='foo' I]", "FOO")] + [DataRow("[title^=f i]", "FOO")] + [DataRow("[title=foo s]", "foo")] + public void CaseSensitivityFlags_Match(string selector, string value) + { + Assert.IsTrue(Matches(selector, $"<p id='x' title='{value}'>a</p>")); + } + + [TestMethod] + [Spec("selectors-4", "#attribute-case")] + public void SensitiveFlag_DoesNotMatchADifferentCase() + { + Assert.IsFalse(Matches("[title=foo s]", "<p id='x' title='FOO'>a</p>")); + } + + [TestMethod] + [Spec("selectors-4", "#attrnmsp")] + public void AttributeWithoutNamespace_MatchesWithAnEmptyPrefix() + { + Assert.IsTrue(Matches("[|title]", "<p id='x' title='a'>a</p>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#attrnmsp")] + public void AttributeWithAnyNamespace_Matches() + { + Assert.IsTrue(Matches("[*|title]", "<p id='x' title='a'>a</p>")); + } + + // ── 7 Linguistic Pseudo-classes ────────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#the-lang-pseudo")] + [DataRow(":lang(fr, de)", "de")] + [DataRow(":lang('*-CH')", "de-CH")] + [DataRow(":lang(de-DE)", "de-Latn-DE")] + public void Lang_TakesListsAndWildcards(string selector, string lang) + { + Assert.IsTrue(Matches(selector, $"<p id='x' lang='{lang}'>a</p>")); + } + + // ── 8 Location Pseudo-classes ──────────────────────────────────────── + + [TestMethod] + [Spec("selectors-4", "#the-target-pseudo")] + public void Target_IsValidButNeverMatches_WithoutAUrlFragment() + { + Assert.IsFalse(Matches(":target", "<p id='x'>a</p>")); + Assert.IsTrue(Matches("p, :target", "<p id='x'>a</p>")); + } + + // ── 9 User Action Pseudo-classes ───────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#the-hover-pseudo")] + [DataRow("p, a:hover")] + [DataRow("p:hover::before, p")] + public void HoverInOneAlternative_DoesntHoldBackTheOthers(string selector) + { + Assert.IsTrue(Matches(selector, "<p id='x'>a</p>")); + } + + // ── 13 Tree-Structural pseudo-classes ──────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#child-index")] + [Spec("selectors-4", "#typed-child-index")] + [DataRow("html:first-child p")] + [DataRow("html:only-child p")] + [DataRow("html:first-of-type p")] + [DataRow("html:only-of-type p")] + public void RootElement_IsItsOwnOnlySibling(string selector) + { + Assert.IsTrue(Matches(selector, "<p id='x'>a</p>")); + } + + [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] + public void NthChildOf_CountsComplexSelectors() + { + Assert.IsTrue(Matches("p:nth-child(2 of div p)", "<div><p>a</p><span></span><p id='x'>a</p></div>")); + Assert.IsFalse(Matches("p:nth-child(1 of .a, #x)", "<div><p class='a'>a</p><p id='x'>a</p></div>")); + } + + [TestMethod] + [Spec("selectors-4", "#the-nth-last-of-type-pseudo")] + [Spec("selectors-4", "#the-only-of-type-pseudo")] + public void TypedChildIndex_CountsOnlySiblingsOfTheSameType() + { + Assert.IsTrue(Matches("p:nth-last-of-type(2)", "<div><p id='x'>a</p><span></span><p>b</p><span></span></div>")); + Assert.IsTrue(Matches("p:only-of-type", "<div><span></span><p id='x'>a</p><span></span></div>")); + } + + // ── 14 Combinators ─────────────────────────────────────────────────── + + [TestMethod] + [Spec("selectors-4", "#general-sibling-combinators")] + [Spec("selectors-4", "#adjacent-sibling-combinators")] + public void SiblingCombinators_SkipTextAndChain() + { + Assert.IsTrue(Matches("h1 ~ pre", "<h1>a</h1>text<p>b</p><pre id='x'>c</pre>")); + Assert.IsTrue(Matches(".a ~ span ~ p", "<span></span><div class='a'></div><p></p><span></span><p id='x'>a</p>")); + Assert.IsTrue(Matches(".a ~ p + p", "<div class='a'></div><span></span><p>a</p><p id='x'>a</p>")); + } + + // ── 15 Specificity ─────────────────────────────────────────────────── + + [TestMethod] + [Spec("selectors-4", "#specificity-rules")] + [DataRow("p:not(.q, #y) { color: #ff0000 } p.a.b { color: #0000ff }", "<p id='x' class='a b'>a</p>")] + [DataRow("div:has(#y, .q) { color: #ff0000 } div.a.b { color: #0000ff }", "<div id='x' class='a b'><p id='y'>a</p></div>")] + [DataRow(".a.a { color: #ff0000 } .a { color: #0000ff }", "<p id='x' class='a'>a</p>")] + [DataRow("p.a { color: #0000ff } p:where(#x#x), .a.b { color: #ff0000 }", "<p id='x' class='a b'>a</p>")] + public void Specificity_OfLogicalCombinationsAndRepeatedSelectors(string css, string body) + { + Assert.AreEqual(Red, Box(css, body).ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#specificity-rules")] + [Spec("selectors-4", "#the-nth-child-pseudo")] + public void NthChildOf_AddsTheSpecificityOfItsSelectorList() + { + Assert.AreEqual(Red, Box(":nth-child(1 of #x) { color: #ff0000 } p.a.b.c { color: #0000ff }", + "<div><p id='x' class='a b c'>a</p></div>").ActualColor); + } + + [TestMethod] + [Spec("selectors-4", "#specificity-rules")] + public void NthChild_WithoutASelectorList_CountsAsOnePseudoClass() + { + Assert.AreEqual(Blue, Box(":nth-child(1) { color: #ff0000 } p.a { color: #0000ff }", + "<div><p id='x' class='a'>a</p></div>").ActualColor); + } + + private static string Document(string css, string body) => + $"<!DOCTYPE html><html><head><style>{css}</style></head><body>{body}</body></html>"; + + private static bool Matches(string selector, string body, string id = "x") => + IsRed(Document(selector + " { background-color: #ff0000 }", body), id); + + private static bool IsRed(string html, string id) + { + var (root, _) = LayoutHarness.Layout(html); + var box = LayoutHarness.FindById(root, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box.ActualBackgroundColor == Red; + } + + private static CssBox Box(string css, string body) + { + var (root, _) = LayoutHarness.Layout(Document(css, body)); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } + + private static bool HasBefore(CssBox box) => BeforeText(box).Length > 0; + + private static string BeforeText(CssBox box) => + string.Concat(box.Boxes.Where(b => b.IsBeforePseudoElement) + .SelectMany(LayoutHarness.Descendants).SelectMany(b => b.Words).Select(w => w.Text.Trim())); +} diff --git a/Source/Test/HtmlRenderer.Test/Html/SelectorMatchingTests.cs b/Source/Test/HtmlRenderer.Test/Html/SelectorMatchingTests.cs index 979536696..e4f33e42b 100644 --- a/Source/Test/HtmlRenderer.Test/Html/SelectorMatchingTests.cs +++ b/Source/Test/HtmlRenderer.Test/Html/SelectorMatchingTests.cs @@ -27,6 +27,7 @@ public void Attr_MatchesAttributeNamesCaseInsensitively() [DataRow("[TITLE]", "<p id=x title=a>a</p>")] [DataRow("[type=TEXT]", "<p id=x type=text>a</p>")] [DataRow("[align=LEFT]", "<p id=x align=left>a</p>")] + [Spec("selectors-4", "#case-sensitive")] public void ElementNames_AttributeNames_AndListedAttributeValues_MatchCaseInsensitively(string selector, string body) { Assert.IsTrue(Matches(selector, body)); @@ -58,6 +59,7 @@ public void IdsAndClasses_MatchCaseSensitivelyInNoQuirksMode(string selector) [TestMethod] [Spec("html", "semantics-other.html#pseudo-classes")] + [Spec("selectors-4", "#link")] public void Link_MatchesAnchorsWithAnHref() { Assert.IsTrue(Matches(":link", "<a id=x href=#a>a</a>")); diff --git a/Spec/selectors-4.yml b/Spec/selectors-4.yml index 5ce2fffb7..97ac902b2 100644 --- a/Spec/selectors-4.yml +++ b/Spec/selectors-4.yml @@ -5,349 +5,429 @@ snapshot: https://www.w3.org/TR/2026/WD-selectors-4-20260122/ sections: '#context': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown + status: n/a '#overview': title: 2 Selectors Overview - status: unknown + status: n/a '#syntax': title: 3 Selector Syntax and Structure - status: unknown + status: partial '#structure': title: 3.1 Structure and Terminology - status: unknown + status: supported '#data-model': title: 3.2 Data Model - status: unknown - '#featureless-elements': - title: 3.2.1 Featureless Elements - status: unknown + status: n/a '#scoping': title: 3.3 Scoped Selectors - status: unknown + status: n/a '#relative': title: 3.4 Relative Selectors - status: unknown + status: partial + gaps: + - "A leading combinator is dropped, ':has(> p)' matches like ':has(p)'" '#pseudo-classes': title: 3.5 Pseudo-classes - status: unknown + status: partial + gaps: + - Pseudo-class names are case-sensitive, see CSS 2.1 5.10 '#pseudo-elements': title: 3.6 Pseudo-elements - status: unknown + status: partial '#pseudo-element-syntax': title: 3.6.1 Syntax - status: unknown + status: partial + gaps: + - Pseudo-element names are case-sensitive, see CSS 2.1 5.10 '#pseudo-element-attachment': title: 3.6.2 Binding to the Document Tree - status: unknown + status: partial + gaps: + - "'::before' without a compound selector in front generates nothing, 'div ::before' and '*::before' work" '#pseudo-element-states': title: 3.6.3 Pseudo-classing Pseudo-elements - status: unknown + status: unsupported + gaps: + - "A user action pseudo-class after a pseudo-element, e.g. '::before:hover', invalidates the selector" + - "Other simple selectors after a pseudo-element, e.g. '::before.a', are accepted" '#sub-pseudo-elements': title: 3.6.4 Sub-pseudo-elements - status: unknown + status: unsupported + gaps: + - "Any pseudo-element is accepted after another, e.g. '::before::before', and never matches" '#pseudo-element-structure': title: 3.6.5 Internal Structure - status: unknown + status: unsupported + gaps: + - "A combinator after a pseudo-element, e.g. '::first-letter em', is accepted" '#case-sensitive': title: 3.7 Characters and case sensitivity - status: unknown + status: partial + gaps: + - Pseudo-class and pseudo-element names are case-sensitive, see CSS 2.1 5.10 + - Attribute values, classes and IDs are matched case-insensitively, see CSS 2.1 5.8.1, 5.8.3 and 5.9 '#namespaces': title: 3.8 Declaring Namespace Prefixes - status: unknown + status: n/a '#invalid': title: 3.9 Invalid Selectors and Error Handling - status: unknown + status: partial + gaps: + - An undeclared namespace prefix doesn't invalidate the selector + - "An empty selector in a list, e.g. 'h1, , p', is skipped instead of invalidating the list" + - "'::-webkit-*' pseudo-elements invalidate the selector instead of matching nothing (Appendix B)" '#legacy-aliasing': title: 3.10 Legacy Aliases - status: unknown + status: supported '#logical-combination': title: 4 Logical Combinations - status: unknown + status: partial '#grouping': title: 4.1 Selector Lists - status: unknown + status: supported '#matches': title: '4.2 The Matches-Any Pseudo-class: :is()' - status: unknown + status: partial + gaps: + - The argument isn't forgiving, an invalid selector in it invalidates the whole rule, see 16.1 '#negation': title: '4.3 The Negation (Matches-None) Pseudo-class: :not()' - status: unknown + status: partial + gaps: + - ":not() inside :not() invalidates the selector" '#zero-matches': title: '4.4 The Specificity-adjustment Pseudo-class: :where()' - status: unknown + status: partial + gaps: + - The argument isn't forgiving, an invalid selector in it invalidates the whole rule, see 16.1 '#relational': title: '4.5 The Relational Pseudo-class: :has()' - status: unknown + status: partial + gaps: + - "Leading combinators are dropped, see 3.4" + - ":has() and pseudo-elements inside :has() are accepted" + - "'*' inside :has() matches text, 'div:has(*)' matches a div with only text in it" '#elemental-selectors': title: 5 Elemental selectors - status: unknown + status: partial '#type-selectors': title: 5.1 Type (tag name) selector - status: unknown + status: supported '#the-universal-selector': title: 5.2 Universal selector - status: unknown + status: supported '#type-nmsp': title: 5.3 Namespaces in Elemental Selectors - status: unknown + status: unsupported + gaps: + - "'*|p' and 'ns|p' never match, '|p' matches every p" + - A default namespace doesn't restrict type selectors + - An undeclared prefix doesn't invalidate the selector '#the-defined-pseudo': title: '5.4 The Defined Pseudo-class: :defined' - status: unknown + status: unsupported + gaps: + - ":defined invalidates the selector, see HTML 4.16.3" '#attribute-selectors': title: 6 Attribute selectors - status: unknown + status: partial '#attribute-representation': title: 6.1 Attribute presence and value selectors - status: unknown + status: partial + gaps: + - Attribute values are matched case-insensitively, see CSS 2.1 5.8.1 '#attribute-substrings': title: 6.2 Substring matching attribute selectors - status: unknown + status: partial + gaps: + - "An empty value matches every element with the attribute, e.g. [title^='']" + - Values are matched case-insensitively '#attribute-case': title: 6.3 Case-sensitivity - status: unknown + status: unsupported + gaps: + - "The i and s flags, e.g. [title=foo i], invalidate the selector" '#attrnmsp': title: 6.4 Attribute selectors and namespaces - status: unknown + status: partial + gaps: + - "'[*|att]' never matches" + - An undeclared prefix doesn't invalidate the selector '#def-values': title: 6.5 Default attribute values in DTDs - status: unknown + status: n/a '#class-html': title: 6.6 Class selectors - status: unknown + status: partial + gaps: + - Class names are matched case-insensitively and only split on spaces, see CSS 2.1 5.8.3 '#id-selectors': title: 6.7 ID selectors - status: unknown + status: partial + gaps: + - IDs are matched case-insensitively, see CSS 2.1 5.9 '#linguistic-pseudos': title: 7 Linguistic Pseudo-classes - status: unknown + status: unsupported '#the-dir-pseudo': title: '7.1 The Directionality Pseudo-class: :dir()' - status: unknown + status: unsupported + gaps: + - ":dir() never matches, see HTML 4.16.3" '#the-lang-pseudo': title: '7.2 The Language Pseudo-class: :lang()' - status: unknown + status: unsupported + gaps: + - ":lang() never matches, see CSS 2.1 5.11.4" + - Lists and quoted ranges, e.g. ':lang(fr, de)' or ':lang("*-CH")', invalidate the selector '#location': title: 8 Location Pseudo-classes - status: unknown + status: partial '#the-any-link-pseudo': title: '8.1 The Hyperlink Pseudo-class: :any-link' - status: unknown + status: unsupported + gaps: + - ":any-link never matches" '#link': title: '8.2 The Link History Pseudo-classes: :link and :visited' - status: unknown + status: supported '#the-target-pseudo': title: '8.3 The Target Pseudo-class: :target' - status: unknown + status: supported '#the-scope-pseudo': title: '8.4 The Reference Element Pseudo-class: :scope' - status: unknown + status: unsupported + gaps: + - ":scope never matches, it should match the root element" '#useraction-pseudos': title: 9 User Action Pseudo-classes - status: unknown + status: unsupported '#the-hover-pseudo': title: '9.1 The Pointer Hover Pseudo-class: :hover' - status: unknown + status: unsupported + gaps: + - ":hover never applies, see CSS 2.1 5.11.3" + - "A rule with :hover in one alternative, e.g. 'p, a:hover', doesn't apply to the other alternatives either" + - "A :hover outside the subject, e.g. 'div:hover p', matches as if the element were always hovered, see CSS 2.1 5.11.3" '#the-active-pseudo': title: '9.2 The Activation Pseudo-class: :active' - status: unknown + status: unsupported + gaps: + - ":active never matches, see CSS 2.1 5.11.3" '#the-focus-pseudo': title: '9.3 The Input Focus Pseudo-class: :focus' - status: unknown + status: unsupported + gaps: + - ":focus never matches, see CSS 2.1 5.11.3" '#the-focus-visible-pseudo': title: '9.4 The Focus-Indicated Pseudo-class: :focus-visible' - status: unknown + status: unsupported + gaps: + - ":focus-visible never matches" '#the-focus-within-pseudo': title: '9.5 The Focus Container Pseudo-class: :focus-within' - status: unknown + status: unsupported + gaps: + - ":focus-within never matches" '#resource-pseudos': title: 10 Resource State Pseudo-classes - status: unknown + status: unsupported '#video-state': title: '10.1 Media Playback State: the :playing, :paused, and :seeking pseudo-classes' - status: unknown + status: unsupported + gaps: + - ":playing, :paused and :seeking invalidate the selector, see HTML 4.16.3" '#media-loading-state': title: '10.2 Media Loading State: the :buffering and :stalled pseudo-classes' - status: unknown + status: unsupported + gaps: + - ":buffering and :stalled invalidate the selector" '#sound-state': title: '10.3 Sound State: the :muted and :volume-locked pseudo-classes' - status: unknown + status: unsupported + gaps: + - ":muted and :volume-locked invalidate the selector, see HTML 4.16.3" '#display-state-pseudos': title: 11 Element Display State Pseudo-classes - status: unknown + status: unsupported '#open-state': title: '11.1 Collapse State: the :open pseudo-class' - status: unknown + status: unsupported + gaps: + - ":open invalidates the selector, see HTML 4.16.3" '#popover-open-state': title: '11.2 Popover State: the :popover-open pseudo-class' - status: unknown + status: unsupported + gaps: + - ":popover-open invalidates the selector" '#modal-state': title: '11.3 Modal (Exclusive Interaction) State: the :modal pseudo-class' - status: unknown + status: unsupported + gaps: + - ":modal invalidates the selector" '#fullscreen-state': title: '11.4 Fullscreen Presentation State: the :fullscreen pseudo-class' - status: unknown + status: unsupported + gaps: + - ":fullscreen invalidates the selector" '#pip-state': title: '11.5 Picture-in-Picture Presentation State: the :picture-in-picture pseudo-class' - status: unknown + status: unsupported + gaps: + - ":picture-in-picture invalidates the selector" '#input-pseudos': title: 12 The Input Pseudo-classes - status: unknown + status: unsupported '#input-states': title: 12.1 Input Control States - status: unknown + status: unsupported '#enableddisabled': title: 12.1.1 The :enabled and :disabled Pseudo-classes - status: unknown + status: unsupported + gaps: + - ":enabled and :disabled never match, see HTML 4.16.3" '#rw-pseudos': title: '12.1.2 The Mutability Pseudo-classes: :read-only and :read-write' - status: unknown + status: unsupported + gaps: + - ":read-only and :read-write never match, see HTML 4.16.3" '#placeholder': title: '12.1.3 The Placeholder-shown Pseudo-class: :placeholder-shown' - status: unknown + status: unsupported + gaps: + - ":placeholder-shown never matches, see HTML 4.16.3" '#autofill': title: '12.1.4 The Automatic Input Pseudo-class: :autofill' - status: unknown + status: unsupported + gaps: + - ":autofill and :-webkit-autofill invalidate the selector" '#the-default-pseudo': title: '12.1.5 The Default-option Pseudo-class: :default' - status: unknown + status: unsupported + gaps: + - ":default never matches, see HTML 4.16.3" '#input-value-states': title: 12.2 Input Value States - status: unknown + status: unsupported '#checked': title: '12.2.1 The Selected-option Pseudo-classes: :checked, :unchecked, and :indeterminate' - status: unknown + status: unsupported + gaps: + - ":checked, :unchecked and :indeterminate never match, see HTML 4.16.3" '#ui-validity': title: 12.3 Input Value-checking - status: unknown + status: unsupported '#validity-pseudos': title: '12.3.1 The Validity Pseudo-classes: :valid and :invalid' - status: unknown + status: unsupported + gaps: + - ":valid and :invalid never match, see HTML 4.16.3" '#range-pseudos': title: '12.3.2 The Range Pseudo-classes: :in-range and :out-of-range' - status: unknown + status: unsupported + gaps: + - ":in-range and :out-of-range never match, see HTML 4.16.3" '#opt-pseudos': title: '12.3.3 The Optionality Pseudo-classes: :required and :optional' - status: unknown + status: unsupported + gaps: + - ":required and :optional never match, see HTML 4.16.3" '#user-pseudos': title: '12.3.4 The User-interaction Pseudo-classes: :user-valid and :user-invalid' - status: unknown + status: unsupported + gaps: + - ":user-valid and :user-invalid invalidate the selector" '#structural-pseudos': title: 13 Tree-Structural pseudo-classes - status: unknown + status: partial '#the-root-pseudo': title: 13.1 :root pseudo-class - status: unknown + status: supported '#the-empty-pseudo': title: 13.2 :empty pseudo-class - status: unknown + status: unsupported + gaps: + - ":empty never matches" '#child-index': title: 13.3 Child-indexed Pseudo-classes - status: unknown + status: partial + gaps: + - "The root element never matches, e.g. 'html:first-child'" '#the-nth-child-pseudo': title: 13.3.1 :nth-child() pseudo-class - status: unknown + status: partial + gaps: + - "'of S' doesn't add the specificity of S, see 15" '#the-nth-last-child-pseudo': title: 13.3.2 :nth-last-child() pseudo-class - status: unknown + status: supported '#the-first-child-pseudo': title: 13.3.3 :first-child pseudo-class - status: unknown + status: supported '#the-last-child-pseudo': title: 13.3.4 :last-child pseudo-class - status: unknown + status: supported '#the-only-child-pseudo': title: 13.3.5 :only-child pseudo-class - status: unknown + status: supported '#typed-child-index': title: 13.4 Typed Child-indexed Pseudo-classes - status: unknown + status: partial + gaps: + - "The root element never matches, e.g. 'html:first-of-type'" '#the-nth-of-type-pseudo': title: 13.4.1 :nth-of-type() pseudo-class - status: unknown + status: supported '#the-nth-last-of-type-pseudo': title: 13.4.2 :nth-last-of-type() pseudo-class - status: unknown + status: supported '#the-first-of-type-pseudo': title: 13.4.3 :first-of-type pseudo-class - status: unknown + status: supported '#the-last-of-type-pseudo': title: 13.4.4 :last-of-type pseudo-class - status: unknown + status: supported '#the-only-of-type-pseudo': title: 13.4.5 :only-of-type pseudo-class - status: unknown + status: supported '#combinators': title: 14 Combinators - status: unknown + status: partial '#descendant-combinators': title: 14.1 Descendant combinator ( ) - status: unknown + status: partial + gaps: + - "Combinator chains don't backtrack, see CSS 2.1 5.2" '#child-combinators': title: 14.2 Child combinator (>) - status: unknown + status: supported '#adjacent-sibling-combinators': title: 14.3 Next-sibling combinator (+) - status: unknown + status: supported '#general-sibling-combinators': title: 14.4 Subsequent-sibling combinator (~) - status: unknown + status: supported '#specificity-rules': title: 15 Calculating a selector’s specificity - status: unknown + status: partial + gaps: + - ":nth-child(An+B of S) and :nth-last-child() count as one pseudo-class, without the specificity of S" '#grammar': title: 16 Grammar - status: unknown + status: partial + gaps: + - "Attribute modifiers are rejected, see 6.3" '#forgiving-selector': title: 16.1 <forgiving-selector-list> and <forgiving-relative-selector-list> - status: unknown + status: unsupported + gaps: + - "An invalid argument of :is() or :where() invalidates the whole rule instead of being dropped" '#api-hooks': title: 17 API Hooks - status: unknown - '#parse-selector': - title: 17.1 Parse A Selector - status: unknown - '#parse-relative-selector': - title: 17.2 Parse A Relative Selector - status: unknown - '#match-against-element': - title: 17.3 Match a Selector Against an Element - status: unknown - '#match-against-pseudo-element': - title: 17.4 Match a Selector Against a Pseudo-element - status: unknown - '#match-against-tree': - title: 17.5 Match a Selector Against a Tree - status: unknown + status: n/a '#changes': title: 18 Changes - status: unknown - '#changes-2022-11': - title: 18.1 Changes since the 11 November 2022 Working Draft - status: unknown - '#changes-2022-05': - title: 18.2 Changes since the 7 May 2022 Working Draft - status: unknown - '#changes-2018-11': - title: 18.3 Changes since the 21 November 2018 Working Draft - status: unknown - '#changes-2018-02': - title: 18.4 Changes since the 2 February 2018 Working Draft - status: unknown - '#changes-2013': - title: 18.5 Changes since the 2 May 2013 Working Draft - status: unknown - '#changes-2012': - title: 18.6 Changes since the 23 August 2012 Working Draft - status: unknown - '#changes-2011': - title: 18.7 Changes since the 29 September 2011 Working Draft - status: unknown - '#changes-level-3': - title: 18.8 Changes Since Level 3 - status: unknown + status: n/a '#acknowledgements': title: 19 Acknowledgements - status: unknown + status: n/a From 4c178c7ef491c044feb7cc417913b093d9bbf695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:11:19 +0100 Subject: [PATCH 060/150] Verify and document CSS Pseudo-Elements Level 4 compliance --- .../HighlightPaintIntegrationTests.cs | 174 ++++++++++++++++++ .../Content/GeneratedContentTests.cs | 4 + .../Css/Selectors/Css21SelectorTests.cs | 3 + .../CssModules/PseudoElements4Tests.cs | 114 ++++++++++++ Spec/css-pseudo-4.yml | 107 ++++++----- 5 files changed, 359 insertions(+), 43 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/HighlightPaintIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/PseudoElements4Tests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/HighlightPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/HighlightPaintIntegrationTests.cs new file mode 100644 index 000000000..ee2b4b254 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/HighlightPaintIntegrationTests.cs @@ -0,0 +1,174 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[TestClass] +public sealed class HighlightPaintIntegrationTests +{ + private static readonly RColor Text = RColor.FromArgb(10, 20, 30); + private static readonly RColor Back = RColor.FromArgb(40, 50, 60); + private static readonly RColor DefaultBack = RColor.FromArgb(0xa9, 0x33, 0x99, 0xff); + + [TestMethod] + [Spec("css-pseudo-4", "#highlight-selectors")] + [Spec("css-pseudo-4", "#highlight-styling")] + [Spec("css-pseudo-4", "#highlight-backgrounds")] + [Spec("css-pseudo-4", "#highlight-text")] + public void Selection_PaintsTheBackgroundAndTheTextInItsColors() + { + var g = PaintSelected("::selection { color: rgb(10,20,30); background-color: rgb(40,50,60) }", + "<div id='b'>x</div>"); + + Assert.IsTrue(Rects(g).Any(r => r.Color == Back)); + Assert.AreEqual(Text, g.DrawStringCalls.Single().Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-pseudo-4.yml")] + [Spec("css-pseudo-4", "#highlight-ua-styles")] + public void WithoutSelectionRules_TheDefaultHighlightUsesHighlightAndHighlightText() + { + var (root, container) = Layout("", "<div id='b' style='background-color:Highlight; color:HighlightText'>x</div>"); + var reference = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + var highlight = Rects(reference).Single().Color; + var highlightText = reference.DrawStringCalls.Single().Color; + + var g = PaintSelected("", "<div id='b' style='color:rgb(10,20,30)'>x</div>"); + + Assert.IsTrue(Rects(g).Any(r => r.Color == highlight)); + Assert.AreEqual(highlightText, g.DrawStringCalls.Single().Color); + } + + [TestMethod] + [Spec("css-pseudo-4", "#highlight-bounds")] + public void SelectionBackground_CoversTheWordUpToTheLineBoxEdges() + { + var (root, container) = Layout("::selection { background-color: rgb(40,50,60) }", + "<div id='b' style='line-height:40px'>x</div>"); + SelectAll(container); + var box = PaintHarness.FindById(root, "b")!; + var g = PaintHarness.PaintBox(container, box); + + var rect = Rects(g).Single(r => r.Color == Back); + var text = g.DrawStringCalls.Single(); + Assert.IsLessThanOrEqualTo(text.Point.X, rect.X); + Assert.IsGreaterThanOrEqualTo(text.Point.X + text.Size.Width, rect.X + rect.Width); + Assert.IsLessThanOrEqualTo(text.Point.Y, rect.Y); + Assert.IsGreaterThanOrEqualTo(text.Point.Y + text.Size.Height, rect.Y + rect.Height); + Assert.IsLessThanOrEqualTo(40, rect.Height); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-pseudo-4.yml")] + [Spec("css-pseudo-4", "#paired-defaults")] + public void AuthorSelectionColor_SuppressesTheDefaultBackground() + { + var g = PaintSelected("::selection { color: rgb(10,20,30) }", "<div id='b'>x</div>"); + + Assert.IsFalse(Rects(g).Any(r => r.Color == DefaultBack)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-pseudo-4.yml")] + [Spec("css-pseudo-4", "#highlight-cascade")] + public void SelectionRule_OnlyAppliesToItsOriginatingElement() + { + var g = PaintSelected("p.w::selection { background-color: rgb(40,50,60) }", + "<p id='b'>x</p><p class='w'>y</p>"); + + Assert.IsFalse(Rects(g).Any(r => r.Color == Back)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-pseudo-4.yml")] + [Spec("css-pseudo-4", "#highlight-text")] + public void TextDecorations_AreRedrawnInTheSelectionColor() + { + var g = PaintSelected("::selection { color: rgb(10,20,30) }", + "<div id='b' style='text-decoration:underline; color:rgb(200,0,0)'>x</div>"); + + var lines = g.Log.OfType<RecordingGraphics.DrawLineCall>().ToList(); + Assert.IsNotEmpty(lines); + Assert.IsTrue(lines.All(l => l.Color == Text)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-pseudo-4.yml")] + [Spec("css-pseudo-4", "#highlight-styling")] + public void SelectionTextDecoration_IsPainted() + { + var g = PaintSelected("::selection { text-decoration: underline; text-decoration-color: rgb(10,20,30) }", + "<div id='b'>x</div>"); + + Assert.IsTrue(g.Log.OfType<RecordingGraphics.DrawLineCall>().Any(l => l.Color == Text)); + } + + [TestMethod] + [Spec("css-pseudo-4", "#highlight-replaced")] + public void SelectedImage_GetsASemiTransparentWash() + { + var (root, container) = Layout("::selection { background-color: rgb(40,50,60) }", + $"<div id='b'><img src='{TestImages.Png(20, 20)}'></div>", images: true); + SelectAll(container); + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + + var wash = Rects(g).Single(r => r.Color.R == 40 && r.Color.G == 50 && r.Color.B == 60); + Assert.IsLessThan(255, (int)wash.Color.A); + } + + private static RecordingGraphics PaintSelected(string css, string body) + { + var (root, container) = Layout(css, body); + SelectAll(container); + return PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + } + + private static (CssBox Root, HtmlContainerInt Container) Layout(string css, string body, bool images = false) + { + var container = new HtmlContainerInt(new MockAdapter()) + { + MaxSize = new RSize(1000, 4000), + Location = RPoint.Empty, + PageSize = new RSize(1000, 4000), + AvoidAsyncImagesLoading = images, + AvoidImagesLateLoading = images + }; + + container.SetHtml($"<html><head><style>{css}</style></head><body style='margin:0'>{body}</body></html>"); + using var layoutGraphics = new RecordingGraphics(); + container.PerformLayout(layoutGraphics); + + return (container.Root!, container); + } + + private static void SelectAll(HtmlContainerInt container) => + container.HandleKeyDown(new MockControl(new MockAdapter()), new RKeyEvent(true, true, false)); + + private static List<RecordingGraphics.DrawRectCall> Rects(RecordingGraphics g) => + g.Log.OfType<RecordingGraphics.DrawRectCall>().ToList(); + + private sealed class MockControl(RAdapter adapter) : RControl(adapter) + { + public override bool LeftMouseButton => false; + public override bool RightMouseButton => false; + public override RPoint MouseLocation => RPoint.Empty; + public override void SetCursorDefault() { } + public override void SetCursorHand() { } + public override void SetCursorIBeam() { } + public override void DoDragDropCopy(object dragDropData) { } + + public override void MeasureString(string str, RFont font, double maxWidth, out int charFit, out double charFitWidth) + { + charFit = str.Length; + charFitWidth = 0; + } + + public override void Invalidate() { } + } +} diff --git a/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs index 20a090c55..47ac61d3f 100644 --- a/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs +++ b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs @@ -11,6 +11,7 @@ public sealed class GeneratedContentTests [TestMethod] [Spec("css21", "generate.html#before-after-content")] + [Spec("css-pseudo-4", "#generated-content")] public void BeforeAndAfter_AreTheFirstAndLastChildOfTheElement() { var x = Box("<p id=x><span>a</span></p><style>#x::before { content: 'B' } #x::after { content: 'A' }</style>"); @@ -23,6 +24,7 @@ public void BeforeAndAfter_AreTheFirstAndLastChildOfTheElement() [TestMethod] [Spec("css21", "generate.html#before-after-content")] + [Spec("css-pseudo-4", "#treelike")] public void PseudoElement_InheritsInheritedPropertiesOnly() { var before = Before("<p id=x style='color:red;border:2px solid black'>a</p><style>#x::before { content: 'B' }</style>"); @@ -36,6 +38,7 @@ public void PseudoElement_InheritsInheritedPropertiesOnly() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "generate.html#before-after-content")] + [Spec("css-pseudo-4", "#generated-content")] public void BlockPseudoElement_IsLaidOutAsABlockInsideTheElement() { var inline = Box("<div id=x>Text</div><style>#x::before { content: 'Some' }</style>"); @@ -49,6 +52,7 @@ public void BlockPseudoElement_IsLaidOutAsABlockInsideTheElement() [Spec("css21", "generate.html#before-after-content")] [DataRow("none")] [DataRow("normal")] + [Spec("css-pseudo-4", "#generated-content")] public void PseudoElementWithoutContent_IsNotGenerated(string content) { var plain = Box("<div id=x>Text</div>"); diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs index 030c967e9..a74b2047d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs @@ -454,6 +454,7 @@ public void Lang_MatchesElementsInThatLanguage() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "selector.html#first-line-pseudo")] + [Spec("css-pseudo-4", "#first-line-pseudo")] public void FirstLine_StylesTheFirstFormattedLine() { var p = Layout("p:first-line { color: #ff0000 }", "<p id='x'>Hello</p>"); @@ -464,6 +465,7 @@ public void FirstLine_StylesTheFirstFormattedLine() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "selector.html#first-letter")] + [Spec("css-pseudo-4", "#first-letter-pseudo")] public void FirstLetter_StylesTheFirstLetter() { var p = Layout("p:first-letter { color: #ff0000 }", "<p id='x'>Hello</p>"); @@ -476,6 +478,7 @@ public void FirstLetter_StylesTheFirstLetter() [TestMethod] [Spec("css21", "selector.html#before-and-after")] [Spec("selectors-4", "#pseudo-element-syntax")] + [Spec("css-pseudo-4", "#css2-compat")] public void BeforeAndAfter_WithASingleColon_GenerateContent() { var p = Layout("p:before { content: 'Note: ' } p:after { content: '!' }", "<p id='x'>a</p>"); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/PseudoElements4Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/PseudoElements4Tests.cs new file mode 100644 index 000000000..60a181b55 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/PseudoElements4Tests.cs @@ -0,0 +1,114 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class PseudoElements4Tests +{ + private static readonly RColor Red = RColor.FromArgb(255, 255, 0, 0); + private static readonly RColor Green = RColor.FromArgb(255, 0, 255, 0); + + [TestMethod] + [Spec("css-pseudo-4", "#first-letter-pseudo")] + [Spec("css-pseudo-4", "#first-line-pseudo")] + [DataRow("p::first-letter, p")] + [DataRow("p::first-line, p")] + public void FirstLetterAndFirstLine_AreValidSelectors(string selector) + { + Assert.AreEqual(Red, Box($"{selector} {{ color: #ff0000 }}", "<p id='x'>a</p>").ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-pseudo-4.yml")] + [Spec("css-pseudo-4", "#first-letter-pseudo")] + [DataRow("p::first-letter::prefix, p")] + [DataRow("p::first-letter::suffix, p")] + public void PrefixAndSuffix_AreValidSubPseudoElementsOfFirstLetter(string selector) + { + Assert.AreEqual(Red, Box($"{selector} {{ color: #ff0000 }}", "<p id='x'>a</p>").ActualColor); + } + + [TestMethod] + [Spec("css-pseudo-4", "#highlight-selectors")] + [Spec("css-pseudo-4", "#highlight-styling")] + public void Selection_SetsTheSelectionColors() + { + var (_, container) = LayoutHarness.Layout( + Document("::selection { color: #ff0000; background-color: #00ff00 }", "<p id='x'>a</p>")); + + Assert.AreEqual(Red, container.SelectionForeColor); + Assert.AreEqual(Green, container.SelectionBackColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-pseudo-4.yml")] + [Spec("css-pseudo-4", "#highlight-selectors")] + [DataRow("p::search-text, p")] + [DataRow("p::target-text, p")] + [DataRow("p::spelling-error, p")] + [DataRow("p::grammar-error, p")] + [DataRow("p::highlight(a), p")] + public void OtherHighlightPseudoElements_AreValidSelectors(string selector) + { + Assert.AreEqual(Red, Box($"{selector} {{ color: #ff0000 }}", "<p id='x'>a</p>").ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-pseudo-4.yml")] + [Spec("css-pseudo-4", "#generated-content")] + public void ReplacedElement_GetsNoBeforeOrAfter() + { + var (root, _) = LayoutHarness.Layout(Document("img::before, img::after { content: 'N' }", + "<img id='x' src='x.png'>")); + + Assert.AreEqual("", GeneratedText(LayoutHarness.FindById(root, "x")!)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-pseudo-4.yml")] + [Spec("css-pseudo-4", "#marker-pseudo")] + public void Marker_IsStyledByMarkerRules() + { + var li = Box("li::marker { color: #ff0000 } li { color: #00ff00 }", "<ul><li id='x'>a</li></ul>"); + + Assert.IsNotNull(li.ListItemBox); + Assert.AreEqual(Red, li.ListItemBox.ActualColor); + } + + [TestMethod] + [Spec("css-pseudo-4", "#marker-pseudo")] + public void Marker_IsAValidSelector() + { + Assert.AreEqual(Red, Box("li::marker, li { color: #ff0000 }", "<ul><li id='x'>a</li></ul>").ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-pseudo-4.yml")] + [Spec("css-pseudo-4", "#placeholder-pseudo")] + [Spec("css-pseudo-4", "#file-selector-button-pseudo")] + [Spec("css-pseudo-4", "#details-content-pseudo")] + [DataRow("input::placeholder, p")] + [DataRow("input::file-selector-button, p")] + [DataRow("details::details-content, p")] + public void FormAndDetailsPseudoElements_AreValidSelectors(string selector) + { + Assert.AreEqual(Red, Box($"{selector} {{ color: #ff0000 }}", "<p id='x'>a</p>").ActualColor); + } + + private static string Document(string css, string body) => + $"<!DOCTYPE html><html><head><style>{css}</style></head><body>{body}</body></html>"; + + private static CssBox Box(string css, string body) + { + var (root, _) = LayoutHarness.Layout(Document(css, body)); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } + + private static string GeneratedText(CssBox box) => + string.Concat(box.Boxes.Where(b => b.IsPseudoElement) + .SelectMany(LayoutHarness.Descendants).SelectMany(b => b.Words).Select(w => w.Text.Trim())); +} diff --git a/Spec/css-pseudo-4.yml b/Spec/css-pseudo-4.yml index 07b72a788..60e073395 100644 --- a/Spec/css-pseudo-4.yml +++ b/Spec/css-pseudo-4.yml @@ -5,112 +5,133 @@ snapshot: https://www.w3.org/TR/2025/WD-css-pseudo-4-20250627/ sections: '#intro': title: 1 Introduction - status: unknown + status: n/a '#typographic-pseudos': title: 2 Typographic Pseudo-elements - status: unknown + status: parsed-only '#first-line-pseudo': title: '2.1 First-Line Text: the ::first-line pseudo-element' - status: unknown + status: parsed-only + gaps: + - "::first-line is accepted but its declarations never apply, see CSS 2.1 5.12.1" '#first-text-line': title: 2.1.1 Finding the First Formatted Line - status: unknown + status: unsupported '#first-line-styling': title: 2.1.2 Styling the ::first-line Pseudo-element - status: unknown + status: unsupported '#first-line-inheritance': title: 2.1.3 Inheritance and the ::first-line Pseudo-element - status: unknown + status: unsupported '#first-letter-pseudo': title: '2.2 First-Letter Text: ::first-letter pseudo-element and its ::prefix and ::suffix children' - status: unknown + status: parsed-only + gaps: + - "::first-letter is accepted but its declarations never apply, see CSS 2.1 5.12.2" + - "::prefix and ::suffix invalidate the selector" '#first-letter-pattern': title: 2.2.1 First Letters and Associated Punctuation - status: unknown + status: unsupported '#first-letter-application': title: 2.2.2 Finding the First-Letter Text - status: unknown + status: unsupported '#first-letter-tree': title: 2.2.3 Inheritance and Box Tree Structure of the First-Letter Pseudo-elements - status: unknown + status: unsupported '#first-letter-styling': title: 2.2.4 Styling the First-Letter Pseudo-elements - status: unknown + status: unsupported '#highlight-pseudos': title: 3 Highlight Pseudo-elements - status: unknown + status: partial '#highlight-selectors': title: '3.1 Selecting Highlighted Content: the ::selection, ::search-text, ::target-text, ::spelling-error, ::grammar-error, and ::highlight() pseudo-elements' - status: unknown + status: partial + gaps: + - "::search-text, ::target-text, ::spelling-error, ::grammar-error and ::highlight() invalidate the selector" '#highlight-styling': title: 3.2 Styling Highlights - status: unknown + status: partial + gaps: + - Only color and background-color apply, text-decoration, text-shadow and custom properties are ignored '#highlight-ua-styles': title: 3.3 Default UA Styles - status: unknown + status: unsupported + gaps: + - "The default ::selection is a semi-transparent blue over the text's own color instead of Highlight and HighlightText" '#paired-defaults': title: 3.3.1 Paired Defaults - status: unknown + status: unsupported + gaps: + - "The default background is painted even when ::selection only sets color" '#highlight-bounds': title: 3.4 Area of a Highlight - status: unknown + status: supported '#highlight-cascade': title: 3.5 Cascading and Per-Element Highlight Styles - status: unknown + status: unsupported + gaps: + - "::selection colors apply to the whole document, the part of the selector before ::selection is ignored" '#highlight-painting': title: 3.6 Painting the Highlight - status: unknown + status: partial '#highlight-backgrounds': title: 3.6.1 Backgrounds - status: unknown + status: supported '#highlight-shadows': title: 3.6.2 Shadows - status: unknown + status: unsupported + gaps: + - text-shadow on ::selection is ignored '#highlight-text': title: 3.6.3 Text and Text Decorations - status: unknown + status: partial + gaps: + - Text decorations keep their own color instead of the ::selection color '#highlight-replaced': title: 3.6.4 Replaced Elements - status: unknown + status: supported '#highlight-security': title: 3.7 Security Considerations for Highlighting - status: unknown + status: n/a '#treelike': title: 4 Tree-Abiding Pseudo-elements - status: unknown + status: partial '#generated-content': title: '4.1 Generated Content Pseudo-elements: ::before and ::after' - status: unknown + status: partial + gaps: + - "A block-level ::before or ::after gets no height, and one with 'content: none' still generates a box, see CSS 2.1 12.1" + - "A replaced element like img gets ::before and ::after content" '#marker-pseudo': title: '4.2 List Markers: the ::marker pseudo-element' - status: unknown + status: parsed-only + gaps: + - "::marker is accepted but its declarations never apply, the marker inherits from the list item" '#placeholder-pseudo': title: '4.3 Placeholder Input: the ::placeholder pseudo-element' - status: unknown + status: unsupported + gaps: + - "::placeholder invalidates the selector" '#element-like': title: 5 Element-backed Pseudo-Elements - status: unknown + status: unsupported '#file-selector-button-pseudo': title: '5.1 File Selector Button: the ::file-selector-button pseudo-element' - status: unknown + status: unsupported + gaps: + - "::file-selector-button invalidates the selector" '#details-content-pseudo': title: '5.2 Expandable contents of details element: the ::details-content pseudo-element' - status: unknown + status: unsupported + gaps: + - "::details-content invalidates the selector" '#interactions': title: 6 Overlapping Pseudo-element Interactions - status: unknown + status: n/a '#cssom': title: 7 Additions to the CSS Object Model - status: unknown - '#CSSPseudoElement-interface': - title: 7.1 CSSPseudoElement Interface - status: unknown - '#window-interface': - title: 7.2 pseudo() method of the Element interface - status: unknown - '#getComputedStyle': - title: 7.3 getComputedStyle() - status: unknown + status: n/a '#css2-compat': title: 8 Compatibility Syntax - status: unknown + status: supported From b9ff5100589493a4629a6a19ebfef995fbeefcf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:04:38 +0100 Subject: [PATCH 061/150] Verify and document CSS Values and Units Level 4 compliance --- .../Core/CssEngine/Calc/CalcCategory.cs | 21 +- .../Core/CssEngine/Calc/CalcEvaluator.cs | 27 +- .../Core/CssEngine/Calc/CalcNode.cs | 4 +- .../Core/CssEngine/Calc/CalcParser.cs | 19 +- .../Core/CssEngine/Calc/CalcSerializer.cs | 21 +- .../Core/CssEngine/Calc/CalcTypeChecker.cs | 10 +- .../Core/CssEngine/Calc/CalcValue.cs | 4 +- .../Core/CssEngine/Calc/CalcValueConverter.cs | 8 +- .../Core/CssEngine/Values/Length.cs | 17 +- .../HtmlRenderer/Core/Parse/CssValueParser.cs | 23 +- .../BackgroundPaintIntegrationTests.cs | 3 + .../Css/CalcEvaluatorTests.cs | 21 +- .../Css/CalcPropertyTests.cs | 61 ++- .../Css/SyntaxAndDataTypesTests.cs | 22 + .../CssModules/CssValuesTests.cs | 496 ++++++++++++++++++ Spec/css-values-4.yml | 235 +++++---- 16 files changed, 734 insertions(+), 258 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssValuesTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcCategory.cs b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcCategory.cs index ddf28cfd8..46339dd5d 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcCategory.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcCategory.cs @@ -3,9 +3,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The CSS calc() type-checking category a (sub-)expression resolves to. Flags so that - /// "Length | Percentage" can represent the combined &lt;length-percentage&gt; type once a - /// length and a percentage have been added/subtracted together. + /// The type a calc() expression resolves to (CSS Values 4 §10.9). Flags, so a sum of a length and + /// a percentage is Length | Percentage. /// </summary> [Flags] internal enum CalcCategory @@ -14,24 +13,12 @@ internal enum CalcCategory Length = 2, Percentage = 4, LengthPercentage = Length | Percentage, - - /// <summary> - /// Unlike Length/Percentage, Angle never needs layout context to resolve (deg/grad/rad/turn all - /// convert via fixed constants), so a valid Angle-category expression always folds fully at - /// Layer A parse time - see CalcSerializer. - /// </summary> Angle = 8, - /// <summary> - /// &lt;time&gt; (s/ms). Like Angle, it resolves without layout context. Currently produced only by - /// <c>@property</c> <c>syntax: "&lt;time&gt;"</c> validation - no layout property consumes a time - /// calc(), so it never reaches CalcEvaluator during a render. - /// </summary> + /// <summary>Only produced by @property syntax validation.</summary> Time = 16, - /// <summary> - /// &lt;resolution&gt; (dpi/dpcm/dppx). Context-free like Angle/Time; same @property-only provenance. - /// </summary> + /// <summary>Only produced by @property syntax validation.</summary> Resolution = 32 } } diff --git a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcEvaluator.cs b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcEvaluator.cs index 2ec7b8d08..ec72254a2 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcEvaluator.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcEvaluator.cs @@ -4,9 +4,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The layout-time context needed to resolve relative units within a calc-family expression: how - /// many pixels 1em/1rem are, and what 100% means for this particular call site (e.g. containing-block - /// width for <c>width</c>, parent font-size for <c>font-size</c>'s em-relative resolution). + /// What em, rem and 100% are in pixels where a calc() is resolved. /// </summary> internal readonly struct CalcContext { @@ -25,21 +23,16 @@ public CalcContext(double hundredPercent, double emFactor, double remFactor, boo public bool FontAdjust { get; } /// <summary> - /// Mirrors ParseLength's returnPoints parameter: when the caller's em/rem/percent factors are - /// already expressed in points (as CssBoxProperties.FontSize's caller does), a bare <c>pt</c> leaf - /// must bypass the normal pt-&gt;px factor rather than being converted into this already-points space. + /// The factors are in points instead of pixels, so a pt value is taken as is, like + /// <see cref="Parse.CssValueParser.ParseLength(string, double, double, string, bool, bool)"/>. /// </summary> public bool ReturnPoints { get; } } /// <summary> - /// Evaluates a validated calc-family AST to a pixel-space number. This is the one place calc() - /// numbers actually get computed — called only from Layer B (<c>CssValueParser.ParseLength</c>), - /// since only layout has the <see cref="CalcContext"/> a percentage/em/rem leaf needs to resolve. - /// Reuses <see cref="Length.ToPixels"/> for every leaf, so no unit-conversion arithmetic is duplicated - /// here. A null result signals a divide-by-zero; per the type-checker's rules every legal divisor is - /// a constant that Layer A already folds and rejects at validation time, so in practice this is a - /// defensive fallback rather than a load-bearing check. + /// Evaluates a type-checked calc() tree at layout time, lengths to pixels, angles to radians, + /// times to milliseconds and resolutions to dppx. Null on a division by zero, which the type + /// checker already rejects. /// </summary> internal static class CalcEvaluator { @@ -62,20 +55,12 @@ internal static class CalcEvaluator .ToPixels(context.EmFactor, context.RemFactor, context.HundredPercent, context.FontAdjust); case AngleCalcNode angle: - // An angle leaf evaluates to radians (the canonical angle unit), so an angle-typed - // calc such as `calc(1turn * 0.35)` yields 0.35 turn in radians (a Number leaf stays a - // unitless scalar). Length calc()s never contain an angle leaf, so this never perturbs - // length evaluation; the only current caller is conic-gradient stop-position parsing. return new Angle((float)angle.Value, angle.Unit).ToRadian(); case TimeCalcNode time: - // Canonical time unit is milliseconds. No layout property evaluates a time calc() today - // (these nodes only arise from @property `syntax: "<time>"` validation, which type-checks - // rather than evaluates), so this case is defensive completeness paralleling the angle leaf. return new Time((float)time.Value, time.Unit).ToMilliseconds(); case ResolutionCalcNode resolution: - // Canonical resolution unit is dots-per-pixel; same @property-only provenance as TimeCalcNode. return new Resolution((float)resolution.Value, resolution.Unit).ToDotsPerPixel(); case UnaryCalcNode unary: diff --git a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcNode.cs b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcNode.cs index de42bde8a..0198713fd 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcNode.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcNode.cs @@ -3,9 +3,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// AST node for a parsed calc()/min()/max()/clamp() expression. Shared between Layer A (the CSS - /// object model, which type-checks and canonicalizes it) and Layer B (layout, which evaluates it to - /// a pixel number using context Layer A never has, such as containing-block width or font size). + /// A node of a parsed calc(), min(), max() or clamp() expression (CSS Values 4 §10.10). /// </summary> internal abstract class CalcNode { diff --git a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcParser.cs b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcParser.cs index 419614196..3b3b6d591 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcParser.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcParser.cs @@ -3,14 +3,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Recursive-descent parser for calc()/min()/max()/clamp() expressions, shared by Layer A (which - /// calls it during property-value conversion) and Layer B (which re-parses already-validated - /// canonical text at layout time — see CalcEvaluator). Grammar: - /// <c>&lt;calc-sum&gt; := &lt;calc-product&gt; (('+'|'-') &lt;calc-product&gt;)*</c> - /// <c>&lt;calc-product&gt; := &lt;calc-value&gt; (('*'|'/') &lt;calc-value&gt;)*</c> - /// <c>&lt;calc-value&gt; := &lt;number&gt; | &lt;dimension&gt; | &lt;percentage&gt; | '(' &lt;calc-sum&gt; ')' | &lt;nested-call&gt; | &lt;unary-sign&gt; &lt;calc-value&gt;</c> - /// Per spec, binary <c>+</c>/<c>-</c> require whitespace on both sides; <c>*</c>/<c>/</c> do not. - /// Returns <c>null</c> on any grammar violation. + /// Parses calc(), min(), max() and clamp() (CSS Values 4 §10.8), both when a declaration is + /// converted and again at layout time from the folded text. Returns null on a syntax error. /// </summary> internal static class CalcParser { @@ -87,9 +81,7 @@ private static CalcNode ParseSum(List<Token> tokens, ref int pos) break; } - // Binary +/- must have whitespace on both sides; without it, this can't be a valid - // binary operator here (an attached sign would already have been merged into the - // adjacent numeric token by the lexer). + // A sign without whitespace before it was already merged into the number token if (!sawWhitespaceBefore) return null; var op = tokens[pos].Data[0]; @@ -206,8 +198,7 @@ private static CalcNode ParseValue(List<Token> tokens, ref int pos) var negative = token.Data == "-"; pos++; - // A unary sign must be immediately followed by a parenthesized group or a nested - // function call (a signed number/dimension is already a single token from the lexer). + // Only reachable before ( or a function, a signed number is a single token if (pos >= tokens.Count || (tokens[pos].Type != TokenType.RoundBracketOpen && tokens[pos].Type != TokenType.Function)) { @@ -237,7 +228,7 @@ private static bool IsSupportedLengthUnit(Length.Unit unit) unit == Length.Unit.Mm || unit == Length.Unit.Cm || unit == Length.Unit.In || unit == Length.Unit.Pt || unit == Length.Unit.Pc; } - /// <summary>Whether <paramref name="name"/> is one of calc/min/max/clamp (case-insensitive).</summary> + /// <summary>Whether <paramref name="name"/> is calc, min, max or clamp, ignoring case.</summary> public static bool IsCalcFamily(string name) { return name.Isi(FunctionNames.Calc) || name.Isi(FunctionNames.Min) || diff --git a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcSerializer.cs b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcSerializer.cs index f092a0cef..70110181c 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcSerializer.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcSerializer.cs @@ -4,10 +4,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Produces the canonical CSS text (<see cref="IPropertyValue.CssText"/>) for a validated calc-family - /// AST (Layer A only). A fully-numeric expression folds to a plain number; an expression built - /// entirely from absolute length units folds to a single pixel length; anything needing layout - /// context (mixed em/rem/%) is re-serialized back to normalized calc()/min()/max()/clamp() text. + /// The text of a type-checked calc() (CSS Values 4 §10.10.1): numbers, angles and absolute lengths + /// fold to a single value, anything with em, rem, ex or % stays an expression for layout. /// </summary> internal static class CalcSerializer { @@ -21,9 +19,6 @@ public static string Serialize(CalcNode node, CalcCategory category) if (category == CalcCategory.Angle) { - // Unlike Length/Percentage, Angle never needs layout context to resolve - deg/grad/rad/turn - // all convert via fixed constants - so a valid Angle expression always folds fully here; - // there's no "defer to layout" text-preserving fallback needed the way there is below. var radians = FoldAngle(node) ?? 0d; var degrees = radians * (180.0 / System.Math.PI); return new Angle((float)degrees, Angle.Unit.Deg).ToString(null, CultureInfo.InvariantCulture); @@ -122,11 +117,8 @@ private static string SerializeExpression(CalcNode node) } /// <summary> - /// Serializes a node as an operand of a parent with the given precedence (0 = none, 1 = sum-level - /// +/-, 2 = product-level */÷), adding parentheses whenever omitting them would change the - /// expression's meaning on re-parse (lower-precedence child under a higher-precedence parent, or - /// a same-precedence child on the right — since +/- and */÷ are left-associative and not, in - /// general, safe to flatten on the right). + /// Serializes an operand of a parent with the given precedence (0 none, 1 + and -, 2 * and /), + /// in parentheses where re-parsing would group it differently. /// </summary> private static string SerializeOperand(CalcNode node, bool isRightOperand, int parentPrecedence) { @@ -141,11 +133,6 @@ private static string SerializeOperand(CalcNode node, bool isRightOperand, int p case PercentageCalcNode percentage: return new Length((float)percentage.Value, Length.Unit.Percent).ToString(null, CultureInfo.InvariantCulture); - // Time/Resolution leaves are only produced by @property `<time>`/`<resolution>` validation, - // which type-checks rather than serializes, so these arms are defensive completeness. A - // Time/Resolution-category Serialize() (were one ever created) folds through neither the Number - // nor Angle branch nor TryFoldPixels, so it reaches SerializeExpression and re-serializes to - // normalized calc() text using these leaf renderings. case TimeCalcNode time: return new Time((float)time.Value, time.Unit).ToString(null, CultureInfo.InvariantCulture); diff --git a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcTypeChecker.cs b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcTypeChecker.cs index 2c7f71edd..3bee69a49 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcTypeChecker.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcTypeChecker.cs @@ -5,10 +5,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Implements CSS calc()'s type-checking rules over a parsed <see cref="CalcNode"/> tree (Layer A - /// only — Layer B's evaluator trusts that a value it's asked to evaluate already passed this check, - /// see CalcValueConverter/CalcEvaluator). Also folds pure-Number subtrees to concrete values, which - /// is what makes a constant divide-by-zero divisor detectable here rather than deferred to layout. + /// Type checking of a calc() tree (CSS Values 4 §10.9), null when the types don't combine. Only a + /// number may be multiplied with or divide another type, so a division by zero is found here. /// </summary> internal static class CalcTypeChecker { @@ -60,9 +58,6 @@ internal static class CalcTypeChecker var right = Check(binary.Right); if (left is null || right != CalcCategory.Number) return null; - // Every valid Number-category subtree is built entirely from Number leaves, so it's - // always foldable here — this is what lets a constant divide-by-zero be rejected at - // validation time rather than silently producing an infinity/NaN at layout time. var divisor = FoldNumber(binary.Right); return divisor == null || divisor == 0d ? null : left; } @@ -140,7 +135,6 @@ internal static class CalcTypeChecker } default: - // Dimension/Percentage leaves are never purely numeric. return null; } } diff --git a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcValue.cs b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcValue.cs index d99ba471e..928e2d5ce 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcValue.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcValue.cs @@ -3,9 +3,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The <see cref="IPropertyValue"/> produced by <see cref="CalcValueConverter"/> for an accepted - /// calc()/min()/max()/clamp() declaration. Mirrors StructValueConverter's wrapper: CssText is the - /// canonical (possibly folded) text, Original is the raw token stream. + /// An accepted calc(), min(), max() or clamp() value, <see cref="CssText"/> is the folded text. /// </summary> internal sealed class CalcValue : IPropertyValue { diff --git a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcValueConverter.cs index f13de2f7a..6a2f775cd 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcValueConverter.cs @@ -3,12 +3,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Accepts a calc()/min()/max()/clamp() declaration whose resolved type-checking category is within - /// <c>allowed</c>, composed onto the existing length/percent/number converters via - /// <c>.Or(...)</c> (see Converters.cs) so support cascades to every property built on them. Runs only - /// for non-var() declarations — var()-containing values are already routed to Converters.Any by - /// Property.TrySetValue, and reach this converter again (for the substituted text) via - /// DomParser.ApplyResolvedPropertyValue's re-parse. + /// Accepts a calc(), min(), max() or clamp() whose type is one of <c>allowed</c>. A value with var() + /// gets here only after substitution. /// </summary> internal sealed class CalcValueConverter : IValueConverter { diff --git a/Source/HtmlRenderer/Core/CssEngine/Values/Length.cs b/Source/HtmlRenderer/Core/CssEngine/Values/Length.cs index 6b8e05466..dbc294d54 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Values/Length.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Values/Length.cs @@ -176,10 +176,8 @@ public static bool TryParse(string s, out Length result) return true; } - // Only a genuinely parsed bare "0" may omit its unit (CSS Values & Units §5.1) - - // StylesheetUnit returns an empty unit string for a plain number but null when it - // couldn't tokenize a number at all, and both leave value at 0, so the unit string - // must be checked to avoid accepting arbitrary non-length input as zero. + // Only 0 may omit the unit (CSS Values 4 §6). The unit is "" for a plain number and null + // when there was no number at all. if (unitString != null && unitString.Length == 0 && value == 0f) { result = Zero; @@ -222,10 +220,7 @@ public float ToPixel() } /// <summary> - /// Resolves this length to pixels, given the context needed for relative units - /// (<c>em</c>/<c>rem</c>/<c>ex</c>/<c>%</c>). This is the single source of truth for - /// "unit + context -> pixels" — <see cref="ToPixel"/> delegates to it for absolute units, - /// and layout-time consumers (e.g. calc() evaluation) use it directly for the relative case. + /// Resolves this length to pixels. Other relative units than em, rem, ex and % resolve to 0. /// </summary> /// <param name="emFactor">Pixels per 1em, i.e. the relevant font size in pixels.</param> /// <param name="remFactor">Pixels per 1rem, i.e. the root element's font size in pixels.</param> @@ -233,10 +228,7 @@ public float ToPixel() /// <param name="fontAdjust">If the length is in pixels and font related, apply the 72/96 factor.</param> internal double ToPixels(double emFactor, double remFactor, double hundredPercent, bool fontAdjust = false) { - // The layout unit here is the CSS pixel, so absolute units resolve against 96dpi - // (CSS Values & Units §5.2: 1in = 96px, 1pt = 1/72in, 1pc = 12pt). These factors must - // stay identical to the ones CssValueParser.ParseLength applies to non-calc() lengths, - // otherwise `calc(1in)` and `width: 1in` would disagree. + // CSS Values 4 §6.2, keep in sync with CssValueParser.ParseLength switch (Type) { case Unit.Em: return emFactor * Value; @@ -262,7 +254,6 @@ public float To(Unit unit) { var value = ToPixel(); - // Inverse of ToPixels' absolute-unit factors: `value` is in CSS pixels. switch (unit) { case Unit.In: // 1 in = 96 px diff --git a/Source/HtmlRenderer/Core/Parse/CssValueParser.cs b/Source/HtmlRenderer/Core/Parse/CssValueParser.cs index 88b525970..78e589ee5 100644 --- a/Source/HtmlRenderer/Core/Parse/CssValueParser.cs +++ b/Source/HtmlRenderer/Core/Parse/CssValueParser.cs @@ -126,9 +126,7 @@ public static bool IsValidLength(string value) } /// <summary> - /// Tokenizes <paramref name="value"/> using the vendored CSS lexer (Core/CssEngine/Parser/Lexer.cs), - /// skipping whitespace/end-of-file tokens - the same "just enough tokenization to recognize a - /// single top-level function call" approach used to detect calc()-family expressions. + /// Tokenizes <paramref name="value"/> with the CSS lexer, without whitespace and end-of-file tokens. /// </summary> internal static List<Token> GetCssTokens(string value, bool isInValue = false) { @@ -217,10 +215,8 @@ private static void AppendFontFaceCandidate(List<CssFontFace> result, List<Token } /// <summary> - /// Recognizes a length string that is a single calc-family (calc/min/max/clamp) function call. - /// Real grammar/type validation happens in the vendored CSS-OM's CalcValueConverter at parse - /// time (for any value that didn't arrive via var() substitution); this is a syntactic - /// recognizer only, used to gate the evaluation branch in <see cref="ParseLength(string, double, double, double, string, bool, bool)"/>. + /// Whether the value is a single calc(), min(), max() or clamp(). Only checks the function name, + /// <see cref="CalcValueConverter"/> validated it when the declaration was parsed. /// </summary> public static bool IsCalcFunction(string value) { @@ -325,9 +321,6 @@ public static double ParseLength(string length, double hundredPercent, double em if (length.EndsWith("%")) return ParseNumber(length, hundredPercent); - // calc()/min()/max()/clamp(): evaluate via the vendored Calc engine (Core/CssEngine/Calc/) - // instead of falling through to the unit-suffix parsing below, which doesn't understand - // function syntax at all. FunctionToken calcFunction; if (TryGetCalcFunction(length, out calcFunction)) { @@ -363,16 +356,16 @@ public static double ParseLength(string length, double hundredPercent, double em factor = fontAdjust ? 72f / 96f : 1f; //TODO:a check support for hi dpi break; case CssConstants.Mm: - factor = 3.779527559f; //3 pixels per millimeter + factor = 3.779527559f; // 96 / 25.4 break; case CssConstants.Cm: - factor = 37.795275591f; //37 pixels per centimeter + factor = 37.795275591f; // 96 / 2.54 break; case CssConstants.In: - factor = 96f; //96 pixels per inch + factor = 96f; break; case CssConstants.Pt: - factor = 96f / 72f; // 1 point = 1/72 of inch + factor = 96f / 72f; if (returnPoints) { @@ -381,7 +374,7 @@ public static double ParseLength(string length, double hundredPercent, double em break; case CssConstants.Pc: - factor = 16f; // 1 pica = 12 points + factor = 16f; break; default: factor = 0f; diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs index 5823eb7b7..d6ccf06f8 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs @@ -211,6 +211,7 @@ public void BackgroundRepeat_TilingCoversTheBorderArea() [DataRow("top center", 30, 0)] [DataRow("0% 0%", 0, 0)] [Spec("css21", "colors.html#background-properties")] + [Spec("css-values-4", "#position-parsing")] public void BackgroundPosition_Keywords(string position, double x, double y) { var g = PaintBackground($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-position:{position}"); @@ -227,6 +228,7 @@ public void BackgroundPosition_Keywords(string position, double x, double y) [DataRow("25% 50%", 15, 35)] [DataRow("10% 90%", 6, 63)] [Spec("css21", "colors.html#background-properties")] + [Spec("css-values-4", "#position-parsing")] public void BackgroundPosition_Percentages(string position, double x, double y) { var g = PaintBackground($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-position:{position}"); @@ -242,6 +244,7 @@ public void BackgroundPosition_Percentages(string position, double x, double y) [DataRow("-5px 7px", -5, 7)] [DataRow("right 15px", 60, 15)] [Spec("css21", "colors.html#background-properties")] + [Spec("css-values-4", "#position-parsing")] public void BackgroundPosition_Lengths(string position, double x, double y) { var g = PaintBackground($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-position:{position}"); diff --git a/Source/Test/HtmlRenderer.Test/Css/CalcEvaluatorTests.cs b/Source/Test/HtmlRenderer.Test/Css/CalcEvaluatorTests.cs index 6d3f8b34c..702854d39 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CalcEvaluatorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CalcEvaluatorTests.cs @@ -6,17 +6,8 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/CalcEvaluatorTests.cs. -/// Tests for <see cref="CalcEvaluator"/>'s angle-leaf evaluation: a calc() whose value is an -/// &lt;angle&gt; (e.g. <c>calc(1turn * 0.35)</c>) evaluates to radians (the canonical angle unit). -/// This is what lets a conic-gradient stop position be authored as a calc() expression - the -/// Charts.css pie-slice case, whose every stop is <c>calc(1turn * &lt;value&gt;)</c>. -/// -/// HTML-Renderer's <see cref="CalcContext"/> constructor takes 4-5 args (hundredPercent, emFactor, -/// remFactor, fontAdjust, returnPoints = false) vs PeachPDF's 3-arg constructor (hundredPercent, -/// emFactor, remFactor). This is a documented non-behavioral API drift, not a real gap: fontAdjust is -/// passed <c>false</c> below (and returnPoints keeps its default of <c>false</c>) to match PeachPDF's -/// 3-arg semantics. +/// <see cref="CalcEvaluator"/> on non-length calc() values: angles evaluate to radians, times to +/// milliseconds and resolutions to dppx. /// </summary> [TestClass] public sealed class CalcEvaluatorTests @@ -31,6 +22,7 @@ public sealed class CalcEvaluatorTests } [TestMethod] + [Spec("css-values-4", "#angles")] [DataRow("calc(1turn * 0.35)", 0.35)] [DataRow("calc(1turn * 0.5)", 0.5)] [DataRow("calc(0.25turn)", 0.25)] @@ -42,6 +34,7 @@ public void AngleCalc_TurnScaled_EvaluatesToRadians(string calc, double expected } [TestMethod] + [Spec("css-values-4", "#angles")] public void AngleCalc_Degrees_EvaluatesToRadians() { var radians = EvaluateAngle("calc(90deg + 90deg)"); @@ -49,10 +42,7 @@ public void AngleCalc_Degrees_EvaluatesToRadians() Assert.AreEqual(Math.PI, radians!.Value, 1e-4); // 180deg } - // ── <time>/<resolution> calc leaves (issue #229 gap 2) ─────────────────────── - // These node types are produced by CalcParser for @property `<time>`/`<resolution>` validation. - // No layout property evaluates or serializes a time/resolution calc, so these tests exercise the - // CalcEvaluator/CalcSerializer/CalcNode leaf handling that path never reaches directly. + // Only @property syntax validation produces time and resolution calc() values. private static double? Evaluate(string calc) { @@ -72,6 +62,7 @@ public void TimeCalc_EvaluatesToMilliseconds(string calc, double expectedMs) } [TestMethod] + [Spec("css-values-4", "#resolution")] [DataRow("calc(2dppx * 2)", 4.0)] // canonical unit is dots-per-pixel [DataRow("calc(96dpi)", 1.0)] // 96dpi == 1dppx public void ResolutionCalc_EvaluatesToDotsPerPixel(string calc, double expectedDppx) diff --git a/Source/Test/HtmlRenderer.Test/Css/CalcPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/CalcPropertyTests.cs index 9ae3d1db1..fe30351ae 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CalcPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CalcPropertyTests.cs @@ -1,13 +1,11 @@ using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; using TheArtOfDev.HtmlRenderer.Core.CssEngine; namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/CalcPropertyTests.cs. -/// CSS-object-model-level unit tests for calc()/min()/max()/clamp(): parsing, type-checking, and -/// canonical text, exercised against HTML-Renderer's ported Source/HtmlRenderer/Core/CssEngine/Calc/ -/// folding engine. Pure CSSOM parse tests - no layout/cascade-level numeric resolution here. +/// calc(), min(), max() and clamp() at parse time: grammar, type checking and the folded text. /// </summary> [TestClass] public sealed class CalcPropertyTests @@ -15,6 +13,7 @@ public sealed class CalcPropertyTests // ── basic arithmetic (fully numeric/absolute -> folds to a plain value) ───────────── [TestMethod] + [Spec("css-values-4", "#calc-simplification")] public void Width_CalcAddition_FoldsToPixelLength() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(100px + 20px)"); @@ -26,6 +25,7 @@ public void Width_CalcAddition_FoldsToPixelLength() } [TestMethod] + [Spec("css-values-4", "#calc-simplification")] public void Width_CalcSubtraction_FoldsToPixelLength() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(200px - 60px)"); @@ -34,6 +34,7 @@ public void Width_CalcSubtraction_FoldsToPixelLength() } [TestMethod] + [Spec("css-values-4", "#calc-simplification")] public void Width_CalcMultiplication_FoldsToPixelLength() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(20px * 4)"); @@ -42,6 +43,7 @@ public void Width_CalcMultiplication_FoldsToPixelLength() } [TestMethod] + [Spec("css-values-4", "#calc-simplification")] public void Width_CalcMultiplicationNumberFirst_FoldsToPixelLength() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(4 * 20px)"); @@ -50,6 +52,7 @@ public void Width_CalcMultiplicationNumberFirst_FoldsToPixelLength() } [TestMethod] + [Spec("css-values-4", "#calc-simplification")] public void Width_CalcDivision_FoldsToPixelLength() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(100px / 4)"); @@ -58,11 +61,10 @@ public void Width_CalcDivision_FoldsToPixelLength() } [TestMethod] + [Spec("css-values-4", "#compat")] public void Width_CalcMixedAbsoluteUnits_FoldsToPixelLength() { - // px is spec-correct (1px = 0.75pt, i.e. 96dpi): 1in = 96px, 2cm = 2*96/2.54 ~= 75.59px, - // folding to ~171.59px. (Internally that's 1in = 72pt + 2cm = 56.69pt = 128.69pt, serialized - // back to canonical px as 128.69 / 0.75.) + // 1in = 96px, 2cm = 2 * 96 / 2.54px var property = CssConstructionFunctions.ParseDeclaration("width: calc(1in + 2cm)"); Assert.IsTrue(property.HasValue); StringAssert.StartsWith(property.Value, "171."); @@ -72,6 +74,7 @@ public void Width_CalcMixedAbsoluteUnits_FoldsToPixelLength() // ── mixed relative units (can't fold until layout - canonical calc() text preserved) ─ [TestMethod] + [Spec("css-values-4", "#calc-simplification")] public void Width_CalcMixedEmPx_PreservesCalcExpression() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(1em + 5px)"); @@ -80,6 +83,7 @@ public void Width_CalcMixedEmPx_PreservesCalcExpression() } [TestMethod] + [Spec("css-values-4", "#calc-simplification")] public void Width_CalcPercentMinusPx_PreservesCalcExpression() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(100% - 20px)"); @@ -90,6 +94,7 @@ public void Width_CalcPercentMinusPx_PreservesCalcExpression() // ── nesting ─────────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css-values-4", "#calc-syntax")] public void Width_NestedCalc_FoldsToPixelLength() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(calc(10px + 10px) * 2)"); @@ -98,6 +103,7 @@ public void Width_NestedCalc_FoldsToPixelLength() } [TestMethod] + [Spec("css-values-4", "#calc-syntax")] public void Width_ParenGrouping_FoldsToPixelLength() { var property = CssConstructionFunctions.ParseDeclaration("width: calc((10px + 10px) * 2)"); @@ -106,18 +112,28 @@ public void Width_ParenGrouping_FoldsToPixelLength() } [TestMethod] - public void Width_ParenGroupingMixedUnits_PreservesGroupingInCanonicalText() + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#calc-simplification")] + public void Width_ParenGroupingMixedUnits_MultipliesIntoTheSum() { - // Without the parens this would mean "1em + (5px * 2)" = a different expression; - // the canonical text must keep the grouping to stay semantically equivalent. var property = CssConstructionFunctions.ParseDeclaration("width: calc((1em + 5px) * 2)"); Assert.IsTrue(property.HasValue); - Assert.AreEqual("calc((1em + 5px) * 2)", property.Value); + Assert.AreEqual("calc(2em + 10px)", property.Value); + } + + [TestMethod] + [Spec("css-values-4", "#calc-syntax")] + public void Width_ParenGroupingMixedUnits_ResolvesToTheProductOfTheSum() + { + var (root, _) = LayoutHarness.Layout("<html><body style='margin:0'><div id=x style='font-size:20px; width:calc((1em + 5px) * 2)'>a</div></body></html>"); + + Assert.AreEqual(50, LayoutHarness.FindById(root, "x")!.ActualWidth, 0.01); } // ── min() / max() / clamp() ────────────────────────────────────────────────────── [TestMethod] + [Spec("css-values-4", "#comp-func")] public void Width_Min_FoldsToSmallerPixelLength() { var property = CssConstructionFunctions.ParseDeclaration("width: min(150px, 100px)"); @@ -126,6 +142,7 @@ public void Width_Min_FoldsToSmallerPixelLength() } [TestMethod] + [Spec("css-values-4", "#comp-func")] public void Width_Max_FoldsToLargerPixelLength() { var property = CssConstructionFunctions.ParseDeclaration("width: max(150px, 100px)"); @@ -134,6 +151,7 @@ public void Width_Max_FoldsToLargerPixelLength() } [TestMethod] + [Spec("css-values-4", "#comp-func")] public void Width_MinMixedUnits_PreservesCanonicalText() { var property = CssConstructionFunctions.ParseDeclaration("width: min(10px, 1em)"); @@ -142,6 +160,7 @@ public void Width_MinMixedUnits_PreservesCanonicalText() } [TestMethod] + [Spec("css-values-4", "#comp-func")] public void Width_ClampWithPercent_PreservesCanonicalText() { var property = CssConstructionFunctions.ParseDeclaration("width: clamp(10px, 50%, 200px)"); @@ -150,6 +169,7 @@ public void Width_ClampWithPercent_PreservesCanonicalText() } [TestMethod] + [Spec("css-values-4", "#comp-func")] public void Width_ClampAllAbsolute_FoldsToPixelLength() { var property = CssConstructionFunctions.ParseDeclaration("width: clamp(10px, 300px, 150px)"); @@ -158,6 +178,7 @@ public void Width_ClampAllAbsolute_FoldsToPixelLength() } [TestMethod] + [Spec("css-values-4", "#comp-func")] public void Width_ClampWrongArgCount_IsInvalid() { var property = CssConstructionFunctions.ParseDeclaration("width: clamp(10px, 20px)"); @@ -165,6 +186,7 @@ public void Width_ClampWrongArgCount_IsInvalid() } [TestMethod] + [Spec("css-values-4", "#comp-func")] public void Width_MinNoArgs_IsInvalid() { var property = CssConstructionFunctions.ParseDeclaration("width: min()"); @@ -174,6 +196,7 @@ public void Width_MinNoArgs_IsInvalid() // ── plain-number category (NumberConverter-based properties) ──────────────────── [TestMethod] + [Spec("css-values-4", "#calc-type-checking")] public void FlexGrow_CalcNumberArithmetic_FoldsToPlainNumber() { var property = CssConstructionFunctions.ParseDeclaration("flex-grow: calc(1 + 1)"); @@ -182,6 +205,7 @@ public void FlexGrow_CalcNumberArithmetic_FoldsToPlainNumber() } [TestMethod] + [Spec("css-values-4", "#calc-type-checking")] public void FlexGrow_CalcWithLength_IsInvalid() { var property = CssConstructionFunctions.ParseDeclaration("flex-grow: calc(1px + 2px)"); @@ -191,13 +215,16 @@ public void FlexGrow_CalcWithLength_IsInvalid() // ── invalid / degenerate expressions ───────────────────────────────────────────── [TestMethod] - public void Width_CalcDivideByZero_IsInvalid() + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#calc-ieee")] + public void Width_CalcDivideByZero_IsValid() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(10px / 0)"); - Assert.IsFalse(property.HasValue); + Assert.IsTrue(property.HasValue); } [TestMethod] + [Spec("css-values-4", "#calc-type-checking")] public void Width_CalcAddNumberToLength_IsInvalid() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(10px + 5)"); @@ -205,6 +232,7 @@ public void Width_CalcAddNumberToLength_IsInvalid() } [TestMethod] + [Spec("css-values-4", "#calc-type-checking")] public void Width_CalcMultiplyTwoLengths_IsInvalid() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(10px * 5px)"); @@ -212,6 +240,7 @@ public void Width_CalcMultiplyTwoLengths_IsInvalid() } [TestMethod] + [Spec("css-values-4", "#calc-type-checking")] public void Width_CalcDivideByLength_IsInvalid() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(10px / 5px)"); @@ -219,6 +248,7 @@ public void Width_CalcDivideByLength_IsInvalid() } [TestMethod] + [Spec("css-values-4", "#calc-syntax")] public void Width_CalcNoWhitespaceAroundPlus_IsInvalid() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(10px+5px)"); @@ -226,6 +256,7 @@ public void Width_CalcNoWhitespaceAroundPlus_IsInvalid() } [TestMethod] + [Spec("css-values-4", "#calc-syntax")] public void Width_CalcWhitespaceOnOneSideOfMinus_IsInvalid() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(10px -5px)"); @@ -233,6 +264,7 @@ public void Width_CalcWhitespaceOnOneSideOfMinus_IsInvalid() } [TestMethod] + [Spec("css-values-4", "#calc-syntax")] public void Width_CalcUnbalancedParens_IsInvalid() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(10px + (5px)"); @@ -240,6 +272,7 @@ public void Width_CalcUnbalancedParens_IsInvalid() } [TestMethod] + [Spec("css-values-4", "#calc-type-checking")] public void Width_CalcAngleUnit_IsInvalid() { var property = CssConstructionFunctions.ParseDeclaration("width: calc(10deg + 5deg)"); @@ -247,6 +280,7 @@ public void Width_CalcAngleUnit_IsInvalid() } [TestMethod] + [Spec("css-values-4", "#calc-syntax")] public void Width_CalcEmpty_IsInvalid() { var property = CssConstructionFunctions.ParseDeclaration("width: calc()"); @@ -323,6 +357,7 @@ public void Transform_RotateCalcSameUnit_FoldsToDegrees() } [TestMethod] + [Spec("css-values-4", "#compat")] public void Transform_SkewXCalcMixedAngleUnits_FoldsToDegrees() { // 1turn / 4 = 90deg - division by a plain number is legal for an angle numerator. diff --git a/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs b/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs index 374a2d392..cb84d67df 100644 --- a/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs @@ -75,6 +75,8 @@ public void VendorExtension_IsParsedAndIgnored(string declaration) [TestMethod] [Spec("css21", "syndata.html#characters")] + [Spec("css-values-4", "#keywords")] + [Spec("css-values-4", "#functional-notations")] public void PropertyNamesKeywordsAndFunctions_AreCaseInsensitive() { var box = Styled("DIV#x { COLOR: GREEN; Background-Color: RGB(0, 128, 0) } #x { Background-Image: URL(a.png) }"); @@ -89,6 +91,7 @@ public void PropertyNamesKeywordsAndFunctions_AreCaseInsensitive() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "syndata.html#characters")] + [Spec("css-values-4", "#dimensions")] public void UnitsAtKeywordsAndImportant_AreCaseInsensitive() { Assert.AreEqual(10, Styled("#x{width:10PX}").ActualWidth, Delta); @@ -308,6 +311,7 @@ public void UnexpectedEndOfStyleSheet_ClosesTheOpenString() [TestMethod] [Spec("css21", "syndata.html#numbers")] + [Spec("css-values-4", "#numbers")] [DataRow("+10px", 10)] [DataRow(".5em", 10)] [DataRow("0.5em", 10)] @@ -319,6 +323,7 @@ public void Number_AllowsASignAndAFraction(string value, double expected) [TestMethod] [Spec("css21", "syndata.html#numbers")] + [Spec("css-values-4", "#numbers")] [DataRow("10.px")] [DataRow("1,5px")] public void MalformedNumber_IsIgnored(string value) @@ -334,6 +339,7 @@ public void NegativeZero_IsNotNegative() [TestMethod] [Spec("css21", "syndata.html#length-units")] + [Spec("css-values-4", "#absolute-lengths")] [DataRow("1in")] [DataRow("2.54cm")] [DataRow("25.4mm")] @@ -347,6 +353,7 @@ public void AbsoluteUnits_AreFixedInRelationToEachOther(string value) [TestMethod] [Spec("css21", "syndata.html#length-units")] + [Spec("css-values-4", "#lengths")] public void UnitAfterZero_IsOptional() { Assert.AreEqual(0, Styled("#x{margin-left:5px;margin-left:0}").ActualMarginLeft, Delta); @@ -354,6 +361,7 @@ public void UnitAfterZero_IsOptional() [TestMethod] [Spec("css21", "syndata.html#length-units")] + [Spec("css-values-4", "#lengths")] public void UnitAfterNonZero_IsRequired() { Assert.AreEqual(5, Styled("#x{margin-left:5px;margin-left:3}").ActualMarginLeft, Delta); @@ -361,6 +369,7 @@ public void UnitAfterNonZero_IsRequired() [TestMethod] [Spec("css21", "syndata.html#length-units")] + [Spec("css-values-4", "#font-relative-lengths")] public void Em_IsTheFontSizeOfTheElement() { Assert.AreEqual(40, Styled("#x{font-size:20px;margin-left:2em}").ActualMarginLeft, Delta); @@ -368,6 +377,7 @@ public void Em_IsTheFontSizeOfTheElement() [TestMethod] [Spec("css21", "syndata.html#length-units")] + [Spec("css-values-4", "#font-relative-lengths")] public void EmInFontSize_IsTheFontSizeOfTheParent() { var box = Styled("#p{font-size:20px} #x{font-size:1.5em;margin-left:1em}", "<div id=p><div id=x>a</div></div>"); @@ -377,6 +387,7 @@ public void EmInFontSize_IsTheFontSizeOfTheParent() [TestMethod] [Spec("css21", "syndata.html#length-units")] + [Spec("css-values-4", "#font-relative-lengths")] public void Ex_IsHalfAnEmWithoutFontMetrics() { Assert.AreEqual(20, Styled("#x{font-size:20px;margin-left:2ex}").ActualMarginLeft, Delta); @@ -384,6 +395,7 @@ public void Ex_IsHalfAnEmWithoutFontMetrics() [TestMethod] [Spec("css21", "syndata.html#length-units")] + [Spec("css-values-4", "#font-relative-lengths")] public void EmOnTheRoot_RefersToTheInitialFontSize() { var (root, _) = LayoutHarness.Layout("<html style='font-size:2em'><body style='margin:0'><div id=x style='margin-left:1em'>a</div></body></html>"); @@ -404,6 +416,7 @@ public void Children_InheritTheComputedLengthNotTheEmValue() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "syndata.html#length-units")] + [Spec("css-values-4", "#numeric-ranges")] [DataRow("width")] [DataRow("height")] [DataRow("padding-left")] @@ -427,6 +440,7 @@ public void NegativeLength_OnAPropertyThatDoesntAllowIt_IsIgnored(string propert [TestMethod] [Spec("css21", "syndata.html#percentage-units")] + [Spec("css-values-4", "#percentages")] public void Percentage_ResolvesAgainstTheReferenceValue() { Assert.AreEqual(50, Styled("#x{width:25%}", "<div style='width:200px'><div id=x>a</div></div>").ActualWidth, Delta); @@ -435,6 +449,7 @@ public void Percentage_ResolvesAgainstTheReferenceValue() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "syndata.html#percentage-units")] + [Spec("css-values-4", "#percentages")] public void Children_InheritTheComputedFontSizeNotThePercentage() { var box = Styled("#p{font-size:10px} #q{font-size:200%} #x{margin-left:1em}", "<div id=p><div id=q><div id=x>a</div></div></div>"); @@ -454,6 +469,7 @@ public void Children_InheritTheComputedLineHeightNotThePercentage() [TestMethod] [Spec("css21", "syndata.html#uri")] + [Spec("css-values-4", "#urls")] [DataRow("url(a.png)")] [DataRow("url( a.png )")] [DataRow("url('a.png')")] @@ -465,6 +481,7 @@ public void Url_AllowsQuotesAndWhitespace(string value) [TestMethod] [Spec("css21", "syndata.html#uri")] + [Spec("css-values-4", "#urls")] public void UnquotedUrl_TakesEscapedParentheses() { Assert.AreEqual("url(\"a(b).png\")", Styled(@"#x{background-image:url(a\(b\).png)}").BackgroundImage); @@ -479,6 +496,8 @@ public void CommentInsideAUrl_IsPartOfTheUrl() [TestMethod] [Spec("css21", "syndata.html#uri")] + [Spec("css-values-4", "#relative-urls")] + [Spec("css-values-4", "#url-processing")] [DataRow("url(yellow)")] [DataRow("url('yellow')")] public void RelativeUrl_ResolvesAgainstTheStyleSheet(string value) @@ -490,6 +509,7 @@ public void RelativeUrl_ResolvesAgainstTheStyleSheet(string value) [TestMethod] [Spec("css21", "syndata.html#uri")] + [Spec("css-values-4", "#relative-urls")] public void DoubleQuotedRelativeUrl_ResolvesAgainstTheStyleSheet() { var (box, href) = StyledWithFile("style/basic.css", Encoding.UTF8.GetBytes("#x { background-image: url(\"yellow\") }")); @@ -584,6 +604,7 @@ public void InvalidRgbFunction_IsIgnored(string value) [TestMethod] [Spec("css21", "syndata.html#strings")] + [Spec("css-values-4", "#strings")] [DataRow("\"this is a 'string'\"", "this is a 'string'")] [DataRow("\"this is a \\\"string\\\"\"", "this is a \"string\"")] [DataRow("'this is a \"string\"'", "this is a \"string\"")] @@ -598,6 +619,7 @@ public void String_TakesEitherQuoteAndEscapes(string value, string expected) [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "syndata.html#strings")] [Spec("css-syntax-3", "#consume-string-token")] + [Spec("css-values-4", "#strings")] public void StringBrokenOverLines_IsTheSameString() { var css = "#x[title=\"a not s\\\no very long title\"] { color: green }"; diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssValuesTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssValuesTests.cs new file mode 100644 index 000000000..4a7267a28 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssValuesTests.cs @@ -0,0 +1,496 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssValuesTests +{ + private const string Green = "rgb(0, 128, 0)"; + private const double Delta = 0.01; + + [TestMethod] + [Spec("css-values-4", "#common-keywords")] + [DataRow("initial", "black", 0)] + [DataRow("inherit", Green, 7)] + [DataRow("unset", Green, 0)] + public void CssWideKeyword_ResolvesTheValue(string keyword, string color, double marginLeft) + { + var box = Styled($"#x{{color:red;margin-left:5px;color:{keyword};margin-left:{keyword}}}", + "<div style='color:green;margin-left:7px'><div id=x>a</div></div>"); + + Assert.AreEqual(color, box.Color); + Assert.AreEqual(marginLeft, box.ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css-values-4", "#custom-idents")] + public void CustomIdent_IsCaseSensitive() + { + Assert.AreEqual("2", Before("#x{counter-reset:foo 2} #x::before{content:counter(foo)}")); + Assert.AreEqual("0", Before("#x{counter-reset:FOO 2} #x::before{content:counter(foo)}")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#custom-idents")] + public void Default_IsNotACustomIdent() + { + Assert.AreEqual("2", Before("#x{counter-reset:default 2;counter-reset:default 5} #x::before{content:counter(default)}")); + } + + [TestMethod] + [Spec("css-values-4", "#dashed-idents")] + public void DashedIdent_IsCaseSensitive() + { + Assert.AreEqual(4, Styled("#x{--a:4px;--A:8px;margin-left:var(--a)}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#urls")] + public void SrcFunction_IsAUrl() + { + Assert.AreEqual("src(\"a.png\")", Styled("#x{background-image:src(\"a.png\")}").BackgroundImage); + } + + [TestMethod] + [Spec("css-values-4", "#url-empty")] + [DataRow("url()")] + [DataRow("url(\"\")")] + public void EmptyUrl_ResolvesToNoResource(string value) + { + var requested = new List<string>(); + var container = new HtmlContainerInt(new MockAdapter()) { MaxSize = new RSize(800, 600) }; + container.ImageLoad += (_, e) => requested.Add(e.Src); + container.SetHtml($"<html><head><style>#x{{width:10px;height:10px;background-image:{value}}}</style></head><body><div id=x></div></body></html>"); + using var graphics = new RecordingGraphics(); + container.PerformLayout(graphics); + container.PerformPaint(graphics); + + Assert.AreEqual("url(\"\")", LayoutHarness.FindById(container.Root!, "x")!.BackgroundImage); + CollectionAssert.AreEqual(new[] { "" }, requested); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#url-modifiers")] + public void UnknownUrlModifier_IsInvalid() + { + Assert.AreEqual("none", Styled("#x{background-image:url(\"a.png\" foo)}").BackgroundImage); + } + + [TestMethod] + [Spec("css-values-4", "#numeric-ranges")] + public void NegativeValue_IsAllowedWhereTheRangeAllowsIt() + { + var box = Styled("#x{margin-left:-5px;text-indent:-3px}"); + + Assert.AreEqual(-5, box.ActualMarginLeft, Delta); + Assert.AreEqual(-3, box.ActualTextIndent, Delta); + } + + [TestMethod] + [Spec("css-values-4", "#integers")] + [DataRow("3", "3")] + [DataRow("+3", "3")] + [DataRow("-3", "-3")] + [DataRow("2.5", "0")] + public void Integer_TakesASignButNoFraction(string value, string expected) + { + Assert.AreEqual(expected, Before($"#x{{counter-reset:foo {value}}} #x::before{{content:counter(foo)}}")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#numbers")] + [DataRow("1e1px")] + [DataRow("1E1px")] + [DataRow("+.1e2px")] + [DataRow("100e-1px")] + public void Number_TakesAnExponent(string value) + { + Assert.AreEqual(10, Styled($"#x{{margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css-values-4", "#dimensions")] + public void Dimension_IsANumberImmediatelyFollowedByItsUnit() + { + Assert.AreEqual(5, Styled("#x{margin-left:5px;margin-left:10 px}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css-values-4", "#compat")] + public void CompatibleUnits_AreCombined() + { + Assert.AreEqual(48 + 16 + 16 + 96 / 2.54 + 96 / 2.54, + Styled("#x{margin-left:calc(0.5in + 12pt + 1pc + 1cm + 10mm)}").ActualMarginLeft, 0.01); + } + + [TestMethod] + [Spec("css-values-4", "#mixed-percentages")] + public void PercentageAndLength_MixInCalc() + { + const string parent = "<div style='width:400px'><div id=x>a</div></div>"; + + Assert.AreEqual(190, Styled("#x{width:calc(50% - 10px)}", parent).Size.Width, Delta); + Assert.AreEqual(110, Styled("#x{font-size:10px;padding-left:calc(25% + 1em)}", parent).ActualPaddingLeft, Delta); + } + + [TestMethod] + [Spec("css-values-4", "#ratios")] + [DataRow("4/3")] + [DataRow("8 / 6")] + public void Ratio_ComparesTheQuotient(string ratio) + { + Assert.AreEqual(Green, Styled($"@media (aspect-ratio: {ratio}) {{ #x {{ color: green }} }}").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#ratios")] + public void Ratio_MayBeASingleNumber() + { + Assert.AreEqual(Green, Styled("@media (min-aspect-ratio: 1.3) { #x { color: green } }").Color); + } + + [TestMethod] + [Spec("css-values-4", "#font-relative-lengths")] + public void Rem_IsTheRootFontSize() + { + var box = Styled("html{font-size:10px} #x{font-size:20px;margin-left:2rem;padding-left:calc(1rem)}"); + + Assert.AreEqual(20, box.ActualMarginLeft, Delta); + Assert.AreEqual(10, box.ActualPaddingLeft, Delta); + } + + [TestMethod] + [Spec("css-values-4", "#font-relative-lengths")] + public void Rem_InTextIndent_IsTheRootFontSize() + { + Assert.AreEqual(20, Styled("html{font-size:10px} #x{font-size:20px;text-indent:2rem}").ActualTextIndent, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#font-relative-lengths")] + public void Rem_InWordSpacing_IsTheRootFontSize() + { + double WordSpacing(string value) => + Styled($"html{{font-size:10px}} #x{{font-size:20px;word-spacing:{value}}}", "<div id=x>a b</div>").ActualWordSpacing; + + Assert.AreEqual(WordSpacing("20px"), WordSpacing("2rem"), Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#font-relative-lengths")] + [DataRow("ch")] + [DataRow("ic")] + [DataRow("cap")] + [DataRow("lh")] + [DataRow("rex")] + [DataRow("rch")] + [DataRow("rcap")] + [DataRow("ric")] + [DataRow("rlh")] + public void FontRelativeUnit_ResolvesToALength(string unit) + { + Assert.AreNotEqual(0, Styled($"#x{{font-size:20px;line-height:30px;margin-left:2{unit}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#viewport-relative-units")] + [DataRow("10vw", 80)] + [DataRow("10vh", 60)] + [DataRow("10vmin", 60)] + [DataRow("10vmax", 80)] + [DataRow("10vi", 80)] + [DataRow("10vb", 60)] + [DataRow("calc(10vw)", 80)] + public void ViewportUnit_IsAPercentageOfTheViewport(string value, double expected) + { + Assert.AreEqual(expected, Styled($"#x{{margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#viewport-variants")] + [DataRow("10svw")] + [DataRow("10lvw")] + [DataRow("10dvw")] + public void ViewportSizeVariant_IsAPercentageOfTheViewport(string value) + { + Assert.AreEqual(80, Styled($"#x{{margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#absolute-lengths")] + [DataRow("40Q")] + [DataRow("calc(40Q)")] + public void QuarterMillimeter_IsAnAbsoluteUnit(string value) + { + Assert.AreEqual(96 / 2.54, Styled($"#x{{margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css-values-4", "#angles")] + [DataRow("120deg")] + [DataRow("133.33333grad")] + [DataRow("2.0943951rad")] + [DataRow("0.3333333turn")] + [DataRow("-240deg")] + [DataRow("480deg")] + [DataRow("calc(60deg * 2)")] + public void AngleUnits_AreConvertedToEachOther(string hue) + { + Assert.AreEqual(RColor.FromArgb(255, 0, 255, 0), Styled($"#x{{color:hsl({hue}, 100%, 50%)}}").ActualColor); + } + + [TestMethod] + [Spec("css-values-4", "#resolution")] + [DataRow("96dpi")] + [DataRow("1dppx")] + [DataRow("1x")] + [DataRow("37.795dpcm")] + public void ResolutionUnits_AreConvertedToEachOther(string resolution) + { + Assert.AreEqual(Green, Styled($"@media (min-resolution: {resolution}) and (max-resolution: 96.01dpi) {{ #x {{ color: green }} }}").Color); + } + + [TestMethod] + [Spec("css-values-4", "#calc-func")] + [DataRow("calc(10px + 5px)", 15)] + [DataRow("calc(10px - 5px)", 5)] + [DataRow("calc(10px*3)", 30)] + [DataRow("calc(30px/3)", 10)] + [DataRow("calc(2 * 3px + 1px)", 7)] + [DataRow("calc(1px + 2px * 3)", 7)] + [DataRow("calc(2px * (3 - 1))", 4)] + [DataRow("calc(10px * -0.5)", -5)] + [DataRow("calc(-1 * (10px))", -10)] + [DataRow("calc(-(10px))", -10)] + [DataRow("calc(10px - -5px)", 15)] + [DataRow("calc(calc(10px) * 2)", 20)] + [DataRow("calc(2em + 1px)", 41)] + public void Calc_EvaluatesTheExpression(string value, double expected) + { + Assert.AreEqual(expected, Styled($"#x{{font-size:20px;margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css-values-4", "#comp-func")] + [DataRow("min(10px, 20px)", 10)] + [DataRow("min(10px)", 10)] + [DataRow("max(10px, 2em, 15px)", 40)] + [DataRow("clamp(10px, 5px, 20px)", 10)] + [DataRow("clamp(10px, 15px, 20px)", 15)] + [DataRow("clamp(10px, 50px, 20px)", 20)] + [DataRow("calc(min(10px, 20px) + max(1px, 2px))", 12)] + public void ComparisonFunction_PicksTheArgument(string value, double expected) + { + Assert.AreEqual(expected, Styled($"#x{{font-size:20px;margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#comp-func")] + [DataRow("clamp(30px, 50px, 20px)", 30)] + [DataRow("clamp(none, 50px, 20px)", 20)] + [DataRow("clamp(10px, 5px, none)", 10)] + public void Clamp_FavorsTheMinimumAndTakesNone(string value, double expected) + { + Assert.AreEqual(expected, Styled($"#x{{margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#round-func")] + [DataRow("round(17px, 5px)", 15)] + [DataRow("round(up, 17px, 5px)", 20)] + [DataRow("mod(17px, 5px)", 2)] + [DataRow("rem(-17px, 5px)", -2)] + public void SteppedValueFunction_IsEvaluated(string value, double expected) + { + Assert.AreEqual(expected, Styled($"#x{{margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#trig-funcs")] + [DataRow("calc(sin(90deg) * 10px)", 10)] + [DataRow("calc(cos(0) * 10px)", 10)] + [DataRow("calc(tan(45deg) * 10px)", 10)] + [DataRow("calc(asin(1) / 1deg * 1px)", 90)] + [DataRow("calc(atan2(1, 1) / 1deg * 1px)", 45)] + public void TrigonometricFunction_IsEvaluated(string value, double expected) + { + Assert.AreEqual(expected, Styled($"#x{{margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#exponent-funcs")] + [DataRow("calc(pow(2, 3) * 1px)", 8)] + [DataRow("calc(sqrt(16) * 1px)", 4)] + [DataRow("hypot(3px, 4px)", 5)] + [DataRow("calc(log(1) * 1px + 5px)", 5)] + [DataRow("calc(exp(0) * 5px)", 5)] + public void ExponentialFunction_IsEvaluated(string value, double expected) + { + Assert.AreEqual(expected, Styled($"#x{{margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#sign-funcs")] + [DataRow("abs(-5px)", 5)] + [DataRow("calc(sign(-5px) * -5px)", 5)] + public void SignFunction_IsEvaluated(string value, double expected) + { + Assert.AreEqual(expected, Styled($"#x{{margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#calc-constants")] + [DataRow("calc(pi * 10px)", 31.42)] + [DataRow("calc(e * 10px)", 27.18)] + public void NumericConstant_IsEvaluated(string value, double expected) + { + Assert.AreEqual(expected, Styled($"#x{{margin-left:{value}}}").ActualMarginLeft, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#calc-error-constants")] + public void Infinity_ClampsToTheLargestValue() + { + Assert.IsTrue(Styled("#x{margin-left:5px;margin-left:calc(infinity * 1px)}").ActualMarginLeft > 1e6); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#calc-syntax")] + [DataRow("calc(2ch)")] + [DataRow("calc(2vh)")] + [DataRow("min(2vmin, 3px)")] + public void Calc_TakesEveryLengthUnit(string value) + { + Assert.AreNotEqual(5, Styled($"#x{{margin-left:5px;margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css-values-4", "#calc-syntax")] + [DataRow("calc(10px+5px)")] + [DataRow("calc(10px -5px)")] + [DataRow("calc(10px- 5px)")] + [DataRow("calc()")] + [DataRow("-calc(10px)")] + public void MalformedCalc_IsIgnored(string value) + { + Assert.AreEqual(5, Styled($"#x{{margin-left:5px;margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css-values-4", "#calc-type-checking")] + [DataRow("calc(10px + 1)")] + [DataRow("calc(10px * 1px)")] + [DataRow("calc(1deg)")] + [DataRow("calc(1s)")] + [DataRow("min(10px, 2deg)")] + public void MistypedCalc_IsIgnored(string value) + { + Assert.AreEqual(5, Styled($"#x{{margin-left:5px;margin-left:{value}}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css-values-4", "#calc-type-checking")] + public void NumberCalc_IsANumber() + { + Assert.AreEqual("30px", Styled("#x{font-size:20px;line-height:calc(1 + 0.5)}").LineHeight); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#calc-type-checking")] + [DataRow("calc(3)", "3")] + [DataRow("calc(2.6)", "3")] + public void NumberCalc_IsRoundedWhereAnIntegerIsExpected(string value, string expected) + { + Assert.AreEqual(expected, Before($"#x{{counter-reset:foo {value}}} #x::before{{content:counter(foo)}}")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#calc-type-checking")] + public void IntermediateLengthSquared_IsAllowed() + { + Assert.AreEqual(50, Styled("#x{margin-left:calc(100px * 2px / 4px)}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#calc-ieee")] + public void DivisionByZero_IsInfinityClampedToTheRange() + { + Assert.AreEqual(0, Styled("#x{width:50px;width:calc(-10px / 0)}").ActualWidth, Delta); + } + + [TestMethod] + [Spec("css-values-4", "#calc-computed-value")] + public void Calc_ResolvesPercentagesAgainstTheContainingBlock() + { + var box = Styled("#x{text-indent:calc(10% + 5px)}", "<div style='width:200px'><div id=x>a</div></div>"); + + Assert.AreEqual(25, box.ActualTextIndent, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#calc-computed-value")] + public void Children_InheritTheComputedLengthNotTheCalc() + { + var box = Styled("#p{font-size:10px;text-indent:calc(2em + 1px)} #x{font-size:20px}", "<div id=p><div id=x>a</div></div>"); + + Assert.AreEqual(21, box.ActualTextIndent, Delta); + } + + [TestMethod] + [Spec("css-values-4", "#calc-range")] + public void NegativeCalc_IsKeptWhereTheRangeAllowsIt() + { + Assert.AreEqual(-5, Styled("#x{margin-left:calc(5px - 10px)}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#calc-range")] + public void OutOfRangeCalc_IsClamped() + { + var box = Styled("#x{width:50px;width:calc(5px - 10px);padding-left:calc(5px - 10px)}"); + + Assert.AreEqual(0, box.ActualWidth, Delta); + Assert.AreEqual(0, box.ActualPaddingLeft, Delta); + } + + private static CssBox Styled(string css, string body = "<div id=x>a</div>") + { + var (root, _) = LayoutHarness.Layout($"<html><head><style>{css}</style></head><body style='margin:0'>{body}</body></html>", 800, 600); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } + + private static string Before(string css) + { + var before = Styled(css).Boxes.FirstOrDefault(b => b.IsBeforePseudoElement); + Assert.IsNotNull(before, "No ::before box"); + return before.Text ?? string.Concat(LayoutHarness.Descendants(before).SelectMany(b => b.Words).Select(w => w.Text)); + } +} diff --git a/Spec/css-values-4.yml b/Spec/css-values-4.yml index 427082cc2..5e5575cb8 100644 --- a/Spec/css-values-4.yml +++ b/Spec/css-values-4.yml @@ -5,256 +5,265 @@ snapshot: https://www.w3.org/TR/2024/WD-css-values-4-20240312/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown + status: n/a '#value-defs': title: 2 Value Definition Syntax - status: unknown - '#component-types': - title: 2.1 Component Value Types - status: unknown - '#component-combinators': - title: 2.2 Component Value Combinators - status: unknown - '#component-multipliers': - title: 2.3 Component Value Multipliers - status: unknown - '#combinator-multiplier-patterns': - title: 2.4 Combinator and Multiplier Patterns - status: unknown - '#component-whitespace': - title: 2.5 Component Values and White Space - status: unknown - '#component-functions': - title: 2.6 Functional Notation Definitions - status: unknown - '#value-examples': - title: 2.7 Property Value Examples - status: unknown - '#production-blocks': - title: 2.8 Non-Terminal Definitions and Grammar Production Blocks - status: unknown + status: n/a '#combining-values': title: '3 Combining Values: Interpolation, Addition, and Accumulation' - status: unknown - '#combining-range': - title: 3.1 Range Checking - status: unknown + status: n/a '#textual-values': title: 4 Textual Data Types - status: unknown + status: partial '#keywords': title: 4.1 Pre-defined Keywords - status: unknown + status: supported '#common-keywords': title: '4.1.1 CSS-wide keywords: initial, inherit and unset' - status: unknown + status: supported '#custom-idents': title: '4.2 Unprefixed Author-defined Identifiers: the <custom-ident> type' - status: unknown + status: partial + gaps: + - default is accepted as a <custom-ident>, e.g. as a counter name '#dashed-idents': title: '4.3 Prefixed Author-defined Identifiers: the <dashed-ident> type' - status: unknown + status: supported '#strings': title: '4.4 Quoted Strings: the <string> type' - status: unknown + status: partial + gaps: + - A backslash followed by a newline is kept, see CSS 2.1 4.3.7 '#urls': title: '4.5 Resource Locators: the <url> type' - status: unknown + status: partial + gaps: + - src() isn't supported, the declaration is dropped '#relative-urls': title: 4.5.1 Relative URLs - status: unknown + status: supported + gaps: '#local-urls': title: 4.5.1.1 Fragment URLs - status: unknown + status: n/a '#url-empty': title: 4.5.2 Empty URLs - status: unknown + status: supported '#url-modifiers': title: 4.5.3 URL Modifiers - status: unknown + status: unsupported + gaps: + - An unknown modifier after a quoted URL is appended to the URL instead of making the value invalid '#url-processing': title: 4.5.4 URL Processing Model - status: unknown + status: supported + gaps: '#numeric-types': title: 5 Numeric Data Types - status: unknown + status: partial '#numeric-ranges': title: 5.1 Range Restrictions and Range Definition Notation - status: unknown + status: partial + gaps: + - Negative lengths are accepted where the range forbids them, see CSS 2.1 4.3.2 '#integers': title: '5.2 Integers: the <integer> type' - status: unknown + status: supported '#combine-integers': title: 5.2.1 Computation and Combination of <integer> - status: unknown + status: n/a '#numbers': title: '5.3 Real Numbers: the <number> type' - status: unknown + status: partial + gaps: + - Numbers with an exponent (1e1px) aren't accepted, the declaration is dropped '#combine-numbers': title: 5.3.1 Computation and Combination of <number> - status: unknown + status: n/a '#dimensions': title: '5.4 Numbers with Units: dimension values' - status: unknown + status: partial + gaps: + - Units are case-sensitive, 10PX is dropped, see CSS 2.1 4.1.3 '#compat': title: 5.4.1 Compatible Units - status: unknown + status: supported '#combine-dimensions': title: 5.4.2 Combination of Dimensions - status: unknown + status: n/a '#percentages': title: '5.5 Percentages: the <percentage> type' - status: unknown + status: partial + gaps: + - Percentage font sizes are inherited as the percentage, see CSS 2.1 4.3.3 '#combine-percentages': title: 5.5.1 Computation and Combination of <percentage> - status: unknown + status: n/a '#mixed-percentages': title: 5.6 Mixing Percentages and Dimensions - status: unknown + status: supported '#combine-mixed': title: 5.6.1 Computation and Combination of Percentage and Dimension Mixes - status: unknown + status: n/a '#ratios': title: '5.7 Ratios: the <ratio> type' - status: unknown + status: partial + gaps: + - A ratio given as a single number (1.3) isn't accepted '#combine-ratio': title: 5.7.1 Combination of <ratio> - status: unknown + status: n/a '#lengths': title: '6 Distance Units: the <length> type' - status: unknown + status: partial '#relative-lengths': title: 6.1 Relative Lengths - status: unknown + status: partial '#font-relative-lengths': title: '6.1.1 Font-relative Lengths: the em, rem, ex, rex, cap, rcap, ch, rch, ic, ric, lh, rlh units' - status: unknown + status: partial + gaps: + - ch is accepted but resolves to 0 + - cap, ic, lh, rex, rch, rcap, ric and rlh aren't supported, the declaration is dropped + - rem in word-spacing doesn't resolve against the root element's font size '#viewport-relative-lengths': title: '6.1.2 Viewport-percentage Lengths: the *vw, *vh, *vi, *vb, *vmin, *vmax units' - status: unknown + status: parsed-only '#viewport-variants': title: 6.1.2.1 The Large, Small, and Dynamic Viewport Sizes - status: unknown + status: unsupported + gaps: + - The sv*, lv* and dv* units aren't supported, the declaration is dropped '#viewport-relative-units': title: 6.1.2.2 The Various Viewport-relative Units - status: unknown + status: parsed-only + gaps: + - vw, vh, vmin and vmax are accepted but resolve to 0, and invalidate a calc() + - vi and vb aren't supported, the declaration is dropped '#absolute-lengths': title: '6.2 Absolute Lengths: the cm, mm, Q, in, pt, pc, px units' - status: unknown + status: partial + gaps: + - Q isn't supported, the declaration is dropped '#other-units': title: 7 Other Quantities - status: unknown + status: partial '#angles': title: '7.1 Angle Units: the <angle> type and deg, grad, rad, turn units' - status: unknown + status: supported '#time': title: '7.2 Duration Units: the <time> type and s, ms units' - status: unknown + status: parsed-only + gaps: + - Times are parsed, but transitions and animations never run '#frequency': title: '7.3 Frequency Units: the <frequency> type and Hz, kHz units' - status: unknown + status: n/a '#resolution': title: '7.4 Resolution Units: the <resolution> type and dpi, dpcm, dppx units' - status: unknown + status: supported '#defined-elsewhere': title: 8 Data Types Defined Elsewhere - status: unknown - '#colors': - title: '8.1 Colors: the <color> type' - status: unknown - '#combine-colors': - title: 8.1.1 Combination of <color> - status: unknown - '#images': - title: '8.2 Images: the <image> type' - status: unknown - '#combine-images': - title: 8.2.1 Combination of <image> - status: unknown + status: n/a '#position': title: '8.3 2D Positioning: the <position> type' - status: unknown + status: partial '#position-parsing': title: 8.3.1 Parsing <position> - status: unknown + status: partial + gaps: + - "'background-position' only resolves keywords, see CSS 2.1 14.2.1" '#position-serialization': title: 8.3.2 Serializing <position> - status: unknown + status: n/a '#combine-positions': title: 8.3.3 Combination of <position> - status: unknown + status: n/a '#functional-notations': title: 9 Functional Notations - status: unknown + status: supported '#math': title: 10 Mathematical Expressions - status: unknown + status: partial '#calc-func': title: '10.1 Basic Arithmetic: calc()' - status: unknown + status: supported '#comp-func': title: '10.2 Comparison Functions: min(), max(), and clamp()' - status: unknown + status: partial + gaps: + - clamp() with a minimum above the maximum resolves to the maximum instead of the minimum + - none isn't accepted as a clamp() bound '#round-func': title: '10.3 Stepped Value Functions: round(), mod(), and rem()' - status: unknown + status: unsupported '#round-infinities': title: 10.3.1 Argument Ranges - status: unknown + status: unsupported '#trig-funcs': title: '10.4 Trigonometric Functions: sin(), cos(), tan(), asin(), acos(), atan(), and atan2()' - status: unknown + status: unsupported '#trig-infinities': title: 10.4.1 Argument Ranges - status: unknown + status: unsupported '#exponent-funcs': title: '10.5 Exponential Functions: pow(), sqrt(), hypot(), log(), exp()' - status: unknown + status: unsupported '#exponent-infinities': title: 10.5.1 Argument Ranges - status: unknown + status: unsupported '#sign-funcs': title: '10.6 Sign-Related Functions: abs(), sign()' - status: unknown + status: unsupported '#calc-keywords': title: 10.7 Numeric Keywords - status: unknown + status: unsupported '#calc-constants': title: '10.7.1 Numeric Constants: e, pi' - status: unknown + status: unsupported '#calc-error-constants': title: '10.7.2 Degenerate Numeric Constants: infinity, -infinity, NaN' - status: unknown + status: unsupported '#calc-variables': title: 10.7.3 Numeric Variables - status: unknown + status: n/a '#calc-syntax': title: 10.8 Syntax - status: unknown + status: partial + gaps: + - calc() only takes em, rem, ex, px, cm, mm, in, pt and pc lengths, any other unit makes it invalid '#calc-type-checking': title: 10.9 Type Checking - status: unknown + status: partial + gaps: + - A number calc() isn't accepted where an <integer> is expected, e.g. in counter-reset + - A product or quotient of two dimensions makes the calc() invalid even when the result has a valid type '#calc-ieee': title: 10.9.1 Infinities, NaN, and Signed Zero - status: unknown + status: unsupported + gaps: + - Division by zero makes the declaration invalid instead of producing infinity '#calc-internal': title: 10.10 Internal Representation - status: unknown + status: supported '#calc-simplification': title: 10.10.1 Simplification - status: unknown + status: partial + gaps: + - A number times a sum isn't multiplied into the sum, 'calc((1em + 5px) * 2)' stays as is '#calc-computed-value': title: 10.11 Computed Value - status: unknown + status: partial + gaps: + - A calc() with em is inherited as the expression and resolved again against the child's font size '#calc-range': title: 10.12 Range Checking - status: unknown + status: partial + gaps: + - The result isn't clamped, width and padding take a negative calc() '#calc-serialize': title: 10.13 Serialization - status: unknown + status: n/a '#combine-math': title: 10.14 Combination of Math Functions - status: unknown + status: n/a From ee10b34569d6eafbed1f82b954c42a06ba753264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:16:05 +0100 Subject: [PATCH 062/150] Verify and document CSS Color Level 4 compliance --- .../ColorInterpolationMethodGrammar.cs | 13 +- .../Extensions/ColorFunctionExtensions.cs | 32 +- .../Core/CssEngine/Values/Color.cs | 13 +- .../Core/CssEngine/Values/ColorSpaceMath.cs | 9 +- .../HtmlRenderer/Core/Parse/CssValueParser.cs | 36 +- .../ColorFunctionPaintIntegrationTests.cs | 32 ++ .../ForegroundColorPaintIntegrationTests.cs | 2 + .../SystemColorPaintIntegrationTests.cs | 45 +- .../ColorInterpolationMethodGrammarTests.cs | 11 +- .../Test/HtmlRenderer.Test/Css/ColorTests.cs | 11 + .../Css/SyntaxAndDataTypesTests.cs | 4 + .../CssModules/CssColorTests.cs | 452 ++++++++++++++++++ Spec/css-color-4.yml | 322 ++++++------- 13 files changed, 711 insertions(+), 271 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/ColorFunctionPaintIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssColorTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/ColorInterpolationMethodGrammar.cs b/Source/HtmlRenderer/Core/CssEngine/ColorInterpolationMethodGrammar.cs index f508d92c9..4cbfdfe5a 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ColorInterpolationMethodGrammar.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ColorInterpolationMethodGrammar.cs @@ -4,16 +4,9 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The single, shared grammar for a gradient's <c>&lt;color-interpolation-method&gt;</c> prelude - /// (<a href="https://drafts.csswg.org/css-images-4/#color-interpolation-method">CSS Images 4 §3.1</a>): - /// <code>in [ &lt;rectangular-color-space&gt; | &lt;polar-color-space&gt; &lt;hue-interpolation-method&gt;? ]</code>. - /// Both the CSS-OM validator (<see cref="GradientConverter"/>, which drops a malformed prelude at - /// parse time) and the render-time parser classify keywords through this one class, so the two - /// layers cannot disagree about which preludes are valid. - /// - /// Only the color spaces the interpolation math actually implements are recognized; the - /// wide-gamut RGB spaces <c>a98-rgb</c>/<c>prophoto-rgb</c>/<c>rec2020</c> are valid CSS but are - /// deliberately rejected here rather than approximated. + /// The <c>&lt;color-interpolation-method&gt;</c> of a gradient (CSS Color 4 §13.3). Only the color + /// spaces <see cref="ColorSpaceMath"/> implements are accepted, so a98-rgb, prophoto-rgb, rec2020 + /// and display-p3-linear are rejected. /// </summary> internal static class ColorInterpolationMethodGrammar { diff --git a/Source/HtmlRenderer/Core/CssEngine/Extensions/ColorFunctionExtensions.cs b/Source/HtmlRenderer/Core/CssEngine/Extensions/ColorFunctionExtensions.cs index a92adb739..ad5c46ac3 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Extensions/ColorFunctionExtensions.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Extensions/ColorFunctionExtensions.cs @@ -5,20 +5,14 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Resolves the CSS color <em>function</em> forms from a tokenized value into a concrete - /// <see cref="Color"/>: the legacy/CSS Color 4 <c>rgb()/rgba()/hsl()/hsla()/hwb()/gray()</c> and the - /// CSS Color 4/5 <c>lab()/oklab()/lch()/oklch()/color-mix()</c>. Tokenization is the CSS-OM's job, so - /// this consumes the already-parsed <see cref="FunctionToken"/> stream (nested functions arrive as a - /// single token) rather than re-scanning the source text. + /// Resolves the color functions to an sRGB <see cref="Color"/>: rgb(), rgba(), hsl(), hsla(), hwb(), + /// lab(), lch(), oklab() and oklch() (CSS Color 4), color-mix() (CSS Color 5) and gray(). /// </summary> internal static class ColorFunctionExtensions { /// <summary> - /// Fully resolves a tokenized color value to a concrete <see cref="Color"/> - named colors and - /// hex via <see cref="ValueExtensions.ToColor"/>, plus every color <em>function</em> - /// (rgb/hsl/hwb/gray/lab/oklab/lch/oklch/color-mix). Unlike <c>ToColor</c> (which the CSS-OM - /// cascade uses and which deliberately preserves a function's specified form for serialization), - /// this is the render-layer resolver: it always computes the sRGB value. + /// Resolves a named, hex or function color to sRGB. Unlike <see cref="ValueExtensions.ToColor"/>, + /// which keeps a function as specified, this always computes the color. /// </summary> public static Color? ToResolvedColor(this IEnumerable<Token> value) { @@ -27,8 +21,7 @@ internal static class ColorFunctionExtensions var element = value.OnlyOrDefault(); - // A hex color nested inside a function (e.g. a color-mix() operand) tokenizes as a Hash - // keyword token rather than a ColorToken, so ToColor misses it - resolve it here. + // Inside a function, e.g. as a color-mix() operand, a hex color is a hash token if (element != null && element.Type == TokenType.Hash) return Color.FromHex(element.Data); var function = element as FunctionToken; @@ -168,8 +161,7 @@ internal static class ColorFunctionExtensions if (!ParseMixOperand(segments[1], out c1, out p1)) return null; if (!ParseMixOperand(segments[2], out c2, out p2)) return null; - // CSS Color 5 §3 normalization: a missing percentage is 100% minus the other; percentages - // scale to sum 1, and a pre-normalization sum below 100% scales the result alpha down. + // CSS Color 5 §3.2 if (p1 == null && p2 == null) { p1 = 0.5; p2 = 0.5; } else if (p1 == null) p1 = Math.Max(0, 1.0 - p2.Value); else if (p2 == null) p2 = Math.Max(0, 1.0 - p1.Value); @@ -194,8 +186,7 @@ private static bool ParseMixOperand(List<Token> segment, out Color color, out do } if (tokens.Count == 0) return false; - // The optional percentage may appear before or after the color (CSS Color 5 §3.1), e.g. - // both `red 30%` and `30% red` are valid. + // The percentage may come before or after the color (CSS Color 5 §3) if (tokens[tokens.Count - 1].Type == TokenType.Percentage) { var value = AsTokens(tokens[tokens.Count - 1]).ToPercent(); @@ -209,8 +200,6 @@ private static bool ParseMixOperand(List<Token> segment, out Color color, out do tokens.RemoveAt(0); } - // A color-mix operand may itself be any color form (named/hex/rgb/oklch/...), so resolve it - // through the full render-layer resolver rather than the named/hex-only ToColor. var resolved = tokens.ToResolvedColor(); if (resolved == null) return false; color = resolved.Value; @@ -219,9 +208,8 @@ private static bool ParseMixOperand(List<Token> segment, out Color color, out do // Token helpers - // Partitions a function's argument tokens into `count` component tokens and an optional alpha - // token. Whitespace and commas separate; a "/" delimiter (CSS Color 4) marks the alpha that - // follows. Without a slash, a trailing extra value (legacy rgba/hsla comma alpha) is the alpha. + // Splits the arguments into `count` components and an optional alpha (CSS Color 4 §4.1). Commas + // and whitespace both separate, the alpha follows a "/" or is the extra last value. private static bool Extract(IEnumerable<Token> args, int count, out List<Token> components, out Token alpha) { components = new List<Token>(); @@ -358,8 +346,6 @@ private static ColorSpaceMath.HueMethod MapHueName(string name) /// </summary> internal static Color MixPaletteColors(Color c1, Color c2, string spaceName, string hueName, double? p1, double? p2) { - // CSS keywords are case-insensitive; MapSpace/MapHueName match lowercase literals, so fold first - // (a mixed-case space would otherwise silently fall through to the sRGB/Shorter default). var space = MapSpace(spaceName.ToLowerInvariant()); var hue = MapHueName(hueName.ToLowerInvariant()); diff --git a/Source/HtmlRenderer/Core/CssEngine/Values/Color.cs b/Source/HtmlRenderer/Core/CssEngine/Values/Color.cs index 06b326524..23613aa99 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Values/Color.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Values/Color.cs @@ -268,10 +268,8 @@ public static Color FromHwba(float hue, float whiteness, float blackness, float return FromRgba(red, green, blue, alpha); } - // CSS Color 4/5 spaces (lab/oklab/lch/oklch) and color-mix(). - // Components are in each space's native units (L: lab/lch 0..100, oklab/oklch 0..1; a/b and C - // in native chroma units; H in degrees). The conversion math is shared with gradient - // interpolation via ColorSpaceMath so there is a single implementation of the color science. + // CSS Color 4 §9. Components are in each space's own units: L 0..100 for lab/lch and 0..1 for + // oklab/oklch, H in degrees. public static Color FromLab(double l, double a, double b, float alpha = 1f) { @@ -300,10 +298,9 @@ private static Color FromSpaceComponents(double[] v, ColorSpaceMath.Space cs, fl } /// <summary> - /// <see href="https://www.w3.org/TR/css-color-5/#color-mix">CSS Color 5 <c>color-mix()</c></see>: - /// premultiplied-alpha interpolation in <paramref name="cs"/> with weight <paramref name="t"/> - /// toward <paramref name="c2"/>, the result alpha then scaled by <paramref name="alphaMultiplier"/> - /// (below 1 only when the two percentages summed to under 100%). + /// Interpolates with premultiplied alpha in <paramref name="cs"/> (CSS Color 4 §13), <paramref name="t"/> + /// is the weight of <paramref name="c2"/>. <paramref name="alphaMultiplier"/> is below 1 when the + /// color-mix() percentages add up to less than 100% (CSS Color 5 §3.3). /// </summary> public static Color Mix(Color c1, Color c2, double t, ColorSpaceMath.Space cs, ColorSpaceMath.HueMethod hue, double alphaMultiplier) { diff --git a/Source/HtmlRenderer/Core/CssEngine/Values/ColorSpaceMath.cs b/Source/HtmlRenderer/Core/CssEngine/Values/ColorSpaceMath.cs index 452e53ce2..5a17ca6c9 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Values/ColorSpaceMath.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Values/ColorSpaceMath.cs @@ -3,11 +3,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The single home for CSS color-space conversion math (CSS Color 4/5): forward/reverse transforms - /// between gamma-encoded sRGB and every interpolation space, plus polar-hue interpolation. Kept in - /// the CSS-OM (rather than the render layer) so that solid-color resolution (<see cref="Color"/>'s - /// <c>oklch()</c>/<c>lab()</c>/<c>color-mix()</c> factories) and any gradient interpolation that - /// needs a non-sRGB space share one implementation. + /// Conversion between sRGB and the other color spaces (CSS Color 4 §11), and hue interpolation + /// (CSS Color 4 §13.6). /// </summary> internal static class ColorSpaceMath { @@ -340,7 +337,7 @@ public static double[] ToSpace(double r, double g, double b, Space cs) } } - // Reverse: color-space coordinates -> gamut-mapped sRGB (0..1) + // Reverse: color-space coordinates -> sRGB (0..1), clipped instead of gamut mapped (CSS Color 4 §14.2) public static (double R, double G, double B) FromSpace(double[] v, Space cs) { diff --git a/Source/HtmlRenderer/Core/Parse/CssValueParser.cs b/Source/HtmlRenderer/Core/Parse/CssValueParser.cs index 78e589ee5..0d185a324 100644 --- a/Source/HtmlRenderer/Core/Parse/CssValueParser.cs +++ b/Source/HtmlRenderer/Core/Parse/CssValueParser.cs @@ -615,12 +615,8 @@ private static bool GetColorByRgba(string str, int idx, int length, out RColor c } /// <summary> - /// Parses an rgba()/hsla()-style alpha component, per CSS syntax a fractional number in [0,1] - /// (e.g. "0.5") or a percentage (e.g. "50%") - unlike R/G/B, never a bare 0-255 integer. This is - /// also the canonical form the vendored CSS engine's Color.ToString() emits for any color with - /// partial transparency (hex-alpha, hsl()/hsla(), hwb(), modern space-separated rgb() syntax - - /// all normalized to "rgba(r, g, b, alpha)" at CSS-OM parse time), so this is the path that - /// makes those all resolve correctly here, not just literal author-written rgba(). + /// Parses an alpha, a number from 0 to 1 or a percentage (CSS Color 4 §4.2). A hex color with + /// alpha arrives here too, the CSS engine turns it into rgba(). /// </summary> /// <returns>alpha as a 0-255 byte value, or -1 if invalid</returns> private static int ParseAlphaAtIndex(string str, ref int startIdx) @@ -661,15 +657,8 @@ private static int ParseAlphaAtIndex(string str, ref int startIdx) } /// <summary> - /// Get color by parsing given HSL/HSLA value color string (hsl(210, 100%, 50%), hsla(210, 100%, 50%, 0.5)).<br/> - /// Unlike hex and rgb()/rgba(), the vendored CSS-OM's HslColorConverter/HslaColorConverter only - /// validate hsl() syntax at parse time (hue is a valid angle, saturation/lightness are - /// percentages) - they never actually convert it to an RGB Color the way rgb()/rgba()/hex do - /// (see Core/CssEngine/Model/Converters.cs's HslColorConverter/HslaColorConverter, and compare - /// to Color.ToString() which only ever emits rgb()/rgba()). So unlike those, the raw string this - /// engine sees for an hsl()-declared color is still the literal hsl() text (with hue normalized - /// to a "Ndeg" suffix), not a canonical rgba() - this method does the actual hue/sat/light -> RGB - /// conversion, reusing the vendored Color.FromHsla for the math. + /// Get color by parsing given HSL/HSLA value color string (hsl(210, 100%, 50%), hsla(210, 100%, 50%, 0.5)). + /// Only the legacy comma syntax (CSS Color 4 §4.1.2), the CSS engine keeps hsl() as specified. /// </summary> /// <returns>true - valid color, false - otherwise</returns> private static bool GetColorByHsl(string str, int idx, int length, bool hasAlpha, out RColor color) @@ -721,8 +710,7 @@ private static bool GetColorByHsl(string str, int idx, int length, bool hasAlpha } /// <summary> - /// Parses an hsl() hue component - a bare number (treated as degrees, per CSS) or a number with - /// an explicit angle unit (deg/rad/grad/turn). + /// Parses a hue, degrees or an angle (CSS Color 4 §4.3). /// </summary> private static bool TryParseAngleDegrees(string value, out double degrees) { @@ -757,13 +745,9 @@ private static bool TryParseAngleDegrees(string value, out double degrees) /// understands. /// </summary> /// <remarks> - /// The adapter's palette is tried first because a bare name is by far the common case and a - /// dictionary lookup is cheaper than tokenizing. Anything it doesn't recognize - a CSS Color 4/5 - /// function such as <c>lab()</c>/<c>oklch()</c>/<c>color-mix()</c>, or a fully transparent color, - /// which the palette reports with a zero alpha and is indistinguishable from "unknown" here - is - /// then resolved through the engine, which is the same code path the cascade validated the value - /// with. Hex/rgb()/rgba()/hsl()/hsla() never reach this method; they keep their own fast paths in - /// <see cref="TryGetColor"/>. + /// The adapter's palette comes first, it reports an unknown name and a transparent color alike + /// with a zero alpha, both then go through the CSS engine. Hex, rgb(), rgba(), hsl() and hsla() + /// don't get here, see <see cref="TryGetColor"/>. /// </remarks> /// <returns>true - valid color, false - otherwise</returns> private bool GetColorByName(string str, int idx, int length, out RColor color) @@ -773,9 +757,7 @@ private bool GetColorByName(string str, int idx, int length, out RColor color) color = _adapter.GetColor(text); if (color.A > 0) return true; - // Value mode, so a hex colour lexes as a single colour token. Outside it, a digit-leading - // hex such as #0000ff splits into a "#" delimiter plus a dimension ("0000" + unit "ff"), - // which then fails to resolve as an operand inside color-mix(). + // In value mode a hex color is one token, otherwise #0000ff is a delimiter and a dimension var resolved = GetCssTokens(text, true).ToResolvedColor(); if (resolved != null) { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/ColorFunctionPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/ColorFunctionPaintIntegrationTests.cs new file mode 100644 index 000000000..6469b20a3 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/ColorFunctionPaintIntegrationTests.cs @@ -0,0 +1,32 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[TestClass] +public sealed class ColorFunctionPaintIntegrationTests +{ + [TestMethod] + [DataRow("lab(50 40 59.5)", 255, 191, 87, 0)] + [DataRow("oklch(62.8% 0.258 29.2 / 0.5)", 128, 255, 0, 0)] + [DataRow("hwb(120 0% 0%)", 255, 0, 255, 0)] + [Spec("css-color-4", "#the-color-property")] + [Spec("css-color-4", "#lab-colors")] + public void ColorFunction_PaintsTextAndBackground(string color, int a, int r, int g, int b) + { + AssertPainted(color, RColor.FromArgb(a, r, g, b)); + } + + private static void AssertPainted(string color, RColor expected) + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + $"<div id='b' style='width:100px;height:50px;color:{color};background-color:{color}'>x</div>")); + + var graphics = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + + Assert.AreEqual(expected, graphics.DrawStringCalls.Single().Color); + Assert.AreEqual(expected, graphics.Log.OfType<RecordingGraphics.DrawRectCall>().Single().Color); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/ForegroundColorPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/ForegroundColorPaintIntegrationTests.cs index 684055d76..f9c83b2ca 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/ForegroundColorPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/ForegroundColorPaintIntegrationTests.cs @@ -12,6 +12,7 @@ public sealed class ForegroundColorPaintIntegrationTests [DataRow("rgb(10,20,30)")] [DataRow("#0a141e")] [Spec("css21", "colors.html#colors")] + [Spec("css-color-4", "#the-color-property")] public void Color_PaintsTheText(string color) { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -24,6 +25,7 @@ public void Color_PaintsTheText(string color) [TestMethod] [Spec("css21", "colors.html#colors")] + [Spec("css-color-4", "#the-color-property")] public void Color_IsInherited() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/SystemColorPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/SystemColorPaintIntegrationTests.cs index 0e77b76f4..cb94e650e 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/SystemColorPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/SystemColorPaintIntegrationTests.cs @@ -8,19 +8,25 @@ namespace HtmlRenderer.IntegrationTest.Painting; [TestClass] public sealed class SystemColorPaintIntegrationTests { + [TestMethod] + [DataRow("ButtonFace")] + [DataRow("ButtonText")] + [DataRow("GrayText")] + [DataRow("Highlight")] + [DataRow("HighlightText")] + [Spec("css21", "ui.html#system-colors")] + [Spec("css-color-4", "#css-system-colors")] + public void SystemColor_IsAValidBackgroundColor(string name) => AssertIsAValidBackgroundColor(name); + + // Deprecated in CSS Color 4, Appendix A [TestMethod] [DataRow("ActiveBorder")] [DataRow("ActiveCaption")] [DataRow("AppWorkspace")] [DataRow("Background")] - [DataRow("ButtonFace")] [DataRow("ButtonHighlight")] [DataRow("ButtonShadow")] - [DataRow("ButtonText")] [DataRow("CaptionText")] - [DataRow("GrayText")] - [DataRow("Highlight")] - [DataRow("HighlightText")] [DataRow("InactiveBorder")] [DataRow("InactiveCaption")] [DataRow("InactiveCaptionText")] @@ -38,30 +44,33 @@ public sealed class SystemColorPaintIntegrationTests [DataRow("WindowFrame")] [DataRow("WindowText")] [Spec("css21", "ui.html#system-colors")] - public void SystemColor_IsAValidBackgroundColor(string name) + public void DeprecatedSystemColor_IsAValidBackgroundColor(string name) => AssertIsAValidBackgroundColor(name); + + [TestMethod] + [DataRow("buttontext")] + [DataRow("BUTTONTEXT")] + [Spec("css21", "ui.html#system-colors")] + [Spec("css-color-4", "#css-system-colors")] + public void SystemColor_IsCaseInsensitive(string name) { - var placeholder = RColor.FromArgb(1, 2, 3); var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( - $"<div id='b' style='width:100px;height:50px;background-color:rgb(1,2,3);background-color:{name}'></div>")); + $"<div id='b' style='color:rgb(1,2,3);color:{name}'>x</div>")); var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); - var fill = g.Log.OfType<RecordingGraphics.DrawRectCall>().Single(); - Assert.AreNotEqual(placeholder, fill.Color); - Assert.AreEqual(255, fill.Color.A); + Assert.AreNotEqual(RColor.FromArgb(1, 2, 3), g.DrawStringCalls.Single().Color); } - [TestMethod] - [DataRow("windowtext")] - [DataRow("WINDOWTEXT")] - [Spec("css21", "ui.html#system-colors")] - public void SystemColor_IsCaseInsensitive(string name) + private static void AssertIsAValidBackgroundColor(string name) { + var placeholder = RColor.FromArgb(1, 2, 3); var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( - $"<div id='b' style='color:rgb(1,2,3);color:{name}'>x</div>")); + $"<div id='b' style='width:100px;height:50px;background-color:rgb(1,2,3);background-color:{name}'></div>")); var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); - Assert.AreNotEqual(RColor.FromArgb(1, 2, 3), g.DrawStringCalls.Single().Color); + var fill = g.Log.OfType<RecordingGraphics.DrawRectCall>().Single(); + Assert.AreNotEqual(placeholder, fill.Color); + Assert.AreEqual(255, fill.Color.A); } } diff --git a/Source/Test/HtmlRenderer.Test/Css/ColorInterpolationMethodGrammarTests.cs b/Source/Test/HtmlRenderer.Test/Css/ColorInterpolationMethodGrammarTests.cs index b94284703..720bdb55d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ColorInterpolationMethodGrammarTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ColorInterpolationMethodGrammarTests.cs @@ -6,14 +6,8 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/ColorInterpolationMethodGrammarTests.cs. -/// Tests for the shared <see cref="ColorInterpolationMethodGrammar"/> (the single home of the gradient -/// <c>in &lt;color-interpolation-method&gt;</c> grammar consumed by both the CSS-OM validator and the -/// render-time parser). -/// -/// The grammar validates only the <c>in …</c> slice and returns the rest of the group as the remainder; -/// whether that remainder is a valid gradient direction/shape is the caller's job. So e.g. a hue method -/// on a rectangular space parses here (remainder <c>"longer hue"</c>) and is rejected downstream. +/// <see cref="ColorInterpolationMethodGrammar"/> only takes the <c>in …</c> part out of a gradient's +/// arguments, the remainder is validated by the gradient, e.g. a hue method after a rectangular space. /// </summary> [TestClass] public sealed class ColorInterpolationMethodGrammarTests @@ -37,6 +31,7 @@ public void NoInClause_PassesThroughUnchanged() } [TestMethod] + [Spec("css-color-4", "#interpolation-space")] [DataRow("in srgb")] [DataRow("in srgb-linear")] [DataRow("in display-p3")] diff --git a/Source/Test/HtmlRenderer.Test/Css/ColorTests.cs b/Source/Test/HtmlRenderer.Test/Css/ColorTests.cs index dbdf90532..5286fa2ac 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ColorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ColorTests.cs @@ -22,6 +22,7 @@ public void ColorInvalidHexDigitString() } [TestMethod] + [Spec("css-color-4", "#hex-notation")] public void ColorValidFourLetterString() { var color = "abcd"; @@ -110,6 +111,7 @@ public void ColorMixedLongString() } [TestMethod] + [Spec("css-color-4", "#hex-notation")] public void ColorMixedEightDigitLongStringTransparent() { var color = "facc3600"; @@ -118,6 +120,7 @@ public void ColorMixedEightDigitLongStringTransparent() } [TestMethod] + [Spec("css-color-4", "#hex-notation")] public void ColorMixedEightDigitLongStringOpaque() { var color = "facc36ff"; @@ -192,6 +195,7 @@ public void ColorHslBlackMixed2() } [TestMethod] + [Spec("css-color-4", "#hsl-to-rgb")] public void ColorHslRedPure() { var color = Color.FromHsl(0, 1, 0.5f); @@ -199,6 +203,7 @@ public void ColorHslRedPure() } [TestMethod] + [Spec("css-color-4", "#hsl-to-rgb")] public void ColorHslGreenPure() { var color = Color.FromHsl(1f / 3f, 1, 0.5f); @@ -206,6 +211,7 @@ public void ColorHslGreenPure() } [TestMethod] + [Spec("css-color-4", "#hsl-to-rgb")] public void ColorHslBluePure() { var color = Color.FromHsl(2f / 3f, 1, 0.5f); @@ -227,6 +233,7 @@ public void ColorHslMagentaPure() } [TestMethod] + [Spec("css-color-4", "#hsl-to-rgb")] public void ColorHslYellowGreenMixed() { var color = Color.FromHsl(1f / 4f, 0.75f, 0.63f); @@ -234,6 +241,7 @@ public void ColorHslYellowGreenMixed() } [TestMethod] + [Spec("css-color-4", "#hsl-to-rgb")] public void ColorHslGrayBlueMixed() { var color = Color.FromHsl(210f / 360f, 0.25f, 0.25f); @@ -318,6 +326,7 @@ public void ColorFromGrayPercent() } [TestMethod] + [Spec("css-color-4", "#hwb-to-rgb")] public void ColorFromHwbRed() { var color = Color.FromHwb(0f, 0.2f, 0.2f); @@ -325,6 +334,7 @@ public void ColorFromHwbRed() } [TestMethod] + [Spec("css-color-4", "#hwb-to-rgb")] public void ColorFromHwbGreen() { var color = Color.FromHwb(1f / 3f, 0.2f, 0.6f); @@ -332,6 +342,7 @@ public void ColorFromHwbGreen() } [TestMethod] + [Spec("css-color-4", "#hwb-to-rgb")] public void ColorFromHwbMagentaTransparent() { var color = Color.FromHwba(5f / 6f, 0.4f, 0.2f, 0.5f); diff --git a/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs b/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs index cb84d67df..0b1178fd2 100644 --- a/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs @@ -544,6 +544,7 @@ public void CounterNoneAndCountersNotation_FormatTheCounters(string value, strin [TestMethod] [Spec("css21", "syndata.html#color-units")] + [Spec("css-color-4", "#rgb-functions")] [DataRow("maroon", 0x80, 0x00, 0x00)] [DataRow("red", 0xff, 0x00, 0x00)] [DataRow("orange", 0xff, 0xa5, 0x00)] @@ -574,6 +575,7 @@ public void Color_ResolvesToTheSpecifiedRgbValue(string value, int r, int g, int [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "syndata.html#color-units")] + [Spec("css-color-4", "#rgb-functions")] [DataRow("rgb(255,-10,0)")] [DataRow("rgb(110%, 0%, 0%)")] public void ColorOutsideTheGamut_IsClipped(string value) @@ -583,6 +585,7 @@ public void ColorOutsideTheGamut_IsClipped(string value) [TestMethod] [Spec("css21", "syndata.html#color-units")] + [Spec("css-color-4", "#hex-notation")] [DataRow("#ff000")] [DataRow("#gg0000")] [DataRow("ff0000")] @@ -595,6 +598,7 @@ public void InvalidColor_IsIgnored(string value) [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "syndata.html#color-units")] + [Spec("css-color-4", "#color-syntax-legacy")] [DataRow("rgb(255, 0%, 0)")] [DataRow("rgb(255, 0)")] public void InvalidRgbFunction_IsIgnored(string value) diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssColorTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssColorTests.cs new file mode 100644 index 000000000..ec7c0d823 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssColorTests.cs @@ -0,0 +1,452 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssColorTests +{ + private static readonly RColor Green = RColor.FromArgb(255, 0, 128, 0); + + [TestMethod] + [Spec("css-color-4", "#color-syntax-modern")] + [DataRow("rgb(255 0 0)", 255, 0, 0)] + [DataRow("rgba(255 0 0)", 255, 0, 0)] + [DataRow("RGB(255 0 0)", 255, 0, 0)] + [DataRow("hwb(120 0% 0%)", 0, 255, 0)] + [DataRow("lab(50 40 59.5)", 191, 87, 0)] + [DataRow("lch(50 72 56deg)", 191, 87, 0)] + [DataRow("oklab(0.628 0.225 0.126)", 255, 0, 0)] + [DataRow("oklch(62.8% 0.258 29.2)", 255, 0, 0)] + public void ModernSyntax_SeparatesTheComponentsWithSpaces(string value, int r, int g, int b) + { + Assert.AreEqual(RColor.FromArgb(255, r, g, b), Color(value)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#color-syntax-modern")] + [DataRow("hsl(120 100% 50%)", 0, 255, 0)] + [DataRow("hsl(120deg 100 50)", 0, 255, 0)] + [DataRow("hsla(120 100% 50%)", 0, 255, 0)] + [DataRow("rgb(100% 0% 0%)", 255, 0, 0)] + [DataRow("rgb(255.4 0 0)", 255, 0, 0)] + [DataRow("rgb(calc(200 + 55) 0 0)", 255, 0, 0)] + public void ModernSyntax_IsSupportedByEveryFunction(string value, int r, int g, int b) + { + Assert.AreEqual(RColor.FromArgb(255, r, g, b), Color(value)); + } + + [TestMethod] + [Spec("css-color-4", "#color-syntax-legacy")] + [DataRow("rgb(255, 0, 0)", 255, 0, 0, 255)] + [DataRow("rgba(255, 0, 0, 0.5)", 255, 0, 0, 128)] + [DataRow("rgba(255, 0, 0, 50%)", 255, 0, 0, 128)] + [DataRow("hsl(120, 100%, 50%)", 0, 255, 0, 255)] + [DataRow("hsla(120, 100%, 50%, 0.5)", 0, 255, 0, 128)] + public void LegacySyntax_SeparatesTheComponentsWithCommas(string value, int r, int g, int b, int a) + { + Assert.AreEqual(RColor.FromArgb(a, r, g, b), Color(value)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#color-syntax-legacy")] + [DataRow("rgb(255, 0, 0, 0.5)", 255, 0, 0, 128)] + [DataRow("hsl(120, 100%, 50%, 0.5)", 0, 255, 0, 128)] + [DataRow("rgb(127.6, 0, 0)", 128, 0, 0, 255)] + public void LegacySyntax_TakesAnAlphaAndFractions(string value, int r, int g, int b, int a) + { + Assert.AreEqual(RColor.FromArgb(a, r, g, b), Color(value)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#color-syntax-legacy")] + [DataRow("rgb(1, 2 3)")] + [DataRow("rgb(255 0 0 0.5)")] + [DataRow("hwb(120, 0%, 0%)")] + [DataRow("hwb(120 0% 0% 0.5)")] + [DataRow("lab(50, 40, 59.5)")] + [DataRow("oklab(0.628, 0.225, 0.126)")] + [DataRow("rgb(1 2)")] + public void InvalidSyntax_IsIgnored(string value) + { + Assert.AreEqual(Green, Color($"green;color:{value}")); + } + + [TestMethod] + [Spec("css-color-4", "#alpha-syntax")] + [DataRow("rgba(255, 0, 0, 0.5)", 128)] + [DataRow("hwb(0 0% 0% / 0.5)", 128)] + [DataRow("lab(50 40 59.5 / 50%)", 128)] + [DataRow("oklch(62.8% 0.258 29.2 / 0.25)", 64)] + [DataRow("lch(50 72 56 / 2)", 255)] + public void Alpha_FollowsASlash(string value, int alpha) + { + Assert.AreEqual(alpha, Color(value).A); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#alpha-syntax")] + [DataRow("rgb(255 0 0 / 0.5)", 128)] + [DataRow("rgba(255 0 0 / 50%)", 128)] + [DataRow("lab(50 40 59.5 / -1)", 0)] + public void Alpha_IsAppliedAndClamped(string value, int alpha) + { + Assert.AreEqual(alpha, Color(value).A); + } + + [TestMethod] + [Spec("css-color-4", "#hue-syntax")] + [DataRow("hsl(120, 100%, 50%)")] + [DataRow("hsl(120deg, 100%, 50%)")] + [DataRow("hsl(0.33333turn, 100%, 50%)")] + [DataRow("hsl(480, 100%, 50%)")] + [DataRow("hwb(120deg 0% 0%)")] + [DataRow("lch(87.8 113.3 134.4)")] + public void Hue_IsANumberOfDegreesOrAnAngle(string value) + { + AssertColor(RColor.FromArgb(255, 0, 255, 0), Color(value), 1); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#hue-syntax")] + [DataRow("hwb(480 0% 0%)")] + [DataRow("hwb(-240deg 0% 0%)")] + public void Hue_OutsideOneTurn_WrapsAround(string value) + { + Assert.AreEqual(RColor.FromArgb(255, 0, 255, 0), Color(value)); + } + + [TestMethod] + [Spec("css-color-4", "#missing")] + [DataRow("lab(none 0 0)", 0, 0, 0)] + [DataRow("lch(50 72 none)", 221, 34, 123)] + [DataRow("hwb(none 0% 0%)", 255, 0, 0)] + public void MissingComponent_IsZero(string value, int r, int g, int b) + { + Assert.AreEqual(RColor.FromArgb(255, r, g, b), Color(value)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#missing")] + [DataRow("rgb(none 0 0)", 0, 0, 0, 255)] + [DataRow("hsl(none 100% 50%)", 255, 0, 0, 255)] + [DataRow("lab(50 40 59.5 / none)", 191, 87, 0, 0)] + public void MissingComponent_IsZeroInEveryFunction(string value, int r, int g, int b, int a) + { + Assert.AreEqual(RColor.FromArgb(a, r, g, b), Color(value)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#powerless")] + [DataRow("color-mix(in lch, white, blue)", 175, 137, 255)] + [DataRow("color-mix(in hsl, white, blue)", 159, 159, 223)] + public void PowerlessHue_IsMissingWhenInterpolating(string value, int r, int g, int b) + { + AssertColor(RColor.FromArgb(255, r, g, b), Color(value), 1); + } + + [TestMethod] + [Spec("css-color-4", "#hex-notation")] + [DataRow("#f00", 255, 0, 0, 255)] + [DataRow("#F00", 255, 0, 0, 255)] + [DataRow("#ff0000", 255, 0, 0, 255)] + [DataRow("#f000", 255, 0, 0, 0)] + [DataRow("#ff000080", 255, 0, 0, 128)] + public void HexColor_HasThreeFourSixOrEightDigits(string value, int r, int g, int b, int a) + { + Assert.AreEqual(RColor.FromArgb(a, r, g, b), Color(value)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#hex-notation")] + [DataRow("#f008")] + [DataRow("#ff000088")] + public void HexAlpha_KeepsItsPrecision(string value) + { + Assert.AreEqual(0x88, Color(value).A); + } + + [TestMethod] + [Spec("css-color-4", "#named-colors")] + [Spec("css-color-4", "#untagged")] + [DataRow("rebeccapurple", 102, 51, 153)] + [DataRow("RebeccaPurple", 102, 51, 153)] + [DataRow("aliceblue", 240, 248, 255)] + [DataRow("darkgrey", 169, 169, 169)] + [DataRow("darkgray", 169, 169, 169)] + [DataRow("lightgoldenrodyellow", 250, 250, 210)] + [DataRow("yellowgreen", 154, 205, 50)] + public void NamedColor_IsItsSrgbValue(string value, int r, int g, int b) + { + Assert.AreEqual(RColor.FromArgb(255, r, g, b), Color(value)); + } + + [TestMethod] + [Spec("css-color-4", "#css-system-colors")] + [DataRow("ButtonFace")] + [DataRow("ButtonText")] + [DataRow("GrayText")] + [DataRow("Highlight")] + [DataRow("HighlightText")] + public void SystemColor_IsAColor(string value) + { + Assert.AreNotEqual(RColor.FromArgb(255, 1, 2, 3), Color($"rgb(1,2,3);color:{value}")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#css-system-colors")] + [DataRow("AccentColor")] + [DataRow("AccentColorText")] + [DataRow("ActiveText")] + [DataRow("ButtonBorder")] + [DataRow("Canvas")] + [DataRow("CanvasText")] + [DataRow("Field")] + [DataRow("FieldText")] + [DataRow("LinkText")] + [DataRow("Mark")] + [DataRow("MarkText")] + [DataRow("SelectedItem")] + [DataRow("SelectedItemText")] + [DataRow("VisitedText")] + public void NewSystemColor_IsAColor(string value) + { + Assert.AreNotEqual(RColor.FromArgb(255, 1, 2, 3), Color($"rgb(1,2,3);color:{value}")); + } + + [TestMethod] + [Spec("css-color-4", "#transparent-color")] + public void Transparent_IsTransparentBlack() + { + Assert.AreEqual(0, Color("transparent").A); + Assert.AreEqual(0, Styled("#x{background-color:red;background-color:transparent}").ActualBackgroundColor.A); + } + + [TestMethod] + [Spec("css-color-4", "#currentcolor-color")] + [DataRow("currentcolor")] + [DataRow("currentColor")] + public void CurrentColor_InColor_IsTheInheritedColor(string value) + { + var box = Styled($"#x{{color:{value}}}", "<div style='color:rgb(1,2,3)'><div id=x>a</div></div>"); + + Assert.AreEqual(RColor.FromArgb(255, 1, 2, 3), box.ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#currentcolor-color")] + public void CurrentColor_InOtherProperties_IsTheColor() + { + var box = Styled("#x{color:rgb(1,2,3);background-color:currentcolor;border-left:1px solid currentcolor}"); + + Assert.AreEqual(RColor.FromArgb(255, 1, 2, 3), box.ActualBackgroundColor); + Assert.AreEqual(RColor.FromArgb(255, 1, 2, 3), box.ActualBorderLeftColor); + } + + [TestMethod] + [Spec("css-color-4", "#rgb-to-hsl")] + [Spec("css-color-4", "#rgb-to-hwb")] + [DataRow("color-mix(in hsl, red, lime)", 255, 255, 0)] + [DataRow("color-mix(in hwb, red, lime)", 255, 255, 0)] + [DataRow("color-mix(in hsl, lime, blue)", 0, 255, 255)] + public void SrgbColor_IsConvertedToHslAndHwb(string value, int r, int g, int b) + { + Assert.AreEqual(RColor.FromArgb(255, r, g, b), Color(value)); + } + + [TestMethod] + [Spec("css-color-4", "#cie-lab")] + [Spec("css-color-4", "#specifying-lab-lch")] + [Spec("css-color-4", "#color-convert-algorithm")] + [DataRow("lab(50% 40 59.5)", 191, 87, 0)] + [DataRow("lab(29.2345 39.3825 20.0664)", 125, 35, 41)] + [DataRow("lab(100 0 0)", 255, 255, 255)] + [DataRow("lab(150 0 0)", 255, 255, 255)] + [DataRow("lab(-10 0 0)", 0, 0, 0)] + [DataRow("lch(50% 72 56)", 191, 87, 0)] + public void LabColor_IsConvertedToSrgb(string value, int r, int g, int b) + { + Assert.AreEqual(RColor.FromArgb(255, r, g, b), Color(value)); + } + + [TestMethod] + [Spec("css-color-4", "#ok-lab")] + [Spec("css-color-4", "#specifying-oklab-oklch")] + [DataRow("oklab(62.8% 0.225 0.126)", 255, 0, 0)] + [DataRow("oklch(0.628 0.258 29.2)", 255, 0, 0)] + [DataRow("oklch(1 0 0)", 255, 255, 255)] + [DataRow("oklch(0 0 0)", 0, 0, 0)] + public void OklabColor_IsConvertedToSrgb(string value, int r, int g, int b) + { + Assert.AreEqual(RColor.FromArgb(255, r, g, b), Color(value)); + } + + [TestMethod] + [Spec("css-color-4", "#lab-to-lch")] + [Spec("css-color-4", "#lch-to-lab")] + [DataRow("lab", "lch")] + [DataRow("oklab", "oklch")] + public void PolarForm_IsTheSameColor(string rectangular, string polar) + { + Assert.AreEqual(Color($"color-mix(in {rectangular}, lab(50 40 59.5), lab(50 40 59.5))"), + Color($"color-mix(in {polar}, lab(50 40 59.5), lab(50 40 59.5))")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#specifying-lab-lch")] + public void NegativeChroma_IsClampedToZero() + { + Assert.AreEqual(Color("lch(50 0 0)"), Color("lch(50 -10 0)")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#color-function")] + [DataRow("color(srgb 1 0 0)", 255, 0, 0)] + [DataRow("color(srgb 100% 0% 0%)", 255, 0, 0)] + [DataRow("color(srgb-linear 1 0 0)", 255, 0, 0)] + [DataRow("color(display-p3 1 0 0)", 255, 0, 0)] + [DataRow("color(xyz-d65 0.4124 0.2126 0.0193)", 255, 0, 0)] + public void ColorFunction_IsConvertedToSrgb(string value, int r, int g, int b) + { + AssertColor(RColor.FromArgb(255, r, g, b), Color($"black;color:{value}"), 1); + } + + [TestMethod] + [Spec("css-color-4", "#predefined-sRGB")] + [Spec("css-color-4", "#predefined-sRGB-linear")] + [Spec("css-color-4", "#predefined-display-p3")] + [Spec("css-color-4", "#predefined-xyz")] + [Spec("css-color-4", "#predefined-to-predefined")] + [Spec("css-color-4", "#predefined-to-lab-oklab")] + [Spec("css-color-4", "#oklab-lab-to-predefined")] + [Spec("css-color-4", "#interpolation-space")] + [Spec("css-color-4", "#color-convert-algorithm")] + [DataRow("srgb", 128, 0, 128)] + [DataRow("srgb-linear", 188, 0, 188)] + [DataRow("display-p3", 128, 10, 145)] + [DataRow("xyz", 188, 0, 188)] + [DataRow("xyz-d65", 188, 0, 188)] + [DataRow("xyz-d50", 187, 0, 188)] + [DataRow("lab", 193, 0, 136)] + [DataRow("oklab", 140, 83, 162)] + public void ColorSpace_IsUsedToInterpolate(string space, int r, int g, int b) + { + AssertColor(RColor.FromArgb(255, r, g, b), Color($"color-mix(in {space}, red, blue)"), 1); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#predefined-display-p3-linear")] + [Spec("css-color-4", "#predefined-a98-rgb")] + [Spec("css-color-4", "#predefined-prophoto-rgb")] + [Spec("css-color-4", "#predefined-rec2020")] + [Spec("css-color-4", "#interpolation-space")] + [DataRow("rec2020", 197, 160, 16)] + [DataRow("prophoto-rgb", 0, 0, 0)] + [DataRow("a98-rgb", 0, 0, 0)] + [DataRow("display-p3-linear", 0, 0, 0)] + public void WideGamutColorSpace_IsUsedToInterpolate(string space, int r, int g, int b) + { + var mixed = Color($"color-mix(in {space}, red, lime)"); + + Assert.AreNotEqual(RColor.FromArgb(255, 128, 128, 0), mixed); + if (r + g + b > 0) AssertColor(RColor.FromArgb(255, r, g, b), mixed, 1); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#css-gamut-mapping")] + [Spec("css-color-4", "#GMA-Binary-local-MINDE")] + [Spec("css-color-4", "#color-convert-algorithm")] + public void OutOfGamutColor_IsMappedByReducingChroma() + { + AssertColor(RColor.FromArgb(255, 0, 195, 0), Color("oklch(0.7 0.4 145)"), 2); + } + + [TestMethod] + [Spec("css-color-4", "#component-wise-linear-interpolation")] + [DataRow("color-mix(in srgb, red, blue)", 128, 0, 128, 255)] + [DataRow("color-mix(in srgb, red 25%, blue)", 64, 0, 191, 255)] + [DataRow("color-mix(in srgb, black, white)", 128, 128, 128, 255)] + public void Components_AreInterpolatedLinearly(string value, int r, int g, int b, int a) + { + AssertColor(RColor.FromArgb(a, r, g, b), Color(value), 1); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#interpolation-missing")] + [DataRow("color-mix(in lch, lch(50 72 none), lch(50 72 120))", "lch(50 72 120)")] + [DataRow("color-mix(in hsl, hsl(none 50% 50%), hsl(120, 50%, 50%))", "hsl(120, 50%, 50%)")] + public void MissingComponent_TakesTheOtherColorsValue(string value, string expected) + { + AssertColor(Color(expected), Color(value), 1); + } + + [TestMethod] + [Spec("css-color-4", "#interpolation-alpha")] + [DataRow("color-mix(in srgb, red, transparent)", 255, 0, 0, 128)] + [DataRow("color-mix(in srgb, rgba(255, 0, 0, 0.5), blue)", 85, 0, 170, 191)] + public void Alpha_IsPremultiplied(string value, int r, int g, int b, int a) + { + AssertColor(RColor.FromArgb(a, r, g, b), Color(value), 1); + } + + [TestMethod] + [Spec("css-color-4", "#hue-interpolation")] + [Spec("css-color-4", "#hue-shorter")] + [Spec("css-color-4", "#hue-longer")] + [Spec("css-color-4", "#hue-increasing")] + [Spec("css-color-4", "#hue-decreasing")] + [DataRow("", 0, 255, 255)] + [DataRow("shorter hue", 0, 255, 255)] + [DataRow("longer hue", 255, 0, 0)] + [DataRow("increasing hue", 0, 255, 255)] + [DataRow("decreasing hue", 255, 0, 0)] + public void Hue_IsInterpolatedInTheGivenDirection(string method, int r, int g, int b) + { + Assert.AreEqual(RColor.FromArgb(255, r, g, b), Color($"color-mix(in hsl {method}, lime, blue)")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-4.yml")] + [Spec("css-color-4", "#hue-interpolation")] + [DataRow("color-mix(in hsl, red, blue)", 255, 0, 255)] + [DataRow("color-mix(in hwb decreasing hue, red, blue)", 255, 0, 255)] + public void InterpolatedHue_BelowZeroDegrees_WrapsAround(string value, int r, int g, int b) + { + Assert.AreEqual(RColor.FromArgb(255, r, g, b), Color(value)); + } + + private static RColor Color(string value) => Styled($"#x{{color:{value}}}").ActualColor; + + private static void AssertColor(RColor expected, RColor actual, int tolerance) + { + var message = $"Expected {expected}, got {actual}"; + Assert.AreEqual((double)expected.R, actual.R, tolerance, message); + Assert.AreEqual((double)expected.G, actual.G, tolerance, message); + Assert.AreEqual((double)expected.B, actual.B, tolerance, message); + Assert.AreEqual((double)expected.A, actual.A, tolerance, message); + } + + private static CssBox Styled(string css, string body = "<div id=x>a</div>") + { + var (root, _) = LayoutHarness.Layout($"<html><head><style>{css}</style></head><body style='margin:0'>{body}</body></html>"); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } +} diff --git a/Spec/css-color-4.yml b/Spec/css-color-4.yml index ce53b9023..a1c700a2c 100644 --- a/Spec/css-color-4.yml +++ b/Spec/css-color-4.yml @@ -5,346 +5,326 @@ snapshot: https://www.w3.org/TR/2026/CRD-css-color-4-20261103/ sections: '#introduction': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown + status: n/a '#terminology': title: 2 Color Terminology - status: unknown + status: n/a '#applying-color': title: 3 Applying Color in CSS - status: unknown + status: partial '#accessibility': title: 3.1 Accessibility and Conveying Information By Color - status: unknown + status: n/a '#the-color-property': title: '3.2 Foreground Color: the color property' - status: unknown + status: supported '#transparency': title: '3.3 Transparency: the opacity property' - status: unknown + status: parsed-only + gaps: + - "'opacity' is parsed but never applied when painting" '#tagged-images': title: 3.4 Color Space of Tagged Images - status: unknown + status: unsupported + gaps: + - Images are drawn as the platform decodes them, the renderer doesn't convert embedded color profiles '#untagged': title: 3.5 Color Spaces of Untagged Colors - status: unknown + status: supported '#color-type': title: '4 Representing Colors: the <color> type' - status: unknown + status: partial '#color-syntax': title: 4.1 The <color> syntax - status: unknown + status: partial '#color-syntax-modern': title: 4.1.1 Modern (Space-separated) Color Function Syntax - status: unknown + status: partial + gaps: + - hsl() and hsla() don't take the modern syntax, the color is transparent + - The modern rgb() takes neither percentages nor fractions, rgb(100% 0% 0%) is rgb(100 0 0) and rgb(255.5 0 0) is rgb(255 5 0) + - calc() isn't accepted inside a color function '#color-syntax-legacy': title: 4.1.2 Legacy (Comma-separated) Color Function Syntax - status: unknown + status: partial + gaps: + - rgb() ignores a fourth (alpha) value, hsl() with four values is transparent, and fractions are split at the dot + - Invalid color functions are kept, e.g. mixed commas and spaces, an alpha without a slash, numbers mixed with percentages, too few values, and commas in hwb(), lab(), lch(), oklab() and oklch() '#alpha-syntax': title: '4.2 Representing Transparency in Colors: the <alpha-value> syntax' - status: unknown + status: partial + gaps: + - rgb() and rgba() ignore an alpha after a slash + - A negative alpha isn't clamped to 0 '#hue-syntax': title: '4.3 Representing Cylindrical-coordinate Hues: the <hue> syntax' - status: unknown + status: partial + gaps: + - A hwb() hue outside 0..360 degrees isn't wrapped around '#missing': title: 4.4 “Missing” Color Components and the none Keyword - status: unknown + status: partial + gaps: + - none in rgb() or hsl() makes the color transparent, and a missing alpha is treated as 1 instead of 0 '#powerless': title: 4.4.1 “Powerless” Color Components - status: unknown + status: unsupported + gaps: + - A powerless hue of a converted color isn't treated as missing when interpolating '#parse-color': title: 4.5 Parsing a <color> Value - status: unknown + status: n/a '#numeric-srgb': title: 5 sRGB Colors - status: unknown + status: partial '#rgb-functions': title: '5.1 The RGB functions: rgb() and rgba()' - status: unknown + status: partial + gaps: + - Out-of-range values aren't clipped, see CSS 2.1 4.3.6 + - rgb() ignores the alpha, see 4.1.2 and 4.2 '#hex-notation': title: '5.2 The RGB Hexadecimal Notations: #RRGGBB' - status: unknown + status: partial + gaps: + - "The alpha of 4 and 8 digit hex colors is rounded to two decimals, #f008 gets an alpha of 135 instead of 136" '#color-keywords': title: 6 Color Keywords - status: unknown + status: partial '#named-colors': title: 6.1 Named Colors - status: unknown + status: supported '#css-system-colors': title: 6.2 System Colors - status: unknown + status: partial + gaps: + - The system colors added in Color 4 aren't supported, e.g. Canvas, CanvasText, LinkText, Field, Mark, AccentColor '#transparent-color': title: 6.3 The transparent keyword - status: unknown + status: supported '#currentcolor-color': title: 6.4 The currentcolor keyword - status: unknown + status: partial + gaps: + - currentcolor in other properties than 'color' isn't resolved, a currentcolor background is transparent + - The initial border color is black instead of currentcolor, see CSS 2.1 8.5.2 '#the-hsl-notation': title: '7 HSL Colors: hsl() and hsla() functions' - status: unknown + status: partial + gaps: + - Only the legacy comma syntax is supported, see 4.1.1 '#hsl-to-rgb': title: 7.1 Converting HSL Colors to sRGB - status: unknown + status: supported '#rgb-to-hsl': title: 7.2 Converting sRGB Colors to HSL - status: unknown + status: supported '#hsl-examples': title: 7.3 Examples of HSL Colors - status: unknown + status: n/a '#the-hwb-notation': title: '8 HWB Colors: hwb() function' - status: unknown + status: partial + gaps: + - The legacy comma syntax is accepted, see 4.1.2 '#hwb-to-rgb': title: 8.1 Converting HWB Colors to sRGB - status: unknown + status: supported '#rgb-to-hwb': title: 8.2 Converting sRGB Colors to HWB - status: unknown + status: supported '#hwb-examples': title: 8.3 Examples of HWB Colors - status: unknown + status: n/a '#lab-colors': title: '9 Device-independent Colors: CIE Lab and LCH, Oklab and OkLCh' - status: unknown + status: partial '#cie-lab': title: 9.1 CIE Lab and LCH - status: unknown + status: supported '#ok-lab': title: 9.2 Oklab and OkLCh - status: unknown + status: supported '#specifying-lab-lch': title: '9.3 Specifying Lab and LCH: the lab() and lch() functional notations' - status: unknown + status: partial + gaps: + - A negative chroma isn't clamped to 0 '#specifying-oklab-oklch': title: '9.4 Specifying Oklab and OkLCh: the oklab() and oklch() functional notations' - status: unknown + status: supported '#lab-to-lch': title: 9.5 Converting Lab or Oklab colors to LCH or OkLCh colors - status: unknown + status: supported '#lch-to-lab': title: 9.6 Converting LCH or OkLCh colors to Lab or Oklab colors - status: unknown + status: supported '#predefined': title: 10 Predefined Color Spaces - status: unknown + status: partial '#color-function': title: '10.1 Specifying Predefined Colors: the color() function' - status: unknown + status: unsupported + gaps: + - color() isn't supported, the declaration is dropped '#predefined-sRGB': title: '10.2 The Predefined sRGB Color Space: the sRGB keyword' - status: unknown + status: partial + gaps: + - Only usable as a color-mix() interpolation space, color() isn't supported '#predefined-sRGB-linear': title: '10.3 The Predefined Linear-Light sRGB Color Space: the srgb-linear keyword' - status: unknown + status: partial + gaps: + - Only usable as a color-mix() interpolation space, color() isn't supported '#predefined-display-p3': title: '10.4 The Predefined Display P3 Color Space: the display-p3 keyword' - status: unknown + status: partial + gaps: + - Only usable as a color-mix() interpolation space, color() isn't supported '#predefined-display-p3-linear': title: '10.5 The Predefined Linear-Light Display P3 Color Space: the display-p3-linear keyword' - status: unknown + status: unsupported + gaps: + - Not supported as an interpolation space, color-mix() interpolates in sRGB instead '#predefined-a98-rgb': title: '10.6 The Predefined A98 RGB Color Space: the a98-rgb keyword' - status: unknown + status: unsupported + gaps: + - Not supported as an interpolation space, color-mix() interpolates in sRGB instead '#predefined-prophoto-rgb': title: '10.7 The Predefined ProPhoto RGB Color Space: the prophoto-rgb keyword' - status: unknown + status: unsupported + gaps: + - Not supported as an interpolation space, color-mix() interpolates in sRGB instead '#predefined-rec2020': title: '10.8 The Predefined ITU-R BT.2020-2 Color Space: the rec2020 keyword' - status: unknown + status: unsupported + gaps: + - Not supported as an interpolation space, color-mix() interpolates in sRGB instead '#predefined-xyz': title: '10.9 The Predefined CIE XYZ Color Spaces: the xyz-d50, xyz-d65, and xyz keywords' - status: unknown + status: partial + gaps: + - Only usable as a color-mix() interpolation space, color() isn't supported '#predefined-to-lab-oklab': title: 10.10 Converting Predefined Color Spaces to Lab or Oklab - status: unknown + status: supported '#oklab-lab-to-predefined': title: 10.11 Converting Lab or Oklab to Predefined RGB Color Spaces - status: unknown + status: supported '#predefined-to-predefined': title: 10.12 Converting Between Predefined RGB Color Spaces - status: unknown + status: supported '#alpha': title: 10.13 Simple Alpha Compositing - status: unknown + status: n/a '#color-conversion': title: 11 Converting Colors - status: unknown + status: partial '#color-convert-intro': title: 11.1 Introduction to Converting Colors - status: unknown + status: n/a '#color-convert-algorithm': title: 11.2 Algorithm for Converting Colors - status: unknown + status: partial + gaps: + - Out-of-gamut results are clipped instead of gamut mapped, see 14.2 '#comparing-color-values': title: 12 Comparing <color> Values - status: unknown + status: n/a '#interpolation': title: 13 Color Interpolation - status: unknown + status: partial '#interpolate-gamut': title: 13.1 Interpolation and color gamuts - status: unknown + status: n/a '#component-wise-linear-interpolation': title: 13.2 Component-wise Linear Interpolation - status: unknown + status: supported '#interpolation-space': title: 13.3 Color Space for Interpolation - status: unknown + status: partial + gaps: + - display-p3-linear, a98-rgb, prophoto-rgb and rec2020 aren't supported, see 10.5 to 10.8 '#interpolation-missing': title: 13.4 Interpolating with Missing Components - status: unknown + status: unsupported + gaps: + - A missing component is interpolated as 0 instead of taking the other color's value '#interpolation-alpha': title: 13.5 Interpolating with Alpha - status: unknown + status: supported '#hue-interpolation': title: 13.6 Hue Interpolation - status: unknown + status: partial + gaps: + - An interpolated hsl() or hwb() hue below 0 degrees isn't wrapped around, the result is gray or black '#hue-shorter': title: 13.6.1 shorter - status: unknown + status: supported '#hue-longer': title: 13.6.2 longer - status: unknown + status: supported '#hue-increasing': title: 13.6.3 increasing - status: unknown + status: supported '#hue-decreasing': title: 13.6.4 decreasing - status: unknown + status: supported '#gamut-mapping': title: 14 Gamut Mapping - status: unknown + status: unsupported '#gamut-mapping-intro': title: 14.1 An Introduction to Gamut Mapping - status: unknown - '#GM-clip': - title: 14.1.1 Clipping - status: unknown - '#GM-closest': - title: 14.1.2 Closest Color (MINDE) - status: unknown - '#GM-chroma': - title: 14.1.3 Chroma Reduction - status: unknown - '#GM-excessive-reduction': - title: 14.1.4 Excessive Chroma Reduction - status: unknown - '#GM-chroma-local-MINDE': - title: 14.1.5 Chroma Reduction with Local Clipping - status: unknown - '#GM-hue-curvature': - title: '14.1.6 Deviations from Perceptual Uniformity: Hue Curvature' - status: unknown + status: n/a '#css-gamut-mapping': title: 14.2 CSS Gamut Mapping to an RGB Destination - status: unknown + status: unsupported + gaps: + - Out-of-gamut colors are clipped per channel instead of reducing the chroma in OkLCh '#GMA-Binary-local-MINDE': title: 14.2.1 Binary Search Gamut Mapping with Local MINDE - status: unknown + status: unsupported '#pseudo-binsearch': title: 14.2.2 Sample Pseudocode for the Binary Search Gamut Mapping with Local MINDE - status: unknown + status: n/a '#GMA-EdgeSeeker': title: 14.2.3 The EdgeSeeker Gamut Mapping - status: unknown + status: n/a '#pseudo-edgeseeker': title: 14.2.4 Sample Pseudocode for the EdgeSeeker Gamut Mapping - status: unknown + status: n/a '#GMA-Raytrace': title: 14.2.5 The Ray Trace Gamut Mapping - status: unknown + status: n/a '#pseudo-raytrace': title: 14.2.6 Sample Pseudocode for the Ray Trace Gamut Mapping - status: unknown - '#raytrace-footnotes': - title: 14.2.6.1 Footnotes for Ray Trace algorithm - status: unknown + status: n/a '#resolving-color-values': title: 15 Resolving <color> Values - status: unknown - '#resolving-sRGB-values': - title: 15.1 Resolving sRGB values - status: unknown - '#resolving-lab-lch-values': - title: 15.2 Resolving Lab and LCH values - status: unknown - '#resolving-oklab-oklch-values': - title: 15.3 Resolving Oklab and OkLCh values - status: unknown - '#resolving-color-function-values': - title: 15.4 Resolving values of the color() function - status: unknown - '#resolving-other-colors': - title: 15.5 Resolving other colors - status: unknown + status: n/a '#serializing-color-values': title: 16 Serializing <color> Values - status: unknown - '#serializing-alpha-values': - title: 16.1 Serializing alpha values - status: unknown - '#serializing-legacy-alpha-values': - title: 16.1.1 Serializing legacy alpha values - status: unknown - '#serializing-modern-alpha-values': - title: 16.1.2 Serializing modern alpha values - status: unknown - '#serializing-sRGB-values': - title: 16.2 Serializing sRGB values - status: unknown - '#HTML-compatible-serialization-of-srgb': - title: 16.2.1 HTML-compatible serialization of sRGB values - status: unknown - '#css-serialization-of-srgb': - title: 16.2.2 CSS serialization of sRGB values - status: unknown - '#serializing-lab-lch': - title: 16.3 Serializing Lab and LCH values - status: unknown - '#serializing-oklab-oklch': - title: 16.4 Serializing Oklab and OkLCh values - status: unknown - '#serializing-color-function-values': - title: 16.5 Serializing values of the color() function - status: unknown - '#serializing-other-colors': - title: 16.6 Serializing other colors - status: unknown + status: n/a '#serializing-opacity-values': title: 17 Serializing <opacity-value> - status: unknown + status: n/a '#sample': title: 18 Default Style Rules - status: unknown + status: n/a '#color-conversion-code': title: 19 Sample code for Color Conversions - status: unknown + status: n/a '#color-difference-code': title: 20 Color Difference Formulae - status: unknown - '#color-differences-intro': - title: 20.1 Introduction to Color Difference metrics. - status: unknown - '#color-difference-2000': - title: 20.2 ΔE2000 - status: unknown - '#color-difference-OK': - title: 20.3 ΔEOK - status: unknown - '#color-difference-OK2': - title: 20.4 ΔEOK2 - status: unknown - '#color-difference-OK2r': - title: 20.5 ΔEOKr2 - status: unknown + status: n/a '#security': title: 21 Security Considerations - status: unknown + status: n/a '#privacy': title: 22 Privacy Considerations - status: unknown + status: n/a '#a11y-sec': title: 23 Accessibility Considerations - status: unknown + status: n/a From d613fdf92f1e92f68e774c232547fd34fba4932c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:18:45 +0100 Subject: [PATCH 063/150] Verify and document CSS Color Level 5 compliance --- .../ColorFunctionPaintIntegrationTests.cs | 9 + .../CssModules/CssColor5Tests.cs | 166 ++++++++++++++++++ Spec/css-color-5.yml | 161 +++++++---------- 3 files changed, 241 insertions(+), 95 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssColor5Tests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/ColorFunctionPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/ColorFunctionPaintIntegrationTests.cs index 6469b20a3..6550072ad 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/ColorFunctionPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/ColorFunctionPaintIntegrationTests.cs @@ -19,6 +19,15 @@ public void ColorFunction_PaintsTextAndBackground(string color, int a, int r, in AssertPainted(color, RColor.FromArgb(a, r, g, b)); } + [TestMethod] + [DataRow("color-mix(in srgb, red, blue)", 255, 128, 0, 128)] + [DataRow("color-mix(in srgb, red 20%, blue 20%)", 102, 128, 0, 128)] + [Spec("css-color-5", "#color-mix")] + public void ColorMix_PaintsTextAndBackground(string color, int a, int r, int g, int b) + { + AssertPainted(color, RColor.FromArgb(a, r, g, b)); + } + private static void AssertPainted(string color, RColor expected) { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssColor5Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssColor5Tests.cs new file mode 100644 index 000000000..b9129037c --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssColor5Tests.cs @@ -0,0 +1,166 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssColor5Tests +{ + private static readonly RColor Green = RColor.FromArgb(255, 0, 128, 0); + + [TestMethod] + [Spec("css-color-5", "#color-mix")] + [Spec("css-color-5", "#color-syntax")] + [DataRow("color-mix(in srgb, red, blue)", 128, 0, 128, 255)] + [DataRow("COLOR-MIX(IN SRGB, red, blue)", 128, 0, 128, 255)] + [DataRow("color-mix(in srgb, #ff0000, rgb(0 0 255))", 128, 0, 128, 255)] + [DataRow("color-mix(in srgb, color-mix(in srgb, red, blue), white)", 191, 128, 191, 255)] + [DataRow("color-mix(in srgb, 25% red, blue)", 64, 0, 191, 255)] + public void ColorMix_MixesTwoColors(string value, int r, int g, int b, int a) + { + AssertColor(RColor.FromArgb(a, r, g, b), Color(value)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-5.yml")] + [Spec("css-color-5", "#color-mix")] + public void ColorMix_ResolvesCurrentColor() + { + var box = Styled("#x{color:red;background-color:color-mix(in srgb, currentcolor, blue)}"); + + AssertColor(RColor.FromArgb(255, 128, 0, 128), box.ActualBackgroundColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-5.yml")] + [Spec("css-color-5", "#color-mix-result")] + [DataRow("color-mix(in srgb, red)", 255, 0, 0)] + [DataRow("color-mix(in srgb, red, blue, lime)", 85, 85, 85)] + public void ColorMix_TakesOneOrMoreColors(string value, int r, int g, int b) + { + AssertColor(RColor.FromArgb(255, r, g, b), Color(value)); + } + + [TestMethod] + [Spec("css-color-5", "#color-mix-space")] + [DataRow("srgb", 128, 0, 128)] + [DataRow("srgb-linear", 188, 0, 188)] + [DataRow("xyz", 188, 0, 188)] + [DataRow("oklab", 140, 83, 162)] + public void ColorMix_MixesInTheGivenColorSpace(string space, int r, int g, int b) + { + AssertColor(RColor.FromArgb(255, r, g, b), Color($"color-mix(in {space}, red, blue)")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-5.yml")] + [Spec("css-color-5", "#color-mix-space")] + public void ColorMix_WithoutAColorSpace_MixesInOklab() + { + AssertColor(RColor.FromArgb(255, 140, 83, 162), Color("color-mix(red, blue)")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-5.yml")] + [Spec("css-color-5", "#color-mix-space")] + [Spec("css-color-5", "#color-mix-percent-norm")] + [DataRow("color-mix(in foo, red, blue)")] + [DataRow("color-mix(in srgb, red -10%, blue)")] + [DataRow("color-mix(in srgb, red 110%, blue)")] + public void InvalidColorMix_IsIgnored(string value) + { + Assert.AreEqual(Green, Color($"green;color:{value}")); + } + + [TestMethod] + [Spec("css-color-5", "#color-mix-percent-norm")] + [Spec("css-color-5", "#color-mix-result")] + [DataRow("color-mix(in srgb, red 25%, blue)", 64, 0, 191, 255)] + [DataRow("color-mix(in srgb, red, blue 75%)", 64, 0, 191, 255)] + [DataRow("color-mix(in srgb, red 60%, blue 60%)", 128, 0, 128, 255)] + [DataRow("color-mix(in srgb, red 20%, blue 20%)", 128, 0, 128, 102)] + [DataRow("color-mix(in srgb, red 0%, blue 0%)", 0, 0, 0, 0)] + public void Percentages_AreNormalized(string value, int r, int g, int b, int a) + { + var color = Color(value); + + if (a == 0) Assert.AreEqual(0, color.A); + else AssertColor(RColor.FromArgb(a, r, g, b), color); + } + + [TestMethod] + [Spec("css-color-5", "#color-mix-with-alpha")] + public void ColorMix_PremultipliesTheAlpha() + { + AssertColor(RColor.FromArgb(83, 137, 118, 0), Color("color-mix(in srgb, rgba(255, 0, 0, 0.7) 25%, rgba(0, 255, 0, 0.2))")); + } + + [TestMethod] + [Spec("css-color-5", "#interpolation-space")] + [DataRow("in hsl longer hue", 255, 0, 0)] + [DataRow("in hsl increasing hue", 0, 255, 255)] + public void ColorMix_TakesAHueInterpolationMethod(string method, int r, int g, int b) + { + AssertColor(RColor.FromArgb(255, r, g, b), Color($"color-mix({method}, lime, blue)")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-5.yml")] + [Spec("css-color-5", "#relative-syntax")] + [DataRow("rgb(from red b g r)", 0, 0, 255)] + [DataRow("hsl(from red calc(h + 120) s l)", 0, 255, 0)] + [DataRow("oklch(from red l c h)", 255, 0, 0)] + public void RelativeColor_IsDerivedFromTheOriginColor(string value, int r, int g, int b) + { + AssertColor(RColor.FromArgb(255, r, g, b), Color(value)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-5.yml")] + [Spec("css-color-5", "#light-dark")] + public void LightDark_IsTheLightColorInALightColorScheme() + { + Assert.AreEqual(RColor.FromArgb(255, 255, 0, 0), Color("light-dark(red, blue)")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-5.yml")] + [Spec("css-color-5", "#contrast-color")] + [DataRow("white", 0, 0, 0)] + [DataRow("black", 255, 255, 255)] + public void ContrastColor_IsBlackOrWhite(string background, int r, int g, int b) + { + Assert.AreEqual(RColor.FromArgb(255, r, g, b), Color($"green;color:contrast-color({background})")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-color-5.yml")] + [Spec("css-color-5", "#device-cmyk")] + [Spec("css-color-5", "#cmyk-rgb")] + [DataRow("device-cmyk(0 1 1 0)", 255, 0, 0)] + [DataRow("device-cmyk(0% 0% 0% 100%)", 0, 0, 0)] + public void DeviceCmyk_IsConvertedNaively(string value, int r, int g, int b) + { + Assert.AreEqual(RColor.FromArgb(255, r, g, b), Color($"green;color:{value}")); + } + + private static RColor Color(string value) => Styled($"#x{{color:{value}}}").ActualColor; + + private static void AssertColor(RColor expected, RColor actual) + { + var message = $"Expected {expected}, got {actual}"; + Assert.AreEqual((double)expected.R, actual.R, 1, message); + Assert.AreEqual((double)expected.G, actual.G, 1, message); + Assert.AreEqual((double)expected.B, actual.B, 1, message); + Assert.AreEqual((double)expected.A, actual.A, 1, message); + } + + private static CssBox Styled(string css, string body = "<div id=x>a</div>") + { + var (root, _) = LayoutHarness.Layout($"<html><head><style>{css}</style></head><body style='margin:0'>{body}</body></html>"); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } +} diff --git a/Spec/css-color-5.yml b/Spec/css-color-5.yml index 340acef5e..c6c2e6887 100644 --- a/Spec/css-color-5.yml +++ b/Spec/css-color-5.yml @@ -5,178 +5,149 @@ snapshot: https://www.w3.org/TR/2026/WD-css-color-5-20261029/ sections: '#intro': title: 1 Introduction - status: unknown + status: n/a '#color-syntax': title: 2 The <color> syntax - status: unknown + status: partial + gaps: + - alpha() isn't supported '#color-mix': title: '3 Mixing Colors: the color-mix() Function' - status: unknown + status: partial + gaps: + - currentcolor in color-mix() isn't resolved, the color is transparent '#color-mix-space': title: 3.1 Colorspace for mixing - status: unknown + status: partial + gaps: + - Without an interpolation method the color is transparent instead of mixed in Oklab + - An unknown color space is mixed in sRGB instead of making color-mix() invalid '#color-mix-percent-norm': title: 3.2 Percentage Normalization - status: unknown + status: partial + gaps: + - Percentages below 0% or above 100% aren't rejected '#color-mix-result': title: 3.3 Calculating the Result of color-mix - status: unknown + status: partial + gaps: + - Only exactly two colors are accepted, one or more than two make the color transparent '#color-mix-color-space-effect': title: 3.4 Effect of Mixing Color Space on color-mix - status: unknown + status: n/a '#color-mix-with-alpha': title: 3.5 Effect of Non-Unity Alpha on color-mix - status: unknown + status: supported '#relative-colors': title: 4 Relative Colors - status: unknown + status: unsupported + gaps: + - Relative color syntax isn't supported, the color is transparent or the declaration dropped '#rcs-intro': title: 4.1 Processing Model for Relative Colors - status: unknown + status: unsupported '#relative-syntax': title: 4.2 Relative Color Syntax - status: unknown + status: unsupported '#relative-RGB': title: 4.3 Relative sRGB Colors - status: unknown + status: unsupported '#relative-HSL': title: 4.4 Relative HSL Colors - status: unknown + status: unsupported '#relative-HWB': title: 4.5 Relative HWB Colors - status: unknown + status: unsupported '#relative-Lab': title: 4.6 Relative Lab Colors - status: unknown + status: unsupported '#relative-Oklab': title: 4.7 Relative Oklab Colors - status: unknown + status: unsupported '#relative-LCH': title: 4.8 Relative LCH Colors - status: unknown + status: unsupported '#relative-OkLCh': title: 4.9 Relative OkLCh Colors - status: unknown + status: unsupported '#relative-alpha': title: 4.10 Relative Alpha Colors - status: unknown + status: unsupported '#color-function': title: '5 Specifying Predefined and Custom Color Spaces: the color() Function' - status: unknown + status: unsupported + gaps: + - color() isn't supported, see CSS Color 4 10.1 '#relative-color-function': title: 5.1 Relative Color-Function Colors - status: unknown + status: unsupported '#custom-color': title: 5.2 Custom Color Spaces - status: unknown + status: unsupported '#at-profile': title: '5.3 Specifying a Color Profile: the @color-profile at-rule' - status: unknown + status: unsupported + gaps: + - "@color-profile is ignored" '#cal-cmyk': title: '5.4 CSS and Print: Using Calibrated CMYK and Other Printed Color Spaces' - status: unknown + status: unsupported '#cmyk-to-lab': title: 5.5 Converting CMYK colors to Lab - status: unknown + status: unsupported '#lab-to-cmyk': title: 5.6 Converting Lab colors to CMYK - status: unknown + status: unsupported '#device-cmyk': title: '6 Uncalibrated CMYK Colors: the device-cmyk() Function' - status: unknown + status: unsupported + gaps: + - device-cmyk() isn't supported, the declaration is dropped '#cmyk-rgb': title: 6.1 Naively Converting Between Uncalibrated CMYK and sRGB-Based Color - status: unknown + status: unsupported '#light-dark': title: '7 Reacting to the used color-scheme: the light-dark() Function' - status: unknown + status: unsupported + gaps: + - light-dark() isn't supported, the declaration is dropped '#contrast-color': title: '8 Dynamically specifying a text color with adequate contrast: the contrast-color() Function' - status: unknown + status: unsupported + gaps: + - contrast-color() isn't supported, the declaration is dropped '#interpolation': title: 9 Color Interpolation - status: unknown + status: partial '#interpolation-space': title: 9.1 Color Space for Interpolation - status: unknown + status: partial + gaps: + - Custom color spaces aren't supported, and neither are display-p3-linear, a98-rgb, prophoto-rgb and rec2020, see CSS Color 4 13.3 '#resolving-color-values': title: 10 Resolving <color> Values - status: unknown - '#resolving-mix': - title: 10.1 Resolving color-mix() Values - status: unknown - '#resolving-rcs': - title: 10.2 Resolving Relative Color Syntax Values - status: unknown - '#resolving-device-cmyk-values': - title: 10.3 Resolving device-cmyk Values - status: unknown + status: n/a '#serial': title: 11 Serialization - status: unknown - '#serial-color-mix': - title: 11.1 Serializing color-mix() - status: unknown - '#serial-origin-color': - title: 11.2 Serializing Origin Colors - status: unknown - '#serial-relative-color': - title: 11.3 Serializing Relative Color Functions - status: unknown - '#serial-custom-color': - title: 11.4 Serializing Custom Color Spaces - status: unknown - '#serializing-device-cmyk-values': - title: 11.5 Serializing device-cmyk Values - status: unknown + status: n/a '#apis': title: 12 APIs - status: unknown - '#the-csscolorprofilerule-interface': - title: 12.1 The CSSColorProfileRule interface - status: unknown + status: n/a '#sample': title: 13 Default Style Rules - status: unknown + status: n/a '#color-conversion-code': title: 14 Sample code for Color Conversions - status: unknown + status: n/a '#security': title: 15 Security Considerations - status: unknown + status: n/a '#privacy': title: 16 Privacy Considerations - status: unknown + status: n/a '#a11y-sec': title: 17 Accessibility Considerations - status: unknown + status: n/a '#changes': title: 18 Changes - status: unknown - '#changes-20260113': - title: 18.1 Since the Working Draft of 13 January 2026 - status: unknown - '#changes-20250318': - title: 18.2 Since the Working Draft of 18 March 2025 - status: unknown - '#changes-20240229': - title: 18.3 Since the Working Draft of 29 February 2024 - status: unknown - '#changes-20220628': - title: 18.4 Since the Working Draft of 28 June 2022 - status: unknown - '#changes-20220428': - title: 18.5 Since the Working Draft of 28 April 2022 - status: unknown - '#changes-20211215': - title: 18.6 Since the Working Draft of 15 December 2021 - status: unknown - '#changes-20210601': - title: 18.7 Since the Working Draft of 1 June 2021 - status: unknown - '#changes-20200303': - title: 18.8 Since the FPWD of 10 June 2020 - status: unknown - '#changes-from-4': - title: 18.9 Changes from CSS Color 4 - status: unknown + status: n/a From bc7f2a19d9702810163fa40b290fb55dd4ddb733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 07:57:36 +0100 Subject: [PATCH 064/150] Verify and document CSS Display Level 3 compliance --- .../CssModules/CssDisplayTests.cs | 277 ++++++++++++++++++ .../Layout/BoxGenerationIntegrationTests.cs | 5 + .../VisibilityPaintIntegrationTests.cs | 3 + Spec/css-display-3.yml | 58 ++-- 4 files changed, 323 insertions(+), 20 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssDisplayTests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssDisplayTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssDisplayTests.cs new file mode 100644 index 000000000..d4b9f4587 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssDisplayTests.cs @@ -0,0 +1,277 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.CssModules; + +[DoNotParallelize] +[TestClass] +public sealed class CssDisplayTests +{ + private const double Delta = 1.0; + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + + [TestMethod] + [Spec("css-display-3", "#outer-role")] + public void BlockAndInlineOuterRoles_StackOrShareTheLine() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div><span id='a' style='display:block'>first</span><span id='b' style='display:block'>second</span>" + + "<div id='c' style='display:inline'>third</div> <div id='d' style='display:inline'>fourth</div></div>")); + + Assert.IsGreaterThanOrEqualTo(FindWord(root, "first")!.Bottom - Delta, FindWord(root, "second")!.Top); + Assert.AreEqual(FindWord(root, "third")!.Top, FindWord(root, "fourth")!.Top, Delta); + } + + [Ignore("Gap, see Spec/css-display-3.yml")] + [TestMethod] + [Spec("css-display-3", "#outer-role")] + [Spec("css-display-3", "#run-in-layout")] + public void RunIn_IsInsertedAtTheStartOfTheFollowingBlock() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='display:run-in'>head</div><p style='margin:0'>body</p>")); + + Assert.AreEqual(FindWord(root, "head")!.Top, FindWord(root, "body")!.Top, Delta); + Assert.IsLessThan(FindWord(root, "body")!.Left, FindWord(root, "head")!.Right); + } + + [Ignore("Gap, see Spec/css-display-3.yml")] + [TestMethod] + [DataRow("block flow", "block")] + [DataRow("inline flow", "inline")] + [DataRow("inline flow-root", "inline-block")] + [DataRow("block table", "table")] + [DataRow("inline table", "inline-table")] + [DataRow("flow", "block")] + [Spec("css-display-3", "#the-display-properties")] + public void TwoValueSyntax_ComputesToTheEquivalentShortValue(string value, string expected) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + $"<p style='display:{(expected == "block" ? "inline" : "block")};display:{value}' id='e'>x</p>")); + + Assert.AreEqual(expected, LayoutHarness.FindById(root, "e")!.Display); + } + + [TestMethod] + [Spec("css-display-3", "#inner-model")] + public void FlowInnerDisplay_LaysTheChildrenOutInNormalFlow() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div id='d' style='display:block'><div id='a' style='height:20px'></div><div id='b' style='height:30px'></div></div>")); + + var d = LayoutHarness.FindById(root, "d")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(20, b.Location.Y - d.Location.Y, Delta); + Assert.AreEqual(50, d.Size.Height, Delta); + } + + [Ignore("Gap, see Spec/css-display-3.yml")] + [TestMethod] + [Spec("css-display-3", "#inner-model")] + public void FlowRoot_IsBlockLevelAndContainsItsFloats() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<span id='fr' style='display:flow-root'><div style='float:left;width:50px;height:80px'></div>text</span><div id='after'>after</div>")); + + var fr = LayoutHarness.FindById(root, "fr")!; + + Assert.AreEqual(80, fr.Size.Height, Delta); + Assert.IsGreaterThanOrEqualTo(80 - Delta, FindWord(root, "after")!.Top); + } + + [Ignore("Gap, see Spec/css-display-3.yml")] + [TestMethod] + [Spec("css-display-3", "#inner-model")] + [Spec("css-display-3", "#layout-specific-display")] + public void Ruby_KeepsTheAnnotationOffTheBaseLine() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div><span style='display:ruby'>base<span style='display:ruby-text'>anno</span></span></div>")); + + Assert.IsLessThan(FindWord(root, "base")!.Top - Delta, FindWord(root, "anno")!.Top); + } + + [TestMethod] + [Spec("css-display-3", "#list-items")] + public void ListItem_GeneratesAMarker() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div id='li' style='display:list-item;margin-left:40px;list-style-type:square'>item</div>")); + + var li = LayoutHarness.FindById(root, "li")!; + + Assert.IsNotNull(li.ListItemBox); + } + + [Ignore("Gap, see Spec/css-display-3.yml")] + [TestMethod] + [Spec("css-display-3", "#list-items")] + public void InlineListItem_IsInlineAndGeneratesAMarker() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div>first <span id='li' style='display:inline list-item;list-style-position:inside'>second</span></div>")); + + var li = LayoutHarness.FindById(root, "li")!; + + Assert.AreEqual(FindWord(root, "first")!.Top, FindWord(root, "second")!.Top, Delta); + Assert.IsNotNull(li.ListItemBox); + } + + [TestMethod] + [Spec("css-display-3", "#layout-specific-display")] + public void TableInternalDisplayTypes_BuildATable() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='display:table'><div style='display:table-row'>" + + "<div id='a' style='display:table-cell;width:50px'>a</div><div id='b' style='display:table-cell;width:50px'>b</div>" + + "</div></div>")); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(a.Location.Y, b.Location.Y, Delta); + Assert.IsGreaterThanOrEqualTo(a.Location.X + 50 - Delta, b.Location.X); + } + + [TestMethod] + [Spec("css-display-3", "#box-generation")] + public void DisplayNone_PaintsNothingOfTheSubtree() + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( + "<div style='display:none'><div style='display:block;height:50px;background:rgb(255,0,0)'>gone</div></div><div>after</div>")); + var g = PaintHarness.PaintPage(container); + + Assert.IsFalse(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == Red)); + Assert.IsFalse(g.DrawStringCalls.Any(c => c.Text == "gone")); + Assert.AreEqual(0, g.DrawStringCalls.Single(c => c.Text == "after").Point.Y, Delta); + } + + [Ignore("Gap, see Spec/css-display-3.yml")] + [TestMethod] + [Spec("css-display-3", "#box-generation")] + public void DisplayContents_ReplacesTheElementByItsChildren() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div>first <div id='c' style='display:contents;padding-left:50px;border:5px solid red'>second</div> third</div>")); + + Assert.AreEqual(FindWord(root, "first")!.Top, FindWord(root, "second")!.Top, Delta); + Assert.AreEqual(FindWord(root, "second")!.Top, FindWord(root, "third")!.Top, Delta); + } + + [Ignore("Gap, see Spec/css-display-3.yml")] + [TestMethod] + [Spec("css-display-3", "#box-generation")] + public void DisplayContents_PaintsNoBoxOfItsOwn() + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( + "<div style='display:contents;background:rgb(255,0,0)'><div style='height:20px'>child</div></div>")); + var g = PaintHarness.PaintPage(container); + + Assert.IsFalse(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == Red)); + Assert.IsTrue(g.DrawStringCalls.Any(c => c.Text == "child")); + } + + [Ignore("Gap, see Spec/css-display-3.yml")] + [TestMethod] + [Spec("css-display-3", "#legacy-display")] + public void InlineBlockWithBlockChildren_StaysOnTheLine() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div>first <span style='display:inline-block;width:80px'><div>a</div><div>b</div></span> second</div>")); + + Assert.IsGreaterThan(FindWord(root, "first")!.Right, FindWord(root, "a")!.Left); + Assert.IsGreaterThan(FindWord(root, "a")!.Right, FindWord(root, "second")!.Left); + } + + [Ignore("Gap, see Spec/css-display-3.yml")] + [TestMethod] + [DataRow("flex")] + [DataRow("grid")] + [DataRow("inline-flex")] + [DataRow("inline-grid")] + [Spec("css-display-3", "#inner-model")] + [Spec("css-display-3", "#legacy-display")] + public void FlexAndGridContainers_PaintTheirContent(string display) + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( + $"<div style='display:{display}'><div>aaa</div><div>bbb</div></div>")); + var g = PaintHarness.PaintPage(container); + + Assert.IsTrue(g.DrawStringCalls.Any(c => c.Text == "aaa")); + Assert.IsTrue(g.DrawStringCalls.Any(c => c.Text == "bbb")); + } + + [Ignore("Gap, see Spec/css-display-3.yml")] + [TestMethod] + [Spec("css-display-3", "#transformations")] + public void FloatedInlineBlock_BlockifiesToBlock() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div><span id='f' style='display:inline-block;float:left;width:50px;height:30px'></span>text</div>")); + + Assert.AreEqual("block", LayoutHarness.FindById(root, "f")!.Display); + } + + [TestMethod] + [DataRow("")] + [DataRow("display:inline")] + [Spec("css-display-3", "#root")] + public void RootElement_IsBlockLevelAndFillsTheViewport(string style) + { + var (root, _) = LayoutHarness.Layout($"<!DOCTYPE html><html id='h' style='{style}'><body style='margin:0'><div style='height:20px'></div></body></html>", maxWidth: 400); + + var html = LayoutHarness.FindById(root, "h")!; + + Assert.AreEqual("block", html.Display); + Assert.AreEqual(400, html.Size.Width, Delta); + } + + [Ignore("Gap, see Spec/css-display-3.yml")] + [TestMethod] + [DataRow("inline-block")] + [DataRow("table-cell")] + [Spec("css-display-3", "#root")] + public void RootElement_IsBlockified(string display) + { + var (root, _) = LayoutHarness.Layout($"<!DOCTYPE html><html id='h' style='display:{display}'><body style='margin:0'><div style='height:20px'></div></body></html>", maxWidth: 400); + + var html = LayoutHarness.FindById(root, "h")!; + + Assert.AreEqual("block", html.Display); + Assert.AreEqual(400, html.Size.Width, Delta); + } + + [TestMethod] + [Spec("css-display-3", "#visibility")] + public void VisibilityCollapse_OutsideTables_HidesLikeHidden() + { + var (_, collapsed) = PaintHarness.Layout(PaintHarness.Wrap( + "<div style='visibility:collapse;height:20px;background:rgb(255,0,0)'>gone</div><div>after</div>")); + var g = PaintHarness.PaintPage(collapsed); + + Assert.IsFalse(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == Red)); + Assert.IsFalse(g.DrawStringCalls.Any(c => c.Text == "gone")); + Assert.IsGreaterThanOrEqualTo(20 - Delta, g.DrawStringCalls.Single(c => c.Text == "after").Point.Y); + } + + private static CssRect? FindWord(CssBox box, string text) + { + foreach (var word in box.Words) + { + if (word.Text == text) + return word; + } + + foreach (var child in box.Boxes) + { + var found = FindWord(child, text); + if (found is not null) + return found; + } + + return null; + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs index 4143c865c..20205d26d 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs @@ -129,6 +129,7 @@ public void InlineLevelDisplayValues_SitOnTheSameLine(string display) [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#inline-boxes")] + [Spec("css-display-3", "#legacy-display")] public void InlineTable_SitsOnTheSameLine() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -168,6 +169,7 @@ public void CollapsibleWhiteSpaceBetweenBlocks_GeneratesNoBox() [TestMethod] [Spec("css21", "visuren.html#display-prop")] + [Spec("css-display-3", "#box-generation")] public void DisplayNone_GeneratesNoBoxAndTakesNoSpace() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -187,6 +189,7 @@ public void UnknownElement_IsInlineByDefault() [TestMethod] [Spec("css21", "visuren.html#display-prop")] + [Spec("css-display-3", "#legacy-display")] public void InlineBlock_TakesItsWidthAndHeightOnTheLine() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -213,6 +216,7 @@ public void DisplayNone_IgnoresPositionAndFloat() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#dis-pos-flo")] + [Spec("css-display-3", "#transformations")] public void FloatedInline_IsBlockified() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -227,6 +231,7 @@ public void FloatedInline_IsBlockified() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#dis-pos-flo")] + [Spec("css-display-3", "#transformations")] public void AbsolutelyPositionedInline_IsBlockified() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/VisibilityPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/VisibilityPaintIntegrationTests.cs index 0d51b86df..07e3e4e3b 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/VisibilityPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/VisibilityPaintIntegrationTests.cs @@ -10,6 +10,7 @@ public sealed class VisibilityPaintIntegrationTests { [TestMethod] [Spec("css21", "visufx.html#visibility")] + [Spec("css-display-3", "#visibility")] public void Hidden_IsInheritedByDescendants() { var recording = Paint("<div style='visibility:hidden'><div style='height:10px;background:rgb(255,0,0)'>Child</div></div>"); @@ -20,6 +21,7 @@ public void Hidden_IsInheritedByDescendants() [TestMethod] [Spec("css21", "visufx.html#visibility")] + [Spec("css-display-3", "#visibility")] public void HiddenInline_PaintsNothingButKeepsItsSpace() { var hidden = Paint("<div>aaa <span style='visibility:hidden'>Hid</span> bbb</div>"); @@ -32,6 +34,7 @@ public void HiddenInline_PaintsNothingButKeepsItsSpace() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visufx.html#visibility")] + [Spec("css-display-3", "#visibility")] public void VisibleDescendantOfAHiddenElement_IsPainted() { var recording = Paint("<div style='visibility:hidden'><div style='visibility:visible;height:10px;background:rgb(255,0,0)'>Shown</div></div>"); diff --git a/Spec/css-display-3.yml b/Spec/css-display-3.yml index 09767dbd5..dc0bd77f0 100644 --- a/Spec/css-display-3.yml +++ b/Spec/css-display-3.yml @@ -5,49 +5,67 @@ snapshot: https://www.w3.org/TR/2026/CRD-css-display-3-20260605/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#the-display-properties': title: '2 Box Layout Modes: the display property' - status: unknown + status: partial + gaps: + - The two-value syntax, e.g. 'block flow' or 'inline flow-root', is rejected '#outer-role': title: '2.1 Outer Display Roles for Flow Layout: the block, inline, and run-in keywords' - status: unknown + status: partial + gaps: + - "'run-in' is rejected" '#inner-model': title: '2.2 Inner Display Layout Models: the flow, flow-root, table, flex, grid, and ruby keywords' - status: unknown + status: partial + gaps: + - "'flow-root' is rejected" + - flex and grid containers lay out and paint none of their content + - "'ruby' is rejected" '#list-items': title: '2.3 Generating Marker Boxes: the list-item keyword' - status: unknown + status: partial + gaps: + - "'list-item' combined with an outer or inner display type, e.g. 'inline list-item', is rejected" '#layout-specific-display': title: '2.4 Layout-Internal Display Types: the table-* and ruby-* keywords' - status: unknown + status: partial + gaps: + - The ruby-* keywords are rejected '#box-generation': title: '2.5 Box Generation: the none and contents keywords' - status: unknown + status: partial + gaps: + - "'contents' is rejected, the element keeps its own box" '#legacy-display': title: 2.6 Precomposed Inline-level Display Values - status: unknown + status: partial + gaps: + - "'inline-table' is laid out like a block, see CSS 2.1 9.2.2" + - An inline-block with block-level content is split out of the line, see CSS 2.1 10.6.6 + - "'inline-flex' and 'inline-grid' lose their content, see 2.2" '#transformations': title: 2.7 Automatic Box Type Transformations - status: unknown + status: unsupported + gaps: + - Floated and absolutely positioned boxes keep their inline-level display type '#root': title: 2.8 The Root Element’s Principal Box - status: unknown + status: partial + gaps: + - An inline-level root other than 'inline', e.g. 'inline-block', or a layout-internal root isn't blockified '#order-property': title: '3 Display Order: the order property' - status: unknown + status: parsed-only '#order-accessibility': title: 3.1 Reordering and Accessibility - status: unknown + status: n/a '#visibility': title: '4 Invisibility: the visibility property' - status: unknown + status: partial + gaps: + - "Descendants with 'visibility: visible' of a hidden element are not painted, see CSS 2.1 11.2" '#run-in-layout': title: 5 Run-In Layout - status: unknown + status: unsupported From 0e15f3882a4cf3d287669a911aba6aa3cf3f1be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:00:55 +0100 Subject: [PATCH 065/150] Verify and document CSS Box Model Level 3 compliance --- .../BoxModel/BoxDimensionsIntegrationTests.cs | 3 + .../BoxModel/MarginIntegrationTests.cs | 3 + .../BoxModel/PaddingIntegrationTests.cs | 2 + .../CssModules/CssBoxTests.cs | 64 +++++++++++++++++++ .../Css/MarginPropertyTests.cs | 23 +++---- .../Css/PaddingPropertyTests.cs | 21 ++---- Spec/css-box-3.yml | 48 ++++++-------- 7 files changed, 108 insertions(+), 56 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssBoxTests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BoxDimensionsIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BoxDimensionsIntegrationTests.cs index bfbbfb2f0..0ce67f4e4 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BoxDimensionsIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BoxDimensionsIntegrationTests.cs @@ -10,6 +10,8 @@ public sealed class BoxDimensionsIntegrationTests [TestMethod] [Spec("css21", "box.html#box-dimensions")] + [Spec("css-box-3", "#box-edges")] + [Spec("css-box-3", "#borders")] public void BorderBox_IsContentPlusPaddingPlusBorder_AndTheMarginSurroundsIt() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(Box)); @@ -23,6 +25,7 @@ public void BorderBox_IsContentPlusPaddingPlusBorder_AndTheMarginSurroundsIt() [TestMethod] [Spec("css21", "box.html#box-dimensions")] + [Spec("css-box-3", "#box-model")] public void Background_CoversThePaddingAndBorderArea() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap(Box)); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs index 1206b15ff..3a2933a50 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs @@ -11,6 +11,7 @@ public sealed class MarginIntegrationTests [TestMethod] [Spec("css21", "box.html#margin-properties")] + [Spec("css-box-3", "#margin-physical")] public void NegativeMargin_PullsTheBoxOutOfTheContentArea() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -23,6 +24,7 @@ public void NegativeMargin_PullsTheBoxOutOfTheContentArea() [TestMethod] [Spec("css21", "box.html#margin-properties")] + [Spec("css-box-3", "#margin-physical")] public void VerticalPercentageMargin_ResolvesAgainstTheContainingBlockWidth() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -35,6 +37,7 @@ public void VerticalPercentageMargin_ResolvesAgainstTheContainingBlockWidth() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "box.html#margin-properties")] + [Spec("css-box-3", "#margin-physical")] public void HorizontalPercentageMargin_ResolvesAgainstTheContainingBlockWidth() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/PaddingIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/PaddingIntegrationTests.cs index 98ad7d8f3..960a02015 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/PaddingIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/PaddingIntegrationTests.cs @@ -10,6 +10,7 @@ public sealed class PaddingIntegrationTests [TestMethod] [Spec("css21", "box.html#padding-properties")] + [Spec("css-box-3", "#padding-physical")] public void PercentagePadding_ResolvesAgainstTheContainingBlockWidth() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -24,6 +25,7 @@ public void PercentagePadding_ResolvesAgainstTheContainingBlockWidth() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "box.html#padding-properties")] + [Spec("css-box-3", "#padding-physical")] public void NegativePadding_IsIgnored() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='b' style='padding-left:-10px'></div>")); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssBoxTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssBoxTests.cs new file mode 100644 index 000000000..3b0a9b2b5 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssBoxTests.cs @@ -0,0 +1,64 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; + +namespace HtmlRenderer.IntegrationTest.CssModules; + +[DoNotParallelize] +[TestClass] +public sealed class CssBoxTests +{ + private const double Delta = 0.5; + + [TestMethod] + [Spec("css-box-3", "#margin-physical")] + public void MarginLonghands_SetTheirOwnSideOnly() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='width:200px'><div id='b' style='margin-top:5px;margin-right:7px;margin-bottom:11px;margin-left:13px;height:10px'></div><div id='n' style='height:10px'></div></div>")); + var b = LayoutHarness.FindById(root, "b")!; + var n = LayoutHarness.FindById(root, "n")!; + + Assert.AreEqual(13, b.Location.X, Delta); + Assert.AreEqual(5, b.Location.Y, Delta); + Assert.AreEqual(200 - 13 - 7, b.Size.Width, Delta); + Assert.AreEqual(b.ActualBottom + 11, n.Location.Y, Delta); + } + + [TestMethod] + [Spec("css-box-3", "#margin-physical")] + public void Margin_DoesNotApplyToTableCells() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table style='border-spacing:0'><tr><td id='a' style='padding:0'>a</td><td id='b' style='margin-left:50px;padding:0'>b</td></tr></table>")); + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(a.ActualRight, b.Location.X, Delta); + } + + [TestMethod] + [Spec("css-box-3", "#padding-physical")] + public void PaddingLonghands_SetTheirOwnSideOnly() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div id='b' style='width:100px;padding-top:5px;padding-right:7px;padding-bottom:11px;padding-left:13px'><div id='c' style='height:10px'></div></div>")); + var b = LayoutHarness.FindById(root, "b")!; + var c = LayoutHarness.FindById(root, "c")!; + + Assert.AreEqual(13, c.Location.X, Delta); + Assert.AreEqual(5, c.Location.Y, Delta); + Assert.AreEqual(100 + 13 + 7, b.Size.Width, Delta); + Assert.AreEqual(10 + 5 + 11, b.Size.Height, Delta); + } + + [TestMethod] + [Spec("css-box-3", "#padding-physical")] + public void Padding_AppliesToTableCells() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table style='border-spacing:0'><tr><td id='a' style='padding:0 0 0 30px'>a</td></tr></table>")); + var a = LayoutHarness.FindById(root, "a")!; + + Assert.AreEqual(30, LayoutHarness.Descendants(a).SelectMany(d => d.Words).First().Left - a.Location.X, Delta); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/MarginPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/MarginPropertyTests.cs index 73d180bb7..393d29f71 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MarginPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MarginPropertyTests.cs @@ -3,21 +3,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/MarginProperty.cs. -/// Only the 1-4 value `margin` shorthand-splitting cases apply. The individual longhand-property -/// tests (margin-left, margin-right, margin-top, margin-bottom) and the CSS text -/// recombination/simplification tests (MarginShouldBeRecombinedCorrectly and friends, which depend on -/// PeachPDF's own CSSOM re-serializing a rule back to text) have no equivalent surface in HTML-Renderer -/// and were dropped. -/// The old <c>CssParser.ParseCssBlock</c> raw-property-dictionary API no longer exists - the CSS engine -/// port replaced it with a real, spec-compliant "margin" <c>ShorthandProperty</c> (the same vendored -/// engine PeachPDF itself uses), exercised here through <see cref="CssParser.ParseInlineStyle"/> and its -/// resulting <c>StyleDeclaration</c>'s typed margin-* longhands. Per CSS Box Model: 1 value applies to -/// all sides, 2 values are (vertical, horizontal), 3 values are (top, horizontal, bottom), 4 values are -/// (top, right, bottom, left); a value with any other token count is invalid and leaves the shorthand - -/// and so every margin-* longhand - entirely unset. -/// </summary> [TestClass] public sealed class MarginPropertyTests { @@ -29,6 +14,7 @@ private static string GetProperty(string declaration, string propertyName) } [TestMethod] + [Spec("css-box-3", "#margin-shorthand")] public void MarginAllZeroLegal() { const string declaration = "margin: 0"; @@ -40,6 +26,7 @@ public void MarginAllZeroLegal() } [TestMethod] + [Spec("css-box-3", "#margin-shorthand")] public void MarginAllPercentLegal() { const string declaration = "margin: 25%"; @@ -51,6 +38,7 @@ public void MarginAllPercentLegal() } [TestMethod] + [Spec("css-box-3", "#margin-shorthand")] public void MarginAutoLegal() { const string declaration = "margin: auto"; @@ -62,6 +50,7 @@ public void MarginAutoLegal() } [TestMethod] + [Spec("css-box-3", "#margin-shorthand")] public void MarginSidesLengthLegal() { const string declaration = "margin: 10px 3em"; @@ -73,6 +62,7 @@ public void MarginSidesLengthLegal() } [TestMethod] + [Spec("css-box-3", "#margin-shorthand")] public void MarginSidesLengthAndAutoLegal() { const string declaration = "margin: 10px auto"; @@ -84,6 +74,7 @@ public void MarginSidesLengthAndAutoLegal() } [TestMethod] + [Spec("css-box-3", "#margin-shorthand")] public void MarginThreeValuesLegal() { const string declaration = "margin: 10px 3em 5px"; @@ -95,6 +86,7 @@ public void MarginThreeValuesLegal() } [TestMethod] + [Spec("css-box-3", "#margin-shorthand")] public void MarginAllValuesWithPercentAndAutoLegal() { const string declaration = "margin: 10px 5% auto 2%"; @@ -106,6 +98,7 @@ public void MarginAllValuesWithPercentAndAutoLegal() } [TestMethod] + [Spec("css-box-3", "#margin-shorthand")] public void MarginTooManyValuesIllegal() { const string declaration = "margin: 10px 5% 8px 2% 3px auto"; diff --git a/Source/Test/HtmlRenderer.Test/Css/PaddingPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/PaddingPropertyTests.cs index d2daf75d8..147caa433 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PaddingPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PaddingPropertyTests.cs @@ -3,17 +3,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/PaddingProperty.cs. -/// Only the 1-4 value `padding` shorthand-splitting cases apply, mirroring MarginPropertyTests. The -/// individual longhand-property tests were dropped (out of scope per the porting brief). -/// The old <c>CssParser.ParseCssBlock</c> raw-property-dictionary API no longer exists - the CSS engine -/// port replaced it with a real, spec-compliant "padding" <c>ShorthandProperty</c> (the same vendored -/// engine PeachPDF itself uses), exercised here through <see cref="CssParser.ParseInlineStyle"/>. -/// Unlike the old hand-rolled <c>ParsePaddingProperty</c> (which only split on whitespace with no -/// keyword validation), the real engine's padding longhands only accept a length-percentage - "auto" is -/// correctly rejected (CssPaddingAutoIllegal, previously dropped as out of reach, is restored here). -/// </summary> [TestClass] public sealed class PaddingPropertyTests { @@ -25,6 +14,7 @@ private static string GetProperty(string declaration, string propertyName) } [TestMethod] + [Spec("css-box-3", "#padding-shorthand")] public void CssPaddingAllZeroLegal() { const string declaration = "padding: 0"; @@ -36,6 +26,7 @@ public void CssPaddingAllZeroLegal() } [TestMethod] + [Spec("css-box-3", "#padding-shorthand")] public void CssPaddingAllPercentLegal() { const string declaration = "padding: 25%"; @@ -47,6 +38,7 @@ public void CssPaddingAllPercentLegal() } [TestMethod] + [Spec("css-box-3", "#padding-shorthand")] public void CssPaddingSidesLengthLegal() { const string declaration = "padding: 10px 3em"; @@ -58,11 +50,9 @@ public void CssPaddingSidesLengthLegal() } [TestMethod] + [Spec("css-box-3", "#padding-shorthand")] public void CssPaddingAutoIllegal() { - // "auto" is not a legal padding value (padding only accepts <length-percentage>) - restored - // against the real engine, which now actually validates this (the old hand-rolled - // ParsePaddingProperty had no keyword validation at all and let "auto" through unfiltered). const string declaration = "padding: auto"; Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "padding-left"))); @@ -72,6 +62,7 @@ public void CssPaddingAutoIllegal() } [TestMethod] + [Spec("css-box-3", "#padding-shorthand")] public void CssPaddingThreeValuesLegal() { const string declaration = "padding: 10px 3em 5px"; @@ -83,6 +74,7 @@ public void CssPaddingThreeValuesLegal() } [TestMethod] + [Spec("css-box-3", "#padding-shorthand")] public void CssPaddingAllValuesWithPercentLegal() { const string declaration = "padding: 10px 5% 8px 2%"; @@ -94,6 +86,7 @@ public void CssPaddingAllValuesWithPercentLegal() } [TestMethod] + [Spec("css-box-3", "#padding-shorthand")] public void CssPaddingTooManyValuesIllegal() { const string declaration = "padding: 10px 5% 8px 2% 3px"; diff --git a/Spec/css-box-3.yml b/Spec/css-box-3.yml index b7f96d82e..d9e7bb7fb 100644 --- a/Spec/css-box-3.yml +++ b/Spec/css-box-3.yml @@ -5,58 +5,52 @@ snapshot: https://www.w3.org/TR/2024/REC-css-box-3-20240411/ sections: '#intro': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown - '#placement': - title: 1.2 Module Interactions - status: unknown + status: n/a '#box-model': title: 2 The CSS Box Model - status: unknown + status: partial '#box-edges': title: 2.1 Boxes and Edges - status: unknown + status: supported '#fragmentation': title: 2.2 Fragmentation - status: unknown + status: n/a '#keywords': title: 2.3 Box-edge Keywords - status: unknown + status: parsed-only + gaps: + - margin-box, fill-box, stroke-box and view-box are rejected '#margins': title: 3 Margins - status: unknown + status: partial '#margin-physical': title: '3.1 Page-relative (Physical) Margin Properties: the margin-top, margin-right, margin-bottom, and margin-left properties' - status: unknown + status: partial + gaps: + - Horizontal percentage margins resolve against the box's own width, see CSS 2.1 8.3 '#margin-shorthand': title: '3.2 Margin Shorthand: the margin property' - status: unknown + status: supported '#paddings': title: 4 Padding - status: unknown + status: partial '#padding-physical': title: '4.1 Page-relative (Physical) Padding Properties: the padding-top, padding-right, padding-bottom, and padding-left properties' - status: unknown + status: partial + gaps: + - Negative padding is applied instead of being rejected, see CSS 2.1 8.4 '#padding-shorthand': title: '4.2 Padding Shorthand: the padding property' - status: unknown + status: supported '#borders': title: 5 Borders - status: unknown + status: supported '#changes': title: 6 Changes - status: unknown - '#changes-recent': - title: 6.1 Recent Changes - status: unknown - '#changes-since-L2': - title: 6.2 Changes Since CSS Level 2 - status: unknown + status: n/a '#priv': title: 7 Privacy Considerations - status: unknown + status: n/a '#sec': title: 8 Security Considerations - status: unknown + status: n/a From 08a824635b433f547f73e1af2f287c2b474df90b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:07:28 +0100 Subject: [PATCH 066/150] Verify and document CSS Positioned Layout Level 3 compliance --- Source/HtmlRenderer/Core/Dom/CssBox.cs | 3 +- .../Core/Fragmentation/FragmentEmitter.cs | 8 +- .../Core/Fragmentation/InlineFragmentation.cs | 2 +- .../ContainingBlockIntegrationTests.cs | 3 + .../BoxModel/HeightIntegrationTests.cs | 6 + .../BoxModel/WidthIntegrationTests.cs | 9 + .../CssModules/CssPositionTests.cs | 187 ++++++++++++++++++ .../FixedPositionRepeatsPerPageTest.cs | 2 + .../Layout/BoxGenerationIntegrationTests.cs | 2 + .../Painting/StackingContextTests.cs | 3 + .../AbsolutePositioningIntegrationTests.cs | 18 +- .../OutOfFlowPositioningIntegrationTests.cs | 9 +- .../RelativePositioningIntegrationTests.cs | 11 ++ .../WptCssPositionInsetLogicalParsingTests.cs | 68 +++---- .../Css/WptCssPositionParsingTests.cs | 25 ++- Spec/css-position-3.yml | 107 +++++----- 16 files changed, 346 insertions(+), 117 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssPositionTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index 39993df74..9a0e8b50b 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -933,7 +933,8 @@ protected virtual void PerformLayoutImp(RGraphics g) var leftIsAuto = string.IsNullOrEmpty(Left) || Left == CssConstants.Auto; var rightIsAuto = string.IsNullOrEmpty(Right) || Right == CssConstants.Auto; - // Offsets are from the containing block's padding edge (CSS 2.1 §10.1). 'bottom' + // CSS Position 3 §2.1 wants the padding edge, this uses the content edge. An auto + // inset falls back to that edge too, not to the static position (§3.5.2). 'bottom' // needs the height and is applied after ApplyHeight. var absLeft = !leftIsAuto ? nearestPositionedAncestor.ClientLeft + ActualMarginLeft diff --git a/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs b/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs index 44f057747..0d5a3cdf3 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs @@ -49,7 +49,7 @@ internal FragmentTree Finish() var lastSlot = _container.PageIndexOf(Math.Max(0, root.ActualBottom - Epsilon)); var fragmentainers = new List<FragmentainerFragment>(); - // A fixed box repeats on every page (css-position-3). Its Location is already page-relative, + // A fixed box repeats on every page (CSS Position 3 §2.1). Its Location is already page-relative, // so it's built against a band starting at 0 on every page. var fixedRoots = new List<CssBox>(); CollectFixedRoots(root, fixedRoots); @@ -86,10 +86,8 @@ internal FragmentTree Finish() } /// <summary> - /// Finds every <c>position:fixed</c> box in the tree, at any nesting depth - each one gets its - /// own independent repeat-per-page treatment in <see cref="Finish"/>, regardless of whether it's - /// nested inside another fixed box (rare, but each still resolves its own page-relative position - /// independently per css-position-3, so neither should be folded into the other's subtree). + /// Finds every <c>position:fixed</c> box in the tree, at any depth. A fixed box nested in another one + /// is repeated on its own, as it resolves its page-relative position independently. /// </summary> private static void CollectFixedRoots(CssBox box, List<CssBox> into) { diff --git a/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs b/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs index cd631e515..fe5d71f85 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs @@ -25,7 +25,7 @@ internal static class InlineFragmentation internal static void ApplyLineBreaking(CssBox blockBox) { var container = blockBox.HtmlContainer; - // A fixed box repeats on every page in page coordinates and isn't paginated (css-position-3). + // A fixed box repeats on every page and isn't paginated (CSS Position 3 §3.5.3) if (container == null || !container.HasRealPageGrid || blockBox.IsFixed) return; diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/ContainingBlockIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/ContainingBlockIntegrationTests.cs index 48107c76d..290ce306c 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/ContainingBlockIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/ContainingBlockIntegrationTests.cs @@ -36,6 +36,7 @@ public void BlockInsideAnInline_IsContainedByTheNearestBlockContainer() [TestMethod] [Spec("css21", "visudet.html#containing-block-details")] + [Spec("css-position-3", "#def-cb")] public void AbsoluteBox_IsPlacedInTheNearestPositionedAncestor() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -50,6 +51,7 @@ public void AbsoluteBox_IsPlacedInTheNearestPositionedAncestor() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#containing-block-details")] + [Spec("css-position-3", "#abspos-layout")] public void AbsoluteBox_PercentageWidth_ResolvesAgainstTheNearestPositionedAncestor() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -75,6 +77,7 @@ public void AbsoluteBox_WithoutAPositionedAncestor_IsPlacedInTheInitialContainin [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#containing-block-details")] + [Spec("css-position-3", "#def-cb")] public void AbsoluteBox_IsContainedByThePaddingEdgeOfThePositionedAncestor() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs index 8dc137b49..44f805ee3 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs @@ -167,6 +167,7 @@ public void AutoHeight_IgnoresFloatsAndAbsoluteChildren() [TestMethod] [Spec("css21", "visudet.html#abs-non-replaced-height")] + [Spec("css-position-3", "#abs-non-replaced-height")] public void Absolute_WithTopAndHeight_IsPlacedAtTop() { var root = Layout("<div style='position:relative;width:200px;height:100px'><div id='a' style='position:absolute;left:0;top:10px;width:10px;height:30px'></div></div>"); @@ -178,6 +179,7 @@ public void Absolute_WithTopAndHeight_IsPlacedAtTop() [TestMethod] [Spec("css21", "visudet.html#abs-non-replaced-height")] + [Spec("css-position-3", "#abs-non-replaced-height")] public void Absolute_WithBottomAndHeight_IsPlacedAtBottom() { var root = Layout("<div style='position:relative;width:200px;height:100px'><div id='a' style='position:absolute;left:0;bottom:10px;width:10px;height:30px'></div></div>"); @@ -187,6 +189,7 @@ public void Absolute_WithBottomAndHeight_IsPlacedAtBottom() [TestMethod] [Spec("css21", "visudet.html#abs-non-replaced-height")] + [Spec("css-position-3", "#abs-non-replaced-height")] public void Absolute_WithAutoHeight_IsAsTallAsItsContent() { var root = Layout("<div style='position:relative;width:200px;height:100px'><div id='a' style='position:absolute;left:0;top:0;width:100px'>x</div></div>"); @@ -197,6 +200,8 @@ public void Absolute_WithAutoHeight_IsAsTallAsItsContent() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#abs-non-replaced-height")] + [Spec("css-position-3", "#abs-non-replaced-height")] + [Spec("css-position-3", "#abspos-auto-size")] public void Absolute_WithTopAndBottom_StretchesTheAutoHeight() { var root = Layout("<div style='position:relative;width:200px;height:100px'><div id='a' style='position:absolute;left:0;top:10px;bottom:30px;width:10px'></div></div>"); @@ -206,6 +211,7 @@ public void Absolute_WithTopAndBottom_StretchesTheAutoHeight() [TestMethod] [Spec("css21", "visudet.html#abs-replaced-height")] + [Spec("css-position-3", "#abs-replaced-height")] public void AbsoluteImage_KeepsItsIntrinsicHeight() { var root = TestImages.Layout($"<div style='position:relative;width:200px;height:100px'><img id='i' style='position:absolute;left:0;top:0' src='{Image}'></div>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs index 0ea2e01e9..88a7fac6f 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs @@ -211,6 +211,7 @@ public void FloatedImage_KeepsItsIntrinsicWidth() [TestMethod] [Spec("css21", "visudet.html#abs-non-replaced-width")] + [Spec("css-position-3", "#abs-non-replaced-width")] public void Absolute_WithLeftAndWidth_IsPlacedAtLeft() { var root = Layout("<div style='position:relative;width:200px;height:100px'><div id='a' style='position:absolute;left:10px;top:0;width:50px;height:10px'></div></div>"); @@ -222,6 +223,7 @@ public void Absolute_WithLeftAndWidth_IsPlacedAtLeft() [TestMethod] [Spec("css21", "visudet.html#abs-non-replaced-width")] + [Spec("css-position-3", "#abs-non-replaced-width")] public void Absolute_WithRightAndWidth_IsPlacedAtRight() { var root = Layout("<div style='position:relative;width:200px;height:100px'><div id='a' style='position:absolute;right:10px;top:0;width:50px;height:10px'></div></div>"); @@ -232,6 +234,8 @@ public void Absolute_WithRightAndWidth_IsPlacedAtRight() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#abs-non-replaced-width")] + [Spec("css-position-3", "#abs-non-replaced-width")] + [Spec("css-position-3", "#abspos-auto-size")] public void Absolute_WithLeftAndRight_StretchesTheAutoWidth() { var root = Layout("<div style='position:relative;width:200px;height:100px'><div id='a' style='position:absolute;left:10px;right:30px;top:0;height:10px'></div></div>"); @@ -244,6 +248,8 @@ public void Absolute_WithLeftAndRight_StretchesTheAutoWidth() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#abs-non-replaced-width")] + [Spec("css-position-3", "#abs-non-replaced-width")] + [Spec("css-position-3", "#abspos-auto-size")] public void Absolute_WithAutoWidth_ShrinksToFitItsContent() { var root = Layout("<div style='position:relative;width:400px;height:100px'><div id='a' style='position:absolute;left:0;top:0'>xx yy</div></div>"); @@ -256,6 +262,8 @@ public void Absolute_WithAutoWidth_ShrinksToFitItsContent() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#abs-non-replaced-width")] + [Spec("css-position-3", "#abs-non-replaced-width")] + [Spec("css-position-3", "#abspos-margins")] public void Absolute_WithLeftRightAndWidth_IsCenteredByAutoMargins() { var root = Layout("<div style='position:relative;width:200px;height:100px'><div id='a' style='position:absolute;left:0;right:0;width:100px;margin:0 auto;top:0;height:10px'></div></div>"); @@ -265,6 +273,7 @@ public void Absolute_WithLeftRightAndWidth_IsCenteredByAutoMargins() [TestMethod] [Spec("css21", "visudet.html#abs-replaced-width")] + [Spec("css-position-3", "#abs-replaced-width")] public void AbsoluteImage_KeepsItsIntrinsicWidth() { var root = TestImages.Layout($"<div style='position:relative;width:200px;height:100px'><img id='i' style='position:absolute;left:0;top:0' src='{Image}'></div>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssPositionTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssPositionTests.cs new file mode 100644 index 000000000..7fb17bc4a --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssPositionTests.cs @@ -0,0 +1,187 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.CssModules; + +[DoNotParallelize] +[TestClass] +public sealed class CssPositionTests +{ + private const double Delta = 0.5; + + private const string Container = "<div style='position:relative;width:200px;height:100px'>{0}</div>"; + + [TestMethod] + [Spec("css-position-3", "#position-property")] + public void Sticky_WithAutoInsets_StaysInFlow() + { + var root = Layout("<div id='a' style='height:20px'></div><div id='s' style='position:sticky;height:20px'></div><div id='b' style='height:20px'></div>"); + + Assert.AreEqual(20, Box(root, "s").Location.Y, Delta); + Assert.AreEqual(40, Box(root, "b").Location.Y, Delta); + } + + [TestMethod] + [DataRow("relative")] + [DataRow("absolute")] + [DataRow("fixed")] + [DataRow("sticky")] + [Spec("css-position-3", "#position-property")] + [Spec("css-position-3", "#def-cb")] + public void PositionedBox_EstablishesTheContainingBlockForAbsoluteDescendants(string position) + { + var root = Layout($"<div style='height:50px'></div><div id='p' style='position:{position};top:50px;left:30px;width:100px;height:100px'>" + + "<div id='a' style='position:absolute;top:10px;left:10px;width:10px;height:10px'></div></div>"); + var p = Box(root, "p"); + var a = Box(root, "a"); + + Assert.AreEqual(p.Location.X + 10, a.Location.X, Delta); + Assert.AreEqual(p.Location.Y + 10, a.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css-position-3.yml")] + [TestMethod] + [Spec("css-position-3", "#stickypos-insets")] + public void Sticky_IsShiftedIntoTheStickyViewRectangle() + { + var root = Layout("<div style='height:300px'><div id='s' style='position:sticky;top:50px;height:20px'></div></div>"); + + Assert.AreEqual(50, Box(root, "s").Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css-position-3.yml")] + [TestMethod] + [Spec("css-position-3", "#stickypos-insets")] + public void Sticky_StaysInsideItsContainingBlock() + { + var root = Layout("<div style='height:40px'><div id='s' style='position:sticky;top:100px;height:20px'></div></div>"); + + Assert.AreEqual(20, Box(root, "s").Location.Y, Delta); + } + + [TestMethod] + [Spec("css-position-3", "#inset-shorthands")] + public void Inset_SetsTheFourPhysicalInsetsLikeMargin() + { + var root = Layout(string.Format(Container, "<div id='a' style='position:absolute;inset:10px auto auto 20px;width:30px;height:30px'></div>" + + "<div id='b' style='position:absolute;inset:auto 5px 15px auto;width:30px;height:30px'></div>")); + + Assert.AreEqual(20, Box(root, "a").Location.X, Delta); + Assert.AreEqual(10, Box(root, "a").Location.Y, Delta); + Assert.AreEqual(200 - 5 - 30, Box(root, "b").Location.X, Delta); + Assert.AreEqual(100 - 15 - 30, Box(root, "b").Location.Y, Delta); + } + + [TestMethod] + [Spec("css-position-3", "#inset-shorthands")] + public void InsetBlockAndInsetInline_SetTheirAxis() + { + var root = Layout(string.Format(Container, "<div id='a' style='position:absolute;inset-block:10px auto;inset-inline:20px auto;width:30px;height:30px'></div>")); + + Assert.AreEqual(20, Box(root, "a").Location.X, Delta); + Assert.AreEqual(10, Box(root, "a").Location.Y, Delta); + } + + [TestMethod] + [Spec("css-position-3", "#insets")] + public void FlowRelativeInsets_MapToThePhysicalSidesInLtr() + { + var root = Layout(string.Format(Container, "<div id='a' style='position:absolute;inset-block-end:10px;inset-inline-end:20px;width:30px;height:30px'></div>")); + + Assert.AreEqual(200 - 20 - 30, Box(root, "a").Location.X, Delta); + Assert.AreEqual(100 - 10 - 30, Box(root, "a").Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css-position-3.yml")] + [TestMethod] + [Spec("css-position-3", "#insets")] + public void InsetInlineStart_IsTheRightSideInRtl() + { + var root = Layout("<div style='position:relative;direction:rtl;width:200px;height:100px'>" + + "<div id='a' style='position:absolute;top:0;inset-inline-start:20px;width:30px;height:30px'></div></div>"); + + Assert.AreEqual(200 - 20 - 30, Box(root, "a").Location.X, Delta); + } + + [Ignore("Gap, see Spec/css-position-3.yml")] + [TestMethod] + [Spec("css-position-3", "#relpos-insets")] + public void RelativeTableRow_ShiftsItsCells() + { + var root = Layout("<table style='border-spacing:0'><tr><td id='a'>a</td></tr><tr style='position:relative;left:40px;top:5px'><td id='b'>b</td></tr></table>"); + + Assert.AreEqual(Box(root, "a").Location.X + 40, Box(root, "b").Location.X, Delta); + } + + [Ignore("Gap, see Spec/css-position-3.yml")] + [TestMethod] + [Spec("css-position-3", "#staticpos-rect")] + [Spec("css-position-3", "#resolving-insets")] + public void AbsoluteBox_WithAutoInsets_IsPlacedAtItsStaticPosition() + { + var root = Layout("<div style='position:relative;padding-left:15px'><div style='height:30px'></div>" + + "<div id='a' style='position:absolute;width:10px;height:10px'></div></div>"); + var a = Box(root, "a"); + + Assert.AreEqual(15, a.Location.X, Delta); + Assert.AreEqual(30, a.Location.Y, Delta); + } + + [TestMethod] + [Spec("css-position-3", "#resolving-insets")] + [Spec("css-position-3", "#abspos-alignment")] + public void AbsoluteBox_WithOneAutoInset_IsAlignedToTheOtherInset() + { + var root = Layout(string.Format(Container, "<div id='a' style='position:absolute;right:10px;bottom:20px;width:30px;height:30px'></div>")); + + Assert.AreEqual(160, Box(root, "a").Location.X, Delta); + Assert.AreEqual(50, Box(root, "a").Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css-position-3.yml")] + [TestMethod] + [Spec("css-position-3", "#abspos-alignment")] + public void AbsoluteBox_IsAlignedByJustifySelfAndAlignSelf() + { + var root = Layout(string.Format(Container, "<div id='a' style='position:absolute;inset:0;justify-self:end;align-self:center;width:30px;height:30px'></div>")); + + Assert.AreEqual(170, Box(root, "a").Location.X, Delta); + Assert.AreEqual(35, Box(root, "a").Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css-position-3.yml")] + [TestMethod] + [Spec("css-position-3", "#abspos-margins")] + public void AbsoluteBox_AutoMarginsCenterItVertically() + { + var root = Layout(string.Format(Container, "<div id='a' style='position:absolute;top:0;bottom:0;left:0;margin:auto 0;width:30px;height:30px'></div>")); + + Assert.AreEqual(35, Box(root, "a").Location.Y, Delta); + } + + [TestMethod] + [Spec("css-position-3", "#abspos-margins")] + public void AbsoluteBox_AutoMarginsAreZeroWhenAnInsetIsAuto() + { + var root = Layout(string.Format(Container, "<div id='a' style='position:absolute;top:10px;left:20px;margin:auto;width:30px;height:30px'></div>")); + + Assert.AreEqual(20, Box(root, "a").Location.X, Delta); + Assert.AreEqual(10, Box(root, "a").Location.Y, Delta); + } + + [TestMethod] + [Spec("css-position-3", "#abspos-breaking")] + public void AbsoluteBox_IsPositionedIgnoringPageBreaks() + { + var (root, _) = PaintHarness.LayoutPaginated(PaintHarness.Wrap( + "<div style='position:relative'><div style='height:150px'></div><div id='a' style='position:absolute;top:120px;left:0;width:10px;height:10px'></div></div>"), + pageHeight: 100); + + Assert.AreEqual(120, PaintHarness.FindById(root, "a")!.Location.Y, Delta); + } + + private static CssBox Layout(string body) => LayoutHarness.Layout(LayoutHarness.Wrap(body), maxWidth: 400, maxHeight: 400).Root; + + private static CssBox Box(CssBox root, string id) => LayoutHarness.FindById(root, id)!; +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/FixedPositionRepeatsPerPageTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/FixedPositionRepeatsPerPageTest.cs index a942cf764..bba3ce94b 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/FixedPositionRepeatsPerPageTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/FixedPositionRepeatsPerPageTest.cs @@ -31,6 +31,8 @@ private static HtmlContainerInt GetInternal(HtmlContainer wrapper) [TestMethod] [Spec("css21", "visuren.html#fixed-positioning")] + [Spec("css-position-3", "#def-cb")] + [Spec("css-position-3", "#abspos-breaking")] public async Task TopLeftFixedElement_RepeatsIdenticallyOnEveryPage() { using var wrapper = new HtmlContainer(); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs index 20205d26d..b68f45a2f 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs @@ -232,6 +232,7 @@ public void FloatedInline_IsBlockified() [TestMethod] [Spec("css21", "visuren.html#dis-pos-flo")] [Spec("css-display-3", "#transformations")] + [Spec("css-position-3", "#position-property")] public void AbsolutelyPositionedInline_IsBlockified() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -246,6 +247,7 @@ public void AbsolutelyPositionedInline_IsBlockified() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#dis-pos-flo")] + [Spec("css-position-3", "#position-property")] public void AbsolutePosition_ComputesFloatToNone() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs index 68b696642..878f6bcda 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs @@ -35,6 +35,7 @@ public void Background_PaintsBeforeText() [TestMethod] [Spec("css21", "zindex.html#painting-order")] [Spec("css21", "visuren.html#z-index")] + [Spec("css-position-3", "#stacking")] public void AbsoluteBox_PaintsAfterLaterInFlowSiblings() { var g = Paint("<div style='position:relative'>" @@ -47,6 +48,7 @@ public void AbsoluteBox_PaintsAfterLaterInFlowSiblings() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#z-index")] + [Spec("css-position-3", "#stacking")] public void HigherZIndex_PaintsInFrontRegardlessOfDocumentOrder() { var g = Paint("<div style='position:absolute;z-index:2;top:0;left:0;width:50px;height:50px;background:rgb(255,0,0)'></div>" @@ -83,6 +85,7 @@ public void NegativeZIndex_PaintsBelowInFlowBlocks() [TestMethod] [Spec("css21", "zindex.html#painting-order")] [Spec("css21", "visuren.html#z-index")] + [Spec("css-position-3", "#stacking")] public void RelativeBox_PaintsAfterLaterInFlowSiblings() { var g = Paint("<div style='position:relative;height:20px;background:rgb(255,0,0)'></div>" diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/AbsolutePositioningIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/AbsolutePositioningIntegrationTests.cs index 3315e7389..ce883bf19 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/AbsolutePositioningIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/AbsolutePositioningIntegrationTests.cs @@ -10,6 +10,7 @@ public sealed class AbsolutePositioningIntegrationTests [TestMethod] [Spec("css21", "visuren.html#relative-positioning")] + [Spec("css-position-3", "#relpos-insets")] public void PositionRelative_BottomOffset_MovesBoxOppositeDirection() { var html = LayoutHarness.Wrap("<div id='t' style='position:relative; bottom:10px; width:10px; height:10px;'></div>"); @@ -21,6 +22,7 @@ public void PositionRelative_BottomOffset_MovesBoxOppositeDirection() [TestMethod] [Spec("css21", "visuren.html#relative-positioning")] + [Spec("css-position-3", "#relpos-insets")] public void PositionRelative_Offset_DoesNotAffectParentHeightOrFollowingSibling() { var html = LayoutHarness.Wrap( @@ -72,6 +74,7 @@ public void PositionRelative_OffsetOnBoxItself_DoesNotShiftFollowingSibling() [TestMethod] [Spec("css21", "visuren.html#position-props")] + [Spec("css-position-3", "#insets")] public void PositionAbsolute_BottomOffset_PositionsRelativeToContainingBlockBottomEdge() { var html = LayoutHarness.Wrap( @@ -86,6 +89,7 @@ public void PositionAbsolute_BottomOffset_PositionsRelativeToContainingBlockBott [TestMethod] [Spec("css21", "visuren.html#position-props")] + [Spec("css-position-3", "#insets")] public void PositionAbsolute_WithMarginAndBorderedContainingBlock_AppliesBothCorrectly() { var html = LayoutHarness.Wrap( @@ -101,6 +105,7 @@ public void PositionAbsolute_WithMarginAndBorderedContainingBlock_AppliesBothCor [TestMethod] [Spec("css21", "visuren.html#fixed-positioning")] + [Spec("css-position-3", "#position-property")] public void PositionFixed_WithMargin_AppliesMarginOnTopOfOffset() { var html = LayoutHarness.Wrap( @@ -113,12 +118,9 @@ public void PositionFixed_WithMargin_AppliesMarginOnTopOfOffset() } [TestMethod] + [Spec("css-position-3", "#abspos-auto-size")] public void PositionAbsoluteAutoWidth_ShrinksToWidestChild_NotSumOfSiblingBorders() { - // Three siblings under an absolutely-positioned, auto-width parent: #text (real content, ~short), - // #border1 (80px combined border, no content), #border2 (60px combined border, no content) - the - // correct shrink-to-fit width is #border1's own ~80px (the widest single line), not #border1 + - // #border2's borders summed together (~140px). var html = LayoutHarness.Wrap( "<div id='container' style='position:relative; width:400px;'>" + "<div id='target' style='position:absolute;'>" @@ -131,12 +133,12 @@ public void PositionAbsoluteAutoWidth_ShrinksToWidestChild_NotSumOfSiblingBorder var targetWidth = target.ActualRight - target.Location.X; - // Allow a little headroom above 80 for #text's own (much smaller) content contribution. Assert.IsTrue(targetWidth is >= 79 and <= 100, $"expected shrink-to-fit width near 80px (the widest single sibling), was {targetWidth}"); } [TestMethod] + [Spec("css-position-3", "#abspos-auto-size")] public void PositionAbsoluteAutoWidth_MultipleExplicitWidthSiblings_TakesWidestNotSum() { var html = LayoutHarness.Wrap( @@ -150,17 +152,15 @@ public void PositionAbsoluteAutoWidth_MultipleExplicitWidthSiblings_TakesWidestN var targetWidth = target.ActualRight - target.Location.X; - // The widest single sibling (100px) should win - the buggy summed-across-siblings result would be - // at least 100+90=190px. Assert.IsTrue(targetWidth is >= 99 and <= 105, $"expected shrink-to-fit width near 100px (the widest single sibling), was {targetWidth}"); } [TestMethod] + [Spec("css-position-3", "#abspos-auto-size")] public void PositionAbsoluteAutoWidth_NonReplacedInlineChildsExplicitWidth_HasNoEffect() { - // Per CSS2.1 10.3.3, `width` has no effect on a non-replaced inline-level box - its explicit width - // must not be folded into an ancestor's shrink-to-fit computation. + // 'width' doesn't apply to non-replaced inline boxes (CSS 2.1 §10.3.1) var html = LayoutHarness.Wrap( "<div id='container' style='position:relative; width:400px;'>" + "<div id='target' style='position:absolute;'>" diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/OutOfFlowPositioningIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/OutOfFlowPositioningIntegrationTests.cs index 352329b41..18d9115bd 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/OutOfFlowPositioningIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/OutOfFlowPositioningIntegrationTests.cs @@ -11,6 +11,7 @@ public sealed class OutOfFlowPositioningIntegrationTests [TestMethod] [Spec("css21", "visuren.html#absolute-positioning")] + [Spec("css-position-3", "#position-property")] public void PositionAbsolute_HasNoImpactOnLaterSiblings() { var html = LayoutHarness.Wrap( @@ -42,6 +43,7 @@ public void PositionAbsolute_MarginsDoNotCollapse() [TestMethod] [Spec("css21", "visuren.html#absolute-positioning")] + [Spec("css-position-3", "#def-cb")] public void PositionAbsolute_EstablishesTheContainingBlockForAbsoluteDescendants() { var html = LayoutHarness.Wrap( @@ -55,7 +57,8 @@ public void PositionAbsolute_EstablishesTheContainingBlockForAbsoluteDescendants } [TestMethod] - [Spec("css21", "visuren.html#absolute-positioning")] + [Spec("css21", "visudet.html#containing-block-details")] + [Spec("css-position-3", "#def-cb")] public void PositionAbsolute_WithoutPositionedAncestor_UsesTheInitialContainingBlock() { var html = "<html><head></head><body style='margin:30px'><div style='margin:40px'>" @@ -83,6 +86,7 @@ public void PositionAbsolute_ContentDoesNotFlowAroundOutsideFloats() [TestMethod] [Spec("css21", "visuren.html#position-props")] + [Spec("css-position-3", "#insets")] public void PositionAbsolute_RightOffset_PositionsRelativeToContainingBlockRightEdge() { var html = LayoutHarness.Wrap( @@ -97,6 +101,7 @@ public void PositionAbsolute_RightOffset_PositionsRelativeToContainingBlockRight [TestMethod] [Spec("css21", "visuren.html#position-props")] + [Spec("css-position-3", "#insets")] public void PositionAbsolute_PercentageOffsets_ReferToTheContainingBlock() { var html = LayoutHarness.Wrap( @@ -112,6 +117,7 @@ public void PositionAbsolute_PercentageOffsets_ReferToTheContainingBlock() [TestMethod] [Spec("css21", "visuren.html#fixed-positioning")] + [Spec("css-position-3", "#def-cb")] public void PositionFixed_IgnoresPositionedAncestors() { var html = LayoutHarness.Wrap( @@ -127,6 +133,7 @@ public void PositionFixed_IgnoresPositionedAncestors() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#fixed-positioning")] + [Spec("css-position-3", "#insets")] public void PositionFixed_RightAndBottom_AnchorToTheViewport() { var html = LayoutHarness.Wrap("<div id='t' style='position:fixed;right:10px;bottom:20px;width:30px;height:40px'></div>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/RelativePositioningIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/RelativePositioningIntegrationTests.cs index 3ccb0bbc4..610ef15fc 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/RelativePositioningIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/RelativePositioningIntegrationTests.cs @@ -11,6 +11,7 @@ public sealed class RelativePositioningIntegrationTests [TestMethod] [Spec("css21", "visuren.html#choose-position")] + [Spec("css-position-3", "#position-property")] public void PositionStatic_IgnoresTheBoxOffsets() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -24,6 +25,7 @@ public void PositionStatic_IgnoresTheBoxOffsets() [TestMethod] [Spec("css21", "visuren.html#relative-positioning")] + [Spec("css-position-3", "#relpos-insets")] public void LeftAndTop_MoveTheBoxRightAndDown() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -37,6 +39,7 @@ public void LeftAndTop_MoveTheBoxRightAndDown() [TestMethod] [Spec("css21", "visuren.html#relative-positioning")] + [Spec("css-position-3", "#relpos-insets")] public void RightAlone_MovesTheBoxLeft() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -47,6 +50,7 @@ public void RightAlone_MovesTheBoxLeft() [TestMethod] [Spec("css21", "visuren.html#relative-positioning")] + [Spec("css-position-3", "#relpos-insets")] public void LeftAndRight_LeftWinsInLtr() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -58,6 +62,7 @@ public void LeftAndRight_LeftWinsInLtr() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#relative-positioning")] + [Spec("css-position-3", "#relpos-insets")] public void LeftAndRight_RightWinsInRtl() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -68,6 +73,7 @@ public void LeftAndRight_RightWinsInRtl() [TestMethod] [Spec("css21", "visuren.html#relative-positioning")] + [Spec("css-position-3", "#relpos-insets")] public void TopAndBottom_TopWins() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -78,6 +84,7 @@ public void TopAndBottom_TopWins() [TestMethod] [Spec("css21", "visuren.html#relative-positioning")] + [Spec("css-position-3", "#relpos-insets")] public void RelativeOffset_KeepsTheBoxSize() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -88,6 +95,7 @@ public void RelativeOffset_KeepsTheBoxSize() [TestMethod] [Spec("css21", "visuren.html#position-props")] + [Spec("css-position-3", "#insets")] public void NegativeOffsets_AreAllowed() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -102,6 +110,7 @@ public void NegativeOffsets_AreAllowed() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#position-props")] + [Spec("css-position-3", "#insets")] public void PercentageLeft_RefersToTheContainingBlockWidth() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -113,6 +122,7 @@ public void PercentageLeft_RefersToTheContainingBlockWidth() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#position-props")] + [Spec("css-position-3", "#insets")] public void PercentageTop_RefersToTheContainingBlockHeight() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -124,6 +134,7 @@ public void PercentageTop_RefersToTheContainingBlockHeight() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#relative-positioning")] + [Spec("css-position-3", "#relpos-insets")] public void RelativeInline_MovesItsText() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.Test/Css/WptCssPositionInsetLogicalParsingTests.cs b/Source/Test/HtmlRenderer.Test/Css/WptCssPositionInsetLogicalParsingTests.cs index 9171deb66..60013cf2d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/WptCssPositionInsetLogicalParsingTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/WptCssPositionInsetLogicalParsingTests.cs @@ -5,37 +5,8 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/WptCssPositionInsetLogicalParsingTests.cs. -/// Grammar coverage for the logical inset longhands (inset-block-start/inset-block-end/ -/// inset-inline-start/inset-inline-end) and their two shorthands (inset-block/inset-inline), adapted -/// from the web-platform-tests css/css-position/parsing/inset-valid.html and inset-invalid.html -/// fixtures. -/// The Shorthand_* tests (inset-block/inset-inline) are ported as plain passing tests - they resolve to -/// dedicated <c>InsetBlockProperty</c>/<c>InsetInlineProperty</c> shorthand classes registered under -/// their own correct names. -/// One WPT expectation deliberately isn't ported (matching PeachPDF's own choice): WPT expects the -/// unitless zero length "0" to compute to "0px" (a live getComputedStyle() convention); this engine's -/// declaration parser stores a length as declared rather than serializing a canonical computed-style -/// string, so the equivalent assertion here is against "0", not "0px". -/// Newly-discovered gap (beyond the triage brief): the 7 Longhand_Accepts* theories (28 cases across -/// inset-block-start/end and inset-inline-start/end) are ported under [Ignore]. Confirmed by direct -/// probing (ParseStyleSheet(".x { inset-block-start: -10px; }") then inspecting the resulting -/// StyleDeclaration): the value IS parsed and stored correctly (HasValue == true, Value == "-10px"), but -/// under the property name "top", not "inset-block-start" - because -/// <c>Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs:447-450</c> registers these -/// longhands as bare aliases reusing <c>TopProperty</c>/<c>BottomProperty</c>/<c>LeftProperty</c>/ -/// <c>RightProperty</c> directly (<c>() =&gt; new TopProperty()</c>), whose constructor hardcodes -/// <c>Name = PropertyNames.Top</c> regardless of which alias name it was created under. Since -/// <c>StyleDeclaration.GetProperty</c> (Model/StyleDeclaration.cs:209-212) matches purely by -/// <c>Declarations.FirstOrDefault(m =&gt; m.Name.Isi(name))</c>, looking the value back up by its -/// literal declared name ("inset-block-start") never finds it, and <c>GetPropertyValue</c> falls through -/// to string.Empty. PeachPDF avoids this by giving each logical inset longhand its own dedicated -/// property class (e.g. <c>InsetBlockStartProperty</c>, PeachPDF/src/PeachPDF/CSS/Factories/ -/// PropertyFactory.cs:505) with its own correctly-named constructor - that per-longhand class does not -/// exist in this fork. The corresponding Longhand_Rejects* theories are NOT [Ignore]d: they still assert -/// an empty string, which this same lookup gap also produces (for the "wrong" reason - the value is -/// simply unfindable by name, not spec-rejected) - matching this project's established convention for a -/// narrower converter/lookup still happening to satisfy a "was rejected" assertion. +/// The flow-relative inset longhands are stored under the physical property they map to, e.g. inset-block-start as +/// top, so looking one up by its own name finds nothing and the Longhand_Accepts* tests stay ignored. /// </summary> [TestClass] public sealed class WptCssPositionInsetLogicalParsingTests @@ -50,7 +21,8 @@ private static string DeclaredValue(string property, string value) // ─── Longhands: 'auto | <length-percentage>' ────────────────────────────── [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-position-3.yml")] + [Spec("css-position-3", "#insets")] [DataRow("inset-block-start")] [DataRow("inset-block-end")] [DataRow("inset-inline-start")] @@ -61,7 +33,8 @@ public void Longhand_AcceptsZero(string property) } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-position-3.yml")] + [Spec("css-position-3", "#insets")] [DataRow("inset-block-start")] [DataRow("inset-block-end")] [DataRow("inset-inline-start")] @@ -72,7 +45,8 @@ public void Longhand_AcceptsAuto(string property) } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-position-3.yml")] + [Spec("css-position-3", "#insets")] [DataRow("inset-block-start")] [DataRow("inset-block-end")] [DataRow("inset-inline-start")] @@ -83,7 +57,8 @@ public void Longhand_AcceptsPercentage(string property) } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-position-3.yml")] + [Spec("css-position-3", "#insets")] [DataRow("inset-block-start")] [DataRow("inset-block-end")] [DataRow("inset-inline-start")] @@ -94,7 +69,8 @@ public void Longhand_AcceptsRemLength(string property) } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-position-3.yml")] + [Spec("css-position-3", "#insets")] [DataRow("inset-block-start")] [DataRow("inset-block-end")] [DataRow("inset-inline-start")] @@ -105,7 +81,8 @@ public void Longhand_AcceptsNegativeLength(string property) } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-position-3.yml")] + [Spec("css-position-3", "#insets")] [DataRow("inset-block-start")] [DataRow("inset-block-end")] [DataRow("inset-inline-start")] @@ -116,7 +93,8 @@ public void Longhand_AcceptsNegativePercentage(string property) } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-position-3.yml")] + [Spec("css-position-3", "#insets")] [DataRow("inset-block-start")] [DataRow("inset-block-end")] [DataRow("inset-inline-start")] @@ -161,6 +139,7 @@ public void Longhand_RejectsUnitlessNumber(string property) [TestMethod] [DataRow("inset-block")] [DataRow("inset-inline")] + [Spec("css-position-3", "#inset-shorthands")] public void Shorthand_SingleValue_AcceptsAuto(string property) { Assert.AreEqual("auto", DeclaredValue(property, "auto")); @@ -169,6 +148,7 @@ public void Shorthand_SingleValue_AcceptsAuto(string property) [TestMethod] [DataRow("inset-block")] [DataRow("inset-inline")] + [Spec("css-position-3", "#inset-shorthands")] public void Shorthand_SingleValue_AcceptsCalcExpression(string property) { Assert.IsFalse(string.IsNullOrEmpty(DeclaredValue(property, "calc(2em + 3ex)"))); @@ -177,6 +157,7 @@ public void Shorthand_SingleValue_AcceptsCalcExpression(string property) [TestMethod] [DataRow("inset-block")] [DataRow("inset-inline")] + [Spec("css-position-3", "#inset-shorthands")] public void Shorthand_TwoEqualValues_CollapseToOne(string property) { Assert.AreEqual("auto", DeclaredValue(property, "auto auto")); @@ -186,6 +167,7 @@ public void Shorthand_TwoEqualValues_CollapseToOne(string property) [TestMethod] [DataRow("inset-block")] [DataRow("inset-inline")] + [Spec("css-position-3", "#inset-shorthands")] public void Shorthand_TwoUnequalValues_StayTwoValues(string property) { Assert.AreEqual("10% -5px", DeclaredValue(property, "10% -5px")); @@ -194,6 +176,7 @@ public void Shorthand_TwoUnequalValues_StayTwoValues(string property) [TestMethod] [DataRow("inset-block")] [DataRow("inset-inline")] + [Spec("css-position-3", "#inset-shorthands")] public void Shorthand_RejectsAngle(string property) { Assert.AreEqual(string.Empty, DeclaredValue(property, "0deg")); @@ -202,6 +185,7 @@ public void Shorthand_RejectsAngle(string property) [TestMethod] [DataRow("inset-block")] [DataRow("inset-inline")] + [Spec("css-position-3", "#inset-shorthands")] public void Shorthand_RejectsUnitlessNumber(string property) { Assert.AreEqual(string.Empty, DeclaredValue(property, "10")); @@ -210,6 +194,7 @@ public void Shorthand_RejectsUnitlessNumber(string property) [TestMethod] [DataRow("inset-block")] [DataRow("inset-inline")] + [Spec("css-position-3", "#inset-shorthands")] public void Shorthand_RejectsMixedGlobalAndOrdinaryValue(string property) { // A global keyword (inherit/initial/unset/revert) can only appear alone - CSS Cascade 4 §3.1 - @@ -221,7 +206,8 @@ public void Shorthand_RejectsMixedGlobalAndOrdinaryValue(string property) // ─── z-index: calc() folds to a plain rounded integer (css-position/parsing/z-index-positioned-computed.html) ── [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-values-4.yml")] + [Spec("css-values-4", "#calc-type-checking")] [DataRow("calc(3 - 2)", "1")] [DataRow("calc(1.5 + 1.5)", "3")] [DataRow("calc(2 * -3)", "-6")] @@ -233,11 +219,9 @@ public void ZIndex_CalcExpression_FoldsToNearestInteger(string value, string exp } [TestMethod] - [Ignore("not yet spec compliant")] + [Spec("css-values-4", "#calc-type-checking")] public void ZIndex_RejectsLengthCalcExpression() { - // z-index's grammar is 'auto | <integer>' - a calc() is only valid when it type-checks as a - // plain <number> (CSS Values 4 §10.9); a length-category calc() must still be rejected. Assert.AreEqual(string.Empty, DeclaredValue("z-index", "calc(1px + 1px)")); } } diff --git a/Source/Test/HtmlRenderer.Test/Css/WptCssPositionParsingTests.cs b/Source/Test/HtmlRenderer.Test/Css/WptCssPositionParsingTests.cs index 41cb996cc..01d3f99dd 100644 --- a/Source/Test/HtmlRenderer.Test/Css/WptCssPositionParsingTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/WptCssPositionParsingTests.cs @@ -4,20 +4,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/WptCssPositionParsingTests.cs. -/// Grammar coverage for <c>position</c>, the four physical inset longhands (<c>top</c>/<c>right</c>/ -/// <c>bottom</c>/<c>left</c>), and <c>z-index</c>, adapted from the web-platform-tests -/// css/css-position/parsing/ fixtures (position-valid.html, position-invalid.html, top/right/bottom/ -/// left-valid.html, top/right/bottom/left-invalid.html, z-index-valid.html, z-index-invalid.html). -/// WPT's test_valid_value/test_invalid_value assert against a live getComputedStyle(); this engine has -/// no CSSOM computed-style API to match that against, so the equivalent here is whether the declaration -/// round-trips through <see cref="StyleDeclaration.GetPropertyValue"/> (accepted) or is dropped -/// entirely, leaving an empty string (rejected). -/// <c>position</c> (<see cref="PositionProperty"/>), <c>top</c>/<c>right</c>/<c>bottom</c>/<c>left</c> -/// (Coordinate/*.cs), and non-calc() <c>z-index</c> (<see cref="ZIndexProperty"/>) all exist with matching -/// accept/reject behavior. -/// </summary> [TestClass] public sealed class WptCssPositionParsingTests { @@ -36,6 +22,7 @@ private static string DeclaredValue(string property, string value) [DataRow("absolute")] [DataRow("sticky")] [DataRow("fixed")] + [Spec("css-position-3", "#position-property")] public void Position_AcceptsValidKeyword(string value) { Assert.AreEqual(value, DeclaredValue("position", value)); @@ -44,6 +31,7 @@ public void Position_AcceptsValidKeyword(string value) [TestMethod] [DataRow("auto")] [DataRow("static relative")] + [Spec("css-position-3", "#position-property")] public void Position_RejectsInvalidValue(string value) { Assert.AreEqual(string.Empty, DeclaredValue("position", value)); @@ -56,6 +44,7 @@ public void Position_RejectsInvalidValue(string value) [DataRow("right")] [DataRow("bottom")] [DataRow("left")] + [Spec("css-position-3", "#insets")] public void Coordinate_AcceptsAuto(string property) { Assert.AreEqual("auto", DeclaredValue(property, "auto")); @@ -66,6 +55,7 @@ public void Coordinate_AcceptsAuto(string property) [DataRow("right")] [DataRow("bottom")] [DataRow("left")] + [Spec("css-position-3", "#insets")] public void Coordinate_AcceptsNegativeLength(string property) { Assert.AreEqual("-10px", DeclaredValue(property, "-10px")); @@ -76,6 +66,7 @@ public void Coordinate_AcceptsNegativeLength(string property) [DataRow("right")] [DataRow("bottom")] [DataRow("left")] + [Spec("css-position-3", "#insets")] public void Coordinate_AcceptsNegativePercentage(string property) { Assert.AreEqual("-20%", DeclaredValue(property, "-20%")); @@ -86,6 +77,7 @@ public void Coordinate_AcceptsNegativePercentage(string property) [DataRow("right")] [DataRow("bottom")] [DataRow("left")] + [Spec("css-position-3", "#insets")] public void Coordinate_AcceptsCalcExpression(string property) { Assert.IsFalse(string.IsNullOrEmpty(DeclaredValue(property, "calc(2em + 3ex)"))); @@ -96,6 +88,7 @@ public void Coordinate_AcceptsCalcExpression(string property) [DataRow("right")] [DataRow("bottom")] [DataRow("left")] + [Spec("css-position-3", "#insets")] public void Coordinate_RejectsNonLengthKeyword(string property) { Assert.AreEqual(string.Empty, DeclaredValue(property, "min-content")); @@ -106,6 +99,7 @@ public void Coordinate_RejectsNonLengthKeyword(string property) [DataRow("right")] [DataRow("bottom")] [DataRow("left")] + [Spec("css-position-3", "#insets")] public void Coordinate_RejectsUnitlessNumber(string property) { Assert.AreEqual(string.Empty, DeclaredValue(property, "60")); @@ -116,6 +110,7 @@ public void Coordinate_RejectsUnitlessNumber(string property) [DataRow("right")] [DataRow("bottom")] [DataRow("left")] + [Spec("css-position-3", "#insets")] public void Coordinate_RejectsMultipleValues(string property) { Assert.AreEqual(string.Empty, DeclaredValue(property, "10px 20%")); @@ -128,6 +123,7 @@ public void Coordinate_RejectsMultipleValues(string property) [DataRow("-789")] [DataRow("0")] [DataRow("123")] + [Spec("css21", "visuren.html#z-index")] public void ZIndex_AcceptsValidValue(string value) { Assert.AreEqual(value, DeclaredValue("z-index", value)); @@ -138,6 +134,7 @@ public void ZIndex_AcceptsValidValue(string value) [DataRow("10px")] [DataRow("0.5")] [DataRow("auto 123")] + [Spec("css21", "visuren.html#z-index")] public void ZIndex_RejectsInvalidValue(string value) { Assert.AreEqual(string.Empty, DeclaredValue("z-index", value)); diff --git a/Spec/css-position-3.yml b/Spec/css-position-3.yml index df21609a9..23855f678 100644 --- a/Spec/css-position-3.yml +++ b/Spec/css-position-3.yml @@ -5,97 +5,116 @@ snapshot: https://www.w3.org/TR/2025/WD-css-position-3-20251007/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#position-property': title: '2 Choosing A Positioning Scheme: position property' - status: unknown + status: partial + gaps: + - "Absolutely positioned boxes aren't blockified and 'float' isn't computed to none, see CSS 2.1 9.7" '#def-cb': title: 2.1 Containing Blocks of Positioned Boxes - status: unknown + status: partial + gaps: + - An absolutely positioned box is placed against the content edge of its containing block instead of the padding edge '#original-cb': title: 2.1.1 Further Adjustments to the Containing Block - status: unknown + status: unsupported '#stacking': title: 2.2 Painting Order and Stacking Contexts - status: unknown + status: partial + gaps: + - "'z-index' is ignored and positioned boxes form no stacking contexts, see CSS 2.1 9.9.1" + - Relatively positioned boxes paint with the normal flow, see CSS 2.1 E.2 '#coords': title: 3 Positioning Coordinates - status: unknown + status: partial '#insets': title: '3.1 Box Insets: the top, right, bottom, left, inset-block-start, inset-inline-start, inset-block-end, and inset-inline-end properties' - status: unknown + status: partial + gaps: + - Percentage insets of a relatively positioned box resolve against its own size, see CSS 2.1 9.3.2 + - "'right' and 'bottom' of a fixed box are ignored, see CSS 2.1 9.6.1" + - inset-inline-start and inset-inline-end always map to left and right, also in rtl + - The flow-relative longhands are declared as the physical property they map to, looking one up by its own name gives nothing '#inset-shorthands': title: '3.2 Box Insets Shorthands: the inset-block, inset-inline, and inset properties' - status: unknown + status: supported '#relpos-insets': title: 3.3 Relative Positioning - status: unknown + status: partial + gaps: + - Relative positioning of inline boxes is ignored, see CSS 2.1 9.4.3 + - When 'left' and 'right' are both set, 'left' wins even if the containing block is rtl + - A relatively positioned table row doesn't shift its cells '#stickypos-insets': title: 3.4 Sticky positioning - status: unknown + status: unsupported + gaps: + - A sticky box stays at its in-flow position, its insets are ignored '#stickypos-scroll': title: 3.4.1 Scroll Position of Sticky-Positioned Boxes - status: unknown + status: n/a '#abspos-insets': title: 3.5 Absolute (and Fixed) Positioning - status: unknown + status: partial '#resolving-insets': title: 3.5.1 Resolvings Insets for the “Inset-Modified Containing Block” - status: unknown + status: partial + gaps: + - With both insets in an axis auto, the box goes to the containing block's edge instead of its static position, see 3.5.2 '#staticpos-rect': title: 3.5.2 Calculating the Static Position and the “Static-Position Rectangle” - status: unknown + status: unsupported + gaps: + - No static position, auto insets resolve to the containing block's content edge '#abspos-breaking': title: 3.5.3 Fragmenting Absolutely-positioned Elements - status: unknown + status: partial + gaps: + - An absolutely positioned box in a table cell loses one of its lines in paged output '#abspos-layout': title: 4 Absolute Positioning Layout Model - status: unknown + status: partial + gaps: + - Percentages resolve against the parent instead of the containing block, see CSS 2.1 10.1 '#abspos-auto-size': title: 4.1 Automatic Sizes of Absolutely-Positioned Boxes - status: unknown + status: partial + gaps: + - With both insets set, an auto size is its content size instead of the stretch-fit size + - The fit-content width is computed before the content is measured, see CSS 2.1 10.3.7 '#abspos-margins': title: 4.2 Auto Margins of Absolutely-Positioned Boxes - status: unknown + status: partial + gaps: + - Auto margins compute to 0 even with both insets set, the box is never centered '#abspos-alignment': title: 5 Self-Alignment of Absolutely Positioned Boxes - status: unknown + status: partial + gaps: + - "'justify-self' and 'align-self' are ignored" '#abspos-old': title: 6 Old Absolute Positioning Layout Model - status: unknown + status: partial '#abs-non-replaced-width': title: 6.1 The Width of Absolutely-Positioned, Non-Replaced Elements - status: unknown + status: partial + gaps: + - Auto widths and auto margins don't follow the constraint equation, see CSS 2.1 10.3.7 '#abs-replaced-width': title: 6.2 The width of absolute or fixed positioned, replaced elements - status: unknown + status: supported '#abs-non-replaced-height': title: 6.3 The Height Of Absolutely Positioned, Non-Replaced Elements - status: unknown + status: partial + gaps: + - With 'top' and 'bottom' set, an auto height is the content height, see CSS 2.1 10.6.4 '#abs-replaced-height': title: 6.4 The Height Of Absolutely Positioned, Replaced Elements - status: unknown + status: supported '#comparison': title: 7 Informative Comparison of Normal Flow, Floats, and Positioning - status: unknown - '#comp-normal-flow': - title: 7.1 Normal Flow Example - status: unknown - '#comp-relpos': - title: 7.2 Relative Positioning Example - status: unknown - '#comp-floating': - title: 7.3 Floating Example - status: unknown - '#comp-abspos': - title: 7.4 Absolute Positioning Example - status: unknown + status: n/a '#ack': title: 8 Acknowledgments - status: unknown + status: n/a From 685dcb0e8efaa51d6e44085b4ccc9366df5c5e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:11:58 +0100 Subject: [PATCH 067/150] Verify and document CSS Box Sizing Level 3 compliance --- .../HtmlRenderer/Core/Dom/CssBoxProperties.cs | 6 +- .../BoxModel/HeightIntegrationTests.cs | 9 + .../BoxModel/MinMaxHeightIntegrationTests.cs | 1 + .../BoxModel/WidthIntegrationTests.cs | 11 + .../CssModules/CssSizingTests.cs | 199 ++++++++++++++++++ .../Css/BoxSizingPropertyTests.cs | 3 +- .../Css/CoordinatePropertyTests.cs | 57 +++-- Spec/css-sizing-3.yml | 77 ++++--- 8 files changed, 308 insertions(+), 55 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index 3df953e83..63a5401c5 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs @@ -1011,10 +1011,8 @@ public double AvailableWidth } /// <summary> - /// The padding+border this box's own explicit <see cref="Width"/>/<see cref="Height"/> value needs - /// added on top of it to reach the border-box measure - zero for <c>box-sizing: border-box</c> - /// (where the declared value already IS the border-box measure), padding+border for the default - /// <c>content-box</c> (where the declared value is the content measure alone). + /// What has to be added to an explicit <see cref="Width"/> to get the border-box width, the padding and + /// border for <c>content-box</c> and nothing for <c>border-box</c> (CSS Sizing 3 §3.3). /// </summary> public double ActualBoxSizeIncludedWidth { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs index 44f805ee3..af7654bbd 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs @@ -21,6 +21,7 @@ public sealed class HeightIntegrationTests [TestMethod] [Spec("css21", "visudet.html#the-height-property")] + [Spec("css-sizing-3", "#preferred-size-properties")] public void LengthHeight_IsTheContentHeight() { var root = Layout("<div id='c' style='height:50px;padding:10px 0;border-top:5px solid'></div>"); @@ -30,6 +31,7 @@ public void LengthHeight_IsTheContentHeight() [TestMethod] [Spec("css21", "visudet.html#the-height-property")] + [Spec("css-sizing-3", "#behave-auto")] public void PercentageHeight_AgainstAnAutoHeightContainingBlock_IsAuto() { var root = Layout("<div><div id='c' style='height:50%'>x</div></div>"); @@ -40,6 +42,7 @@ public void PercentageHeight_AgainstAnAutoHeightContainingBlock_IsAuto() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#the-height-property")] + [Spec("css-sizing-3", "#percentage-sizing")] public void PercentageHeight_ResolvesAgainstAnExplicitContainingBlockHeight() { var root = Layout("<div style='height:200px'><div id='c' style='height:50%'></div></div>"); @@ -50,6 +53,7 @@ public void PercentageHeight_ResolvesAgainstAnExplicitContainingBlockHeight() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#the-height-property")] + [Spec("css-sizing-3", "#sizing-values")] public void NegativeHeight_IsIgnored() { var root = Layout("<div id='c' style='height:-50px'>x</div>"); @@ -70,6 +74,7 @@ public void HeightOnAnInline_DoesNotApply() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#the-height-property")] + [Spec("css-sizing-3", "#preferred-size-properties")] public void HeightOnAnInlineBlock_IsApplied() { var root = Layout("<div id='p'>a<span id='s' style='display:inline-block;height:100px'>x</span></div>"); @@ -271,6 +276,7 @@ public void Float_AutoHeight_CoversItsFloatingDescendants() [TestMethod] [Spec("css21", "visudet.html#min-max-heights")] + [Spec("css-sizing-3", "#min-size-properties")] public void MinHeight_GrowsAnAutoHeightBlock() { var root = Layout("<div id='c' style='min-height:50px'>x</div>"); @@ -280,6 +286,7 @@ public void MinHeight_GrowsAnAutoHeightBlock() [TestMethod] [Spec("css21", "visudet.html#min-max-heights")] + [Spec("css-sizing-3", "#max-size-properties")] public void MaxHeight_LimitsAnAutoHeightBlock() { var root = Layout("<div id='c' style='max-height:5px'>x<br>y<br>z</div>"); @@ -299,6 +306,7 @@ public void PercentageMinHeight_AgainstAnAutoHeightContainingBlock_IsZero() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#min-max-heights")] + [Spec("css-sizing-3", "#percentage-sizing")] public void PercentageMaxHeight_ResolvesAgainstAnExplicitContainingBlockHeight() { var root = Layout("<div style='height:200px'><div id='c' style='height:150px;max-height:50%'></div></div>"); @@ -309,6 +317,7 @@ public void PercentageMaxHeight_ResolvesAgainstAnExplicitContainingBlockHeight() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#min-max-heights")] + [Spec("css-sizing-3", "#max-size-properties")] public void MaxHeight_ScalesDownAnImageKeepingItsRatio() { var root = TestImages.Layout($"<img id='i' style='max-height:10px' src='{Image}'>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MinMaxHeightIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MinMaxHeightIntegrationTests.cs index a2cd0ed00..cc0701578 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MinMaxHeightIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MinMaxHeightIntegrationTests.cs @@ -10,6 +10,7 @@ public sealed class MinMaxHeightIntegrationTests [TestMethod] [Spec("css21", "visudet.html#min-max-heights")] + [Spec("css-sizing-3", "#min-size-properties")] public void MinHeightWinsOverConflictingMaxHeight() { var html = LayoutHarness.Wrap("<div id='b' style='height:8px; min-height:20px; max-height:5px;'></div>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs index 88a7fac6f..b7f9f7bec 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs @@ -17,6 +17,7 @@ public sealed class WidthIntegrationTests [TestMethod] [Spec("css21", "visudet.html#the-width-property")] + [Spec("css-sizing-3", "#preferred-size-properties")] public void LengthWidth_IsTheContentWidth() { var root = Layout("<div id='c' style='width:100px;padding:0 10px;border:0 solid;border-width:0 5px;height:5px'></div>"); @@ -26,6 +27,7 @@ public void LengthWidth_IsTheContentWidth() [TestMethod] [Spec("css21", "visudet.html#the-width-property")] + [Spec("css-sizing-3", "#percentage-sizing")] public void PercentageWidth_ResolvesAgainstTheContainingBlockWidth() { var root = Layout("<div style='width:200px;padding:0 50px'><div id='c' style='width:50%;padding:0 5px;height:5px'></div></div>"); @@ -38,6 +40,7 @@ public void PercentageWidth_ResolvesAgainstTheContainingBlockWidth() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#the-width-property")] + [Spec("css-sizing-3", "#sizing-values")] public void NegativeWidth_IsIgnored() { var root = Layout("<div style='width:200px'><div id='c' style='width:-50px;height:5px'></div></div>"); @@ -105,6 +108,7 @@ public void EmWidth_IsApplied() [TestMethod] [Spec("css21", "visudet.html#blockwidth")] + [Spec("css-sizing-3", "#stretch-fit-sizing")] public void AutoWidth_FillsTheContainingBlockMinusTheMargins() { var root = Layout("<div style='width:200px'><div id='c' style='margin-left:10px;margin-right:20px;height:5px'></div></div>"); @@ -190,6 +194,7 @@ public void Float_WithAutoMargins_HasZeroMargins() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#float-width")] + [Spec("css-sizing-3", "#intrinsic-sizes")] public void AutoWidthFloat_ShrinksToFitItsContent() { var root = Layout("<div id='f' style='float:left'>xx yy</div><div id='t'>zz</div>"); @@ -294,6 +299,7 @@ public void InlineBlock_WithAWidth_TakesThatWidthOnTheLine() [TestMethod] [Spec("css21", "visudet.html#inlineblock-width")] + [Spec("css-sizing-3", "#intrinsic-sizes")] public void InlineBlock_WithAutoWidth_IsAsWideAsItsContent() { var root = Layout("<div>a<span id='s' style='display:inline-block'>xx</span><span id='t'>y</span></div>"); @@ -306,6 +312,7 @@ public void InlineBlock_WithAutoWidth_IsAsWideAsItsContent() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#inlineblock-width")] + [Spec("css-sizing-3", "#intrinsic-sizes")] public void InlineBlock_TooWideForTheLine_ShrinksToTheAvailableWidthAsOneBox() { var root = Layout("<div style='width:200px'>a <span id='s' style='display:inline-block'>aaaa bbbb cccc dddd eeee ffff gggg hhhh</span></div>"); @@ -326,6 +333,7 @@ public void InlineBlockImage_KeepsItsIntrinsicWidth() [TestMethod] [Spec("css21", "visudet.html#min-max-widths")] + [Spec("css-sizing-3", "#max-size-properties")] public void MaxWidth_ScalesDownAnImageKeepingItsRatio() { var root = TestImages.Layout($"<img id='i' style='max-width:20px' src='{Image}'>"); @@ -337,6 +345,7 @@ public void MaxWidth_ScalesDownAnImageKeepingItsRatio() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#min-max-widths")] + [Spec("css-sizing-3", "#max-size-properties")] public void MaxWidth_LimitsABlock() { var root = Layout("<div style='width:200px'><div id='c' style='max-width:100px;height:5px'></div></div>"); @@ -347,6 +356,7 @@ public void MaxWidth_LimitsABlock() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#min-max-widths")] + [Spec("css-sizing-3", "#min-size-properties")] public void MinWidth_WidensABlock() { var root = Layout("<div style='width:200px'><div id='c' style='width:50px;min-width:100px;height:5px'></div></div>"); @@ -357,6 +367,7 @@ public void MinWidth_WidensABlock() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#min-max-widths")] + [Spec("css-sizing-3", "#min-size-properties")] public void MinWidth_WidensAnImage() { var root = TestImages.Layout($"<img id='i' style='min-width:80px' src='{Image}'>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs new file mode 100644 index 000000000..9f097afa1 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs @@ -0,0 +1,199 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.CssModules; + +[DoNotParallelize] +[TestClass] +public sealed class CssSizingTests +{ + private const double Delta = 0.5; + + [TestMethod] + [Spec("css-sizing-3", "#box-sizing")] + public void ContentBox_SizesTheContentBox() + { + var root = Layout("<div id='b' style='box-sizing:content-box;width:100px;height:50px;padding:10px;border:5px solid'></div>"); + var b = Box(root, "b"); + + Assert.AreEqual(130, b.Size.Width, Delta); + Assert.AreEqual(80, b.Size.Height, Delta); + } + + [TestMethod] + [Spec("css-sizing-3", "#box-sizing")] + public void BorderBox_SizesTheBorderBox() + { + var root = Layout("<div id='b' style='box-sizing:border-box;width:100px;height:50px;padding:10px;border:5px solid'><div id='c' style='height:10px'></div></div>"); + var b = Box(root, "b"); + + Assert.AreEqual(100, b.Size.Width, Delta); + Assert.AreEqual(50, b.Size.Height, Delta); + Assert.AreEqual(70, Box(root, "c").Size.Width, Delta); + } + + [TestMethod] + [Spec("css-sizing-3", "#box-sizing")] + public void BorderBox_PercentageWidth_SizesTheBorderBox() + { + var root = Layout("<div style='width:200px'><div id='b' style='box-sizing:border-box;width:50%;padding:0 10px;border:5px solid;height:10px'></div></div>"); + + Assert.AreEqual(100, Box(root, "b").Size.Width, Delta); + } + + [Ignore("Gap, see Spec/css-sizing-3.yml")] + [TestMethod] + [Spec("css-sizing-3", "#box-sizing")] + public void BorderBox_ContentSizeIsFlooredAtZero() + { + var root = Layout("<div id='b' style='box-sizing:border-box;width:100px;height:10px;padding-left:60px;border-left:60px solid'></div>"); + + Assert.AreEqual(120, Box(root, "b").Size.Width, Delta); + } + + [TestMethod] + [Spec("css-sizing-3", "#box-sizing")] + public void BorderBox_AppliesToMaxHeight() + { + var root = Layout("<div id='b' style='box-sizing:border-box;max-height:50px;padding:10px;border:5px solid'><div style='height:200px'></div></div>"); + + Assert.AreEqual(50, Box(root, "b").Size.Height, Delta); + } + + [Ignore("Gap, see Spec/css-sizing-3.yml")] + [TestMethod] + [DataRow("inline-size:100px", "width")] + [DataRow("block-size:40px", "height")] + [Spec("css-sizing-3", "#preferred-size-properties")] + public void FlowRelativePreferredSize_MapsToThePhysicalSize(string style, string axis) + { + var root = Layout($"<div style='width:300px'><div id='b' style='{style}'><div style='height:10px'></div></div></div>"); + var b = Box(root, "b"); + + Assert.AreEqual(axis == "width" ? 100 : 40, axis == "width" ? b.Size.Width : b.Size.Height, Delta); + } + + [Ignore("Gap, see Spec/css-sizing-3.yml")] + [TestMethod] + [Spec("css-sizing-3", "#min-size-properties")] + public void MinBlockSize_GrowsTheBox() + { + var root = Layout("<div id='b' style='min-block-size:40px'></div>"); + + Assert.AreEqual(40, Box(root, "b").Size.Height, Delta); + } + + [Ignore("Gap, see Spec/css-sizing-3.yml")] + [TestMethod] + [Spec("css-sizing-3", "#max-size-properties")] + public void MaxInlineSize_LimitsTheBox() + { + var root = Layout("<div style='width:300px'><div id='b' style='max-inline-size:100px;height:10px'></div></div>"); + + Assert.AreEqual(100, Box(root, "b").Size.Width, Delta); + } + + [TestMethod] + [Spec("css-sizing-3", "#sizing-values")] + [Spec("css-sizing-3", "#percentage-sizing")] + public void PercentageSizes_ResolveAgainstADefiniteContainingBlock() + { + var root = Layout("<div style='width:300px'><div id='b' style='width:50%;max-width:40%;height:10px'></div></div>"); + + Assert.AreEqual(150, Box(root, "b").Size.Width, Delta); + } + + [Ignore("Gap, see Spec/css-sizing-3.yml")] + [TestMethod] + [Spec("css-sizing-3", "#percentage-sizing")] + public void NestedPercentageHeights_ResolveAgainstTheResolvedPercentage() + { + var root = Layout("<div style='height:200px'><div id='f' style='height:50%'><div id='i' style='height:50%'></div></div></div>"); + + Assert.AreEqual(100, Box(root, "f").Size.Height, Delta); + Assert.AreEqual(50, Box(root, "i").Size.Height, Delta); + } + + [Ignore("Gap, see Spec/css-sizing-3.yml")] + [TestMethod] + [DataRow("min-content")] + [DataRow("max-content")] + [DataRow("fit-content")] + [Spec("css-sizing-3", "#sizing-values")] + public void IntrinsicSizeKeywords_SizeABlockToItsContent(string keyword) + { + var root = Layout($"<div style='width:400px'><div id='b' style='width:{keyword}'><span style='display:inline-block;width:60px;height:10px'></span></div></div>"); + + Assert.AreEqual(60, Box(root, "b").Size.Width, Delta); + } + + [Ignore("Gap, see Spec/css-sizing-3.yml")] + [TestMethod] + [Spec("css-sizing-3", "#sizing-values")] + [Spec("css-sizing-3", "#stretch-fit-sizing")] + public void Stretch_FillsTheContainingBlock() + { + var root = Layout("<div style='width:300px'><span id='b' style='display:inline-block;width:stretch;margin:0 10px;height:10px'></span></div>"); + + Assert.AreEqual(280, Box(root, "b").Size.Width, Delta); + } + + [TestMethod] + [Spec("css-sizing-3", "#behave-auto")] + public void PercentageHeight_AgainstAnIndefiniteHeight_BehavesAsAuto() + { + var root = Layout("<div><div id='b' style='height:50%'><div style='height:30px'></div></div></div>"); + + Assert.AreEqual(30, Box(root, "b").Size.Height, Delta); + } + + [TestMethod] + [Spec("css-sizing-3", "#stretch-fit-sizing")] + public void AutoWidthBlock_IsStretchFit() + { + var root = Layout("<div style='width:300px'><div id='b' style='margin:0 10px 0 20px;padding:0 5px;border:0 solid;border-left-width:3px;height:10px'></div></div>"); + var b = Box(root, "b"); + + Assert.AreEqual(20, b.Location.X, Delta); + Assert.AreEqual(270, b.Size.Width, Delta); + } + + [TestMethod] + [Spec("css-sizing-3", "#intrinsic-sizes")] + [Spec("css-sizing-3", "#intrinsic-contribution")] + public void TableColumns_UseTheMinAndMaxContentOfTheirCells() + { + var root = Layout("<table id='t' style='border-spacing:0'><tr><td id='a' style='padding:0'><span style='display:inline-block;width:80px;height:10px'></span></td>" + + "<td id='b' style='padding:0'><span style='display:inline-block;width:30px;height:10px'></span></td></tr></table>"); + + Assert.AreEqual(80, Box(root, "a").Size.Width, 1); + Assert.AreEqual(30, Box(root, "b").Size.Width, 1); + } + + [TestMethod] + [Spec("css-sizing-3", "#intrinsic-sizes")] + public void NarrowTable_ShrinksColumnsToTheirMinContent() + { + var root = Layout("<table id='t' style='width:10px;border-spacing:0'><tr><td id='a' style='padding:0'>aaaa bbbb cccc</td></tr></table>"); + var a = Box(root, "a"); + var words = LayoutHarness.Descendants(a).SelectMany(d => d.Words).Where(w => w.Text.Trim().Length > 0).ToList(); + + Assert.AreEqual(3, words.Select(w => w.Top).Distinct().Count()); + Assert.AreEqual(words.Max(w => w.Width), a.Size.Width, 1); + } + + [TestMethod] + [Spec("css-sizing-3", "#cyclic-percentage-contribution")] + public void CyclicPercentageWidth_IsAutoForTheContribution_ThenResolves() + { + var root = Layout("<div style='width:400px'><div id='f' style='float:left'><div id='c' style='width:50%'><span style='display:inline-block;width:100px;height:10px'></span></div></div></div>"); + + Assert.AreEqual(100, Box(root, "f").Size.Width, Delta); + Assert.AreEqual(50, Box(root, "c").Size.Width, Delta); + } + + private static CssBox Layout(string body) => LayoutHarness.Layout("<!DOCTYPE html><html><head></head><body style='margin:0'>" + body + "</body></html>", maxWidth: 400).Root; + + private static CssBox Box(CssBox root, string id) => LayoutHarness.FindById(root, id)!; +} diff --git a/Source/Test/HtmlRenderer.Test/Css/BoxSizingPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/BoxSizingPropertyTests.cs index e61df7f57..86347f6a4 100644 --- a/Source/Test/HtmlRenderer.Test/Css/BoxSizingPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/BoxSizingPropertyTests.cs @@ -3,11 +3,11 @@ namespace HtmlRenderer.Test.Css; -/// <summary>Ported from PeachPDF.Tests/CSS/PropertyTests/BoxSizingPropertyTests.cs. Pure CSSOM parse tests.</summary> [TestClass] public sealed class BoxSizingPropertyTests { [TestMethod] + [Spec("css-sizing-3", "#box-sizing")] public void BoxSizingContentBoxLegal() { var snippet = "box-sizing: content-box"; @@ -22,6 +22,7 @@ public void BoxSizingContentBoxLegal() } [TestMethod] + [Spec("css-sizing-3", "#box-sizing")] public void BoxSizingBorderBoxLegal() { var snippet = "box-sizing: border-box"; diff --git a/Source/Test/HtmlRenderer.Test/Css/CoordinatePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/CoordinatePropertyTests.cs index 1d8bca295..797c3b6f6 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CoordinatePropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CoordinatePropertyTests.cs @@ -3,21 +3,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/CoordinateProperty.cs. -/// Only the width/height/auto length-validation cases apply to HTML-Renderer: this fork's -/// CssBoxProperties has no Left/Right/Top/Bottom/MinWidth/MinHeight/MaxHeight CSS properties (only -/// Width, Height and MaxWidth exist - see CssBoxProperties.cs), so all left/top/right/bottom and -/// min-*/max-height source cases were dropped. -/// The old <c>CssParser.ParseCssBlock</c>/<c>CssData.GetCssBlock</c> raw-property-dictionary API this -/// file originally used no longer exists - the CSS engine port replaced it with a real, spec-compliant -/// parser (ported from ExCSS via PeachPDF) whose only declaration-level introspection surface is -/// <see cref="CssParser.ParseInlineStyle"/>, returning an <c>IStyleRule</c> whose <c>Style</c> -/// (a <c>StyleDeclaration</c>) exposes each longhand as a typed, validating property - an invalid value -/// is simply never stored, so <c>GetPropertyValue</c>/the string indexer returns "" rather than throwing -/// or keeping a "has no value" flag. Validity is exercised through that same real pipeline (tokenizer -&gt; -/// grammar -&gt; value converter) that inline "style" attributes and stylesheet rules go through. -/// </summary> [TestClass] public sealed class CoordinatePropertyTests { @@ -29,68 +14,110 @@ private static string GetProperty(string declaration, string propertyName) } [TestMethod] + [Spec("css-sizing-3", "#preferred-size-properties")] public void CssHeightLegalPercentage() { Assert.AreEqual("28%", GetProperty("height: 28%", "height")); } [TestMethod] + [Spec("css-sizing-3", "#preferred-size-properties")] public void CssHeightLegalLengthInEm() { Assert.AreEqual("0.3em", GetProperty("height: 0.3em", "height")); } [TestMethod] + [Spec("css-sizing-3", "#preferred-size-properties")] public void CssHeightLegalLengthInPx() { Assert.AreEqual("144px", GetProperty("height: 144px", "height")); } [TestMethod] + [Spec("css-sizing-3", "#preferred-size-properties")] public void CssHeightLegalAutoUppercase() { Assert.AreEqual("auto", GetProperty("height: AUTO", "height")); } [TestMethod] + [Spec("css-sizing-3", "#preferred-size-properties")] public void CssWidthLegalLengthInCm() { Assert.AreEqual("0.5cm", GetProperty("width: 0.5cm", "width")); } [TestMethod] + [Spec("css-sizing-3", "#preferred-size-properties")] public void CssWidthLegalLengthInMm() { Assert.AreEqual("1.5mm", GetProperty("width: 1.5mm", "width")); } [TestMethod] + [Spec("css-sizing-3", "#preferred-size-properties")] public void CssWidthIllegalLength() { Assert.IsTrue(string.IsNullOrEmpty(GetProperty("width: 1.5 meter", "width"))); } [TestMethod] + [Spec("css-sizing-3", "#preferred-size-properties")] public void CssWidthPercentLegal() { Assert.AreEqual("20.5%", GetProperty("width: 20.5%", "width")); } [TestMethod] + [Spec("css-sizing-3", "#preferred-size-properties")] public void CssWidthLegalLengthInInches() { Assert.AreEqual("3in", GetProperty("width: 3in", "width")); } [TestMethod] + [Spec("css-sizing-3", "#preferred-size-properties")] public void CssHeightAngleIllegal() { Assert.IsTrue(string.IsNullOrEmpty(GetProperty("height: 3deg", "height"))); } [TestMethod] + [Spec("css-sizing-3", "#preferred-size-properties")] public void CssHeightResolutionIllegal() { Assert.IsTrue(string.IsNullOrEmpty(GetProperty("height: 3dpi", "height"))); } + + [TestMethod] + [Spec("css-sizing-3", "#max-size-properties")] + [DataRow("max-width")] + [DataRow("max-height")] + public void MaxSizeNone_IsAccepted(string property) + { + Assert.AreEqual("none", GetProperty($"{property}: none", property)); + } + + [Ignore("Gap, see Spec/css-sizing-3.yml")] + [TestMethod] + [Spec("css-sizing-3", "#min-size-properties")] + [DataRow("min-width")] + [DataRow("min-height")] + public void MinSizeAuto_IsAccepted(string property) + { + Assert.AreEqual("auto", GetProperty($"{property}: auto", property)); + } + + [Ignore("Gap, see Spec/css-sizing-3.yml")] + [TestMethod] + [Spec("css-sizing-3", "#sizing-values")] + [DataRow("width")] + [DataRow("height")] + [DataRow("min-width")] + [DataRow("max-height")] + public void NegativeSize_IsRejected(string property) + { + Assert.IsTrue(string.IsNullOrEmpty(GetProperty($"{property}: -10px", property))); + } } diff --git a/Spec/css-sizing-3.yml b/Spec/css-sizing-3.yml index 7e967fcfc..afc52669c 100644 --- a/Spec/css-sizing-3.yml +++ b/Spec/css-sizing-3.yml @@ -5,76 +5,83 @@ snapshot: https://www.w3.org/TR/2026/WD-css-sizing-3-20260904/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#terms': title: 2 Terminology - status: unknown - '#auto-box-sizes': - title: 2.1 Auto Box Sizes - status: unknown - '#contributions': - title: 2.2 Intrinsic Size Contributions - status: unknown - '#constraints': - title: 2.3 Intrinsic Size Constraints - status: unknown + status: n/a '#specifying-sizes': title: 3 Specifying Box Sizes - status: unknown + status: partial '#sizing-properties': title: 3.1 Sizing Properties - status: unknown + status: partial '#preferred-size-properties': title: '3.1.1 Preferred Size Properties: the width, height, inline-size, and block-size properties' - status: unknown + status: partial + gaps: + - "'inline-size' and 'block-size' are ignored" + - "'height' is ignored on inline-block boxes, see CSS 2.1 10.5" '#min-size-properties': title: '3.1.2 Minimum Size Properties: the min-width, min-height, min-inline-size, and min-block-size properties' - status: unknown + status: partial + gaps: + - "'min-width' is ignored, see CSS 2.1 10.4" + - "'auto' is rejected" + - "'min-inline-size' and 'min-block-size' are ignored" '#max-size-properties': title: '3.1.3 Maximum Size Properties: the max-width, max-height, max-inline-size, and max-block-size properties' - status: unknown + status: partial + gaps: + - "'max-width' only applies to images and 'max-height' doesn't apply to them, see CSS 2.1 10.4 and 10.7" + - "'max-inline-size' and 'max-block-size' are ignored" '#sizing-values': title: '3.2 Sizing Values: the <length-percentage>, auto | none, stretch, min-content, max-content, and fit-content values' - status: unknown + status: partial + gaps: + - stretch, min-content, max-content and fit-content are rejected + - Negative sizes are accepted and applied instead of being rejected '#behave-auto': title: 3.2.1 “Behaving as auto” - status: unknown + status: supported '#the-contain-floats-value': title: 3.2.2 Containing or Excluding Floats - status: unknown + status: n/a '#box-sizing': title: '3.3 Box Edges for Sizing: the box-sizing property' - status: unknown + status: partial + gaps: + - With 'border-box', a padding and border larger than the size don't floor the content box at zero, the box keeps the specified size '#column-sizing': title: '3.4 New Column Sizing Values: the stretch, min-content, max-content, and fit-content values' - status: unknown + status: unsupported '#extrinsic': title: 4 Extrinsic Size Determination - status: unknown + status: partial '#percentage-sizing': title: 4.1 Percentage Sizing - status: unknown + status: partial + gaps: + - Percentage heights and max-heights compute to auto even against a definite height, see CSS 2.1 10.5 '#stretch-fit-sizing': title: '4.2 Stretch-fit Sizing: filling the containing block' - status: unknown + status: partial + gaps: + - "'stretch' is rejected, only an auto width block is stretch-fit sized" '#intrinsic': title: 5 Intrinsic Size Determination - status: unknown + status: partial '#intrinsic-sizes': title: 5.1 Intrinsic Sizes - status: unknown + status: partial + gaps: + - The fit-content width of an auto width float is computed before its content is measured, see CSS 2.1 10.3.5 + - An auto width inline-block wider than the line wraps across lines instead of shrinking, see CSS 2.1 10.3.9 '#intrinsic-contribution': title: 5.2 Intrinsic Contributions - status: unknown + status: supported '#cyclic-percentage-contribution': title: 5.2.1 Intrinsic Contributions of Percentage-Sized Boxes - status: unknown + status: supported '#min-content-zero': title: 5.2.2 Compressible Replaced Elements - status: unknown + status: unsupported From 97c7a483d97b8a73c7104be31003687f44514ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:15:32 +0100 Subject: [PATCH 068/150] Verify and document CSS Overflow Level 3 compliance --- Source/HtmlRenderer/Core/Utils/RenderUtils.cs | 3 +- .../CssModules/CssOverflowTests.cs | 158 ++++++++++++++++++ .../Painting/OverflowClipIntegrationTests.cs | 11 ++ .../Fragmentation/MonolithicContentTests.cs | 3 + Spec/css-overflow-3.yml | 54 +++--- 5 files changed, 200 insertions(+), 29 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssOverflowTests.cs diff --git a/Source/HtmlRenderer/Core/Utils/RenderUtils.cs b/Source/HtmlRenderer/Core/Utils/RenderUtils.cs index 238e336ac..b98307c35 100644 --- a/Source/HtmlRenderer/Core/Utils/RenderUtils.cs +++ b/Source/HtmlRenderer/Core/Utils/RenderUtils.cs @@ -33,7 +33,8 @@ public static bool IsColorVisible(RColor color) /// <summary> /// Pushes a clip to the containing block's content box if any ancestor block has 'overflow: hidden' - /// (CSS 2.1 §11.1.1). + /// (CSS Overflow 3 §3.1). The spec clips to the padding box of the box with 'hidden' itself, and only + /// 'hidden' clips here, not 'scroll' or 'auto'. /// </summary> /// <param name="g">the graphics to clip</param> /// <param name="box">the box that is about to be painted</param> diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssOverflowTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssOverflowTests.cs new file mode 100644 index 000000000..b4ed6c604 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssOverflowTests.cs @@ -0,0 +1,158 @@ +using System.Collections.Generic; +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.CssModules; + +[DoNotParallelize] +[TestClass] +public sealed class CssOverflowTests +{ + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + + [TestMethod] + [Spec("css-overflow-3", "#ink")] + public void InkOverflow_OfAVisibleBox_IsPainted() + { + var (_, container) = PaintHarness.Layout(Doc( + "<div style='width:50px;height:20px'><div style='width:150px;height:150px;background:rgb(255,0,0)'></div></div>")); + var g = PaintHarness.PaintPage(container); + + var rect = g.Log.OfType<RecordingGraphics.DrawRectCall>().Single(r => r.Color == Red); + Assert.AreEqual(150, rect.Width, 0.5); + Assert.AreEqual(150, rect.Height, 0.5); + } + + [TestMethod] + [Spec("css-overflow-3", "#scrollable")] + public void ScrollableOverflow_ExtendsTheDocumentSize() + { + var (_, container) = PaintHarness.Layout(Doc( + "<div style='height:20px'><div style='height:500px'></div></div>" + + "<div style='position:relative'><div style='position:absolute;left:0;top:0;width:700px;height:10px'></div></div>"), 300, 300); + + Assert.IsGreaterThanOrEqualTo(500, container.ActualSize.Height); + Assert.IsGreaterThanOrEqualTo(700, container.ActualSize.Width); + } + + [TestMethod] + [Spec("css-overflow-3", "#scrolling")] + public void ScrollPosition_MovesTheContentWithinTheViewport() + { + var (_, container) = PaintHarness.Layout(Doc( + "<div style='height:200px'></div><div style='height:20px;background:rgb(255,0,0)'></div><div style='height:500px'></div>"), 300, 300); + + container.ScrollOffset = new RPoint(0, -150); + var g = PaintHarness.PaintPage(container); + + Assert.AreEqual(50, g.Log.OfType<RecordingGraphics.DrawRectCall>().Single(r => r.Color == Red).Y, 0.5); + } + + [TestMethod] + [Spec("css-overflow-3", "#overflow-control")] + public void OverflowHidden_ClipsToTheBox() + { + var clip = ClipOfRedBox( + "<div style='overflow:hidden;width:50px;height:60px'><div style='width:150px;height:150px;background:rgb(255,0,0)'></div></div>"); + + Assert.AreEqual(50, clip.Right, 0.5); + Assert.AreEqual(60, clip.Bottom, 0.5); + } + + [Ignore("Gap, see Spec/css-overflow-3.yml")] + [TestMethod] + [DataRow("overflow:clip")] + [DataRow("overflow:hidden hidden")] + [DataRow("overflow-x:hidden;overflow-y:hidden")] + [Spec("css-overflow-3", "#overflow-control")] + public void ClippingValues_ClipTheContent(string style) + { + var clip = ClipOfRedBox( + $"<div style='{style};width:50px;height:60px'><div style='width:150px;height:150px;background:rgb(255,0,0)'></div></div>"); + + Assert.AreEqual(50, clip.Right, 0.5); + Assert.AreEqual(60, clip.Bottom, 0.5); + } + + [Ignore("Gap, see Spec/css-overflow-3.yml")] + [TestMethod] + [Spec("css-overflow-3", "#overflow-control")] + public void OverflowXHidden_ClipsOnlyHorizontallyAsHiddenAndAuto() + { + var clip = ClipOfRedBox( + "<div style='overflow-x:hidden;width:50px;height:60px'><div style='width:150px;height:150px;background:rgb(255,0,0)'></div></div>"); + + Assert.AreEqual(50, clip.Right, 0.5); + Assert.AreEqual(60, clip.Bottom, 0.5); + } + + [Ignore("Gap, see Spec/css-overflow-3.yml")] + [TestMethod] + [Spec("css-overflow-3", "#overflow-control")] + public void OverflowHidden_EstablishesAnIndependentFormattingContext() + { + var (root, _) = LayoutHarness.Layout(Doc( + "<div id='b' style='overflow:hidden'><div style='float:left;width:20px;height:50px'></div>x</div>")); + + Assert.AreEqual(50, LayoutHarness.FindById(root, "b")!.Size.Height, 0.5); + } + + [Ignore("Gap, see Spec/css-overflow-3.yml")] + [TestMethod] + [Spec("css-overflow-3", "#overflow-clip-margin")] + public void OverflowClipMargin_ExpandsTheClip() + { + var clip = ClipOfRedBox( + "<div style='overflow:clip;overflow-clip-margin:10px;width:50px;height:60px'><div style='width:150px;height:150px;background:rgb(255,0,0)'></div></div>"); + + Assert.AreEqual(60, clip.Right, 0.5); + Assert.AreEqual(70, clip.Bottom, 0.5); + } + + [Ignore("Gap, see Spec/css-overflow-3.yml")] + [TestMethod] + [Spec("css-overflow-3", "#text-overflow")] + public void TextOverflowEllipsis_EndsTheClippedLineWithAnEllipsis() + { + var (_, container) = PaintHarness.Layout(Doc( + "<div style='overflow:hidden;white-space:nowrap;text-overflow:ellipsis;width:60px'>aaaa bbbb cccc dddd</div>")); + var g = PaintHarness.PaintPage(container); + + Assert.IsTrue(g.DrawStringCalls.Any(c => c.Text.Contains('…'))); + } + + private static string Doc(string body) => $"<!DOCTYPE html><html><head></head><body style='margin:0'>{body}</body></html>"; + + private static RRect ClipOfRedBox(string body) + { + var (_, container) = PaintHarness.Layout(Doc(body)); + var g = PaintHarness.PaintPage(container); + + return ClipAt(g, e => e is RecordingGraphics.DrawRectCall r && r.Color == Red); + } + + private static RRect ClipAt(RecordingGraphics g, System.Func<object, bool> match) + { + var stack = new List<RRect>(); + foreach (var entry in g.Log) + { + switch (entry) + { + case RecordingGraphics.PushClipCall push: + stack.Add(push.Rect); + break; + case RecordingGraphics.PopClipCall: + stack.RemoveAt(stack.Count - 1); + break; + default: + if (match(entry)) + return stack.Aggregate(RRect.Intersect); + break; + } + } + + Assert.Fail("No matching draw call was recorded"); + return RRect.Empty; + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/OverflowClipIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OverflowClipIntegrationTests.cs index a22fa061f..61365a471 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/OverflowClipIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OverflowClipIntegrationTests.cs @@ -112,6 +112,7 @@ public void RoundedBoxInTableCell_MultipleRadii_LayoutAndPaintDoNotThrow() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visufx.html#overflow")] + [Spec("css-overflow-3", "#overflow-control")] public void OverflowHidden_WithPadding_PaintClipReachesThePaddingBoxEdge() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -131,6 +132,7 @@ public void OverflowHidden_WithPadding_PaintClipReachesThePaddingBoxEdge() [TestMethod] [Spec("css21", "visufx.html#overflow")] + [Spec("css-overflow-3", "#overflow-control")] public void OverflowVisible_DoesNotClipOverflowingContent() { var clip = ClipOfRedBox( @@ -142,6 +144,7 @@ public void OverflowVisible_DoesNotClipOverflowingContent() [TestMethod] [Spec("css21", "visufx.html#overflow")] + [Spec("css-overflow-3", "#overflow-control")] public void OverflowHidden_ClipsOverflowingChildToTheBox() { var clip = ClipOfRedBox( @@ -154,6 +157,7 @@ public void OverflowHidden_ClipsOverflowingChildToTheBox() [TestMethod] [Spec("css21", "visufx.html#overflow")] + [Spec("css-overflow-3", "#overflow-control")] public void OverflowHidden_ClipsOverflowingText() { var (_, container) = PaintHarness.Layout(Doc( @@ -167,6 +171,7 @@ public void OverflowHidden_ClipsOverflowingText() [TestMethod] [Spec("css21", "visufx.html#overflow")] + [Spec("css-overflow-3", "#overflow-control")] public void OverflowHidden_ClipsAbsoluteDescendantWhoseContainingBlockIsTheBox() { var clip = ClipOfRedBox( @@ -179,6 +184,7 @@ public void OverflowHidden_ClipsAbsoluteDescendantWhoseContainingBlockIsTheBox() [TestMethod] [Spec("css21", "visufx.html#overflow")] + [Spec("css-overflow-3", "#overflow-control")] public void OverflowHidden_OnAnInlineElement_DoesNotClip() { var clip = ClipOfRedBox( @@ -192,6 +198,7 @@ public void OverflowHidden_OnAnInlineElement_DoesNotClip() [Spec("css21", "visufx.html#overflow")] [DataRow("scroll")] [DataRow("auto")] + [Spec("css-overflow-3", "#overflow-control")] public void ScrollAndAuto_ClipOverflowingContent(string overflow) { var clip = ClipOfRedBox( @@ -204,6 +211,7 @@ public void ScrollAndAuto_ClipOverflowingContent(string overflow) [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visufx.html#overflow")] + [Spec("css-overflow-3", "#overflow-control")] public void OverflowHidden_DoesNotClipAtAnIntermediateNonClippingBox() { var clip = ClipOfRedBox( @@ -217,6 +225,7 @@ public void OverflowHidden_DoesNotClipAtAnIntermediateNonClippingBox() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visufx.html#overflow")] + [Spec("css-overflow-3", "#overflow-control")] public void OverflowHidden_DoesNotClipAbsoluteDescendantWhoseContainingBlockIsAnAncestor() { var clip = ClipOfRedBox( @@ -230,6 +239,7 @@ public void OverflowHidden_DoesNotClipAbsoluteDescendantWhoseContainingBlockIsAn [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visufx.html#overflow")] + [Spec("css-overflow-3", "#overflow-control")] public void OverflowHidden_DoesNotClipFixedDescendant() { var clip = ClipOfRedBox( @@ -243,6 +253,7 @@ public void OverflowHidden_DoesNotClipFixedDescendant() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visufx.html#overflow")] + [Spec("css-overflow-3", "#overflow-propagation")] public void OverflowOnBody_IsAppliedToTheViewport() { var (_, container) = PaintHarness.Layout( diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs index ecdce9a3e..0979f64f3 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs @@ -56,6 +56,7 @@ public void Overflow_DecidesScrollContainer(string overflow, bool expected) [TestMethod] [DataRow("html")] [DataRow("body")] + [Spec("css-overflow-3", "#overflow-propagation")] public void ViewportPropagationSource_IsNotAScrollContainer(string tag) { var box = BoxOfTag($"{tag} {{ overflow: hidden }}", tag); @@ -69,6 +70,7 @@ public void ViewportPropagationSource_IsNotAScrollContainer(string tag) // value propagates only while the root's own is `visible`. Once the root has declared one it took // the propagation, and the body is a scroll container in its own right. [TestMethod] + [Spec("css-overflow-3", "#overflow-propagation")] public void Body_UnderARootThatAlreadyDeclaredOverflow_IsAScrollContainer() { var box = BoxOfTag("html { overflow: hidden } body { overflow: auto }", "body"); @@ -79,6 +81,7 @@ public void Body_UnderARootThatAlreadyDeclaredOverflow_IsAScrollContainer() // ...and the companion direction, so the test above is not passing merely because `auto` is set. [TestMethod] + [Spec("css-overflow-3", "#overflow-propagation")] public void Body_UnderAVisibleRoot_PropagatesAndIsNotAScrollContainer() { var box = BoxOfTag("html { overflow: visible } body { overflow: auto }", "body"); diff --git a/Spec/css-overflow-3.yml b/Spec/css-overflow-3.yml index bf9b5307c..5b40d57a4 100644 --- a/Spec/css-overflow-3.yml +++ b/Spec/css-overflow-3.yml @@ -5,61 +5,59 @@ snapshot: https://www.w3.org/TR/2025/WD-css-overflow-3-20251007/ sections: '#intro': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown - '#placement': - title: 1.2 Module Interactions - status: unknown + status: n/a '#overflow-concepts': title: 2 Overflow Concepts and Terminology - status: unknown + status: supported '#ink': title: 2.1 Ink Overflow - status: unknown + status: supported '#scrollable': title: 2.2 Scrollable Overflow - status: unknown + status: supported '#scrolling': title: 2.3 Scrolling Overflow - status: unknown + status: n/a '#overflow-properties': title: 3 Scrolling and Clipping Overflow - status: unknown + status: partial '#overflow-control': title: '3.1 Managing Overflow: the overflow-x, overflow-y, and overflow properties' - status: unknown + status: partial + gaps: + - "'hidden' clips at the content edge (widened by 2px on the left) instead of the padding edge, see CSS 2.1 11.1.1" + - "'scroll' and 'auto' don't clip, they behave like 'visible'" + - "'clip' is rejected" + - "'overflow-x' and 'overflow-y' are ignored and 'overflow' only takes a single value" + - Content overflowing a non-clipping box inside a clipping one is clipped at the content edge of the non-clipping box + - Absolutely positioned and fixed descendants are clipped even when their containing block is outside the clipping box + - A box with 'overflow' other than visible doesn't establish an independent formatting context, its floats stick out '#scroll-visibility': title: 3.1.1 Interaction of visibility and overflow - status: unknown + status: n/a '#corner-clipping': title: 3.1.2 Interaction of border-radius and overflow - status: unknown + status: unsupported '#static-media': title: 3.1.3 Overflow in Print and Other Static Media - status: unknown + status: n/a '#overflow-clip-margin': title: '3.2 Expanding Clipping Bounds: the overflow-clip-margin property' - status: unknown + status: unsupported '#overflow-propagation': title: 3.3 Overflow Viewport Propagation - status: unknown + status: partial + gaps: + - "'overflow' on the root or body clips that box instead of being applied to the viewport, only pagination treats it as propagated" '#smooth-scrolling': title: '3.4 Smooth Scrolling: the scroll-behavior Property' - status: unknown + status: n/a '#scrollbar-layout': title: 4 Scrollbars and Layout - status: unknown - '#scrollbar-sizing': - title: 4.1 Scrollbar Contributions to Sizing - status: unknown - '#scrollbar-gutter-property': - title: '4.2 Reserving space for the scrollbar: the scrollbar-gutter property' - status: unknown + status: n/a '#auto-ellipsis': title: 5 Automatic Ellipses - status: unknown + status: unsupported '#text-overflow': title: '5.1 Overflow Ellipsis: the text-overflow property' - status: unknown + status: unsupported From 84bd41039e6a5f557f4e7fb14f66bb1896fe04bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:04:03 +0100 Subject: [PATCH 069/150] Verify and document CSS Fragmentation Level 3 compliance --- Source/HtmlRenderer/Core/Dom/CssBox.cs | 15 +- Source/HtmlRenderer/Core/Dom/CssLineBox.cs | 5 +- .../Core/Fragmentation/BlockFragmentation.cs | 8 +- .../Core/Fragmentation/BreakToken.cs | 2 +- .../Core/Fragmentation/BreakValues.cs | 1 - .../Core/Fragmentation/FragmentEmitter.cs | 22 +- .../Core/Fragmentation/InlineFragmentation.cs | 2 +- .../Core/Fragmentation/MonolithicContent.cs | 16 +- .../Core/Fragmentation/TableHeaderRepeat.cs | 30 +- .../HtmlRenderer/Core/Fragments/Fragment.cs | 2 +- .../BreakPropagationIntegrationTests.cs | 7 +- .../PagedMediaIntegrationTests.cs | 25 ++ .../BoxDecorationBreakIntegrationTests.cs | 48 +++ .../Painting/FragmentPaintIntegrationTests.cs | 2 +- .../HtmlRenderer.Test/Css/PropertyBoxTests.cs | 8 +- .../Css/PropertyBreakTests.cs | 34 +- .../Css/PropertyPaginationTests.cs | 5 + .../Fragmentation/BreakValuesTests.cs | 2 + .../Fragmentation/CssBreakTests.cs | 343 ++++++++++++++++++ .../ForcedBreakTargetIsTheFramesTests.cs | 9 +- .../Fragmentation/MonolithicContentTests.cs | 3 + Spec/css-break-3.yml | 89 +++-- 22 files changed, 573 insertions(+), 105 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/BoxDecorationBreakIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Fragmentation/CssBreakTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index 9a0e8b50b..ea5350e40 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -864,11 +864,8 @@ protected virtual void PerformLayoutImp(RGraphics g) if (_incomingToken != null && ReferenceEquals(_incomingToken.Box, this)) { - // Resuming this box's own interrupted child/content loop, not placing it fresh - // - css-break-3 §2 gives a box one inline position across all its fragments, so - // there is nothing to re-derive here; Location already holds it from the pass - // that placed this box originally. Back out the relative offset, it gets applied - // again below. + // Resuming this box's own child loop, Location still holds where the earlier + // pass placed it. Back out the relative offset, it gets applied again below. top = Location.Y - RelativeOffsetY; } else if (_resumeTopOverride.HasValue) @@ -879,12 +876,8 @@ protected virtual void PerformLayoutImp(RGraphics g) } else if (BlockFragmentation.TryGetForcedBreakTarget(this, prevSibling, baseTopWithoutMargin, out var breakSlot, out var breakTop)) { - // css-break-3 5.2 preserves a box's own top margin at a FORCED break (unlike an - // unforced one, where BlockFragmentation.ResolveBlockTop truncates it to avoid - // paginating through blank space) - breakTop itself is the page's own content - // top (TryGetForcedBreakTarget's own contract, kept a pure boundary value so its - // slot/target stay meaningful on their own), so the margin is added here, once, - // at the point it becomes this box's actual placement. + // The margin after a forced break is kept (css-break-3 §5.2), breakTop is the + // page's content top without it. var breakTopWithMargin = breakTop + MarginTopCollapse(prevSibling); if (CanDeferToLaterPass()) diff --git a/Source/HtmlRenderer/Core/Dom/CssLineBox.cs b/Source/HtmlRenderer/Core/Dom/CssLineBox.cs index f46a6e4c9..08ef2a832 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLineBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLineBox.cs @@ -135,9 +135,8 @@ internal double LineTop } /// <summary> - /// Shifts every word and per-box rectangle on this line down by <paramref name="delta"/> - used - /// by <see cref="Fragmentation.InlineFragmentation"/> to push a line (css-break-3 4.1: a line box - /// is monolithic - the whole of it moves, never just the words that don't fit) to the next page. + /// Shifts every word and per-box rectangle on this line down by <paramref name="delta"/>, a line + /// moves to the next page as a whole (css-break-3 §4.1). /// </summary> internal void ShiftLine(double delta) { diff --git a/Source/HtmlRenderer/Core/Fragmentation/BlockFragmentation.cs b/Source/HtmlRenderer/Core/Fragmentation/BlockFragmentation.cs index 382f44538..cccf5337f 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/BlockFragmentation.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/BlockFragmentation.cs @@ -37,7 +37,7 @@ internal static double ResolveBlockTop(CssBox box, CssBox prevSibling, double ba /// already satisfy, and if so the page slot and top it moves to. /// </summary> /// <remarks> - /// Without a previous sibling the break belongs to an ancestor (css-break-3 §3.1). It isn't + /// Without a previous sibling the break belongs to an ancestor (css-break-3 §3.1.1). It isn't /// propagated, it's dropped, which also keeps a heading at the top of the document from opening a /// blank page. /// </remarks> @@ -67,7 +67,7 @@ internal static bool TryGetForcedBreakTarget(CssBox box, CssBox prevSibling, dou /// <summary> /// Moves <paramref name="child"/> to the top of the next page when it straddles a page boundary but /// must not be broken, because of <c>break-inside: avoid</c> (CSS 2.1 §13.3.3 rule D) or because it's - /// monolithic (css-break-3 §2). A child taller than a page stays where it is, CSS 2.1 §13.3.3 drops + /// monolithic (css-break-3 §4.1). A child taller than a page stays where it is, CSS 2.1 §13.3.3 drops /// rule D when there aren't enough break points. /// </summary> /// <remarks> @@ -114,7 +114,7 @@ internal static void RelocateIfNeeded(RGraphics g, CssBox child) /// </summary> /// <remarks> /// The run is trimmed from its front until it fits on the page together with <paramref name="child"/>, - /// if not even its last member fits nothing moves (css-break-3 §4.3). Pulling a run that can't fit + /// if not even its last member fits nothing moves (css-break-3 §4.4). Pulling a run that can't fit /// makes its later members trigger the same pull again and again. /// </remarks> internal static void EnforceKeepWithNext(RGraphics g, CssBox child) @@ -193,7 +193,7 @@ private static List<CssBox> CollectPrecedingKeepWithNextRun(CssBox box) /// <summary> /// While <paramref name="movedBox"/> is its parent's first in-flow child, the break before it is - /// also the break before the parent (css-break-3 §3.1), so the parent's top moves by the same + /// also the break before the parent (css-break-3 §3.1.1), so the parent's top moves by the same /// <paramref name="delta"/>, and so on up the ancestors. /// </summary> /// <remarks> diff --git a/Source/HtmlRenderer/Core/Fragmentation/BreakToken.cs b/Source/HtmlRenderer/Core/Fragmentation/BreakToken.cs index 57b45e2a4..187a68ad0 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/BreakToken.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/BreakToken.cs @@ -3,7 +3,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation { /// <summary> - /// Where layout stopped in one fragmentainer, so the next pass resumes from there (css-break-3 §4.4). + /// Where layout stopped in one fragmentainer, so the next pass resumes from there (css-break-3 §4.3). /// Only forced breaks (<see cref="BlockFragmentation.TryGetForcedBreakTarget"/>) produce one, every /// other break is corrected within the same pass. /// </summary> diff --git a/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs b/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs index f5a41f719..920b49ac6 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs @@ -5,7 +5,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation /// <summary> /// Classifies <c>break-before</c>/<c>break-after</c>/<c>break-inside</c> values (CSS 2.1 §13.3.1, /// css-break-3 §3). Only pages fragment, so column and region values never force or avoid a break. - /// <c>left</c> and <c>right</c> don't force a break. /// </summary> internal static class BreakValues { diff --git a/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs b/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs index 0d5a3cdf3..38c497e1c 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs @@ -100,10 +100,9 @@ private static void CollectFixedRoots(CssBox box, List<CssBox> into) } /// <summary> - /// Whether <paramref name="box"/> or any descendant has some rectangle (its own decoration - /// rects, a word, or a child's) overlapping <paramref name="band"/> - used both to decide - /// whether a page-slot is content-empty (skip it) and whether a child belongs in this band's - /// fragment at all. + /// Whether <paramref name="box"/> or any descendant has a rectangle (decoration, word or child) + /// overlapping <paramref name="band"/>, i.e. whether the page is content-empty and whether a child + /// gets a fragment on it. /// </summary> private bool HasContentInBand(CssBox box, PageBand band) { @@ -126,12 +125,8 @@ private bool HasContentInBand(CssBox box, PageBand band) foreach (var child in box.Boxes) { - // A fixed box is handled separately when there's a real page grid (see - // CollectFixedRoots/Finish) - it repeats identically on every page rather than - // belonging to whichever band its own (page-relative, not absolute) coordinates would - // otherwise overlap. Without a real page grid (WinForms/WPF continuous-scroll, one - // fragmentainer for the whole document) it stays in the normal walk unchanged - "stays - // put" there is a paint-time scroll-offset suppression, not a repeat-per-page concern. + // With pages a fixed box is added to every page by Finish, its coordinates are + // page-relative. Without pages it stays in the normal walk, paint keeps it in place. if (_container.HasRealPageGrid && child.Position == CssConstants.Fixed) continue; if (HasContentInBand(child, band)) return true; } @@ -178,7 +173,7 @@ private BoxFragment BuildBoxFragment(CssBox box, int fragmentainerIndex, PageBan var words = new List<TextFragment>(); foreach (var word in box.Words) { - // A word is monolithic (css-break-3 4.1) - never sliced, only localized. + // A word is monolithic (css-break-3 §4.1), never sliced. if (Overlaps(word.Rectangle, band)) words.Add(new TextFragment(ToLocal(word.Rectangle, band), word)); } @@ -245,9 +240,8 @@ private static RRect Clip(RRect rect, PageBand band) private static RRect ToLocal(RRect rect, PageBand band) => new RRect(rect.X, rect.Y - band.Origin, rect.Width, rect.Height); /// <summary> - /// A no-op <see cref="SliceGeometry"/> - every edge is treated as a real box edge, since real - /// box-decoration-break slicing (distinguishing a genuine break edge from a real box edge) is - /// deferred until paint needs to draw a spanning box's borders correctly (Stage E2). + /// A <see cref="SliceGeometry"/> that treats every edge as a real box edge, edges at a page break + /// aren't told apart (css-break-3 §5.4). /// </summary> private static SliceGeometry TrivialSlice(RRect rect) => new(rect, rect, HasLeftEdge: true, HasRightEdge: true); } diff --git a/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs b/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs index fe5d71f85..8eea8948c 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs @@ -6,7 +6,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation { /// <summary> - /// Page breaks between the line boxes of a block container (CSS 2.1 §13.3.3, css-break-3 §5.4). Lines + /// Page breaks between the line boxes of a block container (CSS 2.1 §13.3.3, css-break-3 §3.3). Lines /// already laid out by <see cref="CssLayoutEngine.CreateLineBoxes"/> are shifted, a line never straddles /// a page boundary and <c>orphans</c>/<c>widows</c> move the break instead of the lines. /// </summary> diff --git a/Source/HtmlRenderer/Core/Fragmentation/MonolithicContent.cs b/Source/HtmlRenderer/Core/Fragmentation/MonolithicContent.cs index 7e5fe937c..b9a1d319a 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/MonolithicContent.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/MonolithicContent.cs @@ -5,7 +5,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation { /// <summary> - /// Content that can't be broken (css-break-3 §2, CSS 2.1 §13.3.5 for replaced elements). + /// Content that can't be broken (css-break-3 §4.1, CSS 2.1 §13.3.5 for replaced elements). /// </summary> /// <remarks> /// <see cref="IsMonolithic"/> is a property of the content. <see cref="PaginatesItsOwnContent"/> is an @@ -13,7 +13,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation /// </remarks> internal static class MonolithicContent { - /// <summary>Whether §2 forbids breaking inside <paramref name="box"/>.</summary> + /// <summary>Whether <paramref name="box"/> is monolithic.</summary> internal static bool IsMonolithic(CssBox box) => IsReplaced(box) || IsScrollContainer(box); /// <summary> @@ -22,11 +22,8 @@ internal static class MonolithicContent internal static bool IsReplaced(CssBox box) => box is CssBoxImage or CssBoxFrame; /// <summary> - /// Whether <paramref name="box"/> is a scroll container - §2's "elements with <c>overflow</c> - /// other than <c>visible</c> or <c>clip</c>". The root element is excluded (its overflow - /// propagates to the viewport rather than making it a scroll container, CSS Overflow 3 §3.3); the - /// body is excluded only while the root's own overflow is still <c>visible</c>, per the same - /// section's propagation rule. + /// Whether <paramref name="box"/> is a scroll container. Not the root, and not the body while the + /// root's overflow is <c>visible</c>, their overflow goes to the viewport (CSS Overflow 3 §3.3). /// </summary> internal static bool IsScrollContainer(CssBox box) => box.Overflow != CssConstants.Visible && !IsViewportPropagationSource(box); @@ -64,9 +61,8 @@ internal static bool RunsAnEngineOfItsOwn(string display) => internal static bool IsMonolithicForFragmentation(CssBox box) => IsMonolithic(box); /// <summary> - /// Whether content <paramref name="height"/> tall fits in no fragmentainer at all - §2's - /// overflow-rather-than-slice rule. Content with nowhere to fit must not be treated as breakable: - /// moving it only repeats the question on the next fragmentainer. + /// Whether content <paramref name="height"/> tall fits in no fragmentainer at all (css-break-3 + /// §4.4). Moving such content to the next fragmentainer only repeats the question there. /// </summary> internal static bool FitsNoFragmentainer(double height, HtmlContainerInt container) => height >= container.PageSize.Height; diff --git a/Source/HtmlRenderer/Core/Fragmentation/TableHeaderRepeat.cs b/Source/HtmlRenderer/Core/Fragmentation/TableHeaderRepeat.cs index 96444f152..ab6db4e29 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/TableHeaderRepeat.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/TableHeaderRepeat.cs @@ -3,29 +3,21 @@ namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation { /// <summary> - /// Builds the detached row clones <see cref="CssBox.RepeatedHeaderRows"/> holds - css-tables-3 - /// 6.2's repeated &lt;thead&gt; content, one clone per continuation page a table's body spans. + /// Builds the detached row clones <see cref="CssBox.RepeatedHeaderRows"/> holds, the &lt;thead&gt; + /// rows repeated on every page a table continues on (CSS 2.1 §17.2). /// </summary> /// <remarks> - /// Unlike PeachPDF's proxy-based approach (a shared source subtree re-emitted at each page's - /// position purely at the fragment-tree level), this clones real, laid-out <see cref="CssBox"/> - /// instances. That's a deliberate simplification for this port: it makes the repeat visible - /// through both the existing scroll-offset PDF pipeline and the new fragment tree without - /// teaching two different rendering paths about "one source, several positions" - at the cost of - /// only reproducing what a clone can cheaply carry over (a header's own decoration and words; - /// multi-line per-box decoration rectangles inside a header cell are not reproduced, since that - /// needs cloning CssLineBox instances too - an accepted gap for the common single-line-header - /// case this feature targets). + /// A clone carries the header's decoration and words, not its line boxes, so the per-line + /// decoration rectangles of a multi-line header cell aren't repeated. /// </remarks> internal static class TableHeaderRepeat { /// <summary> - /// Clones <paramref name="source"/> (a &lt;thead&gt; row, recursively with its cells and their - /// content) and shifts the clone so the row's rendered top - <paramref name="sourceRenderedTop"/>, - /// the caller's own reference, since a &lt;tr&gt; box's own Location is never assigned by table - /// layout (only its cells' is - see <see cref="Dom.CssLayoutEngineTable"/>'s row loop) - lands - /// at <paramref name="targetTop"/>. The clone is fully detached - not part of any box's - /// <see cref="CssBox.Boxes"/> - so re-running table layout can never mistake it for real content. + /// Clones <paramref name="source"/> (a &lt;thead&gt; row with its cells and their content) and + /// shifts the clone from <paramref name="sourceRenderedTop"/> to <paramref name="targetTop"/>. The + /// caller passes the row's top because table layout only positions the cells, not the + /// &lt;tr&gt; box. The clone isn't in any box's <see cref="CssBox.Boxes"/>, so table layout never + /// sees it. /// </summary> internal static CssBox CloneAndPosition(CssBox source, double sourceRenderedTop, double targetTop) { @@ -51,9 +43,7 @@ private static CssBox CloneSubtree(CssBox source, CssBox newParent) clone.Text = source.Text; clone.ParseToWords(); - // Reuses the source's already-measured word geometry rather than re-measuring - the - // clone's tokenization matches the source's own (same Text, same ParseToWords), so a - // positional pairing is safe here. + // Same text, same words, so the source's measured geometry pairs up by index. var count = clone.Words.Count < source.Words.Count ? clone.Words.Count : source.Words.Count; for (var i = 0; i < count; i++) { diff --git a/Source/HtmlRenderer/Core/Fragments/Fragment.cs b/Source/HtmlRenderer/Core/Fragments/Fragment.cs index f1ce2e98e..15c4892fe 100644 --- a/Source/HtmlRenderer/Core/Fragments/Fragment.cs +++ b/Source/HtmlRenderer/Core/Fragments/Fragment.cs @@ -21,7 +21,7 @@ internal abstract record Fragment(RRect Rect); /// <summary> /// Tells a decoration rectangle whether each of its four physical edges is a real box edge or a - /// fragmentation break, for CSS <c>box-decoration-break</c> (css-break-3 §6.2). Not a <see cref="Fragment"/> + /// fragmentation break, for CSS <c>box-decoration-break</c> (css-break-3 §5.4). Not a <see cref="Fragment"/> /// itself - it's carried by a <see cref="LineFragment"/>. <paramref name="UnbrokenStrip"/> is what a /// <c>slice</c> value resolves against; <paramref name="FragmentRect"/> is what <c>clone</c> resolves against. /// </summary> diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BreakPropagationIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BreakPropagationIntegrationTests.cs index 504069ce1..8e683e9d5 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BreakPropagationIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BreakPropagationIntegrationTests.cs @@ -65,6 +65,7 @@ private static CssBox FindById(CssBox root, string id) [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#forced")] + [Spec("css-break-3", "#break-propagation")] public async Task ForcedBreakBeforeAFirstChild_MovesTheContainer() { var (root, container) = await BuildAsync( @@ -85,6 +86,7 @@ public async Task ForcedBreakBeforeAFirstChild_MovesTheContainer() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#forced")] + [Spec("css-break-3", "#break-propagation")] public async Task ForcedBreakBeforeANestedFirstChild_MovesTheOutermostContainerItBegins() { var (root, container) = await BuildAsync( @@ -101,6 +103,7 @@ public async Task ForcedBreakBeforeANestedFirstChild_MovesTheOutermostContainerI [TestMethod] [Spec("css21", "page.html#forced")] + [Spec("css-break-3", "#forced-breaks")] public async Task ForcedBreakBeforeALaterChild_LeavesTheContainerWhereItIs() { var (root, container) = await BuildAsync( @@ -118,8 +121,8 @@ public async Task ForcedBreakBeforeALaterChild_LeavesTheContainerWhereItIs() Assert.AreEqual(1, SlotOf(container, second)); } - // css-break-3 §3.1: a forced break before the first content of the root makes no blank page. [TestMethod] + [Spec("css-break-3", "#break-propagation")] public async Task ForcedBreakBeforeTheFirstBoxInTheFlow_ManufacturesNoBlankPage() { var (root, container) = await BuildAsync( @@ -139,6 +142,7 @@ public async Task ForcedBreakBeforeTheFirstBoxInTheFlow_ManufacturesNoBlankPage( [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#forced")] + [Spec("css-break-3", "#break-propagation")] public async Task BreakAfterOnALastChild_ForcesTheBreakBeforeTheFollowingSibling() { var (root, container) = await BuildAsync( @@ -157,6 +161,7 @@ public async Task BreakAfterOnALastChild_ForcesTheBreakBeforeTheFollowingSibling [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] + [Spec("css-break-3", "#unforced-breaks")] public async Task BreakInsideAvoidOnAFirstChild_PullsTheRunAcrossTheContainer() { var (root, container) = await BuildAsync( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs index 8504d7652..95a84a42c 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs @@ -98,6 +98,7 @@ public void FarAwayAbsoluteBox_DoesNotProduceBlankPages() [TestMethod] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#page-break-properties")] public void PageBreakBeforeAlways_StartsTheBoxOnANewPage() { var (root, container) = Paginate( @@ -108,6 +109,7 @@ public void PageBreakBeforeAlways_StartsTheBoxOnANewPage() [TestMethod] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#page-break-properties")] public void PageBreakAfterAlways_StartsTheNextBoxOnANewPage() { var (root, container) = Paginate( @@ -119,6 +121,7 @@ public void PageBreakAfterAlways_StartsTheNextBoxOnANewPage() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#break-between")] [DataRow("<div style='height:50px'></div><div id='b' style='page-break-before:left;height:10px'></div>")] [DataRow("<div style='height:50px'></div><div id='b' style='page-break-before:right;height:10px'></div>")] [DataRow("<div style='height:50px;page-break-after:left'></div><div id='b' style='height:10px'></div>")] @@ -132,6 +135,7 @@ public void LeftAndRight_ForceABreak(string body) [TestMethod] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#forced-breaks")] [DataRow("<div style='height:50px;page-break-after:avoid'></div><div id='b' style='page-break-before:always;height:10px'></div>")] [DataRow("<div style='height:50px;page-break-after:always'></div><div id='b' style='page-break-before:avoid;height:10px'></div>")] public void Always_TakesPrecedenceOverAvoid(string body) @@ -143,6 +147,7 @@ public void Always_TakesPrecedenceOverAvoid(string body) [TestMethod] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#break-within")] public void PageBreakInsideAvoid_KeepsTheBoxOnOnePage() { var (root, container) = Paginate( @@ -156,6 +161,7 @@ public void PageBreakInsideAvoid_KeepsTheBoxOnOnePage() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#break-within")] public void PageBreakInsideAvoid_KeepsABoxWithoutTextOnOnePage() { var (root, container) = Paginate( @@ -167,6 +173,7 @@ public void PageBreakInsideAvoid_KeepsABoxWithoutTextOnOnePage() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#break-decoration")] public void ASplitBox_HasNoBorderWhereItIsSplit() { var (_, container) = PaintHarness.LayoutPaginated(PaintHarness.Wrap( @@ -190,6 +197,7 @@ public void ASplitBox_HasNoBorderWhereItIsSplit() [TestMethod] [Spec("css21", "page.html#break-inside")] + [Spec("css-break-3", "#widows-orphans")] public void OrphansAndWidows_DefaultToTwoAndInherit() { var (root, _) = Paginate("<div style='orphans:5;widows:4'><p id='c'>c</p></div><p id='d'>d</p>"); @@ -203,6 +211,7 @@ public void OrphansAndWidows_DefaultToTwoAndInherit() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#break-inside")] + [Spec("css-break-3", "#widows-orphans")] [DataRow("orphans:0;widows:0")] [DataRow("orphans:-3;widows:-3")] public void NonPositiveOrphansAndWidows_AreIgnored(string declaration) @@ -215,6 +224,7 @@ public void NonPositiveOrphansAndWidows_AreIgnored(string declaration) [TestMethod] [Spec("css21", "page.html#break-inside")] + [Spec("css-break-3", "#widows-orphans")] public void Orphans_MovesTheParagraphWhenTooFewLinesFit() { var filler = PageHeight - 2.5 * LineHeight; @@ -225,6 +235,7 @@ public void Orphans_MovesTheParagraphWhenTooFewLinesFit() [TestMethod] [Spec("css21", "page.html#break-inside")] + [Spec("css-break-3", "#widows-orphans")] public void Widows_PullsLinesToTheNextPage() { var filler = PageHeight - 5.5 * LineHeight; @@ -239,6 +250,7 @@ public void Widows_PullsLinesToTheNextPage() [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] + [Spec("css-break-3", "#break-margins")] public void UnforcedBreakInTheMargin_SetsTheMarginToZero() { var (root, container) = Paginate( @@ -251,6 +263,7 @@ public void UnforcedBreakInTheMargin_SetsTheMarginToZero() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#allowed-page-breaks")] + [Spec("css-break-3", "#possible-breaks")] public void ABlockWhoseLinesAllMoveToTheNextPage_MovesWithThem() { var filler = PageHeight - 1.5 * LineHeight; @@ -262,6 +275,7 @@ public void ABlockWhoseLinesAllMoveToTheNextPage_MovesWithThem() [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] + [Spec("css-break-3", "#possible-breaks")] public void Breaks_FallBetweenLineBoxes() { var (root, container) = Paginate("<div style='height:5px'></div>" + Paragraph("p", 40)); @@ -274,6 +288,7 @@ public void Breaks_FallBetweenLineBoxes() [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] + [Spec("css-break-3", "#unforced-breaks")] [DataRow("page-break-after:avoid", "")] [DataRow("", "page-break-before:avoid")] public void AvoidBetweenSiblings_MovesTheFirstAlongWithTheSecond(string headStyle, string nextStyle) @@ -290,6 +305,7 @@ public void AvoidBetweenSiblings_MovesTheFirstAlongWithTheSecond(string headStyl [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#allowed-page-breaks")] + [Spec("css-break-3", "#unforced-breaks")] public void AvoidBetweenSiblings_AlsoMovesABlockWithoutText() { var (root, container) = Paginate( @@ -302,6 +318,7 @@ public void AvoidBetweenSiblings_AlsoMovesABlockWithoutText() [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] + [Spec("css-break-3", "#unforced-breaks")] public void PageBreakInsideAvoidOnAnAncestor_ForbidsBreaksBetweenItsChildren() { var (root, container) = Paginate( @@ -315,6 +332,7 @@ public void PageBreakInsideAvoidOnAnAncestor_ForbidsBreaksBetweenItsChildren() [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] + [Spec("css-break-3", "#unforced-breaks")] public void AvoidBoxTallerThanAPage_IsStillBroken() { var (root, container) = Paginate("<div style='height:100px'></div>" + Paragraph("p", 25, "page-break-inside:avoid")); @@ -326,6 +344,7 @@ public void AvoidBoxTallerThanAPage_IsStillBroken() [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] + [Spec("css-break-3", "#unforced-breaks")] public void OrphansAndWidowsThatCannotBeSatisfied_AreDropped() { // Fewer than 20 lines fit on a page, so no split of 25 lines satisfies both. @@ -339,6 +358,7 @@ public void OrphansAndWidowsThatCannotBeSatisfied_AreDropped() [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] + [Spec("css-break-3", "#break-margins")] public void ForcedBreak_RetainsTheMarginTop() { var (root, container) = Paginate( @@ -350,6 +370,7 @@ public void ForcedBreak_RetainsTheMarginTop() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#allowed-page-breaks")] + [Spec("css-break-3", "#break-margins")] public void ForcedBreak_SetsTheMarginBottomToZero() { var (root, container) = Paginate( @@ -364,6 +385,7 @@ public void ForcedBreak_SetsTheMarginBottomToZero() [TestMethod] [Spec("css21", "page.html#forced")] + [Spec("css-break-3", "#forced-breaks")] public void ForcedBreak_OccursEvenWhereTheContentWouldFit() { var (root, container) = Paginate( @@ -380,6 +402,7 @@ public void ForcedBreak_OccursEvenWhereTheContentWouldFit() [TestMethod] [Spec("css21", "page.html#best-page-breaks")] + [Spec("css-break-3", "#possible-breaks")] public void ReplacedElement_IsNotBroken() { var (root, container) = Paginate( @@ -392,6 +415,7 @@ public void ReplacedElement_IsNotBroken() [TestMethod] [Spec("css21", "page.html#best-page-breaks")] + [Spec("css-break-3", "#best-breaks")] [DataRow(20, new[] { 20, 0 })] [DataRow(21, new[] { 19, 2 })] [DataRow(22, new[] { 20, 2 })] @@ -406,6 +430,7 @@ public void Example_Orphans4Widows2With20LinesAvailable(int lines, int[] expecte [TestMethod] [Spec("css21", "page.html#best-page-breaks")] + [Spec("css-break-3", "#best-breaks")] [DataRow(8, new[] { 8, 0, 0 })] [DataRow(9, new[] { 0, 9, 0 })] [DataRow(30, new[] { 0, 10, 20 })] diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BoxDecorationBreakIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BoxDecorationBreakIntegrationTests.cs new file mode 100644 index 000000000..95768ad6e --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BoxDecorationBreakIntegrationTests.cs @@ -0,0 +1,48 @@ +using System; +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[TestClass] +[DoNotParallelize] +public sealed class BoxDecorationBreakIntegrationTests +{ + private static readonly RColor BorderColor = RColor.FromArgb(255, 1, 2, 3); + + private static int VerticalEdges(RecordingGraphics recording) => + recording.Log.OfType<RecordingGraphics.DrawLineCall>() + .Count(l => l.Color == BorderColor && Math.Abs(l.X1 - l.X2) < 0.01); + + private static int HorizontalEdges(RecordingGraphics recording) => + recording.Log.OfType<RecordingGraphics.DrawLineCall>() + .Count(l => l.Color == BorderColor && Math.Abs(l.Y1 - l.Y2) < 0.01); + + [TestMethod] + [Spec("css-break-3", "#break-decoration")] + public void Slice_InlineBrokenAcrossLines_HasNoBorderAtTheBrokenEdges() + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( + "<div><span style='border:3px solid rgb(1,2,3)'>first<br>second<br>third</span></div>")); + + var recording = PaintHarness.PaintPage(container, 0); + + Assert.AreEqual(2, VerticalEdges(recording)); + Assert.AreEqual(6, HorizontalEdges(recording)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-break-3.yml")] + [Spec("css-break-3", "#break-decoration")] + public void Clone_InlineBrokenAcrossLines_HasBordersOnEveryFragment() + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( + "<div><span style='border:3px solid rgb(1,2,3);box-decoration-break:clone'>first<br>second<br>third</span></div>")); + + var recording = PaintHarness.PaintPage(container, 0); + + Assert.AreEqual(6, VerticalEdges(recording)); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs index 0930f19c8..a4b92bcc5 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs @@ -19,6 +19,7 @@ public sealed class FragmentPaintIntegrationTests { [TestMethod] [Spec("css21", "page.html#forced")] + [Spec("css-break-3", "#fragmentation-model")] public void EachPage_PaintsOnlyItsOwnFragmentainersContent() { var (_, container) = PaintHarness.LayoutPaginated(PaintHarness.Wrap( @@ -78,7 +79,6 @@ public void BoxSpanningTwoPages_PaintsItsBackgroundOnBoth() { var recording = PaintHarness.PaintPage(container, page); - // box-decoration-break: slice, each fragment paints the whole background and the page clip cuts it. Assert.IsTrue(recording.Log.OfType<RecordingGraphics.DrawRectCall>() .Any(r => r.Color == RColorOf(10, 20, 30))); } diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyBoxTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyBoxTests.cs index 7f2ef00e7..fb7070dda 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PropertyBoxTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PropertyBoxTests.cs @@ -445,6 +445,7 @@ public void CssCursorUrlsLegal() } [TestMethod] + [Spec("css-break-3", "#break-decoration")] public void CssBoxDecorationBreakNumberIllegal() { var snippet = "box-decoration-break : 1.5 "; @@ -458,6 +459,7 @@ public void CssBoxDecorationBreakNumberIllegal() } [TestMethod] + [Spec("css-break-3", "#break-decoration")] public void CssBoxDecorationBreakSliceLegal() { var snippet = "box-decoration-break : slice "; @@ -473,6 +475,7 @@ public void CssBoxDecorationBreakSliceLegal() } [TestMethod] + [Spec("css-break-3", "#break-decoration")] public void CssBoxDecorationBreakClonePascalLegal() { var snippet = "box-decoration-break : Clone "; @@ -488,6 +491,7 @@ public void CssBoxDecorationBreakClonePascalLegal() } [TestMethod] + [Spec("css-break-3", "#break-decoration")] public void CssBoxDecorationBreakInheritLegal() { var snippet = "box-decoration-break : inherit!important "; @@ -502,12 +506,13 @@ public void CssBoxDecorationBreakInheritLegal() Assert.AreEqual("inherit", concrete.Original); } - // The rest of the CSS-wide keywords on box-decoration-break (css-break-3 §6.2). + // The rest of the CSS-wide keywords on box-decoration-break. [TestMethod] [DataRow("initial")] [DataRow("unset")] [DataRow("revert")] [DataRow("revert-layer")] + [Spec("css-break-3", "#break-decoration")] public void CssBoxDecorationBreakGlobalKeywordLegal(string keyword) { var property = ParseDeclaration($"box-decoration-break:{keyword}"); @@ -524,6 +529,7 @@ public void CssBoxDecorationBreakGlobalKeywordLegal(string keyword) [DataRow("none")] [DataRow("both")] [DataRow("slice clone")] + [Spec("css-break-3", "#break-decoration")] public void CssBoxDecorationBreakRejectsNonSpecValue(string value) { var property = ParseDeclaration($"box-decoration-break:{value}"); diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs index f4e33a728..025a682df 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs @@ -13,6 +13,7 @@ namespace HtmlRenderer.Test.Css; public sealed class PropertyBreakTests { [TestMethod] + [Spec("css-break-3", "#break-between")] public void CssBreakAfterLegalAvoid() { var snippet = "break-after:avoid"; @@ -29,6 +30,7 @@ public void CssBreakAfterLegalAvoid() [TestMethod] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#page-break-properties")] public void CssPageBreakAfterLegalAvoid() { var snippet = "page-break-after:avoid"; @@ -44,6 +46,7 @@ public void CssPageBreakAfterLegalAvoid() } [TestMethod] + [Spec("css-break-3", "#break-between")] public void CssBreakAfterLegalPageCapital() { var snippet = "break-after:Page"; @@ -60,6 +63,7 @@ public void CssBreakAfterLegalPageCapital() [TestMethod] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#page-break-properties")] public void CssPageBreakAfterIllegalAvoidColumn() { var snippet = "page-break-after:avoid-column"; @@ -73,6 +77,7 @@ public void CssPageBreakAfterIllegalAvoidColumn() } [TestMethod] + [Spec("css-break-3", "#break-between")] public void CssBreakAfterLegalAvoidColumn() { var snippet = "break-after:avoid-column"; @@ -88,6 +93,7 @@ public void CssBreakAfterLegalAvoidColumn() } [TestMethod] + [Spec("css-break-3", "#break-between")] public void CssBreakBeforeLegalAuto() { var snippet = "break-before:AUTO"; @@ -104,6 +110,7 @@ public void CssBreakBeforeLegalAuto() [TestMethod] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#page-break-properties")] public void CssPageBreakBeforeLegalAuto() { var snippet = "page-break-before:AUTO"; @@ -120,6 +127,7 @@ public void CssPageBreakBeforeLegalAuto() [TestMethod] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#page-break-properties")] public void CssPageBreakBeforeLegalLeft() { var snippet = "page-break-before:left"; @@ -135,6 +143,7 @@ public void CssPageBreakBeforeLegalLeft() } [TestMethod] + [Spec("css-break-3", "#break-between")] public void CssBreakBeforeIllegalValue() { var snippet = "break-before:whatever"; @@ -148,6 +157,7 @@ public void CssBreakBeforeIllegalValue() } [TestMethod] + [Spec("css-break-3", "#break-within")] public void CssBreakInsideIllegalPage() { var snippet = "break-inside:page"; @@ -161,6 +171,7 @@ public void CssBreakInsideIllegalPage() } [TestMethod] + [Spec("css-break-3", "#break-within")] public void CssBreakInsideLegalAvoidRegionUppercase() { var snippet = "break-inside:avoid-REGION"; @@ -177,6 +188,7 @@ public void CssBreakInsideLegalAvoidRegionUppercase() [TestMethod] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#page-break-properties")] public void CssPageBreakInsideLegalAvoid() { var snippet = "page-break-inside:avoid"; @@ -193,6 +205,7 @@ public void CssPageBreakInsideLegalAvoid() [TestMethod] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#page-break-properties")] public void CssPageBreakInsideLegalAutoUppercase() { var snippet = "page-break-inside:AUTO"; @@ -210,6 +223,7 @@ public void CssPageBreakInsideLegalAutoUppercase() [TestMethod] [DataRow("break-before")] [DataRow("break-after")] + [Spec("css-break-3", "#break-between")] public void BreakBeforeAfter_AcceptsSupportedSpecValues(string name) { string[] supportedValues = @@ -225,8 +239,8 @@ public void BreakBeforeAfter_AcceptsSupportedSpecValues(string name) } } - // css-break-3 values the engine doesn't implement. [TestMethod] + [Ignore("Gap, see Spec/css-break-3.yml")] [DataRow("break-before", "recto")] [DataRow("break-before", "verso")] [DataRow("break-before", "avoid-region")] @@ -235,15 +249,18 @@ public void BreakBeforeAfter_AcceptsSupportedSpecValues(string name) [DataRow("break-after", "verso")] [DataRow("break-after", "avoid-region")] [DataRow("break-after", "region")] - public void BreakBeforeAfter_RejectsUnimplementedSpecValues(string name, string value) + [Spec("css-break-3", "#break-between")] + public void BreakBeforeAfter_AcceptsRectoVersoAndRegionValues(string name, string value) { var property = ParseDeclaration($"{name}:{value}"); - Assert.IsFalse(property.HasValue, $"{name}:{value} is not implemented by Map.BreakModes and should be rejected"); + Assert.IsTrue(property.HasValue); + Assert.AreEqual(value, property.Value); } [TestMethod] [DataRow("break-before")] [DataRow("break-after")] + [Spec("css-break-3", "#break-between")] public void BreakBeforeAfter_IsCaseInsensitiveAndKeepsAuthoredText(string name) { var property = ParseDeclaration($"{name}:AvOiD-PaGe"); @@ -256,17 +273,18 @@ public void BreakBeforeAfter_IsCaseInsensitiveAndKeepsAuthoredText(string name) [DataRow("break-before", "all")] [DataRow("break-after", "all")] [DataRow("break-before", "avoid-nothing")] + [Spec("css-break-3", "#break-between")] public void BreakBeforeAfter_RejectsNonSpecValue(string name, string value) { var property = ParseDeclaration($"{name}:{value}"); Assert.IsFalse(property.HasValue); } - // css-break-3 only has "always" on the page-break-* aliases, the engine also takes it here. + // Not a css-break-3 value, css-break-4 adds it. [TestMethod] [DataRow("break-before")] [DataRow("break-after")] - public void BreakBeforeAfter_AcceptsAlwaysUnlikePeachPdf(string name) + public void BreakBeforeAfter_AcceptsAlways(string name) { var property = ParseDeclaration($"{name}:always"); Assert.IsTrue(property.HasValue); @@ -279,6 +297,7 @@ public void BreakBeforeAfter_AcceptsAlwaysUnlikePeachPdf(string name) [DataRow("avoid-page")] [DataRow("avoid-column")] [DataRow("avoid-region")] + [Spec("css-break-3", "#break-within")] public void BreakInside_AcceptsSpecValue(string value) { var property = ParseDeclaration($"break-inside:{value}"); @@ -293,6 +312,7 @@ public void BreakInside_AcceptsSpecValue(string value) [DataRow("left")] [DataRow("recto")] [DataRow("always")] + [Spec("css-break-3", "#break-within")] public void BreakInside_RejectsNonSpecValue(string value) { var property = ParseDeclaration($"break-inside:{value}"); @@ -311,6 +331,7 @@ public void BreakInside_RejectsNonSpecValue(string value) [DataRow("page-break-after", "left")] [DataRow("page-break-after", "right")] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#page-break-properties")] public void PageBreakBeforeAfter_AcceptsLegacyValue(string name, string value) { var property = ParseDeclaration($"{name}:{value}"); @@ -328,6 +349,7 @@ public void PageBreakBeforeAfter_AcceptsLegacyValue(string name, string value) [DataRow("page-break-after", "avoid-page")] [DataRow("page-break-after", "verso")] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#page-break-properties")] public void PageBreakBeforeAfter_RejectsModernOnlyValue(string name, string value) { var property = ParseDeclaration($"{name}:{value}"); @@ -338,6 +360,7 @@ public void PageBreakBeforeAfter_RejectsModernOnlyValue(string name, string valu [DataRow("auto")] [DataRow("avoid")] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#page-break-properties")] public void PageBreakInside_AcceptsLegacyValue(string value) { var property = ParseDeclaration($"page-break-inside:{value}"); @@ -351,6 +374,7 @@ public void PageBreakInside_AcceptsLegacyValue(string value) [DataRow("avoid-region")] [DataRow("page")] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#page-break-properties")] public void PageBreakInside_RejectsNonLegacyValue(string value) { var property = ParseDeclaration($"page-break-inside:{value}"); diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyPaginationTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyPaginationTests.cs index 56a1a21e3..78c7f982f 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PropertyPaginationTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PropertyPaginationTests.cs @@ -25,6 +25,7 @@ public void CssOrphansZeroLegal() [TestMethod] [Spec("css21", "page.html#break-inside")] + [Spec("css-break-3", "#widows-orphans")] public void CssOrphansTwoLegal() { var snippet = "orphans : 2 "; @@ -41,6 +42,7 @@ public void CssOrphansTwoLegal() [TestMethod] [Spec("css21", "page.html#break-inside")] + [Spec("css-break-3", "#widows-orphans")] public void CssOrphansNegativeIllegal() { var snippet = "orphans : -2 "; @@ -55,6 +57,7 @@ public void CssOrphansNegativeIllegal() [TestMethod] [Spec("css21", "page.html#break-inside")] + [Spec("css-break-3", "#widows-orphans")] public void CssOrphansFloatingIllegal() { var snippet = "orphans : 1.5 "; @@ -84,6 +87,7 @@ public void CssWidowsZeroLegal() [TestMethod] [Spec("css21", "page.html#break-inside")] + [Spec("css-break-3", "#widows-orphans")] public void CssWidowsThreeLegal() { var snippet = "widows: 3"; @@ -100,6 +104,7 @@ public void CssWidowsThreeLegal() [TestMethod] [Spec("css21", "page.html#break-inside")] + [Spec("css-break-3", "#widows-orphans")] public void CssWidowsLengthIllegal() { var snippet = "widows: 5px"; diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/BreakValuesTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/BreakValuesTests.cs index 147d0e314..b63b798c1 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/BreakValuesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/BreakValuesTests.cs @@ -13,11 +13,13 @@ public sealed class BreakValuesTests [DataRow("avoid-page", false)] [DataRow(null, false)] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#break-between")] public void IsForcedBreak_MatchesThisPortsReducedValueSet(string value, bool expected) => Assert.AreEqual(expected, BreakValues.IsForcedBreak(value)); [TestMethod] [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#break-between")] [DataRow("avoid", true)] [DataRow("avoid-page", true)] [DataRow("auto", false)] diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/CssBreakTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/CssBreakTests.cs new file mode 100644 index 000000000..2ca1c150a --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/CssBreakTests.cs @@ -0,0 +1,343 @@ +using System.Globalization; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.Fragmentation; + +/// <summary> +/// CSS Fragmentation 3 behavior that the CSS 2.1 paged media tests don't already cover. +/// </summary> +[TestClass] +public sealed class CssBreakTests +{ + private const double Delta = 0.5; + private const double PageHeight = 300; + + private static (CssBox Root, HtmlContainerInt Container) Paginate(string body, string css = "") => + LayoutHarness.Layout( + $"<html><head><style>{css}</style></head><body style='margin:0'>{body}</body></html>", + 400, pageHeight: PageHeight, margin: 0); + + private static CssBox Find(CssBox root, string id) => LayoutHarness.FindById(root, id)!; + + private static int PageOf(HtmlContainerInt container, CssBox box) => container.PageIndexOf(box.Location.Y); + + private static string Px(double value) => value.ToString("0.###", CultureInfo.InvariantCulture) + "px"; + + private static string Paragraph(string id, int lines, string style = "") => + $"<div id='{id}' style='{style}'>" + string.Join("<br>", Enumerable.Range(1, lines).Select(i => $"L{i}")) + "</div>"; + + private static readonly Lazy<double> LineHeightValue = new(() => + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(Paragraph("p", 1))); + return Find(root, "p").Size.Height; + }); + + private static double LineHeight => LineHeightValue.Value; + + [TestMethod] + [Spec("css-break-3", "#break-between")] + public void ColumnBreak_DoesNotBreakThePage() + { + var (root, container) = Paginate("<div style='height:50px'></div><div id='b' style='break-before:column;height:10px'></div>"); + + Assert.AreEqual(50, Find(root, "b").Location.Y, Delta); + Assert.AreEqual(1, container.FragmentTree!.Fragmentainers.Count); + } + + [TestMethod] + [Spec("css-break-3", "#break-between")] + [DataRow("avoid", true)] + [DataRow("avoid-page", true)] + [DataRow("avoid-column", false)] + public void AvoidValues_KeepTheBoxWithTheNextOnlyForPageBreaks(string value, bool kept) + { + var (root, container) = Paginate( + "<div style='height:200px'></div>" + + $"<div id='h' style='break-after:{value}'>Head</div>" + + Paragraph("p", 8, "break-inside:avoid")); + + Assert.AreEqual(1, PageOf(container, Find(root, "p"))); + Assert.AreEqual(kept ? 1 : 0, PageOf(container, Find(root, "h"))); + } + + [TestMethod] + [Spec("css-break-3", "#break-within")] + [DataRow("avoid", 1)] + [DataRow("avoid-page", 1)] + [DataRow("avoid-column", 0)] + [DataRow("avoid-region", 0)] + public void BreakInside_AvoidsOnlyPageBreaks(string value, int expectedPage) + { + var (root, container) = Paginate("<div style='height:250px'></div>" + Paragraph("p", 6, $"break-inside:{value}")); + + Assert.AreEqual(expectedPage, PageOf(container, Find(root, "p"))); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-break-3.yml")] + [Spec("css-break-3", "#break-propagation")] + public void BreakBeforeAvoidOnAFirstChild_AppliesBeforeItsContainer() + { + var (root, container) = Paginate( + "<div style='height:200px'></div>" + + "<div id='h'>Head</div>" + + "<div id='w'>" + Paragraph("p", 8, "break-before:avoid;break-inside:avoid") + "</div>"); + + Assert.AreEqual(1, PageOf(container, Find(root, "p"))); + Assert.AreEqual(1, PageOf(container, Find(root, "h"))); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-break-3.yml")] + [Spec("css-break-3", "#possible-breaks")] + public void ForcedBreakInsideAMonolithicBox_IsIgnored() + { + var (root, container) = Paginate( + "<div style='overflow:auto;height:100px'><div style='height:20px'></div><div id='b' style='break-before:page;height:10px'></div></div>" + + "<div id='after' style='height:10px'></div>"); + + Assert.AreEqual(0, PageOf(container, Find(root, "b"))); + Assert.AreEqual(0, PageOf(container, Find(root, "after"))); + } + + [TestMethod] + [Spec("css-break-3", "#fragmentation-model")] + public void BoxSpanningTwoPages_HasOneFragmentPerPage() + { + var (root, container) = Paginate("<div style='height:250px'></div><div id='b' style='height:100px'></div>"); + + var b = Find(root, "b"); + var fragments = container.FragmentTree!.Fragmentainers.Select(f => FindFragment(f.Root, b)).ToList(); + Assert.AreEqual(2, fragments.Count); + Assert.AreEqual(50, fragments[0]!.Rect.Height, Delta); + Assert.AreEqual(50, fragments[1]!.Rect.Height, Delta); + Assert.AreEqual(0, fragments[1]!.Rect.Top, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-break-3.yml")] + [Spec("css-break-3", "#forced-breaks")] + public void DifferentPageValues_ForceABreak() + { + var (root, container) = Paginate( + "<div style='height:50px'></div><div id='b' style='page:chapter;height:10px'></div>"); + + Assert.AreEqual(1, PageOf(container, Find(root, "b"))); + } + + [TestMethod] + [Spec("css-break-3", "#break-between")] + public void AbsolutelyPositionedBox_IgnoresBreakBefore() + { + var (root, container) = Paginate( + "<div style='height:50px'></div><div id='b' style='position:absolute;top:60px;break-before:page;height:10px'></div>"); + + Assert.AreEqual(0, PageOf(container, Find(root, "b"))); + } + + [TestMethod] + [Spec("css-break-3", "#break-between")] + public void Float_HonorsBreakBefore() + { + var (root, container) = Paginate( + "<div style='height:50px'></div><div id='b' style='float:left;width:50px;break-before:page;height:10px'></div>"); + + Assert.AreEqual(1, PageOf(container, Find(root, "b"))); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-break-3.yml")] + [Spec("css-break-3", "#break-between")] + [DataRow("recto")] + [DataRow("verso")] + public void RectoAndVerso_ForceABreak(string value) + { + var (root, container) = Paginate($"<div style='height:50px'></div><div id='b' style='break-before:{value};height:10px'></div>"); + + Assert.IsTrue(PageOf(container, Find(root, "b")) >= 1); + } + + [TestMethod] + [Spec("css-break-3", "#page-break-properties")] + public void BreakBefore_OverridesAnEarlierPageBreakBefore() + { + var (root, container) = Paginate( + "<div style='height:50px'></div><div id='b' class='x' style='height:10px'></div>", + "div { page-break-before: always } .x { break-before: auto }"); + + Assert.AreEqual(0, PageOf(container, Find(root, "b"))); + } + + [TestMethod] + [Spec("css-break-3", "#page-break-properties")] + public void PageBreakBefore_OverridesAnEarlierBreakBefore() + { + var (root, container) = Paginate( + "<div style='height:50px'></div><div id='b' class='x' style='height:10px'></div>", + "div { break-before: page } .x { page-break-before: auto }"); + + Assert.AreEqual(0, PageOf(container, Find(root, "b"))); + } + + [TestMethod] + [Spec("css-break-3", "#page-break-properties")] + [DataRow("page-break-before:always")] + [DataRow("break-before:page")] + public void PageBreakAlwaysAndBreakPage_ForceTheSameBreak(string declaration) + { + var (root, container) = Paginate($"<div style='height:50px'></div><div id='b' style='{declaration};height:10px'></div>"); + + Assert.AreEqual(container.PageTopOf(1), Find(root, "b").Location.Y, Delta); + } + + [TestMethod] + [Spec("css-break-3", "#forced-breaks")] + public void ForcedBreak_HasNoEffectWithoutPages() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='height:50px'></div><div id='b' style='break-before:page;height:10px'></div>")); + + Assert.AreEqual(50, Find(root, "b").Location.Y, Delta); + } + + [TestMethod] + [Spec("css-break-3", "#parallel-flows")] + public void ForcedBreakInsideAFloat_DoesNotMoveContentBesideIt() + { + var (root, container) = Paginate( + "<div style='float:left;width:100px'><div style='height:50px'></div><div id='f' style='break-before:page;height:10px'></div></div>" + + "<div id='o' style='height:10px'>o</div>"); + + Assert.AreEqual(0, PageOf(container, Find(root, "o"))); + } + + [TestMethod] + [Spec("css-break-3", "#parallel-flows")] + public void TableCellsInOneRow_BreakIndependently() + { + var filler = PageHeight - 1.5 * LineHeight; + var (root, container) = Paginate( + $"<div style='height:{Px(filler)}'></div>" + + "<table cellspacing='0' cellpadding='0'><tr><td id='a'>" + string.Join("<br>", Enumerable.Range(1, 4).Select(i => $"A{i}")) + + "</td><td id='b'>B1</td></tr></table>"); + + var a = Find(root, "a"); + foreach (var line in a.LineBoxes) + Assert.AreEqual(container.PageIndexOf(line.LineTop), container.PageIndexOf(line.LineBottom - 0.01)); + } + + [TestMethod] + [Spec("css-break-3", "#best-breaks")] + public void ZeroHeightBoxAtTheEndOfAFullPage_StaysOnThatPage() + { + var (root, container) = Paginate($"<div style='height:{Px(PageHeight - 1)}'></div><div id='z'></div>"); + + Assert.AreEqual(0, PageOf(container, Find(root, "z"))); + Assert.AreEqual(1, container.FragmentTree!.Fragmentainers.Count); + } + + [TestMethod] + [Spec("css-break-3", "#box-splitting")] + public void SplitBox_FillsTheRestOfThePage() + { + var filler = PageHeight - 1.5 * LineHeight; + var (root, container) = Paginate($"<div style='height:{Px(filler)}'></div>" + Paragraph("p", 4, "orphans:1;widows:1;background:red")); + + var page0 = container.FragmentTree!.Fragmentainers[0]; + var p = Find(root, "p"); + var fragment = FindFragment(page0.Root, p); + Assert.IsNotNull(fragment); + Assert.AreEqual(page0.Rect.Height, fragment.Rect.Bottom, Delta); + } + + [TestMethod] + [Spec("css-break-3", "#box-splitting")] + public void BreakGap_CountsTowardsAFixedHeight() + { + var filler = PageHeight - 1.5 * LineHeight; + var (root, _) = Paginate($"<div style='height:{Px(filler)}'></div>" + Paragraph("p", 3, "orphans:1;widows:1;height:100px")); + + var p = Find(root, "p"); + Assert.AreEqual(filler + 100, p.ActualBottom, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-break-3.yml")] + [Spec("css-break-3", "#break-decoration")] + public void Clone_RepeatsTheBorderAndPaddingAfterTheBreak() + { + var filler = PageHeight - 1.5 * LineHeight; + var (root, container) = Paginate( + $"<div style='height:{Px(filler - 10)}'></div>" + + Paragraph("p", 4, "orphans:1;widows:1;border-top:5px solid black;padding-top:5px;box-decoration-break:clone")); + + var p = Find(root, "p"); + var firstOnPage1 = p.LineBoxes.First(l => container.PageIndexOf(l.LineTop) == 1); + Assert.AreEqual(container.PageTopOf(1) + 10, firstOnPage1.LineTop, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-break-3.yml")] + [Spec("css-break-3", "#transforms")] + public void RelativeOffset_IsAppliedAfterFragmentation() + { + var filler = PageHeight - 1.5 * LineHeight; + var (root, container) = Paginate( + $"<div style='height:{Px(filler)}'></div>" + Paragraph("p", 4, "orphans:1;widows:1;position:relative;top:20px")); + + var p = Find(root, "p"); + Assert.AreEqual(filler + 20, p.LineBoxes[0].LineTop, Delta); + Assert.AreEqual(container.PageTopOf(1) + 20, p.LineBoxes[1].LineTop, Delta); + } + + [TestMethod] + [Spec("css-break-3", "#transforms")] + public void AbsoluteBoxSpanningABreak_IsSplitAcrossBothPages() + { + var (root, container) = Paginate( + "<div style='height:10px'></div><div id='a' style='position:absolute;top:250px;height:100px;width:50px;background:red'></div>" + + "<div style='height:500px'></div>"); + + var a = Find(root, "a"); + var fragments = container.FragmentTree!.Fragmentainers.Select(f => FindFragment(f.Root, a)).ToList(); + Assert.AreEqual(50, fragments[0]!.Rect.Height, Delta); + Assert.AreEqual(50, fragments[1]!.Rect.Height, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-break-3.yml")] + [Spec("css-break-3", "#transforms")] + public void AbsoluteBoxReachingPastTheLastPage_GetsAPageOfItsOwn() + { + var (_, container) = Paginate( + "<div style='height:10px'></div><div style='position:absolute;top:250px;height:100px;width:50px;background:red'></div>"); + + Assert.AreEqual(2, container.FragmentTree!.Fragmentainers.Count); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-break-3.yml")] + [Spec("css-break-3", "#parallel-flows")] + public void ContentOverflowingAFixedHeightBox_SpillsOntoMorePages() + { + var (_, container) = Paginate(Paragraph("p", 60, "height:50px")); + + Assert.IsTrue(container.FragmentTree!.Fragmentainers.Count >= 2); + } + + private static TheArtOfDev.HtmlRenderer.Core.Fragments.BoxFragment? FindFragment( + TheArtOfDev.HtmlRenderer.Core.Fragments.BoxFragment fragment, CssBox box) + { + if (ReferenceEquals(fragment.Box, box)) + return fragment; + foreach (var child in fragment.Children) + { + var found = FindFragment(child, box); + if (found != null) + return found; + } + return null; + } +} diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/ForcedBreakTargetIsTheFramesTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/ForcedBreakTargetIsTheFramesTests.cs index deea804d3..7f55f1b3a 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/ForcedBreakTargetIsTheFramesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/ForcedBreakTargetIsTheFramesTests.cs @@ -37,6 +37,7 @@ public sealed class ForcedBreakTargetIsTheFramesTests [TestMethod] [Spec("css21", "page.html#forced")] + [Spec("css-break-3", "#forced-breaks")] public void PlainForcedBreak_TargetsTheNextSlotsContentTop_AndIsWhereTheBoxLanded() { var (root, container) = LayoutHarness.Layout( @@ -53,6 +54,7 @@ public void PlainForcedBreak_TargetsTheNextSlotsContentTop_AndIsWhereTheBoxLande // A predecessor ending flush on a page boundary already satisfies the break, no target is reported. [TestMethod] [Spec("css21", "page.html#forced")] + [Spec("css-break-3", "#forced-breaks")] public void PredecessorEndingFlushOnABoundary_TargetsThatBoundary_NotTheSlotAfterIt() { var (root, container) = LayoutHarness.Layout( @@ -72,9 +74,9 @@ public void PredecessorEndingFlushOnABoundary_TargetsThatBoundary_NotTheSlotAfte // A zero-height marker moved to a page top by its own forced break, followed by another forced // break, should leave a blank page. - [Ignore("A forced break after a zero-height box that its own forced break moved to a page top is " + - "treated as already satisfied, both boxes share the page.")] + [Ignore("Gap, see Spec/css-break-3.yml")] [TestMethod] + [Spec("css-break-3", "#forced-breaks")] public void ConsecutiveForcedBreaks_StepPastTheMarkerRatherThanCollapsingOntoIt() { var (root, container) = LayoutHarness.Layout( @@ -98,6 +100,7 @@ public void ConsecutiveForcedBreaks_StepPastTheMarkerRatherThanCollapsingOntoIt( // The margin-top after a forced break is retained. [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] + [Spec("css-break-3", "#break-margins")] public void TargetIsTheBoundary_AndThePreservedMarginIsAddedToIt() { var (root, container) = LayoutHarness.Layout( @@ -114,6 +117,7 @@ public void TargetIsTheBoundary_AndThePreservedMarginIsAddedToIt() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "page.html#forced")] + [Spec("css-break-3", "#break-propagation")] public void BreakBeforeAFirstInFlowChild_IsTakenByTheContainerItBegins() { var (root, container) = LayoutHarness.Layout( @@ -132,6 +136,7 @@ public void BreakBeforeAFirstInFlowChild_IsTakenByTheContainerItBegins() // Nothing precedes the box, so there is no break to take and no leading blank page. [TestMethod] + [Spec("css-break-3", "#break-propagation")] public void BoxThatBeginsTheFlow_HasNoTargetAtAll() { var (root, container) = LayoutHarness.Layout( diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs index 0979f64f3..6f87ec025 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs @@ -16,6 +16,7 @@ public sealed class MonolithicContentTests [TestMethod] [DataRow("<img id='t' src='x.png' style='width:10pt;height:10pt'>")] [DataRow("<iframe id='t' style='width:10pt;height:10pt'></iframe>")] + [Spec("css-break-3", "#possible-breaks")] public void ReplacedElement_IsMonolithic(string markup) { var box = BoxOf(markup); @@ -25,6 +26,7 @@ public void ReplacedElement_IsMonolithic(string markup) } [TestMethod] + [Spec("css-break-3", "#possible-breaks")] public void OrdinaryBlock_IsNotMonolithic() { var box = BoxOf("<div id='t'>text</div>"); @@ -42,6 +44,7 @@ public void OrdinaryBlock_IsNotMonolithic() [DataRow("visible", false)] // 'clip' isn't a known overflow value, the box stays 'visible', which is also what §2 wants. [DataRow("clip", false)] + [Spec("css-break-3", "#possible-breaks")] public void Overflow_DecidesScrollContainer(string overflow, bool expected) { var box = BoxOf($"<div id='t' style='overflow:{overflow}'>text</div>"); diff --git a/Spec/css-break-3.yml b/Spec/css-break-3.yml index 3993e1d27..af3d44bad 100644 --- a/Spec/css-break-3.yml +++ b/Spec/css-break-3.yml @@ -5,76 +5,107 @@ snapshot: https://www.w3.org/TR/2018/CR-css-break-3-20181204/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Values - status: unknown + status: n/a '#fragmentation-model': title: 2 Fragmentation Model and Terminology - status: unknown + status: partial '#parallel-flows': title: 2.1 Parallel Fragmentation Flows - status: unknown + status: partial + gaps: + - Content overflowing past the end of the root box, e.g. out of a fixed-height box, makes no further pages and is cut off '#nested-flows': title: 2.2 Nested Fragmentation Flows - status: unknown + status: unsupported + gaps: + - Pages are the only fragmentation context, there's no multi-column or region fragmentation '#breaking-controls': title: 3 Controlling Breaks - status: unknown + status: partial '#break-between': title: '3.1 Breaks Between Boxes: the break-before and break-after properties' - status: unknown + status: partial + gaps: + - "'left' and 'right' don't force a break, see CSS 2.1 13.3.1" + - "'recto', 'verso', 'region' and 'avoid-region' are rejected as invalid" '#break-propagation': title: 3.1.1 Child→Parent Break Propagation - status: unknown + status: partial + gaps: + - A forced break before a first child or after a last child isn't propagated to its container, see CSS 2.1 13.3.4 + - "'break-before: avoid' on a first child isn't propagated to its container" '#break-within': title: '3.2 Breaks Within Boxes: the break-inside property' - status: unknown + status: partial + gaps: + - "A 'break-inside: avoid' block without text is split instead of moved to the next page, see CSS 2.1 13.3.1" '#widows-orphans': title: '3.3 Breaks Between Lines: orphans, widows' - status: unknown + status: partial + gaps: + - "'orphans' and 'widows' of 0 or less are accepted and fall back to 2 instead of being ignored" '#page-break-properties': title: '3.4 Page Break Aliases: the page-break-before, page-break-after, and page-break-inside properties' - status: unknown + status: supported '#breaking-rules': title: 4 Rules for Breaking - status: unknown + status: partial '#possible-breaks': title: 4.1 Possible Break Points - status: unknown + status: partial + gaps: + - A forced break inside a monolithic box, e.g. a scroll container, isn't ignored + - When all lines of a block move to the next page, the block's top edge stays on the previous page, see CSS 2.1 13.3.3 '#break-types': title: 4.2 Types of Breaks - status: unknown + status: n/a '#forced-breaks': title: 4.3 Forced Breaks - status: unknown + status: partial + gaps: + - A forced break right after a zero-height box that a forced break moved to a page top makes no new page + - "A different 'page' value doesn't force a break" '#unforced-breaks': title: 4.4 Unforced Breaks - status: unknown + status: partial + gaps: + - "'break-after: avoid' doesn't pull a block without text along to the next page, see CSS 2.1 13.3.3" '#best-breaks': title: 4.5 Optimizing Unforced Breaks - status: unknown + status: supported '#breaking-boxes': title: 5 Box Model for Breaking - status: unknown + status: partial '#varying-size-boxes': title: 5.1 Breaking into Varying-size Fragmentainers - status: unknown + status: unsupported + gaps: + - Every page has the same size, page sizes from @page rules aren't applied '#break-margins': title: 5.2 Adjoining Margins at Breaks - status: unknown + status: partial + gaps: + - "At a forced break the previous box's 'margin-bottom' isn't truncated, see CSS 2.1 13.3.3" + - The top margin of an hr isn't truncated at an unforced break, see CSS 2.1 13.3.3 + - A forced break before the first box of the flow isn't taken, and the margin after it is truncated anyway '#box-splitting': title: 5.3 Splitting Boxes - status: unknown + status: supported '#break-decoration': title: '5.4 Fragmented Borders and Backgrounds: the box-decoration-break property' - status: unknown + status: partial + gaps: + - A box split across pages draws its border at the split, see CSS 2.1 13.3.1 + - "'clone' is ignored, no border or padding is added at a break" '#joining-boxes': title: 5.4.1 Joining Boxes for slice - status: unknown + status: unsupported + gaps: + - A box split across pages positions its background in each page's fragment instead of across the whole box + - An inline box broken across lines draws its background image only in the first fragment '#transforms': title: 5.5 Transforms, Positioning, and Pagination - status: unknown + status: partial + gaps: + - A relatively positioned box is fragmented at its offset position instead of its in-flow position + - An absolutely positioned box reaching past the end of the root box is cut off at the last page From 676a04cada6258863f5309a9dd4ad610189cc978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:09:09 +0100 Subject: [PATCH 070/150] Verify and document CSS Paged Media Level 3 compliance --- Source/HtmlRenderer.PdfSharp/PdfGenerator.cs | 5 +- Source/HtmlRenderer/Core/PageBandGeometry.cs | 3 +- .../PagedMediaIntegrationTests.cs | 4 + .../PageModelPaintIntegrationTests.cs | 57 ++++++++++ .../PageRuleTests.cs | 5 + .../PageSizeRuleTests.cs | 55 +++++++++ .../HtmlRenderer.Test/Css/PageRuleTests.cs | 68 ++++++++++- .../Fragmentation/CssBreakTests.cs | 1 + .../HtmlContainerIntPaginationTests.cs | 2 +- Spec/css-page-3.yml | 107 +++++++++++------- 10 files changed, 255 insertions(+), 52 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/PageModelPaintIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.PdfSharp.Test/PageSizeRuleTests.cs diff --git a/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs b/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs index 5b481392b..8042e993e 100644 --- a/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs +++ b/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs @@ -232,10 +232,7 @@ private static void HandleLinks(PdfDocument document, HtmlContainer container, X if (tree == null || tree.Fragmentainers.Count == 0) return; - // Pagination slot -> PDF page index. Not a bare multiply/divide by page height any more: - // a content-empty slot is never materialized as a fragmentainer at all (blank-page - // skipping), so slot indices are not contiguous across tree.Fragmentainers the way a - // fixed-size page grid's would be. + // Pagination slot -> PDF page index, a content-empty slot gets no page (css-page-3 §3.2). var slotToPage = new Dictionary<int, int>(); for (var pageIndex = 0; pageIndex < tree.Fragmentainers.Count; pageIndex++) { diff --git a/Source/HtmlRenderer/Core/PageBandGeometry.cs b/Source/HtmlRenderer/Core/PageBandGeometry.cs index d0de2e13f..1bbd204ec 100644 --- a/Source/HtmlRenderer/Core/PageBandGeometry.cs +++ b/Source/HtmlRenderer/Core/PageBandGeometry.cs @@ -2,8 +2,7 @@ namespace TheArtOfDev.HtmlRenderer.Core { /// <summary> /// The block-axis band and margins of one page, in output units. All pages share the container's - /// <see cref="HtmlContainerInt.PageSize"/> and margins, <c>@page</c> rules aren't applied - /// (CSS 2.1 §13.2). + /// <see cref="HtmlContainerInt.PageSize"/> and margins. /// </summary> internal readonly struct PageBandGeometry { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs index 95a84a42c..adfcd67ca 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs @@ -55,6 +55,7 @@ private static int[] LinesPerPage(HtmlContainerInt container, CssBox block, int [TestMethod] [Spec("css21", "page.html#page-box")] + [Spec("css-page-3", "#page-model")] public void PageArea_IsTheInitialContainingBlock() { var (root, _) = Paginate("<div id='d' style='width:50%;height:10px'></div>", margin: 20); @@ -67,6 +68,7 @@ public void PageArea_IsTheInitialContainingBlock() [TestMethod] [Spec("css21", "page.html#page-box")] + [Spec("css-page-3", "#page-model")] public void EachPageArea_StartsBelowItsPageMargin() { var (root, container) = Paginate( @@ -83,6 +85,7 @@ public void EachPageArea_StartsBelowItsPageMargin() [TestMethod] [Spec("css21", "page.html#outside-page-box")] + [Spec("css-page-3", "#content-outside-box")] public void FarAwayAbsoluteBox_DoesNotProduceBlankPages() { var (_, container) = Paginate( @@ -386,6 +389,7 @@ public void ForcedBreak_SetsTheMarginBottomToZero() [TestMethod] [Spec("css21", "page.html#forced")] [Spec("css-break-3", "#forced-breaks")] + [Spec("css-page-3", "#page-breaks")] public void ForcedBreak_OccursEvenWhereTheContentWouldFit() { var (root, container) = Paginate( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageModelPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageModelPaintIntegrationTests.cs new file mode 100644 index 000000000..b0c620f53 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageModelPaintIntegrationTests.cs @@ -0,0 +1,57 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[TestClass] +[DoNotParallelize] +public sealed class PageModelPaintIntegrationTests +{ + private const double PageHeight = 200; + private const double Margin = 20; + private static readonly RColor Canvas = RColor.FromArgb(255, 1, 2, 3); + + private static HtmlContainerInt Layout(string style, string body) => + PaintHarness.LayoutPaginated( + $"<html><head><style>{style}</style></head><body style='margin:0'>{body}</body></html>", + pageWidth: 400, pageHeight: PageHeight, margin: Margin).Container; + + private static RecordingGraphics.DrawRectCall[] CanvasRects(HtmlContainerInt container, int page) => + PaintHarness.PaintPage(container, page).Log.OfType<RecordingGraphics.DrawRectCall>().Where(r => r.Color == Canvas).ToArray(); + + [TestMethod] + [Spec("css-page-3", "#painting")] + public void DocumentBackground_IsPaintedInThePageAreaOfEveryPage() + { + var container = Layout("html { background: rgb(1,2,3) }", "<div style='height:300px'></div>"); + + Assert.AreEqual(2, container.FragmentTree!.Fragmentainers.Count); + var first = CanvasRects(container, 0).Single(); + Assert.AreEqual(Margin, first.Y, 0.5); + Assert.AreEqual(PageHeight, first.Height, 0.5); + Assert.AreEqual(Margin, CanvasRects(container, 1).Single().Y, 0.5); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-page-3.yml")] + [Spec("css-page-3", "#painting")] + public void DocumentBackground_FillsTheWholePageAreaOfTheLastPage() + { + var container = Layout("html { background: rgb(1,2,3) }", "<div style='height:300px'></div>"); + + Assert.AreEqual(PageHeight, CanvasRects(container, 1).Sum(r => r.Height), 0.5); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-page-3.yml")] + [Spec("css-page-3", "#painting")] + public void PageBackground_IsPainted() + { + var container = Layout("@page { background: rgb(1,2,3) }", "<div style='height:50px'></div>"); + + Assert.IsTrue(CanvasRects(container, 0).Length > 0); + } +} diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/PageRuleTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/PageRuleTests.cs index 430e71238..4241ac674 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/PageRuleTests.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/PageRuleTests.cs @@ -24,6 +24,7 @@ private static async Task<int> PageCount(string html, int marginTop = 0, int mar [TestMethod] [Spec("css21", "page.html#page-box")] + [Spec("css-page-3", "#page-model")] public async Task PageArea_IsThePageMinusTheConfiguredMargins() { Assert.AreEqual(1, await PageCount($"<html><body style='margin:0'>{Block}</body></html>")); @@ -33,6 +34,7 @@ public async Task PageArea_IsThePageMinusTheConfiguredMargins() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#page-margins")] + [Spec("css-page-3", "#at-page-rule")] [DataRow("@page { margin: 250pt 0 }")] [DataRow("@page { margin-top: 30%; margin-bottom: 30% }")] public async Task PageMargins_ShrinkThePageArea(string rule) @@ -43,6 +45,8 @@ public async Task PageMargins_ShrinkThePageArea(string rule) [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#page-selectors")] + [Spec("css-page-3", "#first-pseudo")] + [Spec("css-page-3", "#spread-pseudos")] [DataRow("@page :first { margin-top: 500pt }")] [DataRow("@page :right { margin-top: 500pt }")] public async Task PageSelector_AppliesToTheFirstPage(string rule) @@ -53,6 +57,7 @@ public async Task PageSelector_AppliesToTheFirstPage(string rule) [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#page-cascade")] + [Spec("css-page-3", "#cascading-and-page-context")] public async Task PageContext_FollowsTheCascade() { const string rules = "@page :first { margin-top: 500pt } @page { margin-top: 0 }"; diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/PageSizeRuleTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/PageSizeRuleTests.cs new file mode 100644 index 000000000..e14ffe2b4 --- /dev/null +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/PageSizeRuleTests.cs @@ -0,0 +1,55 @@ +using PdfSharp; +using PdfSharp.Pdf; +using TheArtOfDev.HtmlRenderer.PdfSharp; + +namespace HtmlRenderer.PdfSharp.Test; + +// Parallel full layout passes race on the adapter singletons. +[TestClass] +[DoNotParallelize] +public sealed class PageSizeRuleTests +{ + private static async Task<PdfDocument> Generate(string css, string body = "<p>x</p>") => + await PdfGenerator.GeneratePdf( + $"<html><head><style>{css}</style></head><body>{body}</body></html>", + new PdfGenerateConfig { PageSize = PageSize.A4 }); + + [TestMethod] + [Spec("css-page-3", "#page-model")] + public async Task PageSize_ComesFromTheConfiguration() + { + using var document = await Generate(""); + + Assert.AreEqual(595, document.Pages[0].Width.Point, 1); + Assert.AreEqual(842, document.Pages[0].Height.Point, 1); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-page-3.yml")] + [Spec("css-page-3", "#page-model")] + [Spec("css-page-3", "#page-size-prop")] + [DataRow("@page { size: A5 }", 420d, 595d)] + [DataRow("@page { size: 200pt 300pt }", 200d, 300d)] + [DataRow("@page { size: landscape }", 842d, 595d)] + public async Task SizeProperty_SetsThePageSize(string css, double width, double height) + { + using var document = await Generate(css); + + Assert.AreEqual(width, document.Pages[0].Width.Point, 1); + Assert.AreEqual(height, document.Pages[0].Height.Point, 1); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-page-3.yml")] + [Spec("css-page-3", "#using-named-pages")] + public async Task NamedPage_UsesItsPageRule() + { + using var document = await Generate( + "@page wide { size: 842pt 595pt } div { page: wide }", + "<p>x</p><div>wide</div>"); + + Assert.AreEqual(2, document.Pages.Count); + Assert.AreEqual(595, document.Pages[0].Width.Point, 1); + Assert.AreEqual(842, document.Pages[1].Width.Point, 1); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/PageRuleTests.cs b/Source/Test/HtmlRenderer.Test/Css/PageRuleTests.cs index 7a9a47a8d..f9c277878 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PageRuleTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PageRuleTests.cs @@ -5,14 +5,60 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Parsing of <c>@page</c> rules. Nothing applies them to the page yet. +/// Parsing of <c>@page</c> rules. /// </summary> [TestClass] public sealed class PageRuleTests { + [TestMethod] + [Spec("css-page-3", "#syntax-page-selector")] + [DataRow("@page :blank { margin: 0 }", ":blank")] + [DataRow("@page:first { margin: 0 }", ":first")] + [DataRow("@page toc, index { margin: 0 }", "toc, index")] + public void AtPage_SpecSelector_IsParsed(string css, string selector) + { + var sheet = CssConstructionFunctions.ParseStyleSheet(css); + var rule = sheet.Rules.OfType<PageRule>().FirstOrDefault(); + Assert.IsNotNull(rule); + Assert.AreEqual(selector, rule.SelectorText); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-page-3.yml")] + [Spec("css-page-3", "#syntax-page-selector")] + [DataRow("@page :blank:first { margin: 0 }", ":blank:first")] + [DataRow("@page chapter:first:left { margin: 0 }", "chapter:first:left")] + public void AtPage_SeveralPseudoClasses_AreParsed(string css, string selector) + { + var sheet = CssConstructionFunctions.ParseStyleSheet(css); + var rule = sheet.Rules.OfType<PageRule>().FirstOrDefault(); + Assert.IsNotNull(rule); + Assert.AreEqual(selector, rule.SelectorText); + } + + [TestMethod] + [Spec("css-page-3", "#syntax-page-selector")] + [DataRow("@page : first { margin: 0 }")] + [DataRow("@page :first chapter { margin: 0 }")] + public void AtPage_InvalidSelector_DropsTheRule(string css) + { + var sheet = CssConstructionFunctions.ParseStyleSheet(css); + Assert.AreEqual(0, sheet.Rules.OfType<PageRule>().Count()); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-page-3.yml")] + [Spec("css-page-3", "#syntax-page-selector")] + public void AtPage_UnknownPseudoClass_DropsTheRule() + { + var sheet = CssConstructionFunctions.ParseStyleSheet("@page :middle { margin: 0 }"); + Assert.AreEqual(0, sheet.Rules.OfType<PageRule>().Count()); + } + // ── @page { size: ... } ──────────────────────────────────────────────── [TestMethod] + [Spec("css-page-3", "#page-size-prop")] public void AtPage_SizeA4_IsStoredOnStyleDeclaration() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page { size: A4; }"); @@ -22,6 +68,7 @@ public void AtPage_SizeA4_IsStoredOnStyleDeclaration() } [TestMethod] + [Spec("css-page-3", "#page-size-prop")] public void AtPage_SizeLetter_IsStoredOnStyleDeclaration() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page { size: letter; }"); @@ -31,6 +78,7 @@ public void AtPage_SizeLetter_IsStoredOnStyleDeclaration() } [TestMethod] + [Spec("css-page-3", "#page-size-prop")] public void AtPage_SizeA4Landscape_IsStoredOnStyleDeclaration() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page { size: A4 landscape; }"); @@ -40,6 +88,7 @@ public void AtPage_SizeA4Landscape_IsStoredOnStyleDeclaration() } [TestMethod] + [Spec("css-page-3", "#page-size-prop")] public void AtPage_SizeExplicitDimensions_IsStoredOnStyleDeclaration() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page { size: 210mm 297mm; }"); @@ -52,6 +101,7 @@ public void AtPage_SizeExplicitDimensions_IsStoredOnStyleDeclaration() [TestMethod] [Spec("css21", "page.html#page-box")] + [Spec("css-page-3", "#at-page-rule")] public void AtPage_NoSelector_HasNullSelector() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page { margin: 20mm; }"); @@ -62,6 +112,7 @@ public void AtPage_NoSelector_HasNullSelector() [TestMethod] [Spec("css21", "page.html#page-selectors")] + [Spec("css-page-3", "#first-pseudo")] public void AtPage_FirstPseudoSelector_SelectorTextIsFirst() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page :first { margin-top: 0; }"); @@ -72,6 +123,7 @@ public void AtPage_FirstPseudoSelector_SelectorTextIsFirst() [TestMethod] [Spec("css21", "page.html#page-selectors")] + [Spec("css-page-3", "#spread-pseudos")] public void AtPage_LeftPseudoSelector_SelectorTextIsLeft() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page :left { margin-left: 30mm; }"); @@ -82,6 +134,7 @@ public void AtPage_LeftPseudoSelector_SelectorTextIsLeft() [TestMethod] [Spec("css21", "page.html#page-selectors")] + [Spec("css-page-3", "#spread-pseudos")] public void AtPage_RightPseudoSelector_SelectorTextIsRight() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page :right { margin-right: 30mm; }"); @@ -93,6 +146,7 @@ public void AtPage_RightPseudoSelector_SelectorTextIsRight() // ── @page named selectors ────────────────────────────────────────────── [TestMethod] + [Spec("css-page-3", "#page-selectors")] public void AtPage_NamedSelector_SelectorTextIsName() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page chapter { margin-top: 0; }"); @@ -102,6 +156,7 @@ public void AtPage_NamedSelector_SelectorTextIsName() } [TestMethod] + [Spec("css-page-3", "#page-selectors")] public void AtPage_NamedSelector_IsNotColonPrefixed() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page chapter { margin-top: 0; }"); @@ -111,6 +166,7 @@ public void AtPage_NamedSelector_IsNotColonPrefixed() } [TestMethod] + [Spec("css-page-3", "#syntax-page-selector")] public void AtPage_CommaSeparatedNamedSelectors_AllNamesPresent() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page chapter1, chapter2, chapter3 { margin-top: 0; }"); @@ -120,6 +176,7 @@ public void AtPage_CommaSeparatedNamedSelectors_AllNamesPresent() } [TestMethod] + [Spec("css-page-3", "#page-selectors")] public void AtPage_NamedSelector_PreservesCase() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page Chapter { margin-top: 0; }"); @@ -131,6 +188,7 @@ public void AtPage_NamedSelector_PreservesCase() // ── @page compound name:pseudo selectors ─────────────────────────────── [TestMethod] + [Spec("css-page-3", "#syntax-page-selector")] public void AtPage_CompoundNamePseudoSelector_ParsesAsOneRule() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page chapter1:left { margin-top: 0; }"); @@ -140,6 +198,7 @@ public void AtPage_CompoundNamePseudoSelector_ParsesAsOneRule() } [TestMethod] + [Spec("css-page-3", "#syntax-page-selector")] public void AtPage_CompoundCommaSeparatedSelectors_AllEntriesPresent() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page chapter1:left, chapter2:left { margin-top: 0; }"); @@ -151,6 +210,7 @@ public void AtPage_CompoundCommaSeparatedSelectors_AllEntriesPresent() // ── @page margin boxes ───────────────────────────────────────────────── [TestMethod] + [Spec("css-page-3", "#margin-at-rules")] public void AtPage_TopCenterMarginBox_IsParsedIntoMargins() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page { @top-center { content: \"Page\"; } }"); @@ -161,6 +221,7 @@ public void AtPage_TopCenterMarginBox_IsParsedIntoMargins() } [TestMethod] + [Spec("css-page-3", "#margin-at-rules")] public void AtPage_TopCenterMarginBox_HasTopCenterSelector() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page { @top-center { content: counter(page); } }"); @@ -171,6 +232,7 @@ public void AtPage_TopCenterMarginBox_HasTopCenterSelector() } [TestMethod] + [Spec("css-page-3", "#populating-margin-boxes")] public void AtPage_TopCenterMarginBox_ContentIsNonEmpty() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page { @top-center { content: \"Header\"; } }"); @@ -182,6 +244,7 @@ public void AtPage_TopCenterMarginBox_ContentIsNonEmpty() } [TestMethod] + [Spec("css-page-3", "#margin-at-rules")] public void AtPage_MultipleMarginBoxes_AllParsed() { var sheet = CssConstructionFunctions.ParseStyleSheet(@" @@ -198,6 +261,7 @@ public void AtPage_MultipleMarginBoxes_AllParsed() // ── named pages ──────────────────────────────────────────────────────── [TestMethod] + [Spec("css-page-3", "#using-named-pages")] public void PageNameProperty_Identifier_ParsesCorrectly() { var sheet = CssConstructionFunctions.ParseStyleSheet("div { page: chapter; }"); @@ -207,6 +271,7 @@ public void PageNameProperty_Identifier_ParsesCorrectly() } [TestMethod] + [Spec("css-page-3", "#using-named-pages")] public void PageNameProperty_Auto_ParsesCorrectly() { var sheet = CssConstructionFunctions.ParseStyleSheet("div { page: auto; }"); @@ -218,6 +283,7 @@ public void PageNameProperty_Auto_ParsesCorrectly() // ── margin box explicit width ─────────────────────────────────────────── [TestMethod] + [Spec("css-page-3", "#margin-at-rules")] public void AtPage_MarginBox_ExplicitWidthIsParsedFromStyle() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page { @top-left { content: \"L\"; width: 100pt; } }"); diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/CssBreakTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/CssBreakTests.cs index 2ca1c150a..1ead2abbc 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/CssBreakTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/CssBreakTests.cs @@ -119,6 +119,7 @@ public void BoxSpanningTwoPages_HasOneFragmentPerPage() [TestMethod] [Ignore("Gap, see Spec/css-break-3.yml")] [Spec("css-break-3", "#forced-breaks")] + [Spec("css-page-3", "#using-named-pages")] public void DifferentPageValues_ForceABreak() { var (root, container) = Paginate( diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/HtmlContainerIntPaginationTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/HtmlContainerIntPaginationTests.cs index 35f78cbd2..f2b294a30 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/HtmlContainerIntPaginationTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/HtmlContainerIntPaginationTests.cs @@ -4,7 +4,7 @@ namespace HtmlRenderer.Test.Fragmentation; /// <summary> -/// Which page slots get a fragmentainer. css-page-3 §3.2 asks to avoid content-empty pages. +/// Which page slots get a fragmentainer. /// </summary> [TestClass] public sealed class HtmlContainerIntPaginationTests diff --git a/Spec/css-page-3.yml b/Spec/css-page-3.yml index 21eb72f1d..58500397e 100644 --- a/Spec/css-page-3.yml +++ b/Spec/css-page-3.yml @@ -5,127 +5,146 @@ snapshot: https://www.w3.org/TR/2023/WD-css-page-3-20230914/ sections: '#intro': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown + status: n/a '#page-terms': title: 2 Page Terminology - status: unknown + status: n/a '#page-model': title: 3 The Page Model - status: unknown + status: partial + gaps: + - "Page size and margins come from PdfGenerateConfig, @page rules aren't applied" '#painting': title: 3.1 Page Backgrounds and Painting Order - status: unknown + status: partial + gaps: + - "@page backgrounds aren't painted" + - The root background covers only the root box, not the rest of the last page's page area, see CSS 2.1 14.2 '#content-outside-box': title: 3.2 Content outside the page box - status: unknown + status: supported '#progression': title: 3.3 Page Progression - status: unknown + status: unsupported + gaps: + - Pages aren't classified as left or right pages '#page-selector-and-context': title: 4 Page Selectors and the Page Context - status: unknown + status: parsed-only '#at-page-rule': title: 4.1 The @page Rule - status: unknown + status: parsed-only + gaps: + - "@page declarations are parsed but never applied, see CSS 2.1 13.2.1" '#page-selectors': title: 4.2 Page selectors - status: unknown + status: parsed-only '#spread-pseudos': title: '4.2.1 Spread pseudo-classes: :left, :right' - status: unknown + status: parsed-only + gaps: + - "@page :left and :right rules are parsed but never applied, see CSS 2.1 13.2.2" '#first-pseudo': title: '4.2.2 First-page pseudo-class: :first' - status: unknown + status: parsed-only + gaps: + - "@page :first rules are parsed but never applied, see CSS 2.1 13.2.2" '#blank-pseudo': title: '4.2.3 Blank-page pseudo-class: :blank' - status: unknown + status: parsed-only '#syntax-page-selector': title: 4.3 @page rule grammar - status: unknown + status: parsed-only + gaps: + - "A selector with more than one pseudo-class, e.g. ':blank:first', drops the rule" + - "An unknown pseudo-class, e.g. ':middle', is accepted" '#cascading-and-page-context': title: 4.4 Cascading in the page context - status: unknown + status: parsed-only + gaps: + - "@page declarations aren't cascaded, see CSS 2.1 13.4" '#margin-boxes': title: 5 Page-Margin Boxes - status: unknown + status: parsed-only '#margin-at-rules': title: 5.1 At-rules for page-margin boxes - status: unknown + status: parsed-only '#populating-margin-boxes': title: 5.2 Populating page-margin boxes - status: unknown + status: parsed-only '#margin-dimension': title: 5.3 Computing Page-margin Box Dimensions - status: unknown + status: unsupported '#margin-box-terms': title: 5.3.1 Page-Margin Box Layout Terminology - status: unknown + status: n/a '#variable-sizing': title: 5.3.2 Page-Margin Box Variable Dimension Computation Rules - status: unknown + status: unsupported '#variable-auto-margins': title: 5.3.2.1 Margins - status: unknown + status: unsupported '#variable-auto-sizing': title: 5.3.2.2 Resolving auto widths - status: unknown + status: unsupported '#variable-minmax': title: 5.3.2.3 Handling min-width and max-width - status: unknown + status: unsupported '#variable-position': title: 5.3.2.4 Positioning - status: unknown + status: unsupported '#variable-mapping': title: 5.3.2.5 Boxes on other sides - status: unknown + status: unsupported '#fixed-sizing': title: 5.3.3 Page-Margin Box Fixed Dimension Computation Rules - status: unknown + status: unsupported '#margin-box-ex': title: 5.4 Page-margin box examples - status: unknown + status: n/a '#page-properties': title: 6 Page Properties - status: unknown + status: parsed-only '#page-based-counters': title: 6.1 Page-based counters - status: unknown + status: unsupported '#margin-text-alignment': title: 6.2 Page-margin boxes and default values - status: unknown + status: unsupported '#page-size': title: 7 Page Size - status: unknown + status: parsed-only '#page-size-prop': title: '7.1 Page size: the size property' - status: unknown + status: parsed-only + gaps: + - "'size' is parsed but never applied, the page size comes from PdfGenerateConfig" '#page-size-media-query': title: 7.1.1 Media Queries - status: unknown + status: n/a '#page-orientation-prop': title: '7.1.2 Rotating The Printed Page: the page-orientation property' - status: unknown + status: unsupported '#marks': title: '7.2 Crop and Registration Marks: the marks property' - status: unknown + status: unsupported '#bleed': title: '7.3 Bleed Area: the bleed property' - status: unknown + status: unsupported '#renderingpages': title: 7.4 Rendering page boxes that do not fit a page sheet - status: unknown + status: n/a '#positioning-page-box': title: 7.5 Positioning the page box on the sheet - status: unknown + status: n/a '#page-breaks': title: 8 Page Breaks - status: unknown + status: supported '#using-named-pages': title: '8.1 Using named pages: page' - status: unknown + status: parsed-only + gaps: + - "'page' is parsed but never applied, a different value neither forces a break nor selects a named page" '#image-properties': title: 9 Image Properties - status: unknown + status: n/a From 747c1802e036b01d0129bfdbd91e14ef9a1cec03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 07:59:30 +0100 Subject: [PATCH 071/150] Verify and document CSS Lists and Counters Level 3 compliance --- .../StyleProperties/List/ListStyleProperty.cs | 5 +- Source/HtmlRenderer/Core/Dom/CssBox.cs | 10 +- .../HtmlRenderer/Core/Dom/CssCounterEngine.cs | 13 +- .../CssContentEngineIntegrationTests.cs | 6 + .../ListItemCounterIntegrationTests.cs | 1 + .../Content/GeneratedContentTests.cs | 14 ++ .../Content/ListStyleTests.cs | 10 + .../Content/ListsAndCountersTests.cs | 183 ++++++++++++++++++ .../Css/ListPropertyTests.cs | 52 +++-- .../Dom/CssCounterEngineTests.cs | 1 + Spec/css-lists-3.yml | 73 ++++--- 11 files changed, 310 insertions(+), 58 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Content/ListsAndCountersTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/List/ListStyleProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/List/ListStyleProperty.cs index a2039dbf3..23478381e 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/List/ListStyleProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/List/ListStyleProperty.cs @@ -4,9 +4,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine internal sealed class ListStyleProperty : ShorthandProperty { - // list-style's operands are order-independent (CSS Lists 3 - "in any order"), so a token such as - // "none" that both list-style-type and list-style-image accept must not be claimed positionally - // ("list-style: none square" must parse like "list-style: square none"). + // CSS Lists 3 §3.6, a "none" goes to whichever of type and image isn't set otherwise, so it can't + // be claimed by position ("none square" parses like "square none"). private static readonly IValueConverter StyleConverter = WithAnyOrderIndependent( ListStyleConverter.Option().For(PropertyNames.ListStyleType), ListPositionConverter.Option().For(PropertyNames.ListStylePosition), diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index ea5350e40..1215181e3 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -1130,9 +1130,9 @@ protected override sealed CssBoxProperties GetParent() } /// <summary> - /// Gets the index of the box to be used on a (ordered) list + /// The ordinal of this list item, counted over the parent's list items from its start and + /// reversed attributes (HTML §4.4.5). The list-item counter isn't involved. /// </summary> - /// <returns></returns> private int GetIndexForList() { bool reversed = !string.IsNullOrEmpty(ParentBox.GetAttribute("reversed")); @@ -1174,9 +1174,9 @@ private int GetIndexForList() } /// <summary> - /// Creates the <see cref="_listItemBox"/> + /// Creates the marker box (CSS Lists 3 §3) from list-style-type and places it 5px left of the + /// list item's border box. /// </summary> - /// <param name="g"></param> private void CreateListItemBox(RGraphics g) { if (Display == CssConstants.ListItem && ListStyleType != CssConstants.None) @@ -1222,7 +1222,7 @@ private void CreateListItemBox(RGraphics g) _listItemBox.Size = new RSize(_listItemBox.Words[0].Width, _listItemBox.Words[0].Height); } _listItemBox.Words[0].Left = Location.X - _listItemBox.Size.Width - 5; - _listItemBox.Words[0].Top = Location.Y + ActualPaddingTop; // +FontAscent; + _listItemBox.Words[0].Top = Location.Y + ActualPaddingTop; } } diff --git a/Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs b/Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs index 7087a5929..418de5706 100644 --- a/Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs @@ -18,7 +18,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Dom { /// <summary> - /// Counters (CSS 2.1 §12.4), resolved into a per-box snapshot that <c>content: counter()</c> reads. + /// Counters (CSS 2.1 §12.4, CSS Lists 3 §4), resolved into a per-box snapshot that <c>content: counter()</c> reads. /// </summary> internal static class CssCounterEngine { @@ -32,8 +32,9 @@ public static void ResolveCounters(CssBox root) } /// <summary> - /// Each counter name maps to a stack of instances, one per nested scope (CSS 2.1 §12.4.1). - /// A reset's instance is popped again once all its siblings are done. + /// Each counter name maps to a stack of instances, one per nested scope (CSS Lists 3 §4.3). + /// A reset's instance is popped again once all its siblings are done. Values carry over in tree + /// order because every box writes into the same instance (CSS Lists 3 §4.4.1). /// </summary> private static void ResolveSiblings(IEnumerable<CssBox> boxes, Dictionary<string, List<int>> counters) { @@ -74,6 +75,7 @@ private static void ApplyCounterReset(CssBox box, Dictionary<string, List<int>> private static void ApplyCounterIncrement(CssBox box, Dictionary<string, List<int>> counters, HashSet<string> created) { + // CSS Lists 3 §4.6 if (box.Display == CssConstants.ListItem && !CounterNameAppears(box.CounterIncrement, CssConstants.ListItem)) { Increment(counters, created, CssConstants.ListItem, 1); @@ -86,7 +88,8 @@ private static void ApplyCounterIncrement(CssBox box, Dictionary<string, List<in private static void Reset(Dictionary<string, List<int>> counters, HashSet<string> created, string name, int value) { - // A second reset among the same siblings replaces that instance instead of nesting a new one. + // A second reset among the same siblings replaces that instance instead of nesting a new one + // (CSS Lists 3 §4.4.2). if (created.Add(name)) { if (!counters.TryGetValue(name, out var instances)) @@ -102,7 +105,7 @@ private static void Reset(Dictionary<string, List<int>> counters, HashSet<string private static void Increment(Dictionary<string, List<int>> counters, HashSet<string> created, string name, int value) { - // Not in scope, reset it to 0 on this box (CSS 2.1 §12.4.1) + // Not in scope, instantiate it with 0 on this box (CSS Lists 3 §4.2) if (!counters.TryGetValue(name, out var instances)) { Reset(counters, created, name, 0); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Content/CssContentEngineIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Content/CssContentEngineIntegrationTests.cs index 75476d848..cbe9f2c71 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Content/CssContentEngineIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Content/CssContentEngineIntegrationTests.cs @@ -58,6 +58,7 @@ public void ApplyContent_WithOpenAndCloseQuote_UsesCurlyQuotes() [TestMethod] [Spec("css21", "generate.html#counter-styles")] + [Spec("css-lists-3", "#counter-functions")] public void ApplyContent_WithCounterNoStyle_UsesDecimal() { var html = LayoutHarness.Wrap( @@ -122,6 +123,7 @@ public void ApplyContent_WithCounterAndStyleAndLiteral_Concatenates() [TestMethod] [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#increment-set")] public void CounterIncrement_ThreadsSequentiallyAcrossSiblings() { var html = LayoutHarness.Wrap( @@ -144,6 +146,7 @@ public void CounterIncrement_ThreadsSequentiallyAcrossSiblings() [TestMethod] [Spec("css21", "generate.html#scope")] + [Spec("css-lists-3", "#inheriting-counters")] public void CounterReset_OnNestedAncestor_IsVisibleToDescendant() { var html = LayoutHarness.Wrap( @@ -157,6 +160,7 @@ public void CounterReset_OnNestedAncestor_IsVisibleToDescendant() [TestMethod] [Spec("css21", "generate.html#scope")] + [Spec("css-lists-3", "#nested-counters")] public void CounterReset_InANestedList_DoesNotLeakIntoTheOuterList() { var html = LayoutHarness.Wrap( @@ -179,6 +183,7 @@ public void CounterReset_InANestedList_DoesNotLeakIntoTheOuterList() [TestMethod] [Spec("css21", "generate.html#undisplayed-counters")] + [Spec("css-lists-3", "#counters-without-boxes")] public void CounterIncrement_OnADisplayNoneElement_IsIgnored() { var html = LayoutHarness.Wrap( @@ -196,6 +201,7 @@ public void CounterIncrement_OnADisplayNoneElement_IsIgnored() [TestMethod] [Spec("css21", "generate.html#scope")] + [Spec("css-lists-3", "#counter-functions")] public void Counter_NotInScope_ReadsAsZero() { Assert.AreEqual("0", ResolveBeforeContent("counter(nothing)").Text); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Content/ListItemCounterIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Content/ListItemCounterIntegrationTests.cs index 2d80cd3bf..def283663 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Content/ListItemCounterIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Content/ListItemCounterIntegrationTests.cs @@ -25,6 +25,7 @@ public void OlStart_OutsideTheStyleRange_FallsBackToDecimal(int start, string st [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#text-markers")] public void ListStyleTypeSquare_UsesAFilledSquare() { var html = LayoutHarness.Wrap("<ul style='list-style-type:square'><li id='a'>x</li></ul>"); diff --git a/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs index 47ac61d3f..e74744907 100644 --- a/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs +++ b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs @@ -103,6 +103,7 @@ public void NewlineEscape_CollapsesToASpaceWithNormalWhiteSpace() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "generate.html#content")] + [Spec("css-lists-3", "#counter-functions")] public void CountersFunction_JoinsAllCountersInScope() { var html = "<div style='counter-reset:c 1'><div style='counter-reset:c 2'><p id=x>a</p></div></div>" @@ -162,6 +163,7 @@ public void CloseQuoteAtDepthZero_InsertsNothing() [TestMethod] [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#auto-numbering")] public void CounterResetAndIncrement_OnTheSameElement_ResetFirst() { Assert.AreEqual("6", Before("<p id=x style='counter-reset:c 5;counter-increment:c'>a</p><style>#x::before { content: counter(c) }</style>").Text); @@ -169,6 +171,8 @@ public void CounterResetAndIncrement_OnTheSameElement_ResetFirst() [TestMethod] [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#counter-reset")] + [Spec("css-lists-3", "#increment-set")] public void RepeatedCounterName_IsProcessedInOrder() { Assert.AreEqual("0", Before("<p id=x style='counter-reset:c 2 c'>a</p><style>#x::before { content: counter(c) }</style>").Text); @@ -177,6 +181,7 @@ public void RepeatedCounterName_IsProcessedInOrder() [TestMethod] [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#increment-set")] public void ZeroAndNegativeIncrements_AreAllowed() { Assert.AreEqual("5", Before("<p id=x style='counter-reset:c 5;counter-increment:c 0'>a</p><style>#x::before { content: counter(c) }</style>").Text); @@ -185,6 +190,7 @@ public void ZeroAndNegativeIncrements_AreAllowed() [TestMethod] [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#counter-reset")] public void CounterReset_FollowsTheCascade() { var html = "<p id=x>a</p><style>#x { counter-reset: a 5 } #x { counter-reset: b 2 } #x::before { content: counter(a) counter(b) }</style>"; @@ -195,6 +201,7 @@ public void CounterReset_FollowsTheCascade() [TestMethod] [Spec("css21", "generate.html#counters")] [Spec("css21", "generate.html#scope")] + [Spec("css-lists-3", "#increment-set")] public void ChaptersAndSections_AreNumbered() { var html = "<div style='counter-reset:chapter'>" @@ -214,6 +221,7 @@ public void ChaptersAndSections_AreNumbered() [TestMethod] [Spec("css21", "generate.html#scope")] + [Spec("css-lists-3", "#nested-counters")] public void NestedLists_GetANewCounterInstancePerList() { var html = "<ol><li id=a>i</li><li id=b>i" @@ -232,6 +240,8 @@ public void NestedLists_GetANewCounterInstancePerList() [TestMethod] [Spec("css21", "generate.html#scope")] + [Spec("css-lists-3", "#increment-set")] + [Spec("css-lists-3", "#instantiating-counters")] public void CounterIncrement_OutOfScope_ResetsTheCounterOnThatElement() { var html = "<div><p style='counter-increment:c'>a</p><p id=x>b</p></div><style>#x::before { content: counter(c) }</style>"; @@ -248,6 +258,7 @@ public void CounterIncrement_OutOfScope_ResetsTheCounterOnThatElement() [DataRow("armenian", "Գ")] [DataRow("georgian", "გ")] [DataRow("decimal-leading-zero", "03")] + [Spec("css-lists-3", "#counter-functions")] public void Counter_IsFormattedInTheGivenStyle(string style, string expected) { Assert.AreEqual(expected, Before($"<p id=x style='counter-reset:c 3'>a</p><style>#x::before {{ content: counter(c, {style}) }}</style>").Text); @@ -256,6 +267,7 @@ public void Counter_IsFormattedInTheGivenStyle(string style, string expected) [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "generate.html#counter-styles")] + [Spec("css-lists-3", "#counter-functions")] public void Counter_WithGlyphOrNoneStyle_UsesTheGlyphOrNothing() { Assert.AreEqual("[]", Before("<p id=x style='counter-reset:c 3'>a</p><style>#x::before { content: '[' counter(c, none) ']' }</style>").Text); @@ -265,6 +277,7 @@ public void Counter_WithGlyphOrNoneStyle_UsesTheGlyphOrNothing() [TestMethod] [Spec("css21", "generate.html#undisplayed-counters")] + [Spec("css-lists-3", "#counters-without-boxes")] public void HiddenElement_StillIncrements() { var html = "<div><p style='counter-reset:c'>a</p><p style='counter-increment:c;visibility:hidden'>b</p><p id=x>c</p></div>" @@ -276,6 +289,7 @@ public void HiddenElement_StillIncrements() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "generate.html#undisplayed-counters")] + [Spec("css-lists-3", "#counters-without-boxes")] public void PseudoElementThatIsNotGenerated_DoesNotIncrement() { var html = "<div style='counter-reset:c'><p id=a>a</p><p id=x>b</p></div>" diff --git a/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs b/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs index 7f046a9f0..976f06174 100644 --- a/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs +++ b/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs @@ -7,6 +7,8 @@ public sealed class ListStyleTests { [TestMethod] [Spec("css21", "generate.html#lists")] + [Spec("css-lists-3", "#declaring-a-list-item")] + [Spec("css-lists-3", "#marker-pseudo")] public void ListItem_GetsAMarkerBox() { var x = Box("<div id=x style='display:list-item'>a</div>"); @@ -28,6 +30,7 @@ public void ListItem_GetsAMarkerBox() [DataRow("upper-alpha", "C.")] [DataRow("armenian", "Գ.")] [DataRow("georgian", "გ.")] + [Spec("css-lists-3", "#text-markers")] public void NumberedListStyleType_NumbersTheItem(string type, string expected) { Assert.AreEqual(expected, Marker($"<ol style='list-style-type:{type}'><li>a</li><li>b</li><li id=x>c</li></ol>")); @@ -35,6 +38,7 @@ public void NumberedListStyleType_NumbersTheItem(string type, string expected) [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#text-markers")] public void GlyphListStyleTypes_AreDistinct() { var disc = Marker("<ul style='list-style-type:disc'><li id=x>a</li></ul>"); @@ -46,6 +50,7 @@ public void GlyphListStyleTypes_AreDistinct() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#text-markers")] public void ListStyleTypeNone_HasNoMarker() { Assert.IsNull(Box("<ul style='list-style-type:none'><li id=x>a</li></ul>").ListItemBox); @@ -53,6 +58,7 @@ public void ListStyleTypeNone_HasNoMarker() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-property")] public void ListStyleShorthand_SetsTheType() { Assert.AreEqual("square", Box("<ul style='list-style:square inside'><li id=x>a</li></ul>").ListStyleType); @@ -61,6 +67,7 @@ public void ListStyleShorthand_SetsTheType() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-position-property")] public void OutsideMarker_IsLeftOfTheContent() { var x = Box("<ul><li id=x>a</li></ul>"); @@ -71,6 +78,7 @@ public void OutsideMarker_IsLeftOfTheContent() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-position-property")] public void InsideMarker_IsInsideThePrincipalBox() { var x = Box("<ul style='list-style-position:inside'><li id=x>a</li></ul>"); @@ -81,6 +89,7 @@ public void InsideMarker_IsInsideThePrincipalBox() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-position-property")] public void RtlMarker_IsRightOfTheContent() { var x = Box("<ul style='direction:rtl'><li id=x>a</li></ul>"); @@ -91,6 +100,7 @@ public void RtlMarker_IsRightOfTheContent() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#image-markers")] public void ListStyleImage_ReplacesTheMarker() { var x = Box("<ul style='list-style-image:url(data:image/png;base64,iVBORw0KGgo=)'><li id=x>a</li></ul>"); diff --git a/Source/Test/HtmlRenderer.Test/Content/ListsAndCountersTests.cs b/Source/Test/HtmlRenderer.Test/Content/ListsAndCountersTests.cs new file mode 100644 index 000000000..bfbe7dcee --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Content/ListsAndCountersTests.cs @@ -0,0 +1,183 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using static HtmlRenderer.Test.CssEngineSupport.CssConstructionFunctions; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Content; + +[TestClass] +public sealed class ListsAndCountersTests +{ + [TestMethod] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#marker-pseudo")] + public void MarkerRule_StylesTheMarker() + { + var x = Box("<ul><li id=x>a</li></ul><style>li::marker { color: red }</style>"); + + Assert.AreEqual(255, x.ListItemBox!.ActualColor.R); + Assert.AreEqual(0, x.ActualColor.R); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#content-property")] + public void MarkerContent_ReplacesTheMarkerString() + { + Assert.AreEqual("X", Marker("<ul><li id=x>a</li></ul><style>li::marker { content: 'X' }</style>")); + } + + [TestMethod] + [Spec("css-lists-3", "#content-property")] + public void ListItemWithoutMarkerString_HasNoMarkerBox() + { + Assert.IsNull(Box("<ol style='list-style-type:none'><li id=x>a</li></ol>").ListItemBox); + Assert.IsNull(Box("<div id=x style='display:list-item;list-style:none'>a</div>").ListItemBox); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#text-markers")] + public void StringListStyleType_IsTheMarkerString() + { + Assert.AreEqual("*", Marker("<ul style='list-style-type:\"*\"'><li id=x>a</li></ul>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#text-markers")] + public void UnknownCounterStyle_FallsBackToDecimal() + { + Assert.AreEqual("1.", Marker("<ul style='list-style-type:no-such-style'><li id=x>a</li></ul>")); + } + + [TestMethod] + [Spec("css-lists-3", "#list-style-property")] + public void ListStyleShorthand_NoneGoesToTheLonghandNotOtherwiseSet() + { + var noneImage = Box("<ul style='list-style:none url(x.png)'><li id=x>a</li></ul>"); + Assert.AreEqual("none", noneImage.ListStyleType); + Assert.AreEqual("url(\"x.png\")", noneImage.ListStyleImage); + + var noneDisc = Box("<ol style='list-style:none disc'><li id=x>a</li></ol>"); + Assert.AreEqual("disc", noneDisc.ListStyleType); + Assert.AreEqual("none", noneDisc.ListStyleImage); + + Assert.AreEqual("none", Box("<ol style='list-style:none'><li id=x>a</li></ol>").ListStyleType); + Assert.AreEqual("decimal", Box("<ol style='list-style:none disc url(x.png)'><li id=x>a</li></ol>").ListStyleType); + } + + [TestMethod] + [Spec("css-lists-3", "#list-style-property")] + public void ListStyleShorthand_ResetsTheOmittedLonghands() + { + Assert.AreEqual("disc", Box("<ol style='list-style:inside'><li id=x>a</li></ol>").ListStyleType); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#auto-numbering")] + public void None_IsNotACounterName() + { + Assert.IsFalse(ParseDeclaration("counter-reset: none 3").HasValue); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#auto-numbering")] + public void CounterValue_IsClampedToTheSupportedRange() + { + Assert.AreEqual("2147483647", Before("<p id=x style='counter-reset:c 2147483647;counter-increment:c'>a</p><style>#x::before { content: counter(c) }</style>").Text); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#increment-set")] + public void CounterSet_SetsTheValueAfterTheIncrement() + { + Assert.AreEqual("2", Before("<p id=x style='counter-reset:c 5;counter-increment:c;counter-set:c 2'>a</p><style>#x::before { content: counter(c) }</style>").Text); + } + + [TestMethod] + [Spec("css-lists-3", "#inheriting-counters")] + public void CounterValue_ComesFromThePrecedingElementInTreeOrder() + { + var html = "<ul style='counter-reset:example 0'>" + + "<li id=foo style='counter-increment:example'>foo<div id=bar style='counter-increment:example'>bar</div></li>" + + "<li id=x>baz</li></ul>" + + "<style>#foo::before, #bar::before, #x::before { content: counter(example) }</style>"; + + Assert.AreEqual("1", Before(html, "foo").Text); + Assert.AreEqual("2", Before(html, "bar").Text); + Assert.AreEqual("2", Before(html).Text); + } + + [TestMethod] + [Spec("css-lists-3", "#instantiating-counters")] + public void ResetOnALaterSibling_ReplacesTheSiblingsCounter() + { + var html = "<div><p style='counter-reset:c 3'>a</p><p style='counter-reset:c 7'>b</p><p id=x>c</p></div>" + + "<div style='counter-reset:c 1'><p id=y style='counter-reset:c 2'>d</p></div>" + + "<style>#x::before, #y::before { content: counter(c) }</style>"; + + Assert.AreEqual("7", Before(html).Text); + Assert.AreEqual("2", Before(html, "y").Text); + } + + [TestMethod] + [Spec("css-lists-3", "#declaring-a-list-item")] + [Spec("css-lists-3", "#list-item-counter")] + public void ListItems_IncrementTheListItemCounter() + { + var html = "<div style='counter-reset:list-item'><p id=a style='display:list-item'>a</p><p>b</p><p id=c style='display:list-item'>c</p></div>" + + "<style>p::before { content: counter(list-item) }</style>"; + + Assert.AreEqual("1", Before(html, "a").Text); + Assert.AreEqual("2", Before(html, "c").Text); + } + + [TestMethod] + [Spec("css-lists-3", "#list-item-counter")] + public void ExplicitListItemIncrement_ReplacesTheImplicitOne() + { + var html = "<ol><li>a</li><li id=x>b</li></ol><style>li { counter-increment: list-item 2 } li::before { content: counter(list-item) }</style>"; + Assert.AreEqual("4", Before(html).Text); + + html = "<ol><li>a</li><li id=x>b</li></ol><style>li { counter-increment: list-item 0 } li::before { content: counter(list-item) }</style>"; + Assert.AreEqual("0", Before(html).Text); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#list-item-counter")] + public void Marker_ShowsTheListItemCounter() + { + Assert.AreEqual("4.", Marker("<ol><li>a</li><li id=x>b</li></ol><style>li { counter-increment: list-item 2 }</style>")); + Assert.AreEqual("5.", Marker("<ol style='counter-reset:list-item 4'><li id=x>a</li></ol>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#list-item-counter")] + public void NestedList_StartsANewListItemCounter() + { + Assert.AreEqual("1", Before("<ol><li>a<ol><li id=x>b</li></ol></li></ol><style>li::before { content: counter(list-item) }</style>").Text); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#list-item-counter")] + public void StartAndValue_SetTheListItemCounter() + { + Assert.AreEqual("5", Before("<ol start=5><li id=x>a</li></ol><style>li::before { content: counter(list-item) }</style>").Text); + Assert.AreEqual("7", Before("<ol><li id=x value=7>a</li></ol><style>li::before { content: counter(list-item) }</style>").Text); + } + + private static CssBox Before(string body, string id = "x") + { + // First in tree order, it can sit in an anonymous block + var before = LayoutHarness.Descendants(Box(body, id)).FirstOrDefault(b => b.IsBeforePseudoElement); + Assert.IsNotNull(before, $"No ::before on '{id}'"); + return before; + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/ListPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/ListPropertyTests.cs index 2e7ba758c..f153347ef 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ListPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ListPropertyTests.cs @@ -9,6 +9,7 @@ public sealed class ListPropertyTests { [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-position-property")] public void CssListStylePositionOutsideLegal() { var property = ParseDeclaration("list-style-position: outside "); @@ -23,6 +24,7 @@ public void CssListStylePositionOutsideLegal() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-position-property")] public void CssListStylePositionOutsideIllegal() { var property = ParseDeclaration("list-style-position: out-side "); @@ -36,6 +38,7 @@ public void CssListStylePositionOutsideIllegal() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-position-property")] public void CssListStylePositionNoneIllegal() { var property = ParseDeclaration("list-style-position: none "); @@ -49,6 +52,7 @@ public void CssListStylePositionNoneIllegal() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-position-property")] public void CssListStylePositionInsideLegal() { var property = ParseDeclaration("list-style-position: insiDe "); @@ -63,6 +67,7 @@ public void CssListStylePositionInsideLegal() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#image-markers")] public void CssListStyleImageNoneLegal() { var property = ParseDeclaration("list-style-image: none "); @@ -77,6 +82,7 @@ public void CssListStyleImageNoneLegal() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#image-markers")] public void CssListStyleImageUrlLegal() { var property = ParseDeclaration("list-style-image: url(http://www.example.com/images/list.png)"); @@ -91,6 +97,7 @@ public void CssListStyleImageUrlLegal() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#text-markers")] public void CssListStyleTypeDiscLegal() { var property = ParseDeclaration("list-style-type: disc "); @@ -105,6 +112,7 @@ public void CssListStyleTypeDiscLegal() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#text-markers")] public void CssListStyleTypeLowerAlphaLegal() { var property = ParseDeclaration("list-style-type: lower-ALPHA "); @@ -114,14 +122,13 @@ public void CssListStyleTypeLowerAlphaLegal() var concrete = (ListStyleTypeProperty)property; Assert.IsFalse(concrete.IsInherited); Assert.IsTrue(concrete.HasValue); - // Map.ListStyles resolves lower-alpha to the ListStyle.LowerLatin enum member, but the converter's - // serialized text is the keyword that was actually written (lower-alpha), not a canonicalized - // "lower-latin" - see Source/HtmlRenderer/Core/CssEngine/Model/Map.cs. + // Serialized as written, not as the lower-latin it maps to Assert.AreEqual("lower-alpha", concrete.Value); } [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#text-markers")] public void CssListStyleTypeGeorgianLegal() { var property = ParseDeclaration("list-style-type: georgian "); @@ -136,6 +143,7 @@ public void CssListStyleTypeGeorgianLegal() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#text-markers")] public void CssListStyleTypeDecimalLeadingZeroLegal() { var property = ParseDeclaration("list-style-type: decimal-leading-zerO "); @@ -149,9 +157,10 @@ public void CssListStyleTypeDecimalLeadingZeroLegal() } [TestMethod] + [Spec("css-lists-3", "#text-markers")] public void CssListStyleTypeHebrewLegal() { - // CSS 2.0 keyword, dropped from CSS 2.1, still defined in Map.ListStyles + // Dropped from CSS 2.1, predefined again in CSS Counter Styles 3 var property = ParseDeclaration("list-style-type: HEbrew "); Assert.AreEqual("list-style-type", property.Name); Assert.IsFalse(property.IsImportant); @@ -163,15 +172,9 @@ public void CssListStyleTypeHebrewLegal() } [TestMethod] - [Ignore("Gap: CSS Counter Styles Level 3's extended predefined counter-style keyword set (hiragana, " + - "katakana, arabic-indic, cjk-decimal, devanagari, and friends, the lower-armenian/upper-armenian " + - "split, disclosure-open/-closed, etc.) is not implemented - the ListStyle enum " + - "(Source/HtmlRenderer/Core/CssEngine/Enumerations/ListStyle.cs) and Map.ListStyles " + - "(Source/HtmlRenderer/Core/CssEngine/Model/Map.cs:166-185) only define " + - "none/disc/circle/square/decimal/decimal-leading-zero/lower-roman/upper-roman/lower-greek/" + - "lower-latin/upper-latin/armenian/georgian/hebrew/lower-alpha/upper-alpha, so any of these extra " + - "keywords fails to parse (HasValue false) rather than resolving as PeachPDF's port claims.")] - public void CssListStyleTypeExtendedKeywordSetUnsupported() + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#text-markers")] + public void CssListStyleTypeExtendedKeywordSetLegal() { var property = ParseDeclaration("list-style-type: hiragana "); var concrete = (ListStyleTypeProperty)property; @@ -180,14 +183,8 @@ public void CssListStyleTypeExtendedKeywordSetUnsupported() } [TestMethod] - [Ignore("Gap: list-style-type does not accept a CSS string literal as a custom counter-style glyph " + - "(CSS Counter Styles Level 3 / CSS Lists 3 'symbols()'-adjacent shorthand form). " + - "ListStyleTypeProperty's converter (Source/HtmlRenderer/Core/CssEngine/StyleProperties/List/" + - "ListStyleTypeProperty.cs) is Converters.ListStyleConverter = Map.ListStyles.ToConverter() - a pure " + - "keyword/identifier dictionary converter (Source/HtmlRenderer/Core/CssEngine/Extensions/" + - "ValueConverterExtensions.cs:77 DictionaryValueConverter) with no string-literal branch, so a quoted " + - "value like \"-> \" fails to convert entirely (DeclaredValue stays null - HasValue false, " + - "IsInherited reads true only because IsInitial does, not because 'inherit' was written).")] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#text-markers")] public void CssListStyleTypeStringLegal() { var property = ParseDeclaration("list-style-type: \"-> \" "); @@ -215,6 +212,7 @@ public void CssListStyleTypeNumberIllegal() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-property")] public void CssListStyleCircleLegal() { var property = ParseDeclaration("list-style: circle "); @@ -229,6 +227,7 @@ public void CssListStyleCircleLegal() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-property")] public void CssListStyleNone() { var property = ParseDeclaration("list-style: none"); @@ -243,6 +242,7 @@ public void CssListStyleNone() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-property")] public void CssListStyleSquareInsideLegal() { var property = ParseDeclaration("list-style: square inside "); @@ -257,6 +257,7 @@ public void CssListStyleSquareInsideLegal() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-property")] public void CssListStyleSquareImageInsideLegal() { var property = ParseDeclaration("list-style: square url('image.png') inside "); @@ -271,6 +272,7 @@ public void CssListStyleSquareImageInsideLegal() [TestMethod] [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#counter-reset")] public void CssCounterResetLegal() { var property = ParseDeclaration("counter-reset: chapter section 1 page;"); @@ -285,6 +287,7 @@ public void CssCounterResetLegal() [TestMethod] [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#counter-reset")] public void CssCounterResetSingleLegal() { var property = ParseDeclaration("counter-reset: counter-name"); @@ -299,6 +302,7 @@ public void CssCounterResetSingleLegal() [TestMethod] [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#counter-reset")] public void CssCounterResetNoneLegal() { var property = ParseDeclaration("counter-reset: none"); @@ -313,6 +317,7 @@ public void CssCounterResetNoneLegal() [TestMethod] [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#counter-reset")] public void CssCounterResetNumberIllegal() { var property = ParseDeclaration("counter-reset: 3"); @@ -326,6 +331,7 @@ public void CssCounterResetNumberIllegal() [TestMethod] [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#counter-reset")] public void CssCounterResetNegativeLegal() { var property = ParseDeclaration("counter-reset : counter-name -1"); @@ -340,6 +346,7 @@ public void CssCounterResetNegativeLegal() [TestMethod] [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#counter-reset")] public void CssCounterResetTwoCountersExplicitLegal() { var property = ParseDeclaration("counter-reset : counter1 1 counter2 4 "); @@ -354,6 +361,7 @@ public void CssCounterResetTwoCountersExplicitLegal() [TestMethod] [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#increment-set")] public void CssCounterIncrementNoneLegal() { var property = ParseDeclaration("counter-increment: none"); @@ -368,6 +376,7 @@ public void CssCounterIncrementNoneLegal() [TestMethod] [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#increment-set")] public void CssCounterIncrementLegal() { var property = ParseDeclaration("counter-increment: chapter section 2 page"); @@ -382,6 +391,7 @@ public void CssCounterIncrementLegal() [TestMethod] [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-property")] public void CssListStyleNoneSquareLegal() { var property = ParseDeclaration("list-style: none square"); diff --git a/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs b/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs index d4c59fba9..7bcd4143d 100644 --- a/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs +++ b/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs @@ -17,6 +17,7 @@ public sealed class CssCounterEngineTests [DataRow(4, "upper-roman", "IV")] [DataRow(1, "lower-alpha", "a")] [DataRow(3, "upper-alpha", "C")] + [Spec("css-lists-3", "#counter-functions")] public void FormatCounterValue_KnownStyles_FormatAsExpected(int number, string style, string expected) { Assert.AreEqual(expected, CssCounterEngine.FormatCounterValue(number, style)); diff --git a/Spec/css-lists-3.yml b/Spec/css-lists-3.yml index 208fce7fa..7ba4d92cc 100644 --- a/Spec/css-lists-3.yml +++ b/Spec/css-lists-3.yml @@ -5,67 +5,92 @@ snapshot: https://www.w3.org/TR/2020/WD-css-lists-3-20201117/ sections: '#intro': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown + status: n/a '#declaring-a-list-item': title: 2 Declaring a List Item - status: unknown + status: supported '#markers': title: 3 Markers - status: unknown + status: partial '#marker-pseudo': title: 3.1 The ::marker Pseudo-Element - status: unknown + status: partial + gaps: + - "::marker rules are ignored, the marker always takes the list item's style" '#marker-properties': title: 3.1.1 Properties Applying to ::marker - status: unknown + status: unsupported '#content-property': title: 3.2 Generating Marker Contents - status: unknown + status: partial + gaps: + - "'content' on ::marker is ignored, see 3.1" + - A list-style-image marker isn't generated, see 3.3 '#image-markers': title: '3.3 Image Markers: the list-style-image property' - status: unknown + status: parsed-only + gaps: + - "'list-style-image' is ignored, see CSS 2.1 12.5.1" '#text-markers': title: '3.4 Text-based Markers: the list-style-type property' - status: unknown + status: partial + gaps: + - A <string> value is rejected + - Only the CSS 2.1 counter styles and hebrew are accepted, other names and symbols() are rejected instead of falling back to decimal + - The marker counts the list's items instead of using the list-item counter, see 4.6 '#list-style-position-property': title: '3.5 Positioning Markers: The list-style-position property' - status: unknown + status: partial + gaps: + - inside is ignored, the marker is always outside, see CSS 2.1 12.5.1 + - "The marker is on the left with 'direction: rtl', see CSS 2.1 12.5.1" '#list-style-property': title: '3.6 Styling Markers: the list-style shorthand property' - status: unknown + status: supported '#marker-side': title: 3.7 The marker-side property - status: unknown + status: unsupported '#auto-numbering': title: 4 Automatic Numbering With Counters - status: unknown + status: partial + gaps: + - none is accepted as a counter name + - Counter values wrap around at the 32-bit limits instead of being clamped '#counter-reset': title: '4.1 Creating Counters: the counter-reset property' - status: unknown + status: supported '#increment-set': title: '4.2 Manipulating Counter Values: the counter-increment and counter-set properties' - status: unknown + status: partial + gaps: + - "'counter-set' isn't supported, the declaration is dropped" '#nested-counters': title: 4.3 Nested Counters and Scope - status: unknown + status: supported '#creating-counters': title: 4.4 Creating and Inheriting Counters - status: unknown + status: supported '#inheriting-counters': title: 4.4.1 Inheriting Counters - status: unknown + status: supported '#instantiating-counters': title: 4.4.2 Instantiating Counters - status: unknown + status: supported '#counters-without-boxes': title: 4.5 Counters in elements that do not generate boxes - status: unknown + status: partial + gaps: + - "A ::before or ::after with 'content: none' or 'normal' still resets and increments counters, see CSS 2.1 12.4.3" '#list-item-counter': title: 4.6 The Implicit list-item Counter - status: unknown + status: partial + gaps: + - The marker number ignores the list-item counter, so counter-reset, counter-increment and counter-set of list-item don't change it + - ol and ul don't reset list-item, a nested list continues the outer list's count + - start, value and reversed only change the marker, not the list-item counter '#counter-functions': title: '4.7 Outputting Counters: the counter() and counters() functions' - status: unknown + status: partial + gaps: + - counters() inserts nothing, see CSS 2.1 12.2 + - disc, circle, square and none format the counter as a decimal number, see CSS 2.1 12.4.2 From 80d64c03ca065df80f41fa2cd392523acb96fc72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:03:57 +0100 Subject: [PATCH 072/150] Verify and document CSS Generated Content Level 3 compliance --- .../StyleProperties/StringSetProperty.cs | 4 +- .../ContentFunctionConverter.cs | 4 +- .../StringFunctionConverter.cs | 6 +- .../StringSetValueConverter.cs | 13 +--- .../HtmlRenderer/Core/Dom/CssContentEngine.cs | 5 +- .../CssContentEngineIntegrationTests.cs | 6 ++ .../Content/GeneratedContentModuleTests.cs | 75 +++++++++++++++++++ .../Content/GeneratedContentTests.cs | 7 ++ .../Css/ContentPropertyTests.cs | 24 +++++- .../Css/PropertyContentTests.cs | 14 ++++ .../Css/StringFunctionConverterTests.cs | 24 +++--- .../Css/StringSetPropertyTests.cs | 16 ++-- .../Css/StringSetWithStringFunctionTests.cs | 19 +++-- Spec/css-content-3.yml | 75 ++++++++++++------- 14 files changed, 219 insertions(+), 73 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Content/GeneratedContentModuleTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/StringSetProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/StringSetProperty.cs index 121804ee7..03b1cfe4a 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/StringSetProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/StringSetProperty.cs @@ -1,9 +1,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Represents the CSS string-set property from CSS Generated Content for Paged Media Module. - /// Syntax: string-set: [ &lt;custom-ident&gt; &lt;content-list&gt; ]# | none - /// where &lt;content-list&gt; = [ &lt;string&gt; | &lt;counter()&gt; | &lt;counters()&gt; | &lt;content()&gt; | &lt;attr()&gt; ]+ + /// <c>string-set</c> (CSS Generated Content 3 §2.7.1). /// </summary> internal sealed class StringSetProperty : Property { diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/ContentFunctionConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/ContentFunctionConverter.cs index b48dbacd9..6c576fbbb 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/ContentFunctionConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/ContentFunctionConverter.cs @@ -4,8 +4,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Converter for the content() function used in string-set property. - /// Supports: content(text), content(before), content(after), content(first-letter) + /// <c>content()</c> (CSS Generated Content 3 §2.7.3), without the <c>marker</c> argument. /// </summary> internal sealed class ContentFunctionConverter : IValueConverter { @@ -18,7 +17,6 @@ public IPropertyValue Convert(IEnumerable<Token> value) if (!(first is FunctionToken funcToken) || !funcToken.Data.Equals(FunctionNames.Content, System.StringComparison.OrdinalIgnoreCase)) return null; - // Default mode is "text" if no argument provided var mode = "text"; if (funcToken.ArgumentTokens.Any()) diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/StringFunctionConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/StringFunctionConverter.cs index 38472be8d..4b0c30d63 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/StringFunctionConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/StringFunctionConverter.cs @@ -4,8 +4,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Converter for the string() function used in content and string-set properties. - /// Supports: string(name), string(name, first), string(name, last), string(name, start), string(name, first-except) + /// <c>string()</c> (CSS Generated Content 3 §2.7.2). /// </summary> internal sealed class StringFunctionConverter : IValueConverter { @@ -18,7 +17,6 @@ public IPropertyValue Convert(IEnumerable<Token> value) if (!(first is FunctionToken funcToken) || !funcToken.Data.Equals("string", System.StringComparison.OrdinalIgnoreCase)) return null; - // First argument must be the string name (identifier) var argToken = funcToken.ArgumentTokens.FirstOrDefault(); if (!(argToken is KeywordToken nameToken)) @@ -26,10 +24,8 @@ public IPropertyValue Convert(IEnumerable<Token> value) var name = nameToken.Data; - // Default keyword is "first" if not specified var keyword = "first"; - // Check for optional second argument (keyword) var args = funcToken.ArgumentTokens .Where(t => t.Type != TokenType.Comma && t.Type != TokenType.Whitespace) .ToArray(); diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/StringSetValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/StringSetValueConverter.cs index 595bc2db8..ab95c4920 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/StringSetValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/StringSetValueConverter.cs @@ -4,9 +4,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Converter for the string-set property. - /// Syntax: string-set: [ &lt;custom-ident&gt; &lt;content-list&gt; ]# | none - /// where &lt;content-list&gt; = [ &lt;string&gt; | &lt;counter()&gt; | &lt;counters()&gt; | &lt;content()&gt; | &lt;attr()&gt; ]+ + /// <c>string-set</c> (CSS Generated Content 3 §2.7.1). Each name takes the GCPM content list + /// (counters, <c>content()</c>, <c>attr()</c>, <c>string()</c>), not just the strings of the /TR grammar. /// </summary> internal sealed class StringSetValueConverter : IValueConverter { @@ -14,7 +13,6 @@ internal sealed class StringSetValueConverter : IValueConverter public StringSetValueConverter() { - // Content list can contain: strings, counter(), counters(), content(), attr(), string() _contentListItemConverter = Converters.StringConverter .Or(Converters.CounterConverter) .Or(new ContentFunctionConverter()) @@ -26,14 +24,13 @@ public IPropertyValue Convert(IEnumerable<Token> value) { var first = value.OnlyOrDefault(); - // Check for "none" keyword if (first is KeywordToken keyword && keyword.Data.Isi(Keywords.None)) { return new StringSetValue(null, value); } - // Parse comma-separated list of name/content-list pairs using ToList() - var items = value.ToList(); // This splits by commas, returns List<List<Token>> + // ValueExtensions.ToList, not LINQ's: one token list per comma-separated pair + var items = value.ToList(); var pairs = new List<StringSetPair>(); foreach (var item in items) @@ -56,13 +53,11 @@ private StringSetPair ParsePair(List<Token> tokens) if (tokens.Count < 2) return null; - // First token should be an identifier (the name) if (!(tokens[0] is KeywordToken nameToken) || nameToken.Type != TokenType.Ident) return null; var name = nameToken.Data; - // Remaining tokens form the content-list - split by whitespace var contentItems = tokens.Skip(1).ToItems(); var values = new List<IPropertyValue>(); diff --git a/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs b/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs index 629124594..384b084bc 100644 --- a/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs @@ -21,7 +21,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Dom { /// <summary> /// Turns the <c>content</c> value of a <c>::before</c>/<c>::after</c> box into its <see cref="CssBox.Text"/> - /// (CSS 2.1 §12.2). Components it doesn't know are skipped. + /// (CSS 2.1 §12.2, CSS Generated Content 3 §2). Components it doesn't know are skipped. /// </summary> internal static class CssContentEngine { @@ -76,7 +76,7 @@ private static string Resolve(CssBox box, string content) : CssConstants.Decimal; // The pseudo-element has its own counter snapshot, a counter not in scope reads as 0 - // (CSS 2.1 §12.4.1) + // (CSS Lists 3 §4.7) int counterValue; if (!box.Counters.TryGetValue(counterNameToken.Data, out counterValue)) { @@ -95,7 +95,6 @@ private static string Resolve(CssBox box, string content) { sb.Append('”'); } - // The quote depth isn't tracked, no-open-quote and no-close-quote do nothing } } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Content/CssContentEngineIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Content/CssContentEngineIntegrationTests.cs index cbe9f2c71..8da4c7263 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Content/CssContentEngineIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Content/CssContentEngineIntegrationTests.cs @@ -10,6 +10,7 @@ public sealed class CssContentEngineIntegrationTests { [TestMethod] [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#strings")] public void ApplyContent_WithStringLiteral_SetsText() { var before = ResolveBeforeContent("\"Hello World\""); @@ -18,6 +19,7 @@ public void ApplyContent_WithStringLiteral_SetsText() [TestMethod] [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#strings")] public void ApplyContent_WithMultipleStringLiterals_Concatenates() { var before = ResolveBeforeContent("\"Hello\" \" \" \"World\""); @@ -26,6 +28,7 @@ public void ApplyContent_WithMultipleStringLiterals_Concatenates() [TestMethod] [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#content-property")] public void ApplyContent_WithNone_DoesNotCreateContent() { var html = LayoutHarness.Wrap("<p id='p'>text</p><style>#p::before { content: none; }</style>"); @@ -37,6 +40,7 @@ public void ApplyContent_WithNone_DoesNotCreateContent() [TestMethod] [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#strings")] public void ApplyContent_WithAttrFunction_RetrievesAttribute() { var html = LayoutHarness.Wrap( @@ -50,6 +54,7 @@ public void ApplyContent_WithAttrFunction_RetrievesAttribute() [TestMethod] [Spec("css21", "generate.html#quotes-insert")] + [Spec("css-content-3", "#quote-values")] public void ApplyContent_WithOpenAndCloseQuote_UsesCurlyQuotes() { var before = ResolveBeforeContent("open-quote \"quoted\" close-quote"); @@ -110,6 +115,7 @@ public void ApplyContent_WithCounterUnknownStyle_FallsBackToDecimal() [TestMethod] [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#content-values")] public void ApplyContent_WithCounterAndStyleAndLiteral_Concatenates() { var html = LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.Test/Content/GeneratedContentModuleTests.cs b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentModuleTests.cs new file mode 100644 index 000000000..6ae2ad4ae --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentModuleTests.cs @@ -0,0 +1,75 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using static HtmlRenderer.Test.CssEngineSupport.CssConstructionFunctions; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Content; + +[TestClass] +public sealed class GeneratedContentModuleTests +{ + [TestMethod] + [Ignore("Gap, see Spec/css-content-3.yml")] + [Spec("css-content-3", "#content-property")] + public void ImageOnAnElement_ReplacesItsContents() + { + var words = LayoutHarness.Descendants(Box("<p id=x style='content:url(data:image/png;base64,iVBORw0KGgo=)'>abc</p>")) + .SelectMany(b => b.Words).ToList(); + + Assert.IsTrue(words.Any(w => w.IsImage)); + Assert.IsFalse(words.Any(w => w.Text == "abc")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-content-3.yml")] + [Spec("css-content-3", "#alt")] + public void AltText_LeavesTheVisibleContent() + { + Assert.AreEqual("a", Before("<p id=x>b</p><style>#x::before { content: 'a' / 'alt' }</style>").Text); + } + + [TestMethod] + [Spec("css-content-3", "#strings")] + public void WhiteSpaceInStrings_CollapsesAcrossStrings() + { + var before = Before("<p id=x>a</p><style>#x::before { content: 'First ' ' Second' }</style>"); + + CollectionAssert.AreEqual(new[] { "First", "Second" }, before.Words.Select(w => w.Text).ToArray()); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-content-3.yml")] + [Spec("css-content-3", "#quotes-property")] + [DataRow("auto")] + [DataRow("match-parent")] + public void QuotesKeyword_IsAccepted(string keyword) + { + Assert.IsTrue(ParseDeclaration($"quotes: {keyword}").HasValue); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-content-3.yml")] + [Spec("css-content-3", "#string-set")] + [Spec("css-content-3", "#string-function")] + public void StringFunction_InsertsTheNamedString() + { + var html = "<h1 style='string-set: title \"Loomings\"'>a</h1><p id=x>b</p><style>#x::before { content: '[' string(title) ']' }</style>"; + + Assert.AreEqual("[Loomings]", Before(html).Text); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-content-3.yml")] + [Spec("css-content-3", "#content-function")] + public void ContentFunction_InsertsTheElementText() + { + Assert.AreEqual("[abc]", Before("<p id=x>abc</p><style>#x::before { content: '[' content(text) ']' }</style>").Text); + } + + private static CssBox Before(string body, string id = "x") + { + var before = Box(body, id).Boxes.FirstOrDefault(b => b.IsBeforePseudoElement); + Assert.IsNotNull(before, $"No ::before on '{id}'"); + return before; + } +} diff --git a/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs index e74744907..95ff23e2f 100644 --- a/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs +++ b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs @@ -53,6 +53,7 @@ public void BlockPseudoElement_IsLaidOutAsABlockInsideTheElement() [DataRow("none")] [DataRow("normal")] [Spec("css-pseudo-4", "#generated-content")] + [Spec("css-content-3", "#content-property")] public void PseudoElementWithoutContent_IsNotGenerated(string content) { var plain = Box("<div id=x>Text</div>"); @@ -63,6 +64,7 @@ public void PseudoElementWithoutContent_IsNotGenerated(string content) [TestMethod] [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#strings")] public void Content_ConcatenatesStringsAndAttributes() { var before = Before("<p id=x data-n='7'>a</p><style>#x::before { content: 'No. ' attr(data-n) attr(data-missing) '!' }</style>"); @@ -115,6 +117,7 @@ public void CountersFunction_JoinsAllCountersInScope() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#content-uri")] public void Uri_InsertsTheImage() { var before = Before("<p id=x>a</p><style>#x::before { content: url(data:image/png;base64,iVBORw0KGgo=) }</style>"); @@ -125,6 +128,7 @@ public void Uri_InsertsTheImage() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void Quotes_SuppliesTheQuoteMarks() { Assert.AreEqual("<a>", Before("<p id=x style=\"quotes:'<' '>'\">a</p><style>#x::before { content: open-quote 'a' close-quote }</style>").Text); @@ -133,6 +137,7 @@ public void Quotes_SuppliesTheQuoteMarks() [TestMethod] [Spec("css21", "generate.html#quotes-insert")] + [Spec("css-content-3", "#quote-values")] public void OpenAndCloseQuote_InsertQuoteMarks() { var html = "<p><q id=x>a</q></p><style>q::before { content: open-quote } q::after { content: close-quote }</style>"; @@ -145,6 +150,7 @@ public void OpenAndCloseQuote_InsertQuoteMarks() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "generate.html#quotes-insert")] + [Spec("css-content-3", "#quote-values")] public void NestedQuote_UsesTheNextPair() { var html = "<p style=\"quotes:'1' '1' '2' '2'\"><q>a <q id=x>b</q></q></p>" @@ -156,6 +162,7 @@ public void NestedQuote_UsesTheNextPair() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "generate.html#quotes-insert")] + [Spec("css-content-3", "#quote-values")] public void CloseQuoteAtDepthZero_InsertsNothing() { Assert.AreEqual("a", Before("<p id=x>a</p><style>#x::before { content: close-quote 'a' }</style>").Text); diff --git a/Source/Test/HtmlRenderer.Test/Css/ContentPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/ContentPropertyTests.cs index 4d9a704bf..ac5c05395 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ContentPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ContentPropertyTests.cs @@ -3,8 +3,7 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/ContentProperty.cs. Pure CSSOM parse tests exercising the -/// "content" property's GCPM function support (content(), string(), counter(), attr()) - no layout involved. +/// Parsing of content values, no layout. /// </summary> [TestClass] public sealed class ContentPropertyTests @@ -17,6 +16,7 @@ private static StyleRule Parse(string source) } [TestMethod] + [Spec("css-syntax-3", "#consume-string-token")] public void CssContentParseStringWithDoubleQuoteEscape() { var source = "a{content:\"\\\"\"}"; @@ -25,6 +25,7 @@ public void CssContentParseStringWithDoubleQuoteEscape() } [TestMethod] + [Spec("css-syntax-3", "#consume-string-token")] public void CssContentParseStringWithSingleQuoteEscape() { var source = "a{content:'\\''}"; @@ -33,6 +34,7 @@ public void CssContentParseStringWithSingleQuoteEscape() } [TestMethod] + [Spec("css-syntax-3", "#consume-string-token")] public void CssContentParseStringWithDoubleQuoteMultipleEscapes() { var source = "a{content:\"abc\\\"\\\"d\\\"ef\"}"; @@ -41,6 +43,7 @@ public void CssContentParseStringWithDoubleQuoteMultipleEscapes() } [TestMethod] + [Spec("css-syntax-3", "#consume-string-token")] public void CssContentParseStringWithSingleQuoteMultipleEscapes() { var source = "a{content:'abc\\'\\'d\\'ef'}"; @@ -49,6 +52,7 @@ public void CssContentParseStringWithSingleQuoteMultipleEscapes() } [TestMethod] + [Spec("css-content-3", "#content-function")] public void CssContentParseContentFunctionText() { var source = "a::before{content:content(text)}"; @@ -57,6 +61,7 @@ public void CssContentParseContentFunctionText() } [TestMethod] + [Spec("css-content-3", "#content-function")] public void CssContentParseContentFunctionBefore() { var source = "a::before{content:content(before)}"; @@ -65,6 +70,7 @@ public void CssContentParseContentFunctionBefore() } [TestMethod] + [Spec("css-content-3", "#content-function")] public void CssContentParseContentFunctionAfter() { var source = "a::before{content:content(after)}"; @@ -73,6 +79,7 @@ public void CssContentParseContentFunctionAfter() } [TestMethod] + [Spec("css-content-3", "#content-function")] public void CssContentParseContentFunctionFirstLetter() { var source = "a::before{content:content(first-letter)}"; @@ -81,6 +88,7 @@ public void CssContentParseContentFunctionFirstLetter() } [TestMethod] + [Spec("css-content-3", "#content-function")] public void CssContentParseContentFunctionWithString() { var source = "a::before{content:\"Chapter \" content(text)}"; @@ -89,6 +97,7 @@ public void CssContentParseContentFunctionWithString() } [TestMethod] + [Spec("css-content-3", "#content-function")] public void CssContentParseContentFunctionWithCounter() { var source = "a::before{content:content(before) \" - \" counter(page)}"; @@ -97,6 +106,7 @@ public void CssContentParseContentFunctionWithCounter() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void CssContentParseStringFunction() { var source = "a::before{content:string(chapter)}"; @@ -105,6 +115,7 @@ public void CssContentParseStringFunction() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void CssContentParseStringFunctionWithFirstKeyword() { var source = "a::before{content:string(chapter, first)}"; @@ -113,6 +124,7 @@ public void CssContentParseStringFunctionWithFirstKeyword() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void CssContentParseStringFunctionWithLastKeyword() { var source = "a::before{content:string(chapter, last)}"; @@ -121,6 +133,7 @@ public void CssContentParseStringFunctionWithLastKeyword() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void CssContentParseStringFunctionWithStartKeyword() { var source = "a::before{content:string(chapter, start)}"; @@ -129,6 +142,7 @@ public void CssContentParseStringFunctionWithStartKeyword() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void CssContentParseStringFunctionWithFirstExceptKeyword() { var source = "a::before{content:string(chapter, first-except)}"; @@ -137,6 +151,7 @@ public void CssContentParseStringFunctionWithFirstExceptKeyword() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void CssContentParseStringFunctionWithStringLiteral() { var source = "a::before{content:\"Chapter: \" string(chapter)}"; @@ -145,6 +160,7 @@ public void CssContentParseStringFunctionWithStringLiteral() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void CssContentParseStringFunctionWithCounter() { var source = "a::before{content:string(chapter) \" - Page \" counter(page)}"; @@ -153,6 +169,7 @@ public void CssContentParseStringFunctionWithCounter() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void CssContentParseMultipleStringFunctions() { var source = "a::before{content:string(chapter) \" / \" string(section)}"; @@ -161,6 +178,7 @@ public void CssContentParseMultipleStringFunctions() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void CssContentParseStringFunctionWithContentFunction() { var source = "a::before{content:string(chapter) \": \" content(text)}"; @@ -169,6 +187,7 @@ public void CssContentParseStringFunctionWithContentFunction() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void CssContentParseStringFunctionWithAttr() { var source = "a::before{content:string(chapter) \" - \" attr(title)}"; @@ -177,6 +196,7 @@ public void CssContentParseStringFunctionWithAttr() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void CssContentParseComplexCombination() { var source = "a::before{content:\"Part \" string(part, first) \" - Chapter \" string(chapter) \" (Page \" counter(page) \")\"}"; diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyContentTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyContentTests.cs index d12d08454..9ba4df33a 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PropertyContentTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PropertyContentTests.cs @@ -98,6 +98,7 @@ public void CssColorNameIllegal() [TestMethod] [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#content-property")] public void CssContentNormalLegal() { var snippet = "content : normal "; @@ -114,6 +115,7 @@ public void CssContentNormalLegal() [TestMethod] [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#content-property")] public void CssContentNoneLegalUppercaseN() { var snippet = "content : noNe "; @@ -130,6 +132,7 @@ public void CssContentNoneLegalUppercaseN() [TestMethod] [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#content-values")] public void CssContentStringLegal() { var snippet = "content : 'hi' "; @@ -146,6 +149,7 @@ public void CssContentStringLegal() [TestMethod] [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#content-values")] public void CssContentNoOpenQuoteNoCloseQuoteLegal() { var snippet = "content : no-open-quote no-close-quote "; @@ -162,6 +166,7 @@ public void CssContentNoOpenQuoteNoCloseQuoteLegal() [TestMethod] [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#content-values")] public void CssContentUrlLegal() { var snippet = "content : url(test.html) "; @@ -178,6 +183,7 @@ public void CssContentUrlLegal() [TestMethod] [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#content-values")] public void CssContentStringsLegal() { var snippet = "content : 'how' 'are' 'you' "; @@ -194,6 +200,7 @@ public void CssContentStringsLegal() [TestMethod] [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void CssQuoteStringIllegal() { var snippet = "quotes : '\"' "; @@ -208,6 +215,7 @@ public void CssQuoteStringIllegal() [TestMethod] [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void CssQuoteStringsLegal() { var snippet = "quotes : '\"' '\"' "; @@ -224,6 +232,7 @@ public void CssQuoteStringsLegal() [TestMethod] [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void CssQuoteStringsIllegal() { var snippet = "quotes : \"'\""; @@ -238,6 +247,7 @@ public void CssQuoteStringsIllegal() [TestMethod] [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void CssQuoteStringsMultipleLegal() { // The fourth quote mark is a literal U+FFFD on purpose @@ -255,6 +265,7 @@ public void CssQuoteStringsMultipleLegal() [TestMethod] [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void CssQuoteStringsMultipleIllegal() { var snippet = "quotes : '\"' '\"' '`' "; @@ -269,6 +280,7 @@ public void CssQuoteStringsMultipleIllegal() [TestMethod] [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void CssQuoteNoneLegal() { var snippet = "quotes : none"; @@ -285,6 +297,7 @@ public void CssQuoteNoneLegal() [TestMethod] [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void CssQuoteNoneStringIllegal() { var snippet = "quotes : 'none'"; @@ -299,6 +312,7 @@ public void CssQuoteNoneStringIllegal() [TestMethod] [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void CssQuoteNormalIllegal() { var snippet = "quotes : normal "; diff --git a/Source/Test/HtmlRenderer.Test/Css/StringFunctionConverterTests.cs b/Source/Test/HtmlRenderer.Test/Css/StringFunctionConverterTests.cs index 5c1cd2bd4..e6aafa706 100644 --- a/Source/Test/HtmlRenderer.Test/Css/StringFunctionConverterTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/StringFunctionConverterTests.cs @@ -5,21 +5,13 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/ValueConverters/StringFunctionConverterTests.cs. -/// Tests for the value converter that parses the string() CSS function, which retrieves named strings -/// set by the string-set property: string(&lt;custom-ident&gt; [, [ first | start | last | first-except ] ]?). -/// The CSS engine port's <c>StringFunctionConverter</c> is wired into both <c>ContentProperty</c> and -/// <c>StringSetValueConverter</c>, matching PeachPDF's chain exactly. The token-level tests use -/// <see cref="CssValueParser.GetCssTokens"/>, this fork's internal equivalent of PeachPDF's -/// CssValueParser.GetCssTokens, accessible here via the InternalsVisibleTo("HtmlRenderer.Test") grant on -/// HtmlRenderer.csproj. Unlike PeachPDF's raw tokenizer, this fork's GetCssTokens already filters out -/// whitespace tokens - the ".Where(t => t.Type != TokenType.Whitespace)" filters below are therefore -/// no-ops here, kept only to mirror the source 1:1. +/// Parsing of string(). GetCssTokens already drops whitespace tokens, the whitespace filters below change nothing. /// </summary> [TestClass] public sealed class StringFunctionConverterTests { [TestMethod] + [Spec("css-content-3", "#string-function")] public void StringFunction_WithNameOnly_ParsesCorrectly() { const string input = "string(chapter)"; @@ -40,6 +32,7 @@ public void StringFunction_WithNameOnly_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void StringFunction_WithFirstKeyword_ParsesCorrectly() { const string input = "string(chapter, first)"; @@ -62,6 +55,7 @@ public void StringFunction_WithFirstKeyword_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void StringFunction_WithLastKeyword_ParsesCorrectly() { const string input = "string(chapter, last)"; @@ -78,6 +72,7 @@ public void StringFunction_WithLastKeyword_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void StringFunction_WithStartKeyword_ParsesCorrectly() { const string input = "string(chapter, start)"; @@ -94,6 +89,7 @@ public void StringFunction_WithStartKeyword_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void StringFunction_WithFirstExceptKeyword_ParsesCorrectly() { const string input = "string(chapter, first-except)"; @@ -110,6 +106,7 @@ public void StringFunction_WithFirstExceptKeyword_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void StringFunction_WithHyphenatedName_ParsesCorrectly() { const string input = "string(my-chapter-title)"; @@ -125,6 +122,7 @@ public void StringFunction_WithHyphenatedName_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void StringFunction_WithUnderscoreName_ParsesCorrectly() { const string input = "string(chapter_title)"; @@ -140,6 +138,7 @@ public void StringFunction_WithUnderscoreName_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void StringFunction_InContentProperty_ParsesCorrectly() { const string input = "content: string(chapter)"; @@ -152,6 +151,7 @@ public void StringFunction_InContentProperty_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void StringFunction_CombinedWithStringLiteral_ParsesCorrectly() { const string input = "content: \"Chapter: \" string(chapter)"; @@ -164,6 +164,7 @@ public void StringFunction_CombinedWithStringLiteral_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void StringFunction_CombinedWithCounter_ParsesCorrectly() { const string input = "content: string(chapter) \" - Page \" counter(page)"; @@ -176,6 +177,7 @@ public void StringFunction_CombinedWithCounter_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void StringFunction_MultipleInContent_ParsesCorrectly() { const string input = "content: string(chapter) \" / \" string(section)"; @@ -188,6 +190,7 @@ public void StringFunction_MultipleInContent_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void StringFunction_WithWhitespaceAroundComma_ParsesCorrectly() { const string input = "string(chapter , last)"; @@ -204,6 +207,7 @@ public void StringFunction_WithWhitespaceAroundComma_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-function")] public void StringFunction_CaseInsensitiveKeyword_ParsesCorrectly() { const string input = "string(chapter, LAST)"; diff --git a/Source/Test/HtmlRenderer.Test/Css/StringSetPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/StringSetPropertyTests.cs index f5417ae04..db7d6914f 100644 --- a/Source/Test/HtmlRenderer.Test/Css/StringSetPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/StringSetPropertyTests.cs @@ -4,16 +4,13 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/StringSetPropertyTests.cs. -/// <see cref="StringSetProperty"/> and its <c>StringSetValueConverter</c> fully implement "none", a -/// single name+content-list pair, comma-separated multiple pairs, and reject non-ident names/a missing -/// content-list, matching PeachPDF's grammar exactly. Its <c>content()</c> sub-converter collapses -/// "content(text)" to "content()" the same way PeachPDF's does. +/// Parsing of string-set. content(text) serializes as content(). /// </summary> [TestClass] public sealed class StringSetPropertyTests { [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSetNoneLegal() { var property = CssConstructionFunctions.ParseDeclaration("string-set: none"); @@ -27,6 +24,7 @@ public void StringSetNoneLegal() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSetSimpleTextLegal() { var property = CssConstructionFunctions.ParseDeclaration("string-set: chapter content(text)"); @@ -40,6 +38,7 @@ public void StringSetSimpleTextLegal() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSetWithStringLiteral() { var property = CssConstructionFunctions.ParseDeclaration("string-set: header \"Page \" counter(page)"); @@ -53,6 +52,7 @@ public void StringSetWithStringLiteral() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSetWithAttrFunction() { var property = CssConstructionFunctions.ParseDeclaration("string-set: title attr(title)"); @@ -66,6 +66,7 @@ public void StringSetWithAttrFunction() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSetContentBefore() { var property = CssConstructionFunctions.ParseDeclaration("string-set: heading content(before) \":\" content(text)"); @@ -79,6 +80,7 @@ public void StringSetContentBefore() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSetContentAfter() { var property = CssConstructionFunctions.ParseDeclaration("string-set: heading content(after)"); @@ -92,6 +94,7 @@ public void StringSetContentAfter() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSetContentFirstLetter() { var property = CssConstructionFunctions.ParseDeclaration("string-set: initial content(first-letter)"); @@ -105,6 +108,7 @@ public void StringSetContentFirstLetter() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSetMultiplePairs() { var property = CssConstructionFunctions.ParseDeclaration("string-set: header content(text), footer counter(page)"); @@ -118,6 +122,7 @@ public void StringSetMultiplePairs() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSetInvalidNoContentListIllegal() { var property = CssConstructionFunctions.ParseDeclaration("string-set: header"); @@ -130,6 +135,7 @@ public void StringSetInvalidNoContentListIllegal() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSetInvalidNumberIllegal() { var property = CssConstructionFunctions.ParseDeclaration("string-set: 123 content(text)"); diff --git a/Source/Test/HtmlRenderer.Test/Css/StringSetWithStringFunctionTests.cs b/Source/Test/HtmlRenderer.Test/Css/StringSetWithStringFunctionTests.cs index c087ca162..adb3918d0 100644 --- a/Source/Test/HtmlRenderer.Test/Css/StringSetWithStringFunctionTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/StringSetWithStringFunctionTests.cs @@ -4,16 +4,13 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/StringSetWithStringFunctionTests.cs. -/// Extended tests for the string-set property that specifically exercise the string() function within -/// its content-list. <see cref="StringSetProperty"/>'s content-list-item converter composes a -/// <c>StringFunctionConverter</c> the same way PeachPDF's does, so string(name[, first|last|start| -/// first-except]) inside string-set works identically. +/// Parsing of string() inside string-set. /// </summary> [TestClass] public sealed class StringSetWithStringFunctionTests { [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunction_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: section string(chapter)"); @@ -25,6 +22,7 @@ public void StringSet_WithStringFunction_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionAndKeyword_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: section string(chapter, first)"); @@ -37,6 +35,7 @@ public void StringSet_WithStringFunctionAndKeyword_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionAndLiteral_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: section string(chapter) \" - \" content(text)"); @@ -48,6 +47,7 @@ public void StringSet_WithStringFunctionAndLiteral_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSet_WithMultipleStringFunctions_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: full-title string(chapter) \" > \" string(section)"); @@ -59,6 +59,7 @@ public void StringSet_WithMultipleStringFunctions_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionLastKeyword_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: current-section string(section, last)"); @@ -70,6 +71,7 @@ public void StringSet_WithStringFunctionLastKeyword_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionStartKeyword_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: page-header string(chapter, start)"); @@ -81,6 +83,7 @@ public void StringSet_WithStringFunctionStartKeyword_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionFirstExceptKeyword_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: heading string(chapter, first-except)"); @@ -92,6 +95,7 @@ public void StringSet_WithStringFunctionFirstExceptKeyword_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionAndCounter_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: page-title string(chapter) \" (\" counter(page) \")\""); @@ -103,6 +107,7 @@ public void StringSet_WithStringFunctionAndCounter_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionAndAttr_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: full-heading string(chapter) \" - \" attr(title)"); @@ -114,6 +119,7 @@ public void StringSet_WithStringFunctionAndAttr_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionAndContentFunction_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: combined string(chapter) \": \" content(text)"); @@ -125,6 +131,7 @@ public void StringSet_WithStringFunctionAndContentFunction_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSet_MultipleNamesWithStringFunction_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: header string(chapter), footer string(section, last)"); @@ -136,6 +143,7 @@ public void StringSet_MultipleNamesWithStringFunction_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSet_ComplexNestedExample_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: breadcrumb string(part, first) \" / \" string(chapter) \" / \" content(text)"); @@ -151,6 +159,7 @@ public void StringSet_ComplexNestedExample_ParsesCorrectly() } [TestMethod] + [Spec("css-content-3", "#string-set")] public void StringSet_WithHyphenatedIdentifiers_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: my-heading string(my-chapter)"); diff --git a/Spec/css-content-3.yml b/Spec/css-content-3.yml index f8d5b6afa..277fd1f35 100644 --- a/Spec/css-content-3.yml +++ b/Spec/css-content-3.yml @@ -5,85 +5,104 @@ snapshot: https://www.w3.org/TR/2025/WD-css-content-3-20251204/ sections: '#content-property': title: '1 Inserting and Replacing Content: the content property' - status: unknown + status: partial + gaps: + - An <image> on an element doesn't replace the element + - "A ::before or ::after with 'content: none' or 'normal' still generates a box, see CSS 2.1 12.1" '#accessibility': title: 1.1 Accessibility of Generated Content - status: unknown + status: n/a '#alt': title: 1.2 Alternative Text for Accessibility - status: unknown + status: unsupported + gaps: + - A value with alt text after / is rejected, so the pseudo-element isn't generated at all '#content-values': title: '2 Generated Content Values: the <content-list> type' - status: unknown + status: partial '#strings': title: '2.1 Basic Strings: <string> and <attr()> values' - status: unknown + status: supported '#content-uri': title: '2.2 2D Images: <image> values' - status: unknown + status: parsed-only + gaps: + - url() and gradients insert nothing, see CSS 2.1 12.2 '#element-content': title: '2.3 Elemental Content: the contents keyword' - status: unknown + status: unsupported '#quotes': title: 2.4 Quotation Marks - status: unknown + status: partial '#quotes-property': title: '2.4.1 Quotation Mark System: the quotes property' - status: unknown + status: parsed-only + gaps: + - "'quotes' is ignored, see CSS 2.1 12.3.1" + - auto and match-parent are rejected '#quote-values': title: '2.4.2 Inserting Quotation Marks: the *-quote keywords' - status: unknown + status: partial + gaps: + - The quote depth isn't tracked, see CSS 2.1 12.3.2 '#leaders': title: 2.5 Leaders - status: unknown + status: unsupported '#leader-function': title: 2.5.1 The leader() function - status: unknown + status: unsupported '#leader-rules': title: 2.5.2 Rendering leaders - status: unknown + status: unsupported '#leader-alignment': title: 2.5.3 Procedure for rendering leaders - status: unknown + status: unsupported '#cross-references': title: 2.6 Cross References - status: unknown + status: unsupported '#target-counter': title: 2.6.1 The target-counter() function - status: unknown + status: unsupported '#target-counters': title: 2.6.2 The target-counters() function - status: unknown + status: unsupported '#target-text': title: 2.6.3 The target-text() function - status: unknown + status: unsupported '#named-strings': title: 2.7 Named Strings - status: unknown + status: parsed-only '#string-set': title: '2.7.1 Naming Strings: the string-set property' - status: unknown + status: parsed-only + gaps: + - string-set never assigns a named string + - A CSS-wide keyword is accepted as the name '#string-function': title: '2.7.2 Inserting Named Strings: the string() function' - status: unknown + status: parsed-only + gaps: + - string() inserts nothing '#content-function': title: 2.7.3 The content() function - status: unknown + status: parsed-only + gaps: + - content() inserts nothing '#counters': title: 2.8 Automatic Counters and Numbering - status: unknown + status: n/a '#bookmark-generation': title: 3 Bookmarks - status: unknown + status: unsupported '#bookmark-level': title: '3.1 Setting a Bookmark: the bookmark-level property' - status: unknown + status: unsupported '#bookmark-label': title: '3.2 Labelling a Bookmark: the bookmark-label property' - status: unknown + status: unsupported '#bookmark-state': title: '3.3 Initial Bookmark Toggle State: the bookmark-state property' - status: unknown + status: unsupported '#changes': title: 4 Changes - status: unknown + status: n/a From 55a1997da610b2a3ad7d38ea4d5a8cd39a61fa63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:05:51 +0100 Subject: [PATCH 073/150] Verify and document CSS Backgrounds and Borders Level 3 compliance --- .../CssEngine/BackgroundPositionGrammar.cs | 2 +- .../Core/CssEngine/BackgroundSizeGrammar.cs | 2 +- .../Core/CssEngine/BoxShadowGrammar.cs | 42 +- .../StyleProperties/Box/BoxShadowProperty.cs | 3 - .../BoxShadowValueConverter.cs | 8 +- .../HtmlRenderer/Core/Dom/CssBoxProperties.cs | 5 +- .../Handlers/BackgroundImageDrawHandler.cs | 2 +- .../Core/Handlers/BordersDrawHandler.cs | 10 +- .../BoxModel/BorderIntegrationTests.cs | 8 + .../BackgroundPaintIntegrationTests.cs | 20 +- .../Painting/BorderRadiusIntegrationTests.cs | 36 +- .../BorderStylePaintIntegrationTests.cs | 10 + .../Painting/CssBackgrounds3Tests.cs | 441 ++++++++++++++++++ .../Css/BackgroundPropertyTests.cs | 45 +- .../Css/BorderImagePropertyTests.cs | 39 +- .../Css/BorderRadiusPropertyTests.cs | 25 +- .../Css/BoxShadowGrammarTests.cs | 18 +- .../Css/CssBackgrounds3ParseTests.cs | 107 +++++ Spec/css-backgrounds-3.yml | 196 ++++---- 19 files changed, 827 insertions(+), 192 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds3Tests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Css/CssBackgrounds3ParseTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/BackgroundPositionGrammar.cs b/Source/HtmlRenderer/Core/CssEngine/BackgroundPositionGrammar.cs index 1cc522ca5..b3e52ec8f 100644 --- a/Source/HtmlRenderer/Core/CssEngine/BackgroundPositionGrammar.cs +++ b/Source/HtmlRenderer/Core/CssEngine/BackgroundPositionGrammar.cs @@ -3,7 +3,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Grammar of a <c>background-position</c> value: the 1 to 4 token forms, including edge offsets + /// Grammar of a <c>background-position</c> value, CSS Backgrounds 3 §2.6: the 1 to 4 token forms, including edge offsets /// like "right 20px bottom 10px". Used by <see cref="Converters.PointConverter"/> and /// <see cref="BasicShapeGrammar"/> to validate and serialize the value. /// </summary> diff --git a/Source/HtmlRenderer/Core/CssEngine/BackgroundSizeGrammar.cs b/Source/HtmlRenderer/Core/CssEngine/BackgroundSizeGrammar.cs index 4d6787684..9d56175fd 100644 --- a/Source/HtmlRenderer/Core/CssEngine/BackgroundSizeGrammar.cs +++ b/Source/HtmlRenderer/Core/CssEngine/BackgroundSizeGrammar.cs @@ -3,7 +3,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Grammar of a <c>background-size</c> value: <c>auto</c>, <c>cover</c>, <c>contain</c> or + /// Grammar of a <c>background-size</c> value, CSS Backgrounds 3 §2.9: <c>auto</c>, <c>cover</c>, <c>contain</c> or /// 1 to 2 length, percentage or auto tokens. Used by <see cref="Converters.BackgroundSizeConverter"/> /// to validate and serialize the value. /// </summary> diff --git a/Source/HtmlRenderer/Core/CssEngine/BoxShadowGrammar.cs b/Source/HtmlRenderer/Core/CssEngine/BoxShadowGrammar.cs index fcee98a0e..02661e877 100644 --- a/Source/HtmlRenderer/Core/CssEngine/BoxShadowGrammar.cs +++ b/Source/HtmlRenderer/Core/CssEngine/BoxShadowGrammar.cs @@ -5,23 +5,14 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Shared, layer-agnostic grammar for the CSS <c>box-shadow</c> value (CSS Backgrounds &amp; Borders - /// Level 3 §5): a comma-separated list of shadow layers, each - /// <c>[ inset? &amp;&amp; &lt;length&gt;{2,4} &amp;&amp; &lt;color&gt;? ]</c>. Like - /// <see cref="BasicShapeGrammar"/> / <see cref="AspectRatioGrammar"/>, it validates the grammar and - /// captures each layer's structure as <b>raw component strings</b> (never resolved numbers), so both - /// Layer A (the CSS-OM converter, which only needs to accept/reject and preserve the authored text) - /// and Layer B (the paint-time resolver in <c>TheArtOfDev.HtmlRenderer.Core</c>, which resolves each length - /// against the box's own font/size) share a single parser rather than re-implementing the grammar. + /// Grammar of a <c>box-shadow</c> value, CSS Backgrounds 3 §6.1. Keeps the components as authored + /// strings, em/rem lengths can only be resolved against the box. /// </summary> internal static class BoxShadowGrammar { /// <summary> - /// A single parsed shadow layer. Lengths and the color are kept as the authored component strings - - /// box-shadow lengths may be font-relative (<c>em</c>/<c>rem</c>), so only Layer B can resolve them. - /// <see cref="Blur"/> defaults to <c>"0"</c> and <see cref="Spread"/> to <c>"0"</c> when the author - /// omitted them; <see cref="Color"/> is null when omitted, meaning "use the element's own color" - /// (currentColor) at paint time. + /// One shadow layer. <see cref="Blur"/> and <see cref="Spread"/> default to <c>"0"</c>, a null + /// <see cref="Color"/> means currentcolor. /// </summary> internal sealed class ShadowLayer { @@ -33,15 +24,10 @@ internal sealed class ShadowLayer public string Color { get; set; } } - // The one color grammar (named/hex/rgb()/rgba()/hsl()/currentColor/transparent), reused so box-shadow - // never re-implements color parsing - matches the CLAUDE.md "one grammar per value" rule. private static readonly IValueConverter ColorValidator = Converters.ColorConverter.WithCurrentColor(); /// <summary> - /// Parses a <c>box-shadow</c> value's tokens into an ordered list of <see cref="ShadowLayer"/>s - /// (first-declared first), or returns null when the value is not a valid box-shadow. The literal - /// keyword <c>none</c> returns an <b>empty list</b> (no shadows), distinct from a null (invalid) - /// result. + /// The layers in declaration order, an empty list for <c>none</c>, or null if the value is invalid. /// </summary> internal static List<ShadowLayer> TryParse(IReadOnlyList<Token> tokens) { @@ -120,12 +106,8 @@ private static ShadowLayer ParseLayer(IReadOnlyList<Token> group) } /// <summary> - /// Validates the color component. The full color grammar (named/rgb()/rgba()/hsl()/currentColor) - /// is checked via the shared <see cref="ColorValidator"/>, but a hex color is special-cased: the - /// raw <c>CssValueParser.GetCssTokens</c> tokenizer Layer B feeds this grammar produces a bare - /// <see cref="TokenType.Hash"/> token for <c>#fff</c> (only the in-declaration-value tokenizer path - /// yields a <see cref="ColorToken"/> the converter understands), so accept a well-formed hex token - /// directly - otherwise a hex shadow color accepted by Layer A would be dropped at paint time. + /// Outside of a declaration value the tokenizer gives a hex color as a bare <see cref="TokenType.Hash"/> + /// token instead of a <see cref="ColorToken"/>, which <see cref="ColorValidator"/> doesn't accept. /// </summary> private static bool IsValidColor(IReadOnlyList<Token> colorTokens) { @@ -142,14 +124,8 @@ private static bool IsHexColor(string data) } /// <summary> - /// Merges a <c>#</c> delimiter followed by a number/dimension back into one hex-color token. The - /// <c>CssValueParser.GetCssTokens</c> tokenizer Layer B feeds this grammar is not in "value" mode, - /// so a <b>digit-leading</b> hex color (<c>#08f</c>, <c>#000</c>) is split into a bare - /// <see cref="TokenType.Delim"/> <c>#</c> plus a <see cref="TokenType.Number"/>/<see cref="TokenType.Dimension"/> - /// (e.g. <c>08f</c> becomes a dimension "08" + unit "f"), whereas a letter-leading hex (<c>#c00</c>) - /// becomes a single <see cref="TokenType.Hash"/> token. Without this, a digit-leading hex shadow color - /// accepted by Layer A (whose value-mode tokenizer yields a proper color token) would fail to re-parse - /// at paint time and the shadow would silently vanish. + /// Outside of a declaration value the tokenizer splits a hex color starting with a digit (<c>#08f</c>) + /// into a <c>#</c> delimiter and a number or dimension, this merges them back into one hash token. /// </summary> private static List<Token> NormalizeHexColorTokens(IReadOnlyList<Token> tokens) { diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Box/BoxShadowProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Box/BoxShadowProperty.cs index 6e3c2d7e9..2798696c0 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Box/BoxShadowProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Box/BoxShadowProperty.cs @@ -2,9 +2,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { internal sealed class BoxShadowProperty : Property { - // The box-shadow grammar (none | [ inset? && <length>{2,4} && <color>? ]#) is validated once by - // the shared BoxShadowGrammar via BoxShadowValueConverter; the authored text is preserved so the - // paint-time resolver (CssBox.PaintBoxShadows) sees exactly what was written. private static readonly IValueConverter StyleConverter = new BoxShadowValueConverter().OrDefault(); internal BoxShadowProperty() diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/BoxShadowValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/BoxShadowValueConverter.cs index d67adef47..049acc252 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/BoxShadowValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/BoxShadowValueConverter.cs @@ -4,12 +4,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Validates a <c>box-shadow</c> value through the shared <see cref="BoxShadowGrammar"/> - accepting - /// <c>none</c> or a comma-separated list of <c>[ inset? &amp;&amp; &lt;length&gt;{2,4} &amp;&amp; - /// &lt;color&gt;? ]</c> shadow layers, and rejecting everything else so an invalid value (e.g. - /// <c>box-shadow: banana</c>) is dropped at parse time. The authored value text is preserved verbatim - /// so the paint-time resolver (Layer B, <c>CssBox.PaintBoxShadows</c>) re-tokenizes and resolves the - /// same value against the box - a single grammar shared across both layers. + /// Validates a <c>box-shadow</c> value with <see cref="BoxShadowGrammar"/> and keeps the authored text. /// </summary> internal sealed class BoxShadowValueConverter : IValueConverter { @@ -31,7 +26,6 @@ public BoxShadowValue(IEnumerable<Token> tokens) Original = new TokenValue(tokens); } - // Preserve the authored text verbatim - Layer B re-parses and resolves this string. public string CssText => Original.Text; public TokenValue Original { get; } diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index 63a5401c5..a43fa9286 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs @@ -1547,9 +1547,8 @@ private static string SecondCssValue(string value) } /// <summary> - /// Computes overlap-reduced corner radii for the given rendering rectangle, per CSS Backgrounds - /// §4. Horizontal and vertical axes are reduced independently so that, e.g., two large radii on - /// the same edge never sum to more than that edge's length. + /// The corner radii for <paramref name="rect"/> after the overlap reduction, CSS Backgrounds 3 §4.5. + /// The horizontal and vertical radii are scaled separately. /// </summary> public BorderRadii ComputeRadii(RRect rect) { diff --git a/Source/HtmlRenderer/Core/Handlers/BackgroundImageDrawHandler.cs b/Source/HtmlRenderer/Core/Handlers/BackgroundImageDrawHandler.cs index 9a9421905..f1b500a66 100644 --- a/Source/HtmlRenderer/Core/Handlers/BackgroundImageDrawHandler.cs +++ b/Source/HtmlRenderer/Core/Handlers/BackgroundImageDrawHandler.cs @@ -23,7 +23,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Handlers internal static class BackgroundImageDrawHandler { /// <summary> - /// Draw the background image of the given box in the given rectangle, CSS 2.1 §14.2.1. + /// Draw the background image of the given box in the given rectangle, CSS Backgrounds 3 §2.3 to §2.6. /// The image is positioned and tiled within <paramref name="rectangle"/>, the border box. /// </summary> /// <param name="g">the device to draw into</param> diff --git a/Source/HtmlRenderer/Core/Handlers/BordersDrawHandler.cs b/Source/HtmlRenderer/Core/Handlers/BordersDrawHandler.cs index 2cd90b33f..6099b2242 100644 --- a/Source/HtmlRenderer/Core/Handlers/BordersDrawHandler.cs +++ b/Source/HtmlRenderer/Core/Handlers/BordersDrawHandler.cs @@ -19,7 +19,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Handlers { /// <summary> - /// Contains all the complex paint code to paint different style borders. + /// Paints the borders, CSS Backgrounds 3 §3 and the rounded corners of §4. /// </summary> internal static class BordersDrawHandler { @@ -72,7 +72,6 @@ public static void DrawBoxBorders(RGraphics g, CssBox box, RRect rect, bool isFi /// <param name="box">Box which the border corresponds</param> /// <param name="brush">the brush to use</param> /// <param name="rectangle">the bounding rectangle to draw in</param> - /// <returns>Beveled border path, null if there is no rounded corners</returns> public static void DrawBorder(Border border, RGraphics g, CssBox box, RBrush brush, RRect rectangle) { SetInOutsetRectanglePoints(border, box, rectangle, true, true); @@ -150,7 +149,6 @@ private static void DrawBorder(Border border, CssBox box, RGraphics g, RRect rec /// <param name="r">the rectangle the border is enclosing</param> /// <param name="isLineStart">Specifies if the border is for a starting line (no bevel on left)</param> /// <param name="isLineEnd">Specifies if the border is for an ending line (no bevel on right)</param> - /// <returns>Beveled border path, null if there is no rounded corners</returns> private static void SetInOutsetRectanglePoints(Border border, CssBox b, RRect r, bool isLineStart, bool isLineEnd) { switch (border) @@ -191,9 +189,9 @@ private static void SetInOutsetRectanglePoints(Border border, CssBox b, RRect r, } /// <summary> - /// Makes a border path for rounded borders.<br/> - /// To support rounded dotted/dashed borders we need to use arc in the border path.<br/> - /// Return null if the border is not rounded.<br/> + /// The path of a rounded border, stroked along the middle of the border so dotted and dashed + /// styles follow the curve. Null if the border has no rounded corner. The top and bottom borders + /// take the whole corner arcs, the left and right ones only where there's no top or bottom border. /// </summary> /// <param name="g">the device to draw into</param> /// <param name="border">Desired border</param> diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs index 6b7db4e1c..3fb2dc0d4 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs @@ -9,6 +9,7 @@ public sealed class BorderIntegrationTests { [TestMethod] [Spec("css21", "box.html#border-width-properties")] + [Spec("css-backgrounds-3", "#border-width")] public void WidthKeywords_GrowFromThinOverMediumToThick() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -25,6 +26,7 @@ public void WidthKeywords_GrowFromThinOverMediumToThick() [TestMethod] [Spec("css21", "box.html#border-width-properties")] + [Spec("css-backgrounds-3", "#border-width")] public void StyleNone_ComputesTheWidthToZero() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='b' style='border-width:5px;border-style:none'></div>")); @@ -37,6 +39,7 @@ public void StyleNone_ComputesTheWidthToZero() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "box.html#border-width-properties")] + [Spec("css-backgrounds-3", "#border-width")] public void StyleHidden_ComputesTheWidthToZero() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='b' style='border-width:5px;border-style:hidden'></div>")); @@ -47,6 +50,7 @@ public void StyleHidden_ComputesTheWidthToZero() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "box.html#border-width-properties")] + [Spec("css-backgrounds-3", "#border-width")] public void InitialWidth_IsMedium() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -57,6 +61,7 @@ public void InitialWidth_IsMedium() [TestMethod] [Spec("css21", "box.html#border-color-properties")] + [Spec("css-backgrounds-3", "#border-color")] public void TransparentColor_PaintsNothingVisible() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='b' style='border:2px solid transparent'></div>")); @@ -67,6 +72,7 @@ public void TransparentColor_PaintsNothingVisible() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "box.html#border-color-properties")] + [Spec("css-backgrounds-3", "#border-color")] public void InitialColor_IsTheElementsColor() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='b' style='color:rgb(10,20,30);border:2px solid'></div>")); @@ -76,6 +82,7 @@ public void InitialColor_IsTheElementsColor() [TestMethod] [Spec("css21", "box.html#border-shorthand-properties")] + [Spec("css-backgrounds-3", "#border-shorthands")] public void BorderShorthand_SetsAllFourSides() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='b' style='border:3px dashed red'></div>")); @@ -112,6 +119,7 @@ public void SplitRtlInline_PutsTheRightBorderOnTheFirstLine() [TestMethod] [Spec("css21", "box.html#border-shorthand-properties")] + [Spec("css-backgrounds-3", "#border-shorthands")] public void SideShorthand_OnlySetsThatSide() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='b' style='border-top:4px solid blue'></div>")); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs index d6ccf06f8..4fa00ccbf 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs @@ -19,6 +19,7 @@ public sealed class BackgroundPaintIntegrationTests [TestMethod] [Spec("css21", "colors.html#background-properties")] + [Spec("css-backgrounds-3", "#background-color")] public void BackgroundColor_PaintsASolidFillRect() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -36,6 +37,7 @@ public void BackgroundColor_PaintsASolidFillRect() [TestMethod] [Spec("css21", "colors.html#background-properties")] + [Spec("css-backgrounds-3", "#background-color")] public void BackgroundColorTransparent_PaintsNothing() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -113,6 +115,7 @@ public void Background_IsNotInherited() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "colors.html#background")] + [Spec("css-backgrounds-3", "#root-background")] public void RootBackground_CoversTheWholeCanvas() { var (_, container) = PaintHarness.Layout( @@ -128,6 +131,7 @@ public void RootBackground_CoversTheWholeCanvas() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "colors.html#background")] + [Spec("css-backgrounds-3", "#body-background")] public void BodyBackground_PropagatesToTheCanvas() { var (_, container) = PaintHarness.Layout( @@ -144,6 +148,7 @@ public void BodyBackground_PropagatesToTheCanvas() [TestMethod] [Spec("css21", "colors.html#background-properties")] + [Spec("css-backgrounds-3", "#background-image")] public void BackgroundImage_PaintsOnTopOfTheBackgroundColor() { var g = PaintBackground($"width:100px;height:50px;background-color:rgb(10,20,30);background-image:{Image};background-repeat:no-repeat"); @@ -157,6 +162,7 @@ public void BackgroundImage_PaintsOnTopOfTheBackgroundColor() [TestMethod] [Spec("css21", "colors.html#background-properties")] + [Spec("css-backgrounds-3", "#background-image")] public void BackgroundImageNone_PaintsNoImage() { var g = PaintBackground("width:100px;height:50px;background-image:none;background-repeat:no-repeat"); @@ -166,6 +172,7 @@ public void BackgroundImageNone_PaintsNoImage() [TestMethod] [Spec("css21", "colors.html#background-properties")] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatNoRepeat_DrawsOneCopyAtTheTopLeft() { var g = PaintBackground($"width:100px;height:50px;background-image:{Image};background-repeat:no-repeat"); @@ -179,6 +186,7 @@ public void BackgroundRepeatNoRepeat_DrawsOneCopyAtTheTopLeft() [DataRow("repeat-x", 0, 0, 100, ImageHeight)] [DataRow("repeat-y", 0, 0, ImageWidth, 50)] [Spec("css21", "colors.html#background-properties")] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeat_TilesAlongTheGivenAxes(string repeat, double x, double y, double width, double height) { var g = PaintBackground($"width:100px;height:50px;background-image:{Image};background-repeat:{repeat}"); @@ -194,6 +202,7 @@ public void BackgroundRepeat_TilesAlongTheGivenAxes(string repeat, double x, dou [TestMethod] [Spec("css21", "colors.html#background-properties")] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeat_TilingCoversTheBorderArea() { var g = PaintBackground($"width:100px;height:50px;border:5px solid black;background-image:{Image}"); @@ -212,6 +221,7 @@ public void BackgroundRepeat_TilingCoversTheBorderArea() [DataRow("0% 0%", 0, 0)] [Spec("css21", "colors.html#background-properties")] [Spec("css-values-4", "#position-parsing")] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPosition_Keywords(string position, double x, double y) { var g = PaintBackground($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-position:{position}"); @@ -228,7 +238,8 @@ public void BackgroundPosition_Keywords(string position, double x, double y) [DataRow("25% 50%", 15, 35)] [DataRow("10% 90%", 6, 63)] [Spec("css21", "colors.html#background-properties")] - [Spec("css-values-4", "#position-parsing")] + [Spec("css-values-4", "#position")] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPosition_Percentages(string position, double x, double y) { var g = PaintBackground($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-position:{position}"); @@ -244,7 +255,8 @@ public void BackgroundPosition_Percentages(string position, double x, double y) [DataRow("-5px 7px", -5, 7)] [DataRow("right 15px", 60, 15)] [Spec("css21", "colors.html#background-properties")] - [Spec("css-values-4", "#position-parsing")] + [Spec("css-values-4", "#position")] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPosition_Lengths(string position, double x, double y) { var g = PaintBackground($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-position:{position}"); @@ -257,6 +269,7 @@ public void BackgroundPosition_Lengths(string position, double x, double y) [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "colors.html#background-properties")] + [Spec("css-backgrounds-3", "#background-origin")] public void BackgroundPosition_IsRelativeToThePaddingBox() { var g = PaintBackground($"width:100px;height:100px;border:5px solid black;background-image:{Image};background-repeat:no-repeat"); @@ -269,6 +282,7 @@ public void BackgroundPosition_IsRelativeToThePaddingBox() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "colors.html#background-properties")] + [Spec("css-backgrounds-3", "#background-attachment")] public void BackgroundAttachmentFixed_PositionsTheImageAgainstTheViewport() { var g = PaintBackground($"margin-top:100px;width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-attachment:fixed"); @@ -279,6 +293,7 @@ public void BackgroundAttachmentFixed_PositionsTheImageAgainstTheViewport() [TestMethod] [Spec("css21", "colors.html#background-properties")] + [Spec("css-backgrounds-3", "#background")] public void BackgroundShorthand_SetsAllGivenLonghands() { var g = PaintBackground($"width:100px;height:100px;background:rgb(10,20,30) {Image} no-repeat right bottom"); @@ -291,6 +306,7 @@ public void BackgroundShorthand_SetsAllGivenLonghands() [TestMethod] [Spec("css21", "colors.html#background-properties")] + [Spec("css-backgrounds-3", "#background")] public void BackgroundShorthand_ResetsOmittedLonghandsToTheirInitialValues() { var g = PaintBackground($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background:rgb(10,20,30)"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderRadiusIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderRadiusIntegrationTests.cs index f6905c472..5daeb43f6 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderRadiusIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderRadiusIntegrationTests.cs @@ -4,32 +4,6 @@ namespace HtmlRenderer.IntegrationTest.Painting; -/// <summary> -/// Ported from PeachPDF.Tests/Integration/BorderRadiusIntegrationTests.cs. -/// Standard CSS <c>border-radius</c> (shorthand and all four longhands) used to be entirely unrecognized -/// by this fork's Core, which instead had a proprietary <c>corner-radius</c>/<c>corner-{nw,ne,se,sw}-radius</c> -/// mechanism (backed by <c>CssBoxProperties.CornerRadius</c>/<c>ActualCornerNw</c>/etc.) as its only way to -/// get rounded corners. The CSS engine port replaced that proprietary mechanism entirely with a real, -/// spec-compliant implementation (see Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs ~1257-1400): -/// - the standard 4-longhand model (<c>BorderTopLeftRadius</c>/<c>BorderTopRightRadius</c>/ -/// <c>BorderBottomRightRadius</c>/<c>BorderBottomLeftRadius</c>), each carrying independent X/Y radii -/// (elliptical corners, via a "h v" pair per longhand, or "/" in the shorthand); -/// - the <c>border-radius</c> shorthand's real CSS Backgrounds 3 5.5 1/2/3/4-value expansion, in the -/// correct top-left/top-right/bottom-right/bottom-left order (unlike the old proprietary shorthand's -/// NE/NW/SE/SW order); -/// - percentage-of-box-dimension resolution (<c>ActualBorderTopLeftRadiusX</c> resolves a percentage -/// against <c>Size.Width</c>, <c>...RadiusY</c> against <c>Size.Height</c> - not hardcoded to 0); and -/// - overlap reduction via <c>ComputeRadii</c>, exactly CSS Backgrounds 3 4's "scale every radius down -/// proportionally if adjacent corners would sum to more than an edge's length" algorithm. -/// So every case below - including the four that were previously <c>[Ignore]</c>d because border-radius -/// didn't exist at all - now genuinely passes against the real properties -/// (<c>ActualBorderTopLeftRadiusX/Y</c> etc., <c>IsRounded</c>, <c>ComputeRadii</c>), and this file also -/// ports the elliptical/percentage/overlap-reduction cases from PeachPDF's original test file that the -/// old proprietary mechanism had no representable equivalent for at all. The proprietary -/// <c>corner-radius</c>/<c>corner-*-radius</c> syntax and its backing <c>ActualCornerNw/Ne/Se/Sw</c>/ -/// <c>CornerRadius</c> members have been removed from Core entirely, so the "supplementary" tests this -/// file used to carry for them are gone too. -/// </summary> [DoNotParallelize] [TestClass] public sealed class BorderRadiusIntegrationTests @@ -39,6 +13,7 @@ public sealed class BorderRadiusIntegrationTests // ── Circular radii (symmetric X = Y) ──────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadius_Shorthand_SetsAllCorners() { var box = FindDivBox("border-radius:10px;border:1px solid black"); @@ -55,6 +30,7 @@ public void BorderRadius_Shorthand_SetsAllCorners() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadius_TwoValues_SetsOpposingCorners() { var box = FindDivBox("border-radius:10px 20px"); @@ -66,6 +42,7 @@ public void BorderRadius_TwoValues_SetsOpposingCorners() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadius_FourValues_SetsAllCornersIndividually() { var box = FindDivBox("border-radius:5px 10px 15px 20px"); @@ -77,6 +54,7 @@ public void BorderRadius_FourValues_SetsAllCornersIndividually() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderTopLeftRadius_Longhand_SetsOnlyTopLeft() { var box = FindDivBox("border-top-left-radius:12px"); @@ -90,6 +68,7 @@ public void BorderTopLeftRadius_Longhand_SetsOnlyTopLeft() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadius_Zero_IsNotRounded() { var box = FindDivBox(""); @@ -99,6 +78,7 @@ public void BorderRadius_Zero_IsNotRounded() // ── Elliptical radii (X != Y) ─────────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadius_EllipticalShorthand_SetsAllCornersXAndY() { // border-radius: 40px / 15px -> each corner: X=40, Y=15 @@ -115,6 +95,7 @@ public void BorderRadius_EllipticalShorthand_SetsAllCornersXAndY() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderTopLeftRadius_Longhand_EllipticalValues() { // border-top-left-radius: 15px 25px -> X=15, Y=25 @@ -128,6 +109,7 @@ public void BorderTopLeftRadius_Longhand_EllipticalValues() // ── Percentage values ──────────────────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadius_Percentage_ResolvesRelativeToDimensions() { // 200x100 box; border-radius: 50% -> X = 50% of 200 = 100, Y = 50% of 100 = 50 @@ -140,6 +122,7 @@ public void BorderRadius_Percentage_ResolvesRelativeToDimensions() // ── Overlapping radii reduction ───────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css-backgrounds-3", "#corner-overlap")] public void BorderRadius_OverlappingRadii_AreReducedProportionally() { // 100x100 box; border-radius: 60px - adjacent radii sum to 120 > 100, so all must scale by @@ -157,6 +140,7 @@ public void BorderRadius_OverlappingRadii_AreReducedProportionally() } [TestMethod] + [Spec("css-backgrounds-3", "#corner-overlap")] public void BorderRadius_NonOverlappingRadii_AreNotChanged() { // 200x200 box; border-radius: 30px - 30+30=60 < 200, no reduction. diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs index 0e78bf4d0..f2126d468 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs @@ -31,6 +31,7 @@ public void BorderStyle_AllCss1Keywords_DoNotThrowWhenPainted(string style) [DataRow("inset", 50, 100)] [DataRow("outset", 100, 50)] [Spec("css21", "box.html#border-style-properties")] + [Spec("css-backgrounds-3", "#border-style")] public void InsetAndOutset_DarkenOppositeSides(string style, int topLeft, int bottomRight) { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -46,6 +47,7 @@ public void InsetAndOutset_DarkenOppositeSides(string style, int topLeft, int bo [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "box.html#border-style-properties")] + [Spec("css-backgrounds-3", "#border-style")] public void BorderStyleDouble_DrawsTwoEqualWidthStripesWithGap() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -62,6 +64,7 @@ public void BorderStyleDouble_DrawsTwoEqualWidthStripesWithGap() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "box.html#border-style-properties")] + [Spec("css-backgrounds-3", "#border-style")] public void BorderStyleGroove_OuterStripeIsDarker_InnerStripeIsBaseColor() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -79,6 +82,7 @@ public void BorderStyleGroove_OuterStripeIsDarker_InnerStripeIsBaseColor() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "box.html#border-style-properties")] + [Spec("css-backgrounds-3", "#border-style")] public void BorderRightStyleDouble_DrawsTwoEqualWidthVerticalStripesWithGap() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -96,6 +100,7 @@ public void BorderRightStyleDouble_DrawsTwoEqualWidthVerticalStripesWithGap() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "box.html#border-style-properties")] + [Spec("css-backgrounds-3", "#border-style")] public void BorderLeftStyleGroove_OuterStripeIsDarker_InnerStripeIsBaseColor() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -112,6 +117,7 @@ public void BorderLeftStyleGroove_OuterStripeIsDarker_InnerStripeIsBaseColor() [TestMethod] [Spec("css21", "box.html#border-color-properties")] + [Spec("css-backgrounds-3", "#border-color")] public void BorderColorPerSide_ResolvesDistinctColorPerEdge() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -157,6 +163,7 @@ public void BorderColorPerSide_CurrentColor_ResolvesToElementColor() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "box.html#border-style-properties")] + [Spec("css-backgrounds-3", "#border-style")] public void BorderStyleRidge_IsMirrorImageOfGroove() { var (grooveRoot, grooveContainer) = PaintHarness.Layout(PaintHarness.Wrap( @@ -196,6 +203,7 @@ public void BorderStyleDoubleWithRoundedCorners_FallsBackToASingleUnstripedStrok [TestMethod] [Spec("css21", "box.html#border-style-properties")] + [Spec("css-backgrounds-3", "#border-style")] public void BorderStyleTwoValueShorthand_OnlyPaintsTheSolidSides() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -217,6 +225,7 @@ public void BorderStyleTwoValueShorthand_OnlyPaintsTheSolidSides() [TestMethod] [Spec("css21", "box.html#border-color-properties")] + [Spec("css-backgrounds-3", "#border-color")] public void BorderColorFourValueShorthand_ResolvesTopRightBottomLeftPerSide() { var (root, _) = PaintHarness.Layout(PaintHarness.Wrap( @@ -231,6 +240,7 @@ public void BorderColorFourValueShorthand_ResolvesTopRightBottomLeftPerSide() [TestMethod] [Spec("css21", "box.html#border-width-properties")] + [Spec("css-backgrounds-3", "#border-width")] public void BorderWidthTwoValueShorthand_ThenLaterOneValue_OverridesAllSidesPerSpecificity() { var (root, _) = PaintHarness.Layout( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds3Tests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds3Tests.cs new file mode 100644 index 000000000..10ab9ed59 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds3Tests.cs @@ -0,0 +1,441 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[TestClass] +public sealed class CssBackgrounds3Tests +{ + // MockAdapter decodes every image as 40x30 + private const string Image = "url(data:image/png;base64,iVBORw0KGgo=)"; + + private static readonly RColor Fill = RColor.FromArgb(10, 20, 30); + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + private static readonly RColor Blue = RColor.FromArgb(0, 0, 255); + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#layering")] + public void MultipleLayers_PaintEveryImage() + { + var g = Paint($"width:100px;height:100px;background-image:{Image}, {Image};background-repeat:no-repeat;background-position:left top, right bottom"); + + Assert.AreEqual(2, g.DrawImageCalls.Count); + } + + [TestMethod] + [Spec("css-backgrounds-3", "#background-color")] + public void BackgroundColor_IsPaintedBeneathTheImage() + { + var g = Paint($"width:100px;height:50px;background-color:rgb(10,20,30);background-image:{Image};background-repeat:no-repeat"); + + var fill = g.Log.FindIndex(c => c is RecordingGraphics.DrawRectCall r && r.Color == Fill); + var image = g.Log.FindIndex(c => c is RecordingGraphics.DrawImageCall); + Assert.IsTrue(fill >= 0 && fill < image); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#background-color")] + public void BackgroundColor_IsPaintedBeneathAGradient() + { + var g = Paint("width:100px;height:50px;background-color:rgb(10,20,30);background-image:linear-gradient(red, blue)"); + + Assert.IsTrue(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == Fill)); + } + + [TestMethod] + [Spec("css-backgrounds-3", "#background-image")] + public void BackgroundImage_LinearGradientFillsTheBox() + { + var g = Paint("width:100px;height:50px;background-image:linear-gradient(red, blue)"); + + var fill = g.Log.OfType<RecordingGraphics.DrawRectCall>().Single(); + Assert.AreEqual(Red, fill.Color); + Assert.AreEqual(new RRect(0, 0, 100, 50), new RRect(fill.X, fill.Y, fill.Width, fill.Height)); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#background-image")] + public void BackgroundImage_RadialGradientIsPainted() + { + var g = Paint("width:100px;height:50px;background-image:radial-gradient(red, blue)"); + + Assert.IsTrue(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any()); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [DataRow("repeat no-repeat", 0, 0, 100, 30)] + [DataRow("no-repeat repeat", 0, 0, 40, 50)] + [Spec("css-backgrounds-3", "#background-repeat")] + public void BackgroundRepeat_TwoValueSyntax(string repeat, double x, double y, double width, double height) + { + var g = Paint($"width:100px;height:50px;background-image:{Image};background-repeat:{repeat}"); + + var tiles = g.Log.OfType<RecordingGraphics.DrawRectCall>().Single(r => r.Color == RColor.Empty); + Assert.AreEqual(new RRect(x, y, width, height), new RRect(tiles.X, tiles.Y, tiles.Width, tiles.Height)); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] + public void BackgroundRepeatSpace_DistributesWholeTilesWithSpaceBetween() + { + var g = Paint($"width:100px;height:30px;background-image:{Image};background-repeat:space no-repeat"); + + var xs = g.DrawImageCalls.Select(c => c.DestRect.X).OrderBy(x => x).ToList(); + CollectionAssert.AreEqual(new[] { 0.0, 60.0 }, xs); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] + public void BackgroundRepeatRound_ScalesTheTileToFitAWholeNumberOfTimes() + { + var g = Paint($"width:110px;height:30px;background-image:{Image};background-repeat:round no-repeat"); + + Assert.AreEqual(3, g.DrawImageCalls.Count); + Assert.AreEqual(110.0 / 3, g.DrawImageCalls[0].DestRect.Width, 0.5); + } + + [TestMethod] + [Spec("css-backgrounds-3", "#background-attachment")] + public void BackgroundAttachmentScroll_FixesTheImageToTheBox() + { + var g = Paint($"margin-top:100px;width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-attachment:scroll"); + + Assert.AreEqual(100, g.DrawImageCalls.Single().DestRect.Y, 0.5); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [DataRow("right 10px bottom 20px", 50, 50)] + [DataRow("left 10px top 15px", 10, 15)] + [DataRow("right 10px top", 50, 0)] + [DataRow("center bottom 10px", 30, 60)] + [Spec("css-backgrounds-3", "#background-position")] + public void BackgroundPosition_EdgeOffsets(string position, double x, double y) + { + var g = Paint($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-position:{position}"); + + var image = g.DrawImageCalls.Single(); + Assert.AreEqual(x, image.DestRect.X, 0.5); + Assert.AreEqual(y, image.DestRect.Y, 0.5); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [DataRow("padding-box", 5, 5, 100, 50)] + [DataRow("content-box", 15, 15, 80, 30)] + [Spec("css-backgrounds-3", "#background-clip")] + public void BackgroundClip_ClipsTheBackgroundToTheGivenBox(string clip, double x, double y, double width, double height) + { + var g = Paint($"width:80px;height:30px;padding:10px;border:5px solid black;background-color:rgb(10,20,30);background-clip:{clip}"); + + var fill = g.Log.OfType<RecordingGraphics.DrawRectCall>().Single(r => r.Color == Fill); + Assert.AreEqual(new RRect(x, y, width, height), new RRect(fill.X, fill.Y, fill.Width, fill.Height)); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [DataRow("content-box", 15, 15)] + [Spec("css-backgrounds-3", "#background-origin")] + public void BackgroundOrigin_PositionsTheImageInTheGivenBox(string origin, double x, double y) + { + var g = Paint($"width:80px;height:80px;padding:10px;border:5px solid black;background-image:{Image};background-repeat:no-repeat;background-origin:{origin}"); + + var image = g.DrawImageCalls.Single(); + Assert.AreEqual(x, image.DestRect.X, 0.5); + Assert.AreEqual(y, image.DestRect.Y, 0.5); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [DataRow("20px 10px", 20, 10)] + [DataRow("50%", 50, 37.5)] + [DataRow("auto 60px", 80, 60)] + [DataRow("contain", 100, 75)] + [DataRow("cover", 133.3, 100)] + [Spec("css-backgrounds-3", "#background-size")] + public void BackgroundSize_ScalesTheImage(string size, double width, double height) + { + var g = Paint($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-size:{size}"); + + var image = g.DrawImageCalls.Single(); + Assert.AreEqual(width, image.DestRect.Width, 0.5); + Assert.AreEqual(height, image.DestRect.Height, 0.5); + } + + [TestMethod] + [Spec("css-backgrounds-3", "#background")] + public void BackgroundShorthand_AcceptsPositionAndSize() + { + var g = Paint($"width:100px;height:100px;background:{Image} no-repeat right bottom / 20px 10px rgb(10,20,30)"); + + Assert.IsTrue(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == Fill)); + Assert.AreEqual(1, g.DrawImageCalls.Count); + } + + [TestMethod] + [Spec("css-backgrounds-3", "#root-background")] + public void RootBackground_IsPainted() + { + var (_, container) = PaintHarness.Layout( + "<!DOCTYPE html><html style='background-color:rgb(10,20,30)'><body style='margin:0'><div style='height:10px'></div></body></html>", + 500, 300); + + var g = PaintHarness.PaintPage(container); + + Assert.AreEqual(1, g.Log.OfType<RecordingGraphics.DrawRectCall>().Count(r => r.Color == Fill)); + } + + [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] + public void BorderRadius_PercentagesReferToTheBorderBox() + { + var box = LayoutDiv("width:100px;height:50px;padding:10px;border:5px solid black;border-radius:50%"); + + Assert.AreEqual(65, box.ActualBorderTopLeftRadiusX, 0.5); + Assert.AreEqual(40, box.ActualBorderTopLeftRadiusY, 0.5); + } + + [TestMethod] + [Spec("css-backgrounds-3", "#corner-shaping")] + public void RoundedBackground_FollowsTheOuterBorderEdgeCurve() + { + var g = PaintShapes("width:100px;height:100px;border:4px solid black;background-color:rgb(10,20,30);border-radius:10px 20px"); + + var path = g.Paths.Single(p => p.Brush == Fill); + Assert.AreEqual(new RPoint(10, 0), path.Points[0]); + CollectionAssert.AreEqual(new[] { 20.0, 10.0, 20.0, 10.0 }, path.Arcs.Select(a => a.RadiusX).ToArray()); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#corner-shaping")] + public void RoundedBorder_FollowsTheOuterAndInnerBorderCurves() + { + var g = PaintShapes("width:100px;height:100px;border:4px solid black;border-radius:10px"); + + // The stroke runs along the middle of the border, a circle of 10 - 4/2 around the corner's center at (10,10) + var top = g.Paths.First(p => p.Brush == null); + Assert.AreEqual(new RPoint(2, 10), top.Points[0]); + Assert.AreEqual(8, top.Arcs[0].RadiusX, 0.01); + } + + [TestMethod] + [Spec("css-backgrounds-3", "#corner-clipping")] + public void RoundedCorners_ClipTheBackgroundColor() + { + var g = PaintShapes("width:100px;height:100px;background-color:rgb(10,20,30);border-radius:10px"); + + var path = g.Paths.Single(p => p.Brush == Fill); + Assert.AreEqual(4, path.Arcs.Count); + Assert.IsFalse(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == Fill)); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#corner-clipping")] + public void RoundedCorners_ClipTheBackgroundImage() + { + var g = PaintShapes($"width:100px;height:100px;background-image:{Image};border-radius:10px"); + + Assert.IsFalse(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == RColor.Empty), + "the tiled image is painted as a plain rectangle"); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#corner-clipping")] + public void RoundedCorners_ClipOverflowingContentToThePaddingEdgeCurve() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "<div id='b' style='width:100px;height:100px;overflow:hidden;border-radius:50px'>" + + "<div style='height:100px;background-color:rgb(10,20,30)'></div></div>")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + + Assert.IsFalse(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == Fill), + "the child's background is only clipped to the rectangular padding box"); + } + + [TestMethod] + [Spec("css-backgrounds-3", "#corner-transitions")] + public void AdjoiningInsetBorders_MeetAtTheDiagonal() + { + var g = PaintShapes("width:20px;height:20px;border-style:inset;border-width:10px;border-color:red blue"); + + var top = g.Log.OfType<RecordingGraphics.DrawPolygonCall>().Single(p => p.Points.All(pt => pt.Y <= 10)); + CollectionAssert.AreEquivalent(new[] { new RPoint(0, 0), new RPoint(40, 0), new RPoint(30, 10), new RPoint(10, 10) }, top.Points); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#corner-transitions")] + public void AdjoiningBorders_MeetAtTheDiagonal() + { + var g = PaintShapes("width:20px;height:20px;border-style:solid;border-width:10px;border-color:red blue"); + + // The top border is a trapezoid from the outer corners to the inner ones + var top = g.Log.OfType<RecordingGraphics.DrawPolygonCall>().Single(p => p.Color == Red && p.Points.All(pt => pt.Y <= 10)); + CollectionAssert.AreEquivalent(new[] { new RPoint(0, 0), new RPoint(40, 0), new RPoint(30, 10), new RPoint(10, 10) }, top.Points); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#corner-transitions")] + public void RoundedCorner_TransitionsBetweenTheAdjoiningBorderColors() + { + var g = PaintShapes("width:100px;height:100px;border-style:solid;border-width:4px;border-color:red blue;border-radius:10px"); + + // Each border's path ends halfway around the corner, the top one doesn't run the full arc + var top = g.Paths.First(p => p.Pen == Red); + Assert.IsTrue(top.Points[0].X > 2, $"the top border starts at the left edge, {top.Points[0]}"); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#corner-overlap")] + public void OverlappingRadii_AreAllScaledByTheSameFactor() + { + var box = LayoutDiv("width:200px;height:100px;border-radius:60px"); + + var radii = box.ComputeRadii(new RRect(0, 0, 200, 100)); + + Assert.AreEqual(50, radii.TLX, 0.5); + Assert.AreEqual(50, radii.TLY, 0.5); + } + + [TestMethod] + [Spec("css-backgrounds-3", "#border-radius-tables")] + public void BorderRadius_RoundsASeparatedBordersTable() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "<table id='t' style='border-collapse:separate;border-radius:10px;background-color:rgb(10,20,30)'><tr><td>x</td></tr></table>")); + var g = new ShapeRecordingGraphics(); + + PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!, g); + + Assert.AreEqual(4, g.Paths.Single(p => p.Brush == Fill).Arcs.Count); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#border-radius-tables")] + public void BorderRadius_DoesNotApplyToACollapsingBordersTable() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "<table id='t' style='border-collapse:collapse;border-radius:10px;background-color:rgb(10,20,30)'><tr><td>x</td></tr></table>")); + var g = new ShapeRecordingGraphics(); + + PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!, g); + + Assert.IsFalse(g.Paths.Any(p => p.Brush == Fill)); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#border-images")] + public void BorderImage_ReplacesTheBorderStyle() + { + var g = Paint($"width:100px;height:100px;border:10px solid red;border-image:{Image} 10"); + + Assert.IsFalse(g.Log.OfType<RecordingGraphics.DrawLineCall>().Any(l => l.Color == Red)); + Assert.IsTrue(g.DrawImageCalls.Any()); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] + public void BoxShadow_PaintsAShadowBeneathTheBackground() + { + var g = Paint("width:100px;height:50px;background-color:rgb(10,20,30);box-shadow:5px 5px blue"); + + var shadow = g.Log.FindIndex(c => c is RecordingGraphics.DrawRectCall r && r.Color == Blue); + var background = g.Log.FindIndex(c => c is RecordingGraphics.DrawRectCall r && r.Color == Fill); + Assert.IsTrue(shadow >= 0 && shadow < background); + } + + /// <summary>Lays out a div with <paramref name="style"/> at the origin, loading images synchronously.</summary> + private static (HtmlContainerInt Container, TheArtOfDev.HtmlRenderer.Core.Dom.CssBox Box) LayoutDivWithContainer(string style) + { + var container = new HtmlContainerInt(new MockAdapter()) + { + MaxSize = new RSize(1000, 4000), + Location = RPoint.Empty, + PageSize = new RSize(1000, 4000), + AvoidAsyncImagesLoading = true + }; + container.SetHtml(PaintHarness.Wrap($"<div id='b' style='{style}'></div>")).GetAwaiter().GetResult(); + + using (var layoutGraphics = new RecordingGraphics()) + container.PerformLayout(layoutGraphics); + + return (container, PaintHarness.FindById(container.Root!, "b")!); + } + + private static TheArtOfDev.HtmlRenderer.Core.Dom.CssBox LayoutDiv(string style) => LayoutDivWithContainer(style).Box; + + private static RecordingGraphics Paint(string style) + { + var (container, box) = LayoutDivWithContainer(style); + return PaintHarness.PaintBox(container, box); + } + + private static ShapeRecordingGraphics PaintShapes(string style) + { + var (container, box) = LayoutDivWithContainer(style); + var g = new ShapeRecordingGraphics(); + PaintHarness.PaintBox(container, box, g); + return g; + } + + internal sealed record Arc(RPoint End, double RadiusX, double RadiusY); + + internal sealed class ShapePath : RGraphicsPath + { + public List<RPoint> Points { get; } = []; + public List<Arc> Arcs { get; } = []; + public RColor? Brush { get; set; } + public RColor? Pen { get; set; } + + public override void Start(double x, double y) => Points.Add(new RPoint(x, y)); + public override void LineTo(double x, double y) => Points.Add(new RPoint(x, y)); + + public override void ArcTo(double x, double y, double radiusX, double radiusY, Corner corner) + { + Points.Add(new RPoint(x, y)); + Arcs.Add(new Arc(new RPoint(x, y), radiusX, radiusY)); + } + + public override void Dispose() { } + } + + /// <summary>Also records the paths, which <see cref="RecordingGraphics"/> drops.</summary> + internal sealed class ShapeRecordingGraphics : RecordingGraphics + { + public List<ShapePath> Paths { get; } = []; + + public override RGraphicsPath GetGraphicsPath() => new ShapePath(); + + public override void DrawPath(RPen pen, RGraphicsPath path) + { + var shape = (ShapePath)path; + shape.Pen = pen is MockPen p ? p.Color : RColor.Empty; + Paths.Add(shape); + } + + public override void DrawPath(RBrush brush, RGraphicsPath path) + { + var shape = (ShapePath)path; + shape.Brush = brush is MockBrush b ? b.Color : RColor.Empty; + Paths.Add(shape); + } + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/BackgroundPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/BackgroundPropertyTests.cs index ca8791255..9b7e236b4 100644 --- a/Source/Test/HtmlRenderer.Test/Css/BackgroundPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/BackgroundPropertyTests.cs @@ -7,6 +7,7 @@ namespace HtmlRenderer.Test.Css; public sealed class BackgroundPropertyTests { [TestMethod] + [Spec("css-backgrounds-3", "#background-attachment")] public void BackgroundAttachmentScrollLegal() { var snippet = "background-attachment : scroll"; @@ -35,6 +36,7 @@ public void BackgroundAttachmentInitialLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-attachment")] public void BackgroundAttachmentFixedUppercaseLegal() { var snippet = "background-attachment : Fixed "; @@ -49,6 +51,7 @@ public void BackgroundAttachmentFixedUppercaseLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-attachment")] public void BackgroundAttachmentFixedLocalLegal() { var snippet = "background-attachment : fixed , local "; @@ -63,6 +66,7 @@ public void BackgroundAttachmentFixedLocalLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-attachment")] public void BackgroundAttachmentFixedLocalScrollScrollLegal() { var snippet = "background-attachment : fixed , local,scroll,scroll "; @@ -77,6 +81,7 @@ public void BackgroundAttachmentFixedLocalScrollScrollLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-attachment")] public void BackgroundAttachmentNoneIllegal() { var snippet = "background-attachment : none "; @@ -90,6 +95,7 @@ public void BackgroundAttachmentNoneIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-clip")] public void BackgroundClipPaddingBoxUppercaseLegal() { var snippet = "background-clip : Padding-Box "; @@ -104,6 +110,7 @@ public void BackgroundClipPaddingBoxUppercaseLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-clip")] public void BackgroundClipPaddingBoxBorderBoxLegal() { var snippet = "background-clip : Padding-Box, border-box "; @@ -118,6 +125,7 @@ public void BackgroundClipPaddingBoxBorderBoxLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-clip")] public void BackgroundClipContentBoxLegal() { var snippet = "background-clip : content-box"; @@ -227,8 +235,7 @@ public void BackgroundImageUrlLegal() [DataRow("background-image: element(#hero)")] public void BackgroundImageExtendedFunctionLegal(string snippet) { - // image-set()/cross-fade()/element() are valid <image> values (CSS Images 4) and now parse via the - // shared ImageSourceConverter, even though PeachPDF renders nothing for them (issue #229 gap 3). + // Valid <image> values (CSS Images 4), parsed even though nothing is rendered for them. var property = CssConstructionFunctions.ParseDeclaration(snippet); Assert.AreEqual("background-image", property.Name); Assert.IsInstanceOfType<BackgroundImageProperty>(property); @@ -290,6 +297,7 @@ public void BackgroundImageUrlNoneUrlLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-origin")] public void BackgroundOriginContentBoxLegal() { var snippet = "background-origin: CONTENT-BOX"; @@ -304,6 +312,7 @@ public void BackgroundOriginContentBoxLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-origin")] public void BackgroundOriginContentBoxPaddingBoxLegal() { var snippet = "background-origin: CONTENT-BOX, Padding-Box"; @@ -318,6 +327,7 @@ public void BackgroundOriginContentBoxPaddingBoxLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-origin")] public void BackgroundOriginBorderBoxLegal() { var snippet = "background-origin: border-box"; @@ -332,6 +342,7 @@ public void BackgroundOriginBorderBoxLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPositionTopLegal() { var snippet = "background-position: top"; @@ -346,6 +357,7 @@ public void BackgroundPositionTopLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPositionPercentPercentLegal() { var snippet = "background-position: 25% 75%"; @@ -360,6 +372,7 @@ public void BackgroundPositionPercentPercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPositionCenterPercentLegal() { var snippet = "background-position: center 75%"; @@ -374,6 +387,7 @@ public void BackgroundPositionCenterPercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPositionRightLengthBottomLengthLegal() { var snippet = "background-position: right 20px bottom 20px"; @@ -388,6 +402,7 @@ public void BackgroundPositionRightLengthBottomLengthLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPositionLengthLengthCenterMultipleLegal() { var snippet = "background-position: 10px 20px, center"; @@ -402,6 +417,7 @@ public void BackgroundPositionLengthLengthCenterMultipleLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPositionZeroMultipleLegal() { var snippet = "background-position: 0 0, 0 0"; @@ -416,6 +432,7 @@ public void BackgroundPositionZeroMultipleLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRepeatXLegal() { var snippet = "background-repeat: repeat-x"; @@ -430,6 +447,7 @@ public void BackgroundRepeatRepeatXLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRepeatYLegal() { var snippet = "background-repeat: repeat-y"; @@ -444,6 +462,7 @@ public void BackgroundRepeatRepeatYLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRepeatLegal() { var snippet = "background-repeat: REPEAT"; @@ -458,6 +477,7 @@ public void BackgroundRepeatRepeatLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRoundLegal() { var snippet = "background-repeat: rounD"; @@ -472,6 +492,7 @@ public void BackgroundRepeatRoundLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRepeatSpaceLegal() { var snippet = "background-repeat: repeat space"; @@ -486,6 +507,7 @@ public void BackgroundRepeatRepeatSpaceLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRepeatXSpaceIllegal() { var snippet = "background-repeat: repeat-x space"; @@ -499,6 +521,7 @@ public void BackgroundRepeatRepeatXSpaceIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRepeatXRepeatYMultipleLegal() { var snippet = "background-repeat: repeat-X, repeat-Y"; @@ -513,6 +536,7 @@ public void BackgroundRepeatRepeatXRepeatYMultipleLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatSpaceRoundLegal() { var snippet = "background-repeat: space round"; @@ -527,6 +551,7 @@ public void BackgroundRepeatSpaceRoundLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatNoRepeatRepeatXIllegal() { var snippet = "background-repeat: no-repeat repeat-x"; @@ -540,6 +565,7 @@ public void BackgroundRepeatNoRepeatRepeatXIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRepeatRepeatNoRepeatRepeatLegal() { var snippet = "background-repeat: repeat repeat, no-repeat repeat"; @@ -554,6 +580,7 @@ public void BackgroundRepeatRepeatRepeatNoRepeatRepeatLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeLengthLegal() { var snippet = "background-size: 2em"; @@ -568,6 +595,7 @@ public void BackgroundSizeLengthLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizePercentLegal() { var snippet = "background-size: 20%"; @@ -582,6 +610,7 @@ public void BackgroundSizePercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeAutoAutoLegal() { var snippet = "background-size: auto auto"; @@ -596,6 +625,7 @@ public void BackgroundSizeAutoAutoLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeAutoLengthLegal() { var snippet = "background-size: auto 50px"; @@ -610,6 +640,7 @@ public void BackgroundSizeAutoLengthLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeLengthLengthLegal() { var snippet = "background-size: 25px 50px"; @@ -624,6 +655,7 @@ public void BackgroundSizeLengthLengthLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizePercentPercentLegal() { var snippet = "background-size: 50% 50%"; @@ -638,6 +670,7 @@ public void BackgroundSizePercentPercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeAutoUppercaseLegal() { var snippet = "background-size: AUTO"; @@ -652,6 +685,7 @@ public void BackgroundSizeAutoUppercaseLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeCoverLegal() { var snippet = "background-size: cover"; @@ -666,6 +700,7 @@ public void BackgroundSizeCoverLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeContainCoverMultipleLegal() { var snippet = "background-size: contain,cover"; @@ -680,6 +715,7 @@ public void BackgroundSizeContainCoverMultipleLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeContainLengthAutoPercentLegal() { var snippet = "background-size: contain,100px,auto,20%"; @@ -961,10 +997,7 @@ public void BackgroundShorthand_MultipleLayers_ExtractsCommaJoinedNotSpaceJoined [TestMethod] public void BackgroundShorthand_MultipleLayers_OriginClipCommaJoined() { - // Each layer explicitly gives both box-model keywords (origin then clip) to avoid a - // separate, pre-existing ambiguity in this shorthand: a single box-model keyword is - // always assigned to background-origin (never background-clip) here, rather than - // setting both per spec - unrelated to the comma-vs-whitespace joining this test targets. + // Both box keywords per layer, a single one only sets background-origin (see Spec/css-backgrounds-3.yml) var style = CssConstructionFunctions.ParseDeclarations( "background: url(\"a.png\") padding-box content-box, url(\"b.png\") border-box padding-box"); diff --git a/Source/Test/HtmlRenderer.Test/Css/BorderImagePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/BorderImagePropertyTests.cs index b130ed185..c08a74f7f 100644 --- a/Source/Test/HtmlRenderer.Test/Css/BorderImagePropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/BorderImagePropertyTests.cs @@ -3,11 +3,11 @@ namespace HtmlRenderer.Test.Css; -/// <summary>Ported from PeachPDF.Tests/CSS/PropertyTests/BorderImageProperty.cs. Pure CSSOM parse tests - no layout involved.</summary> [TestClass] public sealed class BorderImagePropertyTests { [TestMethod] + [Spec("css-backgrounds-3", "#border-image-source")] public void BorderImageSourceNoneLegal() { var snippet = "border-image-source: none "; @@ -22,6 +22,7 @@ public void BorderImageSourceNoneLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-source")] public void BorderImageSourceUrlLegal() { var snippet = "border-image-source: url(image.jpg)"; @@ -36,6 +37,7 @@ public void BorderImageSourceUrlLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-source")] public void BorderImageSourceLinearGradientLegal() { var snippet = "border-image-source: linear-gradient(to top, red, yellow)"; @@ -50,6 +52,7 @@ public void BorderImageSourceLinearGradientLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-outset")] public void BorderImageOutsetZeroLegal() { var snippet = "border-image-outset: 0"; @@ -64,6 +67,7 @@ public void BorderImageOutsetZeroLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-outset")] public void BorderImageOutsetLengthPercentLegal() { var snippet = "border-image-outset: 10px 25%"; @@ -78,6 +82,7 @@ public void BorderImageOutsetLengthPercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-outset")] public void BorderImageOutsetLengthPercentZeroLegal() { var snippet = "border-image-outset: 10px 25% 0"; @@ -92,6 +97,7 @@ public void BorderImageOutsetLengthPercentZeroLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-outset")] public void BorderImageOutsetLengthPercentZeroPercentLegal() { var snippet = "border-image-outset: 10px 25% 0 10%"; @@ -106,6 +112,7 @@ public void BorderImageOutsetLengthPercentZeroPercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-outset")] public void BorderImageOutsetZerosIllegal() { var snippet = "border-image-outset: 0 0 0 0 0"; @@ -119,6 +126,7 @@ public void BorderImageOutsetZerosIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-width")] public void BorderImageWidthZeroLegal() { var snippet = "border-image-width: 0"; @@ -133,6 +141,7 @@ public void BorderImageWidthZeroLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-width")] public void BorderImageWidthAutoLegal() { var snippet = "border-image-width: auto"; @@ -147,6 +156,7 @@ public void BorderImageWidthAutoLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-width")] public void BorderImageWidthMultipleLegal() { var snippet = "border-image-width: 5"; @@ -161,6 +171,7 @@ public void BorderImageWidthMultipleLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-width")] public void BorderImageWidthLengthPercentLegal() { var snippet = "border-image-width: 10px 25%"; @@ -175,6 +186,7 @@ public void BorderImageWidthLengthPercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-width")] public void BorderImageWidthLengthPercentZeroLegal() { var snippet = "border-image-width: 10px 25% 0"; @@ -189,6 +201,7 @@ public void BorderImageWidthLengthPercentZeroLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-width")] public void BorderImageWidthLengthPercentAutoPercentLegal() { var snippet = "border-image-width: 10px 25% auto 10%"; @@ -203,6 +216,7 @@ public void BorderImageWidthLengthPercentAutoPercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-width")] public void BorderImageWidthZerosIllegal() { var snippet = "border-image-width: 0 0 0 0 0"; @@ -216,6 +230,7 @@ public void BorderImageWidthZerosIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-repeat")] public void BorderImageRepeatStretchUppercaseLegal() { var snippet = "border-image-repeat: StRETCH"; @@ -230,6 +245,7 @@ public void BorderImageRepeatStretchUppercaseLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-repeat")] public void BorderImageRepeatRepeatLegal() { var snippet = "border-image-repeat: repeat"; @@ -244,6 +260,7 @@ public void BorderImageRepeatRepeatLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-repeat")] public void BorderImageRepeatRoundLegal() { var snippet = "border-image-repeat: round"; @@ -258,6 +275,7 @@ public void BorderImageRepeatRoundLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-repeat")] public void BorderImageRepeatStretchRoundLegal() { var snippet = "border-image-repeat: stretch round"; @@ -272,6 +290,7 @@ public void BorderImageRepeatStretchRoundLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-repeat")] public void BorderImageRepeatNoRepeatIllegal() { var snippet = "border-image-repeat: no-repeat"; @@ -285,6 +304,7 @@ public void BorderImageRepeatNoRepeatIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-slice")] public void BorderImageSlicePixelsLegal() { var snippet = "border-image-slice: 3"; @@ -299,6 +319,7 @@ public void BorderImageSlicePixelsLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-slice")] public void BorderImageSlicePercentLegal() { var snippet = "border-image-slice: 10%"; @@ -313,6 +334,7 @@ public void BorderImageSlicePercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-slice")] public void BorderImageSliceFillLegal() { var snippet = "border-image-slice: fill"; @@ -331,6 +353,7 @@ public void BorderImageSliceFillLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-slice")] public void BorderImageSlicePercentFillLegal() { var snippet = "border-image-slice: 10% fill"; @@ -345,6 +368,7 @@ public void BorderImageSlicePercentFillLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-slice")] public void BorderImageSlicePercentPixelsFillLegal() { var snippet = "border-image-slice: 10% 30 fill"; @@ -359,7 +383,9 @@ public void BorderImageSlicePercentPixelsFillLegal() } [TestMethod] - public void BorderImageSlicePercentPixelsFillZerosLegal() + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [Spec("css-backgrounds-3", "#border-image-slice")] + public void BorderImageSliceFillBetweenNumbersIllegal() { var snippet = "border-image-slice: 10% 30 fill 0 0"; var property = CssConstructionFunctions.ParseDeclaration(snippet); @@ -373,6 +399,7 @@ public void BorderImageSlicePercentPixelsFillZerosLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-slice")] public void BorderImageSlicePercentPixelsFillZerosIllegal() { var snippet = "border-image-slice: 10% 30 fill 0 0 0"; @@ -386,6 +413,7 @@ public void BorderImageSlicePercentPixelsFillZerosIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-slice")] public void BorderImageSlicePercentPixelsZerosFillIllegal() { var snippet = "border-image-slice: 10% 30 0 0 0 fill"; @@ -399,6 +427,7 @@ public void BorderImageSlicePercentPixelsZerosFillIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image")] public void BorderImageNoneLegal() { var snippet = "border-image: none "; @@ -413,6 +442,7 @@ public void BorderImageNoneLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image")] public void BorderImageUrlOffsetLegal() { var snippet = "border-image: url(image.png) 50 50"; @@ -427,6 +457,7 @@ public void BorderImageUrlOffsetLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image")] public void BorderImageUrlOffsetRepeatLegal() { var snippet = "border-image: url(image.png) 30 30 repeat"; @@ -441,6 +472,7 @@ public void BorderImageUrlOffsetRepeatLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image")] public void BorderImageUrlStretchUppercaseLegal() { var snippet = "border-image: url(image.png) STRETCH"; @@ -455,6 +487,7 @@ public void BorderImageUrlStretchUppercaseLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image")] public void BorderImageUrlOffsetWidthTwoLegal() { var snippet = "border-image: url(image.png) 30 30 / 15px 15px"; @@ -469,6 +502,7 @@ public void BorderImageUrlOffsetWidthTwoLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image")] public void BorderImageUrlOffsetWidthFourLegal() { var snippet = "border-image: url(image.png) 30 30 0 10 / 15px 0 15px 2em"; @@ -483,6 +517,7 @@ public void BorderImageUrlOffsetWidthFourLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image")] public void BorderImageUrlOffsetWidthOutsetLegal() { var snippet = "border-image: url(image.png) 30 30 / 15px 15px / 5% 2% 0 10%"; diff --git a/Source/Test/HtmlRenderer.Test/Css/BorderRadiusPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/BorderRadiusPropertyTests.cs index e89912a86..4b378da50 100644 --- a/Source/Test/HtmlRenderer.Test/Css/BorderRadiusPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/BorderRadiusPropertyTests.cs @@ -3,12 +3,11 @@ namespace HtmlRenderer.Test.Css; -/// <summary>Ported from PeachPDF.Tests/CSS/PropertyTests/BorderRadiusProperty.cs. Pure CSSOM parse tests, -/// including CSS-text round-trip/simplification tests via <see cref="Rule.Text"/>.</summary> [TestClass] public sealed class BorderRadiusPropertyTests { [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderBottomLeftRadiusPxPxLegal() { var snippet = "border-bottom-left-radius: 40px 40px"; @@ -23,6 +22,7 @@ public void BorderBottomLeftRadiusPxPxLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderBottomLeftRadiusPxEmLegal() { var snippet = "border-bottom-left-radius : 40px 20em"; @@ -37,6 +37,7 @@ public void BorderBottomLeftRadiusPxEmLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderBottomLeftRadiusPxPercentLegal() { var snippet = "border-bottom-left-radius: 10px 5%"; @@ -51,6 +52,7 @@ public void BorderBottomLeftRadiusPxPercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderBottomLeftRadiusPercentLegal() { var snippet = "border-bottom-left-radius: 10%"; @@ -65,6 +67,7 @@ public void BorderBottomLeftRadiusPercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderBottomRightRadiusZeroLegal() { var snippet = "border-bottom-right-radius: 0"; @@ -79,6 +82,7 @@ public void BorderBottomRightRadiusZeroLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderBottomRightRadiusPxLegal() { var snippet = "border-bottom-right-radius: 20px"; @@ -93,6 +97,7 @@ public void BorderBottomRightRadiusPxLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderTopLeftRadiusCmLegal() { var snippet = "border-top-left-radius: 3.5cm"; @@ -107,6 +112,7 @@ public void BorderTopLeftRadiusCmLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderTopRightRadiusPercentPercentLegal() { var snippet = "border-top-right-radius: 15% 3.5%"; @@ -121,6 +127,7 @@ public void BorderTopRightRadiusPercentPercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusPercentPercentLegal() { var snippet = "border-radius: 15% 3.5%"; @@ -135,6 +142,7 @@ public void BorderRadiusPercentPercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusZeroLegal() { var snippet = "border-radius: 0"; @@ -149,6 +157,7 @@ public void BorderRadiusZeroLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusThreeLengthsLegal() { var snippet = "border-radius: 2px 4px 3px"; @@ -163,6 +172,7 @@ public void BorderRadiusThreeLengthsLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusFourLengthsLegal() { var snippet = "border-radius: 2px 4px 3px 0"; @@ -177,6 +187,7 @@ public void BorderRadiusFourLengthsLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusFiveLengthsIllegal() { var snippet = "border-radius: 2px 4px 3px 0 1px"; @@ -190,6 +201,7 @@ public void BorderRadiusFiveLengthsIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusLengthFractionLegal() { var snippet = "border-radius: 1em/5em"; @@ -204,6 +216,7 @@ public void BorderRadiusLengthFractionLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusLengthFractionInbalancedLegal() { var snippet = "border-radius: 4px 3px 6px / 2px 4px"; @@ -218,6 +231,7 @@ public void BorderRadiusLengthFractionInbalancedLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusFullFractionLegal() { var snippet = "border-radius: 4px 3px 6px 1em / 2px 4px 0 20%"; @@ -232,6 +246,7 @@ public void BorderRadiusFullFractionLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusFiveTailFractionIllegal() { var snippet = "border-radius: 4px 3px 6px 1em / 2px 4px 0 20% 0"; @@ -245,6 +260,7 @@ public void BorderRadiusFiveTailFractionIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusFiveHeadFractionIllegal() { var snippet = "border-radius: 4px 3px 6px 1em 0 / 2px 4px 0 20%"; @@ -258,6 +274,7 @@ public void BorderRadiusFiveHeadFractionIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusCircleShouldBeExpandedAndRecombinedCorrectly() { var snippet = ".centered { border-radius: 5px; }"; @@ -268,6 +285,7 @@ public void BorderRadiusCircleShouldBeExpandedAndRecombinedCorrectly() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusEllipseShouldBeExpandedAndRecombinedCorrectly() { var snippet = ".centered { border-radius: 5px/3px; }"; @@ -278,6 +296,7 @@ public void BorderRadiusEllipseShouldBeExpandedAndRecombinedCorrectly() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusSimplificationShouldWork() { var snippet = ".centered { border-top-left-radius: 0 1px; border-bottom-left-radius: 1px 2px; border-top-right-radius: 0 3px; border-bottom-right-radius: 1px 4px; }"; @@ -288,6 +307,7 @@ public void BorderRadiusSimplificationShouldWork() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusRecombinationAndReductionCheck() { var snippet = ".centered { border-top-left-radius: 0 1px; border-bottom-left-radius: 0 1px; border-top-right-radius: 1px 1px; border-bottom-right-radius: 0 1px; }"; @@ -298,6 +318,7 @@ public void BorderRadiusRecombinationAndReductionCheck() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadiusPureCircleRecombination() { var snippet = ".test { border-top-left-radius:15px;border-bottom-left-radius:15px;border-bottom-right-radius:0;border-top-right-radius:0;}"; diff --git a/Source/Test/HtmlRenderer.Test/Css/BoxShadowGrammarTests.cs b/Source/Test/HtmlRenderer.Test/Css/BoxShadowGrammarTests.cs index 5c3778315..4f0851f34 100644 --- a/Source/Test/HtmlRenderer.Test/Css/BoxShadowGrammarTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/BoxShadowGrammarTests.cs @@ -6,12 +6,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/BoxShadowGrammarTests.cs. -/// Tests for the shared <see cref="BoxShadowGrammar"/> (the <c>box-shadow</c> value grammar -/// <c>none | [ inset? &amp;&amp; &lt;length&gt;{2,4} &amp;&amp; &lt;color&gt;? ]#</c>) and its -/// Layer-A accept/reject via the full parser. -/// </summary> [TestClass] public sealed class BoxShadowGrammarTests { @@ -19,6 +13,7 @@ public sealed class BoxShadowGrammarTests BoxShadowGrammar.TryParse(CssValueParser.GetCssTokens(value)); [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] public void None_ReturnsEmptyList() { var layers = Parse("none"); @@ -27,6 +22,7 @@ public void None_ReturnsEmptyList() } [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] public void TwoLengths_OffsetsOnly() { var layers = Parse("2px 3px"); @@ -41,6 +37,7 @@ public void TwoLengths_OffsetsOnly() } [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] public void ThreeLengths_HasBlur() { var layers = Parse("2px 2px 5px"); @@ -51,6 +48,7 @@ public void ThreeLengths_HasBlur() } [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] public void FourLengths_HasBlurAndSpread() { var layers = Parse("1px 1px 2px 3px"); @@ -61,6 +59,7 @@ public void FourLengths_HasBlurAndSpread() } [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] public void Inset_WithColor() { var layers = Parse("inset 0 0 5px red"); @@ -74,6 +73,7 @@ public void Inset_WithColor() } [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] [DataRow("2px 2px red", "red")] [DataRow("2px 2px #fff", "#fff")] // letter-leading hex (Hash token) [DataRow("2px 2px #08f", "#08f")] // digit-leading short hex (Delim + dimension) @@ -89,6 +89,7 @@ public void Color_IsCaptured(string value, string expectedColor) } [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] public void ColorBeforeLengths_IsAccepted() { // The && grammar allows the color in any position, including before the lengths. @@ -100,6 +101,7 @@ public void ColorBeforeLengths_IsAccepted() } [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] public void EmValidLengthsAreKeptAsAuthoredStrings() { var layers = Parse("0.5em 0.5em 1em"); @@ -110,6 +112,7 @@ public void EmValidLengthsAreKeptAsAuthoredStrings() } [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] public void NegativeOffsetsAndSpread_AreValid() { var layers = Parse("-2px -3px 4px -1px"); @@ -121,6 +124,7 @@ public void NegativeOffsetsAndSpread_AreValid() } [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] public void MultipleLayers_ParseInOrder() { var layers = Parse("1px 1px 2px 3px rgba(0,0,0,.5), inset 0 0 0 1px blue"); @@ -132,6 +136,7 @@ public void MultipleLayers_ParseInOrder() } [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] [DataRow("")] [DataRow("banana")] // no lengths [DataRow("2px")] // only one length @@ -151,6 +156,7 @@ public void Invalid_ReturnsNull(string value) } [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] [DataRow("box-shadow: none", true)] [DataRow("box-shadow: 2px 2px", true)] [DataRow("box-shadow: inset 0 0 5px red", true)] diff --git a/Source/Test/HtmlRenderer.Test/Css/CssBackgrounds3ParseTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssBackgrounds3ParseTests.cs new file mode 100644 index 000000000..7d15676ad --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/CssBackgrounds3ParseTests.cs @@ -0,0 +1,107 @@ +using HtmlRenderer.Test.CssEngineSupport; + +namespace HtmlRenderer.Test.Css; + +[TestClass] +public sealed class CssBackgrounds3ParseTests +{ + [TestMethod] + [DataRow("right 10px top")] + [DataRow("right 10px bottom 20px")] + [DataRow("center bottom 10%")] + [Spec("css-backgrounds-3", "#background-position")] + public void BackgroundPosition_EdgeOffsetsAreAccepted(string position) + { + var property = CssConstructionFunctions.ParseDeclaration($"background-position: {position}"); + + Assert.IsTrue(property.HasValue); + } + + [TestMethod] + [DataRow("left right")] + [DataRow("left 10px 20px")] + [Spec("css-backgrounds-3", "#background-position")] + public void BackgroundPosition_InvalidCombinationsAreRejected(string position) + { + var property = CssConstructionFunctions.ParseDeclaration($"background-position: {position}"); + + Assert.IsFalse(property.HasValue); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [DataRow("10px left")] + [DataRow("top 10px")] + [Spec("css-backgrounds-3", "#background-position")] + public void BackgroundPosition_AnOffsetFirstMustBeHorizontal(string position) + { + var property = CssConstructionFunctions.ParseDeclaration($"background-position: {position}"); + + Assert.IsFalse(property.HasValue); + } + + [TestMethod] + [Spec("css-backgrounds-3", "#background")] + public void BackgroundShorthand_ColorIsOnlyAllowedInTheFinalLayer() + { + var style = CssConstructionFunctions.ParseDeclarations("background: red url(a.png), url(b.png)"); + + Assert.AreEqual("", style.BackgroundImage); + } + + [TestMethod] + [Spec("css-backgrounds-3", "#background")] + public void BackgroundShorthand_TwoBoxValuesSetOriginThenClip() + { + var style = CssConstructionFunctions.ParseDeclarations("background: url(a.png) padding-box content-box"); + + Assert.AreEqual("padding-box", style.BackgroundOrigin); + Assert.AreEqual("content-box", style.BackgroundClip); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [Spec("css-backgrounds-3", "#background")] + public void BackgroundShorthand_OneBoxValueSetsOriginAndClip() + { + var style = CssConstructionFunctions.ParseDeclarations("background: url(a.png) padding-box"); + + Assert.AreEqual("padding-box", style.BackgroundOrigin); + Assert.AreEqual("padding-box", style.BackgroundClip); + } + + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [TestMethod] + [DataRow("border-radius: -5px")] + [DataRow("border-top-left-radius: 5px -5px")] + [Spec("css-backgrounds-3", "#border-radius")] + public void BorderRadius_NegativeValuesAreRejected(string declaration) + { + var property = CssConstructionFunctions.ParseDeclaration(declaration); + + Assert.IsFalse(property.HasValue); + } + + [TestMethod] + [DataRow("box-shadow: 1px 2px 3px -1px red inset")] + [DataRow("box-shadow: inset 0 0 red, 1px 1px blue")] + [Spec("css-backgrounds-3", "#box-shadow")] + public void BoxShadow_IsAccepted(string declaration) + { + var property = CssConstructionFunctions.ParseDeclaration(declaration); + + Assert.IsTrue(property.HasValue); + } + + [TestMethod] + [DataRow("box-shadow: 1px 2px -3px red")] + [DataRow("box-shadow: 1px")] + [DataRow("box-shadow: none, 1px 1px red")] + [Spec("css-backgrounds-3", "#box-shadow")] + public void BoxShadow_InvalidValuesAreRejected(string declaration) + { + var property = CssConstructionFunctions.ParseDeclaration(declaration); + + Assert.IsFalse(property.HasValue); + } +} diff --git a/Spec/css-backgrounds-3.yml b/Spec/css-backgrounds-3.yml index 5184ac6df..8401fc112 100644 --- a/Spec/css-backgrounds-3.yml +++ b/Spec/css-backgrounds-3.yml @@ -5,190 +5,200 @@ snapshot: https://www.w3.org/TR/2024/CRD-css-backgrounds-3-20240311/ sections: '#introduction': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#backgrounds': title: 2 Backgrounds - status: unknown + status: partial '#layering': title: 2.1 Layering Multiple Background Images - status: unknown + status: unsupported + gaps: + - Only the first layer of a comma-separated background is painted '#background-color': title: '2.2 Base Color: the background-color property' - status: unknown + status: partial + gaps: + - The color isn't painted beneath a gradient 'background-image', the gradient replaces it '#background-image': title: '2.3 Image Sources: the background-image property' - status: unknown + status: partial + gaps: + - Only url() images and linear gradients are painted, radial gradients aren't, see CSS Images 3 '#background-repeat': title: '2.4 Tiling Images: the background-repeat property' - status: unknown + status: partial + gaps: + - The two-value syntax isn't resolved, 'repeat no-repeat' tiles in both directions + - "'space' and 'round' are painted as 'repeat'" '#background-attachment': title: '2.5 Affixing Images: the background-attachment property' - status: unknown + status: partial + gaps: + - "'fixed' is ignored, see CSS 2.1 14.2.1" '#background-position': title: '2.6 Positioning Images: the background-position property' - status: unknown + status: partial + gaps: + - Only keywords are resolved, see CSS 2.1 14.2.1 + - Edge offsets like 'right 10px bottom 20px' aren't resolved + - "'10px left' and 'top 10px' are accepted, though the first of two values has to be horizontal" '#bg-position-serialization': title: 2.6.1 Serialization of background-position values - status: unknown + status: n/a '#background-clip': title: '2.7 Painting Area: the background-clip property' - status: unknown + status: parsed-only + gaps: + - "'background-clip' is ignored, the background is always painted to the border box" '#background-origin': title: '2.8 Positioning Area: the background-origin property' - status: unknown + status: parsed-only + gaps: + - "'background-origin' is ignored, images are positioned against the border box" '#background-size': title: '2.9 Sizing Images: the background-size property' - status: unknown + status: parsed-only + gaps: + - "'background-size' is ignored, images are drawn at their natural size" '#background': title: '2.10 Backgrounds Shorthand: the background property' - status: unknown + status: partial + gaps: + - A single box value only sets 'background-origin', not 'background-clip' too '#special-backgrounds': title: 2.11 Backgrounds of Special Elements - status: unknown + status: partial '#root-background': title: 2.11.1 The Canvas Background and the Root Element - status: unknown + status: partial + gaps: + - The root's background only covers the root box, not the whole canvas, see CSS 2.1 14.2 '#body-background': title: 2.11.2 The Canvas Background and the HTML <body> Element - status: unknown + status: unsupported + gaps: + - The body's background isn't propagated to the canvas, see CSS 2.1 14.2 '#first-line-background': title: 2.11.3 The ::first-line Pseudo-element‘s Background - status: unknown + status: unsupported + gaps: + - "::first-line declarations never apply, see CSS 2.1 5.12.1" '#borders': title: 3 Borders - status: unknown + status: partial '#border-color': title: '3.1 Line Colors: the border-color properties' - status: unknown + status: partial + gaps: + - The initial color is black instead of currentcolor, see CSS 2.1 8.5.2 '#border-style': title: '3.2 Line Patterns: the border-style properties' - status: unknown + status: partial + gaps: + - double, groove and ridge are painted as solid, see CSS 2.1 8.5.3 '#border-width': title: '3.3 Line Thickness: the border-width properties' - status: unknown + status: partial + gaps: + - "'border-style: hidden' keeps the width and the initial width isn't 'medium', see CSS 2.1 8.5.1" '#border-shorthands': title: 3.4 Border Shorthand Properties - status: unknown + status: supported '#corners': title: 4 Rounded Corners - status: unknown + status: partial '#border-radius': title: '4.1 Curve Radii: the border-radius properties' - status: unknown + status: partial + gaps: + - Negative radii are accepted instead of being rejected '#corner-shaping': title: 4.2 Corner Shaping - status: unknown + status: partial + gaps: + - A rounded border is stroked along the outer radius moved in by half the border width, so it doesn't follow the outer and inner border curves '#corner-clipping': title: 4.3 Corner Clipping - status: unknown + status: partial + gaps: + - Background images aren't clipped to the rounded corners + - Overflowing content is clipped to the rectangular padding box, not to its rounded corners '#corner-transitions': title: 4.4 Color and Style Transitions - status: unknown + status: partial + gaps: + - Adjoining solid, dotted and dashed borders overlap at the corners instead of meeting at the diagonal + - A rounded corner is painted entirely in the top or bottom border's color and style '#corner-overlap': title: 4.5 Overlapping Curves - status: unknown + status: partial + gaps: + - The horizontal and vertical radii are scaled by separate factors, which changes the shape of the corners '#border-radius-tables': title: 4.6 Effect on Tables - status: unknown + status: partial + gaps: + - "'border-radius' also rounds a table with collapsing borders" '#border-images': title: 5 Border Images - status: unknown + status: parsed-only + gaps: + - "'border-image' and its longhands are parsed but never painted, the regular border is drawn instead" '#border-image-source': title: '5.1 Image Source: the border-image-source property' - status: unknown + status: parsed-only '#border-image-slice': title: '5.2 Image Slicing: the border-image-slice property' - status: unknown + status: parsed-only + gaps: + - "'fill' on its own and 'fill' between the numbers are accepted, e.g. 'fill' and '10% 30 fill 0 0'" '#border-image-width': title: '5.3 Drawing Areas: the border-image-width property' - status: unknown + status: parsed-only '#border-image-outset': title: '5.4 Edge Overhang: the border-image-outset property' - status: unknown + status: parsed-only '#border-image-repeat': title: '5.5 Image Tiling: the border-image-repeat property' - status: unknown + status: parsed-only '#border-image-process': title: 5.6 Drawing the Border Image - status: unknown + status: unsupported '#border-image': title: '5.7 Border Image Shorthand: the border-image property' - status: unknown + status: parsed-only '#border-image-tables': title: 5.8 Effect on Tables - status: unknown + status: unsupported '#misc': title: 6 Miscellaneous Effects - status: unknown + status: parsed-only '#box-shadow': title: '6.1 Drop Shadows: the box-shadow property' - status: unknown + status: parsed-only + gaps: + - "'box-shadow' is parsed but never painted" '#shadow-shape': title: 6.1.1 Shadow Shape, Spread, and Knockout - status: unknown + status: unsupported '#shadow-blur': title: 6.1.2 Blurring Shadow Edges - status: unknown + status: unsupported '#shadow-layers': title: 6.1.3 Layering, Layout, and Other Details - status: unknown + status: unsupported '#levels': title: 7 Levels - status: unknown - '#level-1': - title: 7.1 Level 1 - status: unknown - '#level-2': - title: 7.2 Level 2 - status: unknown - '#level-3': - title: 7.3 Level 3 - status: unknown + status: n/a '#changes': title: 8 Changes - status: unknown - '#changes-2023-02': - title: 8.1 Changes since the 14 February 2023 Candidate Recommendation Snapshot - status: unknown - '#changes-2020-12': - title: 8.2 Changes since the 22 December 2020 Candidate Recommendation Snapshot - status: unknown - '#changes-2017-10': - title: 8.3 Changes since the 17 October 2017 Candidate Recommendation - status: unknown - '#changes-2014-09': - title: 8.4 Changes since the 9 September 2014 Candidate Recommendation - status: unknown - '#changes-2014-02': - title: 8.5 Changes since the 4 February 2014 Last Call Working Draft - status: unknown - '#changes-2012-07': - title: 8.6 Changes since the 24 July 2012 Candidate Recommendation - status: unknown - '#changes-2012-04': - title: 8.7 Changes since the 17 April 2012 Candidate Recommendation - status: unknown - '#changes-2012LC': - title: 8.8 Changes since the 14 February 2012 “Last Call” Working Draft - status: unknown - '#changes-2011': - title: 8.9 Changes Since the 15 February 2011 Candidate Recommendation - status: unknown - '#changes-2009': - title: 8.10 Changes Since the 17 December 2009 Candidate Recommendation - status: unknown + status: n/a '#acknowledgments': title: 9 Acknowledgments - status: unknown + status: n/a '#privacy': title: 10 Privacy Considerations - status: unknown + status: n/a '#security': title: 11 Security Considerations - status: unknown + status: n/a From 257a6152a206e76d08abd8dfc061101b8f5deb7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:11:38 +0100 Subject: [PATCH 074/150] Verify and document CSS Images Level 3 compliance --- Source/HtmlRenderer/Core/Entities/CssImage.cs | 7 +- .../Core/Entities/ParsedLinearGradient.cs | 6 +- .../Core/Handlers/CssImagePainter.cs | 21 +- .../HtmlRenderer/Core/Parse/CssValueParser.cs | 20 +- .../Painting/CssImages3Tests.cs | 268 ++++++++++++++++++ .../Content/ListStyleTests.cs | 1 + .../HtmlRenderer.Test/Css/GradientTests.cs | 29 +- .../Css/ObjectSizingPropertyTests.cs | 17 +- Spec/css-images-3.yml | 98 ++++--- 9 files changed, 384 insertions(+), 83 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/CssImages3Tests.cs diff --git a/Source/HtmlRenderer/Core/Entities/CssImage.cs b/Source/HtmlRenderer/Core/Entities/CssImage.cs index 887428fe4..364f123c6 100644 --- a/Source/HtmlRenderer/Core/Entities/CssImage.cs +++ b/Source/HtmlRenderer/Core/Entities/CssImage.cs @@ -1,11 +1,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.Entities { /// <summary> - /// The parsed form of a single <c>background-image</c> value: either a <c>url()</c> reference or a - /// gradient function. Unlike the fuller model this is trimmed down from (same - /// name for an easy diff), <c>background-image</c> here is a single value, not a comma-separated - /// layered list - this project only backports enough to replace the old non-standard - /// "background-gradient" property. + /// The parsed <c>background-image</c>, a <c>url()</c> or a linear gradient (CSS Images 3 §2). Only + /// one layer. /// </summary> internal abstract class CssImage { diff --git a/Source/HtmlRenderer/Core/Entities/ParsedLinearGradient.cs b/Source/HtmlRenderer/Core/Entities/ParsedLinearGradient.cs index 00a4a23c3..5d8884ae4 100644 --- a/Source/HtmlRenderer/Core/Entities/ParsedLinearGradient.cs +++ b/Source/HtmlRenderer/Core/Entities/ParsedLinearGradient.cs @@ -4,10 +4,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.Entities { /// <summary> - /// A fully parsed <c>linear-gradient()</c>/<c>repeating-linear-gradient()</c> value - the angle and - /// raw (not yet position-resolved) color stop list. Derived from - /// Html/Core/Entities/ParsedLinearGradient.cs, minus CSS Color 4 interpolation-color-space support - /// (ColorSpace/HueMethod) - gradients always interpolate in sRGB here. + /// A parsed <c>linear-gradient()</c> or <c>repeating-linear-gradient()</c>, the stop positions aren't + /// resolved yet. Interpolates in sRGB, an interpolation color space isn't supported. /// </summary> internal sealed class ParsedLinearGradient { diff --git a/Source/HtmlRenderer/Core/Handlers/CssImagePainter.cs b/Source/HtmlRenderer/Core/Handlers/CssImagePainter.cs index 7028690be..fad8e6fc9 100644 --- a/Source/HtmlRenderer/Core/Handlers/CssImagePainter.cs +++ b/Source/HtmlRenderer/Core/Handlers/CssImagePainter.cs @@ -21,14 +21,12 @@ namespace TheArtOfDev.HtmlRenderer.Core.Handlers { /// <summary> - /// Turns a parsed <c>linear-gradient()</c> value into a brush ready to paint. Derived from - /// Html/Core/Handlers/CssImagePainter.cs, trimmed to the linear-gradient-only, single (non-layered) - /// background-image case this engine supports - see <see cref="ParsedLinearGradient"/>. + /// Turns a parsed <c>linear-gradient()</c> into a brush, CSS Images 3 §3.1. /// </summary> internal static class CssImagePainter { /// <summary> - /// Builds the brush for a <c>linear-gradient()</c> background, filling the given rectangle. + /// The brush for a <c>linear-gradient()</c> with <paramref name="rect"/> as its gradient box. /// </summary> public static RBrush GetLinearGradientBrush(RGraphics g, RRect rect, ParsedLinearGradient gradient) { @@ -42,9 +40,7 @@ public static RBrush GetLinearGradientBrush(RGraphics g, RRect rect, ParsedLinea } /// <summary> - /// Computes the CSS gradient line endpoints for the given box rect and angle (standard CSS - /// "corner-to-corner projection" algorithm - the line is centered on the box and long enough that - /// the box's bounding rectangle is fully spanned along the gradient direction). + /// The end points of the gradient line, CSS Images 3 §3.1.1. /// </summary> private static (RPoint p1, RPoint p2) ComputeGradientLine(RRect rect, double angleRad) { @@ -82,10 +78,9 @@ private static RColor LerpColor(RColor a, RColor b, double t) } /// <summary> - /// Resolves each stop's position to a [0,1] fraction of the gradient line (per CSS Images §3.5.5: - /// the first/last stop default to 0%/100%, middle stops without an explicit position are spaced - /// evenly between their neighbors, and bare-position "hints" between two color stops bias the - /// interpolation curve rather than being a stop themselves). + /// Resolves each stop's position to a fraction of the gradient line, CSS Images 3 §3.4.3. The + /// adapters only interpolate linearly, so a color hint (§3.4.2) is approximated by extra stops + /// along its curve. em positions assume a 16px font. /// </summary> private static (RColor Color, double Position)[] NormalizeGradientStops( (RColor? Color, Length? Position, bool IsHint)[] stops, @@ -179,8 +174,8 @@ private static (RColor Color, double Position)[] NormalizeGradientStops( } /// <summary> - /// Flattens a repeating gradient's stop tile into a single non-repeating stop list spanning the - /// full [0,1] gradient line, by replicating the tile as many times as needed in both directions. + /// Repeats the stops of a repeating gradient (CSS Images 3 §3.3) over the whole gradient line, + /// the adapters have no repeating brush. /// </summary> private static (RColor Color, double Position)[] ExpandRepeatingStops((RColor Color, double Position)[] stops) { diff --git a/Source/HtmlRenderer/Core/Parse/CssValueParser.cs b/Source/HtmlRenderer/Core/Parse/CssValueParser.cs index 0d185a324..c8214591b 100644 --- a/Source/HtmlRenderer/Core/Parse/CssValueParser.cs +++ b/Source/HtmlRenderer/Core/Parse/CssValueParser.cs @@ -839,12 +839,8 @@ private static int ParseHexInt(string str, int idx, int length) #region Background image / gradient parsing /// <summary> - /// Parses a single <c>background-image</c> value: either a <c>url()</c> reference or a - /// <c>linear-gradient()</c>/<c>repeating-linear-gradient()</c> function. Returns null for "none", - /// an empty value, or anything else this engine doesn't recognize (matching this engine's existing - /// silent-failure behavior for unsupported values).<br/> - /// Trimmed to the single-value, - /// linear-gradient-only case this engine supports (no layered background-image, no radial/conic). + /// Parses a <c>background-image</c> into its first <c>url()</c> or (repeating) linear gradient, + /// CSS Images 3 §2. Null for <c>none</c> and every other image. /// </summary> public CssImage ParseImage(string value) { @@ -872,13 +868,8 @@ public CssImage ParseImage(string value) } /// <summary> - /// Parses a <c>linear-gradient()</c>/<c>repeating-linear-gradient()</c> function value: an - /// optional angle or "to &lt;side&gt;" direction (default 180deg, top to bottom), followed by 2+ - /// comma-separated color stops (each optionally followed by 1-2 length/percent positions), and - /// bare-position color hints between stops. Returns null if the value isn't a recognized gradient - /// function or doesn't have at least 2 real color stops.<br/> - /// Linear-gradient parsing, minus CSS Color 4 - /// interpolation-color-space ("in oklab" etc.) support - see ParsedLinearGradient. + /// Parses a <c>linear-gradient()</c> or <c>repeating-linear-gradient()</c>, CSS Images 3 §3.1.1. + /// Null if it has fewer than two color stops. /// </summary> private ParsedLinearGradient ParseLinearGradient(string value) { @@ -1000,7 +991,8 @@ private ParsedLinearGradient ParseLinearGradient(string value) /// <summary> /// Converts "to &lt;side&gt; [&lt;side&gt;]" direction keywords (e.g. "right", "bottom left") to - /// the equivalent gradient-line angle in radians, per the CSS Images spec's side/corner table. + /// the gradient-line angle in radians. A corner is always a multiple of 45deg, it doesn't depend on + /// the box's aspect ratio. /// </summary> private static double SideKeywordsToAngleRad(List<string> sides) { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssImages3Tests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssImages3Tests.cs new file mode 100644 index 000000000..48c472346 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssImages3Tests.cs @@ -0,0 +1,268 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[TestClass] +public sealed class CssImages3Tests +{ + // The adapter decodes every image as 40x30 + private const string Image = "url(data:image/png;base64,iVBORw0KGgo=)"; + + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + private static readonly RColor Blue = RColor.FromArgb(0, 0, 255); + + [TestMethod] + [Spec("css-images-3", "#url-notation")] + [Spec("css-images-3", "#default-sizing")] + public void UrlImage_IsDrawnAtItsNaturalSize() + { + var (g, _) = Paint($"<div id='b' style='width:100px;height:100px;background-image:{Image};background-repeat:no-repeat'></div>"); + + Assert.AreEqual(new RRect(0, 0, 40, 30), g.DrawImageCalls.Single().DestRect); + } + + [TestMethod] + [Spec("css-images-3", "#default-sizing")] + public void ImageWithOnlyAWidth_KeepsItsNaturalAspectRatio() + { + var root = TestImages.Layout($"<img id='i' src='{TestImages.Png(40, 30)}' style='width:80px'>"); + + var rect = TestImages.ImageRect(root, "i"); + + Assert.AreEqual(80, rect.Width, 0.5); + Assert.AreEqual(60, rect.Height, 0.5); + } + + [Ignore("Gap, see Spec/css-images-3.yml")] + [TestMethod] + [Spec("css-images-3", "#default-sizing")] + public void Gradient_IsSizedToThePaddingBox() + { + var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;border:10px solid black;background-image:linear-gradient(to right, red, blue)'></div>"); + + Assert.AreEqual(10, gradient!.P1.X, 0.5); + Assert.AreEqual(110, gradient.P2.X, 0.5); + } + + [TestMethod] + [DataRow("linear-gradient(red, blue)", 50, 0, 50, 50)] + [DataRow("linear-gradient(to right, red, blue)", 0, 25, 100, 25)] + [DataRow("linear-gradient(to top, red, blue)", 50, 50, 50, 0)] + [DataRow("linear-gradient(90deg, red, blue)", 0, 25, 100, 25)] + [DataRow("linear-gradient(0.25turn, red, blue)", 0, 25, 100, 25)] + [Spec("css-images-3", "#linear-gradient-syntax")] + public void LinearGradient_DirectionSetsTheGradientLine(string image, double x1, double y1, double x2, double y2) + { + var (_, gradient) = Paint($"<div id='b' style='width:100px;height:50px;background-image:{image}'></div>"); + + Assert.AreEqual(x1, gradient!.P1.X, 0.5); + Assert.AreEqual(y1, gradient.P1.Y, 0.5); + Assert.AreEqual(x2, gradient.P2.X, 0.5); + Assert.AreEqual(y2, gradient.P2.Y, 0.5); + } + + [TestMethod] + [Spec("css-images-3", "#linear-gradient-syntax")] + public void LinearGradient_AngledLineSpansTheBoxCorners() + { + // 45deg on 100x50: the line has length 100 sin 45 + 50 cos 45 + var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:linear-gradient(45deg, red, blue)'></div>"); + + var dx = gradient!.P2.X - gradient.P1.X; + var dy = gradient.P2.Y - gradient.P1.Y; + Assert.AreEqual(150 * Math.Sqrt(0.5), Math.Sqrt(dx * dx + dy * dy), 0.5); + Assert.AreEqual(dx, -dy, 0.01); + } + + [Ignore("Gap, see Spec/css-images-3.yml")] + [TestMethod] + [Spec("css-images-3", "#linear-gradient-syntax")] + public void LinearGradient_CornerDirectionDependsOnTheBoxShape() + { + var (_, gradient) = Paint("<div id='b' style='width:200px;height:100px;background-image:linear-gradient(to bottom right, red, blue)'></div>"); + + Assert.AreEqual(60, gradient!.P1.X, 0.5); + Assert.AreEqual(10, gradient.P1.Y, 0.5); + Assert.AreEqual(140, gradient.P2.X, 0.5); + Assert.AreEqual(90, gradient.P2.Y, 0.5); + } + + [TestMethod] + [Spec("css-images-3", "#radial-gradients")] + public void RadialGradient_IsAccepted() + { + var (root, _) = PaintHarness.Layout(PaintHarness.Wrap("<div id='b' style='background-image:radial-gradient(circle at top left, red, blue)'></div>")); + + StringAssert.StartsWith(PaintHarness.FindById(root, "b")!.BackgroundImage, "radial-gradient(circle"); + } + + [Ignore("Gap, see Spec/css-images-3.yml")] + [TestMethod] + [DataRow("radial-gradient(red, blue)")] + [DataRow("repeating-radial-gradient(red, blue 10px)")] + [Spec("css-images-3", "#radial-gradients")] + public void RadialGradient_IsPainted(string image) + { + var (g, _) = Paint($"<div id='b' style='width:100px;height:50px;background-image:{image}'></div>"); + + Assert.IsTrue(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any()); + } + + [TestMethod] + [Spec("css-images-3", "#repeating-gradients")] + public void RepeatingLinearGradient_RepeatsTheStopsAlongTheLine() + { + var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:repeating-linear-gradient(to right, red, blue 25px)'></div>"); + + var reds = gradient!.Stops.Where(s => s.Color == Red).Select(s => s.Position).ToList(); + CollectionAssert.AreEqual(new[] { 0, 0.25, 0.5, 0.75, 1 }, reds.Select(p => Math.Round(p, 3)).ToArray()); + } + + [TestMethod] + [Spec("css-images-3", "#color-stop-syntax")] + public void ColorStops_TakeLengthsPercentagesAndTwoPositions() + { + var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:linear-gradient(to right, red 10px, blue 40% 60%, red)'></div>"); + + CollectionAssert.AreEqual(new[] { 0.1, 0.4, 0.6, 1 }, gradient!.Stops.Select(s => Math.Round(s.Position, 3)).ToArray()); + CollectionAssert.AreEqual(new[] { Red, Blue, Blue, Red }, gradient.Stops.Select(s => s.Color).ToArray()); + } + + [Ignore("Gap, see Spec/css-images-3.yml")] + [TestMethod] + [Spec("css-images-3", "#color-stop-syntax")] + public void ColorStops_ResolveEmAgainstTheElementsFontSize() + { + var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;font-size:20px;background-image:linear-gradient(to right, red 1em, blue 2em)'></div>"); + + CollectionAssert.AreEqual(new[] { 0.2, 0.4 }, gradient!.Stops.Select(s => Math.Round(s.Position, 3)).ToArray()); + } + + [TestMethod] + [Spec("css-images-3", "#coloring-gradient-line")] + public void ColorHint_ShiftsTheMidpointOfTheTransition() + { + var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:linear-gradient(to right, rgb(0,0,0), 25%, rgb(200,200,200))'></div>"); + + // Half the color is reached at the hint, at 25% instead of 50% + var atHint = gradient!.Stops.Where(s => s.Position > 0 && s.Position < 1).OrderBy(s => Math.Abs(s.Color.R - 100)).First(); + Assert.IsTrue(atHint.Position < 0.3, $"half the color at {atHint.Position}"); + } + + [Ignore("Gap, see Spec/css-images-3.yml")] + [TestMethod] + [Spec("css-images-3", "#coloring-gradient-line")] + public void ColorsAreInterpolatedPremultiplied() + { + var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:linear-gradient(to right, red, 50%, transparent)'></div>"); + + // Fading red into transparent black keeps the red, only the alpha changes + foreach (var stop in gradient!.Stops.Where(s => s.Color.A > 0)) + Assert.AreEqual(255, stop.Color.R, $"stop at {stop.Position}"); + } + + [TestMethod] + [Spec("css-images-3", "#color-stop-fixup")] + public void ColorStopFixup_DefaultsTheEndsAndSpacesStopsEvenly() + { + var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:linear-gradient(to right, red, blue, red 60%, blue, red)'></div>"); + + CollectionAssert.AreEqual(new[] { 0, 0.3, 0.6, 0.8, 1 }, gradient!.Stops.Select(s => Math.Round(s.Position, 3)).ToArray()); + } + + [Ignore("Gap, see Spec/css-images-3.yml")] + [TestMethod] + [Spec("css-images-3", "#color-stop-fixup")] + public void ColorStopFixup_ClampsAStopBeforeThePreviousOne() + { + var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:linear-gradient(to right, red 50%, blue 20%)'></div>"); + + CollectionAssert.AreEqual(new[] { 0.5, 0.5 }, gradient!.Stops.Select(s => Math.Round(s.Position, 3)).ToArray()); + } + + [Ignore("Gap, see Spec/css-images-3.yml")] + [TestMethod] + [DataRow("contain", 0, 12.5, 100, 75)] + [DataRow("cover", -16.7, 0, 133.3, 100)] + [DataRow("none", 30, 35, 40, 30)] + [DataRow("scale-down", 30, 35, 40, 30)] + [Spec("css-images-3", "#the-object-fit")] + public void ObjectFit_SizesTheImageInTheContentBox(string fit, double x, double y, double width, double height) + { + var (g, _) = Paint($"<img id='b' src='{TestImages.Png(40, 30)}' style='display:block;width:100px;height:100px;object-fit:{fit}'>"); + + var image = g.DrawImageCalls.Single().DestRect; + Assert.AreEqual(new RRect(x, y, width, height).ToString(), new RRect(Math.Round(image.X, 1), Math.Round(image.Y, 1), Math.Round(image.Width, 1), Math.Round(image.Height, 1)).ToString()); + } + + [Ignore("Gap, see Spec/css-images-3.yml")] + [TestMethod] + [Spec("css-images-3", "#the-object-position")] + public void ObjectPosition_PlacesTheImageInTheContentBox() + { + var (g, _) = Paint($"<img id='b' src='{TestImages.Png(40, 30)}' style='display:block;width:100px;height:100px;object-fit:none;object-position:right 10px top'>"); + + var image = g.DrawImageCalls.Single().DestRect; + Assert.AreEqual(new RRect(50, 0, 40, 30), image); + } + + /// <summary>Lays out and paints the element with id "b", returns the draw calls and the last gradient brush.</summary> + private static (RecordingGraphics Graphics, GradientAdapter.Gradient? Gradient) Paint(string body) + { + var adapter = new GradientAdapter(); + var container = new HtmlContainerInt(adapter) + { + MaxSize = new RSize(1000, 4000), + Location = RPoint.Empty, + PageSize = new RSize(1000, 4000), + AvoidAsyncImagesLoading = true, + AvoidImagesLateLoading = true + }; + container.SetHtml(PaintHarness.Wrap(body)).GetAwaiter().GetResult(); + + using (var layoutGraphics = new RecordingGraphics(adapter)) + container.PerformLayout(layoutGraphics); + + var g = new RecordingGraphics(adapter); + PaintHarness.PaintBox(container, PaintHarness.FindById(container.Root!, "b")!, g); + return (g, adapter.Gradients.LastOrDefault()); + } + + /// <summary>Like <see cref="MockAdapter"/>, but remembers the linear gradient brushes it creates.</summary> + internal sealed class GradientAdapter : RAdapter + { + internal sealed record Gradient(RPoint P1, RPoint P2, (RColor Color, double Position)[] Stops); + + public List<Gradient> Gradients { get; } = []; + + protected override RColor GetColorInt(string colorName) + { + var color = System.Drawing.Color.FromName(colorName); + return RColor.FromArgb(color.A, color.R, color.G, color.B); + } + + protected override RPen CreatePen(RColor color) => new MockPen(color); + + protected override RBrush CreateSolidBrush(RColor color) => new MockBrush(color); + + protected override RBrush CreateLinearGradientBrush(RPoint p1, RPoint p2, (RColor Color, double Position)[] stops) + { + Gradients.Add(new Gradient(p1, p2, stops)); + return new MockBrush(stops.Length > 0 ? stops[0].Color : RColor.Black); + } + + protected override RImage ConvertImageInt(object image) => image as RImage ?? new MockImage(0, 0); + + protected override RImage ImageFromStreamInt(System.IO.Stream memoryStream) => new MockImage(40, 30); + + protected override RFont CreateFontInt(string family, double size, RFontStyle style) => new MockFont(size); + + protected override RFont CreateFontInt(RFontFamily family, double size, RFontStyle style) => new MockFont(size); + + protected override RFontFamily LoadFontFaceFontInt(byte[] fontBytes, string filePath) => new MockFontFamily(filePath); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs b/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs index 976f06174..e9d637d94 100644 --- a/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs +++ b/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs @@ -101,6 +101,7 @@ public void RtlMarker_IsRightOfTheContent() [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "generate.html#list-style")] [Spec("css-lists-3", "#image-markers")] + [Spec("css-images-3", "#image-values")] public void ListStyleImage_ReplacesTheMarker() { var x = Box("<ul style='list-style-image:url(data:image/png;base64,iVBORw0KGgo=)'><li id=x>a</li></ul>"); diff --git a/Source/Test/HtmlRenderer.Test/Css/GradientTests.cs b/Source/Test/HtmlRenderer.Test/Css/GradientTests.cs index 05e31551c..6fe1f4cfc 100644 --- a/Source/Test/HtmlRenderer.Test/Css/GradientTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/GradientTests.cs @@ -7,7 +7,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary>Ported from PeachPDF.Tests/CSS/Gradient.cs.</summary> [TestClass] public sealed class GradientTests { @@ -29,6 +28,7 @@ public void InRadialGradient() } [TestMethod] + [Spec("css-images-3", "#linear-gradient-syntax")] public void BackgroundImageLinearGradientWithAngle() { var source = "background-image: linear-gradient(135deg, red, blue)"; @@ -40,6 +40,7 @@ public void BackgroundImageLinearGradientWithAngle() } [TestMethod] + [Spec("css-images-3", "#linear-gradient-syntax")] public void BackgroundImageLinearGradientWithSide() { var source = "background-image: linear-gradient(to right, red, orange, yellow, green, blue, indigo, violet)"; @@ -51,6 +52,7 @@ public void BackgroundImageLinearGradientWithSide() } [TestMethod] + [Spec("css-images-3", "#linear-gradient-syntax")] public void BackgroundImageLinearGradientWithCornerAndRgba() { var source = "background-image: linear-gradient(to bottom right, red, rgba(255,0,0,0))"; @@ -62,6 +64,7 @@ public void BackgroundImageLinearGradientWithCornerAndRgba() } [TestMethod] + [Spec("css-images-3", "#linear-gradient-syntax")] public void BackgroundImageLinearGradientWithSideAndHsl() { var source = "background-image: linear-gradient(to bottom, hsl(0, 80%, 70%), #bada55)"; @@ -73,6 +76,7 @@ public void BackgroundImageLinearGradientWithSideAndHsl() } [TestMethod] + [Spec("css-images-3", "#linear-gradient-syntax")] public void BackgroundImageLinearGradientNoAngle() { var source = "background-image: linear-gradient(yellow, blue 20%, #0f0)"; @@ -84,6 +88,7 @@ public void BackgroundImageLinearGradientNoAngle() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientCircleFarthestCorner() { var source = "background-image: radial-gradient(circle farthest-corner at 45px 45px , #00FFFF 0%, rgba(0, 0, 255, 0) 50%, #0000FF 95%)"; @@ -95,6 +100,7 @@ public void BackgroundImageRadialGradientCircleFarthestCorner() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientEllipseFarthestCorner() { var source = "background-image: radial-gradient(ellipse farthest-corner at 470px 47px , #FFFF80 20%, rgba(204, 153, 153, 0.4) 30%, #E6E6FF 60%)"; @@ -106,6 +112,7 @@ public void BackgroundImageRadialGradientEllipseFarthestCorner() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientFarthestCornerWithPoint() { var source = "background-image: radial-gradient(farthest-corner at 45px 45px , #FF0000 0%, #0000FF 100%)"; @@ -117,6 +124,7 @@ public void BackgroundImageRadialGradientFarthestCornerWithPoint() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientSingleSize() { var source = "background-image: radial-gradient(16px at 60px 50% , #000000 0%, #000000 14px, rgba(0, 0, 0, 0.3) 18px, rgba(0, 0, 0, 0) 19px)"; @@ -128,6 +136,7 @@ public void BackgroundImageRadialGradientSingleSize() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientCircle() { var source = "background-image: radial-gradient(circle, yellow, green)"; @@ -139,6 +148,7 @@ public void BackgroundImageRadialGradientCircle() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientOnlyGradientStops() { var source = "background-image: radial-gradient(yellow, green)"; @@ -150,6 +160,7 @@ public void BackgroundImageRadialGradientOnlyGradientStops() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientEllipseAtCenter() { var source = "background-image: radial-gradient(ellipse at center, yellow 0%, green 100%)"; @@ -161,6 +172,7 @@ public void BackgroundImageRadialGradientEllipseAtCenter() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientFarthestCornerWithoutPoint() { var source = "background-image: radial-gradient(farthest-corner, yellow, green)"; @@ -172,6 +184,7 @@ public void BackgroundImageRadialGradientFarthestCornerWithoutPoint() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientClosestSideWithPoint() { var source = "background-image: radial-gradient(closest-side at 20px 30px, red, yellow, green)"; @@ -183,6 +196,7 @@ public void BackgroundImageRadialGradientClosestSideWithPoint() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientSizeAndPoint() { var source = "background-image: radial-gradient(20px 30px at 20px 30px, red, yellow, green)"; @@ -194,6 +208,7 @@ public void BackgroundImageRadialGradientSizeAndPoint() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientClosestSideCircleShuffledWithPoint() { var source = "background-image: radial-gradient(closest-side circle at 20px 30px, red, yellow, green)"; @@ -205,6 +220,7 @@ public void BackgroundImageRadialGradientClosestSideCircleShuffledWithPoint() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientFarthestSideLeftBottom() { var source = "background-image: radial-gradient(farthest-side at left bottom, red, yellow 50px, green);"; @@ -218,6 +234,7 @@ public void BackgroundImageRadialGradientFarthestSideLeftBottom() // ── Percentage-based positions (used by ParseRadialGradient renderer parser) ── [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientAtPercentPosition() { var source = "background-image: radial-gradient(at 30% 70%, red, blue)"; @@ -229,6 +246,7 @@ public void BackgroundImageRadialGradientAtPercentPosition() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientEllipseAtPercentPosition() { var source = "background-image: radial-gradient(ellipse at 25% 75%, yellow, green)"; @@ -240,6 +258,7 @@ public void BackgroundImageRadialGradientEllipseAtPercentPosition() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientCircleAtPercentPosition() { var source = "background-image: radial-gradient(circle at 80% 20%, orange, crimson)"; @@ -251,6 +270,7 @@ public void BackgroundImageRadialGradientCircleAtPercentPosition() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientAtCenterKeyword() { var source = "background-image: radial-gradient(at center, gold, navy)"; @@ -262,6 +282,7 @@ public void BackgroundImageRadialGradientAtCenterKeyword() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientThreeStopsNoShape() { var source = "background-image: radial-gradient(red, yellow 50%, blue)"; @@ -273,6 +294,7 @@ public void BackgroundImageRadialGradientThreeStopsNoShape() } [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] public void BackgroundImageRadialGradientRgbaTransparency() { var source = "background-image: radial-gradient(rgba(255,0,0,0), rgba(255,0,0,1))"; @@ -284,6 +306,7 @@ public void BackgroundImageRadialGradientRgbaTransparency() } [TestMethod] + [Spec("css-images-3", "#repeating-gradients")] public void BackgroundImageRepeatingLinearGradientRedBlue() { var source = "background-image: repeating-linear-gradient(red, blue 20px, red 40px)"; @@ -295,6 +318,7 @@ public void BackgroundImageRepeatingLinearGradientRedBlue() } [TestMethod] + [Spec("css-images-3", "#repeating-gradients")] public void BackgroundImageRepeatingRadialGradientRedBlue() { var source = "background-image: repeating-radial-gradient(red, blue 20px, red 40px)"; @@ -306,6 +330,7 @@ public void BackgroundImageRepeatingRadialGradientRedBlue() } [TestMethod] + [Spec("css-images-3", "#repeating-gradients")] public void BackgroundImageRepeatingRadialGradientFunky() { var source = "background-image: repeating-radial-gradient(circle closest-side at 20px 30px, red, yellow, green 100%, yellow 150%, red 200%)"; @@ -328,6 +353,7 @@ public void BackgroundImageRepeatingRadialGradientFunky() "linear-gradient(90deg, rgb(255, 0, 0) 0 8px, rgb(0, 0, 255))")] [DataRow("background-image: radial-gradient(red 0 8px, blue)", "radial-gradient(rgb(255, 0, 0) 0 8px, rgb(0, 0, 255))")] + [Spec("css-images-3", "#color-stop-syntax")] public void GradientTwoPositionColorStopRoundTrips(string snippet, string expected) { var property = CssConstructionFunctions.ParseDeclaration(snippet); @@ -344,6 +370,7 @@ public void GradientTwoPositionColorStopRoundTrips(string snippet, string expect [DataRow("background-image: linear-gradient(red, 25%, blue)", true)] [DataRow("background-image: linear-gradient(red 0 25% 50%, blue)", false)] [DataRow("background-image: linear-gradient(0 50%, blue)", false)] + [Spec("css-images-3", "#color-stop-syntax")] public void GradientColorStopValidity(string snippet, bool valid) { var property = CssConstructionFunctions.ParseDeclaration(snippet); diff --git a/Source/Test/HtmlRenderer.Test/Css/ObjectSizingPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/ObjectSizingPropertyTests.cs index f2200d09f..26ed1d98f 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ObjectSizingPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ObjectSizingPropertyTests.cs @@ -4,17 +4,11 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/ObjectSizing.cs. Exercises <c>object-fit</c> -/// (<see cref="ObjectFitProperty"/>, Source/HtmlRenderer/Core/CssEngine/StyleProperties/Sizing/ObjectFitProperty.cs) -/// and <c>object-position</c> (<see cref="ObjectPositionProperty"/>, same directory) through the CSS engine's -/// declaration parser, mirroring PeachPDF's <c>ParseDeclaration</c> helper via -/// <see cref="ParseDeclaration"/>. -/// </summary> [TestClass] public sealed class ObjectSizingPropertyTests { [TestMethod] + [Spec("css-images-3", "#the-object-fit")] public void CssObjectFitNoneLegal() { var property = ParseDeclaration("object-fit : none"); @@ -29,6 +23,7 @@ public void CssObjectFitNoneLegal() } [TestMethod] + [Spec("css-images-3", "#the-object-fit")] public void ObjectFitScaledownIllegal() { var property = ParseDeclaration("object-fit : scaledown"); @@ -42,6 +37,7 @@ public void ObjectFitScaledownIllegal() } [TestMethod] + [Spec("css-images-3", "#the-object-fit")] public void ObjectFitScaleDownLegal() { var property = ParseDeclaration("object-fit : scale-DOWN"); @@ -56,6 +52,7 @@ public void ObjectFitScaleDownLegal() } [TestMethod] + [Spec("css-images-3", "#the-object-fit")] public void CssObjectFitCoverLegal() { var property = ParseDeclaration("object-fit : cover"); @@ -70,6 +67,7 @@ public void CssObjectFitCoverLegal() } [TestMethod] + [Spec("css-images-3", "#the-object-fit")] public void CssObjectFitContainLegal() { var property = ParseDeclaration("object-fit : contain"); @@ -84,6 +82,7 @@ public void CssObjectFitContainLegal() } [TestMethod] + [Spec("css-images-3", "#the-object-position")] public void CssObjectPositionCenterLegal() { var property = ParseDeclaration("object-position : center"); @@ -98,6 +97,7 @@ public void CssObjectPositionCenterLegal() } [TestMethod] + [Spec("css-images-3", "#the-object-position")] public void ObjectPositionTopLeftIllegal() { var property = ParseDeclaration("object-position : top-left"); @@ -111,6 +111,7 @@ public void ObjectPositionTopLeftIllegal() } [TestMethod] + [Spec("css-images-3", "#the-object-position")] public void ObjectPositionTopLeftLegal() { var property = ParseDeclaration("object-position : top left"); @@ -125,6 +126,7 @@ public void ObjectPositionTopLeftLegal() } [TestMethod] + [Spec("css-images-3", "#the-object-position")] public void CssObjectPosition5050Legal() { var property = ParseDeclaration("object-position : 50% 50% "); @@ -139,6 +141,7 @@ public void CssObjectPosition5050Legal() } [TestMethod] + [Spec("css-images-3", "#the-object-position")] public void CssObjectPositionLeft30Legal() { var property = ParseDeclaration("object-position : left 30px"); diff --git a/Spec/css-images-3.yml b/Spec/css-images-3.yml index 04b24063e..7beaf8f78 100644 --- a/Spec/css-images-3.yml +++ b/Spec/css-images-3.yml @@ -5,106 +5,126 @@ snapshot: https://www.w3.org/TR/2023/CRD-css-images-3-20231218/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#image-values': title: '2 Image Values: the <image> type' - status: unknown + status: partial + gaps: + - Only 'background-image' paints images, 'list-style-image' is ignored, see CSS 2.1 12.5.1 '#url-notation': title: '2.1 Image References: the url() notation' - status: unknown + status: supported '#ambiguous-urls': title: 2.1.1 Ambiguous Reference-or-Image URLs - status: unknown + status: n/a '#url-metadata': title: 2.1.2 Image Metadata - status: unknown + status: n/a '#gradients': title: 3 Gradients - status: unknown + status: partial '#linear-gradients': title: '3.1 Linear Gradients: the linear-gradient() notation' - status: unknown + status: partial '#linear-gradient-syntax': title: 3.1.1 linear-gradient() syntax - status: unknown + status: partial + gaps: + - A corner direction like 'to bottom right' is a fixed multiple of 45deg instead of depending on the box's aspect ratio '#linear-gradient-examples': title: 3.1.2 Linear Gradient Examples - status: unknown + status: n/a '#radial-gradients': title: '3.2 Radial Gradients: the radial-gradient() notation' - status: unknown + status: parsed-only + gaps: + - Radial gradients are accepted but not painted, the background stays empty '#radial-gradient-syntax': title: 3.2.1 radial-gradient() Syntax - status: unknown + status: parsed-only '#radial-color-stops': title: 3.2.2 Placing Color Stops - status: unknown + status: unsupported '#degenerate-radials': title: 3.2.3 Degenerate Radial Gradients - status: unknown + status: unsupported '#radial-gradient-examples': title: 3.2.4 Radial Gradient Examples - status: unknown + status: n/a '#repeating-gradients': title: '3.3 Repeating Gradients: the repeating-linear-gradient() and repeating-radial-gradient() notations' - status: unknown + status: partial + gaps: + - repeating-radial-gradient() isn't painted, see 3.2 '#gradient-colors': title: 3.4 Defining Gradient Color - status: unknown + status: partial '#color-stop-syntax': title: 3.4.1 Color Stop Lists - status: unknown + status: partial + gaps: + - em positions are resolved against 16px instead of the element's font size '#coloring-gradient-line': title: 3.4.2 Coloring the Gradient Line - status: unknown + status: partial + gaps: + - Colors are interpolated without premultiplied alpha, a fade to transparent darkens '#color-stop-fixup': title: 3.4.3 Color Stop “Fixup” - status: unknown + status: partial + gaps: + - A stop positioned before a previous stop isn't moved up to it '#sizing': title: 4 Sizing Images and Objects in CSS - status: unknown + status: partial '#sizing-terms': title: 4.1 Object-Sizing Terminology - status: unknown + status: n/a '#object-negotiation': title: 4.2 CSS⇋Object Negotiation - status: unknown + status: n/a '#concrete-size-resolution': title: 4.3 Concrete Object Size Resolution - status: unknown + status: partial '#default-sizing': title: 4.3.1 Default Sizing Algorithm - status: unknown + status: partial + gaps: + - A gradient background is sized to the border box instead of the background positioning area '#cover-contain': title: 4.3.2 Cover and Contain Constraint Sizing - status: unknown + status: unsupported + gaps: + - Nothing is sized with contain or cover, 'background-size' and 'object-fit' are ignored '#object-sizing-examples': title: 4.4 Examples of CSS Object Sizing - status: unknown + status: n/a '#the-object-fit': title: '4.5 Sizing Objects: the object-fit property' - status: unknown + status: parsed-only + gaps: + - "'object-fit' is ignored, the image is always stretched to the content box" '#the-object-position': title: '4.6 Positioning Objects: the object-position property' - status: unknown + status: parsed-only + gaps: + - "'object-position' is ignored" '#image-processing': title: 5 Image Processing - status: unknown + status: unsupported '#the-image-orientation': title: '5.1 Orienting an Image on the Page: the image-orientation property' - status: unknown + status: unsupported + gaps: + - "'image-orientation' isn't parsed" '#the-image-rendering': title: '5.2 Determining How To Scale an Image: the image-rendering property' - status: unknown + status: unsupported + gaps: + - "'image-rendering' isn't parsed, the platform decides how images are scaled" '#interpolation': title: 6 Interpolation - status: unknown + status: n/a '#serialization': title: 7 Serialization - status: unknown + status: n/a From 41bdb2a176a43f1c2649119dd6c562d0fa96c2f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:17:38 +0100 Subject: [PATCH 075/150] Verify and document CSS Basic User Interface Level 3 compliance --- .../Core/Handlers/OutlineDrawHandler.cs | 2 +- .../Painting/CssUi3Tests.cs | 88 +++++++++++++++++ .../OutlineStylePaintIntegrationTests.cs | 14 ++- .../Test/HtmlRenderer.Test/Css/CssUi3Tests.cs | 99 +++++++++++++++++++ .../Css/OutlinePropertyTests.cs | 15 +++ Spec/css-ui-3.yml | 72 ++++++++------ 6 files changed, 255 insertions(+), 35 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/CssUi3Tests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Css/CssUi3Tests.cs diff --git a/Source/HtmlRenderer/Core/Handlers/OutlineDrawHandler.cs b/Source/HtmlRenderer/Core/Handlers/OutlineDrawHandler.cs index 426bb294b..3b28ad349 100644 --- a/Source/HtmlRenderer/Core/Handlers/OutlineDrawHandler.cs +++ b/Source/HtmlRenderer/Core/Handlers/OutlineDrawHandler.cs @@ -18,7 +18,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.Handlers { /// <summary> - /// Paints <c>outline</c>, CSS 2.1 §18.4, as a ring of four rectangles just outside the border box. + /// Paints <c>outline</c>, CSS 2.1 §18.4 and CSS UI 3 §4, as a ring of four rectangles just outside the border box. /// </summary> internal static class OutlineDrawHandler { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssUi3Tests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssUi3Tests.cs new file mode 100644 index 000000000..a5a1e8aa3 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssUi3Tests.cs @@ -0,0 +1,88 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[TestClass] +public sealed class CssUi3Tests +{ + private static readonly RColor Outline = RColor.FromArgb(51, 51, 51); + + [TestMethod] + [DataRow("content-box", 130, 80)] + [DataRow("border-box", 100, 50)] + [Spec("css-ui-3", "#box-sizing")] + [Spec("css-sizing-3", "#box-sizing")] + public void BoxSizing_SelectsTheBoxWidthAndHeightApplyTo(string sizing, double width, double height) + { + var box = Layout($"<div id='b' style='box-sizing:{sizing};width:100px;height:50px;padding:10px;border:5px solid black'></div>"); + + Assert.AreEqual(width, box.Size.Width, 0.5); + Assert.AreEqual(height, box.Size.Height, 0.5); + } + + [Ignore("Gap, see Spec/css-ui-3.yml")] + [TestMethod] + [Spec("css-ui-3", "#box-sizing")] + public void BoxSizingBorderBox_ContentSizeIsNeverNegative() + { + var box = Layout("<div id='b' style='box-sizing:border-box;width:10px;height:10px;padding:10px;border:5px solid black'></div>"); + + Assert.AreEqual(30, box.Size.Width, 0.5); + Assert.AreEqual(30, box.Size.Height, 0.5); + } + + [TestMethod] + [Spec("css-ui-3", "#outline-width")] + public void OutlineWidth_TakesTheBorderWidthKeywords() + { + var g = Paint("<div id='b' style='width:100px;height:50px;outline:thick solid rgb(51,51,51)'>x</div>"); + + var top = g.Log.OfType<RecordingGraphics.DrawRectCall>().First(r => r.Color == Outline); + Assert.AreEqual(5, top.Height, 0.1); + } + + [Ignore("Gap, see Spec/css-ui-3.yml")] + [TestMethod] + [Spec("css-ui-3", "#outline-width")] + public void OutlineWidth_InitialValueIsMedium() + { + var g = Paint("<div id='b' style='width:100px;height:50px;outline-style:solid;outline-color:rgb(51,51,51)'>x</div>"); + + var top = g.Log.OfType<RecordingGraphics.DrawRectCall>().First(r => r.Color == Outline); + Assert.AreEqual(3, top.Height, 0.1); + } + + [Ignore("Gap, see Spec/css-ui-3.yml")] + [TestMethod] + [Spec("css-ui-3", "#outline-props")] + public void Outline_FollowsTheBorderRadius() + { + var g = Paint("<div id='b' style='width:100px;height:50px;border-radius:10px;outline:5px solid rgb(51,51,51)'>x</div>"); + + Assert.IsFalse(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == Outline), "the outline is a ring of rectangles"); + } + + [Ignore("Gap, see Spec/css-ui-3.yml")] + [TestMethod] + [Spec("css-ui-3", "#text-overflow")] + public void TextOverflowEllipsis_EndsTheClippedLineWithAnEllipsis() + { + var g = Paint("<div id='b' style='width:50px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis'>a very long line of text</div>"); + + Assert.IsTrue(g.DrawStringCalls.Any(s => s.Text.Contains('…'))); + } + + private static TheArtOfDev.HtmlRenderer.Core.Dom.CssBox Layout(string body) + { + var (root, _) = PaintHarness.Layout(PaintHarness.Wrap(body)); + return PaintHarness.FindById(root, "b")!; + } + + private static RecordingGraphics Paint(string body) + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap(body)); + return PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs index 1c0f2959a..37dba369b 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs @@ -10,6 +10,7 @@ public sealed class OutlineStylePaintIntegrationTests { [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] + [Spec("css-ui-3", "#outline")] public void SolidOutline_PaintsAFourSidedRingAroundTheBorderBox() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -27,8 +28,9 @@ public void SolidOutline_PaintsAFourSidedRingAroundTheBorderBox() Assert.AreEqual(2, rects.Count(r => System.Math.Abs(r.Width - 5) < 0.1)); } - [Ignore("outline-style: auto (CSS UI 3) isn't parsed")] + [Ignore("Gap, see Spec/css-ui-3.yml")] [TestMethod] + [Spec("css-ui-3", "#outline-style")] public void OutlineAuto_PaintsTheSameAsSolid() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -44,6 +46,7 @@ public void OutlineAuto_PaintsTheSameAsSolid() [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] + [Spec("css-ui-3", "#outline-color")] public void OutlineColorUnset_FallsBackToCurrentColor() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -78,6 +81,7 @@ public void Outline_PaintsAfterBorder() [DataRow("none")] [DataRow("hidden")] [Spec("css21", "ui.html#dynamic-outlines")] + [Spec("css-ui-3", "#outline-style")] public void OutlineStyleNoneOrHidden_PaintsNothing(string style) { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -91,6 +95,7 @@ public void OutlineStyleNoneOrHidden_PaintsNothing(string style) [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] + [Spec("css-ui-3", "#outline-width")] public void ZeroWidthOutline_PaintsNothing() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -102,8 +107,9 @@ public void ZeroWidthOutline_PaintsNothing() Assert.IsFalse(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == RColor.FromArgb(51, 51, 51))); } - [Ignore("outline-offset (CSS UI 3) isn't parsed")] + [Ignore("Gap, see Spec/css-ui-3.yml")] [TestMethod] + [Spec("css-ui-3", "#outline-offset")] public void OutlineOffset_PushesTheRingFurtherFromTheBorderBox() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -162,6 +168,7 @@ public void InlineSplitAroundABlock_KeepsTheOutlineOnEveryPart() [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] + [Spec("css-ui-3", "#outline-props")] public void Outline_DoesNotTakeUpSpace() { var (root, _) = PaintHarness.Layout(PaintHarness.Wrap( @@ -185,6 +192,7 @@ public void Outline_DoesNotTakeUpSpace() [DataRow("inset")] [DataRow("outset")] [Spec("css21", "ui.html#dynamic-outlines")] + [Spec("css-ui-3", "#outline-style")] public void OutlineStyle_AcceptsEveryBorderStyle(string style) { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -200,6 +208,7 @@ public void OutlineStyle_AcceptsEveryBorderStyle(string style) [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] + [Spec("css-ui-3", "#outline-props")] public void Outline_IsPaintedOnTopOfTheContent() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -218,6 +227,7 @@ public void Outline_IsPaintedOnTopOfTheContent() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] + [Spec("css-ui-3", "#outline-props")] public void InlineBrokenAcrossLines_GetsOneOutlineEnclosingAllItsBoxes() { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.Test/Css/CssUi3Tests.cs b/Source/Test/HtmlRenderer.Test/Css/CssUi3Tests.cs new file mode 100644 index 000000000..111878450 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/CssUi3Tests.cs @@ -0,0 +1,99 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.Test.Css; + +[TestClass] +public sealed class CssUi3Tests +{ + [TestMethod] + [Spec("css-ui-3", "#box-sizing")] + [Spec("css-sizing-3", "#box-sizing")] + public void BoxSizing_IsNotInherited() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div style='box-sizing:border-box'><p id=x>a</p></div>")); + + Assert.AreEqual("content-box", LayoutHarness.FindById(root, "x")!.BoxSizing); + } + + [Ignore("Gap, see Spec/css-ui-3.yml")] + [TestMethod] + [Spec("css-ui-3", "#outline-style")] + public void OutlineStyleAuto_IsAccepted() + { + var property = CssConstructionFunctions.ParseDeclaration("outline-style: auto"); + + Assert.IsTrue(property.HasValue); + } + + [Ignore("Gap, see Spec/css-ui-3.yml")] + [TestMethod] + [Spec("css-ui-3", "#outline-color")] + public void OutlineColorInvert_IsRejectedWithoutColorInversion() + { + var property = CssConstructionFunctions.ParseDeclaration("outline-color: invert"); + + Assert.IsFalse(property.HasValue); + } + + [TestMethod] + [DataRow("cursor: pointer")] + [DataRow("cursor: url(hand.cur) 2 3, pointer")] + [Spec("css-ui-3", "#cursor")] + public void Cursor_IsAccepted(string declaration) + { + var property = CssConstructionFunctions.ParseDeclaration(declaration); + + Assert.IsTrue(property.HasValue); + } + + [Ignore("Gap, see Spec/css-ui-3.yml")] + [TestMethod] + [Spec("css-ui-3", "#cursor")] + public void Cursor_SetsThePointerOverTheElement() + { + var (_, container) = LayoutHarness.Layout(LayoutHarness.Wrap("<div style='cursor:pointer;height:100px'></div>")); + var control = new CursorControl(container.Adapter); + + container.HandleMouseMove(control, new RPoint(50, 50)); + + Assert.AreEqual("hand", control.Cursor); + } + + [TestMethod] + [DataRow("resize: both")] + [DataRow("caret-color: red")] + [DataRow("text-overflow: ellipsis")] + [DataRow("outline-offset: 2px")] + [DataRow("image-rendering: pixelated")] + [DataRow("image-orientation: none")] + public void UnsupportedProperties_AreDropped(string declaration) + { + var style = CssConstructionFunctions.ParseDeclarations(declaration); + + Assert.AreEqual(0, style.Length); + } + + private sealed class CursorControl(RAdapter adapter) : RControl(adapter) + { + public string? Cursor { get; private set; } + + public override bool LeftMouseButton => false; + public override bool RightMouseButton => false; + public override RPoint MouseLocation => RPoint.Empty; + public override void SetCursorDefault() => Cursor = "default"; + public override void SetCursorHand() => Cursor = "hand"; + public override void SetCursorIBeam() => Cursor = "ibeam"; + public override void DoDragDropCopy(object dragDropData) { } + + public override void MeasureString(string str, RFont font, double maxWidth, out int charFit, out double charFitWidth) + { + charFit = str.Length; + charFitWidth = 0; + } + + public override void Invalidate() { } + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs index abbeeb764..c9b443981 100644 --- a/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs @@ -8,6 +8,7 @@ public sealed class OutlinePropertyTests { [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] + [Spec("css-ui-3", "#outline-style")] public void CssOutlineStyleDottedLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-style : dotTED"); @@ -22,6 +23,7 @@ public void CssOutlineStyleDottedLegal() [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] + [Spec("css-ui-3", "#outline-style")] public void CssOutlineStyleSolidLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-style : solid"); @@ -35,6 +37,7 @@ public void CssOutlineStyleSolidLegal() } [TestMethod] + [Spec("css-ui-3", "#outline-style")] public void CssOutlineStyleNoIllegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-style : no"); @@ -49,6 +52,7 @@ public void CssOutlineStyleNoIllegal() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] + [Spec("css-ui-3", "#outline-style")] public void CssOutlineStyleHiddenIllegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-style : hidden"); @@ -71,6 +75,7 @@ public void CssOutlineColorInvertLegal() } [TestMethod] + [Spec("css-ui-3", "#outline-color")] public void CssOutlineColorHslLegal() { // hsl(320, 80%, 50%) is equivalent to rgba(229, 26, 161, 1) @@ -85,6 +90,7 @@ public void CssOutlineColorHslLegal() } [TestMethod] + [Spec("css-ui-3", "#outline-color")] public void CssOutlineColorHexLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-color : #0000FF "); @@ -98,6 +104,7 @@ public void CssOutlineColorHexLegal() } [TestMethod] + [Spec("css-ui-3", "#outline-color")] public void CssOutlineColorRedLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-color : red "); @@ -111,6 +118,7 @@ public void CssOutlineColorRedLegal() } [TestMethod] + [Spec("css-ui-3", "#outline-color")] public void CssOutlineColorIllegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-color : blau "); @@ -123,6 +131,7 @@ public void CssOutlineColorIllegal() } [TestMethod] + [Spec("css-ui-3", "#outline-width")] public void CssOutlineWidthThinImportantLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-width : thin !important"); @@ -136,6 +145,7 @@ public void CssOutlineWidthThinImportantLegal() } [TestMethod] + [Spec("css-ui-3", "#outline-width")] public void CssOutlineWidthNumberIllegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-width : 3"); @@ -148,6 +158,7 @@ public void CssOutlineWidthNumberIllegal() } [TestMethod] + [Spec("css-ui-3", "#outline-width")] public void CssOutlineWidthLengthLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-width : 0.1em"); @@ -190,6 +201,7 @@ public void CssOutlineDualLegal() [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] + [Spec("css-ui-3", "#outline")] public void CssOutlineAllDottedLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline : dotted 0.3em rgb(255, 255, 255)"); @@ -203,6 +215,7 @@ public void CssOutlineAllDottedLegal() } [TestMethod] + [Spec("css-ui-3", "#outline")] public void CssOutlineDoubleColorIllegal() { var property = CssConstructionFunctions.ParseDeclaration("outline : dotted #123456 rgb(255, 255, 255)"); @@ -216,6 +229,7 @@ public void CssOutlineDoubleColorIllegal() [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] + [Spec("css-ui-3", "#outline")] public void CssOutlineAllSolidLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline : 1px solid #000"); @@ -229,6 +243,7 @@ public void CssOutlineAllSolidLegal() } [TestMethod] + [Spec("css-ui-3", "#outline")] public void CssOutlineAllColorNamedLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline : solid black 1px"); diff --git a/Spec/css-ui-3.yml b/Spec/css-ui-3.yml index b2fafae8d..da0241d01 100644 --- a/Spec/css-ui-3.yml +++ b/Spec/css-ui-3.yml @@ -5,73 +5,81 @@ snapshot: https://www.w3.org/TR/2026/REC-css-ui-3-20260407/ sections: '#intro': title: 1 Introduction - status: unknown - '#purpose': - title: 1.1 Purpose - status: unknown + status: n/a '#interaction': title: 2 Module Interactions - status: unknown - '#values': - title: 2.1 Value Definitions - status: unknown + status: n/a '#box-model': title: 3 Box Model addition - status: unknown + status: partial '#box-sizing': title: '3.1 Changing the Box Model: the box-sizing property' - status: unknown + status: partial + gaps: + - With 'border-box', padding and border wider than 'width' or 'height' make the content size negative instead of 0 '#outline-props': title: 4 Outline properties - status: unknown + status: partial + gaps: + - The outline is painted right after the box's border, see CSS 2.1 18.4 + - An inline broken across lines gets a separate ring per line box, see CSS 2.1 18.4 + - The outline doesn't follow the 'border-radius' curve '#outline': title: '4.1 Outlines Shorthand: the outline property' - status: unknown + status: supported '#outline-width': title: '4.2 Outline Thickness: the outline-width property' - status: unknown + status: partial + gaps: + - The initial width is 2px instead of 'medium' '#outline-style': title: '4.3 Outline Patterns: the outline-style property' - status: unknown + status: partial + gaps: + - Only solid outlines are painted, see CSS 2.1 18.4 + - "'hidden' is accepted, see CSS 2.1 18.4" + - "'auto' isn't accepted" '#outline-color': title: '4.4 Outline Colors: the outline-color property' - status: unknown + status: partial + gaps: + - "'invert' is accepted and painted as currentcolor instead of being rejected" '#outline-offset': title: '4.5 Offsetting the Outline: the outline-offset property' - status: unknown + status: unsupported + gaps: + - "'outline-offset' isn't parsed" '#resizing-and-overflow': title: 5 Resizing & Overflow - status: unknown + status: unsupported '#resize': title: '5.1 Resizing Boxes: the resize property' - status: unknown + status: n/a '#text-overflow': title: '5.2 Overflow Ellipsis: the text-overflow property' - status: unknown + status: unsupported + gaps: + - "'text-overflow' isn't parsed, overflowing text is clipped" '#pointing-keyboard': title: 6 Pointing Devices and Keyboards - status: unknown + status: parsed-only '#pointer-interaction': title: 6.1 Pointer interaction - status: unknown + status: parsed-only '#cursor': title: '6.1.1 Styling the Cursor: the cursor property' - status: unknown + status: parsed-only + gaps: + - "'cursor' is ignored, the controls show a hand over links, an I-beam over text and the arrow elsewhere" '#predefined-cursors': title: 6.1.1.1 Predefined Cursors - status: unknown + status: parsed-only '#canvas_cursor': title: 6.1.1.2 Cursor of the canvas - status: unknown + status: parsed-only '#insertion-caret': title: 6.2 Insertion caret - status: unknown - '#caret-color': - title: '6.2.1 Coloring the Insertion Caret: the caret-color property' - status: unknown + status: n/a '#keyboard': title: 6.3 Keyboard control - status: unknown - '#input-method-editor': - title: '6.3.1 Obsolete: the ime-mode property' - status: unknown + status: n/a From 0aab1b2ec668be08dc2031a9008e29e8f35a773a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:07:47 +0100 Subject: [PATCH 076/150] Verify and document CSS Fonts Level 4 compliance --- .../Adapters/WinFormsAdapter.cs | 41 +- Source/HtmlRenderer/Adapters/RAdapter.cs | 67 ++-- .../Core/CssEngine/Model/Converters.cs | 12 +- .../Core/Handlers/FontsHandler.cs | 51 +-- .../HtmlRenderer/Core/Parse/CssValueParser.cs | 14 +- Source/HtmlRenderer/Core/Parse/DomParser.cs | 11 +- .../Core/Utils/FontFaceDescriptorResolver.cs | 25 +- .../Core/Utils/FontStretchResolver.cs | 13 +- .../Core/Utils/UnicodeRangeParser.cs | 25 +- .../Text/CssFonts4IntegrationTests.cs | 341 +++++++++++++++++ .../Text/FontIntegrationTests.cs | 43 +++ .../Text/SmallCapsIntegrationTests.cs | 3 + .../Css/CssFonts4PropertyTests.cs | 100 +++++ .../HtmlRenderer.Test/Css/FontFaceTests.cs | 8 +- .../Css/FontFamilyPropertyTests.cs | 10 + .../Css/FontKeywordPropertyTests.cs | 33 +- .../Css/FontPaletteParsingTests.cs | 11 +- .../Css/FontShorthandPropertyTests.cs | 11 + Spec/css-fonts-4.yml | 360 ++++++++---------- 19 files changed, 779 insertions(+), 400 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Text/CssFonts4IntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Css/CssFonts4PropertyTests.cs diff --git a/Source/HtmlRenderer.WinForms/Adapters/WinFormsAdapter.cs b/Source/HtmlRenderer.WinForms/Adapters/WinFormsAdapter.cs index 384f93983..a38363338 100644 --- a/Source/HtmlRenderer.WinForms/Adapters/WinFormsAdapter.cs +++ b/Source/HtmlRenderer.WinForms/Adapters/WinFormsAdapter.cs @@ -50,13 +50,10 @@ internal sealed class WinFormsAdapter : RAdapter /// </summary> private static readonly WinFormsAdapter _instance = new WinFormsAdapter(); - // Adapter-level PrivateFontCollection for @font-face-loaded faces - one collection shared for the - // adapter's (process) lifetime, growing by one family per LoadFontFaceFontInt call. + // The @font-face faces, kept for the lifetime of the process. private readonly PrivateFontCollection _fontFaceCollection = new PrivateFontCollection(); - // Backs LoadFontFaceFontInt's temp-file registration (see its own doc comment for why a temp file - // is used instead of PrivateFontCollection.AddMemoryFont) - one directory per process, cleaned up - // by the OS's normal temp-file housekeeping, not by this process. + // The font files of the @font-face faces, see LoadFontFaceFontInt. Left to the OS to clean up. private static readonly string _fontFaceTempDirectory = CreateFontFaceTempDirectory(); private static string CreateFontFaceTempDirectory() @@ -211,37 +208,13 @@ protected override RFont CreateFontInt(RFontFamily family, double size, RFontSty } /// <summary> - /// Loads one <c>@font-face</c> face's bytes into the adapter's <see cref="PrivateFontCollection"/> - /// via <see cref="PrivateFontCollection.AddFontFile"/> - through a temp file, matching this - /// adapter's own pre-existing <c>DemoForm.LoadCustomFonts</c> pattern (that path is untouched, - /// this is a new, separate mechanism specific to <c>@font-face</c>). + /// Adds the font to the <see cref="PrivateFontCollection"/> and returns its family, found by the + /// family name in the font file. <see cref="PrivateFontCollection.Families"/> groups the faces of + /// a family and isn't in insertion order. /// </summary> /// <remarks> - /// Deliberately NOT <see cref="PrivateFontCollection.AddMemoryFont"/>, despite it needing no temp - /// file: empirically (a throwaway repro project registering a dozen distinct families into one - /// <see cref="PrivateFontCollection"/>), <c>AddMemoryFont</c> is unreliable on this target - /// framework - <see cref="PrivateFontCollection.Families"/> permanently fails to reflect several of - /// the added families (not a timing race: polling for up to 300ms after the call never finds them - /// either), while the identical sequence of files through <c>AddFontFile</c> succeeds 100% of the - /// time across repeated runs. This is a known-flaky area of GDI+'s <c>AddMemoryFont</c> P/Invoke - /// path, not a bug in this port. The temp file is deliberately never deleted: GDI+ keeps it - /// memory-mapped for as long as this process-lifetime singleton's <see cref="PrivateFontCollection"/> - /// references it, and the OS's own temp-directory housekeeping reclaims it afterward - the same - /// "small, bounded, process-lifetime" rationale the removed <c>AddMemoryFont</c>/<c>AllocHGlobal</c> - /// approach relied on. - /// <para> - /// The returned <see cref="FontFamilyAdapter"/> is found by sniffing the font's own internal - /// family name via <see cref="TtfFontDescription"/> and matching it against - /// <see cref="PrivateFontCollection.Families"/> - not by comparing <c>Families.Length</c> before - /// and after, nor by taking the array's last entry. Two bugs made that approach unreliable: (1) - /// <c>Families</c> groups every face by family name (the whole point of - /// <see cref="PrivateFontCollection"/> - it lets <see cref="Font"/> pick the right face via - /// <see cref="FontStyle"/> automatically), so registering a second face of an *already-registered* - /// family (e.g. this face set's own Bold after its Regular) never changes the count at all; and - /// (2) even when the count does change, <c>Families</c> is returned in a GDI-defined (effectively - /// alphabetical) order, not insertion order, so "the last entry" is often a completely unrelated, - /// alphabetically-later family, not the one just added. - /// </para> + /// Goes through a temp file, <see cref="PrivateFontCollection.AddMemoryFont"/> doesn't reliably + /// list the added families. The file can't be deleted while the collection uses it. /// </remarks> protected override RFontFamily LoadFontFaceFontInt(byte[] fontBytes, string filePath) { diff --git a/Source/HtmlRenderer/Adapters/RAdapter.cs b/Source/HtmlRenderer/Adapters/RAdapter.cs index a496ce8a0..e34b4157d 100644 --- a/Source/HtmlRenderer/Adapters/RAdapter.cs +++ b/Source/HtmlRenderer/Adapters/RAdapter.cs @@ -322,18 +322,14 @@ public RImage ImageFromStream(Stream memoryStream) } /// <summary> - /// Check if the given font exists in the system by font family name. Consulted by - /// <see cref="Core.Parse.CssParser.ParseFontFamily"/> to decide whether a <c>font-family</c> - /// candidate should be kept as-is or the next fallback (ultimately <see cref="Core.Utils.CssConstants.DefaultFont"/>) - /// tried instead - so this must recognize a family the moment it's usable, including one - /// registered via <see cref="AddFontFace"/>, not just <see cref="AddFontFamily"/>/system fonts. + /// Check if the given font exists in the system by font family name, including the families + /// registered by <see cref="AddFontFace"/>. <see cref="Core.Parse.CssParser.ParseFontFamily"/> uses + /// it to pick the first available family of a <c>font-family</c> list. /// </summary> /// <param name="font">the font name to check</param> /// <returns>true - font exists by given family name, false - otherwise</returns> /// <remarks> - /// Virtual so the PdfSharp backend can override it - its <c>@font-face</c> registrations live - /// entirely in its own <c>FontResolver</c> (see <see cref="AddFontFace"/>'s doc comment for why), - /// invisible to the shared <c>FontsHandler</c> this base implementation checks. + /// Virtual for the PdfSharp backend, which keeps its <c>@font-face</c> faces in its own font resolver. /// </remarks> public virtual bool IsFontExists(string font) { @@ -362,26 +358,19 @@ public void AddFontFamilyMapping(string fromFamily, string toFamily) } /// <summary> - /// Loads one <c>@font-face</c> <c>src: url(...)</c> candidate and registers it as a face of - /// <paramref name="familyName"/> for CSS Fonts Level 4 matching. Resolves the resource through - /// <see cref="GetResourceStream"/> - the same funnel used for images/stylesheets, so this supports - /// <c>file:</c>/<c>data:</c>/<c>http(s):</c> uniformly - loads the platform-specific face via - /// <see cref="LoadFontFaceFontInt"/>, and registers it with <see cref="FontsHandler.AddFontFace"/>. + /// Loads one <c>url()</c> item of an <c>@font-face</c> <c>src</c> (CSS Fonts 4 §4.3.2) through + /// <see cref="GetResourceStream"/> and registers it as a face of <paramref name="familyName"/>. /// </summary> - /// <param name="familyName">the CSS family name declared by the <c>@font-face</c> rule</param> - /// <param name="uri">the resolved, absolute <c>src</c> URI to fetch (already resolved against the document base/a stylesheet's own location by the caller)</param> - /// <param name="weight">CSS Fonts Level 4 numeric weight (1-1000) this face matches for</param> - /// <param name="isItalic">whether this face matches an italic/oblique request</param> - /// <param name="stretch">CSS Fonts Level 3 stretch (1-9) this face matches for</param> - /// <param name="ranges">the face's <c>unicode-range</c> restriction, or null for "covers whatever is asked of it"</param> - /// <returns>true if the face was loaded and registered, false if the resource could not be resolved/loaded (the caller tries the next <c>src</c> candidate)</returns> + /// <param name="familyName">the family name of the <c>@font-face</c> rule</param> + /// <param name="uri">the absolute URI of the item</param> + /// <param name="weight">numeric weight (1-1000)</param> + /// <param name="isItalic">whether the face is italic or oblique</param> + /// <param name="stretch">width on the 1-9 scale of <see cref="Core.Utils.FontStretchResolver"/></param> + /// <param name="ranges">the <c>unicode-range</c>, or null for any character</param> + /// <returns>false if the resource couldn't be loaded, the caller then tries the next item</returns> /// <remarks> - /// Virtual so the PdfSharp backend can override it to bypass <see cref="FontsHandler"/>'s shared - /// registry entirely and register directly with its own <c>FontResolver</c> instead - PDFsharp - /// needs raw font bytes at PDF-generation time for embedding (via its own richer - /// <c>IFontResolver</c>-based CSS-Fonts-L4 matching), unlike WinForms/WPF, which just need an - /// opaque platform font-family handle. See the base implementation's own doc comment for the - /// shared-registry path this overrides. + /// Virtual for the PdfSharp backend, which needs the font bytes for embedding and keeps the faces + /// in its own font resolver. /// </remarks> public virtual async Task<bool> AddFontFace(string familyName, RUri uri, int weight, bool isItalic, int stretch, IReadOnlyList<CodepointRange> ranges) { @@ -426,14 +415,12 @@ public virtual async Task<bool> AddFontFace(string familyName, RUri uri, int wei } /// <summary> - /// Satisfies an <c>@font-face</c> <c>src: local(...)</c> candidate: rather than fetching a resource - /// at all, this looks for a family already registered under <paramref name="localFamilyName"/> (a - /// system font, or an earlier <see cref="AddFontFamily"/>/<see cref="AddFontFace"/> registration) - /// and, if found, registers *that same* <see cref="RFontFamily"/> as a face of - /// <paramref name="familyName"/> too. + /// Registers the family named by a <c>local()</c> item of an <c>@font-face</c> <c>src</c> + /// (CSS Fonts 4 §4.3.3.1) as a face of <paramref name="familyName"/>. The name is matched against + /// the registered families, not against full font names. /// </summary> - /// <returns>true if a local family by that name was found and registered, false otherwise (the caller tries the next <c>src</c> candidate)</returns> - /// <remarks>Virtual for the same reason as <see cref="AddFontFace"/> - see its doc comment.</remarks> + /// <returns>false if there's no such family, the caller then tries the next item</returns> + /// <remarks>Virtual for the PdfSharp backend, see <see cref="AddFontFace"/>.</remarks> public virtual bool AddFontFaceFromLocalFamily(string familyName, string localFamilyName, int weight, bool isItalic, int stretch, IReadOnlyList<CodepointRange> ranges) { var localFamily = _fontsHandler.TryGetExistingFamily(localFamilyName); @@ -667,16 +654,12 @@ internal RFont CreateFont(RFontFamily family, double size, RFontStyle style) protected abstract RFont CreateFontInt(RFontFamily family, double size, RFontStyle style); /// <summary> - /// Registers <paramref name="fontBytes"/> (a loaded <c>@font-face</c> <c>src</c> candidate's raw - /// font file bytes) with the platform text engine and returns an opaque <see cref="RFontFamily"/> - /// handle for it - one file's bytes in, one face's family handle out (a WinForms/WPF - /// implementation registers with the OS/GDI text engine and returns a handle to that one face; - /// this is not called for backends - like PdfSharp - that override <see cref="AddFontFace"/> to - /// bypass this entirely). + /// Registers the bytes of an <c>@font-face</c> font file with the platform text engine and returns + /// the family of the loaded face. Not called when <see cref="AddFontFace"/> is overridden. /// </summary> - /// <param name="fontBytes">the font file's raw bytes (TTF/OTF)</param> - /// <param name="filePath">the resolved source this was loaded from, for diagnostics/error messages only</param> - /// <returns>the registered face's family handle, or null if the bytes could not be loaded as a font (the caller tries the next <c>src</c> candidate)</returns> + /// <param name="fontBytes">the font file (TTF/OTF)</param> + /// <param name="filePath">where the bytes came from, for diagnostics</param> + /// <returns>the family of the face, or null if the bytes aren't a font</returns> protected abstract RFontFamily LoadFontFaceFontInt(byte[] fontBytes, string filePath); /// <summary> diff --git a/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs b/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs index 813083f3b..1da03e1ac 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs @@ -295,15 +295,9 @@ public static readonly IValueConverter public static readonly IValueConverter ContainerTypeConverter = Map.ContainerTypes.ToConverter(); public static readonly IValueConverter ClearModeConverter = Map.ClearModes.ToConverter(); public static readonly IValueConverter FontStretchConverter = Map.FontStretches.ToConverter(); - // "oblique" alone matches via the plain keyword map (tried first); "oblique <angle>" (CSS Fonts - // Level 4 - e.g. "oblique 10deg") only reaches the StartsWithValueConverter branch once the - // plain single-identifier match has already failed, i.e. there are more tokens to account for. - // AngleConverter is intentionally NOT Option()-wrapped here (unlike e.g. LineHeightConverter's - // own StartsWithDelimiter().Option() composition elsewhere) - StartsWithValueConverter.Construct - // trusts "the wrapped converter returned non-null" as its own "matched" signal, and an Option() - // converter's Construct never returns null, which would make every OTHER possible font-style - // value (plain keywords, initial, absent) falsely reconstruct as "oblique" whenever the "font" - // shorthand needs to be re-serialized from its decomposed longhand properties. + // CSS Fonts 4 §2.4 "oblique <angle>". The angle isn't Option() wrapped: StartsWithValueConverter + // takes a non-null Construct as a match, and an Option() never returns null, so every font-style + // would serialize as "oblique" when the font shorthand is rebuilt from its longhands. public static readonly IValueConverter FontStyleConverter = Map.FontStyles.ToConverter() .Or(new StartsWithValueConverter(TokenType.Ident, Keywords.Oblique, AngleConverter)); public static readonly IValueConverter FontWeightConverter = Map.FontWeights.ToConverter(); diff --git a/Source/HtmlRenderer/Core/Handlers/FontsHandler.cs b/Source/HtmlRenderer/Core/Handlers/FontsHandler.cs index c26a279c5..f9f49f218 100644 --- a/Source/HtmlRenderer/Core/Handlers/FontsHandler.cs +++ b/Source/HtmlRenderer/Core/Handlers/FontsHandler.cs @@ -21,11 +21,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.Handlers { /// <summary> - /// One registered <c>@font-face</c> face of a <see cref="FontFamilyModel"/>: its CSS Fonts Level 4 - /// matching axes (numeric weight, italic, stretch), the codepoint <see cref="CodepointRange"/>s it is - /// restricted to (an explicit <c>unicode-range</c> descriptor) or null when it has none (covers - /// whatever a box asks for), and the <see cref="RFontFamily"/> wrapper backends use to actually create - /// an <see cref="RFont"/> from it. + /// One <c>@font-face</c> face of a family with its matching descriptors (CSS Fonts 4 §4.4). Without a + /// <c>unicode-range</c> the ranges are null and the face is used for any character. /// </summary> internal sealed class FontFaceEntry { @@ -170,17 +167,15 @@ public void AddFontFamilyMapping(string fromFamily, string toFamily) } /// <summary> - /// Registers one <c>@font-face</c> face under <paramref name="familyName"/>. Re-registering the - /// exact same axes+ranges combo (the orchestrator re-runs on every <c>SetHtml</c>) replaces the - /// prior entry rather than duplicating it, so repeated loads of the same document don't grow the - /// family's face list unbounded. + /// Registers one <c>@font-face</c> face under <paramref name="familyName"/>. A face with the same + /// descriptors replaces the earlier one, the rules are registered again on every <c>SetHtml</c>. /// </summary> - /// <param name="familyName">the CSS family name declared by the <c>@font-face</c> rule</param> - /// <param name="fontFamily">the loaded face, already registered with the platform text engine</param> - /// <param name="weight">CSS Fonts Level 4 numeric weight (1-1000) this face matches for</param> - /// <param name="isItalic">whether this face matches an italic/oblique request</param> - /// <param name="stretch">CSS Fonts Level 3 stretch (1-9, matching OS/2 <c>usWidthClass</c>) this face matches for</param> - /// <param name="ranges">the face's <c>unicode-range</c> restriction, or null for "covers whatever is asked of it"</param> + /// <param name="familyName">the family name of the <c>@font-face</c> rule</param> + /// <param name="fontFamily">the loaded face</param> + /// <param name="weight">numeric weight (1-1000)</param> + /// <param name="isItalic">whether the face is italic or oblique</param> + /// <param name="stretch">width on the 1-9 scale of <see cref="FontStretchResolver"/></param> + /// <param name="ranges">the <c>unicode-range</c>, or null for any character</param> public void AddFontFace(string familyName, RFontFamily fontFamily, int weight, bool isItalic, int stretch, IReadOnlyList<CodepointRange> ranges) { ArgChecker.AssertArgNotNullOrEmpty(familyName, "familyName"); @@ -244,16 +239,15 @@ public RFont GetCachedFont(string family, double size, RFontStyle style) /// <summary> /// Get cached font instance for the given font properties, matching against the <c>@font-face</c> - /// faces registered for <paramref name="family"/> (CSS Fonts 4 §5, <see cref="TryFindNearestFace"/>). - /// A family without such faces goes through <see cref="GetCachedFont(string,double,RFontStyle)"/>, - /// where the platform picks the face from the <see cref="RFontStyle"/> (CSS 2.1 §15.2). + /// faces of <paramref name="family"/> (CSS Fonts 4 §5.2). A family without such faces goes through + /// <see cref="GetCachedFont(string,double,RFontStyle)"/>, where the platform picks the face. /// </summary> /// <param name="family">the css font-family name</param> /// <param name="size">the font size</param> - /// <param name="style">the box's cascaded style (its Italic/Underline/Strikeout bits are honored regardless of which face is chosen; its Bold bit is superseded by <paramref name="weight"/>)</param> - /// <param name="weight">CSS Fonts Level 4 numeric weight (1-1000) resolved from <c>font-weight</c></param> - /// <param name="stretch">CSS Fonts Level 3 stretch (1-9) resolved from <c>font-stretch</c></param> - /// <param name="codepoint">the box's first non-whitespace character's codepoint, for <c>unicode-range</c> face disambiguation, or null to skip it</param> + /// <param name="style">the box's style, its Bold bit is replaced by <paramref name="weight"/></param> + /// <param name="weight">numeric weight (1-1000)</param> + /// <param name="stretch">width on the 1-9 scale of <see cref="FontStretchResolver"/></param> + /// <param name="codepoint">the box's first non-white-space character for <c>unicode-range</c>, or null to ignore the ranges</param> /// <returns> /// the resolved font, or null when <paramref name="codepoint"/> is given and no registered face /// covers it, the caller then falls back to the default font @@ -325,10 +319,9 @@ private RFont GetCachedFaceFont(string family, FontFaceEntry face, double size, } /// <summary> - /// CSS Fonts 4 §5 face matching: italic first, then stretch, then weight. With a - /// <paramref name="codepoint"/>, only faces whose <c>unicode-range</c> covers it are candidates. A - /// face without a <c>unicode-range</c> is assumed to cover everything, its glyphs aren't checked. - /// Returns false when no face qualifies. + /// CSS Fonts 4 §5.2 face matching, here italic first, then stretch, then weight. With a + /// <paramref name="codepoint"/>, only faces whose <c>unicode-range</c> covers it are candidates, the + /// glyphs of the font itself aren't checked. Returns false when no face qualifies. /// </summary> private static bool TryFindNearestFace(FontFamilyModel family, int weight, bool isItalic, int stretch, int? codepoint, out FontFaceEntry face) { @@ -372,9 +365,7 @@ private static bool FaceCovers(FontFaceEntry entry, int codepoint) } /// <summary> - /// CSS Fonts Level 4 §5.2's nearest-stretch search order: a target at or narrower than normal (5) - /// searches narrower first (down to 1), then wider; a target wider than normal searches wider - /// first (up to 9), then narrower. <paramref name="availableStretches"/> must be non-empty. + /// CSS Fonts 4 §5.2 nearest width. <paramref name="availableStretches"/> must be non-empty. /// </summary> private static int PickNearestStretch(List<int> availableStretches, int target) { @@ -469,7 +460,7 @@ private RFont TryGetFont(string family, double size, RFontStyle style) } /// <summary> - // create font (try using existing font family to support custom fonts) + /// Creates the font, through the registered family if there's one, so added fonts are found. /// </summary> private RFont CreateFont(string family, double size, RFontStyle style) { diff --git a/Source/HtmlRenderer/Core/Parse/CssValueParser.cs b/Source/HtmlRenderer/Core/Parse/CssValueParser.cs index c8214591b..5dcd938ce 100644 --- a/Source/HtmlRenderer/Core/Parse/CssValueParser.cs +++ b/Source/HtmlRenderer/Core/Parse/CssValueParser.cs @@ -146,9 +146,7 @@ internal static List<Token> GetCssTokens(string value, bool isInValue = false) } /// <summary> - /// Resolves an <c>@font-face</c> rule's <c>font-family</c> descriptor value to the plain family - /// name: a single quoted string token unwraps to its literal text, anything else (an unquoted - /// identifier, or a value this tokenizer doesn't recognize as exactly one string) is returned as-is. + /// The family name of an <c>@font-face</c> <c>font-family</c> descriptor, without quotes. /// </summary> internal static string GetFontFaceFamilyName(string propValue) { @@ -163,11 +161,7 @@ internal static string GetFontFaceFamilyName(string propValue) } /// <summary> - /// Parses an <c>@font-face</c> <c>src</c> descriptor into its comma-separated candidates, each with - /// whatever <c>url()</c>/<c>local()</c>/<c>format()</c>/<c>tech()</c> pieces it declared - order - /// matters here: the top-level commas are split first, then each candidate segment is inspected on - /// its own, so a value with multiple <c>url()</c> alternatives (<c>src: url(a.woff2) format("woff2"), - /// url(a.woff) format("woff")</c>) doesn't have its tokens cross-matched between candidates. + /// Splits an <c>@font-face</c> <c>src</c> descriptor into its items (CSS Fonts 4 §4.3.1). /// </summary> internal static List<CssFontFace> GetFontFacePropertyValue(string propValue) { @@ -193,9 +187,7 @@ internal static List<CssFontFace> GetFontFacePropertyValue(string propValue) } /// <summary> - /// Builds one <see cref="CssFontFace"/> candidate from a comma-delimited segment of an <c>src</c> - /// descriptor's tokens and appends it to <paramref name="result"/>, or does nothing for an empty - /// segment (a trailing/doubled comma). + /// Appends the item of one comma-separated segment, an empty segment adds nothing. /// </summary> private static void AppendFontFaceCandidate(List<CssFontFace> result, List<Token> segment) { diff --git a/Source/HtmlRenderer/Core/Parse/DomParser.cs b/Source/HtmlRenderer/Core/Parse/DomParser.cs index c793ef2eb..c37b8c360 100644 --- a/Source/HtmlRenderer/Core/Parse/DomParser.cs +++ b/Source/HtmlRenderer/Core/Parse/DomParser.cs @@ -147,14 +147,9 @@ public DomParser(CssParser cssParser) } /// <summary> - /// Walks every parsed stylesheet's <c>@font-face</c> rules (including ones nested inside - /// <c>@media</c>/<c>@supports</c>/<c>@layer</c>/<c>@container</c> - see <see cref="CssData.FlattenRules"/>) - /// and registers each one's <c>src</c> candidates with <see cref="Adapters.RAdapter.AddFontFace"/>/ - /// <see cref="Adapters.RAdapter.AddFontFaceFromLocalFamily"/>, in source order, stopping at the - /// first candidate that loads successfully - matching the CSS spec's own "try each src in turn" - /// behavior. Called once per <see cref="GenerateCssTree"/>, after stylesheets are collected and - /// before the cascade applies, so every box's <see cref="Dom.CssBoxProperties.ActualFont"/> can - /// already see the registered faces. + /// Registers the <c>@font-face</c> rules of all stylesheets, also the ones nested in conditional + /// group rules, before the cascade. The <c>src</c> items are tried in order until one loads + /// (CSS Fonts 4 §4.3.2), <c>format()</c> and <c>tech()</c> aren't looked at. /// </summary> private static async Task RegisterFontFaces(CssData cssData, HtmlContainerInt htmlContainer) { diff --git a/Source/HtmlRenderer/Core/Utils/FontFaceDescriptorResolver.cs b/Source/HtmlRenderer/Core/Utils/FontFaceDescriptorResolver.cs index 17552658a..34d5ab37b 100644 --- a/Source/HtmlRenderer/Core/Utils/FontFaceDescriptorResolver.cs +++ b/Source/HtmlRenderer/Core/Utils/FontFaceDescriptorResolver.cs @@ -5,22 +5,14 @@ namespace TheArtOfDev.HtmlRenderer.Core.Utils { /// <summary> - /// Resolves an <c>@font-face</c> rule's own <c>font-weight</c>/<c>font-style</c>/<c>font-stretch</c> - /// descriptor strings (<see cref="CssEngine.IFontFaceRule"/>) into the override values a registered - /// face's matching entry takes - these are authoritative for how a specific registered face - /// participates in matching, independent of what the font file's own internal tables say. Returns null - /// for any descriptor this can't confidently resolve to a single concrete value (absent, or a - /// variable-font weight/stretch *range* like <c>100 900</c>/<c>50% 200%</c> - real interpolated - /// variable fonts are out of scope), so the caller falls back to the value sniffed from the file itself - /// instead of silently forcing a wrong/arbitrary one. + /// Resolves the <c>font-weight</c>, <c>font-style</c> and <c>font-stretch</c> descriptors of an + /// <c>@font-face</c> rule (CSS Fonts 4 §4.4), the values the face is matched with. The tables of the + /// font file aren't consulted. Returns null for an absent or unrecognized descriptor. /// </summary> internal static class FontFaceDescriptorResolver { /// <summary> - /// Resolves a <c>font-weight</c> descriptor (<c>normal</c>/<c>bold</c>/a number/absent) to a - /// concrete CSS Fonts numeric weight. A two-token range (variable-font syntax) resolves to its - /// lower bound as a reasonable single-value approximation, since there is no variable-font - /// interpolation here. Any other multi-token or unparseable value returns null. + /// A range resolves to its lower bound, variable fonts aren't supported. /// </summary> internal static int? ResolveWeight(string? weightDescriptor) { @@ -43,9 +35,7 @@ internal static class FontFaceDescriptorResolver } /// <summary> - /// Resolves a <c>font-style</c> descriptor (<c>normal</c>/<c>italic</c>/<c>oblique</c>/ - /// <c>oblique &lt;angle&gt;</c>/absent) to whether the face should be treated as italic for - /// matching purposes. Returns null for absent/unrecognized values. + /// Whether the face is italic, an oblique face counts as italic whatever its angle. /// </summary> internal static bool? ResolveIsItalic(string? styleDescriptor) { @@ -63,10 +53,7 @@ internal static class FontFaceDescriptorResolver } /// <summary> - /// Resolves a <c>font-stretch</c> descriptor (one of the 9 CSS Fonts keywords, or absent) to the - /// matching 1-9 numeric scale via <see cref="FontStretchResolver"/>. Percentage values/ranges - /// (variable-font syntax) and any other unrecognized value return null rather than being silently - /// coerced to normal. + /// Resolves a width keyword to the 1-9 scale of <see cref="FontStretchResolver"/>. /// </summary> internal static int? ResolveStretch(string? stretchDescriptor) { diff --git a/Source/HtmlRenderer/Core/Utils/FontStretchResolver.cs b/Source/HtmlRenderer/Core/Utils/FontStretchResolver.cs index a795d8496..2a2666201 100644 --- a/Source/HtmlRenderer/Core/Utils/FontStretchResolver.cs +++ b/Source/HtmlRenderer/Core/Utils/FontStretchResolver.cs @@ -1,22 +1,15 @@ namespace TheArtOfDev.HtmlRenderer.Core.Utils { /// <summary> - /// Resolves a CSS <c>font-stretch</c> keyword to the 1-9 numeric scale matching the OpenType OS/2 - /// table's <c>usWidthClass</c> field directly (1=ultra-condensed ... 5=normal ... 9=ultra-expanded) - - /// the same scale a ported <c>TtfFontDescription</c>'s <c>Stretch</c> reads from that field for each - /// registered face, so the two are directly comparable without any extra translation. + /// Resolves a <c>font-stretch</c> keyword (CSS Fonts 4 §2.3) to the 1-9 scale of the OpenType OS/2 + /// <c>usWidthClass</c>, 1 is ultra-condensed, 5 normal and 9 ultra-expanded. /// </summary> internal static class FontStretchResolver { internal const int Normal = 5; /// <summary> - /// Resolves a raw <c>font-stretch</c> keyword (a box's cascaded value, or an <c>@font-face</c> - /// descriptor - see <see cref="FontFaceDescriptorResolver.ResolveStretch"/>) to the 1-9 scale. - /// Unlike PeachPDF's own version of this resolver, there's only this one string-keyword overload - - /// HTML-Renderer's CSS engine works with raw cascaded strings throughout (no separate strongly-typed - /// CSS-OM enum for font-stretch the way PeachPDF's does), so a second typed-enum overload would have - /// no caller. + /// Anything that isn't a width keyword resolves to normal. /// </summary> internal static int Resolve(string fontStretchValue) => fontStretchValue switch { diff --git a/Source/HtmlRenderer/Core/Utils/UnicodeRangeParser.cs b/Source/HtmlRenderer/Core/Utils/UnicodeRangeParser.cs index 26ed6fe0e..fb2693ebb 100644 --- a/Source/HtmlRenderer/Core/Utils/UnicodeRangeParser.cs +++ b/Source/HtmlRenderer/Core/Utils/UnicodeRangeParser.cs @@ -10,18 +10,14 @@ namespace TheArtOfDev.HtmlRenderer.Core.Utils { /// <summary> - /// Parses a CSS <c>@font-face</c> <c>unicode-range</c> descriptor into a compact set of inclusive - /// codepoint <see cref="CodepointRange"/>s, reusing the existing CSS tokenizer's <c>U+</c> grammar - /// (<see cref="CssValueParser.GetCssTokens"/> → <see cref="RangeToken"/>, already fully implemented - - /// see <see cref="TheArtOfDev.HtmlRenderer.Core.CssEngine.Parser.Lexer"/>'s <c>UnicodeRange</c> method) - /// rather than re-implementing it. + /// Parses the <c>unicode-range</c> descriptor of <c>@font-face</c> (CSS Fonts 4 §4.5) into inclusive + /// codepoint ranges. /// </summary> public static class UnicodeRangeParser { /// <summary> - /// Parses a <c>unicode-range</c> descriptor into inclusive codepoint ranges, or returns null when - /// the descriptor is absent/blank or contains no valid range (meaning "no explicit subset - the - /// face applies to whatever its font actually covers"). + /// Returns null when the descriptor is absent or has no valid range, the face is then used for + /// any character. /// </summary> public static IReadOnlyList<CodepointRange>? Parse(string? descriptor) { @@ -30,13 +26,8 @@ public static class UnicodeRangeParser List<CodepointRange>? ranges = null; - // The value can arrive either in its CSS source form ("U+41-5A, U+61-7A") or, once round- - // tripped through the CSS-OM, with the "U+" prefix dropped ("41-5A, 61-7A"). Split on the - // top-level commas and re-tokenize each segment with the "U+" prefix the lexer's range - // grammar expects, so both forms parse identically through the one shared tokenizer. - // The netstandard2.0 reference assembly's string.IsNullOrWhiteSpace has no [NotNullWhen] - // annotation, so the compiler can't narrow descriptor to non-null past the check above on - // that TFM alone (net8.0 already narrows it) - the ! makes both legs agree. + // The value may come without its "U+" prefixes ("41-5A, 61-7A"), the tokenizer needs them. + // The ! is for netstandard2.0, whose IsNullOrWhiteSpace has no [NotNullWhen]. foreach (var segment in descriptor!.Split(',')) { var normalized = segment.Trim(); @@ -65,9 +56,7 @@ public static class UnicodeRangeParser if (end < start) continue; - // A declared range whose bound lands on a surrogate can't be a real Unicode scalar value - // (surrogates aren't valid codepoints on their own); nudge inward, and skip a range that - // is nothing but surrogates. + // Surrogates aren't characters, a bound on one moves past them. if (start is >= 0xD800 and <= 0xDFFF) start = 0xE000; if (end is >= 0xD800 and <= 0xDFFF) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/CssFonts4IntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/CssFonts4IntegrationTests.cs new file mode 100644 index 000000000..448bb4773 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/CssFonts4IntegrationTests.cs @@ -0,0 +1,341 @@ +using System.Collections.Concurrent; +using System.Drawing; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.WinForms.Adapters; +using DemoResources = TheArtOfDev.HtmlRenderer.Demo.Common.Resources; + +namespace HtmlRenderer.IntegrationTest.Text; + +/// <summary> +/// CSS Fonts Level 4 font selection, with <c>@font-face</c> faces loaded from the Liberation fonts of the demo. +/// The faces are registered on the shared <see cref="WinFormsAdapter"/>, so every test uses its own family names. +/// </summary> +[DoNotParallelize] +[TestClass] +public sealed class CssFonts4IntegrationTests +{ + private const string Sans = "LiberationSans-Regular.ttf"; + private const string SansItalic = "LiberationSans-Italic.ttf"; + private const string Mono = "LiberationMono-Regular.ttf"; + private const string MonoBold = "LiberationMono-Bold.ttf"; + private const string MonoItalic = "LiberationMono-Italic.ttf"; + + private static readonly ConcurrentDictionary<string, string> FontUris = new(); + + [TestMethod] + [Spec("css-fonts-4", "#font-face-rule")] + [Spec("css-fonts-4", "#font-family-desc")] + [Spec("css-fonts-4", "#font-face-src-loading")] + public void FontFace_DefinesAFamilyElementsCanUse() + { + var font = FontOf( + $"@font-face {{ font-family: F4Basic; src: url({FontUri(Sans)}) }}", + "<span id='s' style='font-family:F4Basic, Georgia'>x</span>"); + + Assert.AreEqual("Liberation Sans", font.Name); + } + + [TestMethod] + [Spec("css-fonts-4", "#local-font-fallback")] + public void FontFace_LocalSource_UsesTheInstalledFont() + { + var font = FontOf( + "@font-face { font-family: F4Local; src: local(NoSuchFontAtAll), local(Georgia) }", + "<span id='s' style='font-family:F4Local, Arial'>x</span>"); + + Assert.AreEqual("Georgia", font.Name); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#local-font-fallback")] + public void FontFace_LocalSource_MatchesTheFullFontName() + { + var font = FontOf( + "@font-face { font-family: F4LocalFull; src: local('Arial Bold') }", + "<span id='s' style='font-family:F4LocalFull, Georgia'>x</span>"); + + Assert.AreEqual("Arial", font.Name); + Assert.IsTrue(font.Bold); + } + + [TestMethod] + [Spec("css-fonts-4", "#src-desc")] + public void FontFace_SourceThatFailsToLoad_FallsBackToTheNextOne() + { + var font = FontOf( + $"@font-face {{ font-family: F4Broken; src: url(data:font/ttf;base64,AAAAAAAA), url({FontUri(Sans)}) }}", + "<span id='s' style='font-family:F4Broken, Georgia'>x</span>"); + + Assert.AreEqual("Liberation Sans", font.Name); + } + + [TestMethod] + [Spec("css-fonts-4", "#font-face-src-formats")] + public void FontFace_SourceInASupportedFormat_IsLoaded() + { + var font = FontOf( + $"@font-face {{ font-family: F4Truetype; src: url({FontUri(Sans)}) format('truetype') }}", + "<span id='s' style='font-family:F4Truetype, Georgia'>x</span>"); + + Assert.AreEqual("Liberation Sans", font.Name); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#font-face-src-formats")] + public void FontFace_SourceInAnUnsupportedFormat_IsSkipped() + { + var font = FontOf( + $"@font-face {{ font-family: F4Format; src: url({FontUri(Mono)}) format('embedded-opentype'), url({FontUri(Sans)}) format('truetype') }}", + "<span id='s' style='font-family:F4Format'>x</span>"); + + Assert.AreEqual("Liberation Sans", font.Name); + } + + [TestMethod] + [Spec("css-fonts-4", "#font-prop-desc")] + [Spec("css-fonts-4", "#font-style-matching")] + [DataRow("400", "Liberation Sans")] + [DataRow("300", "Liberation Sans")] + [DataRow("700", "Liberation Mono")] + [DataRow("900", "Liberation Mono")] + public void FontFace_WeightDescriptor_SelectsTheNearestFace(string weight, string expected) + { + var font = FontOf( + $"@font-face {{ font-family: F4Weight; src: url({FontUri(Sans)}); font-weight: 400 }}" + + $"@font-face {{ font-family: F4Weight; src: url({FontUri(MonoBold)}); font-weight: 700 }}", + $"<span id='s' style='font-family:F4Weight;font-weight:{weight}'>x</span>"); + + Assert.AreEqual(expected, font.Name); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#font-prop-desc")] + public void FontFace_WeightRange_CoversEveryWeightInIt() + { + var font = FontOf( + $"@font-face {{ font-family: F4WeightRange; src: url({FontUri(Sans)}); font-weight: 100 900 }}" + + $"@font-face {{ font-family: F4WeightRange; src: url({FontUri(Mono)}); font-weight: 200 }}", + "<span id='s' style='font-family:F4WeightRange;font-weight:300'>x</span>"); + + Assert.AreEqual("Liberation Sans", font.Name); + } + + [TestMethod] + [Spec("css-fonts-4", "#font-stretch-prop")] + [Spec("css-fonts-4", "#font-stretch-desc")] + [Spec("css-fonts-4", "#font-style-matching")] + [DataRow("normal", "Liberation Sans")] + [DataRow("expanded", "Liberation Sans")] + [DataRow("condensed", "Liberation Mono")] + [DataRow("semi-condensed", "Liberation Mono")] + public void FontFace_StretchDescriptor_SelectsTheNearestFace(string stretch, string expected) + { + var font = FontOf(StretchFaces("F4Stretch" + stretch), $"<span id='s' style='font-family:F4Stretch{stretch};font-stretch:{stretch}'>x</span>"); + + Assert.AreEqual(expected, font.Name); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#font-width-prop")] + public void FontWidth_SelectsTheNearestFace() + { + var font = FontOf(StretchFaces("F4Width"), "<span id='s' style='font-family:F4Width;font-width:condensed'>x</span>"); + + Assert.AreEqual("Liberation Mono", font.Name); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#font-width-prop")] + public void FontStretch_Percentage_SelectsTheNearestFace() + { + var font = FontOf(StretchFaces("F4StretchPercent"), "<span id='s' style='font-family:F4StretchPercent;font-stretch:75%'>x</span>"); + + Assert.AreEqual("Liberation Mono", font.Name); + } + + [TestMethod] + [Spec("css-fonts-4", "#font-prop-desc")] + [Spec("css-fonts-4", "#font-style-matching")] + [DataRow("normal", "Liberation Sans")] + [DataRow("italic", "Liberation Mono")] + [DataRow("oblique", "Liberation Mono")] + public void FontFace_StyleDescriptor_SelectsTheFace(string style, string expected) + { + var font = FontOf( + $"@font-face {{ font-family: F4Style; src: url({FontUri(Sans)}) }}" + + $"@font-face {{ font-family: F4Style; src: url({FontUri(MonoItalic)}); font-style: italic }}", + $"<span id='s' style='font-family:F4Style;font-style:{style}'>x</span>"); + + Assert.AreEqual(expected, font.Name); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#font-style-matching")] + public void FontFace_WidthIsMatchedBeforeStyle() + { + var font = FontOf( + $"@font-face {{ font-family: F4Order; src: url({FontUri(SansItalic)}); font-style: italic }}" + + $"@font-face {{ font-family: F4Order; src: url({FontUri(MonoItalic)}); font-stretch: condensed }}", + "<span id='s' style='font-family:F4Order;font-style:italic;font-stretch:condensed'>x</span>"); + + Assert.AreEqual("Liberation Mono", font.Name); + } + + [TestMethod] + [Spec("css-fonts-4", "#unicode-range-desc")] + public void FontFace_UnicodeRange_SelectsTheFaceThatCoversTheText() + { + var (root, _) = Layout( + UnicodeRangeFaces("F4Range"), + "<span id='digits' style='font-family:F4Range'>123</span><span id='letters' style='font-family:F4Range'>abc</span>"); + + Assert.AreEqual("Liberation Mono", Font(TextBox(root, "digits")).Name); + Assert.AreEqual("Liberation Sans", Font(TextBox(root, "letters")).Name); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#unicode-range-desc")] + public void FontFace_UnicodeRange_IsAppliedPerCharacter() + { + var (root, _) = Layout( + UnicodeRangeFaces("F4RangeMixed"), + "<span id='mixed' style='font-family:F4RangeMixed'>1aaaa</span> <span id='digit' style='font-family:F4RangeMixed'>1</span> <span id='letters' style='font-family:F4RangeMixed'>aaaa</span>"); + + var expected = WordWidth(root, "digit") + WordWidth(root, "letters"); + + Assert.AreEqual(expected, WordWidth(root, "mixed"), 1); + } + + [TestMethod] + [Spec("css-fonts-4", "#font-prop")] + public void Shorthand_SetsTheFontWidth() + { + var font = FontOf(StretchFaces("F4ShorthandWidth"), "<span id='s' style='font:condensed 16px F4ShorthandWidth'>x</span>"); + + Assert.AreEqual("Liberation Mono", font.Name); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#font-weight-prop")] + [DataRow("650", 650)] + [DataRow("1", 1)] + [DataRow("1000", 1000)] + [DataRow("450.5", 450.5)] + public void FontWeight_AcceptsAnyNumberFrom1To1000(string value, double expected) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap($"<span id='s' style='font-weight:{value}'>x</span>")); + + Assert.AreEqual(expected, LayoutHarness.FindById(root, "s")!.ActualFontWeight, 0.01); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#font-style-prop")] + [DataRow("oblique 10deg")] + [DataRow("oblique -10deg")] + public void ObliqueWithAngle_SelectsASlantedFace(string style) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap($"<span id='s' style='font-family:Arial;font-style:{style}'>x</span>")); + + Assert.IsTrue(Font(LayoutHarness.FindById(root, "s")!).Italic); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#generic-font-families")] + public void Math_MapsToAMathFont() + { + var font = FontOf("", "<span id='s' style='font-family:NoSuchFontAtAll, math'>x</span>"); + + Assert.AreEqual("Cambria Math", font.Name); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#generic-family-name-syntax")] + public void UnavailableGenericFunction_IsSkipped() + { + var font = FontOf("", "<div style='font-family:Georgia'><span id='s' style='font-family:generic(kai), Arial'>x</span></div>"); + + Assert.AreEqual("Arial", font.Name); + } + + [TestMethod] + [Spec("css-fonts-4", "#absolute-size-mapping")] + [DataRow("xx-small", 3d / 5)] + [DataRow("x-small", 3d / 4)] + [DataRow("large", 6d / 5)] + public void AbsoluteSizeKeywords_FollowTheScalingFactors(string keyword, double factor) + { + AssertScaledFromMedium(keyword, factor); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#absolute-size-mapping")] + [DataRow("small", 8d / 9)] + [DataRow("x-large", 3d / 2)] + [DataRow("xx-large", 2d)] + [DataRow("xxx-large", 3d)] + public void SmallAndXLargeAndAbove_FollowTheScalingFactors(string keyword, double factor) + { + AssertScaledFromMedium(keyword, factor); + } + + private static void AssertScaledFromMedium(string keyword, double factor) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + $"<div id='m' style='font-size:medium'>x</div><div id='k' style='font-size:{keyword}'>x</div>")); + + var medium = LayoutHarness.FindById(root, "m")!.ActualFont.Size; + + Assert.AreEqual(medium * factor, LayoutHarness.FindById(root, "k")!.ActualFont.Size, 0.5); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#font-size-prop")] + public void XxxLarge_IsLargerThanXxLarge() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div id='xx' style='font-size:xx-large'>x</div><div id='xxx' style='font-size:xxx-large'>x</div>")); + + Assert.IsGreaterThan(LayoutHarness.FindById(root, "xx")!.ActualFont.Size, LayoutHarness.FindById(root, "xxx")!.ActualFont.Size); + } + + private static string StretchFaces(string family) => + $"@font-face {{ font-family: {family}; src: url({FontUri(Sans)}); font-stretch: normal }}" + + $"@font-face {{ font-family: {family}; src: url({FontUri(Mono)}); font-stretch: condensed }}"; + + private static string UnicodeRangeFaces(string family) => + $"@font-face {{ font-family: {family}; src: url({FontUri(Sans)}); unicode-range: U+0-2F, U+3A-10FFFF }}" + + $"@font-face {{ font-family: {family}; src: url({FontUri(Mono)}); unicode-range: U+30-39 }}"; + + private static string FontUri(string file) => + FontUris.GetOrAdd(file, f => "data:font/ttf;base64," + Convert.ToBase64String(DemoResources.GetFontBytes(f))); + + private static (CssBox Root, TheArtOfDev.HtmlRenderer.Core.HtmlContainerInt Container) Layout(string css, string body) => + LayoutHarness.Layout($"<html><head><style>{css}</style></head><body style='margin:0'>{body}</body></html>"); + + private static Font FontOf(string css, string body) + { + var (root, _) = Layout(css, body); + return Font(LayoutHarness.FindById(root, "s")!); + } + + private static Font Font(CssBox box) => ((FontAdapter)box.ActualFont).Font; + + private static CssBox TextBox(CssBox root, string id) => + LayoutHarness.Descendants(LayoutHarness.FindById(root, id)!).First(b => b.Words.Count > 0); + + private static double WordWidth(CssBox root, string id) => TextBox(root, id).Words.Sum(w => w.Width); +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/FontIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/FontIntegrationTests.cs index 9a258716a..08d4cef29 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/FontIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/FontIntegrationTests.cs @@ -16,6 +16,7 @@ public sealed class FontIntegrationTests [TestMethod] [Spec("css21", "fonts.html#algorithm")] + [Spec("css-fonts-4", "#font-matching-algorithm")] public void FirstInstalledFamily_IsUsed() { var font = FontOf("<span id='s' style='font-family:NoSuchFontAtAll, Georgia, serif'>x</span>"); @@ -25,6 +26,7 @@ public void FirstInstalledFamily_IsUsed() [TestMethod] [Spec("css21", "fonts.html#algorithm")] + [Spec("css-fonts-4", "#font-matching-algorithm")] public void NoInstalledFamily_FallsBackToTheDefaultFont() { var font = FontOf("<span id='s' style='font-family:NoSuchFontAtAll, AnotherMissingFont'>x</span>"); @@ -34,6 +36,7 @@ public void NoInstalledFamily_FallsBackToTheDefaultFont() [TestMethod] [Spec("css21", "fonts.html#algorithm")] + [Spec("css-fonts-4", "#font-matching-algorithm")] public void FontSize_IsMatchedExactly() { var font = FontOf("<span id='s' style='font-family:Arial;font-size:17px'>x</span>"); @@ -44,6 +47,9 @@ public void FontSize_IsMatchedExactly() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "fonts.html#algorithm")] + [Spec("css-fonts-4", "#font-family-prop")] + [Spec("css-fonts-4", "#font-matching-algorithm")] + [Spec("css-fonts-4", "#cluster-matching")] public void FamilyWithoutTheGlyph_FallsBackToTheNextFamily() { var font = FontOf("<span id='s' style=\"font-family:Arial, 'Segoe UI Emoji'\">\U0001F600</span>"); @@ -53,6 +59,7 @@ public void FamilyWithoutTheGlyph_FallsBackToTheNextFamily() [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] public void QuotedFamilyName_IsMatched() { var font = FontOf("<span id='s' style=\"font-family:'Times New Roman', Arial\">x</span>"); @@ -62,6 +69,7 @@ public void QuotedFamilyName_IsMatched() [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] public void UnquotedIdentifierSequence_IsJoinedIntoOneName() { var font = FontOf("<span id='s' style='font-family:Times New Roman, Arial'>x</span>"); @@ -71,6 +79,7 @@ public void UnquotedIdentifierSequence_IsJoinedIntoOneName() [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-prop")] public void FontFamily_IsInherited() { var font = FontOf("<div style='font-family:Georgia'><span id='s'>x</span></div>"); @@ -80,6 +89,7 @@ public void FontFamily_IsInherited() [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] public void InvalidFamilyList_IsIgnored() { var font = FontOf("<div style='font-family:Georgia'><span id='s' style='font-family:Red/Black, Arial'>x</span></div>"); @@ -90,6 +100,7 @@ public void InvalidFamilyList_IsIgnored() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] public void QuotedGenericName_IsAFamilyNameNotTheKeyword() { var font = FontOf("<span id='s' style='font-family:\"monospace\"'>x</span>"); @@ -99,6 +110,7 @@ public void QuotedGenericName_IsAFamilyNameNotTheKeyword() [TestMethod] [Spec("css21", "fonts.html#sans-serif-def")] + [Spec("css-fonts-4", "#generic-font-families")] public void SansSerif_MapsToASansSerifFont() { var font = FontOf("<span id='s' style='font-family:sans-serif'>x</span>"); @@ -108,6 +120,8 @@ public void SansSerif_MapsToASansSerifFont() [TestMethod] [Spec("css21", "fonts.html#monospace-def")] + [Spec("css-fonts-4", "#generic-font-families")] + [Spec("css-fonts-4", "#generic-family-name-syntax")] public void Monospace_MapsToAMonospaceFont() { var font = FontOf("<span id='s' style='font-family:monospace'>x</span>"); @@ -118,6 +132,7 @@ public void Monospace_MapsToAMonospaceFont() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "fonts.html#serif-def")] + [Spec("css-fonts-4", "#generic-font-families")] public void Serif_MapsToASerifFont() { var font = FontOf("<span id='s' style='font-family:serif'>x</span>"); @@ -128,6 +143,7 @@ public void Serif_MapsToASerifFont() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "fonts.html#cursive-def")] + [Spec("css-fonts-4", "#generic-font-families")] public void Cursive_MapsToACursiveFont() { var font = FontOf("<span id='s' style='font-family:cursive'>x</span>"); @@ -138,6 +154,7 @@ public void Cursive_MapsToACursiveFont() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "fonts.html#fantasy-def")] + [Spec("css-fonts-4", "#generic-font-families")] public void Fantasy_MapsToAFantasyFont() { var font = FontOf("<span id='s' style='font-family:fantasy'>x</span>"); @@ -147,6 +164,7 @@ public void Fantasy_MapsToAFantasyFont() [TestMethod] [Spec("css21", "fonts.html#generic-font-families")] + [Spec("css-fonts-4", "#generic-font-families")] public void GenericFamily_IsUsedAsTheLastAlternative() { var font = FontOf("<span id='s' style='font-family:NoSuchFontAtAll, monospace'>x</span>"); @@ -156,6 +174,7 @@ public void GenericFamily_IsUsedAsTheLastAlternative() [TestMethod] [Spec("css21", "fonts.html#font-styling")] + [Spec("css-fonts-4", "#font-style-prop")] [DataRow("italic")] [DataRow("oblique")] public void ItalicAndOblique_SelectASlantedFace(string style) @@ -167,6 +186,7 @@ public void ItalicAndOblique_SelectASlantedFace(string style) [TestMethod] [Spec("css21", "fonts.html#font-styling")] + [Spec("css-fonts-4", "#font-style-prop")] public void Normal_InsideItalic_SelectsTheUprightFace() { var font = FontOf("<div style='font-family:Arial;font-style:italic'><span id='s' style='font-style:normal'>x</span></div>"); @@ -176,6 +196,7 @@ public void Normal_InsideItalic_SelectsTheUprightFace() [TestMethod] [Spec("css21", "fonts.html#font-styling")] + [Spec("css-fonts-4", "#font-style-prop")] public void FontStyle_IsInherited() { var font = FontOf("<div style='font-family:Arial;font-style:italic'><span id='s'>x</span></div>"); @@ -186,6 +207,7 @@ public void FontStyle_IsInherited() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "fonts.html#small-caps")] + [Spec("css-fonts-4", "#font-variant-prop")] public void SmallCaps_RendersLowercaseLettersAsCapitals() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -198,6 +220,7 @@ public void SmallCaps_RendersLowercaseLettersAsCapitals() [TestMethod] [Spec("css21", "fonts.html#font-boldness")] + [Spec("css-fonts-4", "#missing-weights")] [DataRow("100", false)] [DataRow("300", false)] [DataRow("normal", false)] @@ -216,6 +239,7 @@ public void Weight_SelectsTheNearestFaceOfARegularAndBoldFamily(string weight, b [TestMethod] [Spec("css21", "fonts.html#font-boldness")] + [Spec("css-fonts-4", "#relative-weights")] public void Bolder_OnANormalParent_IsBold() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -228,6 +252,7 @@ public void Bolder_OnANormalParent_IsBold() [TestMethod] [Spec("css21", "fonts.html#font-boldness")] + [Spec("css-fonts-4", "#relative-weights")] public void Lighter_OnABoldParent_IsNormal() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -241,6 +266,7 @@ public void Lighter_OnABoldParent_IsNormal() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "fonts.html#font-boldness")] + [Spec("css-fonts-4", "#relative-weights")] [DataRow(100, 400, 100)] [DataRow(200, 400, 100)] [DataRow(300, 400, 100)] @@ -261,6 +287,7 @@ public void BolderAndLighter_FollowTheTable(int inherited, int bolder, int light [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "fonts.html#font-boldness")] + [Spec("css-fonts-4", "#relative-weights")] public void Bolder_IsInheritedAsTheComputedWeight() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -271,6 +298,7 @@ public void Bolder_IsInheritedAsTheComputedWeight() [TestMethod] [Spec("css21", "fonts.html#font-boldness")] + [Spec("css-fonts-4", "#font-weight-prop")] public void FontWeight_IsInherited() { var font = FontOf("<div style='font-family:Arial;font-weight:bold'><span id='s'>x</span></div>"); @@ -280,6 +308,7 @@ public void FontWeight_IsInherited() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#absolute-size-mapping")] public void AbsoluteSizeKeywords_GrowFromXxSmallToXxLarge() { string[] keywords = ["xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large"]; @@ -295,6 +324,7 @@ public void AbsoluteSizeKeywords_GrowFromXxSmallToXxLarge() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void Medium_IsTheInitialSize() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -305,6 +335,7 @@ public void Medium_IsTheInitialSize() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void LargerAndSmaller_OnAMediumParent_StepOneKeyword() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -318,6 +349,7 @@ public void LargerAndSmaller_OnAMediumParent_StepOneKeyword() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void Larger_IsInheritedAsTheComputedSize() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -328,6 +360,7 @@ public void Larger_IsInheritedAsTheComputedSize() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void Percentage_RefersToTheParentSize() { var font = FontOf("<div style='font-size:20px'><span id='s' style='font-size:150%'>x</span></div>"); @@ -337,6 +370,7 @@ public void Percentage_RefersToTheParentSize() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void Em_RefersToTheParentSize() { var font = FontOf("<div style='font-size:20px'><span id='s' style='font-size:1.5em'>x</span></div>"); @@ -346,6 +380,7 @@ public void Em_RefersToTheParentSize() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void Length_IsUsedAsIs() { var font = FontOf("<span id='s' style='font-size:12pt'>x</span>"); @@ -355,6 +390,7 @@ public void Length_IsUsedAsIs() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void FontSize_IsInherited() { var font = FontOf("<div style='font-size:16px'><span id='s'>x</span></div>"); @@ -365,6 +401,7 @@ public void FontSize_IsInherited() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void NegativeSize_IsIgnored() { var font = FontOf("<div style='font-size:16px'><span id='s' style='font-size:-10px'>x</span></div>"); @@ -375,6 +412,7 @@ public void NegativeSize_IsIgnored() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void TinySize_IsKept() { var font = FontOf("<span id='s' style='font-size:1px'>x</span>"); @@ -384,6 +422,7 @@ public void TinySize_IsKept() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void Shorthand_SetsStyleWeightSizeAndFamily() { var font = FontOf("<span id='s' style='font:bold italic 16px Georgia, serif'>x</span>"); @@ -396,6 +435,7 @@ public void Shorthand_SetsStyleWeightSizeAndFamily() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void Shorthand_SetsTheLineHeight() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -407,6 +447,7 @@ public void Shorthand_SetsTheLineHeight() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void Shorthand_ResetsOmittedPropertiesToTheirInitialValues() { var font = FontOf("<div style='font-weight:bold;font-style:italic'><span id='s' style='font:16px Arial'>x</span></div>"); @@ -417,6 +458,7 @@ public void Shorthand_ResetsOmittedPropertiesToTheirInitialValues() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void Shorthand_PercentageSizeRefersToTheParentSize() { var font = FontOf("<div style='font-size:20px'><span id='s' style='font:150% Arial'>x</span></div>"); @@ -427,6 +469,7 @@ public void Shorthand_PercentageSizeRefersToTheParentSize() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void SystemFont_SetsTheFontOfTheSystem() { var font = FontOf("<div style='font:32px Georgia'><span id='s' style='font:menu'>x</span></div>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs index 3919927fa..61e969491 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs @@ -22,6 +22,7 @@ private static CssBox FindWordsBox(CssBox root, string id) [TestMethod] [Spec("css21", "fonts.html#small-caps")] + [Spec("css-fonts-4", "#font-variant-prop")] public void FontVariant_SmallCaps_StandaloneProperty_ParsesAndStores() { var (root, _) = LayoutHarness.Layout( @@ -33,6 +34,7 @@ public void FontVariant_SmallCaps_StandaloneProperty_ParsesAndStores() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void FontVariant_SmallCaps_ViaFontShorthand_ParsesAndStores() { var (root, _) = LayoutHarness.Layout( @@ -45,6 +47,7 @@ public void FontVariant_SmallCaps_ViaFontShorthand_ParsesAndStores() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "fonts.html#small-caps")] + [Spec("css-fonts-4", "#font-variant-prop")] public void SmallCaps_SplitsWordIntoCaseRuns() { var (root, _) = LayoutHarness.Layout( diff --git a/Source/Test/HtmlRenderer.Test/Css/CssFonts4PropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssFonts4PropertyTests.cs new file mode 100644 index 000000000..b5bdb26c7 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/CssFonts4PropertyTests.cs @@ -0,0 +1,100 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.Css; + +/// <summary>Which CSS Fonts Level 4 values and descriptors the CSS engine accepts.</summary> +[TestClass] +public sealed class CssFonts4PropertyTests +{ + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#font-style-prop")] + [DataRow("left")] + [DataRow("right")] + public void FontStyle_LeftAndRight_AreAccepted(string value) + { + Assert.AreEqual(value, Value("font-style", value)); + } + + [TestMethod] + [Spec("css-fonts-4", "#font-style-prop")] + [DataRow("oblique 90deg")] + [DataRow("oblique -90deg")] + [DataRow("oblique 0deg")] + public void FontStyle_ObliqueAngleWithinRange_IsAccepted(string value) + { + Assert.AreEqual(value, Value("font-style", value)); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#font-style-prop")] + [DataRow("oblique 91deg")] + [DataRow("oblique -100deg")] + public void FontStyle_ObliqueAngleOutOfRange_IsRejected(string value) + { + Assert.AreEqual("", Value("font-style", value)); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#font-size-prop")] + [DataRow("xxx-large")] + [DataRow("math")] + public void FontSize_Fonts4Keywords_AreAccepted(string value) + { + Assert.AreEqual(value, Value("font-size", value)); + } + + [TestMethod] + [Spec("css-fonts-4", "#font-variant-prop")] + [DataRow("normal")] + [DataRow("small-caps")] + public void FontVariant_Css21Keywords_AreParsed(string value) + { + Assert.AreEqual(value, Value("font-variant", value)); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#font-variant-prop")] + [DataRow("all-small-caps")] + [DataRow("common-ligatures tabular-nums")] + [DataRow("none")] + public void FontVariant_Fonts4Values_AreParsed(string value) + { + Assert.AreEqual(value, Value("font-variant", value)); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#font-prop-desc")] + [DataRow("font-weight: 100 900", "100 900")] + [DataRow("font-weight: 350", "350")] + public void FontFace_WeightDescriptor_AcceptsRangesAndAnyNumber(string declaration, string expected) + { + Assert.AreEqual(expected, FontFace(declaration).Weight); + } + + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [TestMethod] + [Spec("css-fonts-4", "#font-prop-desc")] + public void FontFace_StretchDescriptor_AcceptsPercentages() + { + Assert.AreEqual("75%", FontFace("font-stretch: 75%").Stretch); + } + + private static string Value(string property, string value) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ {property}: {value}; }}"); + return sheet.Rules.OfType<StyleRule>().Single().Style.GetPropertyValue(property); + } + + private static IFontFaceRule FontFace(string declaration) + { + var sheet = CssConstructionFunctions.ParseStyleSheet("@font-face { font-family: X; src: local(Arial); " + declaration + " }"); + return (IFontFaceRule)sheet.Rules[0]; + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs index af764b11d..ad0bb53d3 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs @@ -7,11 +7,13 @@ namespace HtmlRenderer.Test.Css; -/// <summary>Ported from PeachPDF.Tests/CSS/FontFace.cs.</summary> [TestClass] public sealed class FontFaceTests { [TestMethod] + [Spec("css-fonts-4", "#font-face-rule")] + [Spec("css-fonts-4", "#src-desc")] + [Spec("css-fonts-4", "#font-face-src-parsing")] public void FontFaceOpenSansWithSource() { var src = "@font-face{font-family:'Open Sans';src:url(fonts/OpenSans-Light.eot);src:local('Open Sans Light'),local('OpenSans-Light'),url(fonts/OpenSans-Light.ttf) format('truetype'),url(fonts/OpenSans-Light.woff) format('woff');font-style:normal}"; @@ -31,6 +33,8 @@ public void FontFaceOpenSansWithSource() } [TestMethod] + [Spec("css-fonts-4", "#src-desc")] + [Spec("css-fonts-4", "#font-face-src-parsing")] public void FontFaceWithWoff2Source() { var src = "@font-face{font-family:'Inter';src:url(fonts/Inter-Medium.woff2) format('woff2');font-weight:500}"; @@ -65,6 +69,7 @@ public void FontFaceOpenSansNoSource() } [TestMethod] + [Spec("css-fonts-4", "#unicode-range-desc")] [DataRow("U+41-5A")] [DataRow("U+0-7F")] [DataRow("U+4??")] @@ -84,6 +89,7 @@ public void FontFaceUnicodeRangeIsRetained(string range) [TestMethod] [Spec("css-syntax-3", "#starts-a-unicode-range")] [Spec("css-syntax-3", "#consume-unicode-range-token")] + [Spec("css-fonts-4", "#unicode-range-desc")] [DataRow("U+4??", "400", "4FF")] [DataRow("U+??????", "000000", "FFFFFF")] [DataRow("U+41", "41", "41")] diff --git a/Source/Test/HtmlRenderer.Test/Css/FontFamilyPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontFamilyPropertyTests.cs index 47422be85..9a5e82f8b 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontFamilyPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontFamilyPropertyTests.cs @@ -9,6 +9,7 @@ public sealed class FontFamilyPropertyTests { [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-prop")] public void CssFontFamilyMultipleWithIdentifiersLegal() { var property = ParseDeclaration("font-family: Gill Sans Extrabold, sans-serif "); @@ -36,6 +37,7 @@ public void CssFontFamilyInitialLegal() [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-prop")] public void CssFontFamilyMultipleDiverseLegal() { var property = ParseDeclaration("font-family: Courier, \"Lucida Console\", monospace "); @@ -50,6 +52,7 @@ public void CssFontFamilyMultipleDiverseLegal() [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-prop")] public void CssFontFamilyMultipleStringLegal() { var property = ParseDeclaration("font-family: \"Goudy Bookletter 1911\", sans-serif "); @@ -64,6 +67,7 @@ public void CssFontFamilyMultipleStringLegal() [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] public void CssFontFamilyMultipleNumberIllegal() { // "1911" is a number, an unquoted family name may only contain identifiers. @@ -78,6 +82,7 @@ public void CssFontFamilyMultipleNumberIllegal() [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] public void CssFontFamilyMultipleFractionIllegal() { var property = ParseDeclaration("font-family: Red/Black, sans-serif "); @@ -91,6 +96,7 @@ public void CssFontFamilyMultipleFractionIllegal() [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] public void CssFontFamilyMultipleStringMixedWithIdentifierIllegal() { var property = ParseDeclaration("font-family: \"Lucida\" Grande, sans-serif "); @@ -104,6 +110,7 @@ public void CssFontFamilyMultipleStringMixedWithIdentifierIllegal() [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] public void CssFontFamilyMultipleExclamationMarkIllegal() { var property = ParseDeclaration("font-family: Ahem!, sans-serif "); @@ -117,6 +124,7 @@ public void CssFontFamilyMultipleExclamationMarkIllegal() [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] public void CssFontFamilyMultipleAtIllegal() { var property = ParseDeclaration("font-family: test@foo, sans-serif "); @@ -130,6 +138,7 @@ public void CssFontFamilyMultipleAtIllegal() [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] public void CssFontFamilyHashIllegal() { var property = ParseDeclaration("font-family: #POUND "); @@ -143,6 +152,7 @@ public void CssFontFamilyHashIllegal() [TestMethod] [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] public void CssFontFamilyDashIllegal() { var property = ParseDeclaration("font-family: Hawaii 5-0 "); diff --git a/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs index c2887b1b8..4aee69fd1 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs @@ -14,6 +14,7 @@ public sealed class FontKeywordPropertyTests [TestMethod] [Spec("css21", "fonts.html#small-caps")] + [Spec("css-fonts-4", "#font-variant-prop")] public void CssFontVariantNormalUppercaseLegal() { var property = ParseDeclaration("font-variant : NORMAL"); @@ -28,6 +29,7 @@ public void CssFontVariantNormalUppercaseLegal() [TestMethod] [Spec("css21", "fonts.html#small-caps")] + [Spec("css-fonts-4", "#font-variant-prop")] public void CssFontVariantSmallCapsLegal() { var property = ParseDeclaration("font-variant : small-caps "); @@ -42,6 +44,7 @@ public void CssFontVariantSmallCapsLegal() [TestMethod] [Spec("css21", "fonts.html#small-caps")] + [Spec("css-fonts-4", "#font-variant-prop")] public void CssFontVariantSmallCapsIllegal() { var property = ParseDeclaration("font-variant : smallCaps "); @@ -57,6 +60,7 @@ public void CssFontVariantSmallCapsIllegal() [TestMethod] [Spec("css21", "fonts.html#font-styling")] + [Spec("css-fonts-4", "#font-style-prop")] public void CssFontStyleItalicLegal() { var property = ParseDeclaration("font-style : italic"); @@ -71,6 +75,7 @@ public void CssFontStyleItalicLegal() [TestMethod] [Spec("css21", "fonts.html#font-styling")] + [Spec("css-fonts-4", "#font-style-prop")] public void CssFontStyleObliqueLegal() { var property = ParseDeclaration("font-style : oblique "); @@ -84,9 +89,9 @@ public void CssFontStyleObliqueLegal() } [TestMethod] + [Spec("css-fonts-4", "#font-style-prop")] public void CssFontStyleObliqueWithAngleLegal() { - // CSS Fonts 4 "oblique <angle>" var property = ParseDeclaration("font-style : oblique 10deg"); Assert.AreEqual("font-style", property.Name); Assert.IsFalse(property.IsImportant); @@ -98,6 +103,7 @@ public void CssFontStyleObliqueWithAngleLegal() } [TestMethod] + [Spec("css-fonts-4", "#font-style-prop")] public void CssFontStyleObliqueWithNegativeAngleLegal() { var property = ParseDeclaration("font-style : oblique -10deg"); @@ -112,6 +118,7 @@ public void CssFontStyleObliqueWithNegativeAngleLegal() [TestMethod] [Spec("css21", "fonts.html#font-styling")] + [Spec("css-fonts-4", "#font-style-prop")] public void CssFontStyleNormalImportantLegal() { var property = ParseDeclaration("font-style : normal !important"); @@ -128,6 +135,7 @@ public void CssFontStyleNormalImportantLegal() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void CssFontSizeAbsoluteImportantXxSmallLegal() { var property = ParseDeclaration("font-size : xx-small !important"); @@ -142,6 +150,7 @@ public void CssFontSizeAbsoluteImportantXxSmallLegal() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void CssFontSizeAbsoluteMediumUppercaseLegal() { var property = ParseDeclaration("font-size : medium"); @@ -156,6 +165,7 @@ public void CssFontSizeAbsoluteMediumUppercaseLegal() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void CssFontSizeAbsoluteLargeImportantLegal() { var property = ParseDeclaration("font-size : large !important"); @@ -170,6 +180,7 @@ public void CssFontSizeAbsoluteLargeImportantLegal() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void CssFontSizeRelativeLargerLegal() { var property = ParseDeclaration("font-size : larger "); @@ -184,6 +195,7 @@ public void CssFontSizeRelativeLargerLegal() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void CssFontSizeRelativeLargestIllegal() { var property = ParseDeclaration("font-size : largest "); @@ -197,6 +209,7 @@ public void CssFontSizeRelativeLargestIllegal() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void CssFontSizePercentLegal() { var property = ParseDeclaration("font-size : 120% "); @@ -211,6 +224,7 @@ public void CssFontSizePercentLegal() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void CssFontSizeZeroLegal() { var property = ParseDeclaration("font-size : 0 "); @@ -225,6 +239,7 @@ public void CssFontSizeZeroLegal() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void CssFontSizeLengthLegal() { var property = ParseDeclaration("font-size : 3.5em "); @@ -239,6 +254,7 @@ public void CssFontSizeLengthLegal() [TestMethod] [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] public void CssFontSizeNumberIllegal() { var property = ParseDeclaration("font-size : 120.3 "); @@ -254,6 +270,7 @@ public void CssFontSizeNumberIllegal() [TestMethod] [Spec("css21", "fonts.html#font-boldness")] + [Spec("css-fonts-4", "#font-weight-prop")] public void CssFontWeightPercentllegal() { var property = ParseDeclaration("font-weight : 100% "); @@ -267,6 +284,7 @@ public void CssFontWeightPercentllegal() [TestMethod] [Spec("css21", "fonts.html#font-boldness")] + [Spec("css-fonts-4", "#font-weight-prop")] public void CssFontWeightBolderLegalImportant() { var property = ParseDeclaration("font-weight : bolder !important"); @@ -281,6 +299,7 @@ public void CssFontWeightBolderLegalImportant() [TestMethod] [Spec("css21", "fonts.html#font-boldness")] + [Spec("css-fonts-4", "#font-weight-prop")] public void CssFontWeightBoldLegal() { var property = ParseDeclaration("font-weight : bold"); @@ -295,6 +314,7 @@ public void CssFontWeightBoldLegal() [TestMethod] [Spec("css21", "fonts.html#font-boldness")] + [Spec("css-fonts-4", "#font-weight-prop")] public void CssFontWeight400Legal() { var property = ParseDeclaration("font-weight : 400 "); @@ -308,7 +328,8 @@ public void CssFontWeight400Legal() } [TestMethod] - [Ignore("CSS Fonts 4 <number [1,1000]> weights aren't parsed, only the CSS 2.1 multiples of 100")] + [Ignore("Gap, see Spec/css-fonts-4.yml")] + [Spec("css-fonts-4", "#font-weight-prop")] public void CssFontWeight550Legal() { var property = ParseDeclaration("font-weight : 550 "); @@ -335,6 +356,7 @@ public void CssFontWeight550IsCurrentlyIllegal() [TestMethod] [Spec("css21", "fonts.html#font-boldness")] + [Spec("css-fonts-4", "#font-weight-prop")] public void CssFontWeightOutOfRangeIllegal() { var property = ParseDeclaration("font-weight : 1001 "); @@ -348,6 +370,7 @@ public void CssFontWeightOutOfRangeIllegal() [TestMethod] [Spec("css21", "fonts.html#font-boldness")] + [Spec("css-fonts-4", "#font-weight-prop")] public void CssFontWeightZeroIllegal() { var property = ParseDeclaration("font-weight : 0 "); @@ -362,6 +385,7 @@ public void CssFontWeightZeroIllegal() // ── font-stretch ────────────────────────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css-fonts-4", "#font-stretch-prop")] public void CssFontStretchNormalUppercaseImportantLegal() { var property = ParseDeclaration("font-stretch : NORMAL !important"); @@ -375,6 +399,7 @@ public void CssFontStretchNormalUppercaseImportantLegal() } [TestMethod] + [Spec("css-fonts-4", "#font-stretch-prop")] public void CssFontStretchExtraCondensedLegal() { var property = ParseDeclaration("font-stretch : extra-condensed "); @@ -388,6 +413,7 @@ public void CssFontStretchExtraCondensedLegal() } [TestMethod] + [Spec("css-fonts-4", "#font-stretch-prop")] public void CssFontStretchSemiExpandedSpaceBetweenIllegal() { var property = ParseDeclaration("font-stretch : semi expanded "); @@ -456,6 +482,7 @@ public void CssLetterSpacingNormalLegal() // ── font-size-adjust ────────────────────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css-fonts-4", "#font-size-adjust-prop")] public void CssFontSizeAdjustNoneLegal() { var property = ParseDeclaration("font-size-adjust : NONE"); @@ -469,6 +496,7 @@ public void CssFontSizeAdjustNoneLegal() } [TestMethod] + [Spec("css-fonts-4", "#font-size-adjust-prop")] public void CssFontSizeAdjustNumberLegal() { var property = ParseDeclaration("font-size-adjust : 0.5"); @@ -482,6 +510,7 @@ public void CssFontSizeAdjustNumberLegal() } [TestMethod] + [Spec("css-fonts-4", "#font-size-adjust-prop")] public void CssFontSizeAdjustLengthIllegal() { var property = ParseDeclaration("font-size-adjust : 1.1em "); diff --git a/Source/Test/HtmlRenderer.Test/Css/FontPaletteParsingTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontPaletteParsingTests.cs index d48536498..d23963054 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontPaletteParsingTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontPaletteParsingTests.cs @@ -4,12 +4,7 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/FontPaletteParsingTests.cs. -/// Tests for <see cref="FontPaletteValueConverter"/> (the <c>font-palette</c> property) and -/// <see cref="FontPaletteValuesRule"/> (the <c>@font-palette-values</c> at-rule), confirmed an exact -/// match against PeachPDF's implementation. -/// </summary> +/// <summary>Parsing of the <c>font-palette</c> property and the <c>@font-palette-values</c> rule.</summary> [TestClass] public sealed class FontPaletteParsingTests { @@ -27,6 +22,7 @@ private static string FontPalette(string value) [DataRow("--my-palette")] [DataRow("palette-mix(in oklab, --a, --b)")] [DataRow("palette-mix(in lch longer hue, normal 30%, --b 70%)")] + [Spec("css-fonts-4", "#font-palette-prop")] public void FontPalette_AcceptsValidValues(string value) { Assert.AreEqual(value, FontPalette(value)); @@ -39,12 +35,14 @@ public void FontPalette_AcceptsValidValues(string value) [DataRow("palette-mix(in oklab, --a)")] // needs two operands [DataRow("palette-mix(in bogusspace, --a, --b)")] // unknown color space [DataRow("palette-mix(--a, --b)")] // missing color-interpolation-method + [Spec("css-fonts-4", "#font-palette-prop")] public void FontPalette_DropsInvalidValues(string value) { Assert.AreEqual(string.Empty, FontPalette(value)); } [TestMethod] + [Spec("css-fonts-4", "#font-palette-values")] public void FontPaletteValues_ParsesNameAndDescriptors() { var src = "@font-palette-values --brand { font-family: \"Nabla\"; base-palette: 2; override-colors: 0 #ff0000, 1 rgb(0, 255, 0); }"; @@ -60,6 +58,7 @@ public void FontPaletteValues_ParsesNameAndDescriptors() } [TestMethod] + [Spec("css-fonts-4", "#base-palette-desc")] public void FontPaletteValues_LightDarkBasePalette() { var sheet = CssConstructionFunctions.ParseStyleSheet("@font-palette-values --d { font-family: Nabla; base-palette: dark; }"); diff --git a/Source/Test/HtmlRenderer.Test/Css/FontShorthandPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontShorthandPropertyTests.cs index c1c20d055..ae85dfb53 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontShorthandPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontShorthandPropertyTests.cs @@ -9,6 +9,7 @@ public sealed class FontShorthandPropertyTests { [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void CssFontShorthandWithFractionLegal() { var property = ParseDeclaration("font : 12px/14px sans-serif "); @@ -23,6 +24,7 @@ public void CssFontShorthandWithFractionLegal() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void CssFontShorthandPercentLegal() { var property = ParseDeclaration("font : 80% sans-serif "); @@ -37,6 +39,7 @@ public void CssFontShorthandPercentLegal() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void CssFontShorthandBoldItalicLargeLegal() { // Serialized in canonical order: style, variant, weight, stretch. @@ -52,6 +55,7 @@ public void CssFontShorthandBoldItalicLargeLegal() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void CssFontShorthandPredefinedLegal() { var property = ParseDeclaration("font : status-bar "); @@ -66,6 +70,7 @@ public void CssFontShorthandPredefinedLegal() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void CssFontShorthandSizeAndFontListLegal() { var property = ParseDeclaration("font : 15px arial,sans-serif "); @@ -80,6 +85,7 @@ public void CssFontShorthandSizeAndFontListLegal() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void CssFontShorthandStyleWeightSizeLineHeightAndFontListLegal() { var property = ParseDeclaration("font : italic bold 12px/30px Georgia, serif"); @@ -94,6 +100,7 @@ public void CssFontShorthandStyleWeightSizeLineHeightAndFontListLegal() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void CssFontSizeHeightFamilyLegal() { var property = ParseDeclaration("font: 12pt/14pt sans-serif "); @@ -108,6 +115,7 @@ public void CssFontSizeHeightFamilyLegal() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void CssFontSizeHeightMultipleFamiliesLegal() { var property = ParseDeclaration("font: x-large/110% 'New Century Schoolbook', serif "); @@ -122,6 +130,7 @@ public void CssFontSizeHeightMultipleFamiliesLegal() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void CssFontWeightVariantSizeFamiliesLegal() { var property = ParseDeclaration("font: bold italic large Palatino, serif "); @@ -136,6 +145,7 @@ public void CssFontWeightVariantSizeFamiliesLegal() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void CssFontStyleVariantSizeHeightFamilyLegal() { var property = ParseDeclaration("font: normal small-caps 120%/120% Fantasy "); @@ -177,6 +187,7 @@ public void CssFontInitialAll() [TestMethod] [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void CssFontInheritAll() { var property = ParseDeclaration("font: inherit "); diff --git a/Spec/css-fonts-4.yml b/Spec/css-fonts-4.yml index f87943f15..3b29e6fd2 100644 --- a/Spec/css-fonts-4.yml +++ b/Spec/css-fonts-4.yml @@ -5,397 +5,337 @@ snapshot: https://www.w3.org/TR/2026/WD-css-fonts-4-20261104/ sections: '#introduction': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown - '#font-relative': - title: 1.2 Interaction With Font-Relative Units - status: unknown + status: n/a '#basic-font-props': title: 2 Basic Font Properties - status: unknown + status: partial '#font-family-prop': title: '2.1 Font family: the font-family property' - status: unknown + status: partial + gaps: + - The family is chosen once per element, a family without a glyph for a character doesn't fall back to the next one, see CSS 2.1 15.2 '#font-family-name-syntax': title: 2.1.1 Syntax of <font-family-name> - status: unknown + status: partial + gaps: + - A quoted generic name such as "monospace" matches the generic family instead of a family of that name '#generic-family-name-syntax': title: 2.1.2 Syntax of <generic-font-family> - status: unknown + status: partial + gaps: + - generic() is rejected, the whole declaration is dropped '#system': title: 2.1.3 Syntax of <system-font-family-name> - status: unknown + status: unsupported + gaps: + - System font names in the font shorthand reset the font to its initial values, see CSS 2.1 15.8 '#font-families': title: 2.1.4 Relationship Between Faces and Families - status: unknown + status: n/a '#generic-font-families': title: 2.1.5 Generic font families - status: unknown + status: partial + gaps: + - serif, cursive and fantasy aren't mapped to a font, see CSS 2.1 15.3.1 + - math isn't mapped to a font, it falls back to the default font '#font-weight-prop': title: '2.2 Font weight: the font-weight property' - status: unknown + status: partial + gaps: + - Only the multiples of 100 are accepted, not any number from 1 to 1000 '#relative-weights': title: 2.2.1 Relative Weights - status: unknown + status: partial + gaps: + - "'bolder' and 'lighter' add or subtract 300 instead of following the table, and are inherited as keywords, see CSS 2.1 15.6" '#missing-weights': title: 2.2.2 Missing weights - status: unknown + status: supported '#font-width-prop': title: '2.3 Font width: the font-width property' - status: unknown + status: partial + gaps: + - The font-width property isn't recognized, only its legacy name font-stretch + - Percentages aren't accepted, only the keywords '#font-stretch-prop': title: '2.3.1 Font width: the font-stretch legacy name alias' - status: unknown + status: supported '#font-style-prop': title: '2.4 Font style: the font-style property' - status: unknown + status: partial + gaps: + - "'oblique <angle>' is rendered upright, only 'oblique' alone selects the slanted face" + - "'left' and 'right' aren't accepted" + - Oblique angles outside -90deg to 90deg aren't rejected '#font-size-prop': title: '2.5 Font size: the font-size property' - status: unknown + status: partial + gaps: + - "'xxx-large' and 'math' aren't accepted" + - "'larger' and 'smaller' compound in descendants, negative sizes aren't rejected and sizes of 1pt or less become 'medium', see CSS 2.1 15.7" '#absolute-size-mapping': title: 2.5.1 Absolute Size Keyword Mapping Table - status: unknown + status: partial + gaps: + - The keywords step 1 to 4pt away from an 11pt 'medium' instead of following the scaling factors '#font-size-adjust-prop': title: '2.6 Relative sizing: the font-size-adjust property' - status: unknown + status: parsed-only '#font-prop': title: '2.7 Shorthand font property: the font property' - status: unknown + status: partial + gaps: + - System fonts reset the font instead of using the system's font, see CSS 2.1 15.8 '#font-synthesis-intro': title: 2.8 Controlling synthetic faces - status: unknown + status: unsupported '#font-synthesis-weight': title: '2.8.1 Controlling synthesized bold: The font-synthesis-weight property' - status: unknown + status: unsupported '#font-synthesis-style': title: '2.8.2 Controlling synthesized oblique: The font-synthesis-style property' - status: unknown + status: unsupported '#font-synthesis-small-caps': title: '2.8.3 Controlling synthesized small caps: The font-synthesis-small-caps property' - status: unknown + status: unsupported '#font-synthesis-position': title: '2.8.4 Controlling synthesized super- and subscripts: The font-synthesis-position property' - status: unknown + status: unsupported '#font-synthesis': title: '2.8.5 Controlling synthetic faces: the font-synthesis shorthand' - status: unknown + status: unsupported '#font-rendering-controls': title: 3 Font Rendering Controls - status: unknown - '#font-rendering-controls-introduction': - title: 3.1 Introduction to Font Rendering Controls - status: unknown - '#font-display-timeline': - title: 3.2 The Font Display Timeline - status: unknown + status: n/a '#font-resources': title: 4 Font Resources - status: unknown + status: partial '#font-face-rule': title: 4.1 The @font-face rule - status: unknown + status: supported '#font-family-desc': title: '4.2 Font family: the font-family descriptor' - status: unknown + status: supported '#src-desc': title: '4.3 Font reference: the src descriptor' - status: unknown + status: partial '#font-face-src-parsing': title: 4.3.1 Parsing the src descriptor - status: unknown + status: supported '#font-face-src-loading': title: 4.3.2 Loading an individual item in the src descriptor - status: unknown + status: supported '#font-face-src-formats': title: 4.3.3 Selecting items in the src - status: unknown + status: partial + gaps: + - format() and tech() are ignored, every item is fetched in turn until one loads '#local-font-fallback': title: 4.3.3.1 Local font fallback - status: unknown + status: partial + gaps: + - local() is matched against family names, not full font names or PostScript names '#font-prop-desc': title: '4.4 Font property descriptors: the font-style, font-weight, and font-width descriptors' - status: unknown + status: partial + gaps: + - "Ranges (font-weight: 100 900) and weights that aren't multiples of 100 are dropped, the face matches as weight 400" + - Percentages for font-stretch are dropped '#font-stretch-desc': title: '4.4.1 Font width: the font-stretch legacy name alias' - status: unknown + status: supported '#unicode-range-desc': title: '4.5 Character range: the unicode-range descriptor' - status: unknown + status: partial + gaps: + - The face is chosen once per element from its first non-white-space character, not per character '#composite-fonts': title: 4.5.1 Using character ranges to define composite fonts - status: unknown + status: n/a '#font-rend-desc': title: '4.6 Font features and variations: the font-feature-settings and font-variation-settings descriptors' - status: unknown + status: unsupported '#font-named-instance': title: '4.7 Using named instances from variable fonts: the font-named-instance descriptor' - status: unknown + status: unsupported '#font-requests': title: 4.8 Font request guidelines - status: unknown + status: n/a '#font-face-loading': title: 4.8.1 Font loading guidelines - status: unknown - '#font-fetching-requirements': - title: 4.8.2 Font fetching requirements - status: unknown + status: unsupported + gaps: + - Every @font-face rule is loaded while the document is parsed, also when no element uses the family '#font-display-desc': title: '4.9 Controlling Font Display Per Font-Face: the font-display descriptor' - status: unknown + status: n/a '#font-display-font-feature-values': title: 4.9.1 Controlling Font Display Per Font-Family via @font-feature-values - status: unknown + status: unsupported '#font-language-override-desc': title: '4.10 Default font language overriding: the font-language-override descriptor' - status: unknown + status: unsupported '#font-metrics-override-desc': title: '4.11 Default font metrics overriding: the ascent-override, descent-override and line-gap-override descriptors' - status: unknown + status: unsupported '#font-matching-algorithm': title: 5 Font Matching Algorithm - status: unknown + status: partial + gaps: + - The font is chosen once per element, not per character '#localized-name-matching': title: 5.1 Localized name matching - status: unknown + status: unsupported + gaps: + - Family names are only matched against the names the platform lists for the installed families '#font-style-matching': title: 5.2 Matching font styles - status: unknown + status: partial + gaps: + - font-style is matched before font-width instead of after it '#cluster-matching': title: 5.3 Cluster matching - status: unknown + status: unsupported + gaps: + - There's no per-character fallback, see CSS 2.1 15.2 '#char-handling-issues': title: 5.4 Character handling issues - status: unknown + status: n/a '#font-rend-props': title: 6 Font Feature Properties - status: unknown + status: unsupported '#glyph-selection-positioning': title: 6.1 Glyph selection and positioning - status: unknown + status: n/a '#language-specific-support': title: 6.2 Language-specific display - status: unknown + status: unsupported '#font-kerning-prop': title: '6.3 Kerning: the font-kerning property' - status: unknown + status: unsupported '#horizontal-kerning': title: 6.3.1 Horizontal kerning - status: unknown + status: unsupported '#vertical-kerning': title: 6.3.2 Vertical kerning - status: unknown + status: unsupported '#font-variant-ligatures-prop': title: '6.4 Ligatures: the font-variant-ligatures property' - status: unknown + status: unsupported '#font-variant-position-prop': title: '6.5 Subscript and superscript forms: the font-variant-position property' - status: unknown + status: unsupported '#font-variant-caps-prop': title: '6.6 Capitalization: the font-variant-caps property' - status: unknown + status: unsupported + gaps: + - No small capitals are synthesized, all-small-caps text keeps its case '#font-variant-numeric-prop': title: '6.7 Numerical formatting: the font-variant-numeric property' - status: unknown + status: unsupported '#font-variant-alternates-prop': title: '6.8 Alternates and swashes: the font-variant-alternates property' - status: unknown + status: unsupported '#font-feature-values': title: '6.9 Defining font specific alternates: the @font-feature-values rule' - status: unknown + status: unsupported '#font-feature-values-syntax': title: 6.9.1 Basic syntax - status: unknown + status: unsupported '#multi-value-features': title: 6.9.2 Multi-valued feature value definitions - status: unknown + status: unsupported '#font-variant-east-asian-prop': title: '6.10 East Asian text rendering: the font-variant-east-asian property' - status: unknown + status: unsupported '#font-variant-prop': title: '6.11 Overall shorthand for font rendering: the font-variant property' - status: unknown + status: parsed-only + gaps: + - Only the CSS 2.1 values normal and small-caps are accepted + - "'small-caps' is ignored, see CSS 2.1 15.5" '#font-feature-settings-prop': title: '6.12 Low-level font feature settings control: the font-feature-settings property' - status: unknown + status: unsupported '#font-language-override-prop': title: '6.13 Font language override: the font-language-override property' - status: unknown + status: unsupported '#font-feature-variation-resolution': title: 7 Font Feature and Variation Resolution - status: unknown + status: unsupported '#default-features': title: 7.1 Default features - status: unknown + status: unsupported '#feature-variation-precedence': title: 7.2 Feature and variation precedence - status: unknown + status: unsupported '#feature-precedence-examples': title: 7.3 Feature precedence examples - status: unknown + status: n/a '#font-variation-props': title: 8 Font Variation Properties - status: unknown + status: unsupported '#font-optical-sizing-def': title: '8.1 Optical sizing control: the font-optical-sizing property' - status: unknown + status: unsupported '#font-variation-settings-def': title: '8.2 Low-level font variation settings control: the font-variation-settings property' - status: unknown + status: unsupported '#color-font-support': title: 9 Color Font Support - status: unknown + status: unsupported '#font-palette-prop': title: '9.1 Controlling Color Font Palettes: The font-palette property' - status: unknown + status: parsed-only '#font-palette-values': title: '9.2 User-defined font color palettes: The @font-palette-values rule' - status: unknown + status: parsed-only '#font-family-2-desc': title: '9.2.1 Font family: the font-family descriptor' - status: unknown + status: parsed-only '#base-palette-desc': title: '9.2.2 Specifying the base palette: the base-palette descriptor' - status: unknown + status: parsed-only '#override-color': title: '9.2.3 Overriding a colors from a palette: The override-colors descriptor' - status: unknown + status: parsed-only '#font-variant-emoji-prop': title: '9.3 Selecting the text presentation style: The font-variant-emoji property' - status: unknown + status: unsupported '#font-taxonomy': title: 10 Font Taxonomy - status: unknown - '#installed-fonts': - title: 10.1 Installed Fonts - status: unknown - '#web-fonts': - title: 10.2 Web Fonts - status: unknown - '#preinstalled-and-user-installed-fonts': - title: 10.3 Preinstalled Fonts and User-Installed Fonts - status: unknown - '#system-font': - title: 10.4 System Font - status: unknown + status: n/a '#font-technologies-formats': title: 11 Font Technologies and Formats - status: unknown + status: unsupported '#font-tech-definitions': title: 11.1 Font tech - status: unknown + status: unsupported + gaps: + - tech() is ignored, see 4.3.3 '#font-format-definitions': title: 11.2 Font formats - status: unknown + status: unsupported + gaps: + - format() is ignored, see 4.3.3 '#object-model': title: 12 Object Model - status: unknown - '#om-fontface': - title: 12.1 The CSSFontFaceRule interface - status: unknown - '#om-fontfeaturevalues': - title: 12.2 The CSSFontFeatureValuesRule interface - status: unknown - '#om-fontpalettevalues': - title: 12.3 The CSSFontPaletteValuesRule interface - status: unknown + status: n/a '#serializing': title: 13 Serializing - status: unknown - '#serializing-properties': - title: 13.1 Serializing font-related properties - status: unknown - '#serializing-at-rules': - title: 13.2 Serializing font-related at-rules - status: unknown + status: n/a '#security': title: 14 Security Considerations - status: unknown + status: n/a '#privacy': title: 15 Privacy Considerations - status: unknown - '#sp201': - title: 15.1 What information might this feature expose to Web sites or other parties, and for what purposes is that exposure necessary? - status: unknown - '#sp202': - title: 15.2 Is this specification exposing the minimum amount of information necessary to power the feature? - status: unknown - '#sp203': - title: 15.3 How does this specification deal with personal information or personally-identifiable information or information derived thereof? - status: unknown - '#sp204': - title: 15.4 How does this specification deal with sensitive information? - status: unknown - '#sp205': - title: 15.5 Does this specification introduce new state for an origin that persists across browsing sessions? - status: unknown - '#sp206': - title: 15.6 What information from the underlying platform, e.g. configuration data, is exposed by this specification to an origin? - status: unknown - '#sp207': - title: 15.7 Does this specification allow an origin access to sensors on a user’s device - status: unknown - '#sp208': - title: 15.8 What data does this specification expose to an origin? Please also document what data is identical to data exposed by other features, in the same or different contexts. - status: unknown - '#sp209': - title: 15.9 Does this specification enable new script execution/loading mechanisms? - status: unknown - '#sp210': - title: 15.10 Does this specification allow an origin to access other devices? - status: unknown - '#sp211': - title: 15.11 Does this specification allow an origin some measure of control over a user agent’s native UI? - status: unknown - '#sp212': - title: 15.12 What temporary identifiers might this specification create or expose to the web? - status: unknown - '#sp213': - title: 15.13 How does this specification distinguish between behavior in first-party and third-party contexts? - status: unknown - '#sp214': - title: 15.14 How does this specification work in the context of a user agent’s Private Browsing or "incognito" mode? - status: unknown - '#sp215': - title: 15.15 Does this specification have a "Security Considerations" and "Privacy Considerations" section? - status: unknown - '#sp216': - title: 15.16 Does this specification allow downgrading default security characteristics? - status: unknown - '#sp217': - title: 15.17 What should this questionnaire have asked? - status: unknown + status: n/a '#a11y': title: 16 Accessibility Considerations - status: unknown + status: n/a '#acknowledgments': title: 17 Acknowledgments - status: unknown + status: n/a '#changes': title: 18 Changes - status: unknown - '#changes-20240201': - title: 18.1 Changes from the 1 February 2024 working draft - status: unknown - '#changes-2021-12-21': - title: 18.2 Changes from the 21 December 2021 Working Draft - status: unknown - '#changes-2021-07-29': - title: 18.3 Changes from the 29 July 2021 Working Draft - status: unknown - '#changes-2020-11-17': - title: 18.4 Changes from the 17 November 2020 Working Draft - status: unknown - '#changes-2019-11-13': - title: 18.5 Changes from the 13 November 2019 Working Draft - status: unknown - '#changes-2018-09-20': - title: 18.6 Changes from the 20 September 2018 Working Draft - status: unknown - '#changes-2018-04-10': - title: 18.7 Changes from the 10 April 2018 Working Draft - status: unknown - '#changes-fonts-3': - title: 18.8 Changes from the 20 September 2018 CSS Fonts 3 Recommendation - status: unknown + status: n/a From 4ed521b9feedffc87d7cca7221d9675124478c56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:14:37 +0100 Subject: [PATCH 077/150] Verify and document CSS Text Level 3 compliance --- .../Text/CssText3IntegrationTests.cs | 133 ++++++++++++++++++ .../HtmlEntityDecodingIntegrationTests.cs | 1 + .../Text/TextAlignIntegrationTests.cs | 9 ++ .../Text/TextAlignStartEndIntegrationTests.cs | 11 +- ...tSpacingAndIndentLayoutIntegrationTests.cs | 15 ++ .../Text/TextTransformIntegrationTests.cs | 1 + .../Text/WhiteSpaceLayoutIntegrationTests.cs | 28 +++- .../Css/CssText3PropertyTests.cs | 92 ++++++++++++ .../Css/FontKeywordPropertyTests.cs | 4 + .../Css/TextPropertyTests.cs | 67 ++++++--- .../Css/WhiteSpacePropertyTests.cs | 2 + Spec/css-text-3.yml | 131 ++++++++++------- 12 files changed, 415 insertions(+), 79 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Text/CssText3IntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Css/CssText3PropertyTests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/CssText3IntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/CssText3IntegrationTests.cs new file mode 100644 index 000000000..cb0294d9b --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/CssText3IntegrationTests.cs @@ -0,0 +1,133 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.Text; + +/// <summary>CSS Text Level 3 white space processing, line breaking and alignment.</summary> +[DoNotParallelize] +[TestClass] +public sealed class CssText3IntegrationTests +{ + private const double Delta = 1.0; + + private const string LongWord = "abcdefghijklmnopqrstuvwxyz"; + + private const string Cjk = "中文中文中文中文中文中文中文中文"; + + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-3", "#white-space-phase-1")] + public void BreakSpaces_PreservesSpaces() + { + var p = Paragraph("<p id='p' style='white-space:break-spaces'>A B</p>"); + + Assert.IsTrue(p.LineBoxes[0].Words.Any(w => w.Text == " ")); + } + + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#word-break-property")] + public void KeepAll_DoesntBreakBetweenCjkCharacters() + { + var p = Paragraph($"<p id='p' style='width:50px;word-break:keep-all'>{Cjk}</p>"); + + Assert.AreEqual(1, LinesWithText(p)); + } + + [TestMethod] + [Spec("css-text-3", "#word-break-property")] + [Spec("css-text-3", "#line-break-details")] + public void Normal_BreaksBetweenCjkCharacters() + { + var p = Paragraph($"<p id='p' style='width:50px'>{Cjk}</p>"); + + Assert.IsGreaterThan(1, LinesWithText(p)); + } + + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#word-break-property")] + public void BreakWord_BreaksAWordThatOverflows() + { + var p = Paragraph($"<p id='p' style='width:50px;word-break:break-word'>{LongWord}</p>"); + + Assert.IsGreaterThan(1, LinesWithText(p)); + } + + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#overflow-wrap-property")] + [DataRow("overflow-wrap:break-word")] + [DataRow("overflow-wrap:anywhere")] + [DataRow("word-wrap:break-word")] + public void OverflowWrap_BreaksAWordThatOverflows(string declaration) + { + var p = Paragraph($"<p id='p' style='width:50px;{declaration}'>{LongWord}</p>"); + + Assert.IsGreaterThan(1, LinesWithText(p)); + } + + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#hyphenation")] + public void SoftHyphen_IsABreakOpportunity() + { + var p = Paragraph("<p id='p' style='width:60px'>abcdefghijkl­mnopqrstuvwx</p>"); + + Assert.AreEqual(2, LinesWithText(p)); + } + + [TestMethod] + [Spec("css-text-3", "#line-break-details")] + public void HyphenMinus_IsABreakOpportunity() + { + var p = Paragraph("<p id='p' style='width:60px'>abcdefghijkl-mnopqrstuvwx</p>"); + + Assert.AreEqual(2, LinesWithText(p)); + } + + [TestMethod] + [Spec("css-text-3", "#justify-algos")] + public void Justify_ExpandsBetweenCjkCharacters() + { + var p = Paragraph($"<p id='p' style='width:100px;text-align:justify'>{Cjk}</p>"); + var firstLine = p.LineBoxes.First(l => l.Words.Count > 1); + + Assert.AreEqual(p.ClientLeft, firstLine.Words[0].Left, Delta); + Assert.AreEqual(p.ClientRight, firstLine.Words[^1].Right, Delta); + } + + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#text-align-last-property")] + public void TextAlignLast_AlignsTheLastLine() + { + var p = Paragraph("<p id='p' style='width:200px;text-align:justify;text-align-last:right'>hi</p>"); + + Assert.AreEqual(p.ClientRight, FirstWord(p).Right, Delta); + } + + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#text-align-property")] + public void JustifyAll_JustifiesTheLastLine() + { + var p = Paragraph("<p id='p' style='width:200px;text-align:justify-all'>hi there</p>"); + var words = p.LineBoxes[^1].Words.Where(w => !w.IsSpaces).ToList(); + + Assert.AreEqual(p.ClientRight, words[^1].Right, Delta); + } + + private static CssBox Paragraph(string body) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(body)); + return LayoutHarness.FindById(root, "p")!; + } + + private static CssRect FirstWord(CssBox box) => + LayoutHarness.Descendants(box).SelectMany(b => b.Words).First(w => !w.IsSpaces); + + private static int LinesWithText(CssBox box) => + box.LineBoxes.Count(lb => lb.Words.Any(w => !string.IsNullOrEmpty(w.Text))); +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs index 3ce370b91..2c4491c02 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs @@ -66,6 +66,7 @@ public void DoubleEscapedNumericEntity_RendersAsLiteralEntityReference() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#white-space-model")] + [Spec("css-text-3", "#white-space-phase-1")] public void SingleEscapedNbsp_RendersAsNonBreakingSpace() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p'>Use&nbsp;here</p>")); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs index b28414140..c8f90b3a0 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs @@ -11,6 +11,7 @@ public sealed class TextAlignIntegrationTests [TestMethod] [Spec("css21", "text.html#alignment-prop")] + [Spec("css-text-3", "#text-align-property")] public void Left_PacksTextAgainstTheLeftEdge() { var word = Words("<p id='p' style='margin:0;text-align:left;width:200px'>hi</p>").Single(); @@ -20,6 +21,7 @@ public void Left_PacksTextAgainstTheLeftEdge() [TestMethod] [Spec("css21", "text.html#alignment-prop")] + [Spec("css-text-3", "#white-space-phase-2")] public void Right_PacksTextAgainstTheRightEdge_IgnoringTheTrailingSpace() { var word = Words("<p id='p' style='margin:0;text-align:right;width:200px'>hi </p>").Single(); @@ -29,6 +31,7 @@ public void Right_PacksTextAgainstTheRightEdge_IgnoringTheTrailingSpace() [TestMethod] [Spec("css21", "text.html#alignment-prop")] + [Spec("css-text-3", "#text-align-property")] public void Center_CentersTheTextInTheLineBox() { var word = Words("<p id='p' style='margin:0;text-align:center;width:200px'>hi</p>").Single(); @@ -38,6 +41,7 @@ public void Center_CentersTheTextInTheLineBox() [TestMethod] [Spec("css21", "text.html#alignment-prop")] + [Spec("css-text-3", "#text-align-property")] public void Right_AppliesToEveryLine() { var words = Words("<p id='p' style='margin:0;width:60px;text-align:right'>AAA BBB CCC DDD</p>"); @@ -48,6 +52,7 @@ public void Right_AppliesToEveryLine() [TestMethod] [Spec("css21", "text.html#alignment-prop")] + [Spec("css-text-3", "#text-align-property")] public void TextAlign_IsInherited() { var word = Words("<div style='text-align:right;width:200px'><p id='p' style='margin:0'>hi</p></div>").Single(); @@ -57,6 +62,8 @@ public void TextAlign_IsInherited() [TestMethod] [Spec("css21", "text.html#alignment-prop")] + [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-3", "#expanding-text")] public void Justify_MakesAllButTheLastLineFlushWithBothSides() { var lines = Words("<p id='p' style='margin:0;text-align:justify;width:100px'>aa bb cc dd ee ff gg hh ii jj</p>") @@ -72,6 +79,7 @@ public void Justify_MakesAllButTheLastLineFlushWithBothSides() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#alignment-prop")] + [Spec("css-text-3", "#text-align-property")] public void InitialValue_InRtl_ActsAsRight() { var word = Words("<p id='p' dir='rtl' style='margin:0;width:200px'>hi</p>").Single(); @@ -82,6 +90,7 @@ public void InitialValue_InRtl_ActsAsRight() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#alignment-prop")] + [Spec("css-text-3", "#expanding-text")] public void Justify_DoesntAlterPreservedWhiteSpace() { var words = Words("<p id='p' style='margin:0;text-align:justify;white-space:pre;width:300px'>a b\nc d</p>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs index e25952af4..d15b783d6 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs @@ -14,6 +14,7 @@ private static CssRect FirstWord(CssBox box) => LayoutHarness.Descendants(box).SelectMany(b => b.Words).First(w => !w.IsSpaces); [TestMethod] + [Spec("css-text-3", "#text-align-property")] public void Start_InLtrBlock_PacksTextAgainstTheLeftEdge() { var html = LayoutHarness.Wrap("<p id='p' style='text-align: start; width: 200px'>hi</p>"); @@ -25,8 +26,10 @@ public void Start_InLtrBlock_PacksTextAgainstTheLeftEdge() Assert.AreEqual(p.ClientLeft, word.Left, Delta); } - [Ignore("text-align: start is treated as left, regardless of direction")] + [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] + [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-3", "#bidi-linebox")] public void Start_InRtlBlock_PacksTextAgainstTheRightEdge() { var html = LayoutHarness.Wrap("<p id='p' dir='rtl' style='text-align: start; width: 200px'>hi</p>"); @@ -38,8 +41,9 @@ public void Start_InRtlBlock_PacksTextAgainstTheRightEdge() Assert.AreEqual(p.ClientRight, word.Right, Delta); } - [Ignore("text-align: end is treated as left, regardless of direction")] + [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] + [Spec("css-text-3", "#text-align-property")] public void End_InLtrBlock_PacksTextAgainstTheRightEdge() { var html = LayoutHarness.Wrap("<p id='p' style='text-align: end; width: 200px'>hi</p>"); @@ -52,6 +56,8 @@ public void End_InLtrBlock_PacksTextAgainstTheRightEdge() } [TestMethod] + [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-3", "#bidi-linebox")] public void End_InRtlBlock_PacksTextAgainstTheLeftEdge() { var html = LayoutHarness.Wrap("<p id='p' dir='rtl' style='text-align: end; width: 200px'>hi</p>"); @@ -65,6 +71,7 @@ public void End_InRtlBlock_PacksTextAgainstTheLeftEdge() [TestMethod] [Spec("css21", "text.html#alignment-prop")] + [Spec("css-text-3", "#text-align-property")] public void DefaultsToStart_UnsetTextAlign_BehavesLikeLeftInLtr() { var html = LayoutHarness.Wrap("<p id='p' style='width: 200px'>hi</p>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs index ef0abdf54..bb1e02c78 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs @@ -12,6 +12,7 @@ public sealed class TextSpacingAndIndentLayoutIntegrationTests [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#spacing-props")] + [Spec("css-text-3", "#word-spacing-property")] public void WordSpacing_IncreasesGapBetweenAdjacentWords() { var spacedGap = InterWordGap("<p id='p' style='word-spacing:10px'>AA BB</p>"); @@ -23,6 +24,7 @@ public void WordSpacing_IncreasesGapBetweenAdjacentWords() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#spacing-props")] + [Spec("css-text-3", "#word-spacing-property")] public void WordSpacing_OnAncestor_AffectsDescendantGap() { var spacedGap = InterWordGap("<div style='word-spacing:10px'><p id='p'>AA BB</p></div>"); @@ -33,6 +35,7 @@ public void WordSpacing_OnAncestor_AffectsDescendantGap() [TestMethod] [Spec("css21", "text.html#spacing-props")] + [Spec("css-text-3", "#word-spacing-property")] public void WordSpacingNormal_LeavesGapUnchanged() { var normalGap = InterWordGap("<p id='p' style='word-spacing:normal'>AA BB</p>"); @@ -44,6 +47,7 @@ public void WordSpacingNormal_LeavesGapUnchanged() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#spacing-props")] + [Spec("css-text-3", "#word-spacing-property")] public void WordSpacingNegative_ReducesGap() { var negativeGap = InterWordGap("<p id='p' style='word-spacing:-5px'>AA BB</p>"); @@ -55,6 +59,7 @@ public void WordSpacingNegative_ReducesGap() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#spacing-props")] + [Spec("css-text-3", "#letter-spacing-property")] public void LetterSpacing_WidensTheWordByTheSpacingPerCharacter() { var spaced = FirstWordWidth("<p id='p' style='letter-spacing:10px'>AAAA</p>"); @@ -65,6 +70,7 @@ public void LetterSpacing_WidensTheWordByTheSpacingPerCharacter() [TestMethod] [Spec("css21", "text.html#indentation-prop")] + [Spec("css-text-3", "#text-indent-property")] public void TextIndent_IndentsOnlyTheFirstLine() { var html = LayoutHarness.Wrap( @@ -84,6 +90,7 @@ public void TextIndent_IndentsOnlyTheFirstLine() [TestMethod] [Spec("css21", "text.html#indentation-prop")] + [Spec("css-text-3", "#text-indent-property")] public void TextIndentZero_NoIndentOnAnyLine() { var html = LayoutHarness.Wrap("<p id='p' style='margin:0;'>Hello world</p>"); @@ -96,6 +103,7 @@ public void TextIndentZero_NoIndentOnAnyLine() [TestMethod] [Spec("css21", "text.html#indentation-prop")] + [Spec("css-text-3", "#text-indent-property")] public void NegativeTextIndent_PullsTheFirstLineOutOfTheBlock() { Assert.AreEqual(30, FirstWordLeft( @@ -104,6 +112,7 @@ public void NegativeTextIndent_PullsTheFirstLineOutOfTheBlock() [TestMethod] [Spec("css21", "text.html#indentation-prop")] + [Spec("css-text-3", "#text-indent-property")] public void TextIndent_IsInheritedByABlockChild() { Assert.AreEqual(40, FirstWordLeft( @@ -112,6 +121,7 @@ public void TextIndent_IsInheritedByABlockChild() [TestMethod] [Spec("css21", "text.html#indentation-prop")] + [Spec("css-text-3", "#text-indent-property")] public void TextIndent_DoesntApplyAfterALineBreak() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='p' style='text-indent:40px'>aa<br>bb</div>")); @@ -124,6 +134,7 @@ public void TextIndent_DoesntApplyAfterALineBreak() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#indentation-prop")] + [Spec("css-text-3", "#text-indent-property")] public void PercentageTextIndent_ResolvesAgainstTheContainingBlockWidth() { Assert.AreEqual(40, FirstWordLeft( @@ -133,6 +144,7 @@ public void PercentageTextIndent_ResolvesAgainstTheContainingBlockWidth() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#indentation-prop")] + [Spec("css-text-3", "#text-indent-property")] public void TextIndent_DoesntApplyToAnAnonymousBlockThatIsntTheFirstChild() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='p' style='text-indent:40px'><div>bb</div>cc</div>")); @@ -144,6 +156,8 @@ public void TextIndent_DoesntApplyToAnAnonymousBlockThatIsntTheFirstChild() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#indentation-prop")] + [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-3", "#bidi-linebox")] public void TextIndent_InRtl_IndentsFromTheRightEdge() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -156,6 +170,7 @@ public void TextIndent_InRtl_IndentsFromTheRightEdge() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#indentation-prop")] + [Spec("css-text-3", "#text-indent-property")] public void TextIndent_IndentsTheFirstLineOfAnInlineBlock() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextTransformIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextTransformIntegrationTests.cs index 45651ab8d..3104b7bbd 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextTransformIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextTransformIntegrationTests.cs @@ -9,6 +9,7 @@ public sealed class TextTransformIntegrationTests [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#caps-prop")] + [Spec("css-text-3", "#text-transform-property")] [DataRow("uppercase", "abc dEF", "ABC DEF")] [DataRow("lowercase", "abc dEF", "abc def")] [DataRow("capitalize", "abc dEF", "Abc DEF")] diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs index 6d9475e14..be05eb2ff 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs @@ -12,6 +12,7 @@ public sealed class WhiteSpaceLayoutIntegrationTests [TestMethod] [Spec("css21", "text.html#white-space-prop")] + [Spec("css-text-3", "#white-space-property")] public void Pre_PreservesMultipleConsecutiveSpacesAsLiteralWord() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p' style='white-space:pre'>A B</p>")); @@ -23,6 +24,7 @@ public void Pre_PreservesMultipleConsecutiveSpacesAsLiteralWord() [TestMethod] [Spec("css21", "text.html#white-space-prop")] + [Spec("css-text-3", "#white-space-property")] public void Normal_CollapsesConsecutiveSpaces_NoLiteralSpaceWord() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p'>A B</p>")); @@ -34,6 +36,7 @@ public void Normal_CollapsesConsecutiveSpaces_NoLiteralSpaceWord() [TestMethod] [Spec("css21", "text.html#white-space-prop")] + [Spec("css-text-3", "#white-space-property")] public void Pre_TreatsExplicitNewlineAsForcedLineBreak() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p' style='white-space:pre'>A\nB</p>")); @@ -44,6 +47,7 @@ public void Pre_TreatsExplicitNewlineAsForcedLineBreak() [TestMethod] [Spec("css21", "text.html#white-space-prop")] + [Spec("css-text-3", "#white-space-property")] public void Pre_TreatsCrLfAsASingleNewline() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p' style='white-space:pre'>A\r\nB</p>")); @@ -65,6 +69,7 @@ public void Pre_TreatsALoneCarriageReturnAsANewline() [TestMethod] [Spec("css21", "text.html#white-space-prop")] + [Spec("css-text-3", "#white-space-property")] public void Pre_DoesntWrapAtTheLineBoxEdge() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p' style='margin:0;white-space:pre;width:30px'>AAAA BBBB</p>")); @@ -75,6 +80,7 @@ public void Pre_DoesntWrapAtTheLineBoxEdge() [TestMethod] [Spec("css21", "text.html#white-space-prop")] + [Spec("css-text-3", "#white-space-property")] public void Normal_IgnoresEmbeddedNewline_NoForcedBreak() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p'>A\nB</p>")); @@ -85,6 +91,7 @@ public void Normal_IgnoresEmbeddedNewline_NoForcedBreak() [TestMethod] [Spec("css21", "text.html#white-space-prop")] + [Spec("css-text-3", "#white-space-property")] public void NoWrap_PreventsWrapping_EvenWhenNarrowerThanContent() { var html = LayoutHarness.Wrap("<p id='p' style='white-space:nowrap; width:50px'>a long run of unwrapped text here</p>"); @@ -96,6 +103,7 @@ public void NoWrap_PreventsWrapping_EvenWhenNarrowerThanContent() [TestMethod] [Spec("css21", "text.html#white-space-prop")] + [Spec("css-text-3", "#white-space-property")] public void Normal_WrapsAtNarrowWidth_ForContrastWithNoWrap() { var html = LayoutHarness.Wrap("<p id='p' style='width:50px'>a long run of unwrapped text here</p>"); @@ -107,6 +115,7 @@ public void Normal_WrapsAtNarrowWidth_ForContrastWithNoWrap() [TestMethod] [Spec("css21", "text.html#white-space-prop")] + [Spec("css-text-3", "#white-space-property")] public void PreWrap_PreservesSpacesAndWraps() { var words = Words("<p id='p' style='margin:0;white-space:pre-wrap;width:40px'>AAAA BBBB CCCC</p>"); @@ -117,6 +126,7 @@ public void PreWrap_PreservesSpacesAndWraps() [TestMethod] [Spec("css21", "text.html#white-space-prop")] + [Spec("css-text-3", "#white-space-property")] public void PreLine_CollapsesSpacesButKeepsNewlines() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p' style='margin:0;white-space:pre-line'>A B\nC</p>")); @@ -130,6 +140,7 @@ public void PreLine_CollapsesSpacesButKeepsNewlines() [TestMethod] [Spec("css21", "text.html#white-space-prop")] + [Spec("css-text-3", "#white-space-property")] public void PreLine_WrapsAtTheLineBoxEdge() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p' style='margin:0;white-space:pre-line;width:40px'>AAAA BBBB CCCC</p>")); @@ -140,6 +151,7 @@ public void PreLine_WrapsAtTheLineBoxEdge() [TestMethod] [Spec("css21", "text.html#white-space-prop")] + [Spec("css-text-3", "#white-space-property")] public void WhiteSpace_IsInherited() { var words = Words("<div style='white-space:pre'><p id='p' style='margin:0'>A B</p></div>"); @@ -149,6 +161,7 @@ public void WhiteSpace_IsInherited() [TestMethod] [Spec("css21", "text.html#white-space-model")] + [Spec("css-text-3", "#line-break-transform")] public void Normal_TransformsALinefeedAndTheSpacesAroundItIntoOneSpace() { var expected = GapBetween("<p id='p' style='margin:0'>AA BB</p>", "AA", "BB"); @@ -158,6 +171,7 @@ public void Normal_TransformsALinefeedAndTheSpacesAroundItIntoOneSpace() [TestMethod] [Spec("css21", "text.html#white-space-model")] + [Spec("css-text-3", "#white-space-phase-1")] public void Normal_ConvertsATabToASpace() { var expected = GapBetween("<p id='p' style='margin:0'>AA BB</p>", "AA", "BB"); @@ -167,6 +181,7 @@ public void Normal_ConvertsATabToASpace() [TestMethod] [Spec("css21", "text.html#white-space-model")] + [Spec("css-text-3", "#white-space-phase-2")] public void Normal_RemovesASpaceAtTheBeginningOfALine() { var word = Words("<p id='p' style='margin:0'> AA</p>").Single(); @@ -176,6 +191,7 @@ public void Normal_RemovesASpaceAtTheBeginningOfALine() [TestMethod] [Spec("css21", "text.html#white-space-model")] + [Spec("css-text-3", "#white-space-phase-1")] public void Normal_CollapsesASpaceAtTheStartOfAnInlineWithOneBeforeIt_WhenTheFirstHasNoSpaceAfter() { var expected = GapBetween("<p id='p' style='margin:0'>AA BB</p>", "AA", "BB"); @@ -186,6 +202,7 @@ public void Normal_CollapsesASpaceAtTheStartOfAnInlineWithOneBeforeIt_WhenTheFir [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#white-space-model")] + [Spec("css-text-3", "#white-space-phase-1")] public void Normal_RemovesASpaceFollowingAnotherSpaceAcrossAnElementBoundary() { var expected = GapBetween("<p id='p' style='margin:0'>AA BB</p>", "AA", "BB"); @@ -196,6 +213,7 @@ public void Normal_RemovesASpaceFollowingAnotherSpaceAcrossAnElementBoundary() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#white-space-model")] + [Spec("css-text-3", "#white-space-phase-2")] public void Pre_RendersATabUpToTheNextTabStop() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -209,6 +227,7 @@ public void Pre_RendersATabUpToTheNextTabStop() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#white-space-model")] + [Spec("css-text-3", "#white-space-phase-1")] public void NoBreakSpaces_ArentCollapsed() { var expected = GapBetween("<p id='p' style='margin:0'>A B</p>", "A", "B"); @@ -219,6 +238,7 @@ public void NoBreakSpaces_ArentCollapsed() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#white-space-model")] + [Spec("css-text-3", "#white-space-phase-1")] public void Nbsp_OnlyContent_ProducesNonZeroHeight_MatchingRealText() { var (nbspRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='b'>&nbsp;</div>")); @@ -235,6 +255,7 @@ public void Nbsp_OnlyContent_ProducesNonZeroHeight_MatchingRealText() [TestMethod] [Spec("css21", "text.html#white-space-model")] + [Spec("css-text-3", "#white-space-phase-1")] public void OrdinaryWhitespaceOnlyContent_StillProducesZeroHeight_NoRegression() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='b'> </div>")); @@ -247,6 +268,7 @@ public void OrdinaryWhitespaceOnlyContent_StillProducesZeroHeight_NoRegression() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#white-space-model")] + [Spec("css-text-3", "#white-space-phase-1")] public void Nbsp_BetweenTokens_PreventsLineWrap_ContrastOrdinarySpace() { // Narrow enough that an ordinary space between "10" and "km" wraps. @@ -264,6 +286,7 @@ public void Nbsp_BetweenTokens_PreventsLineWrap_ContrastOrdinarySpace() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#white-space-model")] + [Spec("css-text-3", "#line-break-details")] public void ElementBoundaryWithoutWhiteSpace_IsntALineBreakingOpportunity() { var words = Words("<p id='p' style='margin:0;width:60px'>AAAA<b>BBBB</b></p>"); @@ -274,6 +297,7 @@ public void ElementBoundaryWithoutWhiteSpace_IsntALineBreakingOpportunity() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#white-space-model")] + [Spec("css-text-3", "#line-break-details")] public void Float_DoesntIntroduceALineBreakingOpportunity() { var words = Words("<p id='p' style='margin:0;width:60px'>AAAA<span style='float:right'>x</span>BBBB</p>"); @@ -285,6 +309,7 @@ public void Float_DoesntIntroduceALineBreakingOpportunity() [TestMethod] [Spec("css21", "text.html#ctrlchars")] + [Spec("css-text-3", "#white-space-processing")] public void CombiningCharacter_StaysWithItsBaseCharacter() { var word = Words("<p id='p' style='margin:0'>öx</p>").Single(); @@ -295,6 +320,7 @@ public void CombiningCharacter_StaysWithItsBaseCharacter() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#ctrlchars")] + [Spec("css-text-3", "#white-space-processing")] [DataRow("\u000B")] [DataRow("\u0085")] public void ControlCharacter_IsRenderedLikeANormalCharacter(string control) @@ -304,8 +330,8 @@ public void ControlCharacter_IsRenderedLikeANormalCharacter(string control) Assert.AreEqual(1, words.Count); } - // word-break is CSS Text 3, not CSS 2.1. [TestMethod] + [Spec("css-text-3", "#word-break-property")] public void BreakAll_ForcesMidWordBreak_ContrastNormal() { // No space anywhere: "normal" has no break opportunity and overflows, "break-all" wraps. diff --git a/Source/Test/HtmlRenderer.Test/Css/CssText3PropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssText3PropertyTests.cs new file mode 100644 index 000000000..1ccfd129b --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/CssText3PropertyTests.cs @@ -0,0 +1,92 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.Css; + +/// <summary>Which CSS Text Level 3 values the CSS engine accepts.</summary> +[TestClass] +public sealed class CssText3PropertyTests +{ + [TestMethod] + [Spec("css-text-3", "#text-transform-property")] + [DataRow("none")] + [DataRow("capitalize")] + [DataRow("uppercase")] + [DataRow("lowercase")] + [DataRow("full-width")] + public void TextTransform_SingleKeywords_AreParsed(string value) + { + Assert.AreEqual(value, Value("text-transform", value)); + } + + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#text-transform-property")] + [DataRow("full-size-kana")] + [DataRow("uppercase full-width")] + public void TextTransform_FullSizeKanaAndCombinations_AreParsed(string value) + { + Assert.AreEqual(value, Value("text-transform", value)); + } + + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#text-align-property")] + [DataRow("start")] + [DataRow("end")] + [DataRow("match-parent")] + [DataRow("justify-all")] + public void TextAlign_Text3Keywords_AreParsed(string value) + { + Assert.AreEqual(value, Value("text-align", value)); + } + + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#text-align-last-property")] + public void TextAlignLast_MatchParent_IsParsed() + { + Assert.AreEqual("match-parent", Value("text-align-last", "match-parent")); + } + + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#white-space-property")] + public void WhiteSpace_BreakSpaces_IsParsed() + { + Assert.AreEqual("break-spaces", Value("white-space", "break-spaces")); + } + + [TestMethod] + [Spec("css-text-3", "#hyphenation")] + [DataRow("none")] + [DataRow("manual")] + [DataRow("auto")] + public void Hyphens_IsParsed(string value) + { + Assert.AreEqual(value, Value("hyphens", value)); + } + + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#overflow-wrap-property")] + public void OverflowWrap_Anywhere_IsParsed() + { + Assert.AreEqual("anywhere", Value("overflow-wrap", "anywhere")); + } + + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#word-break-property")] + public void WordBreak_BreakWord_IsParsed() + { + Assert.AreEqual("break-word", Value("word-break", "break-word")); + } + + private static string Value(string property, string value) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ {property}: {value}; }}"); + return sheet.Rules.OfType<StyleRule>().Single().Style.GetPropertyValue(property); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs index 4aee69fd1..8628de491 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs @@ -428,6 +428,7 @@ public void CssFontStretchSemiExpandedSpaceBetweenIllegal() // ── letter-spacing ──────────────────────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css-text-3", "#letter-spacing-property")] public void CssLetterSpacingLengthPxLegal() { var property = ParseDeclaration("letter-spacing: 3px "); @@ -441,6 +442,7 @@ public void CssLetterSpacingLengthPxLegal() } [TestMethod] + [Spec("css-text-3", "#letter-spacing-property")] public void CssLetterSpacingLengthFloatPxLegal() { var property = ParseDeclaration("letter-spacing: .3px "); @@ -454,6 +456,7 @@ public void CssLetterSpacingLengthFloatPxLegal() } [TestMethod] + [Spec("css-text-3", "#letter-spacing-property")] public void CssLetterSpacingLengthFloatEmLegal() { var property = ParseDeclaration("letter-spacing: 0.3em "); @@ -467,6 +470,7 @@ public void CssLetterSpacingLengthFloatEmLegal() } [TestMethod] + [Spec("css-text-3", "#letter-spacing-property")] public void CssLetterSpacingNormalLegal() { var property = ParseDeclaration("letter-spacing: normal "); diff --git a/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs index eb687ef77..c8053d038 100644 --- a/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs @@ -4,29 +4,12 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/TextProperty.cs (source class <c>TextPropertyTests</c>). -/// All word-spacing (incl. "normal" - <see cref="WordSpacingProperty"/>), text-shadow, text-align, -/// text-decoration* (line/color/style), word-break, text-align-last, text-anchor, text-justify (all 8 -/// keywords incl. kashida/newspaper), and overflow-wrap/word-wrap tests are ported as plain passing -/// tests. -/// Gap: TextIndentLegalHanging, TextIndentLegalEachLine, TextIndentLegalHangingEachLine, and -/// TextIndentLegalReorderedKeywordsPreservesAuthoredOrder are ported under [Ignore] - HTML-Renderer's -/// <see cref="TextIndentProperty"/> (Source/HtmlRenderer/Core/CssEngine/StyleProperties/Text/ -/// TextIndentProperty.cs:5-6) uses a bare <c>Converters.LengthOrPercentConverter.OrDefault(Length.Zero)</c>, -/// whereas PeachPDF has a dedicated TextIndentValueConverter supporting the -/// "&lt;length-percentage&gt; &amp;&amp; hanging? &amp;&amp; each-line?" grammar; no TextIndentValueConverter/ -/// TextIndentGrammar exists anywhere under Source/HtmlRenderer (confirmed via grep), so any indent value -/// with hanging/each-line is rejected outright (HasValue false). -/// TextIndentIllegalHangingAloneMissingLength and TextIndentIllegalDuplicateKeyword still port as plain -/// passing tests (not [Ignore]d): both assert HasValue == false, which the narrower converter also -/// produces (for the "wrong" reason - it simply doesn't understand "hanging" at all - but the assertion -/// still holds). -/// </summary> +/// <summary>Parsing of the CSS Text and CSS Text Decoration properties.</summary> [TestClass] public sealed class TextPropertyTests { [TestMethod] + [Spec("css-text-3", "#word-spacing-property")] public void WordSpacingZeroLengthLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-spacing: 0"); @@ -40,6 +23,7 @@ public void WordSpacingZeroLengthLegal() } [TestMethod] + [Spec("css-text-3", "#word-spacing-property")] public void WordSpacingLengthFloatRemLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-spacing: .3rem "); @@ -53,6 +37,7 @@ public void WordSpacingLengthFloatRemLegal() } [TestMethod] + [Spec("css-text-3", "#word-spacing-property")] public void WordSpacingLengthFloatEmLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-spacing: 0.3em "); @@ -66,6 +51,7 @@ public void WordSpacingLengthFloatEmLegal() } [TestMethod] + [Spec("css-text-3", "#word-spacing-property")] public void WordSpacingNormalLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-spacing: normal "); @@ -150,6 +136,7 @@ public void TextShadowLegalColorRgbaLast() } [TestMethod] + [Spec("css-text-3", "#text-align-property")] public void TextAlignLegalJustify() { var property = CssConstructionFunctions.ParseDeclaration("text-align:justify"); @@ -163,6 +150,7 @@ public void TextAlignLegalJustify() } [TestMethod] + [Spec("css-text-3", "#text-indent-property")] public void TextIndentLegalLength() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:3em"); @@ -176,6 +164,7 @@ public void TextIndentLegalLength() } [TestMethod] + [Spec("css-text-3", "#text-indent-property")] public void TextIndentLegalZero() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:0"); @@ -189,6 +178,7 @@ public void TextIndentLegalZero() } [TestMethod] + [Spec("css-text-3", "#text-indent-property")] public void TextIndentLegalPercent() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:10%"); @@ -202,6 +192,7 @@ public void TextIndentLegalPercent() } [TestMethod] + [Spec("css-text-3", "#text-indent-property")] public void TextIndentIllegalNone() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:none"); @@ -212,7 +203,8 @@ public void TextIndentIllegalNone() } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-text-3.yml")] + [Spec("css-text-3", "#text-indent-property")] public void TextIndentLegalHanging() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:40pt hanging"); @@ -226,7 +218,8 @@ public void TextIndentLegalHanging() } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-text-3.yml")] + [Spec("css-text-3", "#text-indent-property")] public void TextIndentLegalEachLine() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:40pt each-line"); @@ -240,7 +233,8 @@ public void TextIndentLegalEachLine() } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-text-3.yml")] + [Spec("css-text-3", "#text-indent-property")] public void TextIndentLegalHangingEachLine() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:40pt hanging each-line"); @@ -254,7 +248,8 @@ public void TextIndentLegalHangingEachLine() } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-text-3.yml")] + [Spec("css-text-3", "#text-indent-property")] public void TextIndentLegalReorderedKeywordsPreservesAuthoredOrder() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:each-line hanging 40pt"); @@ -268,6 +263,7 @@ public void TextIndentLegalReorderedKeywordsPreservesAuthoredOrder() } [TestMethod] + [Spec("css-text-3", "#text-indent-property")] public void TextIndentIllegalHangingAloneMissingLength() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:hanging"); @@ -278,6 +274,7 @@ public void TextIndentIllegalHangingAloneMissingLength() } [TestMethod] + [Spec("css-text-3", "#text-indent-property")] public void TextIndentIllegalDuplicateKeyword() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:40pt hanging hanging"); @@ -421,6 +418,7 @@ public void TextDecorationExpansionAndRecombination() } [TestMethod] + [Spec("css-text-3", "#word-break-property")] public void WordBreakNormalLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-break : normal"); @@ -434,6 +432,7 @@ public void WordBreakNormalLegal() } [TestMethod] + [Spec("css-text-3", "#word-break-property")] public void WordBreakBreakAllLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-break : break-all"); @@ -447,6 +446,7 @@ public void WordBreakBreakAllLegal() } [TestMethod] + [Spec("css-text-3", "#word-break-property")] public void WordBreakKeepAllLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-break : keep-all"); @@ -460,6 +460,7 @@ public void WordBreakKeepAllLegal() } [TestMethod] + [Spec("css-text-3", "#word-break-property")] public void WordBreakNoneIllegal() { var property = CssConstructionFunctions.ParseDeclaration("word-break : none"); @@ -471,6 +472,7 @@ public void WordBreakNoneIllegal() } [TestMethod] + [Spec("css-text-3", "#text-align-last-property")] public void TextAlignLastAutoLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: auto"); @@ -484,6 +486,7 @@ public void TextAlignLastAutoLegal() } [TestMethod] + [Spec("css-text-3", "#text-align-last-property")] public void TextAlignLastStartLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: start"); @@ -497,6 +500,7 @@ public void TextAlignLastStartLegal() } [TestMethod] + [Spec("css-text-3", "#text-align-last-property")] public void TextAlignLastEndLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: end"); @@ -510,6 +514,7 @@ public void TextAlignLastEndLegal() } [TestMethod] + [Spec("css-text-3", "#text-align-last-property")] public void TextAlignLastRightLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: right"); @@ -523,6 +528,7 @@ public void TextAlignLastRightLegal() } [TestMethod] + [Spec("css-text-3", "#text-align-last-property")] public void TextAlignLastLeftLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: left"); @@ -536,6 +542,7 @@ public void TextAlignLastLeftLegal() } [TestMethod] + [Spec("css-text-3", "#text-align-last-property")] public void TextAlignLastCenterLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: center"); @@ -549,6 +556,7 @@ public void TextAlignLastCenterLegal() } [TestMethod] + [Spec("css-text-3", "#text-align-last-property")] public void TextAlignLastJustifyLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: justify"); @@ -562,6 +570,7 @@ public void TextAlignLastJustifyLegal() } [TestMethod] + [Spec("css-text-3", "#text-align-last-property")] public void TextAlignLastNoneIllegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: none"); @@ -623,6 +632,7 @@ public void TextAnchorNoneIllegal() } [TestMethod] + [Spec("css-text-3", "#text-justify-property")] public void TextJustifyAutoLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-justify: auto"); @@ -636,6 +646,7 @@ public void TextJustifyAutoLegal() } [TestMethod] + [Spec("css-text-3", "#text-justify-property")] public void TextJustifyDistributeLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-justify: distribute"); @@ -649,6 +660,7 @@ public void TextJustifyDistributeLegal() } [TestMethod] + [Spec("css-text-3", "#text-justify-property")] public void TextJustifyDistributeAllLinesLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-justify: distribute-all-lines"); @@ -662,6 +674,7 @@ public void TextJustifyDistributeAllLinesLegal() } [TestMethod] + [Spec("css-text-3", "#text-justify-property")] public void TextJustifyDistributeCenterLastLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-justify: distribute-center-last"); @@ -701,6 +714,7 @@ public void TextJustifyInterIdeographLegal() } [TestMethod] + [Spec("css-text-3", "#text-justify-property")] public void TextJustifyInterWordLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-justify: inter-word"); @@ -740,6 +754,7 @@ public void TextJustifyNewspaperLegal() } [TestMethod] + [Spec("css-text-3", "#text-justify-property")] public void TextJustifyNoneIllegal() { var property = CssConstructionFunctions.ParseDeclaration("text-justify: none"); @@ -751,6 +766,7 @@ public void TextJustifyNoneIllegal() } [TestMethod] + [Spec("css-text-3", "#overflow-wrap-property")] public void OverflowWrapNormalLegal() { var property = CssConstructionFunctions.ParseDeclaration("overflow-wrap: normal"); @@ -764,6 +780,7 @@ public void OverflowWrapNormalLegal() } [TestMethod] + [Spec("css-text-3", "#overflow-wrap-property")] public void OverflowWrapAlternateNameNormalLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-wrap: normal"); @@ -777,6 +794,7 @@ public void OverflowWrapAlternateNameNormalLegal() } [TestMethod] + [Spec("css-text-3", "#overflow-wrap-property")] public void OverflowWrapBreakWordLegal() { var property = CssConstructionFunctions.ParseDeclaration("overflow-wrap: break-word"); @@ -790,6 +808,7 @@ public void OverflowWrapBreakWordLegal() } [TestMethod] + [Spec("css-text-3", "#overflow-wrap-property")] public void OverflowWrapAlternateNameBreakWordLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-wrap: break-word"); @@ -803,6 +822,7 @@ public void OverflowWrapAlternateNameBreakWordLegal() } [TestMethod] + [Spec("css-text-3", "#overflow-wrap-property")] public void OverflowWrapNoneIllegal() { var property = CssConstructionFunctions.ParseDeclaration("overflow-wrap: none"); @@ -814,6 +834,7 @@ public void OverflowWrapNoneIllegal() } [TestMethod] + [Spec("css-text-3", "#overflow-wrap-property")] public void OverflowWrapAlternateNameNoneIllegal() { var property = CssConstructionFunctions.ParseDeclaration("word-wrap: none"); diff --git a/Source/Test/HtmlRenderer.Test/Css/WhiteSpacePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/WhiteSpacePropertyTests.cs index adfa20764..b570a63c8 100644 --- a/Source/Test/HtmlRenderer.Test/Css/WhiteSpacePropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/WhiteSpacePropertyTests.cs @@ -15,6 +15,7 @@ private static string GetWhiteSpace(string declaration) [TestMethod] [Spec("css21", "text.html#white-space-prop")] + [Spec("css-text-3", "#white-space-property")] [DataRow("normal")] [DataRow("pre")] [DataRow("nowrap")] @@ -27,6 +28,7 @@ public void WhiteSpaceKeywordLegal(string keyword) [TestMethod] [Spec("css21", "text.html#white-space-prop")] + [Spec("css-text-3", "#white-space-property")] public void WhiteSpaceInvalidKeywordIllegal() { Assert.IsTrue(string.IsNullOrEmpty(GetWhiteSpace("white-space: wavy"))); diff --git a/Spec/css-text-3.yml b/Spec/css-text-3.yml index 4f5b7f563..4cc3643e4 100644 --- a/Spec/css-text-3.yml +++ b/Spec/css-text-3.yml @@ -5,130 +5,155 @@ snapshot: https://www.w3.org/TR/2026/CRD-css-text-3-20260814/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown - '#languages': - title: 1.3 Languages and Typesetting - status: unknown - '#characters': - title: 1.4 Characters and Letters - status: unknown - '#text-encoding': - title: 1.5 Text Processing - status: unknown + status: n/a '#transforming': title: 2 Transforming Text - status: unknown + status: parsed-only '#text-transform-property': title: '2.1 Case Transforms: the text-transform property' - status: unknown + status: parsed-only + gaps: + - text-transform is ignored, see CSS 2.1 16.5 + - full-size-kana and combinations such as 'uppercase full-width' aren't accepted '#text-transform-mapping': title: 2.1.1 Mapping Rules - status: unknown + status: unsupported '#text-transform-order': title: 2.1.2 Order of Operations - status: unknown + status: unsupported '#white-space-property': title: '3 White Space and Wrapping: the white-space property' - status: unknown + status: partial + gaps: + - break-spaces isn't accepted '#white-space-processing': title: 4 White Space Processing & Control Characters - status: unknown + status: partial + gaps: + - Control characters such as U+000B are treated as white space instead of being rendered, see CSS 2.1 16.6.3 '#white-space-rules': title: 4.1 The White Space Processing Rules - status: unknown + status: partial '#white-space-phase-1': title: '4.1.1 Phase I: Collapsing and Transformation' - status: unknown + status: partial + gaps: + - A space following another space isn't removed when an element boundary is between them, see CSS 2.1 16.6.1 + - U+00A0 is collapsed and breakable like a space, see CSS 2.1 16.6.1 '#white-space-phase-2': title: '4.1.2 Phase II: Trimming and Positioning' - status: unknown + status: partial + gaps: + - Preserved tabs are as wide as their glyph instead of reaching the next tab stop '#line-break-transform': title: 4.1.3 Segment Break Transformation Rules - status: unknown + status: supported '#tab-size-property': title: '4.2 Tab Character Size: the tab-size property' - status: unknown + status: unsupported '#line-breaking': title: 5 Line Breaking and Word Boundaries - status: unknown + status: partial '#word-break-property': title: '5.1 Breaking Rules for Letters: the word-break property' - status: unknown + status: partial + gaps: + - keep-all doesn't keep CJK text together + - break-word isn't accepted '#line-break-property': title: '5.2 Line Breaking Strictness: the line-break property' - status: unknown + status: unsupported '#hyphenation': title: '5.3 Hyphenation: the hyphens property' - status: unknown + status: parsed-only + gaps: + - Soft hyphens (U+00AD) aren't break opportunities '#overflow-wrap-property': title: '5.4 Overflow Wrapping: the overflow-wrap (word-wrap) property' - status: unknown + status: parsed-only + gaps: + - overflow-wrap and word-wrap are ignored, a word that doesn't fit overflows + - anywhere isn't accepted '#line-break-details': title: 5.5 Line Breaking Details - status: unknown + status: partial + gaps: + - A line can break at an element boundary or a float without white space in the text, see CSS 2.1 16.6.1 '#justification': title: 6 Alignment and Justification - status: unknown + status: partial '#text-align-property': title: '6.1 Text Alignment: the text-align shorthand' - status: unknown + status: partial + gaps: + - start, end, match-parent and justify-all aren't accepted, so the alignment doesn't follow the direction + - The initial value acts as left also when direction is rtl, see CSS 2.1 16.2 '#text-align-all-property': title: '6.2 Default Text Alignment: the text-align-all property' - status: unknown + status: unsupported '#text-align-last-property': title: '6.3 Last Line Alignment: the text-align-last property' - status: unknown + status: parsed-only + gaps: + - text-align-last is ignored, the last line of justified text is aligned left + - match-parent isn't accepted '#text-justify-property': title: '6.4 Justification Method: the text-justify property' - status: unknown + status: parsed-only '#expanding-text': title: 6.4.1 Expanding and Compressing Text - status: unknown + status: partial + gaps: + - Preserved spaces in pre and pre-wrap text are stretched too, see CSS 2.1 16.2 '#justify-symbols': title: 6.4.2 Handling Symbols and Punctuation - status: unknown + status: n/a '#justify-limits': title: 6.4.3 Unexpandable Text - status: unknown + status: n/a '#justify-cursive': title: 6.4.4 Cursive Scripts - status: unknown + status: n/a '#justify-algos': title: 6.4.5 Minimum Requirements for auto Justification - status: unknown + status: supported '#spacing': title: 7 Spacing - status: unknown + status: parsed-only '#word-spacing-property': title: '7.1 Word Spacing: the word-spacing property' - status: unknown + status: parsed-only + gaps: + - word-spacing has no effect, see CSS 2.1 16.4 '#letter-spacing-property': title: '7.2 Tracking: the letter-spacing property' - status: unknown + status: parsed-only + gaps: + - letter-spacing is ignored, see CSS 2.1 16.4 '#cursive-tracking': title: 7.2.1 Cursive Scripts - status: unknown + status: n/a '#boundary-shaping': title: 7.3 Shaping Across Element Boundaries - status: unknown + status: unsupported '#edge-effects': title: 8 Edge Effects - status: unknown + status: partial '#text-indent-property': title: '8.1 First Line Indentation: the text-indent property' - status: unknown + status: partial + gaps: + - hanging and each-line aren't accepted + - "The gaps of CSS 2.1 16.1 apply: percentages, anonymous blocks, rtl and inline-blocks" '#hanging': title: 8.2 Hanging Glyphs - status: unknown + status: unsupported '#hanging-punctuation-property': title: '8.2.1 Hanging Punctuation: the hanging-punctuation property' - status: unknown + status: unsupported '#bidi-linebox': title: 8.3 Bidirectionality and Line Boxes - status: unknown + status: partial + gaps: + - The line's direction doesn't change which side start and end refer to, see 6.1 + - In rtl text-indent is applied at the left edge, see CSS 2.1 16.1 From 3a21ade8819ede78287fada2d17c4d2e172e1a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:17:57 +0100 Subject: [PATCH 078/150] Verify and document CSS Text Decoration Level 3 compliance --- Source/HtmlRenderer/Core/Dom/CssBox.cs | 9 +- .../CssTextDecor3PaintIntegrationTests.cs | 100 ++++++++++++++++++ .../TextDecorationPaintIntegrationTests.cs | 14 +++ .../Css/TextPropertyTests.cs | 16 +++ Spec/css-text-decor-3.yml | 55 +++++----- 5 files changed, 165 insertions(+), 29 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/CssTextDecor3PaintIntegrationTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index 1215181e3..485040b40 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -1931,12 +1931,13 @@ internal void PaintWord(RGraphics g, CssRect word, RRect wordRect) } /// <summary> - /// Paints the text decoration (underline/strike-through/over-line) + /// Paints the line decorations of one line of the box (CSS Text Decoration 3 §2), as solid lines + /// in the text color. /// </summary> /// <param name="g">the device to draw into</param> - /// <param name="rectangle"> </param> - /// <param name="isFirst"> </param> - /// <param name="isLast"> </param> + /// <param name="rectangle">the box's rectangle on this line</param> + /// <param name="isFirst">whether it's the first line, where the start padding and border are skipped</param> + /// <param name="isLast">whether it's the last line, where the end padding and border are skipped</param> internal void PaintDecoration(RGraphics g, RRect rectangle, bool isFirst, bool isLast) { if (string.IsNullOrEmpty(TextDecoration) || TextDecoration == CssConstants.None) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssTextDecor3PaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssTextDecor3PaintIntegrationTests.cs new file mode 100644 index 000000000..73be781b6 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssTextDecor3PaintIntegrationTests.cs @@ -0,0 +1,100 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.Painting; + +/// <summary>CSS Text Decoration Level 3 line styles and colors, text shadows and the painting order.</summary> +[DoNotParallelize] +[TestClass] +public sealed class CssTextDecor3PaintIntegrationTests +{ + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + + [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-property")] + public void Shorthand_WithStyleAndColor_SetsTheLine() + { + var lines = Log("<span style='text-decoration:dotted underline rgb(255,0,0)'>text</span>").OfType<RecordingGraphics.DrawLineCall>(); + + Assert.AreEqual(1, lines.Count()); + } + + [Ignore("Gap, see Spec/css-text-decor-3.yml")] + [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-property")] + public void Shorthand_SetsTheColor() + { + var line = Log("<span style='color:rgb(0,0,255);text-decoration:underline rgb(255,0,0)'>text</span>").OfType<RecordingGraphics.DrawLineCall>().Single(); + + Assert.AreEqual(Red, line.Color); + } + + [Ignore("Gap, see Spec/css-text-decor-3.yml")] + [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-color-property")] + public void DecorationColor_IsTheColorOfTheLine() + { + var line = Log("<span style='color:rgb(0,0,255);text-decoration-line:underline;text-decoration-color:rgb(255,0,0)'>text</span>").OfType<RecordingGraphics.DrawLineCall>().Single(); + + Assert.AreEqual(Red, line.Color); + } + + [Ignore("Gap, see Spec/css-text-decor-3.yml")] + [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-style-property")] + public void Double_DrawsTwoLines() + { + var lines = Log("<span style='text-decoration-line:underline;text-decoration-style:double'>text</span>").OfType<RecordingGraphics.DrawLineCall>(); + + Assert.AreEqual(2, lines.Count()); + } + + [Ignore("Gap, see Spec/css-text-decor-3.yml")] + [TestMethod] + [Spec("css-text-decor-3", "#text-shadow-property")] + public void TextShadow_PaintsTheTextInTheShadowColor() + { + var strings = Log("<span style='color:black;text-shadow:2px 2px rgb(255,0,0)'>text</span>").OfType<RecordingGraphics.DrawStringCall>(); + + Assert.IsTrue(strings.Any(s => s.Color == Red)); + } + + [Ignore("Gap, see Spec/css-text-decor-3.yml")] + [TestMethod] + [Spec("css-text-decor-3", "#painting-order")] + [DataRow("underline")] + [DataRow("overline")] + public void UnderlineAndOverline_ArePaintedBelowTheText(string line) + { + var log = Log($"<span style='text-decoration:{line}'>text</span>"); + + Assert.IsLessThan(IndexOf<RecordingGraphics.DrawStringCall>(log), IndexOf<RecordingGraphics.DrawLineCall>(log)); + } + + [TestMethod] + [Spec("css-text-decor-3", "#painting-order")] + public void LineThrough_IsPaintedOverTheText() + { + var log = Log("<span style='text-decoration:line-through'>text</span>"); + + Assert.IsGreaterThan(IndexOf<RecordingGraphics.DrawStringCall>(log), IndexOf<RecordingGraphics.DrawLineCall>(log)); + } + + [TestMethod] + [Spec("css-text-decor-3", "#overflow")] + public void Decorations_DontChangeTheLayout() + { + var (plain, _) = PaintHarness.Layout(PaintHarness.Wrap("<div id='d'>text</div>")); + var (decorated, _) = PaintHarness.Layout(PaintHarness.Wrap("<div id='d' style='text-decoration:underline overline line-through'>text</div>")); + + Assert.AreEqual(PaintHarness.FindById(plain, "d")!.Size.Height, PaintHarness.FindById(decorated, "d")!.Size.Height, 0.01); + } + + private static List<object> Log(string body) + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap(body)); + return PaintHarness.PaintPage(container).Log.Cast<object>().ToList(); + } + + private static int IndexOf<T>(List<object> log) => log.FindIndex(call => call is T); +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs index b0ed84dc1..5b7e41c0f 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs @@ -10,6 +10,7 @@ public sealed class TextDecorationPaintIntegrationTests { [TestMethod] [Spec("css21", "text.html#lining-striking-props")] + [Spec("css-text-decor-3", "#text-decoration-line-property")] public void Underline_DrawsLineBelowRectangleTop() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -28,6 +29,7 @@ public void Underline_DrawsLineBelowRectangleTop() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] + [Spec("css-text-decor-3", "#text-decoration-line-property")] public void Overline_DrawsLineAtRectangleTop() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -44,6 +46,7 @@ public void Overline_DrawsLineAtRectangleTop() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] + [Spec("css-text-decor-3", "#text-decoration-line-property")] public void LineThrough_DrawsLineNearRectangleMiddle() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -61,6 +64,7 @@ public void LineThrough_DrawsLineNearRectangleMiddle() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] + [Spec("css-text-decor-3", "#text-decoration-line-property")] public void Underline_OverlineLineThrough_ProduceDifferentYPositions() { var underlineY = GetDecorationY("underline"); @@ -73,6 +77,7 @@ public void Underline_OverlineLineThrough_ProduceDifferentYPositions() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] + [Spec("css-text-decor-3", "#text-decoration-line-property")] public void TextDecorationNone_DrawsNoLine() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -86,6 +91,7 @@ public void TextDecorationNone_DrawsNoLine() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] + [Spec("css-text-decor-3", "#text-decoration-line-property")] public void SeveralLines_AreAllDrawn() { var lines = PageLines("<span style='text-decoration:underline overline'>text</span>"); @@ -95,6 +101,7 @@ public void SeveralLines_AreAllDrawn() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] + [Spec("css-text-decor-3", "#text-decoration-line-property")] public void Blink_DrawsNoLine() { Assert.AreEqual(0, PageLines("<span style='text-decoration:blink'>text</span>").Count); @@ -102,6 +109,7 @@ public void Blink_DrawsNoLine() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] + [Spec("css-text-decor-3", "#line-decoration")] public void Block_PropagatesTheDecorationToItsText() { Assert.AreEqual(1, PageLines("<div style='text-decoration:underline'>text</div>").Count); @@ -109,6 +117,7 @@ public void Block_PropagatesTheDecorationToItsText() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] + [Spec("css-text-decor-3", "#line-decoration")] public void Inline_PropagatesTheDecorationToTheBlockThatSplitsIt() { Assert.AreEqual(3, PageLines("<span style='text-decoration:underline'>a<div>b</div>c</span>").Count); @@ -116,6 +125,7 @@ public void Inline_PropagatesTheDecorationToTheBlockThatSplitsIt() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] + [Spec("css-text-decor-3", "#line-decoration")] public void Underline_SkipsMarginAndPadding() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -131,6 +141,7 @@ public void Underline_SkipsMarginAndPadding() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] + [Spec("css-text-decor-3", "#line-decoration")] public void Image_IsntUnderlined() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -146,6 +157,7 @@ public void Image_IsntUnderlined() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#lining-striking-props")] + [Spec("css-text-decor-3", "#line-decoration")] public void PropagatedDecoration_KeepsTheColorOfTheElementItIsSetOn() { var lines = PageLines("<div style='text-decoration:underline;color:rgb(0,0,255)'>a <span style='color:rgb(255,0,0)'>b</span></div>"); @@ -157,6 +169,7 @@ public void PropagatedDecoration_KeepsTheColorOfTheElementItIsSetOn() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#lining-striking-props")] + [Spec("css-text-decor-3", "#line-decoration")] public void Descendant_CantRemoveTheDecorationOfAnAncestor() { Assert.AreEqual(2, PageLines("<div style='text-decoration:underline'>a <span style='text-decoration:none'>b</span></div>").Count); @@ -168,6 +181,7 @@ public void Descendant_CantRemoveTheDecorationOfAnAncestor() [DataRow("float:left")] [DataRow("position:absolute;top:100px")] [DataRow("display:inline-block")] + [Spec("css-text-decor-3", "#line-decoration")] public void Decoration_IsntPropagatedToFloatsAbsolutesAndInlineBlocks(string style) { Assert.AreEqual(1, PageLines($"<div style='text-decoration:underline'>a <span style='{style}'>b</span></div>").Count); diff --git a/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs index c8053d038..d13341e99 100644 --- a/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs @@ -65,6 +65,7 @@ public void WordSpacingNormalLegal() } [TestMethod] + [Spec("css-text-decor-3", "#text-shadow-property")] public void TextShadowLegalInsetAtLast() { var property = CssConstructionFunctions.ParseDeclaration("text-shadow: 0 0 2px black inset"); @@ -78,6 +79,7 @@ public void TextShadowLegalInsetAtLast() } [TestMethod] + [Spec("css-text-decor-3", "#text-shadow-property")] public void TextShadowLegalColorInFront() { var property = CssConstructionFunctions.ParseDeclaration("text-shadow: rgba(255,255,255,0.5) 0px 3px 3px"); @@ -91,6 +93,7 @@ public void TextShadowLegalColorInFront() } [TestMethod] + [Spec("css-text-decor-3", "#text-shadow-property")] public void TextShadowLegalMultipleMultilines() { const string snippet = @"text-shadow: 0px 3px 0px #b2a98f, @@ -110,6 +113,7 @@ 0px 24px 2px rgba(0,0,0,0.1), } [TestMethod] + [Spec("css-text-decor-3", "#text-shadow-property")] public void TextShadowLegalMultipleInline() { var property = CssConstructionFunctions.ParseDeclaration("text-shadow: 4px 3px 0px #fff, 9px 8px 0px rgba(0,0,0,0.15)"); @@ -123,6 +127,7 @@ public void TextShadowLegalMultipleInline() } [TestMethod] + [Spec("css-text-decor-3", "#text-shadow-property")] public void TextShadowLegalColorRgbaLast() { var property = CssConstructionFunctions.ParseDeclaration("text-shadow: 2px 4px 3px rgba(0,0,0,0.3)"); @@ -285,6 +290,7 @@ public void TextIndentIllegalDuplicateKeyword() } [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-property")] public void TextDecorationIllegal() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration: line-pass"); @@ -296,6 +302,7 @@ public void TextDecorationIllegal() } [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-property")] public void TextDecorationLegalLineThrough() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration: line-Through"); @@ -309,6 +316,7 @@ public void TextDecorationLegalLineThrough() } [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-property")] public void TextDecorationLegalUnderlineOverline() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration: underline overline"); @@ -322,6 +330,7 @@ public void TextDecorationLegalUnderlineOverline() } [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-color-property")] public void TextDecorationColorLegalHex() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration-color: #F00"); @@ -335,6 +344,7 @@ public void TextDecorationColorLegalHex() } [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-color-property")] public void TextDecorationColorLegalRed() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration-color: red"); @@ -348,6 +358,7 @@ public void TextDecorationColorLegalRed() } [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-line-property")] public void TextDecorationLineIllegalInteger() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration-line: 5"); @@ -359,6 +370,7 @@ public void TextDecorationLineIllegalInteger() } [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-line-property")] public void TextDecorationLineLegalNone() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration-line: none"); @@ -372,6 +384,7 @@ public void TextDecorationLineLegalNone() } [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-line-property")] public void TextDecorationLineLegalOverlineUnderlineLineThrough() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration-line: overline underline line-through "); @@ -385,6 +398,7 @@ public void TextDecorationLineLegalOverlineUnderlineLineThrough() } [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-style-property")] public void TextDecorationStyleLegalWavyUppercase() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration-style: WAVY "); @@ -398,6 +412,7 @@ public void TextDecorationStyleLegalWavyUppercase() } [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-style-property")] public void TextDecorationStyleIllegalMultiple() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration-style: wavy dotted"); @@ -409,6 +424,7 @@ public void TextDecorationStyleIllegalMultiple() } [TestMethod] + [Spec("css-text-decor-3", "#text-decoration-property")] public void TextDecorationExpansionAndRecombination() { const string snippet = ".centered {text-decoration:underline;}"; diff --git a/Spec/css-text-decor-3.yml b/Spec/css-text-decor-3.yml index bbc151bfe..d07714a75 100644 --- a/Spec/css-text-decor-3.yml +++ b/Spec/css-text-decor-3.yml @@ -5,58 +5,63 @@ snapshot: https://www.w3.org/TR/2022/CRD-css-text-decor-3-20220505/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown - '#terms': - title: 1.3 Terminology - status: unknown + status: n/a '#line-decoration': title: '2 Line Decoration: Underline, Overline, and Strike-Through' - status: unknown + status: partial + gaps: + - A propagated decoration is drawn in the color of the descendant instead of the decorating box, see CSS 2.1 16.3.1 + - "A descendant's text-decoration: none removes the decoration of its ancestor, see CSS 2.1 16.3.1" + - Decorations propagate to floats, absolutely positioned boxes and inline-blocks, see CSS 2.1 16.3.1 '#text-decoration-line-property': title: '2.1 Text Decoration Lines: the text-decoration-line property' - status: unknown + status: supported '#text-decoration-style-property': title: '2.2 Text Decoration Style: the text-decoration-style property' - status: unknown + status: parsed-only + gaps: + - Every line is drawn solid '#text-decoration-color-property': title: '2.3 Text Decoration Color: the text-decoration-color property' - status: unknown + status: parsed-only + gaps: + - The lines are drawn in the color of the text '#text-decoration-property': title: '2.4 Text Decoration Shorthand: the text-decoration property' - status: unknown + status: partial + gaps: + - Only the line is applied, the style and color are ignored, see 2.2 and 2.3 '#text-underline-position-property': title: '2.5 Text Underline Position: the text-underline-position property' - status: unknown + status: unsupported '#emphasis-marks': title: 3 Emphasis Marks - status: unknown + status: unsupported '#text-emphasis-style-property': title: '3.1 Emphasis Mark Style: the text-emphasis-style property' - status: unknown + status: unsupported '#text-emphasis-color-property': title: '3.2 Emphasis Mark Color: the text-emphasis-color property' - status: unknown + status: unsupported '#text-emphasis-property': title: '3.3 Emphasis Mark Shorthand: the text-emphasis property' - status: unknown + status: unsupported '#text-emphasis-position-property': title: '3.4 Emphasis Mark Position: the text-emphasis-position property' - status: unknown + status: unsupported '#text-shadow-property': title: '4 Text Shadows: the text-shadow property' - status: unknown + status: parsed-only + gaps: + - No shadow is painted '#painting': title: 5 Painting Text Decorations - status: unknown + status: partial '#painting-order': title: 5.1 Painting Order of Text Decorations - status: unknown + status: partial + gaps: + - Underlines and overlines are painted over the text instead of below it '#overflow': title: 5.2 Overflow of Text Decorations - status: unknown + status: supported From 86aea9804493209ffd7b46361f9d91bec6e584db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:05:52 +0100 Subject: [PATCH 079/150] Verify and document CSS Table Level 3 compliance --- Source/HtmlRenderer/Core/CssDefaults.cs | 3 +- Source/HtmlRenderer/Core/Dom/CssBox.cs | 21 +- .../Core/Dom/CssLayoutEngineTable.cs | 20 +- .../CssModules/CssTablesTests.cs | 472 ++++++++++++++++++ .../StageD4RepeatedHeaderTest.cs | 11 +- .../TableRowDefaultAtomicityTest.cs | 20 +- .../Tables/TableBorderIntegrationTests.cs | 14 + .../Tables/TableModelIntegrationTests.cs | 22 + .../TableRepeatedGroupConditionsTests.cs | 65 +-- .../Tables/TableRowBreakValueTests.cs | 32 +- .../Tables/TableSizingIntegrationTests.cs | 19 + ...TableVisibilityCollapseIntegrationTests.cs | 2 + Spec/css-tables-3.yml | 238 +++++---- 13 files changed, 723 insertions(+), 216 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTablesTests.cs diff --git a/Source/HtmlRenderer/Core/CssDefaults.cs b/Source/HtmlRenderer/Core/CssDefaults.cs index 6e7ebbf93..109022872 100644 --- a/Source/HtmlRenderer/Core/CssDefaults.cs +++ b/Source/HtmlRenderer/Core/CssDefaults.cs @@ -101,8 +101,7 @@ @media print { h1, h2, h3, h4, h5, h6 { break-after: avoid } - /* css-tables-3 6.2 repeats a header or footer group across the pages a table spans only - where the group carries an avoid break-inside. */ + /* CSS Tables 3 §6.2 */ thead, tfoot { break-inside: avoid } } diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index 485040b40..0dbf1e628 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -84,11 +84,9 @@ internal CssBox ListItemBox } /// <summary> - /// For a table box only: detached clones of the table's own &lt;thead&gt; rows, one set per - /// continuation page the table's body spans (css-tables-3 6.2's repeated headers) - not part - /// of <see cref="Boxes"/> (so re-running table layout can never mistake them for real body - /// content), rebuilt from scratch on every layout pass by <see cref="Fragmentation.TableHeaderRepeat"/>. - /// Null when the table has no header or never crosses a page boundary. + /// For a table: detached clones of its header rows, one set per continuation page (CSS Tables 3 + /// §6.2), rebuilt on every layout by <see cref="Fragmentation.TableHeaderRepeat"/>. Not part of + /// <see cref="Boxes"/>. Null when nothing repeats. /// </summary> internal List<CssBox> RepeatedHeaderRows { get; set; } @@ -591,16 +589,9 @@ internal void ResumeAt(BreakToken token, double? resumeTopOverride = null) } /// <summary> - /// Whether a forced break here could actually be deferred to (and resumed in) a later pass - - /// false anywhere inside a table cell's subtree. <see cref="CssLayoutEngineTable"/>'s row loop - /// calls <c>cell.PerformLayout</c> directly, the same way it always has, and does not participate - /// in the <see cref="PendingBreakToken"/> bubbling protocol an ordinary block-child loop does (see - /// that property's doc comment) - a table row is not itself laid out via that loop, so nothing - /// would ever read a cell's own <see cref="PendingBreakToken"/> and turn it into a real pass - /// boundary. Deferring anyway would leave the deferred content measured but never positioned - /// (its <see cref="PerformLayoutImp"/> call returns before reaching <c>CreateLineBoxes</c>/the - /// block-child loop, yet nothing ever resumes it) - found as a real regression while - /// investigating table fragmentation, once R1's forced-break deferral existed to trigger it. + /// Whether a forced break here can be deferred to a later pass. Not inside a table cell: + /// <see cref="CssLayoutEngineTable"/> lays cells out directly and never reads their + /// <see cref="PendingBreakToken"/>, so deferred content would never be positioned. /// </summary> private bool CanDeferToLaterPass() { diff --git a/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs b/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs index 3648da4a3..7ec074be1 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs @@ -619,12 +619,9 @@ private void LayoutCells(RGraphics g) _tableBox.Location = new RPoint(startx - _tableBox.ActualBorderLeftWidth - _tableBox.ActualPaddingLeft - GetHorizontalSpacing(), _tableBox.Location.Y); } - // css-tables-3 6.2: a <thead> with an avoiding break-inside repeats on every page the table - // spans. The room for it is reserved here, before the first row of each continuation page is - // placed, so the row ends up below the repeated header instead of underneath it. - // - // The check runs once per row, at the row's start. A single row spanning several pages only - // gets the header on the first page it crosses onto. + // CSS Tables 3 §6.2. The header's room is reserved before the first row of each continuation + // page. Checked once per row at its start, so a row spanning several pages only gets the + // header on the first page it crosses onto. var pageGridContainer = _tableBox.HtmlContainer; var repeatsHeader = pageGridContainer != null && pageGridContainer.HasRealPageGrid && _headerBox != null && BreakValues.AvoidsBreak(_headerBox.BreakInside); @@ -715,9 +712,7 @@ private void LayoutCells(RGraphics g) } } - // css-tables-3 §6.1: a row straddling a page boundary that the UA has to try to keep in - // one piece moves down to the next page as a whole. The table's own break-inside:avoid - // forces that for every row. + // CSS Tables 3 §6.1. The table's own break-inside:avoid keeps every row in one piece. if (pageGridContainer != null && pageGridContainer.HasRealPageGrid && maxBottom > cury) { var topSlot = pageGridContainer.PageIndexOf(cury); @@ -874,11 +869,8 @@ private int PageSlotOf(HtmlContainerInt container, double y) => container.PageIndexOf(Math.Max(y, _tableBox.ClientTop)); /// <summary> - /// css-tables-3 §6.1's "the cells spanning the row do not span any subsequent row" test: true - /// if any cell in <paramref name="row"/> - real or the <see cref="CssSpacingBox"/> placeholder - /// standing in for one that started earlier - continues into a row after - /// <paramref name="currentrow"/>, meaning this row cannot be preserved unfragmented on its own - /// without also pulling along content that belongs to a row not yet reached. + /// Whether a cell of <paramref name="row"/>, including one spanning in from an earlier row, + /// continues into a later row, which makes the row freely fragmentable (CSS Tables 3 §6.1). /// </summary> private static bool RowHasCellSpanningIntoSubsequentRow(CssBox row, int currentrow) { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTablesTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTablesTests.cs new file mode 100644 index 000000000..3b2f76ef0 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTablesTests.cs @@ -0,0 +1,472 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Utils; + +namespace HtmlRenderer.IntegrationTest.CssModules; + +[DoNotParallelize] +[TestClass] +public sealed class CssTablesTests +{ + private const double Delta = 0.5; + + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + private static readonly RColor Blue = RColor.FromArgb(0, 0, 255); + + private static double Width(CssBox box) => box.ActualRight - box.Location.X; + + private static double Height(CssBox box) => box.ActualBottom - box.Location.Y; + + private static bool PaintsColor(RecordingGraphics g, RColor color) => + g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == color) + || g.Log.OfType<RecordingGraphics.DrawLineCall>().Any(l => l.Color == color) + || g.Log.OfType<RecordingGraphics.DrawPolygonCall>().Any(p => p.Color == color); + + [TestMethod] + [Spec("css-tables-3", "#fixup-boxes")] + public void AnonymousCell_HasNoPaddingBorderOrBackground() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='display:table;border-spacing:0'><div id='r' style='display:table-row'><span id='s'>text</span></div></div>")); + + var cell = LayoutHarness.FindById(root, "r")!.Boxes.Single(); + var word = LayoutHarness.Descendants(cell).SelectMany(b => b.Words).First(); + + Assert.IsNull(cell.HtmlTag); + Assert.AreEqual(0, cell.ActualPaddingLeft, Delta); + Assert.AreEqual(0, cell.ActualBorderLeftWidth, Delta); + Assert.AreEqual(RColor.Transparent, cell.ActualBackgroundColor); + Assert.AreEqual(cell.Location.X, word.Left, Delta); + } + + [TestMethod] + [Spec("css-tables-3", "#table-layout-algorithm")] + public void EmptyTable_GetsItsSpecifiedWidthAndHeight() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table id='t' style='width:100px;height:50px;border-spacing:0'></table>")); + + var t = LayoutHarness.FindById(root, "t")!; + + Assert.AreEqual(100, Width(t), 1); + Assert.AreEqual(50, Height(t), 1); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#dimensioning-the-row-column-grid--step1")] + public void ColspanOnANonCellElement_IsIgnored() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='display:table;border-spacing:0'>" + + "<div style='display:table-row'><div id='a' style='display:table-cell' colspan='2'>a</div></div>" + + "<div style='display:table-row'><div id='b' style='display:table-cell;width:50px'>b</div><div id='c' style='display:table-cell;width:50px'>c</div></div>" + + "</div>")); + + Assert.AreEqual(LayoutHarness.FindById(root, "b")!.ActualRight, LayoutHarness.FindById(root, "a")!.ActualRight, Delta); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#dimensioning-the-row-column-grid--step2")] + public void ColumnsSpannedByTheSameCells_AreMergedIntoOne() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table id='t' style='border-spacing:10px'>" + + "<tr><td id='a' colspan='2' style='width:50px;padding:0'>a</td></tr>" + + "<tr><td colspan='2' style='padding:0'>b</td></tr></table>")); + + var t = LayoutHarness.FindById(root, "t")!; + var a = LayoutHarness.FindById(root, "a")!; + + Assert.AreEqual(50, Width(a), 1); + Assert.AreEqual(70, Width(t), 1); + } + + [TestMethod] + [Spec("css-tables-3", "#missing-cells-fixup")] + public void ShortRow_LeavesTheMissingSlotsEmpty() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table id='t' style='border-spacing:0'>" + + "<tr><td id='a' style='width:40px;padding:0'>a</td><td id='b' style='width:60px;padding:0'>b</td></tr>" + + "<tr><td id='c' style='padding:0'>c</td></tr></table>")); + + var a = LayoutHarness.FindById(root, "a")!; + var c = LayoutHarness.FindById(root, "c")!; + + Assert.AreEqual(a.Location.X, c.Location.X, Delta); + Assert.AreEqual(Width(a), Width(c), Delta); + Assert.AreEqual(LayoutHarness.FindById(root, "b")!.ActualRight, LayoutHarness.FindById(root, "t")!.ActualRight, Delta); + } + + [TestMethod] + [Spec("css-tables-3", "#border-spacing-property")] + public void BorderSpacing_IsZeroByDefault() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='display:table'><div style='display:table-row'>" + + "<div id='a' style='display:table-cell'>a</div><div id='b' style='display:table-cell'>b</div></div></div>")); + + Assert.AreEqual(LayoutHarness.FindById(root, "a")!.ActualRight, LayoutHarness.FindById(root, "b")!.Location.X, Delta); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#border-spacing-property")] + public void NegativeBorderSpacing_IsInvalid() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table style='border-spacing:4px;border-spacing:-5px'><tr><td id='a'>a</td><td id='b'>b</td></tr></table>")); + + Assert.AreEqual(4, LayoutHarness.FindById(root, "b")!.Location.X - LayoutHarness.FindById(root, "a")!.ActualRight, Delta); + } + + [TestMethod] + [Spec("css-tables-3", "#global-style-overrides")] + public void RowPadding_IsIgnored() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table id='t' style='border-spacing:0'><tr style='padding:20px'><td id='a' style='padding:0'>a</td></tr></table>")); + + var t = LayoutHarness.FindById(root, "t")!; + var a = LayoutHarness.FindById(root, "a")!; + + Assert.AreEqual(t.Location.X, a.Location.X, Delta); + Assert.AreEqual(t.Location.Y, a.Location.Y, Delta); + } + + [TestMethod] + [Spec("css-tables-3", "#collapsed-style-overrides")] + public void CollapsedTable_IgnoresItsPadding() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='padding:30px'><table id='t' style='border-collapse:collapse;padding:20px'><tr><td id='a' style='padding:0'>a</td></tr></table></div>")); + + var t = LayoutHarness.FindById(root, "t")!; + var a = LayoutHarness.FindById(root, "a")!; + + Assert.AreEqual(t.Location.X, a.Location.X, 1); + Assert.AreEqual(t.Location.Y, a.Location.Y, 1); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#collapsed-style-overrides")] + public void CollapsedTable_IgnoresTheCellBorderRadius() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "<table id='t' style='border-collapse:collapse'><tr><td style='border-radius:10px;background-color:rgb(255,0,0);width:40px;height:20px'></td></tr></table>")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsTrue(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == Red)); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#computing-cell-measures")] + public void CellMinWidth_IsAMinimumForItsColumn() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table style='border-spacing:0'><tr><td id='a' style='min-width:150px;padding:0'>a</td><td>b</td></tr></table>")); + + Assert.AreEqual(150, Width(LayoutHarness.FindById(root, "a")!), 1); + } + + [TestMethod] + [Spec("css-tables-3", "#computing-cell-measures")] + public void BorderBoxCellWidth_IncludesPaddingAndBorder() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='display:table;border-spacing:0'><div style='display:table-row'>" + + "<div id='a' style='display:table-cell;box-sizing:border-box;width:100px;padding:10px;border:2px solid'>a</div></div></div>")); + + Assert.AreEqual(100, Width(LayoutHarness.FindById(root, "a")!), 1); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#computing-cell-measures")] + public void ContentBoxCellWidth_ExcludesPaddingAndBorder() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='display:table;border-spacing:0'><div style='display:table-row'>" + + "<div id='a' style='display:table-cell;width:100px;padding:10px;border:2px solid'>a</div></div></div>")); + + Assert.AreEqual(124, Width(LayoutHarness.FindById(root, "a")!), 1); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#computing-column-measures")] + public void SpanningCell_WidensTheColumnsInProportionToTheirMaxContent() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table style='border-spacing:0'>" + + "<tr><td id='a' style='padding:0'>x</td><td id='b' style='padding:0'>xxxxxxxxxxxxxxxxxxxx</td></tr>" + + "<tr><td colspan='2' style='width:400px;padding:0'>y</td></tr></table>")); + + Assert.IsGreaterThan(3 * Width(LayoutHarness.FindById(root, "a")!), Width(LayoutHarness.FindById(root, "b")!)); + } + + [TestMethod] + [Spec("css-tables-3", "#computing-the-table-width")] + public void AutoWidth_IsLimitedToTheContainingBlock() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='width:200px'><table id='t'><tr><td>some text that is a lot wider than two hundred pixels when it is not wrapped</td></tr></table></div>")); + + Assert.AreEqual(200, Width(LayoutHarness.FindById(root, "t")!), 1); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#computing-the-table-width")] + public void MinWidth_IsAMinimumForAnAutoWidthTable() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table id='t' style='min-width:300px'><tr><td>a</td></tr></table>")); + + Assert.AreEqual(300, Width(LayoutHarness.FindById(root, "t")!), 1); + } + + [TestMethod] + [Spec("css-tables-3", "#width-distribution-algorithm")] + [Spec("css-tables-3", "#distributing-width-to-columns")] + public void PercentAndPixelColumns_GetTheirWidth_AutoColumnsTheRest() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table style='width:400px;border-spacing:0'><tr>" + + "<td id='a' style='width:25%;padding:0'>a</td><td id='b' style='width:50px;padding:0'>b</td><td id='c' style='padding:0'>c</td></tr></table>")); + + Assert.AreEqual(100, Width(LayoutHarness.FindById(root, "a")!), 1); + Assert.AreEqual(50, Width(LayoutHarness.FindById(root, "b")!), 1); + Assert.AreEqual(250, Width(LayoutHarness.FindById(root, "c")!), 1); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#distributing-width-to-columns")] + public void ExcessWidth_GoesToAutoColumnsInProportionToTheirMaxContent() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table style='width:600px;border-spacing:0'><tr>" + + "<td id='a' style='padding:0'>x</td><td id='b' style='padding:0'>xxxxxxxxxxxxxxxxxxxx</td></tr></table>")); + + Assert.IsGreaterThan(5 * Width(LayoutHarness.FindById(root, "a")!), Width(LayoutHarness.FindById(root, "b")!)); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#height-distribution-algorithm")] + public void ExtraTableHeight_IsDistributedOverTheRows() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table style='height:200px;border-spacing:0'>" + + "<tr><td id='a' style='padding:0'>a</td></tr><tr><td id='b' style='padding:0'>b</td></tr></table>")); + + Assert.AreEqual(100, Height(LayoutHarness.FindById(root, "a")!), 1); + Assert.AreEqual(100, Height(LayoutHarness.FindById(root, "b")!), 1); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#row-relayout")] + public void PercentageCellHeight_IsResolvedAgainstTheTableHeight() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table style='height:200px;border-spacing:0'>" + + "<tr><td id='a' style='height:75%;padding:0'>a</td></tr><tr><td style='padding:0'>b</td></tr></table>")); + + Assert.AreEqual(150, Height(LayoutHarness.FindById(root, "a")!), 1); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#table-cell-content-relayout")] + public void PercentageHeightInACellWithAHeight_IsResolvedAgainstTheCell() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table style='border-spacing:0'><tr><td style='height:100px;padding:0;vertical-align:top'><div id='d' style='height:50%'>x</div></td></tr></table>")); + + Assert.AreEqual(50, Height(LayoutHarness.FindById(root, "d")!), 1); + } + + [TestMethod] + [Spec("css-tables-3", "#abspos-boxes-in-table-root")] + public void AbsoluteBox_InAPositionedTable_IsPlacedRelativeToTheTable() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='height:10px'></div>" + + "<table id='t' style='position:relative;margin-left:30px;border-spacing:0'><tr><td style='width:100px;height:50px;padding:0;vertical-align:top'>" + + "<div id='p' style='position:absolute;top:5px;left:7px;width:10px;height:10px'></div></td></tr></table>")); + + var t = LayoutHarness.FindById(root, "t")!; + var p = LayoutHarness.FindById(root, "p")!; + + Assert.AreEqual(t.Location.X + 7, p.Location.X, Delta); + Assert.AreEqual(t.Location.Y + 5, p.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#abspos-boxes-in-table-root")] + public void AbsoluteBox_InAMiddleAlignedCell_IsNotMovedByTheAlignment() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table id='t' style='position:relative;border-spacing:0'><tr><td style='width:100px;height:50px;padding:0;vertical-align:middle'>" + + "<div id='p' style='position:absolute;top:5px;left:7px;width:10px;height:10px'></div></td></tr></table>")); + + Assert.AreEqual(LayoutHarness.FindById(root, "t")!.Location.Y + 5, LayoutHarness.FindById(root, "p")!.Location.Y, Delta); + } + + [TestMethod] + [Spec("css-tables-3", "#abspos-boxes-in-table-internal")] + public void AbsoluteBox_InAPositionedCell_IsPlacedRelativeToTheCell() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table style='border-spacing:10px'><tr><td style='width:50px'>a</td>" + + "<td id='c' style='position:relative;width:100px;height:50px;padding:0;vertical-align:top'>" + + "<div id='p' style='position:absolute;top:5px;left:7px;width:10px;height:10px'></div></td></tr></table>")); + + var c = LayoutHarness.FindById(root, "c")!; + var p = LayoutHarness.FindById(root, "p")!; + + Assert.AreEqual(c.Location.X + 7, p.Location.X, Delta); + Assert.AreEqual(c.Location.Y + 5, p.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#abspos-boxes-in-table-internal")] + public void AbsoluteBox_InAPositionedCell_IsPlacedAgainstItsPaddingEdge() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table style='border-spacing:10px'><tr><td style='width:50px'>a</td>" + + "<td id='c' style='position:relative;width:100px;height:50px;padding:4px;border:3px solid;vertical-align:top'>" + + "<div id='p' style='position:absolute;top:5px;left:7px;width:10px;height:10px'></div></td></tr></table>")); + + var c = LayoutHarness.FindById(root, "c")!; + var p = LayoutHarness.FindById(root, "p")!; + + Assert.AreEqual(c.Location.X + 3 + 7, p.Location.X, Delta); + Assert.AreEqual(c.Location.Y + 3 + 5, p.Location.Y, Delta); + } + + [TestMethod] + [Spec("css-tables-3", "#static-position")] + public void AbsoluteBox_WithoutOffsetsInAPositionedCell_StaysAtItsStaticPosition() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table style='border-spacing:10px'><tr><td style='width:50px'>a</td>" + + "<td id='c' style='position:relative;width:100px;height:50px;padding:4px;vertical-align:top'>" + + "<div id='p' style='position:absolute;width:10px;height:10px'></div></td></tr></table>")); + + var c = LayoutHarness.FindById(root, "c")!; + var p = LayoutHarness.FindById(root, "p")!; + + Assert.AreEqual(c.Location.X + 4, p.Location.X, Delta); + Assert.AreEqual(c.Location.Y + 4, p.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#static-position")] + public void AbsoluteBox_WithoutOffsetsInACell_StaysAtItsStaticPosition() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<table style='position:relative;border-spacing:10px'><tr><td style='width:50px'>a</td>" + + "<td id='c' style='width:100px;height:50px;padding:4px;vertical-align:top'>" + + "<div id='p' style='position:absolute;width:10px;height:10px'></div></td></tr></table>")); + + var c = LayoutHarness.FindById(root, "c")!; + var p = LayoutHarness.FindById(root, "p")!; + + Assert.AreEqual(c.Location.X + 4, p.Location.X, Delta); + Assert.AreEqual(c.Location.Y + 4, p.Location.Y, Delta); + } + + private static string LongTable(string group) => PaintHarness.Wrap( + $"<table style='width:100%'>{group}<tbody>" + + string.Concat(Enumerable.Range(1, 30).Select(i => $"<tr><td style='height:40px'>row{i}</td></tr>")) + + "</tbody></table>"); + + [TestMethod] + [Spec("css-tables-3", "#repeated-headers")] + public void Header_IsPaintedAgainOnTheNextPage() + { + var (_, container) = PaintHarness.LayoutPaginated(LongTable("<thead style='break-inside:avoid'><tr><td>Head</td></tr></thead>"), 400, 300); + + Assert.IsGreaterThan(1, container.FragmentTree!.Fragmentainers.Count); + Assert.IsTrue(PaintHarness.PaintPage(container, 1).DrawStringCalls.Any(s => s.Text.Contains("Head"))); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#repeated-headers")] + public void Footer_IsPaintedOnThePageBeforeTheLast() + { + var (_, container) = PaintHarness.LayoutPaginated(LongTable("<tfoot style='break-inside:avoid'><tr><td>Foot</td></tr></tfoot>"), 400, 300); + + Assert.IsGreaterThan(1, container.FragmentTree!.Fragmentainers.Count); + Assert.IsTrue(PaintHarness.PaintPage(container, 0).DrawStringCalls.Any(s => s.Text.Contains("Foot"))); + } + + [TestMethod] + [Spec("css-tables-3", "#paint-order")] + public void Cells_ArePaintedInDocumentOrder() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "<table id='t'><tr><td rowspan='2' style='background-color:rgb(255,0,0);width:40px'></td><td style='width:40px;height:20px'></td></tr>" + + "<tr><td style='background-color:rgb(0,0,255);width:40px;height:20px'></td></tr></table>")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + var rects = g.Log.OfType<RecordingGraphics.DrawRectCall>().ToList(); + + var red = rects.FindIndex(r => r.Color == Red); + var blue = rects.FindIndex(r => r.Color == Blue); + Assert.IsGreaterThanOrEqualTo(0, red); + Assert.IsGreaterThan(red, blue); + } + + [Ignore("Gap, see Spec/css-tables-3.yml")] + [TestMethod] + [Spec("css-tables-3", "#empty-cell-rendering")] + public void EmptyCellsHide_HasNoEffectInCollapsedMode() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "<table id='t' style='border-collapse:collapse;empty-cells:hide'><tr><td style='background-color:rgb(255,0,0);width:40px;height:20px'></td></tr></table>")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsTrue(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == Red)); + } + + [TestMethod] + [Spec("css-tables-3", "#drawing-cell-borders")] + public void CellBorder_IsPaintedInSeparatedMode() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "<table id='t'><tr><td style='border:2px solid rgb(255,0,0);width:40px;height:20px'></td></tr></table>")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsTrue(PaintsColor(g, Red)); + } + + [TestMethod] + [Spec("css-tables-3", "#visibility-collapse-cell-rendering")] + public void CollapsedCell_IsRenderedLikeAHiddenOne() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "<table id='t'><tr><td id='a' style='width:40px'>a</td><td style='visibility:collapse;background-color:rgb(255,0,0);border:2px solid rgb(255,0,0);width:40px'>hidden</td></tr></table>")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsFalse(PaintsColor(g, Red)); + Assert.IsFalse(g.DrawStringCalls.Any(s => s.Text.Contains("hidden"))); + Assert.IsTrue(g.DrawStringCalls.Any(s => s.Text.Contains('a'))); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageD4RepeatedHeaderTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageD4RepeatedHeaderTest.cs index f9ddb1022..d438fe2c2 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/StageD4RepeatedHeaderTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageD4RepeatedHeaderTest.cs @@ -15,12 +15,10 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; public sealed class StageD4RepeatedHeaderTest { [TestMethod] + [Spec("css-tables-3", "#repeated-headers")] public void ThreadRepeatsOnEveryPageTheTableSpans() { - // break-inside: avoid is explicit here rather than relied on from the UA default stylesheet's - // "@media print { thead, tfoot { break-inside: avoid } }" - this test renders via WinForms, - // whose adapter reports a "screen" media type, so that print-scoped rule never matches here - // (confirmed intentional: only PdfSharpAdapter overrides DefaultMediaType to "print"). + // The UA's thead { break-inside: avoid } is in @media print, which the WinForms adapter doesn't match var sb = new StringBuilder("<html><body><table border='1'><thead style='break-inside:avoid;'><tr><th>Col A</th><th>Col B</th></tr></thead><tbody>"); for (var i = 0; i < 60; i++) { @@ -57,10 +55,7 @@ public void ThreadRepeatsOnEveryPageTheTableSpans() StringAssert.Contains(text, "Col A"); StringAssert.Contains(text, "Col B"); - // Every repeated header must land at the top of a page slot the table's body actually spans, - // and must not be positioned on the table's own first page (it's already there once, in flow). - // The row itself carries no Location (only its cells do - see CssLayoutEngineTable's row loop), - // so the first cell is the reference point. + // Rows have no Location of their own, only their cells var firstSlot = container.PageIndexOf(table.Location.Y); var slot = container.PageIndexOf(headerRow.Boxes[0].Location.Y); Assert.IsTrue(slot > firstSlot, "repeated header should not land back on the table's own first page"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/TableRowDefaultAtomicityTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/TableRowDefaultAtomicityTest.cs index cc95bfd63..c3a5b020c 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/TableRowDefaultAtomicityTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/TableRowDefaultAtomicityTest.cs @@ -10,16 +10,7 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; /// <summary> -/// Verifies a real, spec-confirmed default-behavior gap found while auditing this port's fragmentation -/// engine against PeachPDF a third time, then checking the actual W3C text directly -/// (<see href="https://drafts.csswg.org/css-tables-3/#breaking-rules">css-tables-3 §6.1</see>, current -/// Editor's Draft): "When fragmenting a table, user agents <b>must</b> attempt to preserve the table rows -/// unfragmented if the cells spanning the row do not span any subsequent row, and their height is at -/// least twice smaller than both the fragmentainer height and width. Other rows are said <i>freely -/// fragmentable</i>." This is phrased as a required UA default, not something an author opts into - -/// <c>CssLayoutEngineTable.LayoutCells</c> previously only preserved a row when the TABLE had explicit -/// <c>break-inside:avoid</c>, meaning an ordinary multi-page table with no special markup at all rendered -/// rows split across page boundaries by default, which the spec does not permit as the default. +/// Rows are kept in one piece by default, unless a cell spans into a later row (CSS Tables 3 §6.1). /// </summary> [TestClass] [DoNotParallelize] @@ -40,6 +31,7 @@ private static IEnumerable<CssBox> Walk(CssBox box) } [TestMethod] + [Spec("css-tables-3", "#breaking-rules")] public async Task OrdinaryRowWithNoBreakInsideAvoid_IsStillPreservedUnfragmented_ByDefault() { var checkedAnyStraddleCandidate = false; @@ -48,8 +40,6 @@ public async Task OrdinaryRowWithNoBreakInsideAvoid_IsStillPreservedUnfragmented { using var wrapper = new HtmlContainer(); var filler = string.Concat(Enumerable.Repeat("<p style='margin:0;'>filler line</p>", fillerCount)); - // Deliberately no break-inside:avoid anywhere - this is the plain, no-special-markup case - // css-tables-3 §6.1 says every conformant UA must handle this way by default. await wrapper.SetHtml( $""" <html><body> @@ -80,18 +70,16 @@ await wrapper.SetHtml( checkedAnyStraddleCandidate = true; Assert.AreEqual(topSlot, bottomSlot, - $"at fillerCount={fillerCount}, the second row straddles page slots {topSlot}->{bottomSlot} with no break-inside:avoid anywhere - css-tables-3 6.1 requires it stay whole by default"); + $"at fillerCount={fillerCount}, the second row straddles page slots {topSlot}->{bottomSlot}"); } Assert.IsTrue(checkedAnyStraddleCandidate, "no filler count in range produced a target cell - test is not meaningful as written"); } [TestMethod] + [Spec("css-tables-3", "#breaking-rules")] public async Task RowSpanningIntoASubsequentRow_RemainsFreelyFragmentable() { - // css-tables-3 6.1's own carve-out: a row a rowspan cell only STARTS in (spanning further rows) - // is explicitly excluded from the "preserve unfragmented" default - confirming the new default - // atomicity doesn't overreach into content the spec says must stay freely fragmentable. var foundAStraddle = false; for (var fillerCount = 1; fillerCount < 30; fillerCount++) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableBorderIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableBorderIntegrationTests.cs index 0aebac4fa..58739ebff 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableBorderIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableBorderIntegrationTests.cs @@ -17,6 +17,7 @@ public sealed class TableBorderIntegrationTests [TestMethod] [Spec("css21", "tables.html#borders")] + [Spec("css-tables-3", "#border-collapse-property")] public void BorderCollapse_IsSeparateByDefault() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -27,6 +28,8 @@ public void BorderCollapse_IsSeparateByDefault() [TestMethod] [Spec("css21", "tables.html#separated-borders")] + [Spec("css-tables-3", "#computing-undistributable-space")] + [Spec("css-tables-3", "#bounding-box-assignment")] public void BorderSpacing_SeparatesAdjoiningCellsAndTheTableBorder() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -46,6 +49,7 @@ public void BorderSpacing_SeparatesAdjoiningCellsAndTheTableBorder() [TestMethod] [Spec("css21", "tables.html#separated-borders")] + [Spec("css-tables-3", "#border-spacing-property")] public void BorderSpacing_TwoLengthsAreHorizontalThenVertical() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -60,6 +64,7 @@ public void BorderSpacing_TwoLengthsAreHorizontalThenVertical() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#separated-borders")] + [Spec("css-tables-3", "#computing-undistributable-space")] public void TablePadding_AddsToTheBorderSpacingAtTheEdges() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -85,6 +90,7 @@ public void RowBorders_AreIgnored() [TestMethod] [Spec("css21", "tables.html#empty-cells")] + [Spec("css-tables-3", "#empty-cell-rendering")] public void EmptyCellsHide_PaintsNoBackgroundOrBorderAroundAnEmptyCell() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -98,6 +104,7 @@ public void EmptyCellsHide_PaintsNoBackgroundOrBorderAroundAnEmptyCell() [TestMethod] [Spec("css21", "tables.html#empty-cells")] + [Spec("css-tables-3", "#empty-cell-rendering")] public void EmptyCellsShow_PaintsTheBackgroundOfAnEmptyCell() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -111,6 +118,7 @@ public void EmptyCellsShow_PaintsTheBackgroundOfAnEmptyCell() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#empty-cells")] + [Spec("css-tables-3", "#empty-cell-rendering")] public void EmptyCellsHide_StillPaintsTheBackgroundOfACellWithText() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -137,6 +145,7 @@ public void EmptyCellsHide_RowOfOnlyEmptyCellsHasZeroHeight() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#collapsing-borders")] + [Spec("css-tables-3", "#drawing-collapsed-borders-2")] public void CollapsedBorders_AreCenteredOnTheGridLineBetweenCells() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -152,6 +161,8 @@ public void CollapsedBorders_AreCenteredOnTheGridLineBetweenCells() [TestMethod] [Spec("css21", "tables.html#collapsing-borders")] + [Spec("css-tables-3", "#border-collapse-property")] + [Spec("css-tables-3", "#collapsed-style-overrides")] public void Collapse_IgnoresTheBorderSpacing() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -168,6 +179,7 @@ public void Collapse_IgnoresTheBorderSpacing() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#border-conflict-resolution")] + [Spec("css-tables-3", "#border-specificity")] public void WiderBorder_WinsTheSharedEdge() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -184,6 +196,7 @@ public void WiderBorder_WinsTheSharedEdge() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#border-conflict-resolution")] + [Spec("css-tables-3", "#border-conflict-resolution-algorithm")] public void TableBorder_WinsOverANarrowerCellBorder() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -197,6 +210,7 @@ public void TableBorder_WinsOverANarrowerCellBorder() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#table-border-styles")] + [Spec("css-tables-3", "#border-style-overrides")] public void HiddenBorder_SuppressesTheNeighboursBorder() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableModelIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableModelIntegrationTests.cs index 3a1694cc8..42ed91bb7 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableModelIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableModelIntegrationTests.cs @@ -13,6 +13,7 @@ public sealed class TableModelIntegrationTests [TestMethod] [Spec("css21", "tables.html#table-display")] + [Spec("css-tables-3", "#table-structure")] public void DisplayValues_TurnArbitraryElementsIntoATable() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -35,6 +36,7 @@ public void DisplayValues_TurnArbitraryElementsIntoATable() [TestMethod] [Spec("css21", "tables.html#table-display")] + [Spec("css-tables-3", "#table-structure")] public void HeaderGroup_IsDisplayedBeforeAndFooterGroupAfterTheBody() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -55,6 +57,7 @@ public void HeaderGroup_IsDisplayedBeforeAndFooterGroupAfterTheBody() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#table-display")] + [Spec("css-tables-3", "#table-structure")] public void SecondHeaderGroup_IsTreatedAsARowGroupInSourceOrder() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -75,6 +78,7 @@ public void SecondHeaderGroup_IsTreatedAsARowGroupInSourceOrder() [TestMethod] [Spec("css21", "tables.html#table-display")] + [Spec("css-tables-3", "#table-structure")] public void ColumnElements_AreNotRendered() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -96,6 +100,7 @@ public void ColumnElements_AreNotRendered() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#table-display")] + [Spec("css-tables-3", "#table-structure")] public void InlineTable_SitsOnTheLineBetweenTheSurroundingText() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -113,6 +118,7 @@ public void InlineTable_SitsOnTheLineBetweenTheSurroundingText() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#anonymous-boxes")] + [Spec("css-tables-3", "#fixup-algorithm")] public void CellsWithoutARow_ShareOneAnonymousRow() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -130,6 +136,7 @@ public void CellsWithoutARow_ShareOneAnonymousRow() [TestMethod] [Spec("css21", "tables.html#anonymous-boxes")] + [Spec("css-tables-3", "#fixup-algorithm")] public void NonCellContentOfARow_GetsAnAnonymousCell() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -146,6 +153,7 @@ public void NonCellContentOfARow_GetsAnAnonymousCell() [TestMethod] [Spec("css21", "tables.html#anonymous-boxes")] + [Spec("css-tables-3", "#fixup-algorithm")] public void ChildrenOfAColumn_AreNotRendered() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -171,6 +179,7 @@ private static bool IsHidden(CssBox box) [TestMethod] [Spec("css21", "tables.html#anonymous-boxes")] + [Spec("css-tables-3", "#fixup-algorithm")] public void WhiteSpaceBetweenRows_DoesNotCreateARow() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -186,6 +195,7 @@ public void WhiteSpaceBetweenRows_DoesNotCreateARow() [TestMethod] [Spec("css21", "tables.html#columns")] + [Spec("css-tables-3", "#computing-column-measures")] public void ColumnWidth_IsAMinimumForTheColumn() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -200,6 +210,7 @@ public void ColumnWidth_IsAMinimumForTheColumn() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#columns")] + [Spec("css-tables-3", "#drawing-cell-backgrounds")] public void ColumnBackground_IsPaintedBehindItsCells() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -213,6 +224,7 @@ public void ColumnBackground_IsPaintedBehindItsCells() [TestMethod] [Spec("css21", "tables.html#model")] + [Spec("css-tables-3", "#global-style-overrides")] public void Margin_IsAppliedOutsideTheTable() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -250,6 +262,7 @@ public void PercentageWidth_IsRelativeToTheContainingBlock() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#model")] + [Spec("css-tables-3", "#bounding-box-assignment")] public void Caption_IsRenderedAboveTheTable() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -267,6 +280,7 @@ public void Caption_IsRenderedAboveTheTable() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#caption-position")] + [Spec("css-tables-3", "#caption-side-property")] public void CaptionSideBottom_PutsTheCaptionBelowTheTable() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -280,6 +294,7 @@ public void CaptionSideBottom_PutsTheCaptionBelowTheTable() [TestMethod] [Spec("css21", "tables.html#table-layout")] + [Spec("css-tables-3", "#dimensioning-the-row-column-grid--step1")] public void RowspanCell_PushesTheNextRowsCellIntoTheFollowingColumn() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -297,6 +312,8 @@ public void RowspanCell_PushesTheNextRowsCellIntoTheFollowingColumn() [TestMethod] [Spec("css21", "tables.html#table-layout")] + [Spec("css-tables-3", "#dimensioning-the-row-column-grid--step1")] + [Spec("css-tables-3", "#bounding-box-assignment")] public void ColspanCell_CoversTheColumnsItSpans() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -314,6 +331,7 @@ public void ColspanCell_CoversTheColumnsItSpans() [TestMethod] [Spec("css21", "tables.html#table-layout")] + [Spec("css-tables-3", "#global-style-overrides")] public void CellMargins_AreIgnored() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -329,6 +347,7 @@ public void CellMargins_AreIgnored() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#table-layers")] + [Spec("css-tables-3", "#drawing-cell-backgrounds")] public void RowBackground_IsPaintedBehindItsCells() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -344,6 +363,7 @@ public void RowBackground_IsPaintedBehindItsCells() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#table-layers")] + [Spec("css-tables-3", "#drawing-cell-backgrounds")] public void RowGroupBackground_IsPaintedBehindItsCells() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -356,6 +376,8 @@ public void RowGroupBackground_IsPaintedBehindItsCells() [TestMethod] [Spec("css21", "tables.html#table-layers")] + [Spec("css-tables-3", "#drawing-table-backgrounds-and-borders")] + [Spec("css-tables-3", "#drawing-cell-backgrounds")] public void CellBackground_IsPaintedOverTheTableBackground() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableRepeatedGroupConditionsTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableRepeatedGroupConditionsTests.cs index c16fcf1f3..3e12a1616 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableRepeatedGroupConditionsTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableRepeatedGroupConditionsTests.cs @@ -7,43 +7,11 @@ namespace HtmlRenderer.IntegrationTest.Tables; /// <summary> -/// Ported from PeachPDF.Tests/Integration/TableRepeatedGroupConditionsTests.cs: css-tables-3 §6.2's -/// conditions on whether to repeat a <c>&lt;thead&gt;</c>/<c>&lt;tfoot&gt;</c> at all. +/// When a <c>&lt;thead&gt;</c> is repeated on the following pages (CSS Tables 3 §6.2). /// </summary> /// <remarks> -/// A drastic reduction from PeachPDF's 16 (thead half only, per the port plan's tfoot-drop rule), not a -/// rename - confirmed by direct source read: -/// <list type="bullet"> -/// <item><see cref="TheArtOfDev.HtmlRenderer.Core.Dom.CssLayoutEngineTable"/>'s <c>repeatsHeader</c> gate -/// (<c>Core/Dom/CssLayoutEngineTable.cs</c> ~647-648) checks only -/// <c>BreakValues.AvoidsBreak(_headerBox.BreakInside)</c> and <c>HasRealPageGrid</c> - §6.2's SECOND -/// condition (repeat only if the group's own height is under a quarter of the page) is not implemented at -/// all, confirmed by reading the gate in full: there is no height comparison anywhere in it. Every test -/// built on that cap (<c>AGroupExactlyAQuarterOfThePage_DoesNotRepeat</c>, -/// <c>ATallHeaderThatDoesNotRepeat_LeavesTheLaterBandsToTheRows</c> and their footer siblings) is dropped -/// outright rather than force-fit; <see cref="AGroupTallerThanAQuarterOfThePage_StillRepeats_UnlikeCssTables3"/> -/// pins the real, inverted behavior instead.</item> -/// <item>The UA stylesheet's <c>thead, tfoot { break-inside: avoid }</c> default -/// (<c>Core/CssDefaults.cs</c>) lives under <c>@media print</c>, which only <c>PdfSharpAdapter</c> ever -/// matches (confirmed: <c>RAdapter.DefaultMediaType</c>'s base default is not <c>"print"</c>, and this -/// file's harness lays out over <c>WinFormsAdapter</c>, whose reported type is <c>"screen"</c> - the same -/// established convention documented in <c>StageD4RepeatedHeaderTest.cs</c>). So -/// <c>TheUaStylesheet_GivesATheadAndTfootAvoidBreakInside</c> and -/// <c>TheUaStylesheet_StillGivesHeadingsAnAvoidingBreakAfter</c> are dropped, not ported Ignored - what -/// they'd actually be testing (whether the print stylesheet text itself contains the rule) is a -/// stylesheet-parsing concern for a unit test, not a layout-behavior one, and is already directly -/// confirmed by reading <c>Core/CssDefaults.cs</c>, quoted above; the OBSERVABLE effect of that default (a -/// plain <c>&lt;thead&gt;</c> repeating) is already covered by every other test in this file, each of -/// which declares <c>break-inside:avoid</c> explicitly rather than relying on the print-scoped default, per -/// this repo's own established convention for WinForms-harness tests.</item> -/// <item><c>&lt;tfoot&gt;</c> repeat has no implementation at all (only <c>&lt;thead&gt;</c> - confirmed: -/// <c>TableHeaderRepeat</c> is thead-only, no footer equivalent), so every <c>ADeclinedFooter_*</c>/ -/// <c>AFooterCarriedOntoTheNextPage_*</c>/<c>ARepeatingFooter_*</c>/<c>ThePageACarriedFooterOpens_*</c> test -/// and the footer arm of every <c>[Theory]</c> is dropped, per the port plan's tfoot-drop rule.</item> -/// <item><c>DetachedRowGroup.Repeats</c>/<c>TableSetup.Header</c>/<c>Footer</c> have no counterpart - -/// <see cref="CssBox.RepeatedHeaderRows"/> (null vs non-null, and its count) is this fork's own equivalent -/// signal, used throughout below instead.</item> -/// </list> +/// The UA's <c>thead { break-inside: avoid }</c> is in <c>@media print</c>, which the WinForms adapter +/// doesn't match, so the tests declare it themselves. /// </remarks> [TestClass] [DoNotParallelize] @@ -77,9 +45,8 @@ public void BreakInsideOnAThead_DoesNotReachItsRowsOrCells() "break-inside must not have cascaded from the thead onto any of its own rows or cells"); } - // A group whose author opts back out of the UA default is laid out once, in flow, and never repeated - - // css-tables-3 6.2's first condition, and the opt-out the UA default exists to be taken away from. [TestMethod] + [Spec("css-tables-3", "#repeated-headers")] public void AGroupOptedOutOfAvoidBreakInside_IsNeverRepeated() { var (root, container) = LayoutHarness.Layout(ManyRowsTable("break-inside:auto"), 400, PageHeight, margin: Margin); @@ -88,9 +55,8 @@ public void AGroupOptedOutOfAvoidBreakInside_IsNeverRepeated() Assert.IsNull(TableOf(root).RepeatedHeaderRows); } - // With the UA default's effect declared explicitly instead, the same shape of fixture repeats on every - // page the table covers - the behavior the opt-out test above is a negative control for. [TestMethod] + [Spec("css-tables-3", "#repeated-headers")] public void AGroupWithAnExplicitAvoidBreakInside_RepeatsOnEveryPage() { var (root, container) = LayoutHarness.Layout(ManyRowsTable("break-inside:avoid"), 400, PageHeight, margin: Margin); @@ -103,11 +69,10 @@ public void AGroupWithAnExplicitAvoidBreakInside_RepeatsOnEveryPage() Assert.AreEqual(pages - 1, table.RepeatedHeaderRows!.Count); } - // Confirmed gap (see class remarks): css-tables-3 6.2's "under a quarter of the page" cap does not - // exist in this fork - a header far taller than a quarter of the page still repeats on every page, - // unlike the spec (and unlike PeachPDF, which declines to repeat it). + [Ignore("Gap, see Spec/css-tables-3.yml")] [TestMethod] - public void AGroupTallerThanAQuarterOfThePage_StillRepeats_UnlikeCssTables3() + [Spec("css-tables-3", "#repeated-headers")] + public void AGroupTallerThanAQuarterOfThePage_IsNotRepeated() { var html = LayoutHarness.Wrap( "<table style='width:100%;border-collapse:collapse'>" @@ -116,23 +81,15 @@ public void AGroupTallerThanAQuarterOfThePage_StillRepeats_UnlikeCssTables3() + string.Concat(Enumerable.Range(1, 20).Select(i => $"<tr><td><div style='height:40px'>row {i}</div></td></tr>")) + "</tbody></table>"); - // The header alone (200px) is already well over a quarter of PageHeight (75px) - comfortably past - // the spec's cap, if this fork implemented it. var (root, container) = LayoutHarness.Layout(html, 400, PageHeight, margin: Margin); Assert.IsTrue(container.FragmentTree!.Fragmentainers.Count > 2, "fixture must paginate more than once"); - - var table = TableOf(root); - Assert.IsNotNull(table.RepeatedHeaderRows, - "unlike css-tables-3 6.2, this fork's repeatsHeader gate has no height cap - confirmed by reading it in full"); - Assert.IsTrue(table.RepeatedHeaderRows!.Count > 0); + Assert.IsNull(TableOf(root).RepeatedHeaderRows); } - // The room a repeating header needs IS genuinely reserved on every continuation band it appears on - // (CssLayoutEngineTable.cs's own "reserving the room here... is what keeps that row from being drawn - // underneath the repeated header instead of below it" remark) - body content on a continuation page - // starts below the header's own height, not at the page's bare content top. + // Body rows on a continuation page start below the repeated header. [TestMethod] + [Spec("css-tables-3", "#repeated-headers")] public void ARepeatingHeadersRoom_IsReservedOnEveryContinuationBand() { var (withHeader, containerWithHeader) = LayoutHarness.Layout( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableRowBreakValueTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableRowBreakValueTests.cs index 59c37b431..6bded5eae 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableRowBreakValueTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableRowBreakValueTests.cs @@ -4,21 +4,8 @@ namespace HtmlRenderer.IntegrationTest.Tables; /// <summary> -/// Ported from PeachPDF.Tests/Integration/TableRowBreakValueTests.cs: css-break-3 §3.1's forced breaks at -/// the class-A break point between two table rows. +/// Forced breaks between two table rows, css-break-3 §3.1. /// </summary> -/// <remarks> -/// Confirmed gap, not a rename: <c>CssLayoutEngineTable.cs</c>'s row loop never reads -/// <c>BreakBefore</c>/<c>BreakAfter</c> anywhere - confirmed by grepping the whole file for both names (no -/// matches). The table engine's only page-break-related logic is the straddle-driven row-preservation -/// shift (css-tables-3 §6.1, unconditional-by-default per this fork's own commit history) and the -/// repeated-header loop; there is no forced-break handling of any kind for a <c>&lt;tr&gt;</c> or a row -/// group, unlike the general block layout path (<see cref="TheArtOfDev.HtmlRenderer.Core.Fragmentation.BlockFragmentation.TryGetForcedBreakTarget"/>), -/// which table rows never go through (they are laid out by <c>CssLayoutEngineTable.LayoutCells</c>'s own -/// manual per-cell loop, not the generic block child loop). 4 of PeachPDF's 5 tests are accordingly ported -/// with their full original assertions but <c>[Ignore]</c>d, citing this exact gap; only the one negative -/// control that holds regardless (no break value anywhere, nothing moves) is left active. -/// </remarks> [TestClass] [DoNotParallelize] public sealed class TableRowBreakValueTests @@ -46,9 +33,9 @@ public void WithoutABreakValue_EveryRowStaysOnTheFirstPage() } } - [Ignore("Confirmed gap: CssLayoutEngineTable.cs's row loop never reads BreakBefore anywhere (grepped " + - "the whole file - no matches), so break-before:page on a <tr> has no effect on where it lands.")] + [Ignore("Gap, see Spec/css-tables-3.yml")] [TestMethod] + [Spec("css-tables-3", "#breaking-rules")] public void BreakBeforeOnARow_StartsItOnTheNextPage() { var (root, container) = LayoutHarness.Layout(Table("break-before:page"), 400, PageHeight, margin: Margin); @@ -59,9 +46,9 @@ public void BreakBeforeOnARow_StartsItOnTheNextPage() Assert.AreEqual(1, container.PageIndexOf(r3.Boxes[0].Location.Y)); } - [Ignore("Confirmed gap: CssLayoutEngineTable.cs's row loop never reads BreakAfter anywhere (grepped the " + - "whole file - no matches), so break-after:page on a <tr> has no effect on the row after it.")] + [Ignore("Gap, see Spec/css-tables-3.yml")] [TestMethod] + [Spec("css-tables-3", "#breaking-rules")] public void BreakAfterOnTheRowBefore_StartsTheNextOneOnTheNextPage() { var (root, container) = LayoutHarness.Layout(Table("", "style='break-after:page'"), 400, PageHeight, margin: Margin); @@ -72,10 +59,9 @@ public void BreakAfterOnTheRowBefore_StartsTheNextOneOnTheNextPage() Assert.AreEqual(1, container.PageIndexOf(r3.Boxes[0].Location.Y)); } - [Ignore("Same confirmed gap as BreakBeforeOnARow_StartsItOnTheNextPage - a row group's own break-before " + - "is no more read than an individual row's, since neither ever reaches CssLayoutEngineTable's forced-" + - "break-free row loop.")] + [Ignore("Gap, see Spec/css-tables-3.yml")] [TestMethod] + [Spec("css-tables-3", "#breaking-rules")] public void BreakBeforeOnARowGroup_IsSeenAtItsFirstRow() { var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -90,9 +76,9 @@ public void BreakBeforeOnARowGroup_IsSeenAtItsFirstRow() Assert.AreEqual(1, container.PageIndexOf(r2.Boxes[0].Location.Y)); } - [Ignore("Same confirmed gap: break-before:page on a <tr> is never read at all, so there is no forced " + - "break for the repeated-header loop to take part in taking.")] + [Ignore("Gap, see Spec/css-tables-3.yml")] [TestMethod] + [Spec("css-tables-3", "#breaking-rules")] public void AForcedRowBreak_StillRepeatsTheHeaderOnTheNewPage() { var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableSizingIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableSizingIntegrationTests.cs index ba12df841..e4bbaa3bf 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableSizingIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableSizingIntegrationTests.cs @@ -19,6 +19,7 @@ public sealed class TableSizingIntegrationTests [TestMethod] [Spec("css21", "tables.html#width-layout")] + [Spec("css-tables-3", "#layout-principles")] public void AutoWidth_ShrinksToTheContent() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -30,6 +31,7 @@ public void AutoWidth_ShrinksToTheContent() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#width-layout")] + [Spec("css-tables-3", "#layout-principles")] public void AutoMargins_CenterTheTable() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -41,6 +43,7 @@ public void AutoMargins_CenterTheTable() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#fixed-table-layout")] + [Spec("css-tables-3", "#table-layout-property")] public void FixedLayout_ColumnWidthsComeFromTheFirstRowOnly() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -55,6 +58,7 @@ public void FixedLayout_ColumnWidthsComeFromTheFirstRowOnly() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#fixed-table-layout")] + [Spec("css-tables-3", "#table-layout-property")] public void FixedLayout_ContentDoesNotWidenAColumn() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -67,6 +71,7 @@ public void FixedLayout_ContentDoesNotWidenAColumn() [TestMethod] [Spec("css21", "tables.html#auto-table-layout")] + [Spec("css-tables-3", "#computing-column-measures")] public void ColumnWidth_IsTheWidestCellInTheColumn() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -83,6 +88,7 @@ public void ColumnWidth_IsTheWidestCellInTheColumn() [TestMethod] [Spec("css21", "tables.html#auto-table-layout")] + [Spec("css-tables-3", "#computing-the-table-width")] public void SpecifiedTableWidth_IsDistributedOverTheColumns() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -97,6 +103,7 @@ public void SpecifiedTableWidth_IsDistributedOverTheColumns() [TestMethod] [Spec("css21", "tables.html#auto-table-layout")] + [Spec("css-tables-3", "#computing-the-table-width")] public void SpecifiedTableWidth_GrowsToTheMinimumContentWidth() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -111,6 +118,7 @@ public void SpecifiedTableWidth_GrowsToTheMinimumContentWidth() [TestMethod] [Spec("css21", "tables.html#auto-table-layout")] + [Spec("css-tables-3", "#computing-column-measures")] public void CellWidth_IsAMinimumForItsColumn() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -121,6 +129,7 @@ public void CellWidth_IsAMinimumForItsColumn() [TestMethod] [Spec("css21", "tables.html#auto-table-layout")] + [Spec("css-tables-3", "#width-distribution-algorithm")] public void PercentageColumnWidth_IsRelativeToTheTableWidth() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -132,6 +141,7 @@ public void PercentageColumnWidth_IsRelativeToTheTableWidth() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#auto-table-layout")] + [Spec("css-tables-3", "#computing-column-measures")] public void ColumnGroupWidth_IsAMinimumForItsColumns() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -144,6 +154,7 @@ public void ColumnGroupWidth_IsAMinimumForItsColumns() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#auto-table-layout")] + [Spec("css-tables-3", "#computing-the-table-width")] public void CaptionMinimumWidth_WidensTheTable() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -154,6 +165,7 @@ public void CaptionMinimumWidth_WidensTheTable() [TestMethod] [Spec("css21", "tables.html#height-layout")] + [Spec("css-tables-3", "#computing-the-table-height")] public void TableHeight_IsAMinimumHeight() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -165,6 +177,7 @@ public void TableHeight_IsAMinimumHeight() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#height-layout")] + [Spec("css-tables-3", "#computing-the-table-height")] public void RowHeight_IsAMinimumHeight() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -175,6 +188,7 @@ public void RowHeight_IsAMinimumHeight() [TestMethod] [Spec("css21", "tables.html#height-layout")] + [Spec("css-tables-3", "#row-layout")] public void CellHeight_SetsTheRowHeight() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -186,6 +200,7 @@ public void CellHeight_SetsTheRowHeight() [TestMethod] [Spec("css21", "tables.html#height-layout")] + [Spec("css-tables-3", "#row-layout")] public void RowspanCell_IsCoveredByTheRowsItSpans() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -201,6 +216,7 @@ public void RowspanCell_IsCoveredByTheRowsItSpans() [TestMethod] [Spec("css21", "tables.html#height-layout")] + [Spec("css-tables-3", "#row-layout")] public void VerticalAlignTop_KeepsTheContentAtTheTopOfTheRow() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -213,6 +229,7 @@ public void VerticalAlignTop_KeepsTheContentAtTheTopOfTheRow() [TestMethod] [Spec("css21", "tables.html#height-layout")] + [Spec("css-tables-3", "#row-layout")] public void VerticalAlignBottom_PutsTheContentAtTheBottomOfTheRow() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -226,6 +243,7 @@ public void VerticalAlignBottom_PutsTheContentAtTheBottomOfTheRow() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#height-layout")] + [Spec("css-tables-3", "#row-layout")] public void VerticalAlignBaseline_AlignsTheFirstLinesOfTheCells() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -238,6 +256,7 @@ public void VerticalAlignBaseline_AlignsTheFirstLinesOfTheCells() [TestMethod] [Spec("css21", "tables.html#height-layout")] + [Spec("css-tables-3", "#row-layout")] public void VerticalAlignSub_IsTreatedAsBaseline() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableVisibilityCollapseIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableVisibilityCollapseIntegrationTests.cs index 62325d258..642185ed8 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableVisibilityCollapseIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableVisibilityCollapseIntegrationTests.cs @@ -11,6 +11,7 @@ public sealed class TableVisibilityCollapseIntegrationTests [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#dynamic-effects")] + [Spec("css-tables-3", "#bounding-box-assignment")] public void CollapsedRow_DirectTableChild_TakesNoSpace() { // <tr> as a direct child of <table>, no row group @@ -225,6 +226,7 @@ public void CollapsedRow_InsideDetachedFooter_TakesNoSpace() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "tables.html#dynamic-effects")] + [Spec("css-tables-3", "#bounding-box-assignment")] public void CollapsedColumn_TakesNoWidth() { var (experiment, _) = LayoutHarness.Layout(""" diff --git a/Spec/css-tables-3.yml b/Spec/css-tables-3.yml index 06969125c..b7eadafb5 100644 --- a/Spec/css-tables-3.yml +++ b/Spec/css-tables-3.yml @@ -5,229 +5,299 @@ snapshot: https://www.w3.org/TR/2025/WD-css-tables-3-20251216/ sections: '#intro': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown + status: n/a '#content-model': title: 2 Content Model - status: unknown + status: partial '#table-structure': title: 2.1 Table Structure - status: unknown + status: partial + gaps: + - Only the first header and footer group are handled, see CSS 2.1 17.2 + - "'inline-table' is laid out like a block-level table, see CSS 2.1 17.2" + - Captions aren't rendered, see CSS 2.1 17.4 '#terminology': title: 2.1.1 Terminology - status: unknown + status: n/a '#fixup': title: 2.2 Fixup - status: unknown + status: partial '#fixup-algorithm': title: 2.2.1 Fixup Algorithm - status: unknown + status: partial + gaps: + - Consecutive non-row children don't share one anonymous row, misparented boxes get no anonymous table, see CSS 2.1 17.2.1 '#fixup-boxes': title: 2.2.2 Characteristics of fixup boxes - status: unknown + status: supported '#fixup-examples': title: 2.2.3 Examples - status: unknown + status: n/a '#layout': title: 3 Layout - status: unknown + status: partial '#layout-principles': title: 3.1 Core layout principles - status: unknown + status: partial + gaps: + - "'auto' margins don't center a table, see CSS 2.1 17.5.2" '#table-layout-algorithm': title: 3.2 Table layout algorithm - status: unknown + status: partial + gaps: + - Captions don't count toward the size of an empty table, see CSS 2.1 17.4 '#dimensioning-the-row-column-grid': title: 3.3 Dimensioning the row/column grid - status: unknown + status: partial '#dimensioning-the-row-column-grid--step1': title: 3.3.1 HTML Algorithm - status: unknown + status: partial + gaps: + - colspan and rowspan are read from any element, not only from td and th + - colspan doesn't add columns, see HTML 4.9.12.1 '#dimensioning-the-row-column-grid--step2': title: 3.3.2 Track merging - status: unknown + status: unsupported + gaps: + - Columns that only spanning cells cover aren't merged, a colspan past the column count breaks the table layout, see HTML 4.9.12.1 '#missing-cells-fixup': title: 3.4 Missing cells fixup - status: unknown + status: supported '#layout-modes': title: 3.5 Table layout modes - status: unknown + status: partial '#table-layout-property': title: 3.5.1 The Table-Layout property - status: unknown + status: parsed-only + gaps: + - "'table-layout: fixed' is ignored, see CSS 2.1 17.5.2.1" '#border-collapse-property': title: 3.5.2 The Border-Collapse property - status: unknown + status: partial + gaps: + - Collapsed borders aren't merged, see 3.7 '#border-spacing-property': title: 3.5.2.1 The Border-Spacing property - status: unknown + status: partial + gaps: + - A negative value isn't rejected, its absolute value is used '#caption-side-property': title: 3.5.3 The Caption-Side property - status: unknown + status: parsed-only + gaps: + - Captions aren't rendered, see CSS 2.1 17.4 '#style-overrides': title: 3.6 Style overrides - status: unknown + status: partial '#global-style-overrides': title: 3.6.1 Overrides applying in all modes - status: unknown + status: supported '#collapsed-style-overrides': title: 3.6.2 Overrides applying in collapsed-borders mode - status: unknown + status: partial + gaps: + - "'border-radius' of the cells isn't ignored" + - Borders aren't resolved, see 3.7 '#border-collapsing': title: 3.7 Border-collapsing - status: unknown + status: unsupported '#conflict-resolution-for-collapsed-borders': title: 3.7.1 Conflict Resolution for Collapsed Borders - status: unknown + status: unsupported '#border-conflict-resolution-algorithm': title: 3.7.1.1 Conflict Resolution Algorithm for Collapsed Borders - status: unknown + status: unsupported + gaps: + - Borders aren't resolved, every cell and the table paint their own, see CSS 2.1 17.6.2.1 '#border-style-harmonization-algorithm': title: 3.7.1.2 Harmonization Algorithm for Collapsed Borders - status: unknown + status: unsupported '#border-specificity': title: 3.7.1.3 Specificity of a border style - status: unknown + status: unsupported '#content-measure': title: 3.8 Computing table measures - status: unknown + status: partial '#computing-undistributable-space': title: 3.8.1 Computing Undistributable Space - status: unknown + status: partial + gaps: + - The table's padding is dropped, see CSS 2.1 17.6.1 '#computing-cell-measures': title: 3.8.2 Computing Cell Measures - status: unknown + status: partial + gaps: + - "'min-width' on a cell is ignored, see CSS 2.1 10.4" + - A cell's 'width' includes its padding and border, as if 'box-sizing' were always 'border-box' '#computing-column-measures': title: 3.8.3 Computing Column Measures - status: unknown + status: partial + gaps: + - A spanning cell's width is split evenly over its columns instead of in proportion to their widths + - A column group's 'width' doesn't widen its columns, see CSS 2.1 17.5.2.2 '#width-distribution': title: 3.9 Available Width Distribution - status: unknown + status: partial '#computing-the-table-width': title: 3.9.1 Computing the table width - status: unknown + status: partial + gaps: + - "'min-width' on the table is ignored" + - The caption's minimum width doesn't widen the table, see CSS 2.1 17.5.2.2 '#width-distribution-principles': title: 3.9.2 Core distribution principles - status: unknown - '#width-distribution-principles-rN': - title: 3.9.2.1 Rules - status: unknown - '#width-distribution-principles-rR': - title: 3.9.2.2 Available sizings - status: unknown + status: n/a '#width-distribution-algorithm': title: 3.9.3 Distribution algorithm - status: unknown + status: partial '#width-distribution-in-fixed-mode': title: 3.9.3.1 Changes to width distribution in fixed mode - status: unknown + status: unsupported + gaps: + - "'table-layout: fixed' is ignored, see 3.5.1" '#distributing-width-to-columns': title: 3.9.3.2 Distributing excess width to columns - status: unknown + status: partial + gaps: + - Excess width is split evenly over the auto columns instead of in proportion to their max-content width '#height-distribution': title: 3.10 Available Height Distribution - status: unknown + status: partial '#computing-the-table-height': title: 3.10.1 Computing the table height - status: unknown + status: partial + gaps: + - A row's 'height' is ignored, see CSS 2.1 17.5.3 '#row-layout': title: 3.10.2 Row layout (first pass) - status: unknown + status: partial + gaps: + - "'vertical-align: baseline' aligns like 'top', see CSS 2.1 17.5.3" '#row-relayout': title: 3.10.3 Row layout (second pass) - status: unknown + status: unsupported + gaps: + - Percentage heights of rows and cells aren't resolved against the table height '#height-distribution-principles': title: 3.10.4 Core distribution principles - status: unknown + status: n/a '#height-distribution-algorithm': title: 3.10.5 Distribution algorithm - status: unknown + status: unsupported + gaps: + - A table taller than its rows keeps the extra height below the last row instead of growing the rows '#table-cell-content-relayout': title: 3.10.6 Table-cell content layout (second pass) - status: unknown + status: unsupported + gaps: + - Percentage heights in a cell aren't resolved against the cell's height, see CSS 2.1 10.5 '#bounding-box-assignment': title: 3.11 Positioning of cells, captions and other internal table boxes - status: unknown + status: partial + gaps: + - Captions aren't positioned, see CSS 2.1 17.4 + - "Rows and columns with 'visibility: collapse' keep their space, see CSS 2.1 17.5.5" '#abspos': title: 4 Absolute Positioning - status: unknown + status: partial '#abspos-boxes-in-table-root': title: 4.1 With a table-root as containing block - status: unknown + status: partial + gaps: + - An absolutely positioned box in a cell moves with the cell's vertical alignment '#abspos-boxes-in-table-internal': title: 4.2 With a table-internal as containing block - status: unknown + status: partial + gaps: + - An absolutely positioned box is placed against the cell's content edge instead of its padding edge, see CSS 2.1 10.1 '#static-position': title: 4.3 With a table-internal box as non-containing block parent - status: unknown + status: partial + gaps: + - An absolutely positioned box whose parent isn't its containing block is placed at the containing block's origin instead of its static position '#rendering': title: 5 Rendering - status: unknown + status: partial '#paint-order': title: 5.1 Paint order of cells - status: unknown + status: supported '#empty-cell-rendering': title: 5.2 Empty cell rendering (separated-borders mode) - status: unknown + status: partial + gaps: + - "'hide' also hides cells with text, see CSS 2.1 17.6.1.1" + - "'hide' also hides empty cells in collapsed-borders mode" '#drawing-backgrounds-and-borders': title: 5.3 Drawing backgrounds and borders - status: unknown + status: partial '#drawing-table-backgrounds-and-borders': title: 5.3.1 Drawing table backgrounds and borders - status: unknown + status: supported '#drawing-collapsed-borders-1': title: 5.3.1.1 Changes in collapsed-borders mode - status: unknown + status: unsupported + gaps: + - The table paints its own borders, the edge cells don't take them over '#drawing-cell-backgrounds': title: 5.3.2 Drawing cell backgrounds - status: unknown + status: partial + gaps: + - Column, column group, row and row group backgrounds aren't painted, see CSS 2.1 17.5.1 '#drawing-cell-borders': title: 5.3.3 Drawing cell borders - status: unknown + status: supported '#drawing-collapsed-borders-2': title: 5.3.3.1 Changes in collapsed-borders mode - status: unknown + status: unsupported + gaps: + - Cell borders are drawn side by side with a 1px overlap instead of centered on the grid line, see CSS 2.1 17.6.2 '#border-style-overrides': title: 5.3.4 Border styles (collapsed-borders mode) - status: unknown + status: unsupported + gaps: + - "'hidden' doesn't suppress the other borders at the same edge, see CSS 2.1 17.6.3" '#visibility-collapse-rendering': title: '5.4 Rendering for visibility: collapse' - status: unknown + status: partial '#visibility-collapse-cell-rendering': title: '5.4.1 Rendering a visibility: collapse table cell' - status: unknown + status: partial + gaps: + - A cell spanning a collapsed track isn't clipped, collapsed tracks keep their space, see CSS 2.1 17.5.5 '#visibility-collapse-track-rendering': title: '5.4.2 Rendering a visibility: collapse table-track or table-track-group' - status: unknown + status: unsupported + gaps: + - "Rows and columns with 'visibility: collapse' keep their space, see CSS 2.1 17.5.5" '#fragmentation': title: 6 Fragmentation - status: unknown + status: partial '#breaking-rules': title: 6.1 Breaking across fragmentainers - status: unknown + status: partial + gaps: + - "'break-before' and 'break-after' on rows and row groups are ignored" '#repeated-headers': title: 6.2 Repeating headers across pages - status: unknown + status: partial + gaps: + - Footers aren't repeated + - A header taller than a quarter of the page still repeats '#security': title: 7 Security Considerations - status: unknown + status: n/a '#privacy': title: 8 Privacy Considerations - status: unknown + status: n/a '#bug-list': title: 9 List of bugs being tracked - status: unknown + status: n/a '#appendices': title: 10 Appendices - status: unknown - '#mapping': - title: 10.1 Mapping between CSS & HTML attributes - status: unknown + status: n/a '#TODO': title: 11 (link here for missing sections) - status: unknown + status: n/a '#changes': title: 12 Changes - status: unknown + status: n/a From b03eacadec52d633b0e90ed629f73f1b65c85518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 08:26:09 +0100 Subject: [PATCH 080/150] Record static position and ol reversed gaps found in the CSS modules --- .../BoxModel/HeightIntegrationTests.cs | 12 ++++++++++++ .../BoxModel/WidthIntegrationTests.cs | 12 ++++++++++++ Spec/css21.yml | 2 ++ Spec/html.yml | 2 +- 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs index af7654bbd..afa91961c 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs @@ -214,6 +214,18 @@ public void Absolute_WithTopAndBottom_StretchesTheAutoHeight() Assert.AreEqual(60, Height(Box(root, "a")), Delta); } + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#abs-non-replaced-height")] + [Spec("css-position-3", "#staticpos-rect")] + public void Absolute_WithAutoTopAndBottom_IsPlacedAtItsStaticPosition() + { + var root = Layout("<div style='position:relative;width:200px;height:100px'><div style='height:30px'></div>" + + "<div id='a' style='position:absolute;left:0;width:10px;height:10px'></div></div>"); + + Assert.AreEqual(30, Box(root, "a").Location.Y, Delta); + } + [TestMethod] [Spec("css21", "visudet.html#abs-replaced-height")] [Spec("css-position-3", "#abs-replaced-height")] diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs index b7f9f7bec..088ce16c1 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs @@ -276,6 +276,18 @@ public void Absolute_WithLeftRightAndWidth_IsCenteredByAutoMargins() Assert.AreEqual(50, Box(root, "a").Location.X, Delta); } + [Ignore("Gap, see Spec/css21.yml")] + [TestMethod] + [Spec("css21", "visudet.html#abs-non-replaced-width")] + [Spec("css-position-3", "#staticpos-rect")] + public void Absolute_WithAutoLeftAndRight_IsPlacedAtItsStaticPosition() + { + var root = Layout("<div style='position:relative;width:200px;height:100px'><div style='margin-left:30px'>" + + "<div id='a' style='position:absolute;width:10px;height:10px'></div></div></div>"); + + Assert.AreEqual(30, Box(root, "a").Location.X, Delta); + } + [TestMethod] [Spec("css21", "visudet.html#abs-replaced-width")] [Spec("css-position-3", "#abs-replaced-width")] diff --git a/Spec/css21.yml b/Spec/css21.yml index 0b5508c50..49e7b4536 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -518,6 +518,7 @@ sections: - With 'left' and 'right' set, an auto width is 0 instead of filling the space between them - The shrink-to-fit width of an auto width box is computed before its content is measured, on the first layout it's 0 or NaN - With 'left', 'right' and 'width' set, auto margins compute to 0 instead of centering the box + - With 'left' and 'right' auto, the box is placed at the containing block's content edge instead of its static position visudet.html#abs-replaced-width: title: 10.3.8 Absolutely positioned, replaced elements status: supported @@ -563,6 +564,7 @@ sections: status: partial gaps: - With 'top' and 'bottom' set, an auto height is the content height instead of filling the space between them + - With 'top' and 'bottom' auto, the box is placed at the containing block's content edge instead of its static position visudet.html#abs-replaced-height: title: 10.6.5 Absolutely positioned, replaced elements status: supported diff --git a/Spec/html.yml b/Spec/html.yml index e9001dc34..5104340d4 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1701,7 +1701,7 @@ sections: gaps: - ol, ul and menu are indented with margin-left instead of padding-inline-start - The type attribute of ol, ul and li is ignored - - The reversed attribute of ol is ignored + - reversed without a value, i.e. <ol reversed>, counts up - dir, dl and menu nested in a list keep their vertical margins, nested dir and menu don't switch to circle rendering.html#tables-2: title: 15.3.8 Tables From 7e512d8b313777acca233e0d5405a92e988a883a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:14:00 +0100 Subject: [PATCH 081/150] Quote the legacy color gap so it isn't cut off at the hash --- Spec/html.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Spec/html.yml b/Spec/html.yml index 5104340d4..60a4e0c69 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -56,7 +56,7 @@ sections: title: 2.3.6 Legacy colors status: partial gaps: - - Values other than named colors, #rgb and #rrggbb are parsed as CSS colors instead of as hex digits, e.g. ff0000 and chucknorris aren't colors and rgb(255,0,0) is red + - "Values other than named colors, #rgb and #rrggbb are parsed as CSS colors instead of as hex digits, e.g. ff0000 and chucknorris aren't colors and rgb(255,0,0) is red" common-microsyntaxes.html#space-separated-tokens: title: 2.3.7 Space-separated tokens status: partial From 1180f7564a6c7ce9d1ca05385958b984b5721411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:14:00 +0100 Subject: [PATCH 082/150] Add spec compliance tracking for the remaining CSS modules --- ...FixedPositionPaginationIntegrationTests.cs | 10 +- .../ConditionGroupingRuleIntegrationTests.cs | 3 +- Spec/compositing-1.yml | 185 +++++++++ Spec/css-align-3.yml | 161 ++++++++ Spec/css-animations-1.yml | 131 +++++++ Spec/css-backgrounds-4.yml | 83 ++++ Spec/css-borders-4.yml | 161 ++++++++ Spec/css-box-4.yml | 74 ++++ Spec/css-break-4.yml | 80 ++++ Spec/css-color-adjust-1.yml | 62 +++ Spec/css-conditional-4.yml | 14 + Spec/css-conditional-5.yml | 107 ++++++ Spec/css-contain-2.yml | 71 ++++ Spec/css-counter-styles-3.yml | 116 ++++++ Spec/css-display-4.yml | 62 +++ Spec/css-easing-1.yml | 41 ++ Spec/css-env-1.yml | 35 ++ Spec/css-flexbox-1.yml | 152 ++++++++ Spec/css-font-loading-3.yml | 59 +++ Spec/css-fonts-5.yml | 116 ++++++ Spec/css-gcpm-3.yml | 89 +++++ Spec/css-grid-2.yml | 281 ++++++++++++++ Spec/css-images-4.yml | 152 ++++++++ Spec/css-inline-3.yml | 191 ++++++++++ Spec/css-logical-1.yml | 68 ++++ Spec/css-masking-1.yml | 116 ++++++ Spec/css-multicol-1.yml | 77 ++++ Spec/css-position-4.yml | 32 ++ Spec/css-scroll-anchoring-1.yml | 32 ++ Spec/css-scroll-snap-1.yml | 95 +++++ Spec/css-scrollbars-1.yml | 23 ++ Spec/css-shapes-1.yml | 68 ++++ Spec/css-sizing-4.yml | 86 +++++ Spec/css-speech-1.yml | 110 ++++++ Spec/css-text-4.yml | 227 +++++++++++ Spec/css-text-decor-4.yml | 110 ++++++ Spec/css-transforms-1.yml | 107 ++++++ Spec/css-transforms-2.yml | 128 +++++++ Spec/css-transitions-1.yml | 92 +++++ Spec/css-ui-4.yml | 125 +++++++ Spec/css-values-5.yml | 149 ++++++++ Spec/css-view-transitions-1.yml | 143 +++++++ Spec/css-will-change-1.yml | 29 ++ Spec/css-writing-modes-4.yml | 176 +++++++++ Spec/cssom-1.yml | 182 +++++++++ Spec/cssom-view-1.yml | 107 ++++++ Spec/filter-effects-1.yml | 221 +++++++++++ Spec/geometry-1.yml | 65 ++++ Spec/mediaqueries-5.yml | 245 ++++++++++++ Spec/motion-1.yml | 59 +++ Spec/resize-observer-1.yml | 77 ++++ Spec/web-animations-1.yml | 353 ++++++++++++++++++ 52 files changed, 5730 insertions(+), 8 deletions(-) create mode 100644 Spec/compositing-1.yml create mode 100644 Spec/css-align-3.yml create mode 100644 Spec/css-animations-1.yml create mode 100644 Spec/css-backgrounds-4.yml create mode 100644 Spec/css-borders-4.yml create mode 100644 Spec/css-box-4.yml create mode 100644 Spec/css-break-4.yml create mode 100644 Spec/css-color-adjust-1.yml create mode 100644 Spec/css-conditional-4.yml create mode 100644 Spec/css-conditional-5.yml create mode 100644 Spec/css-contain-2.yml create mode 100644 Spec/css-counter-styles-3.yml create mode 100644 Spec/css-display-4.yml create mode 100644 Spec/css-easing-1.yml create mode 100644 Spec/css-env-1.yml create mode 100644 Spec/css-flexbox-1.yml create mode 100644 Spec/css-font-loading-3.yml create mode 100644 Spec/css-fonts-5.yml create mode 100644 Spec/css-gcpm-3.yml create mode 100644 Spec/css-grid-2.yml create mode 100644 Spec/css-images-4.yml create mode 100644 Spec/css-inline-3.yml create mode 100644 Spec/css-logical-1.yml create mode 100644 Spec/css-masking-1.yml create mode 100644 Spec/css-multicol-1.yml create mode 100644 Spec/css-position-4.yml create mode 100644 Spec/css-scroll-anchoring-1.yml create mode 100644 Spec/css-scroll-snap-1.yml create mode 100644 Spec/css-scrollbars-1.yml create mode 100644 Spec/css-shapes-1.yml create mode 100644 Spec/css-sizing-4.yml create mode 100644 Spec/css-speech-1.yml create mode 100644 Spec/css-text-4.yml create mode 100644 Spec/css-text-decor-4.yml create mode 100644 Spec/css-transforms-1.yml create mode 100644 Spec/css-transforms-2.yml create mode 100644 Spec/css-transitions-1.yml create mode 100644 Spec/css-ui-4.yml create mode 100644 Spec/css-values-5.yml create mode 100644 Spec/css-view-transitions-1.yml create mode 100644 Spec/css-will-change-1.yml create mode 100644 Spec/css-writing-modes-4.yml create mode 100644 Spec/cssom-1.yml create mode 100644 Spec/cssom-view-1.yml create mode 100644 Spec/filter-effects-1.yml create mode 100644 Spec/geometry-1.yml create mode 100644 Spec/mediaqueries-5.yml create mode 100644 Spec/motion-1.yml create mode 100644 Spec/resize-observer-1.yml create mode 100644 Spec/web-animations-1.yml diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/FixedPositionPaginationIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/FixedPositionPaginationIntegrationTests.cs index 3fed3576f..4a7f96a88 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/FixedPositionPaginationIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/FixedPositionPaginationIntegrationTests.cs @@ -35,14 +35,10 @@ public void FixedPositionedBox_PaintPosition_IsInvariantToScrollOffset_WhileNorm Assert.AreEqual(normalRect0.Y + 500, normalRect1.Y, 0.5); } - [Ignore("This fork has no page-break-before/page-break-after support at all - confirmed by direct source " + - "read: no PageBreakBefore/PageBreakAfter property exists anywhere in CssBoxProperties (only " + - "PageBreakInside), and the default stylesheet's own page-break-before/after rules (Core/CssDefaults.cs " + - "~102-105) are inert since nothing consumes them. 'page-break-before: always' is silently dropped, so " + - "there is no way to materialize distinct 'pages' the way PeachPDF's real PDF pagination does - " + - "content just flows continuously down one tall canvas and every paragraph below stacks directly " + - "under its predecessor instead of jumping to a new page band.")] [TestMethod] + [Spec("css21", "page.html#page-break-props")] + [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void PageBreakBefore_PushesFollowingContentToTheNextSimulatedPage() { var (root, _) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/ConditionGroupingRuleIntegrationTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/ConditionGroupingRuleIntegrationTests.cs index 2cbb5a9a6..c1a8ace9e 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/ConditionGroupingRuleIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/ConditionGroupingRuleIntegrationTests.cs @@ -94,7 +94,8 @@ public void Supports_InsideLayer_AppliesOnlyWhenConditionIsSupported() } [TestMethod] - [Ignore("not yet spec compliant - the cascade never applies @container's inner rules")] + [Ignore("Gap, see Spec/css-conditional-5.yml")] + [Spec("css-conditional-5", "#container-rule")] public void Container_InnerRules_Apply_WhenAnEligibleAncestorContainerMatches() { var html = Html( diff --git a/Spec/compositing-1.yml b/Spec/compositing-1.yml new file mode 100644 index 000000000..380bca2d6 --- /dev/null +++ b/Spec/compositing-1.yml @@ -0,0 +1,185 @@ +spec: compositing-1 +title: Compositing and Blending Level 1 +url: https://www.w3.org/TR/compositing-1/ +snapshot: https://www.w3.org/TR/2024/CRD-compositing-1-20240321/ +sections: + '#introduction': + title: 1 Introduction + status: unknown + '#reading-this-document': + title: 2 Reading This Document + status: unknown + '#module-interactions': + title: 2.1 Module interactions + status: unknown + '#values': + title: 2.2 Values + status: unknown + '#csscompositingandblending': + title: 3 Specifying Blending in CSS + status: unknown + '#compositingandblendingorder': + title: 3.1 Order of graphical operations + status: unknown + '#csscompositingrules_CSS': + title: 3.2 Behavior specific to HTML + status: unknown + '#csscompositingrules_SVG': + title: 3.3 Behavior specific to SVG + status: unknown + '#csskeywords': + title: 3.4 CSS Properties + status: unknown + '#mix-blend-mode': + title: 3.4.1 The mix-blend-mode property + status: unknown + '#isolation': + title: 3.4.2 The isolation property + status: unknown + '#background-blend-mode': + title: 3.4.3 The background-blend-mode property + status: unknown + '#canvascompositingandblending': + title: 4 Specifying Compositing and Blending in Canvas 2D + status: unknown + '#whatiscompositing': + title: 5 Introduction to compositing + status: unknown + '#simplealphacompositing': + title: 5.1 Simple alpha compositing + status: unknown + '#simplealphacompositingexamples': + title: 5.1.1 Examples of simple alpha compositing + status: unknown + '#generalformula': + title: 6 General Formula for Compositing and Blending + status: unknown + '#backdropCalc': + title: 7 Backdrop calculation + status: unknown + '#backdropexamples': + title: 7.1 Examples of backdrop calculation + status: unknown + '#groups': + title: 8 Compositing Groups + status: unknown + '#groupinvariance': + title: 8.1 Group invariance + status: unknown + '#isolatedgroups': + title: 8.2 Isolated Groups + status: unknown + '#pagebackdrop': + title: 8.3 The Root Element Group + status: unknown + '#rootgroup': + title: 8.4 The Root Group + status: unknown + '#advancedcompositing': + title: 9 Advanced compositing features + status: unknown + '#porterduffcompositingoperators': + title: 9.1 The Porter Duff Compositing Operators + status: unknown + '#porterduffcompositingoperators_clear': + title: 9.1.1 Clear + status: unknown + '#porterduffcompositingoperators_src': + title: 9.1.2 Copy + status: unknown + '#porterduffcompositingoperators_dst': + title: 9.1.3 Destination + status: unknown + '#porterduffcompositingoperators_srcover': + title: 9.1.4 Source Over + status: unknown + '#porterduffcompositingoperators_dstover': + title: 9.1.5 Destination Over + status: unknown + '#porterduffcompositingoperators_srcin': + title: 9.1.6 Source In + status: unknown + '#porterduffcompositingoperators_dstin': + title: 9.1.7 Destination In + status: unknown + '#porterduffcompositingoperators_srcout': + title: 9.1.8 Source Out + status: unknown + '#porterduffcompositingoperators_dstout': + title: 9.1.9 Destination Out + status: unknown + '#porterduffcompositingoperators_srcatop': + title: 9.1.10 Source Atop + status: unknown + '#porterduffcompositingoperators_dstatop': + title: 9.1.11 Destination Atop + status: unknown + '#porterduffcompositingoperators_xor': + title: 9.1.12 XOR + status: unknown + '#porterduffcompositingoperators_plus': + title: 9.1.13 Lighter + status: unknown + '#groupcompositing': + title: 9.2 Group compositing behavior with Porter Duff modes + status: unknown + '#blending': + title: 10 Blending + status: unknown + '#blendingseparable': + title: 10.1 Separable blend modes + status: unknown + '#blendingnormal': + title: 10.1.1 normal blend mode + status: unknown + '#blendingmultiply': + title: 10.1.2 multiply blend mode + status: unknown + '#blendingscreen': + title: 10.1.3 screen blend mode + status: unknown + '#blendingoverlay': + title: 10.1.4 overlay blend mode + status: unknown + '#blendingdarken': + title: 10.1.5 darken blend mode + status: unknown + '#blendinglighten': + title: 10.1.6 lighten blend mode + status: unknown + '#blendingcolordodge': + title: 10.1.7 color-dodge blend mode + status: unknown + '#blendingcolorburn': + title: 10.1.8 color-burn blend mode + status: unknown + '#blendinghardlight': + title: 10.1.9 hard-light blend mode + status: unknown + '#blendingsoftlight': + title: 10.1.10 soft-light blend mode + status: unknown + '#blendingdifference': + title: 10.1.11 difference blend mode + status: unknown + '#blendingexclusion': + title: 10.1.12 exclusion blend mode + status: unknown + '#blendingnonseparable': + title: 10.2 Non-separable blend modes + status: unknown + '#blendinghue': + title: 10.2.1 hue blend mode + status: unknown + '#blendingsaturation': + title: 10.2.2 saturation blend mode + status: unknown + '#blendingcolor': + title: 10.2.3 color blend mode + status: unknown + '#blendingluminosity': + title: 10.2.4 luminosity blend mode + status: unknown + '#isolationblending': + title: 10.3 Effect of group isolation on blending + status: unknown diff --git a/Spec/css-align-3.yml b/Spec/css-align-3.yml new file mode 100644 index 000000000..8de817c9a --- /dev/null +++ b/Spec/css-align-3.yml @@ -0,0 +1,161 @@ +spec: css-align-3 +title: CSS Box Alignment Module Level 3 +url: https://www.w3.org/TR/css-align-3/ +snapshot: https://www.w3.org/TR/2026/WD-css-align-3-20261008/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#partial': + title: 1.3 Partial Implementations + status: unknown + '#overview': + title: 2 Overview of Alignment Properties + status: unknown + '#terms': + title: 3 Alignment Terminology + status: unknown + '#alignment-values': + title: 4 Alignment Keywords + status: unknown + '#positional-values': + title: '4.1 Positional Alignment: the center, start, end, self-start, self-end, flex-start, flex-end, left, and right keywords' + status: unknown + '#baseline-values': + title: '4.2 Baseline Alignment: the baseline keyword and first/last modifiers' + status: unknown + '#distribution-values': + title: '4.3 Distributed Alignment: the stretch, space-between, space-around, and space-evenly keywords' + status: unknown + '#overflow-values': + title: '4.4 Overflow Alignment: the safe and unsafe keywords and scroll safety limits' + status: unknown + '#auto-safety': + title: 4.4.1 Automatic Overflow Alignment Safety + status: unknown + '#auto-safety-scroll': + title: 4.4.1.1 Content Distribution for Scroll Containers + status: unknown + '#auto-safety-position': + title: 4.4.1.2 Self-Alignment for Absolutely Positioned Boxes + status: unknown + '#auto-safety-default': + title: 4.4.1.3 All Other Alignment + status: unknown + '#content-distribution': + title: '5 Content Distribution: Aligning a Box’s Contents Within Itself' + status: unknown + '#align-justify-content': + title: 5.1 The justify-content and align-content Properties + status: unknown + '#distribution-block': + title: 5.1.1 Block Containers (Including Table Cells) + status: unknown + '#distribution-multicol': + title: 5.1.2 Multicol Containers + status: unknown + '#distribution-flex': + title: 5.1.3 Flex Containers + status: unknown + '#distribution-grid': + title: 5.1.4 Grid Containers + status: unknown + '#place-content': + title: '5.2 Content-Distribution Shorthand: the place-content property' + status: unknown + '#overflow-scroll-position': + title: 5.3 Alignment Overflow and Scroll Containers + status: unknown + '#baseline-align-content': + title: 5.4 Baseline Content-Alignment + status: unknown + '#self-alignment': + title: '6 Self-Alignment: Aligning the Box Within Its Parent' + status: unknown + '#justify-self-property': + title: '6.1 Inline-Axis (or Main-Axis) Self-Alignment: the justify-self property' + status: unknown + '#justify-block': + title: 6.1.1 Block-Level Boxes + status: unknown + '#justify-abspos': + title: 6.1.2 Absolutely-Positioned Boxes + status: unknown + '#justify-cell': + title: 6.1.3 Table Cells + status: unknown + '#justify-flex': + title: 6.1.4 Flex Items + status: unknown + '#justify-grid': + title: 6.1.5 Grid Items + status: unknown + '#align-self-property': + title: '6.2 Block-Axis (or Cross-Axis) Self-Alignment: the align-self property' + status: unknown + '#align-block': + title: 6.2.1 Block-Level Boxes + status: unknown + '#align-abspos': + title: 6.2.2 Absolutely-Positioned Boxes + status: unknown + '#align-cell': + title: 6.2.3 Table Cells + status: unknown + '#align-flex': + title: 6.2.4 Flex Items + status: unknown + '#align-grid': + title: 6.2.5 Grid Items + status: unknown + '#place-self-property': + title: '6.3 Self-Alignment Shorthand: the place-self property' + status: unknown + '#baseline-align-self': + title: 6.4 Baseline Self-Alignment + status: unknown + '#abspos-sizing': + title: 6.5 Effects on Sizing of Absolutely Positioned Boxes with Static-Position Insets + status: unknown + '#default-alignment': + title: 7 Default Alignment + status: unknown + '#justify-items-property': + title: '7.1 Inline-Axis (or Main-Axis) Default Alignment: the justify-items property' + status: unknown + '#align-items-property': + title: '7.2 Block-Axis (or Cross-Axis) Default Alignment: the align-items property' + status: unknown + '#place-items-property': + title: '7.3 Default Alignment Shorthand: the place-items property' + status: unknown + '#gaps': + title: 8 Gaps Between Boxes + status: unknown + '#baseline-rules': + title: 9 Baseline Alignment Details + status: unknown + '#baseline-export': + title: 9.1 Determining the Baselines of a Box + status: unknown + '#baseline-terms': + title: 9.2 Baseline Alignment Grouping + status: unknown + '#align-by-baseline': + title: 9.3 Aligning Boxes by Baseline + status: unknown + '#changes': + title: 10 Changes + status: unknown + '#privacy': + title: 11 Privacy Considerations + status: unknown + '#security': + title: 12 Security Considerations + status: unknown diff --git a/Spec/css-animations-1.yml b/Spec/css-animations-1.yml new file mode 100644 index 000000000..3ba42188c --- /dev/null +++ b/Spec/css-animations-1.yml @@ -0,0 +1,131 @@ +spec: css-animations-1 +title: CSS Animations Level 1 +url: https://www.w3.org/TR/css-animations-1/ +snapshot: https://www.w3.org/TR/2023/WD-css-animations-1-20230302/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#animations': + title: 2 Animations + status: unknown + '#keyframes': + title: 3 Keyframes + status: unknown + '#timing-functions': + title: 3.1 Timing functions for keyframes + status: unknown + '#animation-name': + title: 3.2 The animation-name property + status: unknown + '#animation-duration': + title: 3.3 The animation-duration property + status: unknown + '#animation-timing-function': + title: 3.4 The animation-timing-function property + status: unknown + '#animation-iteration-count': + title: 3.5 The animation-iteration-count property + status: unknown + '#animation-direction': + title: 3.6 The animation-direction property + status: unknown + '#animation-play-state': + title: 3.7 The animation-play-state property + status: unknown + '#animation-delay': + title: 3.8 The animation-delay property + status: unknown + '#animation-fill-mode': + title: 3.9 The animation-fill-mode property + status: unknown + '#animation': + title: 3.10 The animation shorthand property + status: unknown + '#events': + title: 4 Animation Events + status: unknown + '#interface-animationevent': + title: 4.1 The AnimationEvent Interface + status: unknown + '#interface-animationevent-idl': + title: 4.1.1 IDL Definition + status: unknown + '#interface-animationevent-attributes': + title: 4.1.2 Attributes + status: unknown + '#event-animationevent': + title: 4.2 Types of AnimationEvent + status: unknown + '#event-handlers-on-elements-document-objects-and-window-objects': + title: 4.3 Event handlers on elements, Document objects, and Window objects + status: unknown + '#interface-dom': + title: 5 DOM Interfaces + status: unknown + '#interface-cssrule': + title: 5.1 The CSSRule Interface + status: unknown + '#interface-cssrule-idl': + title: 5.1.1 IDL Definition + status: unknown + '#interface-csskeyframerule': + title: 5.2 The CSSKeyframeRule Interface + status: unknown + '#interface-csskeyframerule-idl': + title: 5.2.1 IDL Definition + status: unknown + '#interface-csskeyframerule-attributes': + title: 5.2.2 Attributes + status: unknown + '#interface-csskeyframesrule': + title: 5.3 The CSSKeyframesRule Interface + status: unknown + '#interface-csskeyframesrule-idl': + title: 5.3.1 IDL Definition + status: unknown + '#interface-csskeyframesrule-attributes': + title: 5.3.2 Attributes + status: unknown + '#interface-csskeyframesrule-indexed-property-getter': + title: 5.3.3 The indexed property getter + status: unknown + '#interface-csskeyframesrule-appendrule': + title: 5.3.4 The appendRule method + status: unknown + '#interface-csskeyframesrule-deleterule': + title: 5.3.5 The deleteRule method + status: unknown + '#interface-csskeyframesrule-findrule': + title: 5.3.6 The findRule method + status: unknown + '#interface-globaleventhandlers': + title: 5.4 Extensions to the GlobalEventHandlers Interface Mixin + status: unknown + '#interface-globaleventhandlers-idl': + title: 5.4.1 IDL Definition + status: unknown + '#priv': + title: 6 Privacy Considerations + status: unknown + '#sec': + title: 7 Security Considerations + status: unknown + '#changes': + title: 8 Changes + status: unknown + '#changes-20181011': + title: 8.1 Changes since the Working Draft of 11 October 2018 + status: unknown + '#acknowledgements': + title: 9 Acknowledgements + status: unknown + '#other-open-issues': + title: 10 Other open issues + status: unknown + '#wg-resolutions-pending': + title: 11 Working Group Resolutions that are pending editing + status: unknown diff --git a/Spec/css-backgrounds-4.yml b/Spec/css-backgrounds-4.yml new file mode 100644 index 000000000..fc86dd531 --- /dev/null +++ b/Spec/css-backgrounds-4.yml @@ -0,0 +1,83 @@ +spec: css-backgrounds-4 +title: CSS Backgrounds Module Level 4 +url: https://www.w3.org/TR/css-backgrounds-4/ +snapshot: https://www.w3.org/TR/2025/WD-css-backgrounds-4-20251125/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#backgrounds': + title: 2 Defining Backgrounds + status: unknown + '#background-color': + title: '2.1 Base Color: the background-color property' + status: unknown + '#background-image': + title: '2.2 Image Sources: the background-image property' + status: unknown + '#background-repeat-longhands': + title: '2.3 Tiling Images: the background-repeat-x, background-repeat-y, background-repeat-block, and background-repeat-inline properties' + status: unknown + '#background-repeat': + title: '2.4 Tiling Images Shorthand: the background-repeat property' + status: unknown + '#background-attachment': + title: '2.5 Affixing Images: the background-attachment property' + status: unknown + '#background-position-longhands': + title: '2.6 Background Positioning: the background-position-x, background-position-y, background-position-inline, and background-position-block properties' + status: unknown + '#background-position': + title: '2.7 Background Positioning Shorthand: the background-position shorthand property' + status: unknown + '#bg-position-serialization': + title: 2.7.1 Serialization of background-position values + status: unknown + '#background-clip': + title: '2.8 Painting Area: the background-clip property' + status: unknown + '#background-origin': + title: '2.9 Positioning Area: the background-origin property' + status: unknown + '#background-size': + title: '2.10 Sizing Images: the background-size property' + status: unknown + '#bg-size-serialization': + title: 2.10.1 Serialization of background-size values + status: unknown + '#background-layers': + title: '2.11 Background Image Layers: the background-tbd shorthand property' + status: unknown + '#background': + title: '2.12 Backgrounds Shorthand: the background property' + status: unknown + '#layering': + title: 3 Layering Multiple Background Images + status: unknown + '#special-backgrounds': + title: 4 Backgrounds of Special Elements + status: unknown + '#root-background': + title: 4.1 The Canvas Background and the Root Element + status: unknown + '#body-background': + title: 4.2 The Canvas Background and the HTML <body> Element + status: unknown + '#first-line-background': + title: 4.3 The ::first-line Pseudo-element‘s Background + status: unknown + '#changes': + title: 5 Changes + status: unknown + '#level-changes': + title: 5.1 Additions since [CSS3BG] + status: unknown + '#acknowledgments': + title: 6 Acknowledgments + status: unknown diff --git a/Spec/css-borders-4.yml b/Spec/css-borders-4.yml new file mode 100644 index 000000000..83656f9b0 --- /dev/null +++ b/Spec/css-borders-4.yml @@ -0,0 +1,161 @@ +spec: css-borders-4 +title: CSS Borders and Box Decorations Module Level 4 +url: https://www.w3.org/TR/css-borders-4/ +snapshot: https://www.w3.org/TR/2025/WD-css-borders-4-20251216/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#borders': + title: 2 Borders + status: unknown + '#border-color': + title: '2.1 Line Colors: the border-color properties' + status: unknown + '#border-style': + title: '2.2 Line Patterns: the border-style properties' + status: unknown + '#border-width': + title: '2.3 Line Thickness: the border-width properties' + status: unknown + '#border-shorthands': + title: 2.4 Border Shorthand Properties + status: unknown + '#corners': + title: 3 Corners + status: unknown + '#corner-clipping': + title: 3.1 Corner Clipping + status: unknown + '#corner-transitions': + title: 3.2 Color and Style Transitions + status: unknown + '#corner-overlap': + title: 3.3 Overlapping Curves + status: unknown + '#border-radius-tables': + title: 3.4 Effect on Tables + status: unknown + '#border-radius': + title: '3.5 Corner Sizing: the border-*-*-radius properties' + status: unknown + '#corner-sizing': + title: '3.6 Corner Sizing Shorthands: the border-radius and border-*-radius shorthand properties' + status: unknown + '#corner-sizing-side-shorthands': + title: '3.6.1 Sizing The Corners Of One Side: The border-top-radius, border-right-radius, border-bottom-radius, border-left-radius, border-block-start-radius, border-block-end-radius, border-inline-start-radius, border-inline-end-radius shorthands' + status: unknown + '#corner-sizing-shorthand': + title: '3.6.2 Sizing All Corners At Once: The border-radius shorthand' + status: unknown + '#corner-shaping': + title: '3.7 Corner Shaping: the corner-*-shape properties' + status: unknown + '#corner-shaping-shorthands': + title: '3.8 Corner Shaping Shorthands: the corner-shape and corner-*-shape shorthand properties' + status: unknown + '#corner-shaping-side-shorthands': + title: '3.8.1 Shaping The Corners Of One Side: The corner-*-shape shorthands:' + status: unknown + '#corner-shaping-shorthand': + title: '3.8.2 Shaping All Corners At Once: The corner-shape shorthand' + status: unknown + '#corner-shorthands': + title: '3.9 Corner Sizing & Shaping Shorthands: the corner and corner-* shorthand properties' + status: unknown + '#single-corner-shorthands': + title: '3.9.1 Sizing & shaping a corner: The corner-top-left, corner-top-right, corner-bottom-right, corner-bottom-left, corner-start-start, corner-start-end, corner-end-start, corner-end-end shorthands' + status: unknown + '#corner-side-shorthands': + title: '3.9.2 Sizing & shaping rhe Corners Of One Side: The corner-top, corner-right, corner-bottom, corner-left, corner-block-start, corner-block-end, corner-inline-start, corner-inline-end shorthands' + status: unknown + '#corner-shorthand': + title: '3.9.3 Sizing & Shaping All Corners At Once: The corner shorthand' + status: unknown + '#corner-shape-rendering': + title: 3.9.4 Rendering corner-shape + status: unknown + '#corner-shape-constrain-radii': + title: 3.9.5 Constraining opposite radii + status: unknown + '#corner-shape-interpolation': + title: 3.9.6 Interpolating corner shapes + status: unknown + '#border-images': + title: 4 Border Images + status: unknown + '#border-image-source': + title: '4.1 Image Source: the border-image-source property' + status: unknown + '#border-image-slice': + title: '4.2 Image Slicing: the border-image-slice property' + status: unknown + '#border-image-width': + title: '4.3 Drawing Areas: the border-image-width property' + status: unknown + '#border-image-outset': + title: '4.4 Edge Overhang: the border-image-outset property' + status: unknown + '#border-image-repeat': + title: '4.5 Image Tiling: the border-image-repeat property' + status: unknown + '#border-image-process': + title: 4.6 Drawing the Border Image + status: unknown + '#border-image': + title: '4.7 Border Image Shorthand: the border-image property' + status: unknown + '#border-image-tables': + title: 4.8 Effect on Tables + status: unknown + '#partial-borders': + title: 5 Partial borders + status: unknown + '#border-limit': + title: '5.1 Partial Borders: the border-limit property' + status: unknown + '#border-clip': + title: 5.2 The border-clip properties + status: unknown + '#drop-shadows': + title: 6 Drop Shadows + status: unknown + '#box-shadow-color': + title: '6.1 Coloring shadows: the box-shadow-color property' + status: unknown + '#box-shadow-offset': + title: '6.2 Offsetting shadows: the box-shadow-offset property' + status: unknown + '#box-shadow-blur': + title: '6.3 Blurring shadows: the box-shadow-blur property' + status: unknown + '#box-shadow-spread': + title: '6.4 Spreading shadows: the box-shadow-spread property' + status: unknown + '#box-shadow-position': + title: '6.5 Spreading shadows: the box-shadow-position property' + status: unknown + '#box-shadow': + title: '6.6 Drop Shadows Shorthand: the box-shadow property' + status: unknown + '#shadow-shape': + title: 6.6.1 Shadow Shape, Spread, and Knockout + status: unknown + '#shadow-blur': + title: 6.6.2 Blurring Shadow Edges + status: unknown + '#shadow-layers': + title: 6.7 Layering, Layout, and Other Details + status: unknown + '#border-shape': + title: 7 Border Shaping + status: unknown + '#border-shape-func': + title: 7.1 The border-shape property + status: unknown diff --git a/Spec/css-box-4.yml b/Spec/css-box-4.yml new file mode 100644 index 000000000..37117b0b5 --- /dev/null +++ b/Spec/css-box-4.yml @@ -0,0 +1,74 @@ +spec: css-box-4 +title: CSS Box Model Module Level 4 +url: https://www.w3.org/TR/css-box-4/ +snapshot: https://www.w3.org/TR/2024/WD-css-box-4-20240804/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#placement': + title: 1.2 Module Interactions + status: unknown + '#box-model': + title: 2 The CSS Box Model + status: unknown + '#box-edges': + title: 2.1 Boxes and Edges + status: unknown + '#fragmentation': + title: 2.2 Fragmentation + status: unknown + '#keywords': + title: 2.3 Box-edge Keywords + status: unknown + '#margins': + title: 3 Margins + status: unknown + '#margin-physical': + title: '3.1 Page-relative (Physical) Margin Properties: the margin-top, margin-right, margin-bottom, and margin-left properties' + status: unknown + '#margin-shorthand': + title: '3.2 Margin Shorthand: the margin property' + status: unknown + '#margin-trim': + title: '3.3 Margins at Container Edges: the margin-trim property' + status: unknown + '#margin-trim-block': + title: 3.3.1 Trimming Block Container Content + status: unknown + '#margin-trim-flex': + title: 3.3.2 Trimming Flex Container Content + status: unknown + '#margin-trim-grid': + title: 3.3.3 Trimming Grid Container Content + status: unknown + '#paddings': + title: 4 Padding + status: unknown + '#padding-physical': + title: '4.1 Page-relative (Physical) Padding Properties: the padding-top, padding-right, padding-bottom, and padding-left properties' + status: unknown + '#padding-shorthand': + title: '4.2 Padding Shorthand: the padding property' + status: unknown + '#borders': + title: 5 Borders + status: unknown + '#changes': + title: 6 Recent Changes + status: unknown + '#changes-since-3': + title: 7 Changes Since CSS Level 3 + status: unknown + '#changes-since-2': + title: 8 Changes Since CSS Level 2 + status: unknown + '#priv': + title: 9 Privacy Considerations + status: unknown + '#sec': + title: 10 Security Considerations + status: unknown diff --git a/Spec/css-break-4.yml b/Spec/css-break-4.yml new file mode 100644 index 000000000..e07b78f86 --- /dev/null +++ b/Spec/css-break-4.yml @@ -0,0 +1,80 @@ +spec: css-break-4 +title: CSS Fragmentation Module Level 4 +url: https://www.w3.org/TR/css-break-4/ +snapshot: https://www.w3.org/TR/2018/WD-css-break-4-20181218/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Values + status: unknown + '#fragmentation-model': + title: 2 Fragmentation Model and Terminology + status: unknown + '#parallel-flows': + title: 2.1 Parallel Fragmentation Flows + status: unknown + '#nested-flows': + title: 2.2 Nested Fragmentation Flows + status: unknown + '#breaking-controls': + title: 3 Controlling Breaks + status: unknown + '#break-between': + title: '3.1 Breaks Between Boxes: the break-before and break-after properties' + status: unknown + '#break-propagation': + title: 3.1.1 Child→Parent Break Propagation + status: unknown + '#break-within': + title: '3.2 Breaks Within Boxes: the break-inside property' + status: unknown + '#widows-orphans': + title: '3.3 Breaks Between Lines: orphans, widows' + status: unknown + '#page-break-properties': + title: '3.4 Page Break Aliases: the page-break-before, page-break-after, and page-break-inside properties' + status: unknown + '#breaking-rules': + title: 4 Rules for Breaking + status: unknown + '#possible-breaks': + title: 4.1 Possible Break Points + status: unknown + '#break-types': + title: 4.2 Types of Breaks + status: unknown + '#forced-breaks': + title: 4.3 Forced Breaks + status: unknown + '#unforced-breaks': + title: 4.4 Unforced Breaks + status: unknown + '#best-breaks': + title: 4.5 Optimizing Unforced Breaks + status: unknown + '#breaking-boxes': + title: 5 Box Model for Breaking + status: unknown + '#varying-size-boxes': + title: 5.1 Breaking into Varying-size Fragmentainers + status: unknown + '#break-margins': + title: '5.2 Adjoining Margins at Breaks: the margin-break property' + status: unknown + '#box-splitting': + title: 5.3 Splitting Boxes + status: unknown + '#break-decoration': + title: '5.4 Fragmented Borders and Backgrounds: the box-decoration-break property' + status: unknown + '#joining-boxes': + title: 5.4.1 Joining Boxes for slice + status: unknown + '#transforms': + title: 5.5 Transforms, Positioning, and Pagination + status: unknown diff --git a/Spec/css-color-adjust-1.yml b/Spec/css-color-adjust-1.yml new file mode 100644 index 000000000..8fc377da9 --- /dev/null +++ b/Spec/css-color-adjust-1.yml @@ -0,0 +1,62 @@ +spec: css-color-adjust-1 +title: CSS Color Adjustment Module Level 1 +url: https://www.w3.org/TR/css-color-adjust-1/ +snapshot: https://www.w3.org/TR/2025/CR-css-color-adjust-1-20251216/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#preferred': + title: 2 Preferred Color Schemes + status: unknown + '#color-scheme-prop': + title: '2.1 Opting Into a Preferred Color Scheme: the color-scheme property' + status: unknown + '#color-scheme-effect': + title: 2.2 Effects of the Used Color Scheme + status: unknown + '#color-scheme-override': + title: 2.3 Overriding the Color Scheme + status: unknown + '#forced': + title: 3 Forced Color Palettes + status: unknown + '#forced-colors-properties': + title: 3.1 Properties Affected by Forced Colors Mode + status: unknown + '#forced-color-adjust-prop': + title: '3.2 Opting Out of a Forced Color Palette: the forced-color-adjust property' + status: unknown + '#perf': + title: 4 Performance-based Color Adjustments + status: unknown + '#print-color-adjust': + title: '4.1 Ink Economy: the print-color-adjust property' + status: unknown + '#color-adjust': + title: 4.2 The color-adjust Shorthand + status: unknown + '#color-adjust-emulation': + title: 5 Emulation + status: unknown + '#emulate-forced-colors-mode': + title: 5.1 Emulate Forced Colors Mode + status: unknown + '#forced-color-palettes': + title: 5.2 Forced Colors Mode Color Palettes + status: unknown + '#privacy': + title: 6 Privacy Considerations + status: unknown + '#security': + title: 7 Security Considerations + status: unknown + '#acknowledgements': + title: 8 Acknowledgements + status: unknown + '#changes': + title: 9 Changes + status: unknown diff --git a/Spec/css-conditional-4.yml b/Spec/css-conditional-4.yml new file mode 100644 index 000000000..123955c5b --- /dev/null +++ b/Spec/css-conditional-4.yml @@ -0,0 +1,14 @@ +spec: css-conditional-4 +title: CSS Conditional Rules Module Level 4 +url: https://www.w3.org/TR/css-conditional-4/ +snapshot: https://www.w3.org/TR/2025/CRD-css-conditional-4-20250904/ +sections: + '#introduction': + title: 1 Introduction + status: unknown + '#at-supports-ext': + title: 2 Extensions to the @supports rule + status: unknown + '#support-definition-ext': + title: 2.1 Extensions to the definition of support + status: unknown diff --git a/Spec/css-conditional-5.yml b/Spec/css-conditional-5.yml new file mode 100644 index 000000000..414268cf2 --- /dev/null +++ b/Spec/css-conditional-5.yml @@ -0,0 +1,107 @@ +spec: css-conditional-5 +title: CSS Conditional Rules Module Level 5 +url: https://www.w3.org/TR/css-conditional-5/ +snapshot: https://www.w3.org/TR/2025/WD-css-conditional-5-20251030/ +sections: + '#introduction': + title: 1 Introduction + status: unknown + '#at-supports-ext': + title: 2 Extensions to the @supports rule + status: unknown + '#support-definition-ext': + title: 2.1 Extensions to the definition of support + status: unknown + '#support-definition-ext-fonts': + title: 2.1.1 Font techs and formats + status: unknown + '#support-definition-at-rules': + title: 2.1.2 At-rules + status: unknown + '#support-definition-supports-condition-name': + title: 2.1.3 Named conditions + status: unknown + '#when-rule': + title: '3 Generalized Conditional Rules: the @when rule' + status: unknown + '#else-rule': + title: '4 Chained Conditionals: the @else rule' + status: unknown + '#container-queries': + title: 5 Container Queries + status: unknown + '#container-type': + title: '5.1 Creating Query Containers: the container-type property' + status: unknown + '#container-name': + title: '5.2 Naming Query Containers: the container-name property' + status: unknown + '#container-shorthand': + title: '5.3 Creating Named Containers: the container shorthand' + status: unknown + '#container-rule': + title: '5.4 Container Queries: the @container rule' + status: unknown + '#animated-containers': + title: 5.5 Animated Containers + status: unknown + '#container-features': + title: 6 Container Features + status: unknown + '#size-container': + title: 6.1 Size Container Features + status: unknown + '#width': + title: '6.1.1 Width: the width feature' + status: unknown + '#height': + title: '6.1.2 Height: the height feature' + status: unknown + '#inline-size': + title: '6.1.3 Inline-size: the inline-size feature' + status: unknown + '#block-size': + title: '6.1.4 Block-size: the block-size feature' + status: unknown + '#aspect-ratio': + title: '6.1.5 Aspect-ratio: the aspect-ratio feature' + status: unknown + '#orientation': + title: '6.1.6 Orientation: the orientation feature' + status: unknown + '#style-container': + title: 6.2 Style Container Features + status: unknown + '#scroll-state-container': + title: 6.3 Scroll State Container Features + status: unknown + '#updating-scroll-state': + title: 6.3.1 Updating Scroll State + status: unknown + '#stuck': + title: '6.3.2 Sticky positioning: the stuck feature' + status: unknown + '#snapped': + title: '6.3.3 Scroll snapping: the snapped feature' + status: unknown + '#scrollable': + title: '6.3.4 Scrollable: the scrollable feature' + status: unknown + '#scrolled': + title: '6.3.5 Scrolled: the scrolled feature' + status: unknown + '#container-lengths': + title: '7 Container Relative Lengths: the cqw, cqh, cqi, cqb, cqmin, cqmax units' + status: unknown + '#supports-condition-rule': + title: '8 Defining Custom Support Queries: the @supports-condition rule' + status: unknown + '#apis': + title: 9 APIs + status: unknown + '#the-csscontainerrule-interface': + title: 9.1 The CSSContainerRule interface + status: unknown + '#the-csssupportscondition-interface': + title: 9.2 The CSSSupportsConditionRule interface + status: unknown diff --git a/Spec/css-contain-2.yml b/Spec/css-contain-2.yml new file mode 100644 index 000000000..dda5457cd --- /dev/null +++ b/Spec/css-contain-2.yml @@ -0,0 +1,71 @@ +spec: css-contain-2 +title: CSS Containment Module Level 2 +url: https://www.w3.org/TR/css-contain-2/ +snapshot: https://www.w3.org/TR/2022/WD-css-contain-2-20220917/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#interaction': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#contain-property': + title: '2 Strong Containment: the contain property' + status: unknown + '#containment-types': + title: 3 Types of Containment + status: unknown + '#containment-size': + title: 3.1 Size Containment + status: unknown + '#containment-size-opt': + title: 3.1.1 Possible Size-Containment Optimizations + status: unknown + '#containment-layout': + title: 3.2 Layout Containment + status: unknown + '#containment-layout-opt': + title: 3.2.1 Possible Layout-Containment Optimizations + status: unknown + '#containment-style': + title: 3.3 Style Containment + status: unknown + '#containment-style-opt': + title: 3.3.1 Possible Style-Containment Optimizations + status: unknown + '#containment-paint': + title: 3.4 Paint Containment + status: unknown + '#containment-paint-opt': + title: 3.4.1 Possible Paint-Containment Optimizations + status: unknown + '#content-visibility': + title: '4 Suppressing An Element’s Contents Entirely: the content-visibility property' + status: unknown + '#using-cv-hidden': + title: '4.1 Using content-visibility: hidden' + status: unknown + '#using-cv-auto': + title: '4.2 Using content-visibility: auto' + status: unknown + '#content-visibility-auto-state-changed': + title: '4.3 Detecting content-visibility: auto state changes: the contentvisibilityautostatechanged event' + status: unknown + '#cv-notes': + title: 4.4 Restrictions and Clarifications + status: unknown + '#cv-a11y': + title: 4.5 Accessibility Implications + status: unknown + '#cv-examples': + title: 4.6 Examples + status: unknown + '#privacy': + title: 5 Privacy Considerations + status: unknown + '#security': + title: 6 Security Considerations + status: unknown diff --git a/Spec/css-counter-styles-3.yml b/Spec/css-counter-styles-3.yml new file mode 100644 index 000000000..5565262cb --- /dev/null +++ b/Spec/css-counter-styles-3.yml @@ -0,0 +1,116 @@ +spec: css-counter-styles-3 +title: CSS Counter Styles Level 3 +url: https://www.w3.org/TR/css-counter-styles-3/ +snapshot: https://www.w3.org/TR/2021/CR-css-counter-styles-3-20210727/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#counter-styles': + title: 2 Counter Styles + status: unknown + '#the-counter-style-rule': + title: '3 Defining Custom Counter Styles: the @counter-style rule' + status: unknown + '#counter-style-system': + title: '3.1 Counter algorithms: the system descriptor' + status: unknown + '#cyclic-system': + title: '3.1.1 Cycling Symbols: the cyclic system' + status: unknown + '#fixed-system': + title: '3.1.2 Exhaustible Symbols: the fixed system' + status: unknown + '#symbolic-system': + title: '3.1.3 Repeating Symbols: the symbolic system' + status: unknown + '#alphabetic-system': + title: '3.1.4 Bijective Numerals: the alphabetic system' + status: unknown + '#numeric-system': + title: '3.1.5 Positional Numerals: the numeric system' + status: unknown + '#additive-system': + title: '3.1.6 Accumulating Numerals: the additive system' + status: unknown + '#extends-system': + title: '3.1.7 Building from Existing Counter Styles: the extends system' + status: unknown + '#counter-style-negative': + title: '3.2 Formatting negative values: the negative descriptor' + status: unknown + '#counter-style-prefix': + title: '3.3 Symbols before the marker: the prefix descriptor' + status: unknown + '#counter-style-suffix': + title: '3.4 Symbols after the marker: the suffix descriptor' + status: unknown + '#counter-style-range': + title: '3.5 Limiting the counter scope: the range descriptor' + status: unknown + '#counter-style-pad': + title: '3.6 Zero-Padding and Constant-Width Representations: the pad descriptor' + status: unknown + '#counter-style-fallback': + title: '3.7 Defining fallback: the fallback descriptor' + status: unknown + '#counter-style-symbols': + title: '3.8 Marker characters: the symbols and additive-symbols descriptors' + status: unknown + '#counter-style-speak-as': + title: '3.9 Speech Synthesis: the speak-as descriptor' + status: unknown + '#symbols-function': + title: '4 Defining Anonymous Counter Styles: the symbols() function' + status: unknown + '#extending-css2': + title: 5 Extending list-style-type, counter(), and counters() + status: unknown + '#predefined-counters': + title: 6 Simple Predefined Counter Styles + status: unknown + '#simple-numeric': + title: '6.1 Numeric: decimal, decimal-leading-zero, arabic-indic, armenian, upper-armenian, lower-armenian, bengali, cambodian, khmer, cjk-decimal, devanagari, georgian, gujarati, gurmukhi, hebrew, kannada, lao, malayalam, mongolian, myanmar, oriya, persian, lower-roman, upper-roman, tamil, telugu, thai, tibetan' + status: unknown + '#simple-alphabetic': + title: '6.2 Alphabetic: lower-alpha, lower-latin, upper-alpha, upper-latin, lower-greek, hiragana, hiragana-iroha, katakana, katakana-iroha' + status: unknown + '#simple-symbolic': + title: '6.3 Symbolic: disc, circle, square, disclosure-open, disclosure-closed' + status: unknown + '#simple-fixed': + title: '6.4 Fixed: cjk-earthly-branch, cjk-heavenly-stem' + status: unknown + '#complex-predefined-counters': + title: 7 Complex Predefined Counter Styles + status: unknown + '#complex-cjk': + title: 7.1 Longhand East Asian Counter Styles + status: unknown + '#limited-japanese': + title: '7.1.1 Japanese: japanese-informal and japanese-formal' + status: unknown + '#limited-korean': + title: '7.1.2 Korean: korean-hangul-formal, korean-hanja-informal, and korean-hanja-formal' + status: unknown + '#limited-chinese': + title: '7.1.3 Chinese: simp-chinese-informal, simp-chinese-formal, trad-chinese-informal, and trad-chinese-formal' + status: unknown + '#ethiopic-numeric-counter-style': + title: '7.2 Ethiopic Numeric Counter Style: ethiopic-numeric' + status: unknown + '#additional-predefined': + title: 8 Additional “Ready-made” Counter Styles + status: unknown + '#apis': + title: 9 APIs + status: unknown + '#extensions-to-cssrule-interface': + title: 9.1 Extensions to the CSSRule interface + status: unknown + '#the-csscounterstylerule-interface': + title: 9.2 The CSSCounterStyleRule interface + status: unknown + '#ua-stylesheet': + title: 10 Sample style sheet for HTML + status: unknown diff --git a/Spec/css-display-4.yml b/Spec/css-display-4.yml new file mode 100644 index 000000000..047bcfc4c --- /dev/null +++ b/Spec/css-display-4.yml @@ -0,0 +1,62 @@ +spec: css-display-4 +title: CSS Display Module Level 4 +url: https://www.w3.org/TR/css-display-4/ +snapshot: https://www.w3.org/TR/2025/WD-css-display-4-20251106/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#the-display-properties': + title: '2 Box Layout Modes: the display property' + status: unknown + '#outer-role': + title: '2.1 Outer Display Roles for Flow Layout: the block, inline, and run-in keywords' + status: unknown + '#inner-model': + title: '2.2 Inner Display Layout Models: the flow, flow-root, table, flex, grid, and ruby keywords' + status: unknown + '#list-items': + title: '2.3 Generating Marker Boxes: the list-item keyword' + status: unknown + '#layout-specific-display': + title: '2.4 Layout-Internal Display Types: the table-* and ruby-* keywords' + status: unknown + '#box-generation': + title: '2.5 Box Generation: the none and contents keywords' + status: unknown + '#legacy-display': + title: 2.6 Precomposed Inline-level Display Values + status: unknown + '#transformations': + title: 2.7 Automatic Box Type Transformations + status: unknown + '#root': + title: 2.8 The Root Element’s Principal Box + status: unknown + '#display-animation': + title: 2.9 Animating and Interpolating display + status: unknown + '#order-property': + title: '3 Display Order: the order property' + status: unknown + '#order-accessibility': + title: 3.1 Reordering and Accessibility + status: unknown + '#reading-flow': + title: '4 Reading Order: the reading-flow property' + status: unknown + '#reading-order': + title: '4.1 Overriding Reading Flow: the reading-order property' + status: unknown + '#visibility': + title: '5 Invisibility: the visibility property' + status: unknown + '#run-in-layout': + title: 6 Run-In Layout + status: unknown diff --git a/Spec/css-easing-1.yml b/Spec/css-easing-1.yml new file mode 100644 index 000000000..991064eee --- /dev/null +++ b/Spec/css-easing-1.yml @@ -0,0 +1,41 @@ +spec: css-easing-1 +title: CSS Easing Functions Level 1 +url: https://www.w3.org/TR/css-easing-1/ +snapshot: https://www.w3.org/TR/2023/CRD-css-easing-1-20230213/ +sections: + '#introduction': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#easing-functions': + title: 2 Easing functions + status: unknown + '#the-linear-easing-function': + title: '2.1 The linear easing function: linear' + status: unknown + '#cubic-bezier-easing-functions': + title: '2.2 Cubic Bézier easing functions: ease, ease-in, ease-out, ease-in-out, cubic-bezier()' + status: unknown + '#cubic-bezier-algo': + title: 2.2.1 Output of a cubic bézier easing function + status: unknown + '#step-easing-functions': + title: '2.3 Step easing functions: step-start, step-end, steps()' + status: unknown + '#step-easing-algo': + title: 2.3.1 Output of a step easing function + status: unknown + '#serialization': + title: 2.4 Serialization + status: unknown + '#priv-sec': + title: 3 Privacy and Security Considerations + status: unknown + '#changes': + title: 4 Changes + status: unknown + '#acknowledgements': + title: 5 Acknowledgements + status: unknown diff --git a/Spec/css-env-1.yml b/Spec/css-env-1.yml new file mode 100644 index 000000000..4f43ebb67 --- /dev/null +++ b/Spec/css-env-1.yml @@ -0,0 +1,35 @@ +spec: css-env-1 +title: CSS Environment Variables Module Level 1 +url: https://www.w3.org/TR/css-env-1/ +snapshot: https://www.w3.org/TR/2025/WD-css-env-1-20250923/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#environment': + title: 2 Environment Variables + status: unknown + '#safe-area-insets': + title: 2.1 Safe area inset variables + status: unknown + '#safe-area-max-insets': + title: 2.2 Safe area maximum inset variables + status: unknown + '#viewport-segments': + title: 2.3 Viewport segment variables + status: unknown + '#text-zoom': + title: 2.4 Preferred Text Zoom + status: unknown + '#env-function': + title: '3 Using Environment Variables: the env() notation' + status: unknown + '#env-in-shorthands': + title: 3.1 Environment Variables in Shorthand Properties + status: unknown + '#priv': + title: 4 Privacy Considerations + status: unknown + '#sec': + title: 5 Security Considerations + status: unknown diff --git a/Spec/css-flexbox-1.yml b/Spec/css-flexbox-1.yml new file mode 100644 index 000000000..b2ab29769 --- /dev/null +++ b/Spec/css-flexbox-1.yml @@ -0,0 +1,152 @@ +spec: css-flexbox-1 +title: CSS Flexible Box Layout Module Level 1 +url: https://www.w3.org/TR/css-flexbox-1/ +snapshot: https://www.w3.org/TR/2025/CRD-css-flexbox-1-20251014/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#overview': + title: 1.1 Overview + status: unknown + '#placement': + title: 1.2 Module interactions + status: unknown + '#values': + title: 1.3 Value Definitions + status: unknown + '#box-model': + title: 2 Flex Layout Box Model and Terminology + status: unknown + '#flex-containers': + title: '3 Flex Containers: the flex and inline-flex display values' + status: unknown + '#flex-items': + title: 4 Flex Items + status: unknown + '#abspos-items': + title: 4.1 Absolutely-Positioned Flex Children + status: unknown + '#item-margins': + title: 4.2 Flex Item Margins and Paddings + status: unknown + '#painting': + title: 4.3 Flex Item Z-Ordering + status: unknown + '#visibility-collapse': + title: 4.4 Collapsed Items + status: unknown + '#min-size-auto': + title: 4.5 Automatic Minimum Size of Flex Items + status: unknown + '#flow-order': + title: 5 Ordering and Orientation + status: unknown + '#flex-direction-property': + title: '5.1 Flex Flow Direction: the flex-direction property' + status: unknown + '#flex-wrap-property': + title: '5.2 Flex Line Wrapping: the flex-wrap property' + status: unknown + '#flex-flow-property': + title: '5.3 Flex Direction and Wrap: the flex-flow shorthand' + status: unknown + '#order-property': + title: '5.4 Reordering and Accessibility: the order property' + status: unknown + '#flex-lines': + title: 6 Flex Lines + status: unknown + '#flexibility': + title: 7 Flexibility + status: unknown + '#flex-property': + title: 7.1 The flex Shorthand + status: unknown + '#flex-common': + title: 7.1.1 Basic Values of flex + status: unknown + '#flex-components': + title: 7.2 Components of Flexibility + status: unknown + '#flex-grow-property': + title: 7.2.1 The flex-grow property + status: unknown + '#flex-shrink-property': + title: 7.2.2 The flex-shrink property + status: unknown + '#flex-basis-property': + title: 7.2.3 The flex-basis property + status: unknown + '#alignment': + title: 8 Alignment + status: unknown + '#auto-margins': + title: 8.1 Aligning with auto margins + status: unknown + '#justify-content-property': + title: '8.2 Axis Alignment: the justify-content property' + status: unknown + '#align-items-property': + title: '8.3 Cross-axis Alignment: the align-items and align-self properties' + status: unknown + '#align-content-property': + title: '8.4 Packing Flex Lines: the align-content property' + status: unknown + '#flex-baselines': + title: 8.5 Flex Container Baselines + status: unknown + '#layout-algorithm': + title: 9 Flex Layout Algorithm + status: unknown + '#box-manip': + title: 9.1 Initial Setup + status: unknown + '#line-sizing': + title: 9.2 Line Length Determination + status: unknown + '#main-sizing': + title: 9.3 Main Size Determination + status: unknown + '#cross-sizing': + title: 9.4 Cross Size Determination + status: unknown + '#main-alignment': + title: 9.5 Main-Axis Alignment + status: unknown + '#cross-alignment': + title: 9.6 Cross-Axis Alignment + status: unknown + '#resolve-flexible-lengths': + title: 9.7 Resolving Flexible Lengths + status: unknown + '#definite-sizes': + title: 9.8 Definite and Indefinite Sizes + status: unknown + '#intrinsic-sizes': + title: 9.9 Intrinsic Sizes + status: unknown + '#intrinsic-main-sizes': + title: 9.9.1 Flex Container Intrinsic Main Sizes + status: unknown + '#intrinsic-main-sizes-ideal': + title: 9.9.1.1 Ideal Algorithm + status: unknown + '#intrinsic-main-sizes-compat': + title: 9.9.1.2 Web-compatible Intrinsic Sizing Algorithm + status: unknown + '#intrinsic-main-sizes-multiline': + title: 9.9.1.3 Multi-line Min-content Algorithm + status: unknown + '#intrinsic-cross-sizes': + title: 9.9.2 Flex Container Intrinsic Cross Sizes + status: unknown + '#intrinsic-item-contributions': + title: 9.9.3 Flex Item Intrinsic Size Contributions + status: unknown + '#pagination': + title: 10 Fragmenting Flex Layout + status: unknown + '#pagination-algo': + title: 10.1 Sample Flex Fragmentation Algorithm + status: unknown diff --git a/Spec/css-font-loading-3.yml b/Spec/css-font-loading-3.yml new file mode 100644 index 000000000..e2369cc98 --- /dev/null +++ b/Spec/css-font-loading-3.yml @@ -0,0 +1,59 @@ +spec: css-font-loading-3 +title: CSS Font Loading Module Level 3 +url: https://www.w3.org/TR/css-font-loading/ +snapshot: https://www.w3.org/TR/2023/WD-css-font-loading-3-20230406/ +sections: + '#introduction': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Values + status: unknown + '#task-source': + title: 1.2 Task Sources + status: unknown + '#fontface-interface': + title: 2 The FontFace Interface + status: unknown + '#font-face-constructor': + title: 2.1 The Constructor + status: unknown + '#font-face-load': + title: 2.2 The load() method + status: unknown + '#font-face-css-connection': + title: 2.3 Interaction with CSS’s @font-face Rule + status: unknown + '#discovery': + title: 2.4 Discovery of information about a font + status: unknown + '#FontFaceSet-interface': + title: 3 The FontFaceSet Interface + status: unknown + '#FontFaceSet-events': + title: 3.1 Events + status: unknown + '#font-face-set-load': + title: 3.2 The load() method + status: unknown + '#font-face-set-check': + title: 3.3 The check() method + status: unknown + '#font-face-set-ready': + title: 3.4 The ready attribute + status: unknown + '#font-face-set-css': + title: 3.5 Interaction with CSS Font Loading and Matching + status: unknown + '#font-face-source': + title: 4 The FontFaceSource Mixin + status: unknown + '#fontfacesource-workers': + title: 4.1 Worker FontFaceSources + status: unknown + '#document-font-face-set': + title: 4.2 Interaction with CSS’s @font-face Rule + status: unknown + '#font-load-event-examples': + title: 5 API Examples + status: unknown diff --git a/Spec/css-fonts-5.yml b/Spec/css-fonts-5.yml new file mode 100644 index 000000000..4a153c575 --- /dev/null +++ b/Spec/css-fonts-5.yml @@ -0,0 +1,116 @@ +spec: css-fonts-5 +title: CSS Fonts Module Level 5 +url: https://www.w3.org/TR/css-fonts-5/ +snapshot: https://www.w3.org/TR/2026/WD-css-fonts-5-20261104/ +sections: + '#introduction': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#text-scale-meta': + title: 2 Text-Scale meta element + status: unknown + '#text-scale-meta-keywords': + title: 2.1 Keywords + status: unknown + '#legacy-keyword': + title: 2.2 The legacy keyword + status: unknown + '#scale-keyword': + title: 2.3 The scale keyword + status: unknown + '#basic-font-props': + title: 3 Basic Font Properties + status: unknown + '#font-family-prop': + title: '3.1 Font family: the font-family property' + status: unknown + '#generic-font-families': + title: 3.1.1 Generic font families + status: unknown + '#font-weight-prop': + title: '3.2 Font weight: the font-weight property' + status: unknown + '#font-style-prop': + title: '3.3 Font style: the font-style property' + status: unknown + '#font-size-adjust-prop': + title: '3.4 Relative sizing: the font-size-adjust property' + status: unknown + '#font-resources': + title: 4 Font Resources + status: unknown + '#font-face-rule': + title: 4.1 The @font-face rule + status: unknown + '#src-desc': + title: '4.2 Font reference: the src descriptor' + status: unknown + '#font-face-src-parsing': + title: 4.2.1 Parsing the src descriptor + status: unknown + '#font-size-desc': + title: '4.3 Font property descriptors: the font-size' + status: unknown + '#size-adjust-desc': + title: '4.4 Glyph Size Multiplier: the size-adjust descriptor' + status: unknown + '#font-metrics-override-desc': + title: '4.5 Line Height Font Metrics Overrides: the ascent-override, descent-override, and line-gap-override descriptors' + status: unknown + '#font-sup-sub-override-desc': + title: '4.6 Superscript and subscript metrics overrides: the superscript-position-override, subscript-position-override,superscript-size-override and subscript-size-override descriptors' + status: unknown + '#font-rend-props': + title: 5 Font Feature Properties + status: unknown + '#font-language-override-prop': + title: '5.1 Font language override: the font-language-override property' + status: unknown + '#font-feature-variation-resolution': + title: 6 Font Feature and Variation Resolution + status: unknown + '#font-variation-props': + title: 7 Font Variation Properties + status: unknown + '#font-optical-sizing-def': + title: '7.1 Optical sizing control: the font-optical-sizing property' + status: unknown + '#font-technologies-formats': + title: 8 Font Technologies and Formats + status: unknown + '#font-tech-definitions': + title: 8.1 Font tech + status: unknown + '#object-model': + title: 9 Object Model + status: unknown + '#om-fontface': + title: 9.1 The CSSFontFaceRule interface + status: unknown + '#security': + title: 10 Security Considerations + status: unknown + '#privacy': + title: 11 Privacy Considerations + status: unknown + '#acknowledgments': + title: 12 Acknowledgments + status: unknown + '#changes': + title: 13 Changes + status: unknown + '#changes-20260206': + title: 13.1 Changes since the WD of 6 February 2024 + status: unknown + '#changes-20211221': + title: 13.2 Changes since the WD of 21 December 2021 + status: unknown + '#changes-20210729': + title: 13.3 Changes since the WD of 2021-07-29 + status: unknown + '#changes-20210629': + title: 13.4 Changes since the FPWD of 2021-06-29 + status: unknown diff --git a/Spec/css-gcpm-3.yml b/Spec/css-gcpm-3.yml new file mode 100644 index 000000000..76ab9f725 --- /dev/null +++ b/Spec/css-gcpm-3.yml @@ -0,0 +1,89 @@ +spec: css-gcpm-3 +title: CSS Generated Content for Paged Media Module +url: https://www.w3.org/TR/css-gcpm-3/ +snapshot: https://www.w3.org/TR/2024/WD-css-gcpm-3-20240125/ +sections: + '#running-headers-and-footers': + title: 1 Running headers and footers + status: unknown + '#named-strings': + title: 1.1 Named strings + status: unknown + '#setting-named-strings-the-string-set-pro': + title: 1.1.1 The string-set property + status: unknown + '#content-function-header': + title: 1.1.1.1 The content() function + status: unknown + '#using-named-strings': + title: 1.1.2 The string() function + status: unknown + '#running-elements': + title: 1.2 Running elements + status: unknown + '#running-syntax': + title: 1.2.1 The running() value + status: unknown + '#element-syntax': + title: 1.2.2 The element() value + status: unknown + '#footnotes': + title: 2 Footnotes + status: unknown + '#footnote-terms': + title: 2.1 Terminology + status: unknown + '#creating-footnotes': + title: 2.2 Creating footnotes + status: unknown + '#footnote-types': + title: 2.3 Types of footnotes + status: unknown + '#footnote-area': + title: 2.4 The footnote area + status: unknown + '#footnote-area-position': + title: 2.4.1 Positioning of the footnote area + status: unknown + '#footnote-area-size': + title: 2.4.2 Size of the footnote area + status: unknown + '#footnote-counters': + title: 2.5 The Footnote Counter + status: unknown + '#footnote-counter-values': + title: 2.5.1 Values of the footnote counter + status: unknown + '#resetting-footnote-counter': + title: 2.5.2 Resetting the footnote counter + status: unknown + '#footnote-call': + title: 2.6 The footnote-call pseudo-element + status: unknown + '#footnote-marker': + title: 2.7 The footnote-marker pseudo-element + status: unknown + '#footnote-policy': + title: 2.8 Rendering footnotes and footnote policy + status: unknown + '#future': + title: 2.9 Future directions + status: unknown + '#the-first-page-pseudo-element': + title: 3 Selecting Pages + status: unknown + '#document-page-selectors': + title: 3.1 Page Selectors + status: unknown + '#document-sequence-selectors': + title: 3.2 Page groups + status: unknown + '#leaders': + title: 4 Leaders (moved) + status: unknown + '#cross-references': + title: 5 Cross-references (moved) + status: unknown + '#bookmarks': + title: 6 Bookmarks (moved) + status: unknown diff --git a/Spec/css-grid-2.yml b/Spec/css-grid-2.yml new file mode 100644 index 000000000..277cae5c8 --- /dev/null +++ b/Spec/css-grid-2.yml @@ -0,0 +1,281 @@ +spec: css-grid-2 +title: CSS Grid Layout Module Level 2 +url: https://www.w3.org/TR/css-grid-2/ +snapshot: https://www.w3.org/TR/2025/CRD-css-grid-2-20250326/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#background': + title: 1.1 Background and Motivation + status: unknown + '#adapting-to-available-space': + title: 1.1.1 Adapting Layouts to Available Space + status: unknown + '#source-independence': + title: 1.1.2 Source-Order Independence + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#overview': + title: 2 Overview + status: unknown + '#overview-grid': + title: 2.1 Declaring the Grid + status: unknown + '#overview-placement': + title: 2.2 Placing Items + status: unknown + '#overview-sizing': + title: 2.3 Sizing the Grid + status: unknown + '#grid-concepts': + title: 3 Grid Layout Concepts and Terminology + status: unknown + '#grid-line-concept': + title: 3.1 Grid Lines + status: unknown + '#grid-track-concept': + title: 3.2 Grid Tracks and Cells + status: unknown + '#grid-area-concept': + title: 3.3 Grid Areas + status: unknown + '#subgrid-items': + title: 3.4 Nested vs. Subgridded Items + status: unknown + '#order-accessibility': + title: 4 Reordering and Accessibility + status: unknown + '#grid-model': + title: 5 Grid Containers + status: unknown + '#grid-containers': + title: '5.1 Establishing Grid Containers: the grid and inline-grid display values' + status: unknown + '#intrinsic-sizes': + title: 5.2 Sizing Grid Containers + status: unknown + '#overflow': + title: 5.3 Scrollable Grid Overflow + status: unknown + '#overlarge-grids': + title: 5.4 Limiting Large Grids + status: unknown + '#grid-items': + title: 6 Grid Items + status: unknown + '#grid-item-display': + title: 6.1 Grid Item Display + status: unknown + '#grid-item-sizing': + title: 6.2 Grid Item Sizing + status: unknown + '#order-property': + title: '6.3 Reordered Grid Items: the order property' + status: unknown + '#item-margins': + title: 6.4 Grid Item Margins and Paddings + status: unknown + '#z-order': + title: '6.5 Z-axis Ordering: the z-index property' + status: unknown + '#min-size-auto': + title: 6.6 Automatic Minimum Size of Grid Items + status: unknown + '#grid-definition': + title: 7 Defining the Grid + status: unknown + '#explicit-grids': + title: 7.1 The Explicit Grid + status: unknown + '#track-sizing': + title: '7.2 Explicit Track Sizing: the grid-template-rows and grid-template-columns properties' + status: unknown + '#track-sizes': + title: 7.2.1 Track Sizes + status: unknown + '#named-lines': + title: '7.2.2 Naming Grid Lines: the [<custom-ident>*] syntax' + status: unknown + '#repeat-notation': + title: '7.2.3 Repeating Rows and Columns: the repeat() notation' + status: unknown + '#repeat-syntax': + title: 7.2.3.1 Syntax of repeat() + status: unknown + '#auto-repeat': + title: '7.2.3.2 Repeat-to-fill: auto-fill and auto-fit repetitions' + status: unknown + '#repeat-interpolation': + title: 7.2.3.3 Interpolation/Combination of repeat() + status: unknown + '#fr-unit': + title: '7.2.4 Flexible Lengths: the fr unit' + status: unknown + '#computed-tracks': + title: 7.2.5 Computed Value of a Track Listing + status: unknown + '#resolved-track-list': + title: 7.2.6 Resolved Value of a Track Listing + status: unknown + '#resolved-track-list-standalone': + title: 7.2.6.1 Resolved Value of a Standalone Track Listing + status: unknown + '#resolved-track-list-subgrid': + title: 7.2.6.2 Resolved Value of a Subgridded Track Listing + status: unknown + '#grid-template-areas-property': + title: '7.3 Named Areas: the grid-template-areas property' + status: unknown + '#serialize-template': + title: 7.3.1 Serialization Of Template Strings + status: unknown + '#implicit-named-lines': + title: 7.3.2 Implicitly-Assigned Line Names + status: unknown + '#implicit-named-areas': + title: 7.3.3 Implicitly-Named Areas + status: unknown + '#explicit-grid-shorthand': + title: '7.4 Explicit Grid Shorthand: the grid-template property' + status: unknown + '#implicit-grids': + title: 7.5 The Implicit Grid + status: unknown + '#auto-tracks': + title: '7.6 Implicit Track Sizing: the grid-auto-rows and grid-auto-columns properties' + status: unknown + '#grid-auto-flow-property': + title: '7.7 Automatic Placement: the grid-auto-flow property' + status: unknown + '#grid-shorthand': + title: '7.8 Grid Definition Shorthand: the grid property' + status: unknown + '#placement': + title: 8 Placing Grid Items + status: unknown + '#common-uses': + title: 8.1 Common Patterns for Grid Placement + status: unknown + '#common-uses-named-areas': + title: 8.1.1 Named Areas + status: unknown + '#common-uses-numeric': + title: 8.1.2 Numeric Indexes and Spans + status: unknown + '#common-uses-named-lines': + title: 8.1.3 Named Lines and Spans + status: unknown + '#common-uses-auto-placement': + title: 8.1.4 Auto Placement + status: unknown + '#placement-a11y': + title: 8.2 Grid Item Placement vs. Source Order + status: unknown + '#line-placement': + title: '8.3 Line-based Placement: the grid-row-start, grid-column-start, grid-row-end, and grid-column-end properties' + status: unknown + '#grid-placement-errors': + title: 8.3.1 Grid Placement Conflict Handling + status: unknown + '#placement-shorthands': + title: '8.4 Placement Shorthands: the grid-column, grid-row, and grid-area properties' + status: unknown + '#auto-placement-algo': + title: 8.5 Grid Item Placement Algorithm + status: unknown + '#subgrids': + title: 9 Subgrids + status: unknown + '#abspos': + title: 10 Absolute Positioning + status: unknown + '#abspos-items': + title: 10.1 With a Grid Container as Containing Block + status: unknown + '#static-position': + title: 10.2 With a Grid Container as Parent + status: unknown + '#alignment': + title: 11 Alignment and Spacing + status: unknown + '#gutters': + title: '11.1 Gutters: the row-gap, column-gap, and gap properties' + status: unknown + '#auto-margins': + title: 11.2 Aligning with auto margins + status: unknown + '#row-align': + title: '11.3 Inline-axis Alignment: the justify-self and justify-items properties' + status: unknown + '#column-align': + title: '11.4 Block-axis Alignment: the align-self and align-items properties' + status: unknown + '#grid-align': + title: '11.5 Aligning the Grid: the justify-content and align-content properties' + status: unknown + '#grid-baselines': + title: 11.6 Grid Container Baselines + status: unknown + '#layout-algorithm': + title: 12 Grid Layout Algorithm + status: unknown + '#algo-grid-sizing': + title: 12.1 Grid Sizing Algorithm + status: unknown + '#algo-terms': + title: 12.2 Track Sizing Terminology + status: unknown + '#algo-track-sizing': + title: 12.3 Track Sizing Algorithm + status: unknown + '#algo-init': + title: 12.4 Initialize Track Sizes + status: unknown + '#algo-content': + title: 12.5 Resolve Intrinsic Track Sizes + status: unknown + '#extra-space': + title: 12.5.1 Distributing Extra Space Across Spanned Tracks + status: unknown + '#algo-grow-tracks': + title: 12.6 Maximize Tracks + status: unknown + '#algo-flex-tracks': + title: 12.7 Expand Flexible Tracks + status: unknown + '#algo-find-fr-size': + title: 12.7.1 Find the Size of an fr + status: unknown + '#algo-stretch': + title: 12.8 Stretch auto Tracks + status: unknown + '#pagination': + title: 13 Fragmenting Grid Layout + status: unknown + '#fragmentation-alg': + title: 13.1 Sample Fragmentation Algorithm + status: unknown + '#privacy': + title: 14 Privacy Considerations + status: unknown + '#security': + title: 15 Security Considerations + status: unknown + '#changes': + title: 16 Changes + status: unknown + '#changes-202012': + title: 16.1 Changes since the 18 December 2020 CR + status: unknown + '#changes-202008': + title: 16.2 Changes since the August 2020 CR + status: unknown + '#changes-2': + title: 16.3 Additions Since Level 1 + status: unknown + '#acknowledgements': + title: 17 Acknowledgements + status: unknown diff --git a/Spec/css-images-4.yml b/Spec/css-images-4.yml new file mode 100644 index 000000000..699e77d46 --- /dev/null +++ b/Spec/css-images-4.yml @@ -0,0 +1,152 @@ +spec: css-images-4 +title: CSS Images Module Level 4 +url: https://www.w3.org/TR/css-images-4/ +snapshot: https://www.w3.org/TR/2025/WD-css-images-4-20250930/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#image-values': + title: '2 2D Image Values: the <image> type' + status: unknown + '#image-file-formats': + title: 2.1 Image File Formats + status: unknown + '#url-notation': + title: '2.2 Image References: the url() notation' + status: unknown + '#fetching-images': + title: 2.3 Fetching External Images + status: unknown + '#image-set-notation': + title: '2.4 Resolution/Type Negotiation: the image-set() notation' + status: unknown + '#image-notation': + title: '2.5 Image Fallbacks and Annotations: the image() notation' + status: unknown + '#image-fallbacks': + title: 2.5.1 Image Fallbacks + status: unknown + '#image-fragments': + title: 2.5.2 Image Fragments + status: unknown + '#color-images': + title: 2.5.3 Solid-color Images + status: unknown + '#bidi-images': + title: 2.5.4 Bidi-sensitive Images + status: unknown + '#cross-fade-function': + title: '2.6 Combining images: the cross-fade() notation' + status: unknown + '#cross-fade-sizing': + title: 2.6.1 cross-fade() Sizing + status: unknown + '#cross-fade-painting': + title: 2.6.2 cross-fade() Painting + status: unknown + '#cross-fade-complex': + title: 2.6.3 Simplifying Complex cross-fade() + status: unknown + '#element-notation': + title: '2.7 Using Elements as Images: the element() notation' + status: unknown + '#paint-sources': + title: 2.7.1 Paint Sources + status: unknown + '#elementsources': + title: '2.7.2 Using Out-Of-Document Sources: the ElementSources interface' + status: unknown + '#element-cycles': + title: 2.7.3 Cycle Detection + status: unknown + '#gradients': + title: 3 Gradients + status: unknown + '#linear-gradients': + title: '3.1 Linear Gradients: the linear-gradient() notation' + status: unknown + '#color-interpolation': + title: '3.1.1 Effects of color space on interpolation: examples' + status: unknown + '#radial-gradients': + title: '3.2 Radial Gradients: the radial-gradient() notation' + status: unknown + '#radial-color-interpolation': + title: 3.2.1 Adding <color-interpolation-method> + status: unknown + '#radial-size': + title: 3.2.2 Expanding <radial-size> + status: unknown + '#conic-gradients': + title: '3.3 Conic Gradients: the conic-gradient() notation' + status: unknown + '#conic-gradient-syntax': + title: 3.3.1 conic-gradient() Syntax + status: unknown + '#conic-color-stops': + title: 3.3.2 Placing Color Stops + status: unknown + '#conic-gradient-examples': + title: 3.3.3 Conic Gradient Examples + status: unknown + '#repeating-gradients': + title: '3.4 Repeating Gradients: the repeating-linear-gradient(), repeating-radial-gradient(), and repeating-conic-gradient() notations' + status: unknown + '#gradient-colors': + title: 3.5 Defining Gradient Color + status: unknown + '#color-stop-syntax': + title: 3.5.1 Color Stop Lists + status: unknown + '#coloring-gradient-line': + title: 3.5.2 Coloring the Gradient Line + status: unknown + '#color-stop-fixup': + title: 3.5.3 Color Stop “Fixup” + status: unknown + '#stripes': + title: '4 1D Image Values: the <image-1D> type and stripes() notation' + status: unknown + '#sizing': + title: 5 Sizing Images and Objects in CSS + status: unknown + '#the-object-fit': + title: '5.1 Sizing Objects: the object-fit property' + status: unknown + '#image-processing': + title: 6 Image Processing + status: unknown + '#the-image-resolution': + title: '6.1 Overriding Image Resolutions: the image-resolution property' + status: unknown + '#interpolation': + title: 7 Interpolation + status: unknown + '#interpolating-images': + title: 7.1 Interpolating <image> + status: unknown + '#interpolating-image-combinations': + title: 7.2 Interpolating cross-fade() + status: unknown + '#interpolating-gradients': + title: 7.3 Interpolating <gradient> + status: unknown + '#interpolating-stripes': + title: 7.4 Interpolating stripes() + status: unknown + '#serialization': + title: 8 Serialization + status: unknown + '#privacy': + title: 9 Privacy Considerations + status: unknown + '#security': + title: 10 Security Considerations + status: unknown + '#changes': + title: 11 Changes + status: unknown diff --git a/Spec/css-inline-3.yml b/Spec/css-inline-3.yml new file mode 100644 index 000000000..0870cf5a0 --- /dev/null +++ b/Spec/css-inline-3.yml @@ -0,0 +1,191 @@ +spec: css-inline-3 +title: CSS Inline Layout Module Level 3 +url: https://www.w3.org/TR/css-inline-3/ +snapshot: https://www.w3.org/TR/2024/WD-css-inline-3-20241218/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#model': + title: 2 Inline Layout Model + status: unknown + '#line-boxes': + title: 2.1 Layout of Line Boxes + status: unknown + '#line-layout': + title: 2.2 Layout Within Line Boxes + status: unknown + '#invisible-line-boxes': + title: 2.3 Phantom Line Boxes + status: unknown + '#paint-order': + title: 2.4 Painting Order + status: unknown + '#css-metrics': + title: 3 Baselines and Alignment Metrics + status: unknown + '#baseline-intro': + title: 3.1 Introduction to Baselines + status: unknown + '#baseline-types': + title: 3.2 Baselines and Metrics + status: unknown + '#ascent-descent': + title: 3.2.1 Ascent and Descent Metrics + status: unknown + '#font-line-gap': + title: 3.2.2 Line Gap Metrics + status: unknown + '#baseline-tables': + title: 3.3 Baselines of Glyphs and Boxes + status: unknown + '#alignment': + title: 4 Baseline Alignment + status: unknown + '#dominant-baseline-property': + title: '4.1 Dominant Baselines: the dominant-baseline property' + status: unknown + '#transverse-alignment': + title: '4.2 Transverse Box Alignment: the vertical-align property' + status: unknown + '#baseline-source': + title: '4.2.1 Alignment Baseline Source: the baseline-source longhand' + status: unknown + '#alignment-baseline-property': + title: '4.2.2 Alignment Baseline Type: the alignment-baseline longhand' + status: unknown + '#alignment-baseline-svg-legacy': + title: 4.2.2.1 Legacy Values for SVG + status: unknown + '#baseline-shift-property': + title: '4.2.3 Post-Alignment Shift: the baseline-shift longhand' + status: unknown + '#baseline-shift-svg-legacy': + title: 4.2.3.1 Legacy Values for SVG + status: unknown + '#line-height': + title: 5 Logical Heights and Inter-line Spacing + status: unknown + '#line-height-property': + title: '5.1 Line Spacing: the line-height property' + status: unknown + '#text-edges': + title: '5.2 Text Edge Metrics: the line-fit-edge property' + status: unknown + '#inline-height': + title: 5.3 Calculating the Logical Height Contributions (“Layout Bounds”) of Inline Boxes + status: unknown + '#leading-trim': + title: 6 Trimming Leading Over/Under Text + status: unknown + '#text-box-shorthand': + title: '6.1 Shorthand for Text Box Trimming: the text-box property' + status: unknown + '#text-box-trim': + title: '6.2 Trimming Over/Under Text: the text-box-trim property' + status: unknown + '#text-box-edge': + title: '6.3 Text Trimming Metrics: the text-box-edge property' + status: unknown + '#line-fill': + title: '6.4 Inline Box Drawing Height: the inline-sizing property' + status: unknown + '#initial-letter-styling': + title: 7 Initial Letters + status: unknown + '#initial-letter-intro': + title: 7.1 An Introduction to Initial Letters + status: unknown + '#drop-initial': + title: 7.1.1 Drop Initial + status: unknown + '#sunk-initial': + title: 7.1.2 Sunken Initial Letters + status: unknown + '#raise-initial': + title: 7.1.3 Raised Initial Letters + status: unknown + '#selecting-drop-initials': + title: 7.2 Selecting Initial Letters + status: unknown + '#sizing-drop-initials': + title: '7.3 Creating Initial Letters: the initial-letter property' + status: unknown + '#first-most-inline-level': + title: 7.3.1 Applicability + status: unknown + '#aligning-initial-letter': + title: '7.4 Alignment of Initial Letters: the initial-letter-align property' + status: unknown + '#initial-letter-align-defaults': + title: 7.4.1 UA Default Stylesheet for initial-letter-align + status: unknown + '#initial-letter-layout': + title: 7.5 Initial Letter Layout + status: unknown + '#initial-letter-properties': + title: 7.5.1 Properties Applying to Initial Letters + status: unknown + '#initial-letter-box': + title: 7.5.2 Margins, Borders, and Padding + status: unknown + '#sizing-initial-letter': + title: 7.5.3 Font Sizing of Initial Letters + status: unknown + '#initial-letter-shaping': + title: 7.5.4 Shaping and Glyph Selection + status: unknown + '#initial-letter-box-size': + title: 7.5.5 Sizing the Initial Letter Box + status: unknown + '#initial-letter-content-align': + title: 7.5.6 Alignment Within an Initial Letter Box + status: unknown + '#initial-letter-position': + title: 7.6 Initial Letter Positioning and Spacing + status: unknown + '#initial-letter-block-position': + title: 7.6.1 Block-axis Positioning + status: unknown + '#initial-letter-inline-position': + title: 7.6.2 Inline Kerning + status: unknown + '#initial-letter-wrapping': + title: '7.7 Initial Letter Wrapping: the initial-letter-wrap property' + status: unknown + '#initial-letter-line-layout': + title: 7.8 Line Layout + status: unknown + '#initial-letter-inline-flow': + title: '7.8.1 Inline Flow Layout: Alignment, Justification, and White Space' + status: unknown + '#initial-letter-indentation': + title: '7.8.2 Edge Effects: Indentation and Hanging Punctuation' + status: unknown + '#initial-letter-ancestors': + title: 7.8.3 Ancestor Inlines + status: unknown + '#initial-letter-multi-line': + title: 7.8.4 Multi-line Initial Letters + status: unknown + '#initial-letter-paragraphs': + title: 7.9 Clearing Initial Letters + status: unknown + '#raised-sunken-caps': + title: 7.9.1 Raised and sunken caps + status: unknown + '#short-para-initial-letter': + title: 7.9.2 Short paragraphs with initial letters + status: unknown + '#initial-letter-floats': + title: 7.9.3 Interaction with floats + status: unknown + '#initial-letter-breaking': + title: 7.9.4 Interaction with Fragmentation (Pagination) + status: unknown diff --git a/Spec/css-logical-1.yml b/Spec/css-logical-1.yml new file mode 100644 index 000000000..8d5e6136b --- /dev/null +++ b/Spec/css-logical-1.yml @@ -0,0 +1,68 @@ +spec: css-logical-1 +title: CSS Logical Properties and Values Module Level 1 +url: https://www.w3.org/TR/css-logical-1/ +snapshot: https://www.w3.org/TR/2025/WD-css-logical-1-20251204/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#directional-keywords': + title: '2 Flow-Relative Values: block-start, block-end, inline-start, inline-end' + status: unknown + '#caption-side': + title: 2.1 Logical Values for the caption-side Property + status: unknown + '#float-clear': + title: 2.2 Flow-Relative Values for the float and clear Properties + status: unknown + '#text-align': + title: 2.3 Flow-Relative Values for the text-align Property + status: unknown + '#page': + title: 3 Flow-Relative Page Classifications + status: unknown + '#box': + title: 4 Flow-Relative Box Model Properties + status: unknown + '#dimension-properties': + title: '4.1 Logical Height and Logical Width: the block-size/inline-size, min-block-size/min-inline-size, and max-block-size/max-inline-size properties' + status: unknown + '#margin-properties': + title: '4.2 Flow-Relative Margins: the margin-block-start, margin-block-end, margin-inline-start, margin-inline-end properties and margin-block and margin-inline shorthands' + status: unknown + '#position-properties': + title: '4.3 Flow-Relative Offsets: the inset-block-start, inset-block-end, inset-inline-start, inset-inline-end properties and inset-block, inset-inline, and inset shorthands' + status: unknown + '#padding-properties': + title: '4.4 Flow-Relative Padding: the padding-block-start, padding-block-end, padding-inline-start, padding-inline-end properties and padding-block and padding-inline shorthands' + status: unknown + '#border-properties': + title: 4.5 Flow-Relative Borders + status: unknown + '#border-width': + title: '4.5.1 Flow-Relative Border Widths: the border-block-start-width, border-block-end-width, border-inline-start-width, border-inline-end-width properties and border-block-width and border-inline-width shorthands' + status: unknown + '#border-style': + title: '4.5.2 Flow-Relative Border Styles: the border-block-start-style, border-block-end-style, border-inline-start-style, border-inline-end-style properties and border-block-style and border-inline-style shorthands' + status: unknown + '#border-color': + title: '4.5.3 Flow-Relative Border Colors: the border-block-start-color, border-block-end-color, border-inline-start-color, border-inline-end-color properties and border-block-color and border-inline-color shorthands' + status: unknown + '#border-shorthands': + title: '4.5.4 Flow-Relative Border Shorthands: the border-block-start, border-block-end, border-inline-start, border-inline-end properties and border-block and border-inline shorthands' + status: unknown + '#border-radius-properties': + title: '4.6 Flow-Relative Corner Rounding: the border-start-start-radius, border-start-end-radius, border-end-start-radius, border-end-end-radius properties' + status: unknown + '#logical-shorthand-keyword': + title: '4.7 Four-Directional Shorthand Properties: the margin, padding, border-width, border-style, and border-color shorthands' + status: unknown + '#acknowledgements': + title: 5 Acknowledgements + status: unknown + '#changes': + title: 6 Changes + status: unknown diff --git a/Spec/css-masking-1.yml b/Spec/css-masking-1.yml new file mode 100644 index 000000000..5f4658488 --- /dev/null +++ b/Spec/css-masking-1.yml @@ -0,0 +1,116 @@ +spec: css-masking-1 +title: CSS Masking Module Level 1 +url: https://www.w3.org/TR/css-masking-1/ +snapshot: https://www.w3.org/TR/2021/CRD-css-masking-1-20210805/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#clipping': + title: 1.1 Clipping + status: unknown + '#masking': + title: 1.2 Masking + status: unknown + '#placement': + title: 2 Module interactions + status: unknown + '#values': + title: 3 Values + status: unknown + '#terminology': + title: 4 Terminology + status: unknown + '#clipping-paths': + title: 5 Clipping Paths + status: unknown + '#the-clip-path': + title: '5.1 Clipping Shape: the clip-path property' + status: unknown + '#svg-clipping-paths': + title: 6 SVG Clipping Path Sources + status: unknown + '#ClipPathElement': + title: 6.1 The clipPath element + status: unknown + '#the-clip-rule': + title: '6.2 Winding Rules: the clip-rule property' + status: unknown + '#positioned-masks': + title: 7 Positioned Masks + status: unknown + '#the-mask-image': + title: '7.1 Mask Image Source: the mask-image property' + status: unknown + '#the-mask-mode': + title: '7.2 Mask Image Interpretation: the mask-mode property' + status: unknown + '#the-mask-repeat': + title: '7.3 Tiling Mask Images: the mask-repeat property' + status: unknown + '#the-mask-position': + title: '7.4 Positioning Mask Images: the mask-position property' + status: unknown + '#the-mask-clip': + title: '7.5 Masking Area: the mask-clip property' + status: unknown + '#the-mask-origin': + title: '7.6 Positioning Area: the mask-origin property' + status: unknown + '#the-mask-size': + title: '7.7 Sizing Mask Images: the mask-size property' + status: unknown + '#the-mask-composite': + title: '7.8 Compositing mask layers: the mask-composite property' + status: unknown + '#the-mask': + title: '7.9 Mask Shorthand: the mask property' + status: unknown + '#the-mask-image-rendering-model': + title: 7.10 The Mask Image Rendering Model + status: unknown + '#MaskValues': + title: 7.10.1 Mask processing + status: unknown + '#layering': + title: 7.10.2 Layering Multiple Mask Images + status: unknown + '#mask-borders': + title: 8 Border-Box Mask + status: unknown + '#the-mask-border-source': + title: '8.1 Mask Border Image Source: the mask-border-source property' + status: unknown + '#the-mask-border-mode': + title: '8.2 Mask Border Image Interpretation: the mask-border-mode property' + status: unknown + '#the-mask-border-slice': + title: '8.3 Mask Border Image Slicing: the mask-border-slice property' + status: unknown + '#the-mask-border-width': + title: '8.4 Masking Areas: the mask-border-width property' + status: unknown + '#the-mask-border-outset': + title: '8.5 Edge Overhang: the mask-border-outset property' + status: unknown + '#the-mask-border-repeat': + title: '8.6 Mask Border Image Tiling: the mask-border-repeat property' + status: unknown + '#the-mask-border': + title: '8.7 Mask Border Image Shorthand: the mask-border property' + status: unknown + '#masking-with-the-mask-border-image': + title: 8.8 Masking with the mask border image + status: unknown + '#svg-masks': + title: 9 SVG Mask Sources + status: unknown + '#MaskElement': + title: 9.1 The mask element + status: unknown + '#the-mask-type': + title: '9.2 Mask Source Interpretation: the mask-type property' + status: unknown + '#priv-sec': + title: 10 Privacy and Security Considerations + status: unknown diff --git a/Spec/css-multicol-1.yml b/Spec/css-multicol-1.yml new file mode 100644 index 000000000..8afe012a6 --- /dev/null +++ b/Spec/css-multicol-1.yml @@ -0,0 +1,77 @@ +spec: css-multicol-1 +title: CSS Multi-column Layout Module Level 1 +url: https://www.w3.org/TR/css-multicol-1/ +snapshot: https://www.w3.org/TR/2024/CR-css-multicol-1-20240516/ +sections: + '#introduction': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#the-multi-column-model': + title: 2 The Multi-Column Model + status: unknown + '#the-number-and-width-of-columns': + title: 3 The Number and Width of Columns + status: unknown + '#cw': + title: '3.1 The Inline Size of Columns: the column-width property' + status: unknown + '#cc': + title: '3.2 The Number of Columns: the column-count property' + status: unknown + '#columns': + title: '3.3 The column-width and column-count Shorthand: The columns Property' + status: unknown + '#pseudo-algorithm': + title: 3.4 The Pseudo-algorithm + status: unknown + '#stacking-context': + title: 3.5 Stacking Context + status: unknown + '#column-gaps-and-rules': + title: 4 Column Gaps and Rules + status: unknown + '#cg': + title: '4.1 Gutters Between Columns: the column-gap property' + status: unknown + '#crc': + title: '4.2 The Color of Column Rules: the column-rule-color property' + status: unknown + '#crs': + title: '4.3 The Style Of Column Rules: the column-rule-style property' + status: unknown + '#crw': + title: '4.4 The Width Of Column Rules: the column-rule-width property' + status: unknown + '#cr': + title: '4.5 Column Rule Shorthand: the column-rule property' + status: unknown + '#column-breaks': + title: 5 Column Breaks + status: unknown + '#break-before-break-after-break-inside': + title: '5.1 Controlling Fragmentation: the break-before, break-after, break-inside properties' + status: unknown + '#spanning-columns': + title: 6 Spanning Columns + status: unknown + '#column-span': + title: '6.1 Spanning An Element Across Columns: the column-span property' + status: unknown + '#filling-columns': + title: 7 Filling Columns + status: unknown + '#cf': + title: '7.1 Column Balancing: the column-fill property' + status: unknown + '#overflow': + title: 8 Overflow + status: unknown + '#overflow-inside-multicol-elements': + title: 8.1 Overflow Inside Multicol Containers + status: unknown + '#pagination-and-overflow-outside-multicol': + title: 8.2 Pagination and Overflow Outside Multicol Containers + status: unknown diff --git a/Spec/css-position-4.yml b/Spec/css-position-4.yml new file mode 100644 index 000000000..282d1126e --- /dev/null +++ b/Spec/css-position-4.yml @@ -0,0 +1,32 @@ +spec: css-position-4 +title: CSS Positioned Layout Module Level 4 +url: https://www.w3.org/TR/css-position-4/ +snapshot: https://www.w3.org/TR/2025/WD-css-position-4-20251007/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#scrollable-cb': + title: 2 Scrollable Containing Block + status: unknown + '#top-layer': + title: 3 Top Layer + status: unknown + '#top-styling': + title: 3.1 Top Layer Styling + status: unknown + '#backdrop': + title: 3.2 The ::backdrop Pseudo-Element + status: unknown + '#top-manip': + title: 3.3 Top Layer Manipulation + status: unknown + '#overlay': + title: '3.4 Controlling the Top Layer: the overlay property' + status: unknown + '#painting-order': + title: 4 Painting Order and Stacking Contexts + status: unknown + '#changes': + title: 5 Changes + status: unknown diff --git a/Spec/css-scroll-anchoring-1.yml b/Spec/css-scroll-anchoring-1.yml new file mode 100644 index 000000000..994ed031c --- /dev/null +++ b/Spec/css-scroll-anchoring-1.yml @@ -0,0 +1,32 @@ +spec: css-scroll-anchoring-1 +title: CSS Scroll Anchoring Module Level 1 +url: https://www.w3.org/TR/css-scroll-anchoring-1/ +snapshot: https://www.w3.org/TR/2020/WD-css-scroll-anchoring-1-20201111/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#description': + title: 2 Description + status: unknown + '#anchor-node-selection': + title: 2.1 Anchor Node Selection + status: unknown + '#scroll-adjustment': + title: 2.2 Scroll Adjustment + status: unknown + '#suppression-windows': + title: 2.2.1 Suppression Window + status: unknown + '#suppression-triggers': + title: 2.2.2 Suppression Triggers + status: unknown + '#exclusion-api': + title: 3 Exclusion API + status: unknown + '#priv-sec': + title: 4 Privacy and Security Considerations + status: unknown diff --git a/Spec/css-scroll-snap-1.yml b/Spec/css-scroll-snap-1.yml new file mode 100644 index 000000000..7775e5241 --- /dev/null +++ b/Spec/css-scroll-snap-1.yml @@ -0,0 +1,95 @@ +spec: css-scroll-snap-1 +title: CSS Scroll Snap Module Level 1 +url: https://www.w3.org/TR/css-scroll-snap-1/ +snapshot: https://www.w3.org/TR/2021/CR-css-scroll-snap-1-20210311/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#examples': + title: 2 Motivating Examples + status: unknown + '#overview': + title: 3 Scroll Snap Model + status: unknown + '#properties-on-the-scroll-container': + title: '4 Capturing Scroll Snap Areas: Properties on the scroll container' + status: unknown + '#scroll-snap-type': + title: '4.1 Scroll Snapping Rules: the scroll-snap-type property' + status: unknown + '#snap-axis': + title: '4.1.1 Scroll Snap Axis: the x, y, block, inline, and both values' + status: unknown + '#snap-strictness': + title: '4.1.2 Scroll Snap Strictness: the none, proximity, and mandatory values' + status: unknown + '#re-snap': + title: 4.1.3 Re-snapping After Layout Changes + status: unknown + '#scroll-padding': + title: '4.2 Scroll Snapport: the scroll-padding property' + status: unknown + '#properties-on-the-elements': + title: '5 Aligning Scroll Snap Areas: Properties on the elements' + status: unknown + '#scroll-margin': + title: '5.1 Scroll Snapping Area: the scroll-margin property' + status: unknown + '#scroll-snap-align': + title: '5.2 Scroll Snapping Alignment: the scroll-snap-align property' + status: unknown + '#snap-scope': + title: 5.2.1 Scoping Valid Snap Positions to Visible Boxes + status: unknown + '#snap-overflow': + title: 5.2.2 Snapping Boxes that Overflow the Scrollport + status: unknown + '#unreachable': + title: 5.2.3 Unreachable Snap Positions + status: unknown + '#scroll-snap-stop': + title: '5.3 Scroll Snap Limits: the scroll-snap-stop property' + status: unknown + '#snap-concepts': + title: 6 Snapping Mechanics + status: unknown + '#scroll-types': + title: 6.1 Types of Scrolling Methods + status: unknown + '#choosing': + title: 6.2 Choosing Snap Positions + status: unknown + '#priv-sec': + title: 7 Privacy and Security Considerations + status: unknown + '#acknowledgements': + title: 8 Acknowledgements + status: unknown + '#changes': + title: 9 Changes + status: unknown + '#changes-20190319': + title: 9.1 Changes Since 19 March 2019 CR + status: unknown + '#changes-20190131': + title: 9.2 Changes Since 31 January 2019 CR + status: unknown + '#changes-20180814': + title: 9.3 Changes Since 14 August 2018 CR + status: unknown + '#changes-20171214': + title: 9.4 Changes Since 14 December 2017 CR + status: unknown + '#changes-20170824': + title: 9.5 Changes Since 24 August 2017 CR + status: unknown + '#changes-20161020': + title: 9.6 Changes Since 20 October 2016 CR + status: unknown diff --git a/Spec/css-scrollbars-1.yml b/Spec/css-scrollbars-1.yml new file mode 100644 index 000000000..46f75437a --- /dev/null +++ b/Spec/css-scrollbars-1.yml @@ -0,0 +1,23 @@ +spec: css-scrollbars-1 +title: CSS Scrollbars Styling Module Level 1 +url: https://www.w3.org/TR/css-scrollbars-1/ +snapshot: https://www.w3.org/TR/2021/CR-css-scrollbars-1-20211209/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#scope': + title: 1.1 Scope + status: unknown + '#out-of-scope': + title: 1.1.1 Out Of Scope + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#scrollbar-color': + title: '2 Scrollbar Colors: the scrollbar-color property' + status: unknown + '#scrollbar-width': + title: '3 Scrollbar Thickness: the scrollbar-width property' + status: unknown diff --git a/Spec/css-shapes-1.yml b/Spec/css-shapes-1.yml new file mode 100644 index 000000000..50accd964 --- /dev/null +++ b/Spec/css-shapes-1.yml @@ -0,0 +1,68 @@ +spec: css-shapes-1 +title: CSS Shapes Module Level 1 +url: https://www.w3.org/TR/css-shapes/ +snapshot: https://www.w3.org/TR/2025/CRD-css-shapes-1-20250612/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#module-interactions': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Values + status: unknown + '#terminology': + title: 1.3 Terminology + status: unknown + '#relation-to-box-model-and-float-behavior': + title: 2 Relation to the box model and float behavior + status: unknown + '#basic-shape-functions': + title: 3 Basic Shapes + status: unknown + '#supported-basic-shapes': + title: 3.1 Supported Shapes + status: unknown + '#shape-function': + title: 3.1.1 The shape() Function + status: unknown + '#shape-examples': + title: 3.1.1.1 Using shape() to create responsive, parametric speech bubble + status: unknown + '#interpolating-shape': + title: 3.1.1.2 Interpolating the shape() Function + status: unknown + '#basic-shape-computed-values': + title: 3.2 Computed Values of Basic Shapes + status: unknown + '#basic-shape-serialization': + title: 3.3 Serialization of Basic Shapes + status: unknown + '#basic-shape-interpolation': + title: 3.4 Interpolation of Basic Shapes + status: unknown + '#shapes-from-image': + title: 4 Shapes from Image + status: unknown + '#shapes-from-box-values': + title: 5 Shapes from Box Values + status: unknown + '#declaring-shapes': + title: 6 Declaring Shapes + status: unknown + '#shape-outside-property': + title: '6.1 Float Area Shape: the shape-outside property' + status: unknown + '#shape-image-threshold-property': + title: '6.2 Choosing Image Pixels: the shape-image-threshold property' + status: unknown + '#shape-margin-property': + title: '6.3 Expanding a Shape: the shape-margin property' + status: unknown + '#privacy': + title: 7 Privacy Considerations + status: unknown + '#security': + title: 8 Security Considerations + status: unknown diff --git a/Spec/css-sizing-4.yml b/Spec/css-sizing-4.yml new file mode 100644 index 000000000..b06bc7471 --- /dev/null +++ b/Spec/css-sizing-4.yml @@ -0,0 +1,86 @@ +spec: css-sizing-4 +title: CSS Box Sizing Module Level 4 +url: https://www.w3.org/TR/css-sizing-4/ +snapshot: https://www.w3.org/TR/2026/WD-css-sizing-4-20260904/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#terms': + title: 2 Terminology + status: unknown + '#specifying-sizes': + title: 3 Specifying Box Sizes + status: unknown + '#sizing-properties': + title: 3.1 Sizing Properties + status: unknown + '#sizing-values': + title: '3.2 New Sizing Values: the contain, fit-content(), and calc-size() values' + status: unknown + '#ratios': + title: 4 Aspect Ratios + status: unknown + '#aspect-ratio': + title: '4.1 Preferred Aspect Ratios: the aspect-ratio property' + status: unknown + '#aspect-ratio-automatic': + title: 4.2 Effects of Preferred Aspect Ratio on Automatic Sizes + status: unknown + '#aspect-ratio-margin-collapse': + title: 4.2.1 Margin-collapsing + status: unknown + '#aspect-ratio-minimum': + title: 4.3 Automatic Content-based Minimum Sizes + status: unknown + '#aspect-ratio-size-transfers': + title: 4.4 Min/Max Size Transfers + status: unknown + '#intrinsic': + title: 5 Intrinsic Size Determination + status: unknown + '#intrinsic-sizes': + title: 5.1 Intrinsic Sizes + status: unknown + '#intrinsic-size-override': + title: '5.2 Overriding Contained Intrinsic Sizes: the contain-intrinsic-* properties' + status: unknown + '#last-remembered': + title: 5.2.1 Last Remembered Size + status: unknown + '#cis-scrollbars': + title: '5.2.2 Interaction With overflow: auto' + status: unknown + '#responsive-iframes': + title: '5.3 Responsively-sized iframes: the frame-sizing property' + status: unknown + '#iframe-frame-sizing': + title: 5.3.1 HTML iframe Details + status: unknown + '#window-requestresize': + title: 5.3.2 Extensions to the Window Interface + status: unknown + '#intrinsic-contribution': + title: 5.4 Intrinsic Size Contributions + status: unknown + '#intrinsic-contribution-override': + title: '5.5 Zeroing Min-Content Size Contributions: the min-intrinsic-sizing property' + status: unknown + '#column-sizing': + title: '5.6 New Column Sizing Values: the stretch, min-content, max-content, fit-content, and fit-content() values' + status: unknown + '#extrinsic': + title: 6 Extrinsic Size Determination + status: unknown + '#contain-fit-sizing': + title: '6.1 Contain-fit Sizing: stretching while maintaining an aspect ratio' + status: unknown + '#percentage-sizing': + title: 6.2 Percentage Sizing + status: unknown diff --git a/Spec/css-speech-1.yml b/Spec/css-speech-1.yml new file mode 100644 index 000000000..985d5dbe8 --- /dev/null +++ b/Spec/css-speech-1.yml @@ -0,0 +1,110 @@ +spec: css-speech-1 +title: CSS Speech Module Level 1 +url: https://www.w3.org/TR/css-speech-1/ +snapshot: https://www.w3.org/TR/2023/CRD-css-speech-1-20230214/ +sections: + '#intro': + title: 1 Introduction, design goals + status: unknown + '#background': + title: 2 Background information, CSS 2.1 + status: unknown + '#ssml-rel': + title: 3 Relationship with SSML + status: unknown + '#values': + title: 3.1 Value Definitions + status: unknown + '#example': + title: 4 Example + status: unknown + '#aural-model': + title: 5 The aural formatting model + status: unknown + '#mixing-props': + title: 6 Mixing properties + status: unknown + '#mixing-props-voice-volume': + title: 6.1 The voice-volume property + status: unknown + '#mixing-props-voice-balance': + title: 6.2 The voice-balance property + status: unknown + '#speaking-props': + title: 7 Speaking properties + status: unknown + '#speaking-props-speak': + title: 7.1 The speak property + status: unknown + '#speaking-props-speak-as': + title: 7.2 The speak-as property + status: unknown + '#pause-props': + title: 8 Pause properties + status: unknown + '#pause-props-pause-before-after': + title: 8.1 The pause-before and pause-after properties + status: unknown + '#pause-props-pause': + title: 8.2 The pause shorthand property + status: unknown + '#collapsed-pauses': + title: 8.3 Collapsing pauses + status: unknown + '#rest-props': + title: 9 Rest properties + status: unknown + '#rest-props-rest-before-after': + title: 9.1 The rest-before and rest-after properties + status: unknown + '#rest-props-rest': + title: 9.2 The rest shorthand property + status: unknown + '#cue-props': + title: 10 Cue properties + status: unknown + '#cue-props-cue-before-after': + title: 10.1 The cue-before and cue-after properties + status: unknown + '#cue-props-volume': + title: 10.2 Relation between audio cues and speech synthesis volume levels + status: unknown + '#cue-props-cue': + title: 10.3 The cue shorthand property + status: unknown + '#voice-char-props': + title: 11 Voice characteristic properties + status: unknown + '#voice-props-voice-family': + title: 11.1 The voice-family property + status: unknown + '#voice-selection': + title: 11.1.1 Voice selection, content language + status: unknown + '#voice-props-voice-rate': + title: 11.2 The voice-rate property + status: unknown + '#voice-props-voice-pitch': + title: 11.3 The voice-pitch property + status: unknown + '#voice-props-voice-range': + title: 11.4 The voice-range property + status: unknown + '#voice-props-voice-stress': + title: 11.5 The voice-stress property + status: unknown + '#duration-props': + title: 12 Voice duration property + status: unknown + '#mixing-props-voice-duration': + title: 12.1 The voice-duration property + status: unknown + '#lists': + title: 13 List items and counters styles + status: unknown + '#content': + title: 14 Inserted and replaced content + status: unknown + '#pronunciation': + title: 15 Pronunciation, phonemes + status: unknown diff --git a/Spec/css-text-4.yml b/Spec/css-text-4.yml new file mode 100644 index 000000000..df6f3d155 --- /dev/null +++ b/Spec/css-text-4.yml @@ -0,0 +1,227 @@ +spec: css-text-4 +title: CSS Text Module Level 4 +url: https://www.w3.org/TR/css-text-4/ +snapshot: https://www.w3.org/TR/2026/WD-css-text-4-20260814/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#languages': + title: 1.3 Languages and Typesetting + status: unknown + '#characters': + title: 1.4 Characters and Letters + status: unknown + '#text-encoding': + title: 1.5 Text Processing + status: unknown + '#transforming': + title: 2 Transforming Text + status: unknown + '#text-transform-property': + title: '2.1 Case Transforms: the text-transform property' + status: unknown + '#text-transform-mapping': + title: 2.1.1 Mapping Rules + status: unknown + '#word-space-transform': + title: '2.2 Expanding Between Words: the word-space-transform property' + status: unknown + '#text-transform-order': + title: 2.3 Order of Operations + status: unknown + '#white-space-property': + title: '3 White Space and Wrapping: the white-space property' + status: unknown + '#white-space-processing': + title: 4 White Space Processing & Control Characters + status: unknown + '#white-space-collapsing': + title: '4.1 White Space Collapsing: the white-space-collapse property' + status: unknown + '#white-space-trim': + title: '4.2 White Space Trimming: the white-space-trim property' + status: unknown + '#white-space-rules': + title: 4.3 The White Space Processing Rules + status: unknown + '#white-space-phase-1': + title: '4.3.1 Phase I: Collapsing and Transformation' + status: unknown + '#white-space-phase-2': + title: '4.3.2 Phase II: Trimming and Positioning' + status: unknown + '#line-break-transform': + title: 4.3.3 Segment Break Transformation Rules + status: unknown + '#tab-size-property': + title: '4.4 Tab Character Size: the tab-size property' + status: unknown + '#text-wrapping': + title: 5 Text Wrapping + status: unknown + '#text-wrap-mode': + title: '5.1 Deciding Whether to Wrap: the text-wrap-mode property' + status: unknown + '#wrap-inside': + title: '5.2 Controlling Breaks Within Boxes: the wrap-inside property' + status: unknown + '#example-avoid': + title: "5.2.1 Example of using 'wrap-inside: avoid' in presenting a footer" + status: unknown + '#wrap-before': + title: '5.3 Controlling Breaks Between Boxes: the wrap-before/wrap-after properties' + status: unknown + '#text-wrap-style': + title: '5.4 Selecting How to Wrap: the text-wrap-style property' + status: unknown + '#text-wrap-shorthand': + title: '5.5 Joint Wrapping Control: the text-wrap shorthand property' + status: unknown + '#line-breaking': + title: 6 Line Breaking and Word Boundaries + status: unknown + '#word-break-property': + title: '6.1 Breaking Rules for Letters: the word-break property' + status: unknown + '#analytical-word-breaking': + title: 6.1.1 Analytical Word Breaking + status: unknown + '#lexical-breaking': + title: 6.1.1.1 Lexical Word Breaking + status: unknown + '#orthographic-breaking': + title: 6.1.1.2 Orthographic Breaking + status: unknown + '#fallback-breaking': + title: 6.1.1.3 Fallback Breaking + status: unknown + '#phrase-pref': + title: 6.1.2 Expressing User Preferences for Phrase-based Line Breaking + status: unknown + '#line-break-property': + title: '6.2 Line Breaking Strictness: the line-break property' + status: unknown + '#hyphenation': + title: '6.3 Hyphenation: Morphological Breaking Within Words' + status: unknown + '#hyphens-property': + title: '6.3.1 Hyphenation Control: the hyphens property' + status: unknown + '#hyphenate-character': + title: '6.3.2 Hyphens: the hyphenate-character property' + status: unknown + '#hyphenate-size-limits': + title: '6.3.3 Hyphenation Size Limit: the hyphenate-limit-zone property' + status: unknown + '#hyphenate-char-limits': + title: '6.3.4 Hyphenation Character Limits: the hyphenate-limit-chars property' + status: unknown + '#hyphenate-line-limits': + title: '6.3.5 Hyphenation Line Limits: the hyphenate-limit-lines and hyphenate-limit-last properties' + status: unknown + '#overflow-wrap-property': + title: '6.4 Overflow Wrapping: the overflow-wrap (word-wrap) property' + status: unknown + '#line-break-details': + title: 6.5 Line Breaking Details + status: unknown + '#justification': + title: 7 Alignment and Justification + status: unknown + '#text-align-property': + title: '7.1 Text Alignment: the text-align shorthand' + status: unknown + '#character-alignment': + title: 7.2 Character-based Alignment in a Table Column + status: unknown + '#text-align-all-property': + title: '7.3 Default Text Alignment: the text-align-all property' + status: unknown + '#text-align-last-property': + title: '7.4 Last Line Alignment: the text-align-last property' + status: unknown + '#text-justify-property': + title: '7.5 Justification Method: the text-justify property' + status: unknown + '#expanding-text': + title: 7.5.1 Expanding and Compressing Text + status: unknown + '#justify-symbols': + title: 7.5.2 Handling Symbols and Punctuation + status: unknown + '#justify-limits': + title: 7.5.3 Unexpandable Text + status: unknown + '#justify-cursive': + title: 7.5.4 Cursive Scripts + status: unknown + '#justify-algos': + title: 7.5.5 Minimum Requirements for auto Justification + status: unknown + '#text-group-align-property': + title: '7.6 Aligning a block of text within its container: the text-group-align property' + status: unknown + '#spacing': + title: 8 Spacing + status: unknown + '#word-spacing-property': + title: '8.1 Word Spacing: the word-spacing property' + status: unknown + '#letter-spacing-property': + title: '8.2 Tracking: the letter-spacing property' + status: unknown + '#cursive-tracking': + title: 8.2.1 Cursive Scripts + status: unknown + '#line-padding-property': + title: '8.3 Line Start/End Padding: the line-padding property' + status: unknown + '#text-autospace-property': + title: '8.4 Automatic Contextual Spacing: the text-autospace property' + status: unknown + '#inter-script-spacing': + title: 8.4.1 Inter-script Spacing + status: unknown + '#autospace-bidi': + title: 8.4.2 Interaction with Bidirectional Text + status: unknown + '#text-spacing-trim-property': + title: '8.5 CJK Punctuation Spacing: the text-spacing-trim property' + status: unknown + '#fullwidth-collapsing': + title: 8.5.1 Fullwidth Punctuation Collapsing + status: unknown + '#text-spacing-classes': + title: 8.5.2 Text Spacing Trim Character Classes + status: unknown + '#japanese-start-edges': + title: 8.5.3 Japanese Paragraph-start Conventions in CSS + status: unknown + '#text-spacing-property': + title: '8.6 Character Class Spacing Shorthand: the text-spacing property' + status: unknown + '#boundary-shaping': + title: 8.7 Shaping Across Element Boundaries + status: unknown + '#edge-effects': + title: 9 Edge Effects + status: unknown + '#text-indent-property': + title: '9.1 First Line Indentation: the text-indent property' + status: unknown + '#hanging': + title: 9.2 Hanging Glyphs + status: unknown + '#hanging-punctuation-property': + title: '9.2.1 Hanging Punctuation: the hanging-punctuation property' + status: unknown + '#bidi-linebox': + title: 9.3 Bidirectionality and Line Boxes + status: unknown diff --git a/Spec/css-text-decor-4.yml b/Spec/css-text-decor-4.yml new file mode 100644 index 000000000..75a236779 --- /dev/null +++ b/Spec/css-text-decor-4.yml @@ -0,0 +1,110 @@ +spec: css-text-decor-4 +title: CSS Text Decoration Module Level 4 +url: https://www.w3.org/TR/css-text-decor-4/ +snapshot: https://www.w3.org/TR/2022/WD-css-text-decor-4-20220504/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#terms': + title: 1.3 Terminology + status: unknown + '#line-decoration': + title: '2 Line Decoration: Underline, Overline, and Strike-Through' + status: unknown + '#text-decoration-line-property': + title: '2.1 Text Decoration Lines: the text-decoration-line property' + status: unknown + '#text-decoration-style-property': + title: '2.2 Text Decoration Style: the text-decoration-style property' + status: unknown + '#text-decoration-color-property': + title: '2.3 Text Decoration Color: the text-decoration-color property' + status: unknown + '#text-decoration-thickness-property': + title: '2.4 Text Decoration Line Thickness: the text-decoration-thickness property' + status: unknown + '#text-decoration-thickness': + title: 2.4.1 Automatic Thickness of Text Decoration Lines + status: unknown + '#line-position': + title: 2.5 Determining the Position and Thickness of Line Decorations + status: unknown + '#text-decoration-property': + title: '2.6 Text Decoration Shorthand: the text-decoration property' + status: unknown + '#text-underline-position-property': + title: '2.7 Text Underline Position: the text-underline-position property' + status: unknown + '#underline-offset': + title: '2.8 Text Underline Offset: the text-underline-offset property' + status: unknown + '#line-offset-zero': + title: 2.8.1 Underline Offset Origin (Zero Position) + status: unknown + '#line-auto-offset': + title: 2.8.2 Using Font Metrics for Automatic Positioning + status: unknown + '#text-line-constancy': + title: 2.9 Text Decoration Line Uniformity + status: unknown + '#text-decoration-skipping': + title: '2.10 Text Decoration Line Continuity: the text-decoration-skip shorthand and its sub-properties' + status: unknown + '#text-decoration-skip-self-property': + title: '2.10.1 Skipping Spaces: the text-decoration-skip-self property' + status: unknown + '#text-decoration-skip-box-property': + title: '2.10.2 Skipping Spaces: the text-decoration-skip-box property' + status: unknown + '#text-decoration-skip-inset-property': + title: '2.10.3 Inset Edges: the text-decoration-skip-inset property' + status: unknown + '#text-decoration-skip-spaces-property': + title: '2.10.4 Skipping Spaces: the text-decoration-skip-spaces property' + status: unknown + '#text-decoration-skip-ink-property': + title: '2.10.5 Skipping Glyphs: the text-decoration-skip-ink property' + status: unknown + '#ink-skip-shape': + title: 2.10.6 Shaping Interruptions + status: unknown + '#emphasis-marks': + title: 3 Additional Controls for Emphasis Marks + status: unknown + '#text-emphasis-style-property': + title: '3.1 Emphasis Mark Style: the text-emphasis-style property' + status: unknown + '#text-emphasis-color-property': + title: '3.2 Emphasis Mark Color: the text-emphasis-color property' + status: unknown + '#text-emphasis-property': + title: '3.3 Emphasis Mark Shorthand: the text-emphasis property' + status: unknown + '#text-emphasis-position-property': + title: '3.4 Emphasis Mark Position: the text-emphasis-position property' + status: unknown + '#text-emphasis-skip': + title: '3.5 Emphasis Mark Skip: the text-emphasis-skip property' + status: unknown + '#text-shadow-property': + title: '4 Text Shadows: the text-shadow property' + status: unknown + '#painting': + title: 5 Painting Text Decorations + status: unknown + '#painting-order': + title: 5.1 Painting Order of Text Decorations + status: unknown + '#overflow': + title: 5.2 Overflow of Text Decorations + status: unknown + '#priv-sec': + title: 6 Privacy and Security Considerations + status: unknown diff --git a/Spec/css-transforms-1.yml b/Spec/css-transforms-1.yml new file mode 100644 index 000000000..a948ba368 --- /dev/null +++ b/Spec/css-transforms-1.yml @@ -0,0 +1,107 @@ +spec: css-transforms-1 +title: CSS Transforms Module Level 1 +url: https://www.w3.org/TR/css-transforms-1/ +snapshot: https://www.w3.org/TR/2019/CR-css-transforms-1-20190214/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#module-interactions': + title: 1.1 Module Interactions + status: unknown + '#css-values': + title: 1.2 CSS Values + status: unknown + '#terminology': + title: 2 Terminology + status: unknown + '#transform-rendering': + title: 3 The Transform Rendering Model + status: unknown + '#transform-property': + title: 4 The transform Property + status: unknown + '#serialization-of-transform-functions': + title: 4.1 Serialization of <transform-function>s + status: unknown + '#serialization-of-the-computed-value': + title: 4.2 Serialization of the computed value of <transform-list> + status: unknown + '#transform-origin-property': + title: 5 The transform-origin Property + status: unknown + '#transform-box': + title: '6 Transform reference box: the transform-box property' + status: unknown + '#svg-transform': + title: 7 The SVG transform Attribute + status: unknown + '#transform-attribute-specificity': + title: 7.1 SVG presentation attributes + status: unknown + '#svg-syntax': + title: 7.2 Syntax of the SVG transform attribute + status: unknown + '#svg-transform-functions': + title: 7.3 SVG transform functions + status: unknown + '#svg-user-coordinate-space': + title: 7.4 User coordinate space + status: unknown + '#transform-attribute-dom': + title: 7.5 SVG DOM interface for the transform attribute + status: unknown + '#svg-animation': + title: 8 SVG Animation + status: unknown + '#svg-animate-element': + title: 8.1 The animate and set element + status: unknown + '#neutral-element': + title: 8.2 Neutral element for addition + status: unknown + '#svg-attribute-name': + title: 8.3 The SVG 1.1 'attributeName' attribute + status: unknown + '#transform-functions': + title: 9 The Transform Functions + status: unknown + '#two-d-transform-functions': + title: 9.1 2D Transform Functions + status: unknown + '#transform-primitives': + title: 9.2 Transform function primitives and derivatives + status: unknown + '#transform-function-lists': + title: 10 The Transform Function Lists + status: unknown + '#interpolation-of-transforms': + title: 11 Interpolation of Transforms + status: unknown + '#interpolation-of-transform-functions': + title: 12 Interpolation of primitives and derived transform functions + status: unknown + '#matrix-interpolation': + title: 13 Interpolation of Matrices + status: unknown + '#supporting-functions': + title: 13.1 Supporting functions + status: unknown + '#interpolation-of-2d-matrices': + title: 13.2 Interpolation of 2D matrices + status: unknown + '#decomposing-a-2d-matrix': + title: 13.2.1 Decomposing a 2D matrix + status: unknown + '#interpolation-of-decomposed-2d-matrix-values': + title: 13.2.2 Interpolation of decomposed 2D matrix values + status: unknown + '#recomposing-to-a-2d-matrix': + title: 13.2.3 Recomposing to a 2D matrix + status: unknown + '#mathematical-description': + title: 14 Mathematical Description of Transform Functions + status: unknown + '#priv-sec': + title: 15 Privacy and Security Considerations + status: unknown diff --git a/Spec/css-transforms-2.yml b/Spec/css-transforms-2.yml new file mode 100644 index 000000000..1f45a3f44 --- /dev/null +++ b/Spec/css-transforms-2.yml @@ -0,0 +1,128 @@ +spec: css-transforms-2 +title: CSS Transforms Module Level 2 +url: https://www.w3.org/TR/css-transforms-2/ +snapshot: https://www.w3.org/TR/2021/WD-css-transforms-2-20211109/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#module-interactions': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Value Definitions + status: unknown + '#terminology': + title: 2 Terminology + status: unknown + '#serialization-of-the-computed-value': + title: 2.1 Serialization of the computed value of <transform-list> + status: unknown + '#two-dimensional-subset': + title: 3 Two Dimensional Subset + status: unknown + '#transform-rendering': + title: 4 The Transform Rendering Model + status: unknown + '#3d-transform-rendering': + title: 4.1 3D Transform Rendering + status: unknown + '#perspective': + title: 4.1.1 Perspective + status: unknown + '#3d-rendering-contexts': + title: 4.1.2 3D Rendering Contexts + status: unknown + '#transformed-element-hierarchies': + title: 4.1.3 Transformed element hierarchies + status: unknown + '#accumulated-3d-transformation-matrix-computation': + title: 4.1.4 Accumulated 3D Transformation Matrix Computation + status: unknown + '#backface-visibility': + title: 4.1.5 Backface Visibility + status: unknown + '#processing-of-perspective-transformed-boxes': + title: 4.2 Processing of Perspective-Transformed Boxes + status: unknown + '#individual-transforms': + title: '5 Individual Transform Properties: the translate, scale, and rotate properties' + status: unknown + '#individual-transform-serialization': + title: 5.1 Serialization + status: unknown + '#ctm': + title: 6 Current Transformation Matrix + status: unknown + '#transform-style-property': + title: 7 The transform-style Property + status: unknown + '#grouping-property-values': + title: 7.1 Grouping property values + status: unknown + '#perspective-property': + title: 8 The perspective Property + status: unknown + '#perspective-origin-property': + title: 9 The perspective-origin Property + status: unknown + '#backface-visibility-property': + title: 10 The backface-visibility Property + status: unknown + '#svg-three-dimensional-functions': + title: 11 SVG and 3D transform functions + status: unknown + '#transform-functions': + title: 12 The Transform Functions + status: unknown + '#two-d-transform-functions': + title: 12.1 2D Transform Functions + status: unknown + '#three-d-transform-functions': + title: 12.2 3D Transform Functions + status: unknown + '#transform-primitives': + title: 12.3 Transform function primitives and derivatives + status: unknown + '#matrix-interpolation': + title: 13 Interpolation of Matrices + status: unknown + '#interpolation-of-3d-matrices': + title: 13.1 Interpolation of 3D matrices + status: unknown + '#decomposing-a-3d-matrix': + title: 13.1.1 Decomposing a 3D matrix + status: unknown + '#interpolation-of-decomposed-3d-matrix-values': + title: 13.1.2 Interpolation of decomposed 3D matrix values + status: unknown + '#recomposing-to-a-3d-matrix': + title: 13.1.3 Recomposing to a 3D matrix + status: unknown + '#interpolation-of-transform-functions': + title: 14 Interpolation of primitives and derived transform functions + status: unknown + '#combining-transform-lists': + title: 15 Addition and accumulation of transform lists + status: unknown + '#neutral-element': + title: 15.1 Neutral element for addition + status: unknown + '#mathematical-description': + title: 16 Mathematical Description of Transform Functions + status: unknown + '#svg-transform': + title: 17 The SVG transform Attribute + status: unknown + '#svg-animation': + title: 18 SVG Animation + status: unknown + '#svg-animate-element': + title: 18.1 The animate and set element + status: unknown + '#more-issues': + title: 19 More Issues + status: unknown + '#priv-sec': + title: 20 Security and Privacy Considerations + status: unknown diff --git a/Spec/css-transitions-1.yml b/Spec/css-transitions-1.yml new file mode 100644 index 000000000..a270a13ff --- /dev/null +++ b/Spec/css-transitions-1.yml @@ -0,0 +1,92 @@ +spec: css-transitions-1 +title: CSS Transitions Module Level 1 +url: https://www.w3.org/TR/css-transitions-1/ +snapshot: https://www.w3.org/TR/2026/WD-css-transitions-1-20260108/ +sections: + '#introduction': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#transitions': + title: 2 Transitions + status: unknown + '#transition-property-property': + title: 2.1 The transition-property Property + status: unknown + '#transition-duration-property': + title: 2.2 The transition-duration Property + status: unknown + '#transition-timing-function-property': + title: 2.3 The transition-timing-function Property + status: unknown + '#transition-delay-property': + title: 2.4 The transition-delay Property + status: unknown + '#transition-shorthand-property': + title: 2.5 The transition Shorthand Property + status: unknown + '#starting': + title: 3 Starting of transitions + status: unknown + '#reversing': + title: 3.1 Faster reversing of interrupted transitions + status: unknown + '#application': + title: 4 Application of transitions + status: unknown + '#complete': + title: 5 Completion of transitions + status: unknown + '#transition-events': + title: 6 Transition Events + status: unknown + '#interface-transitionevent': + title: 6.1 Interface TransitionEvent + status: unknown + '#interface-transitionevent-idl': + title: 6.1.1 IDL Definition + status: unknown + '#interface-transitionevent-attributes': + title: 6.1.2 Attributes + status: unknown + '#event-transitionevent': + title: 6.2 Types of TransitionEvent + status: unknown + '#event-handlers-on-elements-document-objects-and-window-objects': + title: 6.3 Event handlers on elements, Document objects, and Window objects + status: unknown + '#interface-dom': + title: 7 DOM Interfaces + status: unknown + '#interface-globaleventhandlers-idl': + title: 7.1 IDL Definition + status: unknown + '#security': + title: 8 Security Considerations + status: unknown + '#privacy': + title: 9 Privacy Considerations + status: unknown + '#accessibility': + title: 10 Accessibility Considerations + status: unknown + '#accessibility-motion': + title: 10.1 Motion + status: unknown + '#accessibility-cascade': + title: 10.2 Cascade + status: unknown + '#changes': + title: 11 Changes + status: unknown + '#changes-2018': + title: 11.1 Changes since Working Draft of 11 October 2018 + status: unknown + '#changes-earlier': + title: 11.2 Earlier changes + status: unknown + '#acknowledgments': + title: 12 Acknowledgments + status: unknown diff --git a/Spec/css-ui-4.yml b/Spec/css-ui-4.yml new file mode 100644 index 000000000..f5a746053 --- /dev/null +++ b/Spec/css-ui-4.yml @@ -0,0 +1,125 @@ +spec: css-ui-4 +title: CSS Basic User Interface Module Level 4 +url: https://www.w3.org/TR/css-ui-4/ +snapshot: https://www.w3.org/TR/2026/WD-css-ui-4-20260120/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#purpose': + title: 1.1 Purpose + status: unknown + '#interaction': + title: 2 Module Interactions + status: unknown + '#values': + title: 2.1 Value Definitions + status: unknown + '#outline-props': + title: 3 Outline properties + status: unknown + '#outline': + title: '3.1 Outlines Shorthand: the outline property' + status: unknown + '#outline-width': + title: '3.2 Outline Thickness: the outline-width property' + status: unknown + '#outline-style': + title: '3.3 Outline Patterns: the outline-style property' + status: unknown + '#outline-color': + title: '3.4 Outline Colors: the outline-color property' + status: unknown + '#outline-offset': + title: '3.5 Offsetting the Outline: the outline-offset property' + status: unknown + '#resizing': + title: 4 Resizing + status: unknown + '#resize': + title: '4.1 Resizing Boxes: the resize property' + status: unknown + '#pointing-keyboard': + title: 5 Pointing Devices and Keyboards + status: unknown + '#pointer-interaction': + title: 5.1 Pointer interaction + status: unknown + '#cursor': + title: '5.1.1 Styling the Cursor: the cursor property' + status: unknown + '#predefined-cursors': + title: 5.1.1.1 Predefined Cursors + status: unknown + '#canvas_cursor': + title: 5.1.1.2 Cursor of the canvas + status: unknown + '#the-insertion-caret': + title: 5.2 Insertion caret + status: unknown + '#caret-color': + title: '5.2.1 Coloring the Insertion Caret: the caret-color property' + status: unknown + '#caret-animation': + title: '5.2.2 Animation of the insertion caret: caret-animation' + status: unknown + '#caret-shape': + title: '5.2.3 Shape of the insertion caret: caret-shape' + status: unknown + '#caret': + title: '5.2.4 Insertion caret shorthand: caret' + status: unknown + '#keyboard': + title: 5.3 Keyboard control + status: unknown + '#nav-dir': + title: '5.3.1 Directional Focus Navigation: the nav-up, nav-right, nav-down, nav-left properties' + status: unknown + '#example-positioned-buttons': + title: '5.3.1.1 Example: positioned buttons' + status: unknown + '#example-moving-focus-to-inside-a-frame': + title: '5.3.1.2 Example: moving focus to inside a frame' + status: unknown + '#input-method-editor': + title: '5.3.2 Obsolete: the ime-mode property' + status: unknown + '#user-interaction': + title: 6 User Interaction + status: unknown + '#content-selection': + title: 6.1 Controlling content selection + status: unknown + '#pointer-events-control': + title: '6.2 Exclusion from Hit-testing: the pointer-events property' + status: unknown + '#inertness': + title: '6.3 Preventing All Interaction: the interactivity property' + status: unknown + '#interest': + title: '6.4 User Interest Delay: the interest-delay-* properties' + status: unknown + '#styling-widgets': + title: 7 Styling Widgets + status: unknown + '#widget-accent': + title: '7.1 Widget Accent Colors: the accent-color property' + status: unknown + '#appearance-switching': + title: '7.2 Switching appearance: the appearance property' + status: unknown + '#appearance-disabling-properties': + title: 7.2.1 Properties Disabling Native Appearance + status: unknown + '#appearance-decorative': + title: 7.2.2 Effects of appearance on Decorative Aspects of Elements + status: unknown + '#appearance-semantics': + title: 7.2.3 Effects of appearance on Semantic Aspects of Elements + status: unknown + '#control-specific-rules': + title: 7.3 Control-Specific Rules + status: unknown + '#input-rules': + title: 7.3.1 Single-Line Text Input Controls + status: unknown diff --git a/Spec/css-values-5.yml b/Spec/css-values-5.yml new file mode 100644 index 000000000..01d50912d --- /dev/null +++ b/Spec/css-values-5.yml @@ -0,0 +1,149 @@ +spec: css-values-5 +title: CSS Values and Units Module Level 5 +url: https://www.w3.org/TR/css-values-5/ +snapshot: https://www.w3.org/TR/2024/WD-css-values-5-20241111/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#textual-values': + title: 2 Textual Data Types + status: unknown + '#value-defs': + title: 3 Value Definition Syntax + status: unknown + '#component-functions': + title: 3.1 Functional Notation Definitions + status: unknown + '#component-function-commas': + title: 3.1.1 Commas in Function Arguments + status: unknown + '#boolean': + title: 3.2 Boolean Expression Multiplier <boolean-expr[]> + status: unknown + '#css-syntax': + title: '3.3 Specifying CSS Syntax in CSS: the <syntax> type' + status: unknown + '#parse-syntax': + title: 3.3.1 Parsing as <syntax> + status: unknown + '#level-4-extensions': + title: 4 Extensions to Level 4 Value Types + status: unknown + '#urls': + title: '4.1 Resource Locators: the <url> type' + status: unknown + '#request-url-modifiers': + title: 4.1.1 Request URL Modifiers + status: unknown + '#position': + title: '4.2 2D Positioning: the <position> type' + status: unknown + '#position-parsing': + title: 4.2.1 Parsing <position> + status: unknown + '#position-serialization': + title: 4.2.2 Serializing <position> + status: unknown + '#combine-positions': + title: 4.2.3 Combination of <position> + status: unknown + '#progress': + title: 5 Interpolation Progress Functional Notations + status: unknown + '#progress-func': + title: '5.1 Calculated Progress Values: the progress() notation' + status: unknown + '#media-progress-func': + title: '5.2 Media Query Progress Values: the media-progress() notation' + status: unknown + '#container-progress-func': + title: '5.3 Container Query Progress Values: the container-progress() notation' + status: unknown + '#mixing': + title: '6 Mixing and Interpolation Notations: the *-mix() family' + status: unknown + '#progress-type': + title: '6.1 Representing Interpolation Progress: the <progress> type' + status: unknown + '#calc-mix': + title: '6.2 Interpolated Numeric and Dimensional Values: the calc-mix() notation' + status: unknown + '#color-mix': + title: '6.3 Interpolated Color Values: the color-mix() notation' + status: unknown + '#cross-fade': + title: '6.4 Interpolated Image Values: the cross-fade() notation' + status: unknown + '#transform-mix': + title: '6.5 Interpolated Transform Values: the transform-mix() notation' + status: unknown + '#mix': + title: '6.6 Interpolated Property Values: the mix() notation' + status: unknown + '#value-insert': + title: 7 Miscellaneous Value Substituting Functions + status: unknown + '#whole-value': + title: '7.1 Representing An Entire Property Value: the <whole-value> type' + status: unknown + '#first-valid': + title: '7.2 Selecting the First Supported Value: the first-valid() notation' + status: unknown + '#if-notation': + title: '7.3 Conditional Value Selection: the if() notation' + status: unknown + '#toggle-notation': + title: '7.4 Toggling Between Values: the toggle() notation' + status: unknown + '#var-notation': + title: '7.5 Custom Property References: the var() notation' + status: unknown + '#inherit-notation': + title: '7.6 Inherited Value References: the inherit() notation' + status: unknown + '#attr-notation': + title: '7.7 Attribute References: the attr() notation' + status: unknown + '#attr-substitution': + title: '7.7.1 Attribute Value Substitution: the attr() notation' + status: unknown + '#attr-security': + title: 7.7.2 Security + status: unknown + '#randomness': + title: 8 Generating Random Values + status: unknown + '#random': + title: '8.1 Generating a Random Numeric Value: the random() function' + status: unknown + '#random-infinities': + title: 8.1.1 Argument Ranges + status: unknown + '#random-item': + title: '8.2 Picking a Random Item From a List: the random-item() function' + status: unknown + '#random-caching': + title: '8.3 Generating/Caching Random Values: the <random-caching-options> value' + status: unknown + '#tree-counting': + title: '9 Tree Counting Functions: the sibling-count() and sibling-index() notations' + status: unknown + '#calc-size': + title: '10 Calculating With Intrinsic Sizes: the calc-size() function' + status: unknown + '#simplifying-calc-size': + title: 10.1 Simplifying calc-size() + status: unknown + '#resolving-calc-size': + title: 10.2 Resolving calc-size() + status: unknown + '#interp-calc-size': + title: 10.3 Interpolating calc-size() + status: unknown + '#interpolate-size': + title: '10.4 Interpolating sizing keywords: the interpolate-size property' + status: unknown diff --git a/Spec/css-view-transitions-1.yml b/Spec/css-view-transitions-1.yml new file mode 100644 index 000000000..a7620dbf1 --- /dev/null +++ b/Spec/css-view-transitions-1.yml @@ -0,0 +1,143 @@ +spec: css-view-transitions-1 +title: CSS View Transitions Module Level 1 +url: https://www.w3.org/TR/css-view-transitions-1/ +snapshot: https://www.w3.org/TR/2024/CRD-css-view-transitions-1-20240328/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#separating-transitions': + title: 1.1 Separating Visual Transitions from DOM Updates + status: unknown + '#customizing': + title: 1.2 View Transition Customization + status: unknown + '#lifecycle': + title: 1.3 View Transition Lifecycle + status: unknown + '#transitions-as-enhancements': + title: 1.4 Transitions as an enhancement + status: unknown + '#rendering-model': + title: 1.5 Rendering Model + status: unknown + '#examples': + title: 1.6 Examples + status: unknown + '#css-properties': + title: 2 CSS properties + status: unknown + '#view-transition-name-prop': + title: '2.1 Tagging Individually Transitioning Subtrees: the view-transition-name property' + status: unknown + '#named-and-transitioning': + title: 2.1.1 Rendering Consolidation + status: unknown + '#pseudo': + title: 3 Pseudo-elements + status: unknown + '#pseudo-root': + title: 3.1 Pseudo-element Trees + status: unknown + '#view-transition-pseudos': + title: 3.2 View Transition Pseudo-elements + status: unknown + '#named-view-transition-pseudo': + title: 3.2.1 Named View Transition Pseudo-elements + status: unknown + '#view-transition-pseudo': + title: '3.2.2 View Transition Tree Root: the ::view-transition pseudo-element' + status: unknown + '#::view-transition-group': + title: '3.2.3 View Transition Named Subtree Root: the ::view-transition-group() pseudo-element' + status: unknown + '#::view-transition-image-pair': + title: '3.2.4 View Transition Image Pair Isolation: the ::view-transition-image-pair() pseudo-element' + status: unknown + '#::view-transition-old': + title: '3.2.5 View Transition Old State Image: the ::view-transition-old() pseudo-element' + status: unknown + '#::view-transition-new': + title: '3.2.6 View Transition New State Image: the ::view-transition-new() pseudo-element' + status: unknown + '#view-transition-rendering': + title: 4 View Transition Layout + status: unknown + '#snapshot-containing-block-concept': + title: 4.1 The Snapshot Containing Block + status: unknown + '#view-transition-stacking-layer': + title: 4.2 View Transition Painting Order + status: unknown + '#ua-styles': + title: 5 User Agent Stylesheet + status: unknown + '#api': + title: 6 API + status: unknown + '#additions-to-document-api': + title: 6.1 Additions to Document + status: unknown + '#ViewTransition-prepare': + title: 6.1.1 startViewTransition() Method Steps + status: unknown + '#the-domtransition-interface': + title: 6.2 The ViewTransition interface + status: unknown + '#ViewTransition-skipTransition': + title: 6.2.1 skipTransition() Method Steps + status: unknown + '#algorithms': + title: 7 Algorithms + status: unknown + '#concepts': + title: 7.1 Data Structures + status: unknown + '#additions-to-document': + title: 7.1.1 Additions to Document + status: unknown + '#elements-concept': + title: 7.1.2 Additions to Elements + status: unknown + '#captured-elements': + title: 7.1.3 Captured elements + status: unknown + '#perform-pending-transition-operations-algorithm': + title: 7.2 Perform pending transition operations + status: unknown + '#setup-view-transition-algorithm': + title: 7.3 Setup view transition + status: unknown + '#capture-old-state-algorithm': + title: 7.3.1 Capture the old state + status: unknown + '#capture-new-state-algorithm': + title: 7.3.2 Capture the new state + status: unknown + '#setup-transition-pseudo-elements-algorithm': + title: 7.3.3 Setup transition pseudo-elements + status: unknown + '#call-dom-update-callback-algorithm': + title: 7.4 Call the update callback + status: unknown + '#skip-the-view-transition-algorithm': + title: 7.5 Skip the view transition + status: unknown + '#page-visibility-change-steps': + title: 7.6 View transition page-visibility change steps + status: unknown + '#capture-the-image-algorithm': + title: 7.7 Capture the image + status: unknown + '#capture-rendering-characteristics-algorithm': + title: 7.7.1 Capture rendering characteristics + status: unknown + '#handle-transition-frame-algorithm': + title: 7.8 Handle transition frame + status: unknown + '#style-transition-pseudo-elements-algorithm': + title: 7.9 Update pseudo-element styles + status: unknown + '#clear-view-transition-algorithm': + title: 7.10 Clear view transition + status: unknown diff --git a/Spec/css-will-change-1.yml b/Spec/css-will-change-1.yml new file mode 100644 index 000000000..f87d89395 --- /dev/null +++ b/Spec/css-will-change-1.yml @@ -0,0 +1,29 @@ +spec: css-will-change-1 +title: CSS Will Change Module Level 1 +url: https://www.w3.org/TR/css-will-change/ +snapshot: https://www.w3.org/TR/2022/CRD-css-will-change-1-20220505/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#values': + title: 1.1 Value Definitions + status: unknown + '#using': + title: 1.2 Using will-change Well + status: unknown + '#will-change': + title: '2 Hinting at Future Behavior: the will-change property' + status: unknown + '#security': + title: 3 Security Considerations + status: unknown + '#privacy': + title: 4 Privacy Considerations + status: unknown + '#acks': + title: 5 Acknowledgements + status: unknown + '#changes': + title: 6 Changes + status: unknown diff --git a/Spec/css-writing-modes-4.yml b/Spec/css-writing-modes-4.yml new file mode 100644 index 000000000..8699b1eb6 --- /dev/null +++ b/Spec/css-writing-modes-4.yml @@ -0,0 +1,176 @@ +spec: css-writing-modes-4 +title: CSS Writing Modes Level 4 +url: https://www.w3.org/TR/css-writing-modes-4/ +snapshot: https://www.w3.org/TR/2019/CR-css-writing-modes-4-20190730/ +sections: + '#text-flow': + title: 1 Introduction to Writing Modes + status: unknown + '#placement': + title: 1.1 Module Interactions + status: unknown + '#values': + title: 1.2 Value Types and Terminology + status: unknown + '#text-direction': + title: 2 Inline Direction and Bidirectionality + status: unknown + '#direction': + title: '2.1 Specifying Directionality: the direction property' + status: unknown + '#unicode-bidi': + title: '2.2 Embeddings and Overrides: the unicode-bidi property' + status: unknown + '#bidi-example': + title: 2.3 Example of Bidirectional Text + status: unknown + '#bidi-algo': + title: 2.4 Applying the Bidirectional Reordering Algorithm + status: unknown + '#bidi-para-direction': + title: 2.4.1 Bidi Paragraph Embedding Levels + status: unknown + '#bidi-control-codes': + title: 2.4.2 CSS–Unicode Bidi Control Translation, Text Reordering + status: unknown + '#bidi-atomic-inlines': + title: 2.4.3 Bidi Treatment of Atomic Inlines + status: unknown + '#bidi-embedding-breaks': + title: 2.4.4 Paragraph Breaks Within Embeddings and Isolates + status: unknown + '#bidi-box-model': + title: 2.4.5 Reordering-induced Box Fragmentation + status: unknown + '#bidi-fragmentation': + title: 2.4.5.1 Conditions of Reordering-induced Box Fragmentation + status: unknown + '#bidi-fragment-boxes': + title: 2.4.5.2 Box Model of Reordering-induced Box Fragments + status: unknown + '#vertical-modes': + title: 3 Vertical Writing Modes + status: unknown + '#vertical-intro': + title: 3.1 Introduction to Vertical Writing + status: unknown + '#block-flow': + title: '3.2 Block Flow Direction: the writing-mode property' + status: unknown + '#svg-writing-mode': + title: 3.2.1 Obsolete SVG1.1 writing-mode Values + status: unknown + '#svg-writing-mode-css': + title: 3.2.1.1 Supporting SVG1.1 writing-mode values in CSS syntax + status: unknown + '#svg-writing-mode-markup': + title: 3.2.1.2 Supporting SVG1.1 writing-mode values in presentational attributes + status: unknown + '#inline-alignment': + title: 4 Inline-level Alignment + status: unknown + '#intro-baselines': + title: 4.1 Introduction to Baselines + status: unknown + '#text-baselines': + title: 4.2 Text Baselines + status: unknown + '#replaced-baselines': + title: 4.3 Atomic Inline Baselines + status: unknown + '#baseline-alignment': + title: 4.4 Baseline Alignment + status: unknown + '#intro-text-layout': + title: 5 Introduction to Vertical Text Layout + status: unknown + '#text-orientation': + title: '5.1 Orienting Text: the text-orientation property' + status: unknown + '#vertical-font-features': + title: 5.1.1 Vertical Typesetting and Font Features + status: unknown + '#vertical-orientations': + title: 5.1.2 Mixed Vertical Orientations + status: unknown + '#glyph-orientation': + title: '5.1.3 Obsolete: the SVG1.1 glyph-orientation-vertical property' + status: unknown + '#abstract-box': + title: 6 Abstract Box Terminology + status: unknown + '#abstract-axes': + title: 6.1 Abstract Dimensions + status: unknown + '#logical-directions': + title: 6.2 Flow-relative Directions + status: unknown + '#line-directions': + title: 6.3 Line-relative Directions + status: unknown + '#logical-to-physical': + title: 6.4 Abstract-to-Physical Mappings + status: unknown + '#abstract-layout': + title: 7 Abstract Box Layout + status: unknown + '#vertical-layout': + title: 7.1 Principles of Layout in Vertical Writing Modes + status: unknown + '#dimension-mapping': + title: 7.2 Dimensional Mapping + status: unknown + '#orthogonal-flows': + title: 7.3 Orthogonal Flows + status: unknown + '#orthogonal-auto': + title: 7.3.1 Available Space in Orthogonal Flows + status: unknown + '#auto-multicol': + title: 7.3.2 Auto-sizing Block Containers in Orthogonal Flows + status: unknown + '#orthogonal-layout': + title: 7.3.3 Auto-sizing Other Orthogonal Flow Roots + status: unknown + '#orthogonal-pagination': + title: 7.3.4 Fragmenting Orthogonal Flows + status: unknown + '#logical-direction-layout': + title: 7.4 Flow-Relative Mappings + status: unknown + '#line-mappings': + title: 7.5 Line-Relative Mappings + status: unknown + '#physical-only': + title: 7.6 Purely Physical Mappings + status: unknown + '#principal-flow': + title: 8 The Principal Writing Mode + status: unknown + '#icb': + title: 8.1 Propagation to the Initial Containing Block + status: unknown + '#page-direction': + title: '8.2 Page Flow: the page progression direction' + status: unknown + '#text-combine': + title: 9 Glyph Composition + status: unknown + '#text-combine-upright': + title: '9.1 Horizontal-in-Vertical Composition: the text-combine-upright property' + status: unknown + '#text-combine-runs': + title: 9.1.1 Text Run Rules + status: unknown + '#text-combine-layout': + title: 9.1.2 Layout Rules + status: unknown + '#text-combine-compression': + title: 9.1.3 Compression Rules + status: unknown + '#text-combine-fullwidth': + title: 9.1.3.1 Full-width Characters + status: unknown + '#priv-sec': + title: 10 Privacy and Security Considerations + status: unknown diff --git a/Spec/cssom-1.yml b/Spec/cssom-1.yml new file mode 100644 index 000000000..c15bbccb3 --- /dev/null +++ b/Spec/cssom-1.yml @@ -0,0 +1,182 @@ +spec: cssom-1 +title: CSS Object Model (CSSOM) +url: https://www.w3.org/TR/cssom-1/ +snapshot: https://www.w3.org/TR/2021/WD-cssom-1-20210826/ +sections: + '#introduction': + title: 1 Introduction + status: unknown + '#terminology': + title: 2 Terminology + status: unknown + '#common-serializing-idioms': + title: 2.1 Common Serializing Idioms + status: unknown + '#cssomstring-type': + title: 3 CSSOMString + status: unknown + '#media-queries': + title: 4 Media Queries + status: unknown + '#parsing-media-queries': + title: 4.1 Parsing Media Queries + status: unknown + '#serializing-media-queries': + title: 4.2 Serializing Media Queries + status: unknown + '#serializing-media-feature-values': + title: 4.2.1 Serializing Media Feature Values + status: unknown + '#comparing-media-queries': + title: 4.3 Comparing Media Queries + status: unknown + '#the-medialist-interface': + title: 4.4 The MediaList Interface + status: unknown + '#selectors': + title: 5 Selectors + status: unknown + '#parsing-selectors': + title: 5.1 Parsing Selectors + status: unknown + '#serializing-selectors': + title: 5.2 Serializing Selectors + status: unknown + '#css-object-model': + title: 6 CSS + status: unknown + '#css-style-sheets': + title: 6.1 CSS Style Sheets + status: unknown + '#the-stylesheet-interface': + title: 6.1.1 The StyleSheet Interface + status: unknown + '#the-cssstylesheet-interface': + title: 6.1.2 The CSSStyleSheet Interface + status: unknown + '#legacy-css-style-sheet-members': + title: 6.1.2.1 Deprecated CSSStyleSheet members + status: unknown + '#css-style-sheet-collections': + title: 6.2 CSS Style Sheet Collections + status: unknown + '#the-http-default-style-header': + title: 6.2.1 The HTTP Default-Style Header + status: unknown + '#the-stylesheetlist-interface': + title: 6.2.2 The StyleSheetList Interface + status: unknown + '#extensions-to-the-document-or-shadow-root-interface': + title: 6.2.3 Extensions to the DocumentOrShadowRoot Interface Mixin + status: unknown + '#style-sheet-association': + title: 6.3 Style Sheet Association + status: unknown + '#fetching-css-style-sheets': + title: 6.3.1 Fetching CSS style sheets + status: unknown + '#the-linkstyle-interface': + title: 6.3.2 The LinkStyle Interface + status: unknown + '#requirements-on-specifications': + title: 6.3.3 Requirements on specifications + status: unknown + '#requirements-on-user-agents-implementing-the-xml-stylesheet-processing-instruction': + title: 6.3.4 Requirements on user agents Implementing the xml-stylesheet processing instruction + status: unknown + '#requirements-on-user-agents-implementing-the-http-link-header': + title: 6.3.5 Requirements on user agents Implementing the HTTP Link Header + status: unknown + '#css-rules': + title: 6.4 CSS Rules + status: unknown + '#the-cssrulelist-interface': + title: 6.4.1 The CSSRuleList Interface + status: unknown + '#the-cssrule-interface': + title: 6.4.2 The CSSRule Interface + status: unknown + '#the-cssstylerule-interface': + title: 6.4.3 The CSSStyleRule Interface + status: unknown + '#the-cssimportrule-interface': + title: 6.4.4 The CSSImportRule Interface + status: unknown + '#the-cssgroupingrule-interface': + title: 6.4.5 The CSSGroupingRule Interface + status: unknown + '#the-cssmediarule-interface': + title: 6.4.6 The CSSMediaRule Interface + status: unknown + '#the-csspagerule-interface': + title: 6.4.7 The CSSPageRule Interface + status: unknown + '#the-cssmarginrule-interface': + title: 6.4.8 The CSSMarginRule Interface + status: unknown + '#the-cssnamespacerule-interface': + title: 6.4.9 The CSSNamespaceRule Interface + status: unknown + '#css-declarations': + title: 6.5 CSS Declarations + status: unknown + '#css-declaration-blocks': + title: 6.6 CSS Declaration Blocks + status: unknown + '#the-cssstyledeclaration-interface': + title: 6.6.1 The CSSStyleDeclaration Interface + status: unknown + '#css-values': + title: 6.7 CSS Values + status: unknown + '#parsing-css-values': + title: 6.7.1 Parsing CSS Values + status: unknown + '#serializing-css-values': + title: 6.7.2 Serializing CSS Values + status: unknown + '#serializing-css-values-examples': + title: 6.7.2.1 Examples + status: unknown + '#dom-access-to-css-declaration-blocks': + title: 7 DOM Access to CSS Declaration Blocks + status: unknown + '#the-elementcssinlinestyle-mixin': + title: 7.1 The ElementCSSInlineStyle Mixin + status: unknown + '#extensions-to-the-window-interface': + title: 7.2 Extensions to the Window Interface + status: unknown + '#utility-apis': + title: 8 Utility APIs + status: unknown + '#the-css.escape()-method': + title: 8.1 The CSS.escape() Method + status: unknown + '#resolved-values': + title: 9 Resolved Values + status: unknown + '#iana-considerations': + title: 10 IANA Considerations + status: unknown + '#default-style': + title: 10.1 Default-Style + status: unknown + '#change-history': + title: 11 Change History + status: unknown + '#changes-from-17-march-2016': + title: 11.1 Changes From 17 March 2016 + status: unknown + '#changes-from-5-december-2013': + title: 11.2 Changes From 5 December 2013 + status: unknown + '#changes-from-12-july-2011-to-5-december-2013': + title: 11.3 Changes From 12 July 2011 To 5 December 2013 + status: unknown + '#security-and-privacy-considerations': + title: 12 Security and Privacy Considerations + status: unknown + '#acknowledgments': + title: 13 Acknowledgments + status: unknown diff --git a/Spec/cssom-view-1.yml b/Spec/cssom-view-1.yml new file mode 100644 index 000000000..e40b8e105 --- /dev/null +++ b/Spec/cssom-view-1.yml @@ -0,0 +1,107 @@ +spec: cssom-view-1 +title: CSSOM View Module +url: https://www.w3.org/TR/cssom-view-1/ +snapshot: https://www.w3.org/TR/2025/WD-cssom-view-1-20250916/ +sections: + '#background': + title: 1 Background + status: unknown + '#terminology': + title: 2 Terminology + status: unknown + '#css-pixels': + title: 2.1 CSS pixels + status: unknown + '#zooming': + title: 2.2 Zooming + status: unknown + '#web-exposed-screen-information': + title: 2.3 Web-exposed screen information + status: unknown + '#common-infrastructure': + title: 3 Common Infrastructure + status: unknown + '#scrolling': + title: 3.1 Scrolling + status: unknown + '#webidl-values': + title: 3.2 WebIDL values + status: unknown + '#extensions-to-the-window-interface': + title: 4 Extensions to the Window Interface + status: unknown + '#the-features-argument-to-the-open()-method': + title: 4.1 The features argument to the open() method + status: unknown + '#the-mediaquerylist-interface': + title: 4.2 The MediaQueryList Interface + status: unknown + '#mediaquerylist-event-summary': + title: 4.2.1 Event summary + status: unknown + '#the-screen-interface': + title: 4.3 The Screen Interface + status: unknown + '#extensions-to-the-document-interface': + title: 5 Extensions to the Document Interface + status: unknown + '#the-caretposition-interface': + title: 5.1 The CaretPosition Interface + status: unknown + '#extension-to-the-element-interface': + title: 6 Extensions to the Element Interface + status: unknown + '#element-scrolling-members': + title: 6.1 Element Scrolling Members + status: unknown + '#extensions-to-the-htmlelement-interface': + title: 7 Extensions to the HTMLElement Interface + status: unknown + '#extensions-to-the-htmlimageelement-interface': + title: 8 Extensions to the HTMLImageElement Interface + status: unknown + '#extensions-to-the-range-interface': + title: 9 Extensions to the Range Interface + status: unknown + '#extensions-to-the-mouseevent-interface': + title: 10 Extensions to the MouseEvent Interface + status: unknown + '#geometry': + title: 11 Geometry + status: unknown + '#the-geometryutils-interface': + title: 11.1 The GeometryUtils Interface + status: unknown + '#visualViewport': + title: 12 VisualViewport + status: unknown + '#the-visualviewport-interface': + title: 12.1 The VisualViewport Interface + status: unknown + '#events': + title: 13 Events + status: unknown + '#resizing-viewports': + title: 13.1 Resizing viewports + status: unknown + '#scrolling-events': + title: 13.2 Scrolling + status: unknown + '#event-summary': + title: 13.3 Event summary + status: unknown + '#post-layout-snapshot': + title: 14 Post-Layout State Snapshotting + status: unknown + '#privacy': + title: 15 Privacy Considerations + status: unknown + '#security': + title: 16 Security Considerations + status: unknown + '#changes': + title: 17 Changes + status: unknown + '#acks': + title: 18 Acknowledgements + status: unknown diff --git a/Spec/filter-effects-1.yml b/Spec/filter-effects-1.yml new file mode 100644 index 000000000..dcd00f66f --- /dev/null +++ b/Spec/filter-effects-1.yml @@ -0,0 +1,221 @@ +spec: filter-effects-1 +title: Filter Effects Module Level 1 +url: https://www.w3.org/TR/filter-effects-1/ +snapshot: https://www.w3.org/TR/2018/WD-filter-effects-1-20181218/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 2 Module interactions + status: unknown + '#values': + title: 3 Values + status: unknown + '#definitions': + title: 4 Terminology + status: unknown + '#FilterProperty': + title: '5 Graphic filters: the filter property' + status: unknown + '#filter-functions': + title: 6 Filter Functions + status: unknown + '#supported-filter-functions': + title: 6.1 Supported Filter Functions + status: unknown + '#computed-values-of-filter-functions': + title: 6.2 Computed Values of Filter Functions + status: unknown + '#serialization-of-filter-functions': + title: 6.3 Serialization of Filter Functions + status: unknown + '#interpolation-of-filter-functions': + title: 6.4 Interpolation of Filter Functions + status: unknown + '#FilterElement': + title: '7 SVG Filter Sources: the filter element' + status: unknown + '#FilterEffectsRegion': + title: 8 Filter Region + status: unknown + '#FilterPrimitivesOverview': + title: 9 Filter primitives + status: unknown + '#FilterPrimitivesOverviewIntro': + title: 9.1 Overview + status: unknown + '#CommonAttributes': + title: 9.2 Common filter primitive attributes + status: unknown + '#FilterPrimitiveTree': + title: 9.3 Filter primitive tree + status: unknown + '#FilterPrimitiveSubRegion': + title: 9.4 Filter primitive subregion + status: unknown + '#feBlendElement': + title: 9.5 Filter primitive feBlend + status: unknown + '#feColorMatrixElement': + title: 9.6 Filter primitive feColorMatrix + status: unknown + '#feComponentTransferElement': + title: 9.7 Filter primitive feComponentTransfer + status: unknown + '#feFuncRElement': + title: 9.7.1 Transfer function feFuncR + status: unknown + '#feFuncGElement': + title: 9.7.2 Transfer function feFuncG + status: unknown + '#feFuncBElement': + title: 9.7.3 Transfer function feFuncB + status: unknown + '#feFuncAElement': + title: 9.7.4 Transfer function feFuncA + status: unknown + '#feCompositeElement': + title: 9.8 Filter primitive feComposite + status: unknown + '#feConvolveMatrixElement': + title: 9.9 Filter primitive feConvolveMatrix + status: unknown + '#feDiffuseLightingElement': + title: 9.10 Filter primitive feDiffuseLighting + status: unknown + '#feDisplacementMapElement': + title: 9.11 Filter primitive feDisplacementMap + status: unknown + '#feDropShadowElement': + title: 9.12 Filter primitive feDropShadow + status: unknown + '#feFloodElement': + title: 9.13 Filter primitive feFlood + status: unknown + '#FloodColorProperty': + title: 9.13.1 The flood-color property + status: unknown + '#FloodOpacityProperty': + title: 9.13.2 The flood-opacity property + status: unknown + '#feGaussianBlurElement': + title: 9.14 Filter primitive feGaussianBlur + status: unknown + '#feImageElement': + title: 9.15 Filter primitive feImage + status: unknown + '#feMergeElement': + title: 9.16 Filter primitive feMerge + status: unknown + '#feMergeNodeElement': + title: 9.16.1 Merge node feMergeNode + status: unknown + '#feMorphologyElement': + title: 9.17 Filter primitive feMorphology + status: unknown + '#feOffsetElement': + title: 9.18 Filter primitive feOffset + status: unknown + '#feSpecularLightingElement': + title: 9.19 Filter primitive feSpecularLighting + status: unknown + '#feTileElement': + title: 9.20 Filter primitive feTile + status: unknown + '#feTurbulenceElement': + title: 9.21 Filter primitive feTurbulence + status: unknown + '#ColorInterpolationFiltersProperty': + title: 10 The color-interpolation-filters property + status: unknown + '#LightSourceDefinitions': + title: 11 Light source elements and properties + status: unknown + '#LightSourceIntro': + title: 11.1 Introduction + status: unknown + '#feDistantLightElement': + title: 11.2 Light source feDistantLight + status: unknown + '#fePointLightElement': + title: 11.3 Light source fePointLight + status: unknown + '#feSpotLightElement': + title: 11.4 Light source feSpotLight + status: unknown + '#LightingColorProperty': + title: 11.5 The lighting-color property + status: unknown + '#FilterCSSImageValue': + title: 12 Filter CSS <image> values + status: unknown + '#interpolating-filter-image': + title: 12.1 Interpolating filter() + status: unknown + '#ShorthandEquivalents': + title: 13 Shorthands defined in terms of the filter element + status: unknown + '#FilterPrimitiveRepresentation': + title: 13.1 Filter primitive representation + status: unknown + '#grayscaleEquivalent': + title: 13.1.1 grayscale + status: unknown + '#sepiaEquivalent': + title: 13.1.2 sepia + status: unknown + '#saturateEquivalent': + title: 13.1.3 saturate + status: unknown + '#huerotateEquivalent': + title: 13.1.4 hue-rotate + status: unknown + '#invertEquivalent': + title: 13.1.5 invert + status: unknown + '#opacityEquivalent': + title: 13.1.6 opacity + status: unknown + '#brightnessEquivalent': + title: 13.1.7 brightness + status: unknown + '#contrastEquivalent': + title: 13.1.8 contrast + status: unknown + '#blurEquivalent': + title: 13.1.9 blur + status: unknown + '#dropshadowEquivalent': + title: 13.1.10 drop-shadow + status: unknown + '#filter-region-for-shorthands': + title: 13.2 Filter region for shorthands + status: unknown + '#animation-of-filters': + title: 14 Animation of Filters + status: unknown + '#interpolation-of-filters': + title: 14.1 Interpolation of Filter Function Lists + status: unknown + '#addition': + title: 14.2 Addition + status: unknown + '#accumulation': + title: 14.3 Accumulation + status: unknown + '#priv-sec': + title: 15 Privacy and Security Considerations + status: unknown + '#tainted-filter-primitives': + title: 15.1 Tainted Filter Primitives + status: unknown + '#fedisplacemnentmap-restrictions': + title: 15.2 feDisplacementMap Restrictions + status: unknown + '#origin-restrictions': + title: 15.3 Origin Restrictions + status: unknown + '#timing-attack': + title: 15.4 Timing Attacks + status: unknown diff --git a/Spec/geometry-1.yml b/Spec/geometry-1.yml new file mode 100644 index 000000000..3fe56aa30 --- /dev/null +++ b/Spec/geometry-1.yml @@ -0,0 +1,65 @@ +spec: geometry-1 +title: Geometry Interfaces Module Level 1 +url: https://www.w3.org/TR/geometry-1/ +snapshot: https://www.w3.org/TR/2025/CRD-geometry-1-20251204/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#DOMPoint': + title: 2 The DOMPoint interfaces + status: unknown + '#transforming-a-point-with-a-matrix': + title: 2.1 Transforming a point with a matrix + status: unknown + '#DOMRect': + title: 3 The DOMRect interfaces + status: unknown + '#DOMRectList': + title: 4 The DOMRectList interface + status: unknown + '#DOMQuad': + title: 5 The DOMQuad interface + status: unknown + '#DOMMatrix': + title: 6 The DOMMatrix interfaces + status: unknown + '#dommatrixinit-dictionary': + title: 6.1 DOMMatrix2DInit and DOMMatrixInit dictionaries + status: unknown + '#dommatrix-parse': + title: 6.2 Parsing a string into an abstract matrix + status: unknown + '#dommatrix-create': + title: 6.3 Creating DOMMatrixReadOnly and DOMMatrix objects + status: unknown + '#dommatrix-attributes': + title: 6.4 DOMMatrix attributes + status: unknown + '#immutable-transformation-methods': + title: 6.5 Immutable transformation methods + status: unknown + '#mutable-transformation-methods': + title: 6.6 Mutable transformation methods + status: unknown + '#structured-serialization': + title: 7 Structured serialization + status: unknown + '#security': + title: 8 Security Considerations + status: unknown + '#priv-sec': + title: 9 Privacy Considerations + status: unknown + '#historical': + title: 10 Historical + status: unknown + '#historical-cssom-view': + title: 10.1 CSSOM View + status: unknown + '#historical-svg': + title: 10.2 SVG + status: unknown + '#historical-non-standard': + title: 10.3 Non-standard + status: unknown diff --git a/Spec/mediaqueries-5.yml b/Spec/mediaqueries-5.yml new file mode 100644 index 000000000..585c4d56d --- /dev/null +++ b/Spec/mediaqueries-5.yml @@ -0,0 +1,245 @@ +spec: mediaqueries-5 +title: Media Queries Level 5 +url: https://www.w3.org/TR/mediaqueries-5/ +snapshot: https://www.w3.org/TR/2026/WD-mediaqueries-5-20260219/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module interactions + status: unknown + '#values': + title: 1.2 Values + status: unknown + '#units': + title: 1.3 Units + status: unknown + '#media': + title: 2 Media Queries + status: unknown + '#mq-list': + title: 2.1 Combining Media Queries + status: unknown + '#mq-prefix': + title: 2.2 Media Query Modifiers + status: unknown + '#mq-not': + title: '2.2.1 Negating a Media Query: the not keyword' + status: unknown + '#mq-only': + title: '2.2.2 Hiding a Media Query From Legacy user agents: the only keyword' + status: unknown + '#media-types': + title: 2.3 Media Types + status: unknown + '#mq-features': + title: 2.4 Media Features + status: unknown + '#mq-ranges': + title: '2.4.1 Media Feature Types: “range” and “discrete”' + status: unknown + '#mq-boolean-context': + title: 2.4.2 Evaluating Media Features in a Boolean Context + status: unknown + '#mq-range-context': + title: 2.4.3 Evaluating Media Features in a Range Context + status: unknown + '#mq-min-max': + title: 2.4.4 Using “min-” and “max-” Prefixes On Range Features + status: unknown + '#media-conditions': + title: 2.5 Combining Media Features + status: unknown + '#mq-syntax': + title: 3 Syntax + status: unknown + '#evaluating': + title: 3.1 Evaluating Media Queries + status: unknown + '#error-handling': + title: 3.2 Error Handling + status: unknown + '#mf-viewport-characteristics': + title: 4 Viewport/Page Characteristics Media Features + status: unknown + '#width': + title: '4.1 Width: the width feature' + status: unknown + '#height': + title: '4.2 Height: the height feature' + status: unknown + '#aspect-ratio': + title: '4.3 Aspect-Ratio: the aspect-ratio feature' + status: unknown + '#orientation': + title: '4.4 Orientation: the orientation feature' + status: unknown + '#mf-overflow-block': + title: '4.5 Block-Axis Overflow: the overflow-block feature' + status: unknown + '#mf-overflow-inline': + title: '4.6 Inline-Axis Overflow: the overflow-inline feature' + status: unknown + '#mf-horizontal-viewport-segments': + title: '4.7 Horizontal Viewport Segments: the horizontal-viewport-segments feature' + status: unknown + '#mf-vertical-viewport-segments': + title: '4.8 Vertical Viewport Segments: the vertical-viewport-segments feature' + status: unknown + '#display-modes': + title: '4.9 Display Modes: the display-mode media feature' + status: unknown + '#mf-display-quality': + title: 5 Display Quality Media Features + status: unknown + '#resolution': + title: '5.1 Display Resolution: the resolution feature' + status: unknown + '#scan': + title: '5.2 Display Type: the scan feature' + status: unknown + '#grid': + title: '5.3 Detecting Console Displays: the grid feature' + status: unknown + '#update': + title: '5.4 Display Update Frequency: the update feature' + status: unknown + '#environment-blending': + title: '5.5 Detecting the display technology: the environment-blending feature' + status: unknown + '#mf-colors': + title: 6 Color Media Features + status: unknown + '#color': + title: '6.1 Color Depth: the color feature' + status: unknown + '#color-index': + title: '6.2 Paletted Color Screens: the color-index feature' + status: unknown + '#monochrome': + title: '6.3 Monochrome Screens: the monochrome feature' + status: unknown + '#color-gamut': + title: '6.4 Color Display Quality: the color-gamut feature' + status: unknown + '#dynamic-range': + title: '6.5 Dynamic Range: the dynamic-range feature' + status: unknown + '#contrast-brightness-of-display': + title: 6.5.1 Determining contrast and brightness of display + status: unknown + '#inverted': + title: '6.6 Detecting inverted colors on the display: the inverted-colors feature' + status: unknown + '#mf-interaction': + title: 7 Interaction Media Features + status: unknown + '#pointer': + title: '7.1 Pointing Device Quality: the pointer feature' + status: unknown + '#hover': + title: '7.2 Hover Capability: the hover feature' + status: unknown + '#any-input': + title: '7.3 All Available Interaction Capabilities: the any-pointer and any-hover features' + status: unknown + '#nav-controls': + title: '7.4 Detecting UA-supplied navigation controls: the nav-controls feature' + status: unknown + '#video-prefixed-features': + title: 8 Video Prefixed Features + status: unknown + '#video-color-gamut': + title: '8.1 Video Color Display Quality: the video-color-gamut feature' + status: unknown + '#video-dynamic-range': + title: '8.2 Video Dynamic Range: the video-dynamic-range feature' + status: unknown + '#mf-scripting': + title: 9 Scripting Media Features + status: unknown + '#scripting': + title: '9.1 Scripting Support: the scripting feature' + status: unknown + '#custom-mq': + title: 10 Custom Media Queries + status: unknown + '#script-custom-mq': + title: 10.1 Script-based Custom Media Queries + status: unknown + '#custom-mq-cssom': + title: 11 CSSOM + status: unknown + '#mf-user-preferences': + title: 12 User Preference Media Features + status: unknown + '#prefers-reduced-motion': + title: '12.1 Detecting the desire for less motion on the page: the prefers-reduced-motion feature' + status: unknown + '#prefers-reduced-transparency': + title: '12.2 Detecting the desire for reduced transparency on the page: the prefers-reduced-transparency feature' + status: unknown + '#prefers-contrast': + title: '12.3 Detecting the desire for increased or decreased color contrast from elements on the page: the prefers-contrast feature' + status: unknown + '#forced-colors': + title: '12.4 Detecting Forced Colors Mode: the forced-colors feature' + status: unknown + '#prefers-color-scheme': + title: '12.5 Detecting the desire for light or dark color schemes: the prefers-color-scheme feature' + status: unknown + '#prefers-reduced-data': + title: '12.6 Detecting the desire for reduced data usage when loading a page: the prefers-reduced-data feature' + status: unknown + '#auto-pref': + title: 12.7 Automatic handling of User Preferences + status: unknown + '#script-control-user-prefs': + title: 13 Script Control of User Preferences + status: unknown + '#navigator-interface': + title: 13.1 Extensions to the Navigator interface + status: unknown + '#preferences-attribute': + title: 13.1.1 preferences attribute + status: unknown + '#preference-manager': + title: 13.1.2 PreferenceManager interface + status: unknown + '#color-scheme-attribute': + title: 13.1.3 colorScheme attribute + status: unknown + '#contrast-attribute': + title: 13.1.4 contrast attribute + status: unknown + '#reduced-motion-attribute': + title: 13.1.5 reducedMotion attribute + status: unknown + '#reduced-transparency-attribute': + title: 13.1.6 reducedTransparency attribute + status: unknown + '#reduced-data-attribute': + title: 13.1.7 reducedData attribute + status: unknown + '#preference-object-interface': + title: 13.1.8 PreferenceObject interface + status: unknown + '#override-attribute': + title: 13.1.8.1 override attribute + status: unknown + '#preference-value-attribute': + title: 13.1.8.2 value attribute + status: unknown + '#valid-values-attribute': + title: 13.1.8.3 validValues attribute + status: unknown + '#onchange-attribute': + title: 13.1.8.4 onchange event handler attribute + status: unknown + '#request-override-method': + title: 13.1.8.5 requestOverride() method + status: unknown + '#clear-override-method': + title: 13.1.8.6 clearOverride() method + status: unknown diff --git a/Spec/motion-1.yml b/Spec/motion-1.yml new file mode 100644 index 000000000..ba4a30a08 --- /dev/null +++ b/Spec/motion-1.yml @@ -0,0 +1,59 @@ +spec: motion-1 +title: Motion Path Module Level 1 +url: https://www.w3.org/TR/motion-1/ +snapshot: https://www.w3.org/TR/2024/WD-motion-1-20241105/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#placement': + title: 1.1 Module interactions + status: unknown + '#values': + title: 1.2 Values + status: unknown + '#motion-paths-overview': + title: 2 Motion Paths + status: unknown + '#offset-path-property': + title: '2.1 Defining A Path: the offset-path property' + status: unknown + '#ray-function': + title: 2.1.1 The ray() Function + status: unknown + '#example-shape': + title: 2.1.2 Examples Of <basic-shape> Positioning + status: unknown + '#example-coord': + title: 2.1.3 Examples of <coord-box> Positioning + status: unknown + '#offset-distance-property': + title: '2.2 Position On The Path: the offset-distance property' + status: unknown + '#path-distance': + title: 2.2.1 Calculating the computed distance along a path + status: unknown + '#offset-position-property': + title: '2.3 Starting Point Of The Path: the offset-position property' + status: unknown + '#offset-anchor-property': + title: '2.4 The Element’s Anchor Point: the offset-anchor property' + status: unknown + '#offset-rotate-property': + title: '2.5 Rotating To Match The Path: the offset-rotate property' + status: unknown + '#offset-shorthand': + title: 2.6 The offset Shorthand + status: unknown + '#transform': + title: 2.7 Calculating The Offset Transform + status: unknown + '#paths': + title: 3 Equivalent Paths For <basic-shape> + status: unknown + '#privacy': + title: 4 Privacy Considerations + status: unknown + '#security': + title: 5 Security Considerations + status: unknown diff --git a/Spec/resize-observer-1.yml b/Spec/resize-observer-1.yml new file mode 100644 index 000000000..46a0ffec3 --- /dev/null +++ b/Spec/resize-observer-1.yml @@ -0,0 +1,77 @@ +spec: resize-observer-1 +title: Resize Observer +url: https://www.w3.org/TR/resize-observer-1/ +snapshot: https://www.w3.org/TR/2020/WD-resize-observer-1-20200211/ +sections: + '#intro': + title: 1 Introduction + status: unknown + '#api': + title: 2 Resize Observer API + status: unknown + '#resize-observer-interface': + title: 2.1 ResizeObserver interface + status: unknown + '#resize-observer-callback': + title: 2.2 ResizeObserverCallback + status: unknown + '#resize-observer-entry-interface': + title: 2.3 ResizeObserverEntry + status: unknown + '#processing-model': + title: 3 Processing Model + status: unknown + '#resize-observation-interface': + title: 3.1 ResizeObservation example struct + status: unknown + '#internal-slot-definitions': + title: 3.2 Internal Slot Definitions + status: unknown + '#document-slots': + title: 3.2.1 Document + status: unknown + '#resize-observer-slots': + title: 3.2.2 ResizeObserver + status: unknown + '#css-definitions': + title: 3.3 CSS Definitions + status: unknown + '#content-rect-h': + title: 3.3.1 content rect + status: unknown + '#algorithms': + title: 3.4 Algorithms + status: unknown + '#gather-active-observations-h': + title: 3.4.1 Gather active observations at depth + status: unknown + '#has-active-observations-h': + title: 3.4.2 Has active observations + status: unknown + '#has-skipped-observations-h': + title: 3.4.3 Has skipped observations + status: unknown + '#create-and-populate-resizeobserverentry-h': + title: 3.4.4 Create and populate a ResizeObserverEntry + status: unknown + '#broadcast-resize-notifications-h': + title: 3.4.5 Broadcast active observations + status: unknown + '#deliver-resize-error': + title: 3.4.6 Deliver Resize Loop Error + status: unknown + '#calculate-depth-for-node-h': + title: 3.4.7 Calculate depth for node + status: unknown + '#calculate-box-size': + title: 3.4.8 Calculate box size, given target and observed box + status: unknown + '#lifetime': + title: 3.5 ResizeObserver Lifetime + status: unknown + '#integrations': + title: 3.6 External Spec Integrations + status: unknown + '#html-event-loop': + title: '3.6.1 HTML Processing Model: Event Loop' + status: unknown diff --git a/Spec/web-animations-1.yml b/Spec/web-animations-1.yml new file mode 100644 index 000000000..118502723 --- /dev/null +++ b/Spec/web-animations-1.yml @@ -0,0 +1,353 @@ +spec: web-animations-1 +title: Web Animations +url: https://www.w3.org/TR/web-animations-1/ +snapshot: https://www.w3.org/TR/2023/WD-web-animations-1-20230605/ +sections: + '#introduction': + title: 1 Introduction + status: unknown + '#use-cases': + title: 1.1 Use cases + status: unknown + '#relationship-to-other-specifications': + title: 1.2 Relationship to other specifications + status: unknown + '#overview-of-this-specification': + title: 1.3 Overview of this specification + status: unknown + '#spec-conventions': + title: 2 Specification conventions + status: unknown + '#web-animations-model-overview': + title: 3 Web Animations model overview + status: unknown + '#timing-model': + title: 4 Timing model + status: unknown + '#timing-model-overview': + title: 4.1 Timing model overview + status: unknown + '#stateless': + title: 4.1.1 Stateless + status: unknown + '#hierarchical': + title: 4.1.2 Hierarchical + status: unknown + '#time-value-section': + title: 4.2 Time values + status: unknown + '#timelines': + title: 4.3 Timelines + status: unknown + '#document-timelines': + title: 4.3.1 Document timelines + status: unknown + '#the-documents-default-timeline': + title: 4.3.2 The default document timeline + status: unknown + '#animations': + title: 4.4 Animations + status: unknown + '#setting-the-timeline': + title: 4.4.1 Setting the timeline of an animation + status: unknown + '#setting-the-associated-effect': + title: 4.4.2 Setting the associated effect of an animation + status: unknown + '#the-current-time-of-an-animation': + title: 4.4.3 The current time of an animation + status: unknown + '#setting-the-current-time-of-an-animation': + title: 4.4.4 Setting the current time of an animation + status: unknown + '#setting-the-start-time-of-an-animation': + title: 4.4.5 Setting the start time of an animation + status: unknown + '#waiting-for-the-associated-effect': + title: 4.4.6 Waiting for the associated effect + status: unknown + '#the-current-ready-promise': + title: 4.4.7 The current ready promise + status: unknown + '#playing-an-animation-section': + title: 4.4.8 Playing an animation + status: unknown + '#pausing-an-animation-section': + title: 4.4.9 Pausing an animation + status: unknown + '#reaching-the-end': + title: 4.4.10 Reaching the end + status: unknown + '#the-current-finished-promise': + title: 4.4.11 The current finished promise + status: unknown + '#updating-the-finished-state': + title: 4.4.12 Updating the finished state + status: unknown + '#finishing-an-animation-section': + title: 4.4.13 Finishing an animation + status: unknown + '#canceling-an-animation-section': + title: 4.4.14 Canceling an animation + status: unknown + '#speed-control': + title: 4.4.15 Speed control + status: unknown + '#setting-the-playback-rate-of-an-animation': + title: 4.4.15.1 Setting the playback rate of an animation + status: unknown + '#seamlessly-updating-the-playback-rate-of-an-animation': + title: 4.4.15.2 Seamlessly updating the playback rate of an animation + status: unknown + '#reversing-an-animation-section': + title: 4.4.16 Reversing an animation + status: unknown + '#play-states': + title: 4.4.17 Play states + status: unknown + '#animation-events-section': + title: 4.4.18 Animation events + status: unknown + '#sorting-animation-events': + title: 4.4.18.1 Sorting animation events + status: unknown + '#animation-playback-events-section': + title: 4.4.18.2 Animation playback events + status: unknown + '#animation-playback-event-types': + title: 4.4.18.3 Types of animation playback events + status: unknown + '#animation-effects': + title: 4.5 Animation effects + status: unknown + '#animation-effects-and-animations': + title: 4.5.1 Relationship between animation effects and animations + status: unknown + '#types-of-animation-effects': + title: 4.5.2 Types of animation effects + status: unknown + '#the-active-interval': + title: 4.5.3 The active interval + status: unknown + '#local-time-section': + title: 4.5.4 Local time + status: unknown + '#animation-effect-phases-and-states': + title: 4.5.5 Animation effect phases and states + status: unknown + '#relevant-animations-section': + title: 4.5.6 Relevant animations + status: unknown + '#fill-behavior': + title: 4.6 Fill behavior + status: unknown + '#fill-modes': + title: 4.6.1 Fill modes + status: unknown + '#repeating': + title: 4.7 Repeating + status: unknown + '#iteration-intervals': + title: 4.7.1 Iteration intervals + status: unknown + '#controlling-iteration': + title: 4.7.2 Controlling iteration + status: unknown + '#iteration-time-space': + title: 4.7.3 Iteration time space + status: unknown + '#interval-timing': + title: 4.7.4 Interval timing + status: unknown + '#core-animation-effect-calculations': + title: 4.8 Core animation effect calculations + status: unknown + '#animation-effect-calculations-overview': + title: 4.8.1 Overview + status: unknown + '#calculating-the-active-duration': + title: 4.8.2 Calculating the active duration + status: unknown + '#transforming-the-local-time': + title: 4.8.3 Transforming the local time + status: unknown + '#calculating-the-active-time': + title: 4.8.3.1 Calculating the active time + status: unknown + '#calculating-the-overall-progress': + title: 4.8.3.2 Calculating the overall progress + status: unknown + '#calculating-the-simple-iteration-progress': + title: 4.8.3.3 Calculating the simple iteration progress + status: unknown + '#calculating-the-current-iteration': + title: 4.8.4 Calculating the current iteration + status: unknown + '#direction-control': + title: 4.9 Direction control + status: unknown + '#calculating-the-directed-progress': + title: 4.9.1 Calculating the directed progress + status: unknown + '#time-transformations': + title: 4.10 Time transformations + status: unknown + '#calculating-the-transformed-progress': + title: 4.10.1 Calculating the transformed progress + status: unknown + '#the-iteration-progress': + title: 4.11 The iteration progress + status: unknown + '#animation-model': + title: 5 Animation model + status: unknown + '#introduction-to-the-animation-model': + title: 5.1 Introduction + status: unknown + '#animating-properties': + title: 5.2 Animating properties + status: unknown + '#custom-properties': + title: 5.2.1 Custom Properties + status: unknown + '#keyframe-effects': + title: 5.3 Keyframe effects + status: unknown + '#keyframes-section': + title: 5.3.1 Keyframes + status: unknown + '#computing-property-values': + title: 5.3.2 Computing property values + status: unknown + '#calculating-computed-keyframes': + title: 5.3.3 Calculating computed keyframes + status: unknown + '#the-effect-value-of-a-keyframe-animation-effect': + title: 5.3.4 The effect value of a keyframe effect + status: unknown + '#combining-effects': + title: 5.4 Combining effects + status: unknown + '#animation-classes': + title: 5.4.1 Animation classes + status: unknown + '#the-effect-stack': + title: 5.4.2 The effect stack + status: unknown + '#calculating-the-result-of-an-effect-stack': + title: 5.4.3 Calculating the result of an effect stack + status: unknown + '#effect-composition': + title: 5.4.4 Effect composition + status: unknown + '#applying-the-composited-result': + title: 5.4.5 Applying the composited result + status: unknown + '#replacing-animations': + title: 5.5 Replacing animations + status: unknown + '#animation-replace-state': + title: 5.5.1 Replace state + status: unknown + '#removing-replaced-animations': + title: 5.5.2 Removing replaced animations + status: unknown + '#side-effects-section': + title: 5.6 Side effects of animation + status: unknown + '#programming-interface': + title: 6 Programming interface + status: unknown + '#time-values-in-the-programming-interface': + title: 6.1 Time values in the programming interface + status: unknown + '#the-animationtimeline-interface': + title: 6.2 The AnimationTimeline interface + status: unknown + '#the-documenttimeline-interface': + title: 6.3 The DocumentTimeline interface + status: unknown + '#the-animation-interface': + title: 6.4 The Animation interface + status: unknown + '#the-animationplaystate-enumeration': + title: 6.4.1 The AnimationPlayState enumeration + status: unknown + '#the-animationreplacestate-enumeration': + title: 6.4.2 The AnimationReplaceState enumeration + status: unknown + '#the-animationeffect-interface': + title: 6.5 The AnimationEffect interface + status: unknown + '#the-effecttiming-dictionaries': + title: 6.5.1 The EffectTiming and OptionalEffectTiming dictionaries + status: unknown + '#the-fillmode-enumeration': + title: 6.5.2 The FillMode enumeration + status: unknown + '#the-playbackdirection-enumeration': + title: 6.5.3 The PlaybackDirection enumeration + status: unknown + '#updating-animationeffect-timing': + title: 6.5.4 Updating the timing of an AnimationEffect + status: unknown + '#the-computedeffecttiming-dictionary': + title: 6.5.5 The ComputedEffectTiming dictionary + status: unknown + '#the-keyframeeffect-interface': + title: 6.6 The KeyframeEffect interface + status: unknown + '#creating-a-new-keyframeeffect-object': + title: 6.6.1 Creating a new KeyframeEffect object + status: unknown + '#property-name-conversion': + title: 6.6.2 Property names and IDL names + status: unknown + '#processing-a-keyframes-argument': + title: 6.6.3 Processing a keyframes argument + status: unknown + '#the-keyframeeffectoptions-dictionary': + title: 6.6.4 The KeyframeEffectOptions dictionary + status: unknown + '#the-compositeoperation-enumeration': + title: 6.7 The CompositeOperation and CompositeOperationOrAuto enumerations + status: unknown + '#the-animatable-interface-mixin': + title: 6.8 The Animatable interface mixin + status: unknown + '#extensions-to-the-document-interface': + title: 6.9 Extensions to the Document interface + status: unknown + '#extensions-to-the-documentorshadowroot-interface-mixin': + title: 6.10 Extensions to the DocumentOrShadowRoot interface mixin + status: unknown + '#extensions-to-the-element-interface': + title: 6.11 Extensions to the Element interface + status: unknown + '#the-animationplaybackevent-interface': + title: 6.12 The AnimationPlaybackEvent interface + status: unknown + '#model-liveness': + title: 6.13 Model liveness + status: unknown + '#integration-with-media-fragments': + title: 7 Integration with Media Fragments + status: unknown + '#interaction-with-page-display': + title: 8 Interaction with page display + status: unknown + '#implementation-requirements': + title: 9 Implementation requirements + status: unknown + '#precision-of-time-values': + title: 9.1 Precision of time values + status: unknown + '#conformance-criteria': + title: 9.2 Conformance criteria + status: unknown + '#acknowledgements': + title: 10 Acknowledgements + status: unknown + '#changes-since-last-publication': + title: 11 Changes since last publication + status: unknown From eaed9f1efe64e8c7d99f7fb58dbe33d4b6d23cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:14:01 +0100 Subject: [PATCH 083/150] Track the whole HTML standard --- Spec/README.md | 2 +- Spec/html.yml | 1605 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1606 insertions(+), 1 deletion(-) diff --git a/Spec/README.md b/Spec/README.md index 893b2a15c..7568e1374 100644 --- a/Spec/README.md +++ b/Spec/README.md @@ -4,7 +4,7 @@ ## Manifest -`<spec>.yml` has the spec's `title` and `url`, and one entry per section, keyed by its anchor, with the numbered title, the `status` and, if anything is missing, the `gaps`. Every section of the spec is listed, except the subsections of an `n/a` section, so the report also shows what hasn't been reviewed yet. For HTML that's limited to the chapters that matter for rendering a static document: 2.3 microsyntaxes, 3.2 elements, 4 the elements of HTML, 13 the HTML syntax, 15 rendering and 16 obsolete features. +`<spec>.yml` has the spec's `title` and `url`, and one entry per section, keyed by its anchor, with the numbered title, the `status` and, if anything is missing, the `gaps`. Every section of the spec is listed, except the subsections of an `n/a` section, so the report also shows what hasn't been reviewed yet. Each CSS module level has its own manifest, named like its /TR URL, e.g. `css-color-4.yml`. Where one level replaces the previous, only the newer one is tracked (`css-cascade-5`, `selectors-4`), a level that only adds to the previous one is tracked next to it (`css-color-4` and `css-color-5`). A module is a single page, so its sections are keyed by the bare anchor, e.g. `#break-between`. diff --git a/Spec/html.yml b/Spec/html.yml index 60a4e0c69..85ebfa41f 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -3,6 +3,117 @@ title: HTML Living Standard url: https://html.spec.whatwg.org/multipage/ snapshot: 7a1d2b8107ef368c4951f15f091d82fb62e59dde sections: + introduction.html#introduction: + title: 1 Introduction + status: unknown + introduction.html#abstract: + title: 1.1 Where does this specification fit? + status: unknown + introduction.html#is-this-html5?: + title: 1.2 Is this HTML5? + status: unknown + introduction.html#background: + title: 1.3 Background + status: unknown + introduction.html#audience: + title: 1.4 Audience + status: unknown + introduction.html#scope: + title: 1.5 Scope + status: unknown + introduction.html#history-2: + title: 1.6 History + status: unknown + introduction.html#design-notes: + title: 1.7 Design notes + status: unknown + introduction.html#serialisability-of-script-execution: + title: 1.7.1 Serializability of script execution + status: unknown + introduction.html#extensibility: + title: 1.7.2 Extensibility + status: unknown + introduction.html#html-vs-xhtml: + title: 1.8 HTML vs XML syntax + status: unknown + introduction.html#structure-of-this-specification: + title: 1.9 Structure of this specification + status: unknown + introduction.html#how-to-read-this-specification: + title: 1.9.1 How to read this specification + status: unknown + introduction.html#typographic-conventions: + title: 1.9.2 Typographic conventions + status: unknown + introduction.html#a-quick-introduction-to-html: + title: 1.10 A quick introduction to HTML + status: unknown + introduction.html#writing-secure-applications-with-html: + title: 1.10.1 Writing secure applications with HTML + status: unknown + introduction.html#common-pitfalls-to-avoid-when-using-the-scripting-apis: + title: 1.10.2 Common pitfalls to avoid when using the scripting APIs + status: unknown + introduction.html#how-to-catch-mistakes-when-writing-html:-validators-and-conformance-checkers: + title: '1.10.3 How to catch mistakes when writing HTML: validators and conformance checkers' + status: unknown + introduction.html#conformance-requirements-for-authors: + title: 1.11 Conformance requirements for authors + status: unknown + introduction.html#presentational-markup: + title: 1.11.1 Presentational markup + status: unknown + introduction.html#syntax-errors: + title: 1.11.2 Syntax errors + status: unknown + introduction.html#restrictions-on-content-models-and-on-attribute-values: + title: 1.11.3 Restrictions on content models and on attribute values + status: unknown + introduction.html#suggested-reading: + title: 1.12 Suggested reading + status: unknown + infrastructure.html#infrastructure: + title: 2 Common infrastructure + status: unknown + infrastructure.html#terminology: + title: 2.1 Terminology + status: unknown + infrastructure.html#parallelism: + title: 2.1.1 Parallelism + status: unknown + infrastructure.html#resources: + title: 2.1.2 Resources + status: unknown + infrastructure.html#xml: + title: 2.1.3 XML compatibility + status: unknown + infrastructure.html#dom-trees: + title: 2.1.4 DOM trees + status: unknown + infrastructure.html#scripting-2: + title: 2.1.5 Scripting + status: unknown + infrastructure.html#plugins: + title: 2.1.6 Plugins + status: unknown + infrastructure.html#encoding-terminology: + title: 2.1.7 Character encodings + status: unknown + infrastructure.html#conformance-classes: + title: 2.1.8 Conformance classes + status: unknown + infrastructure.html#dependencies: + title: 2.1.9 Dependencies + status: unknown + infrastructure.html#extensibility-2: + title: 2.1.10 Extensibility + status: unknown + infrastructure.html#interactions-with-xpath-and-xslt: + title: 2.1.11 Interactions with XPath and XSLT + status: unknown + infrastructure.html#policy-controlled-features: + title: 2.2 Policy-controlled features + status: unknown common-microsyntaxes.html#common-microsyntaxes: title: 2.3 Common microsyntaxes status: partial @@ -76,6 +187,141 @@ sections: common-microsyntaxes.html#unique-values: title: 2.3.11 Unique internal values status: n/a + urls-and-fetching.html#urls: + title: 2.4 URLs + status: unknown + urls-and-fetching.html#terminology-2: + title: 2.4.1 Terminology + status: unknown + urls-and-fetching.html#resolving-urls: + title: 2.4.2 Parsing URLs + status: unknown + urls-and-fetching.html#document-base-urls: + title: 2.4.3 Document base URLs + status: unknown + urls-and-fetching.html#fetching-resources: + title: 2.5 Fetching resources + status: unknown + urls-and-fetching.html#terminology-3: + title: 2.5.1 Terminology + status: unknown + urls-and-fetching.html#content-type-sniffing: + title: 2.5.2 Determining the type of a resource + status: unknown + urls-and-fetching.html#extracting-character-encodings-from-meta-elements: + title: 2.5.3 Extracting character encodings from meta elements + status: unknown + urls-and-fetching.html#cors-settings-attributes: + title: 2.5.4 CORS settings attributes + status: unknown + urls-and-fetching.html#referrer-policy-attributes: + title: 2.5.5 Referrer policy attributes + status: unknown + urls-and-fetching.html#nonce-attributes: + title: 2.5.6 Nonce attributes + status: unknown + urls-and-fetching.html#lazy-loading-attributes: + title: 2.5.7 Lazy loading attributes + status: unknown + urls-and-fetching.html#blocking-attributes: + title: 2.5.8 Blocking attributes + status: unknown + urls-and-fetching.html#fetch-priority-attributes: + title: 2.5.9 Fetch priority attributes + status: unknown + common-dom-interfaces.html#common-dom-interfaces: + title: 2.6 Common DOM interfaces + status: unknown + common-dom-interfaces.html#reflecting-content-attributes-in-idl-attributes: + title: 2.6.1 Reflecting content attributes in IDL attributes + status: unknown + common-dom-interfaces.html#using-reflect-via-idl-extended-attributes: + title: 2.6.2 Using reflect via IDL extended attributes + status: unknown + common-dom-interfaces.html#using-reflect-in-specifications: + title: 2.6.3 Using reflect in specifications + status: unknown + common-dom-interfaces.html#collections: + title: 2.6.4 Collections + status: unknown + common-dom-interfaces.html#the-htmlallcollection-interface: + title: 2.6.4.1 The HTMLAllCollection interface + status: unknown + common-dom-interfaces.html#HTMLAllCollection-call: + title: 2.6.4.1.1 [[Call]] ( thisArgument, argumentsList ) + status: unknown + common-dom-interfaces.html#the-htmlformcontrolscollection-interface: + title: 2.6.4.2 The HTMLFormControlsCollection interface + status: unknown + common-dom-interfaces.html#the-htmloptionscollection-interface: + title: 2.6.4.3 The HTMLOptionsCollection interface + status: unknown + common-dom-interfaces.html#the-domstringlist-interface: + title: 2.6.5 The DOMStringList interface + status: unknown + structured-data.html#safe-passing-of-structured-data: + title: 2.7 Safe passing of structured data + status: unknown + structured-data.html#serializable-objects: + title: 2.7.1 Serializable objects + status: unknown + structured-data.html#transferable-objects: + title: 2.7.2 Transferable objects + status: unknown + structured-data.html#restricting-to-the-same-agent-cluster: + title: 2.7.3 Restricting to the same agent cluster + status: unknown + structured-data.html#structuredserializeinternal: + title: 2.7.4 StructuredSerializeInternal ( value, forStorage [ , memory ] ) + status: unknown + structured-data.html#structuredserialize: + title: 2.7.5 StructuredSerialize ( value ) + status: unknown + structured-data.html#structuredserializeforstorage: + title: 2.7.6 StructuredSerializeForStorage ( value ) + status: unknown + structured-data.html#structureddeserialize: + title: 2.7.7 StructuredDeserialize ( serialized, targetRealm [ , memory ] ) + status: unknown + structured-data.html#structuredserializewithtransfer: + title: 2.7.8 StructuredSerializeWithTransfer ( value, transferList ) + status: unknown + structured-data.html#structureddeserializewithtransfer: + title: 2.7.9 StructuredDeserializeWithTransfer ( serializeWithTransferResult, targetRealm ) + status: unknown + structured-data.html#performing-structured-clones-from-other-specifications: + title: 2.7.10 Performing serialization and transferring from other specifications + status: unknown + structured-data.html#structured-cloning: + title: 2.7.11 Structured cloning API + status: unknown + dom.html#dom: + title: 3 Semantics, structure, and APIs of HTML documents + status: unknown + dom.html#documents: + title: 3.1 Documents + status: unknown + dom.html#the-document-object: + title: 3.1.1 The Document object + status: unknown + dom.html#the-documentorshadowroot-interface: + title: 3.1.2 The DocumentOrShadowRoot interface + status: unknown + dom.html#ancestor-origins: + title: 3.1.3 Ancestor origins + status: unknown + dom.html#resource-metadata-management: + title: 3.1.4 Resource metadata management + status: unknown + dom.html#reporting-document-loading-status: + title: 3.1.5 Reporting document loading status + status: unknown + dom.html#render-blocking-mechanism: + title: 3.1.6 Render-blocking mechanism + status: unknown + dom.html#dom-tree-accessors: + title: 3.1.7 DOM tree accessors + status: unknown dom.html#elements: title: 3.2 Elements status: partial @@ -1145,6 +1391,1323 @@ sections: - ":valid, :invalid, :in-range and :out-of-range never match" - ":dir(), :heading and :open never match" - ":paused and :muted never match a media element" + microdata.html#microdata: + title: 5 Microdata + status: unknown + microdata.html#introduction-7: + title: 5.1 Introduction + status: unknown + microdata.html#overview: + title: 5.1.1 Overview + status: unknown + microdata.html#the-basic-syntax: + title: 5.1.2 The basic syntax + status: unknown + microdata.html#typed-items: + title: 5.1.3 Typed items + status: unknown + microdata.html#global-identifiers-for-items: + title: 5.1.4 Global identifiers for items + status: unknown + microdata.html#selecting-names-when-defining-vocabularies: + title: 5.1.5 Selecting names when defining vocabularies + status: unknown + microdata.html#encoding-microdata: + title: 5.2 Encoding microdata + status: unknown + microdata.html#the-microdata-model: + title: 5.2.1 The microdata model + status: unknown + microdata.html#items: + title: 5.2.2 Items + status: unknown + microdata.html#names:-the-itemprop-attribute: + title: '5.2.3 Names: the itemprop attribute' + status: unknown + microdata.html#values: + title: 5.2.4 Values + status: unknown + microdata.html#associating-names-with-items: + title: 5.2.5 Associating names with items + status: unknown + microdata.html#microdata-and-other-namespaces: + title: 5.2.6 Microdata and other namespaces + status: unknown + microdata.html#mdvocabs: + title: 5.3 Sample microdata vocabularies + status: unknown + microdata.html#vcard: + title: 5.3.1 vCard + status: unknown + microdata.html#conversion-to-vcard: + title: 5.3.1.1 Conversion to vCard + status: unknown + microdata.html#examples-2: + title: 5.3.1.2 Examples + status: unknown + microdata.html#vevent: + title: 5.3.2 vEvent + status: unknown + microdata.html#conversion-to-icalendar: + title: 5.3.2.1 Conversion to iCalendar + status: unknown + microdata.html#examples-3: + title: 5.3.2.2 Examples + status: unknown + microdata.html#licensing-works: + title: 5.3.3 Licensing works + status: unknown + microdata.html#examples-4: + title: 5.3.3.1 Examples + status: unknown + microdata.html#converting-html-to-other-formats: + title: 5.4 Converting HTML to other formats + status: unknown + microdata.html#json: + title: 5.4.1 JSON + status: unknown + interaction.html#editing: + title: 6 User interaction + status: unknown + interaction.html#the-hidden-attribute: + title: 6.1 The hidden attribute + status: unknown + interaction.html#page-visibility: + title: 6.2 Page visibility + status: unknown + interaction.html#the-visibilitystateentry-interface: + title: 6.2.1 The VisibilityStateEntry interface + status: unknown + interaction.html#inert-subtrees: + title: 6.3 Inert subtrees + status: unknown + interaction.html#modal-dialogs-and-inert-subtrees: + title: 6.3.1 Modal dialogs and inert subtrees + status: unknown + interaction.html#the-inert-attribute: + title: 6.3.2 The inert attribute + status: unknown + interaction.html#tracking-user-activation: + title: 6.4 Tracking user activation + status: unknown + interaction.html#user-activation-data-model: + title: 6.4.1 Data model + status: unknown + interaction.html#user-activation-processing-model: + title: 6.4.2 Processing model + status: unknown + interaction.html#user-activation-gated-apis: + title: 6.4.3 APIs gated by user activation + status: unknown + interaction.html#the-useractivation-interface: + title: 6.4.4 The UserActivation interface + status: unknown + interaction.html#user-activation-user-agent-automation: + title: 6.4.5 User agent automation + status: unknown + interaction.html#activation: + title: 6.5 Activation behavior of elements + status: unknown + interaction.html#the-toggleevent-interface: + title: 6.5.1 The ToggleEvent interface + status: unknown + interaction.html#the-commandevent-interface: + title: 6.5.2 The CommandEvent interface + status: unknown + interaction.html#focus: + title: 6.6 Focus + status: unknown + interaction.html#introduction-8: + title: 6.6.1 Introduction + status: unknown + interaction.html#data-model: + title: 6.6.2 Data model + status: unknown + interaction.html#the-tabindex-attribute: + title: 6.6.3 The tabindex attribute + status: unknown + interaction.html#focus-processing-model: + title: 6.6.4 Processing model + status: unknown + interaction.html#sequential-focus-navigation: + title: 6.6.5 Sequential focus navigation + status: unknown + interaction.html#focus-management-apis: + title: 6.6.6 Focus management APIs + status: unknown + interaction.html#the-autofocus-attribute: + title: 6.6.7 The autofocus attribute + status: unknown + interaction.html#assigning-keyboard-shortcuts: + title: 6.7 Assigning keyboard shortcuts + status: unknown + interaction.html#introduction-9: + title: 6.7.1 Introduction + status: unknown + interaction.html#the-accesskey-attribute: + title: 6.7.2 The accesskey attribute + status: unknown + interaction.html#keyboard-shortcuts-processing-model: + title: 6.7.3 Processing model + status: unknown + interaction.html#editing-2: + title: 6.8 Editing + status: unknown + interaction.html#contenteditable: + title: '6.8.1 Making document regions editable: The contenteditable content attribute' + status: unknown + interaction.html#making-entire-documents-editable:-the-designmode-idl-attribute: + title: '6.8.2 Making entire documents editable: the designMode getter and setter' + status: unknown + interaction.html#best-practices-for-in-page-editors: + title: 6.8.3 Best practices for in-page editors + status: unknown + interaction.html#editing-apis: + title: 6.8.4 Editing APIs + status: unknown + interaction.html#spelling-and-grammar-checking: + title: 6.8.5 Spelling and grammar checking + status: unknown + interaction.html#writing-suggestions: + title: 6.8.6 Writing suggestions + status: unknown + interaction.html#autocapitalization: + title: 6.8.7 Autocapitalization + status: unknown + interaction.html#autocorrection: + title: 6.8.8 Autocorrection + status: unknown + interaction.html#input-modalities:-the-inputmode-attribute: + title: '6.8.9 Input modalities: the inputmode attribute' + status: unknown + interaction.html#input-modalities:-the-enterkeyhint-attribute: + title: '6.8.10 Input modalities: the enterkeyhint attribute' + status: unknown + interaction.html#find-in-page: + title: 6.9 Find-in-page + status: unknown + interaction.html#introduction-10: + title: 6.9.1 Introduction + status: unknown + interaction.html#interaction-with-details-and-hidden=until-found: + title: 6.9.2 Interaction with details and hidden=until-found + status: unknown + interaction.html#interaction-with-selection: + title: 6.9.3 Interaction with selection + status: unknown + interaction.html#close-requests-and-close-watchers: + title: 6.10 Close requests and close watchers + status: unknown + interaction.html#close-requests: + title: 6.10.1 Close requests + status: unknown + interaction.html#close-watcher-infrastructure: + title: 6.10.2 Close watcher infrastructure + status: unknown + interaction.html#the-closewatcher-interface: + title: 6.10.3 The CloseWatcher interface + status: unknown + dnd.html#dnd: + title: 6.11 Drag and drop + status: unknown + dnd.html#event-drag: + title: 6.11.1 Introduction + status: unknown + dnd.html#the-drag-data-store: + title: 6.11.2 The drag data store + status: unknown + dnd.html#the-datatransfer-interface: + title: 6.11.3 The DataTransfer interface + status: unknown + dnd.html#the-datatransferitemlist-interface: + title: 6.11.3.1 The DataTransferItemList interface + status: unknown + dnd.html#the-datatransferitem-interface: + title: 6.11.3.2 The DataTransferItem interface + status: unknown + dnd.html#the-dragevent-interface: + title: 6.11.4 The DragEvent interface + status: unknown + dnd.html#drag-and-drop-processing-model: + title: 6.11.5 Processing model + status: unknown + dnd.html#dndevents: + title: 6.11.6 Events summary + status: unknown + dnd.html#the-draggable-attribute: + title: 6.11.7 The draggable attribute + status: unknown + dnd.html#security-risks-in-the-drag-and-drop-model: + title: 6.11.8 Security risks in the drag-and-drop model + status: unknown + popover.html#the-popover-attribute: + title: 6.12 The popover attribute + status: unknown + popover.html#the-popover-target-attributes: + title: 6.12.1 The popover target attributes + status: unknown + popover.html#popover-light-dismiss: + title: 6.12.2 Popover light dismiss + status: unknown + browsers.html#browsers: + title: 7 Loading web pages + status: unknown + browsers.html#loading-web-pages-supporting-concepts: + title: 7.1 Supporting concepts + status: unknown + browsers.html#origin: + title: 7.1.1 Origins + status: unknown + browsers.html#sites: + title: 7.1.1.1 Sites + status: unknown + browsers.html#relaxing-the-same-origin-restriction: + title: 7.1.1.2 Relaxing the same-origin restriction + status: unknown + browsers.html#the-origin-interface: + title: 7.1.1.3 The Origin interface + status: unknown + browsers.html#origin-keyed-agent-clusters: + title: 7.1.2 Origin-keyed agent clusters + status: unknown + browsers.html#cross-origin-opener-policies: + title: 7.1.3 Cross-origin opener policies + status: unknown + browsers.html#the-coop-headers: + title: 7.1.3.1 The headers + status: unknown + browsers.html#browsing-context-group-switches-due-to-cross-origin-opener-policy: + title: 7.1.3.2 Browsing context group switches due to opener policy + status: unknown + browsers.html#coop-reporting: + title: 7.1.3.3 Reporting + status: unknown + browsers.html#coep: + title: 7.1.4 Cross-origin embedder policies + status: unknown + browsers.html#the-coep-headers: + title: 7.1.4.1 The headers + status: unknown + browsers.html#embedder-policy-checks: + title: 7.1.4.2 Embedder policy checks + status: unknown + browsers.html#sandboxing: + title: 7.1.5 Sandboxing + status: unknown + browsers.html#iframe-element-referrer-policy: + title: 7.1.6 iframe element referrer policy + status: unknown + browsers.html#policy-containers: + title: 7.1.7 Policy containers + status: unknown + nav-history-apis.html#nav-traversal-apis: + title: 7.2 APIs related to navigation and session history + status: unknown + nav-history-apis.html#cross-origin-objects: + title: 7.2.1 Security infrastructure for Window, WindowProxy, and Location objects + status: unknown + nav-history-apis.html#integration-with-idl: + title: 7.2.1.1 Integration with IDL + status: unknown + nav-history-apis.html#shared-internal-slot:-crossoriginpropertydescriptormap: + title: '7.2.1.2 Shared internal slot: [[CrossOriginPropertyDescriptorMap]]' + status: unknown + nav-history-apis.html#shared-abstract-operations: + title: 7.2.1.3 Shared abstract operations + status: unknown + nav-history-apis.html#crossoriginproperties-(-o-): + title: 7.2.1.3.1 CrossOriginProperties ( O ) + status: unknown + nav-history-apis.html#crossoriginpropertyfallback-(-p-): + title: 7.2.1.3.2 CrossOriginPropertyFallback ( P ) + status: unknown + nav-history-apis.html#isplatformobjectsameorigin-(-o-): + title: 7.2.1.3.3 IsPlatformObjectSameOrigin ( O ) + status: unknown + nav-history-apis.html#crossorigingetownpropertyhelper-(-o,-p-): + title: 7.2.1.3.4 CrossOriginGetOwnPropertyHelper ( O, P ) + status: unknown + nav-history-apis.html#crossoriginget-(-o,-p,-receiver-): + title: 7.2.1.3.5 CrossOriginGet ( O, P, Receiver ) + status: unknown + nav-history-apis.html#crossoriginset-(-o,-p,-v,-receiver-): + title: 7.2.1.3.6 CrossOriginSet ( O, P, V, Receiver ) + status: unknown + nav-history-apis.html#crossoriginownpropertykeys-(-o-): + title: 7.2.1.3.7 CrossOriginOwnPropertyKeys ( O ) + status: unknown + nav-history-apis.html#the-window-object: + title: 7.2.2 The Window object + status: unknown + nav-history-apis.html#apis-for-creating-and-navigating-browsing-contexts-by-name: + title: 7.2.2.1 Opening and closing windows + status: unknown + nav-history-apis.html#accessing-other-browsing-contexts: + title: 7.2.2.2 Indexed access on the Window object + status: unknown + nav-history-apis.html#named-access-on-the-window-object: + title: 7.2.2.3 Named access on the Window object + status: unknown + nav-history-apis.html#navigating-nested-browsing-contexts-in-the-dom: + title: 7.2.2.4 Accessing related windows + status: unknown + nav-history-apis.html#browser-interface-elements: + title: 7.2.2.5 Historical browser interface element APIs + status: unknown + nav-history-apis.html#script-settings-for-window-objects: + title: 7.2.2.6 Script settings for Window objects + status: unknown + nav-history-apis.html#the-windowproxy-exotic-object: + title: 7.2.3 The WindowProxy exotic object + status: unknown + nav-history-apis.html#windowproxy-getprototypeof: + title: 7.2.3.1 [[GetPrototypeOf]] ( ) + status: unknown + nav-history-apis.html#windowproxy-setprototypeof: + title: 7.2.3.2 [[SetPrototypeOf]] ( V ) + status: unknown + nav-history-apis.html#windowproxy-isextensible: + title: 7.2.3.3 [[IsExtensible]] ( ) + status: unknown + nav-history-apis.html#windowproxy-preventextensions: + title: 7.2.3.4 [[PreventExtensions]] ( ) + status: unknown + nav-history-apis.html#windowproxy-getownproperty: + title: 7.2.3.5 [[GetOwnProperty]] ( P ) + status: unknown + nav-history-apis.html#windowproxy-defineownproperty: + title: 7.2.3.6 [[DefineOwnProperty]] ( P, Desc ) + status: unknown + nav-history-apis.html#windowproxy-get: + title: 7.2.3.7 [[Get]] ( P, Receiver ) + status: unknown + nav-history-apis.html#windowproxy-set: + title: 7.2.3.8 [[Set]] ( P, V, Receiver ) + status: unknown + nav-history-apis.html#windowproxy-delete: + title: 7.2.3.9 [[Delete]] ( P ) + status: unknown + nav-history-apis.html#windowproxy-ownpropertykeys: + title: 7.2.3.10 [[OwnPropertyKeys]] ( ) + status: unknown + nav-history-apis.html#the-location-interface: + title: 7.2.4 The Location interface + status: unknown + nav-history-apis.html#location-getprototypeof: + title: 7.2.4.1 [[GetPrototypeOf]] ( ) + status: unknown + nav-history-apis.html#location-setprototypeof: + title: 7.2.4.2 [[SetPrototypeOf]] ( V ) + status: unknown + nav-history-apis.html#location-isextensible: + title: 7.2.4.3 [[IsExtensible]] ( ) + status: unknown + nav-history-apis.html#location-preventextensions: + title: 7.2.4.4 [[PreventExtensions]] ( ) + status: unknown + nav-history-apis.html#location-getownproperty: + title: 7.2.4.5 [[GetOwnProperty]] ( P ) + status: unknown + nav-history-apis.html#location-defineownproperty: + title: 7.2.4.6 [[DefineOwnProperty]] ( P, Desc ) + status: unknown + nav-history-apis.html#location-get: + title: 7.2.4.7 [[Get]] ( P, Receiver ) + status: unknown + nav-history-apis.html#location-set: + title: 7.2.4.8 [[Set]] ( P, V, Receiver ) + status: unknown + nav-history-apis.html#location-delete: + title: 7.2.4.9 [[Delete]] ( P ) + status: unknown + nav-history-apis.html#location-ownpropertykeys: + title: 7.2.4.10 [[OwnPropertyKeys]] ( ) + status: unknown + nav-history-apis.html#the-history-interface: + title: 7.2.5 The History interface + status: unknown + nav-history-apis.html#navigation-api: + title: 7.2.6 The navigation API + status: unknown + nav-history-apis.html#navigation-api-intro: + title: 7.2.6.1 Introduction + status: unknown + nav-history-apis.html#navigation-interface: + title: 7.2.6.2 The Navigation interface + status: unknown + nav-history-apis.html#navigation-api-core: + title: 7.2.6.3 Core infrastructure + status: unknown + nav-history-apis.html#navigation-api-entry-updates: + title: 7.2.6.4 Initializing and updating the entry list + status: unknown + nav-history-apis.html#the-navigationhistoryentry-interface: + title: 7.2.6.5 The NavigationHistoryEntry interface + status: unknown + nav-history-apis.html#the-history-entry-list: + title: 7.2.6.6 The history entry list + status: unknown + nav-history-apis.html#navigation-api-initiating-navigations: + title: 7.2.6.7 Initiating navigations + status: unknown + nav-history-apis.html#ongoing-navigation-tracking: + title: 7.2.6.8 Ongoing navigation tracking + status: unknown + nav-history-apis.html#navigation-activation-interface: + title: 7.2.6.9 The NavigationActivation interface + status: unknown + nav-history-apis.html#the-navigate-event: + title: 7.2.6.10 The navigate event + status: unknown + nav-history-apis.html#the-navigateevent-interface: + title: 7.2.6.10.1 The NavigateEvent interface + status: unknown + nav-history-apis.html#the-navigationprecommitcontroller-interface: + title: 7.2.6.10.2 The NavigationPrecommitController interface + status: unknown + nav-history-apis.html#the-navigationdestination-interface: + title: 7.2.6.10.3 The NavigationDestination interface + status: unknown + nav-history-apis.html#navigate-event-firing: + title: 7.2.6.10.4 Firing the event + status: unknown + nav-history-apis.html#navigate-event-scroll-focus: + title: 7.2.6.10.5 Scroll and focus behavior + status: unknown + nav-history-apis.html#nav-traversal-event-interfaces: + title: 7.2.7 Event interfaces + status: unknown + nav-history-apis.html#the-navigationcurrententrychangeevent-interface: + title: 7.2.7.1 The NavigationCurrentEntryChangeEvent interface + status: unknown + nav-history-apis.html#the-popstateevent-interface: + title: 7.2.7.2 The PopStateEvent interface + status: unknown + nav-history-apis.html#the-hashchangeevent-interface: + title: 7.2.7.3 The HashChangeEvent interface + status: unknown + nav-history-apis.html#the-pageswapevent-interface: + title: 7.2.7.4 The PageSwapEvent interface + status: unknown + nav-history-apis.html#the-pagerevealevent-interface: + title: 7.2.7.5 The PageRevealEvent interface + status: unknown + nav-history-apis.html#the-pagetransitionevent-interface: + title: 7.2.7.6 The PageTransitionEvent interface + status: unknown + nav-history-apis.html#the-beforeunloadevent-interface: + title: 7.2.7.7 The BeforeUnloadEvent interface + status: unknown + nav-history-apis.html#the-notrestoredreasons-interface: + title: 7.2.8 The NotRestoredReasons interface + status: unknown + document-sequences.html#infrastructure-for-sequences-of-documents: + title: 7.3 Infrastructure for sequences of documents + status: unknown + document-sequences.html#navigables: + title: 7.3.1 Navigables + status: unknown + document-sequences.html#traversable-navigables: + title: 7.3.1.1 Traversable navigables + status: unknown + document-sequences.html#top-level-traversables: + title: 7.3.1.2 Top-level traversables + status: unknown + document-sequences.html#child-navigables: + title: 7.3.1.3 Child navigables + status: unknown + document-sequences.html#jake-diagrams: + title: 7.3.1.4 Jake diagrams + status: unknown + document-sequences.html#related-navigable-collections: + title: 7.3.1.5 Related navigable collections + status: unknown + document-sequences.html#garbage-collection-and-browsing-contexts: + title: 7.3.1.6 Navigable destruction + status: unknown + document-sequences.html#navigable-target-names: + title: 7.3.1.7 Navigable target names + status: unknown + document-sequences.html#windows: + title: 7.3.2 Browsing contexts + status: unknown + document-sequences.html#creating-browsing-contexts: + title: 7.3.2.1 Creating browsing contexts + status: unknown + document-sequences.html#nested-browsing-contexts: + title: 7.3.2.2 Related browsing contexts + status: unknown + document-sequences.html#groupings-of-browsing-contexts: + title: 7.3.2.3 Groupings of browsing contexts + status: unknown + document-sequences.html#fully-active-documents: + title: 7.3.3 Fully active documents + status: unknown + browsing-the-web.html#navigation-and-session-history: + title: 7.4 Navigation and session history + status: unknown + browsing-the-web.html#session-history-infrastructure: + title: 7.4.1 Session history + status: unknown + browsing-the-web.html#session-history-entries: + title: 7.4.1.1 Session history entries + status: unknown + browsing-the-web.html#document-state: + title: 7.4.1.2 Document state + status: unknown + browsing-the-web.html#centralized-modifications-of-session-history: + title: 7.4.1.3 Centralized modifications of session history + status: unknown + browsing-the-web.html#low-level-operations-on-session-history: + title: 7.4.1.4 Low-level operations on session history + status: unknown + browsing-the-web.html#navigating-across-documents: + title: 7.4.2 Navigation + status: unknown + browsing-the-web.html#navigation-supporting-concepts: + title: 7.4.2.1 Supporting concepts + status: unknown + browsing-the-web.html#beginning-navigation: + title: 7.4.2.2 Beginning navigation + status: unknown + browsing-the-web.html#ending-navigation: + title: 7.4.2.3 Ending navigation + status: unknown + browsing-the-web.html#the-usual-cross-document-navigation-case: + title: 7.4.2.3.1 The usual cross-document navigation case + status: unknown + browsing-the-web.html#the-javascript:-url-special-case: + title: '7.4.2.3.2 The javascript: URL special case' + status: unknown + browsing-the-web.html#scroll-to-fragid: + title: 7.4.2.3.3 Fragment navigations + status: unknown + browsing-the-web.html#non-fetch-schemes-and-external-software: + title: 7.4.2.3.4 Non-fetch schemes and external software + status: unknown + browsing-the-web.html#preventing-navigation: + title: 7.4.2.4 Preventing navigation + status: unknown + browsing-the-web.html#aborting-navigation: + title: 7.4.2.5 Aborting navigation + status: unknown + browsing-the-web.html#reloading-and-traversing: + title: 7.4.3 Reloading and traversing + status: unknown + browsing-the-web.html#navigate-non-frag-sync: + title: 7.4.4 Non-fragment synchronous "navigations" + status: unknown + browsing-the-web.html#populating-a-session-history-entry: + title: 7.4.5 Populating a session history entry + status: unknown + browsing-the-web.html#applying-the-history-step: + title: 7.4.6 Applying the history step + status: unknown + browsing-the-web.html#updating-the-traversable: + title: 7.4.6.1 Updating the traversable + status: unknown + browsing-the-web.html#updating-the-document: + title: 7.4.6.2 Updating the document + status: unknown + browsing-the-web.html#revealing-the-document: + title: 7.4.6.3 Revealing the document + status: unknown + browsing-the-web.html#scrolling-to-a-fragment: + title: 7.4.6.4 Scrolling to a fragment + status: unknown + browsing-the-web.html#persisted-user-state-restoration: + title: 7.4.6.5 Persisted history entry state + status: unknown + document-lifecycle.html#document-lifecycle: + title: 7.5 Document lifecycle + status: unknown + document-lifecycle.html#shared-document-creation-infrastructure: + title: 7.5.1 Shared document creation infrastructure + status: unknown + document-lifecycle.html#read-html: + title: 7.5.2 Loading HTML documents + status: unknown + document-lifecycle.html#read-xml: + title: 7.5.3 Loading XML documents + status: unknown + document-lifecycle.html#read-text: + title: 7.5.4 Loading text documents + status: unknown + document-lifecycle.html#read-multipart-x-mixed-replace: + title: 7.5.5 Loading multipart/x-mixed-replace documents + status: unknown + document-lifecycle.html#read-media: + title: 7.5.6 Loading media documents + status: unknown + document-lifecycle.html#read-ua-inline: + title: 7.5.7 Loading a document for inline content that doesn't have a DOM + status: unknown + document-lifecycle.html#loading-documents: + title: 7.5.8 Finishing the loading process + status: unknown + document-lifecycle.html#unloading-documents: + title: 7.5.9 Unloading documents + status: unknown + document-lifecycle.html#destroying-documents: + title: 7.5.10 Destroying documents + status: unknown + document-lifecycle.html#aborting-a-document-load: + title: 7.5.11 Aborting a document load + status: unknown + speculative-loading.html#speculative-loading: + title: 7.6 Speculative loading + status: unknown + speculative-loading.html#speculation-rules: + title: 7.6.1 Speculation rules + status: unknown + speculative-loading.html#speculation-rules-data-model: + title: 7.6.1.1 Data model + status: unknown + speculative-loading.html#speculation-rules-parsing: + title: 7.6.1.2 Parsing + status: unknown + speculative-loading.html#speculation-rules-processing-model: + title: 7.6.1.3 Processing model + status: unknown + speculative-loading.html#navigational-prefetching: + title: 7.6.2 Navigational prefetching + status: unknown + speculative-loading.html#the-speculation-rules-header: + title: 7.6.3 The `Speculation-Rules` header + status: unknown + speculative-loading.html#the-sec-speculation-tags-header: + title: 7.6.4 The `Sec-Speculation-Tags` header + status: unknown + speculative-loading.html#speculative-loading-security: + title: 7.6.5 Security considerations + status: unknown + speculative-loading.html#speculative-loading-cross-site-requests: + title: 7.6.5.1 Cross-site requests + status: unknown + speculative-loading.html#speculative-loading-injected-content: + title: 7.6.5.2 Injected content + status: unknown + speculative-loading.html#speculative-loading-ip-anonymization: + title: 7.6.5.3 IP anonymization + status: unknown + speculative-loading.html#speculative-loading-privacy: + title: 7.6.6 Privacy considerations + status: unknown + speculative-loading.html#speculative-loading-heuristics: + title: 7.6.6.1 Heuristics and optionality + status: unknown + speculative-loading.html#speculative-loading-state-partitioning: + title: 7.6.6.2 State partitioning + status: unknown + speculative-loading.html#speculative-loading-identity-joining: + title: 7.6.6.3 Identity joining + status: unknown + speculative-loading.html#the-x-frame-options-header: + title: 7.7 The `X-Frame-Options` header + status: unknown + speculative-loading.html#the-refresh-header: + title: 7.8 The `Refresh` header + status: unknown + speculative-loading.html#nav-traversal-ui: + title: 7.9 Browser user interface considerations + status: unknown + webappapis.html#webappapis: + title: 8 Web application APIs + status: unknown + webappapis.html#scripting: + title: 8.1 Scripting + status: unknown + webappapis.html#introduction-11: + title: 8.1.1 Introduction + status: unknown + webappapis.html#agents-and-agent-clusters: + title: 8.1.2 Agents and agent clusters + status: unknown + webappapis.html#integration-with-the-javascript-agent-formalism: + title: 8.1.2.1 Integration with the JavaScript agent formalism + status: unknown + webappapis.html#integration-with-the-javascript-agent-cluster-formalism: + title: 8.1.2.2 Integration with the JavaScript agent cluster formalism + status: unknown + webappapis.html#realms-and-their-counterparts: + title: 8.1.3 Realms and their counterparts + status: unknown + webappapis.html#environments: + title: 8.1.3.1 Environments + status: unknown + webappapis.html#environment-settings-objects: + title: 8.1.3.2 Environment settings objects + status: unknown + webappapis.html#realms-settings-objects-global-objects: + title: 8.1.3.3 Realms, settings objects, and global objects + status: unknown + webappapis.html#entry: + title: 8.1.3.3.1 Entry + status: unknown + webappapis.html#incumbent: + title: 8.1.3.3.2 Incumbent + status: unknown + webappapis.html#current: + title: 8.1.3.3.3 Current + status: unknown + webappapis.html#relevant: + title: 8.1.3.3.4 Relevant + status: unknown + webappapis.html#enabling-and-disabling-scripting: + title: 8.1.3.4 Enabling and disabling scripting + status: unknown + webappapis.html#secure-contexts: + title: 8.1.3.5 Secure contexts + status: unknown + webappapis.html#scripting-processing-model: + title: 8.1.4 Script processing model + status: unknown + webappapis.html#script-structs: + title: 8.1.4.1 Scripts + status: unknown + webappapis.html#fetching-scripts: + title: 8.1.4.2 Fetching scripts + status: unknown + webappapis.html#creating-scripts: + title: 8.1.4.3 Creating scripts + status: unknown + webappapis.html#calling-scripts: + title: 8.1.4.4 Calling scripts + status: unknown + webappapis.html#killing-scripts: + title: 8.1.4.5 Killing scripts + status: unknown + webappapis.html#runtime-script-errors: + title: 8.1.4.6 Runtime script errors + status: unknown + webappapis.html#unhandled-promise-rejections: + title: 8.1.4.7 Unhandled promise rejections + status: unknown + webappapis.html#import-map-parse-results: + title: 8.1.4.8 Import map parse results + status: unknown + webappapis.html#speculation-rules-parse-results: + title: 8.1.4.9 Speculation rules parse results + status: unknown + webappapis.html#module-specifier-resolution: + title: 8.1.5 Module specifier resolution + status: unknown + webappapis.html#the-resolution-algorithm: + title: 8.1.5.1 The resolution algorithm + status: unknown + webappapis.html#import-maps: + title: 8.1.5.2 Import maps + status: unknown + webappapis.html#import-map-processing-model: + title: 8.1.5.3 Import map processing model + status: unknown + webappapis.html#javascript-specification-host-hooks: + title: 8.1.6 JavaScript specification host hooks + status: unknown + webappapis.html#the-hostensurecanaddprivateelement-implementation: + title: 8.1.6.1 HostEnsureCanAddPrivateElement(O) + status: unknown + webappapis.html#hostensurecancompilestrings(realm,-parameterstrings,-bodystring,-codestring,-compilationtype,-parameterargs,-bodyarg): + title: 8.1.6.2 HostEnsureCanCompileStrings(realm, parameterStrings, bodyString, codeString, compilationType, parameterArgs, bodyArg) + status: unknown + webappapis.html#hostgetcodeforeval(argument): + title: 8.1.6.3 HostGetCodeForEval(argument) + status: unknown + webappapis.html#the-hostpromiserejectiontracker-implementation: + title: 8.1.6.4 HostPromiseRejectionTracker(promise, operation) + status: unknown + webappapis.html#hostsystemutcepochnanoseconds: + title: 8.1.6.5 HostSystemUTCEpochNanoseconds(global) + status: unknown + webappapis.html#integration-with-javascript-jobs: + title: 8.1.6.6 Job-related host hooks + status: unknown + webappapis.html#hostcalljobcallback: + title: 8.1.6.6.1 HostCallJobCallback(callback, V, argumentsList) + status: unknown + webappapis.html#hostenqueuefinalizationregistrycleanupjob: + title: 8.1.6.6.2 HostEnqueueFinalizationRegistryCleanupJob(finalizationRegistry) + status: unknown + webappapis.html#hostenqueuegenericjob: + title: 8.1.6.6.3 HostEnqueueGenericJob(job, realm) + status: unknown + webappapis.html#hostenqueuepromisejob: + title: 8.1.6.6.4 HostEnqueuePromiseJob(job, realm) + status: unknown + webappapis.html#hostenqueuetimeoutjob: + title: 8.1.6.6.5 HostEnqueueTimeoutJob(job, realm, milliseconds) + status: unknown + webappapis.html#hostmakejobcallback: + title: 8.1.6.6.6 HostMakeJobCallback(callable) + status: unknown + webappapis.html#integration-with-the-javascript-module-system: + title: 8.1.6.7 Module-related host hooks + status: unknown + webappapis.html#hostgetimportmetaproperties: + title: 8.1.6.7.1 HostGetImportMetaProperties(moduleRecord) + status: unknown + webappapis.html#hostgetsupportedimportattributes: + title: 8.1.6.7.2 HostGetSupportedImportAttributes() + status: unknown + webappapis.html#hostloadimportedmodule: + title: 8.1.6.7.3 HostLoadImportedModule(referrer, moduleRequest, loadState, payload) + status: unknown + webappapis.html#event-loops: + title: 8.1.7 Event loops + status: unknown + webappapis.html#definitions-3: + title: 8.1.7.1 Definitions + status: unknown + webappapis.html#queuing-tasks: + title: 8.1.7.2 Queuing tasks + status: unknown + webappapis.html#event-loop-processing-model: + title: 8.1.7.3 Processing model + status: unknown + webappapis.html#generic-task-sources: + title: 8.1.7.4 Generic task sources + status: unknown + webappapis.html#event-loop-for-spec-authors: + title: 8.1.7.5 Dealing with the event loop from other specifications + status: unknown + webappapis.html#events: + title: 8.1.8 Events + status: unknown + webappapis.html#event-handler-attributes: + title: 8.1.8.1 Event handlers + status: unknown + webappapis.html#event-handlers-on-elements,-document-objects,-and-window-objects: + title: 8.1.8.2 Event handlers on elements, Document objects, and Window objects + status: unknown + webappapis.html#idl-definitions: + title: 8.1.8.2.1 IDL definitions + status: unknown + webappapis.html#event-firing: + title: 8.1.8.3 Event firing + status: unknown + webappapis.html#windoworworkerglobalscope-mixin: + title: 8.2 The WindowOrWorkerGlobalScope mixin + status: unknown + webappapis.html#atob: + title: 8.3 Base64 utility methods + status: unknown + dynamic-markup-insertion.html#dynamic-markup-insertion: + title: 8.4 Dynamic markup insertion + status: unknown + dynamic-markup-insertion.html#opening-the-input-stream: + title: 8.4.1 Opening the input stream + status: unknown + dynamic-markup-insertion.html#closing-the-input-stream: + title: 8.4.2 Closing the input stream + status: unknown + dynamic-markup-insertion.html#document.write(): + title: 8.4.3 document.write() + status: unknown + dynamic-markup-insertion.html#document.writeln(): + title: 8.4.4 document.writeln() + status: unknown + dynamic-markup-insertion.html#dom-parsing-and-serialization: + title: 8.5 DOM parsing and serialization APIs + status: unknown + dynamic-markup-insertion.html#the-domparser-interface: + title: 8.5.1 The DOMParser interface + status: unknown + dynamic-markup-insertion.html#html-parsing-methods: + title: 8.5.2 HTML parsing and insertion methods + status: unknown + dynamic-markup-insertion.html#html-streaming-methods: + title: 8.5.3 HTML streaming methods + status: unknown + dynamic-markup-insertion.html#html-serialization-methods: + title: 8.5.4 HTML serialization methods + status: unknown + dynamic-markup-insertion.html#the-innerhtml-property: + title: 8.5.5 The innerHTML property + status: unknown + dynamic-markup-insertion.html#the-outerhtml-property: + title: 8.5.6 The outerHTML property + status: unknown + dynamic-markup-insertion.html#the-insertadjacenthtml()-method: + title: 8.5.7 The insertAdjacentHTML() method + status: unknown + dynamic-markup-insertion.html#the-createcontextualfragment()-method: + title: 8.5.8 The createContextualFragment() method + status: unknown + dynamic-markup-insertion.html#the-xmlserializer-interface: + title: 8.5.9 The XMLSerializer interface + status: unknown + dynamic-markup-insertion.html#html-sanitization: + title: 8.6 HTML sanitization + status: unknown + dynamic-markup-insertion.html#introduction-12: + title: 8.6.1 Introduction + status: unknown + dynamic-markup-insertion.html#safe-and-unsafe: + title: 8.6.1.1 Safe and unsafe + status: unknown + dynamic-markup-insertion.html#the-sanitizer-interface: + title: 8.6.2 The Sanitizer interface + status: unknown + dynamic-markup-insertion.html#sanitizer-configuration: + title: 8.6.3 Sanitizer configuration + status: unknown + dynamic-markup-insertion.html#configuration-invariants: + title: 8.6.3.1 Configuration invariants + status: unknown + dynamic-markup-insertion.html#sanitization-algorithms: + title: 8.6.4 Sanitization algorithms + status: unknown + dynamic-markup-insertion.html#sanitization-constants: + title: 8.6.5 Sanitization constants + status: unknown + dynamic-markup-insertion.html#sanitizer-security-considerations: + title: 8.6.6 Security considerations + status: unknown + dynamic-markup-insertion.html#sanitizer-security-server-reflected: + title: 8.6.6.1 Server-side reflected and stored XSS + status: unknown + dynamic-markup-insertion.html#sanitizer-security-dom-clobbering: + title: 8.6.6.2 DOM clobbering + status: unknown + dynamic-markup-insertion.html#sanitizer-security-script-gadgets: + title: 8.6.6.3 XSS with script gadgets + status: unknown + dynamic-markup-insertion.html#sanitizer-security-mxss: + title: 8.6.6.4 Mutation XSS + status: unknown + timers-and-user-prompts.html#timers: + title: 8.7 Timers + status: unknown + timers-and-user-prompts.html#microtask-queuing: + title: 8.8 Microtask queuing + status: unknown + timers-and-user-prompts.html#user-prompts: + title: 8.9 User prompts + status: unknown + timers-and-user-prompts.html#simple-dialogs: + title: 8.9.1 Simple dialogs + status: unknown + timers-and-user-prompts.html#printing: + title: 8.9.2 Printing + status: unknown + system-state.html#system-state-and-capabilities: + title: 8.10 System state and capabilities + status: unknown + system-state.html#the-navigator-object: + title: 8.10.1 The Navigator object + status: unknown + system-state.html#client-identification: + title: 8.10.1.1 Client identification + status: unknown + system-state.html#language-preferences: + title: 8.10.1.2 Language preferences + status: unknown + system-state.html#navigator.online: + title: 8.10.1.3 Browser state + status: unknown + system-state.html#custom-handlers: + title: '8.10.1.4 Custom scheme handlers: the registerProtocolHandler() method' + status: unknown + system-state.html#security-and-privacy: + title: 8.10.1.4.1 Security and privacy + status: unknown + system-state.html#user-agent-automation: + title: 8.10.1.4.2 User agent automation + status: unknown + system-state.html#cookies: + title: 8.10.1.5 Cookies + status: unknown + system-state.html#pdf-viewing-support: + title: 8.10.1.6 PDF viewing support + status: unknown + imagebitmap-and-animations.html#images-2: + title: 8.11 Images + status: unknown + imagebitmap-and-animations.html#the-imagedata-interface: + title: 8.11.1 The ImageData interface + status: unknown + imagebitmap-and-animations.html#the-imagebitmap-interface: + title: 8.11.2 The ImageBitmap interface + status: unknown + imagebitmap-and-animations.html#animation-frames: + title: 8.12 Animation frames + status: unknown + comms.html#comms: + title: 9 Communication + status: unknown + comms.html#the-messageevent-interface: + title: 9.1 The MessageEvent interface + status: unknown + server-sent-events.html#server-sent-events: + title: 9.2 Server-sent events + status: unknown + server-sent-events.html#server-sent-events-intro: + title: 9.2.1 Introduction + status: unknown + server-sent-events.html#the-eventsource-interface: + title: 9.2.2 The EventSource interface + status: unknown + server-sent-events.html#sse-processing-model: + title: 9.2.3 Processing model + status: unknown + server-sent-events.html#the-last-event-id-header: + title: 9.2.4 The `Last-Event-ID` header + status: unknown + server-sent-events.html#parsing-an-event-stream: + title: 9.2.5 Parsing an event stream + status: unknown + server-sent-events.html#event-stream-interpretation: + title: 9.2.6 Interpreting an event stream + status: unknown + server-sent-events.html#authoring-notes: + title: 9.2.7 Authoring notes + status: unknown + server-sent-events.html#eventsource-push: + title: 9.2.8 Connectionless push and other features + status: unknown + server-sent-events.html#garbage-collection: + title: 9.2.9 Garbage collection + status: unknown + server-sent-events.html#implementation-advice: + title: 9.2.10 Implementation advice + status: unknown + web-messaging.html#web-messaging: + title: 9.3 Cross-document messaging + status: unknown + web-messaging.html#introduction-13: + title: 9.3.1 Introduction + status: unknown + web-messaging.html#security-postmsg: + title: 9.3.2 Security + status: unknown + web-messaging.html#authors: + title: 9.3.2.1 Authors + status: unknown + web-messaging.html#user-agents: + title: 9.3.2.2 User agents + status: unknown + web-messaging.html#posting-messages: + title: 9.3.3 Posting messages + status: unknown + web-messaging.html#channel-messaging: + title: 9.4 Channel messaging + status: unknown + web-messaging.html#introduction-14: + title: 9.4.1 Introduction + status: unknown + web-messaging.html#examples-5: + title: 9.4.1.1 Examples + status: unknown + web-messaging.html#ports-as-the-basis-of-an-object-capability-model-on-the-web: + title: 9.4.1.2 Ports as the basis of an object-capability model on the web + status: unknown + web-messaging.html#ports-as-the-basis-of-abstracting-out-service-implementations: + title: 9.4.1.3 Ports as the basis of abstracting out service implementations + status: unknown + web-messaging.html#message-channels: + title: 9.4.2 Message channels + status: unknown + web-messaging.html#the-messageeventtarget-mixin: + title: 9.4.3 The MessageEventTarget mixin + status: unknown + web-messaging.html#message-ports: + title: 9.4.4 Message ports + status: unknown + web-messaging.html#broadcasting-to-many-ports: + title: 9.4.5 Broadcasting to many ports + status: unknown + web-messaging.html#ports-and-garbage-collection: + title: 9.4.6 Ports and garbage collection + status: unknown + web-messaging.html#broadcasting-to-other-browsing-contexts: + title: 9.5 Broadcasting to other browsing contexts + status: unknown + workers.html#workers: + title: 10 Web workers + status: unknown + workers.html#introduction-15: + title: 10.1 Introduction + status: unknown + workers.html#scope-2: + title: 10.1.1 Scope + status: unknown + workers.html#examples-6: + title: 10.1.2 Examples + status: unknown + workers.html#a-background-number-crunching-worker: + title: 10.1.2.1 A background number-crunching worker + status: unknown + workers.html#module-worker-example: + title: 10.1.2.2 Using a JavaScript module as a worker + status: unknown + workers.html#shared-workers-introduction: + title: 10.1.2.3 Shared workers introduction + status: unknown + workers.html#shared-state-using-a-shared-worker: + title: 10.1.2.4 Shared state using a shared worker + status: unknown + workers.html#delegation: + title: 10.1.2.5 Delegation + status: unknown + workers.html#providing-libraries: + title: 10.1.2.6 Providing libraries + status: unknown + workers.html#tutorials: + title: 10.1.3 Tutorials + status: unknown + workers.html#creating-a-dedicated-worker: + title: 10.1.3.1 Creating a dedicated worker + status: unknown + workers.html#communicating-with-a-dedicated-worker: + title: 10.1.3.2 Communicating with a dedicated worker + status: unknown + workers.html#shared-workers: + title: 10.1.3.3 Shared workers + status: unknown + workers.html#infrastructure-2: + title: 10.2 Infrastructure + status: unknown + workers.html#the-global-scope: + title: 10.2.1 The global scope + status: unknown + workers.html#the-workerglobalscope-common-interface: + title: 10.2.1.1 The WorkerGlobalScope common interface + status: unknown + workers.html#dedicated-workers-and-the-dedicatedworkerglobalscope-interface: + title: 10.2.1.2 Dedicated workers and the DedicatedWorkerGlobalScope interface + status: unknown + workers.html#shared-workers-and-the-sharedworkerglobalscope-interface: + title: 10.2.1.3 Shared workers and the SharedWorkerGlobalScope interface + status: unknown + workers.html#worker-event-loop: + title: 10.2.2 The event loop + status: unknown + workers.html#the-worker's-lifetime: + title: 10.2.3 The worker's lifetime + status: unknown + workers.html#worker-processing-model: + title: 10.2.4 Processing model + status: unknown + workers.html#runtime-script-errors-2: + title: 10.2.5 Runtime script errors + status: unknown + workers.html#creating-workers: + title: 10.2.6 Creating workers + status: unknown + workers.html#the-abstractworker-mixin: + title: 10.2.6.1 The AbstractWorker mixin + status: unknown + workers.html#script-settings-for-workers: + title: 10.2.6.2 Script settings for workers + status: unknown + workers.html#dedicated-workers-and-the-worker-interface: + title: 10.2.6.3 Dedicated workers and the Worker interface + status: unknown + workers.html#shared-workers-and-the-sharedworker-interface: + title: 10.2.6.4 Shared workers and the SharedWorker interface + status: unknown + workers.html#navigator.hardwareconcurrency: + title: 10.2.7 Concurrent hardware capabilities + status: unknown + workers.html#apis-available-to-workers: + title: 10.3 APIs available to workers + status: unknown + workers.html#importing-scripts-and-libraries: + title: 10.3.1 Importing scripts and libraries + status: unknown + workers.html#the-workernavigator-object: + title: 10.3.2 The WorkerNavigator interface + status: unknown + workers.html#worker-locations: + title: 10.3.3 The WorkerLocation interface + status: unknown + worklets.html#worklets: + title: 11 Worklets + status: unknown + worklets.html#worklets-intro: + title: 11.1 Introduction + status: unknown + worklets.html#worklets-motivations: + title: 11.1.1 Motivations + status: unknown + worklets.html#worklets-idempotent: + title: 11.1.2 Code idempotence + status: unknown + worklets.html#worklets-speculative: + title: 11.1.3 Speculative evaluation + status: unknown + worklets.html#worklets-examples: + title: 11.2 Examples + status: unknown + worklets.html#worklets-examples-loading: + title: 11.2.1 Loading scripts + status: unknown + worklets.html#worklets-example-registering: + title: 11.2.2 Registering a class and invoking its methods + status: unknown + worklets.html#worklets-infrastructure: + title: 11.3 Infrastructure + status: unknown + worklets.html#worklets-global: + title: 11.3.1 The global scope + status: unknown + worklets.html#worklet-agents-and-event-loops: + title: 11.3.1.1 Agents and event loops + status: unknown + worklets.html#worklets-creation-termination: + title: 11.3.1.2 Creation and termination + status: unknown + worklets.html#script-settings-for-worklets: + title: 11.3.1.3 Script settings for worklets + status: unknown + worklets.html#worklets-worklet: + title: 11.3.2 The Worklet class + status: unknown + worklets.html#worklets-lifetime: + title: 11.3.3 The worklet's lifetime + status: unknown + webstorage.html#webstorage: + title: 12 Web storage + status: unknown + webstorage.html#introduction-16: + title: 12.1 Introduction + status: unknown + webstorage.html#storage: + title: 12.2 The API + status: unknown + webstorage.html#the-storage-interface: + title: 12.2.1 The Storage interface + status: unknown + webstorage.html#the-sessionstorage-attribute: + title: 12.2.2 The sessionStorage getter + status: unknown + webstorage.html#the-localstorage-attribute: + title: 12.2.3 The localStorage getter + status: unknown + webstorage.html#the-storageevent-interface: + title: 12.2.4 The StorageEvent interface + status: unknown + webstorage.html#privacy: + title: 12.3 Privacy + status: unknown + webstorage.html#user-tracking: + title: 12.3.1 User tracking + status: unknown + webstorage.html#sensitivity-of-data: + title: 12.3.2 Sensitivity of data + status: unknown + webstorage.html#security-storage: + title: 12.4 Security + status: unknown + webstorage.html#dns-spoofing-attacks: + title: 12.4.1 DNS spoofing attacks + status: unknown + webstorage.html#cross-directory-attacks: + title: 12.4.2 Cross-directory attacks + status: unknown + webstorage.html#implementation-risks: + title: 12.4.3 Implementation risks + status: unknown syntax.html#syntax: title: 13 The HTML syntax status: partial @@ -1628,6 +3191,21 @@ sections: named-characters.html#named-character-references: title: 13.5 Named character references status: supported + xhtml.html#the-xhtml-syntax: + title: 14 The XML syntax + status: unknown + xhtml.html#writing-xhtml-documents: + title: 14.1 Writing documents in the XML syntax + status: unknown + xhtml.html#parsing-xhtml-documents: + title: 14.2 Parsing XML documents + status: unknown + xhtml.html#serialising-xhtml-fragments: + title: 14.3 Serializing XML fragments + status: unknown + xhtml.html#parsing-xhtml-fragments: + title: 14.4 Parsing XML fragments + status: unknown rendering.html#rendering: title: 15 Rendering status: partial @@ -1895,3 +3473,30 @@ sections: status: partial gaps: - listing, plaintext and xmp aren't rendered like pre, see 15.3.3 + iana.html#iana: + title: 17 IANA considerations + status: unknown + iana.html#text/html: + title: 17.1 text/html + status: unknown + iana.html#multipart/x-mixed-replace: + title: 17.2 multipart/x-mixed-replace + status: unknown + iana.html#application/xhtml+xml: + title: 17.3 application/xhtml+xml + status: unknown + iana.html#text/ping: + title: 17.4 text/ping + status: unknown + iana.html#application/microdata+json: + title: 17.5 application/microdata+json + status: unknown + iana.html#application/speculationrules+json: + title: 17.6 application/speculationrules+json + status: unknown + iana.html#text/event-stream: + title: 17.7 text/event-stream + status: unknown + iana.html#web+-scheme-prefix: + title: 17.8 web+ scheme prefix + status: unknown From 643ed721a1a84ce639a3a234981e17dbaec89f8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:26:28 +0100 Subject: [PATCH 084/150] Verify and document CSS Flexbox compliance --- .../Core/CssEngine/Model/Converters.cs | 3 +- .../CssModules/CssDisplayTests.cs | 1 + .../Css/FlexPropertyTests.cs | 9 +- .../HtmlRenderer.Test/Css/FlexboxTests.cs | 70 +++++----- .../CssModules/CssFlexboxTests.cs | 100 +++++++++++++++ Spec/css-flexbox-1.yml | 121 ++++++++++-------- 6 files changed, 200 insertions(+), 104 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssFlexboxTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs b/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs index 1da03e1ac..8923ca4df 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs @@ -396,8 +396,7 @@ public static readonly IValueConverter var directionConverter = FlexDirectionConverter.For(PropertyNames.FlexDirection); var wrapConverter = FlexWrapConverter.For(PropertyNames.FlexWrap); - // flex-flow is "<'flex-direction'> || <'flex-wrap'>" (CSS Flexbox 1 §5.1): the double bar means - // the two values may appear in either order, so the pair has to be WithAny, not WithOrder. + // CSS Flexbox 1 §5.3: "||", the two values can come in either order return directionConverter .Or(wrapConverter) .Or(WithAny(directionConverter, wrapConverter)); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssDisplayTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssDisplayTests.cs index d4b9f4587..d867fa840 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssDisplayTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssDisplayTests.cs @@ -194,6 +194,7 @@ public void InlineBlockWithBlockChildren_StaysOnTheLine() [DataRow("inline-grid")] [Spec("css-display-3", "#inner-model")] [Spec("css-display-3", "#legacy-display")] + [Spec("css-flexbox-1", "#flex-containers")] public void FlexAndGridContainers_PaintTheirContent(string display) { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.Test/Css/FlexPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/FlexPropertyTests.cs index 6384e86a6..81f2b57fd 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FlexPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FlexPropertyTests.cs @@ -4,17 +4,11 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/FlexPropertyTests.cs (source class <c>FlexPropertyTests</c>). -/// Pure CSSOM parse tests exercising the flex/align/justify shorthand and longhand -/// <c>StyleDeclaration</c> accessors. Distinct from <see cref="FlexboxTests"/>, which was ported from -/// PeachPDF.Tests/CSS/Flexbox.cs and covers legal-value matrices for these same properties in depth - -/// these two tests were verified not to duplicate anything already asserted there. -/// </summary> [TestClass] public sealed class FlexPropertyTests { [TestMethod] + [Spec("css-flexbox-1", "#alignment")] public void JustifyAlign_Parses() { const string css = """ @@ -36,6 +30,7 @@ public void JustifyAlign_Parses() } [TestMethod] + [Spec("css-flexbox-1", "#flex-property")] public void FlexAuto_Parses() { const string css = """ diff --git a/Source/Test/HtmlRenderer.Test/Css/FlexboxTests.cs b/Source/Test/HtmlRenderer.Test/Css/FlexboxTests.cs index 614f7a062..565b7cbac 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FlexboxTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FlexboxTests.cs @@ -6,72 +6,84 @@ namespace HtmlRenderer.Test.Css; -/// <summary>Ported from PeachPDF.Tests/CSS/Flexbox.cs. Pure CSSOM parse tests - no layout involved.</summary> [TestClass] public sealed class FlexboxTests { [TestMethod] [DynamicData(nameof(FlexDirectionTestDataValues))] + [Spec("css-flexbox-1", "#flex-direction-property")] public void FlexDirectionLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<FlexDirectionProperty>(PropertyNames.FlexDirection, value); [TestMethod] [DynamicData(nameof(FlexWrapTestDataValues))] + [Spec("css-flexbox-1", "#flex-wrap-property")] public void FlexWrapLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<FlexWrapProperty>(PropertyNames.FlexWrap, value); [TestMethod] [DynamicData(nameof(OrderTestDataValues))] + [Spec("css-flexbox-1", "#order-property")] public void OrderLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<OrderProperty>(PropertyNames.Order, value); [TestMethod] [DynamicData(nameof(FlexBasisTestDataValues))] + [Spec("css-flexbox-1", "#flex-basis-property")] public void FlexBasisLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<FlexBasisProperty>(PropertyNames.FlexBasis, value); [TestMethod] [DynamicData(nameof(FlexGrowShrinkTestDataValues))] + [Spec("css-flexbox-1", "#flex-grow-property")] public void FlexGrowLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<FlexGrowProperty>(PropertyNames.FlexGrow, value); [TestMethod] [DynamicData(nameof(FlexGrowShrinkTestDataValues))] + [Spec("css-flexbox-1", "#flex-shrink-property")] public void FlexShrinkLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<FlexShrinkProperty>(PropertyNames.FlexShrink, value); [TestMethod] [DynamicData(nameof(AlignContentTestDataValues))] + [Spec("css-flexbox-1", "#align-content-property")] public void AlignContentLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<AlignContentProperty>(PropertyNames.AlignContent, value); [TestMethod] [DynamicData(nameof(AlignItemsTestDataValues))] + [Spec("css-flexbox-1", "#align-items-property")] public void AlignItemsLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<AlignItemsProperty>(PropertyNames.AlignItems, value); [TestMethod] [DynamicData(nameof(AlignSelfTestDataValues))] + [Spec("css-flexbox-1", "#align-items-property")] public void AlignSelfLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<AlignSelfProperty>(PropertyNames.AlignSelf, value); [TestMethod] [DynamicData(nameof(JustifyContentTestDataValues))] + [Spec("css-flexbox-1", "#justify-content-property")] public void JustifyContentLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<JustifyContentProperty>(PropertyNames.JustifyContent, value); [TestMethod] [DynamicData(nameof(FlexFlowTestDataValues))] + [Spec("css-flexbox-1", "#flex-flow-property")] public void FlexFlowLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<FlexFlowProperty>(PropertyNames.FlexFlow, value); [TestMethod] [DynamicData(nameof(FlexTestDataValues))] + [Spec("css-flexbox-1", "#flex-property")] public void FlexLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<FlexProperty>(PropertyNames.Flex, value); [TestMethod] [DynamicData(nameof(AlignContentInvalidPrefixTestDataValues))] + [Spec("css-flexbox-1", "#align-content-property")] public void AlignContentIllegalPrefixValues(string value) { var snippet = $"align-content: {value}"; @@ -82,6 +94,7 @@ public void AlignContentIllegalPrefixValues(string value) [TestMethod] [DynamicData(nameof(AlignItemsInvalidPrefixTestDataValues))] + [Spec("css-flexbox-1", "#align-items-property")] public void AlignItemsIllegalPrefixValues(string value) { var snippet = $"align-items: {value}"; @@ -92,11 +105,12 @@ public void AlignItemsIllegalPrefixValues(string value) [TestMethod] [DynamicData(nameof(JustifyContentInvalidPrefixTestDataValues))] + [Spec("css-flexbox-1", "#justify-content-property")] public void JustifyContentIllegalPrefixValues(string value) { - var snippet = $"align-items: {value}"; + var snippet = $"justify-content: {value}"; var property = CssConstructionFunctions.ParseDeclaration(snippet); - Assert.AreEqual("align-items", property.Name); + Assert.AreEqual("justify-content", property.Name); Assert.IsFalse(property.HasValue); } @@ -126,6 +140,7 @@ public void AlignmentValueDoesntBreakTheDocument(string declaration) [TestMethod] [DynamicData(nameof(FlexFlowExpandedTestValues))] + [Spec("css-flexbox-1", "#flex-flow-property")] public void FlexFlowShorthandValueExpanded(string propertyValue, string expectedDirection, string expectedWrap) { var source = $".test {{ flex-flow: {propertyValue}; }}"; @@ -139,6 +154,7 @@ public void FlexFlowShorthandValueExpanded(string propertyValue, string expected [TestMethod] [DataRow("1")] [DataRow("2")] + [Spec("css-flexbox-1", "#flex-property")] public void FlexShorthandOneValueExpanded(string propertyValue) { var source = $".test {{ flex: {propertyValue}; }}"; @@ -153,6 +169,7 @@ public void FlexShorthandOneValueExpanded(string propertyValue) [TestMethod] [DataRow("1 30px", "1", "1", "30px")] [DataRow("2 2", "2", "2", "0")] + [Spec("css-flexbox-1", "#flex-property")] public void FlexShorthandTwoValuesExpanded(string propertyValue, string expectedFlexGrow, string expectedFlexShrink, @@ -171,6 +188,7 @@ public void FlexShorthandTwoValuesExpanded(string propertyValue, [DataRow("2 2 10%", "2", "2", "10%")] [DataRow("1 2 20em", "1", "2", "20em")] [DataRow("2 1 min-content", "2", "1", "min-content")] + [Spec("css-flexbox-1", "#flex-property")] public void FlexShorthandThreeValuesExpanded(string propertyValue, string expectedFlexGrow, string expectedFlexShrink, @@ -224,8 +242,7 @@ public void FlexShorthandThreeValuesExpanded(string propertyValue, new object[] { "row nowrap", "row", "nowrap" }, new object[] { "column wrap", "column", "wrap" }, new object[] { "column-reverse wrap-reverse", "column-reverse", "wrap-reverse" }, - // flex-flow is "<'flex-direction'> || <'flex-wrap'>" (CSS Flexbox 1 §5.1), so the two - // values are equally valid in the reverse order. + // CSS Flexbox 1 §5.3, either order new object[] { "nowrap row", "row", "nowrap" }, new object[] { "wrap column", "column", "wrap" }, new object[] { "wrap-reverse column-reverse", "column-reverse", "wrap-reverse" }, @@ -325,15 +342,8 @@ public void FlexShorthandThreeValuesExpanded(string propertyValue, public static IEnumerable<object[]> AlignContentInvalidPrefixTestDataValues => new[] { - new object[] { $"{Keywords.Safe} {Keywords.Start}" }, - new object[] { $"{Keywords.Safe} {Keywords.End}" }, - new object[] { $"{Keywords.Safe} {Keywords.FlexStart}" }, - new object[] { $"{Keywords.Safe} {Keywords.FlexEnd}" }, - - new object[] { $"{Keywords.Unsafe} {Keywords.Start}" }, - new object[] { $"{Keywords.Unsafe} {Keywords.End}" }, - new object[] { $"{Keywords.Unsafe} {Keywords.FlexStart}" }, - new object[] { $"{Keywords.Unsafe} {Keywords.FlexEnd}" }, + new object[] { $"{Keywords.Safe} {Keywords.Baseline}" }, + new object[] { $"{Keywords.Unsafe} {Keywords.SpaceBetween}" }, new object[] { $"{Keywords.First} {Keywords.Start}" }, new object[] { $"{Keywords.First} {Keywords.End}" }, @@ -349,19 +359,8 @@ public void FlexShorthandThreeValuesExpanded(string propertyValue, public static IEnumerable<object[]> AlignItemsInvalidPrefixTestDataValues => new[] { - new object[] { $"{Keywords.Safe} {Keywords.Start}" }, - new object[] { $"{Keywords.Safe} {Keywords.End}" }, - new object[] { $"{Keywords.Safe} {Keywords.FlexStart}" }, - new object[] { $"{Keywords.Safe} {Keywords.FlexEnd}" }, - new object[] { $"{Keywords.Safe} {Keywords.SelfStart}" }, - new object[] { $"{Keywords.Safe} {Keywords.SelfEnd}" }, - - new object[] { $"{Keywords.Unsafe} {Keywords.Start}" }, - new object[] { $"{Keywords.Unsafe} {Keywords.End}" }, - new object[] { $"{Keywords.Unsafe} {Keywords.FlexStart}" }, - new object[] { $"{Keywords.Unsafe} {Keywords.FlexEnd}" }, - new object[] { $"{Keywords.Unsafe} {Keywords.SelfStart}" }, - new object[] { $"{Keywords.Unsafe} {Keywords.SelfEnd}" }, + new object[] { $"{Keywords.Safe} {Keywords.Stretch}" }, + new object[] { $"{Keywords.Unsafe} {Keywords.Normal}" }, new object[] { $"{Keywords.First} {Keywords.Start}" }, new object[] { $"{Keywords.First} {Keywords.End}" }, @@ -381,19 +380,10 @@ public void FlexShorthandThreeValuesExpanded(string propertyValue, public static IEnumerable<object[]> JustifyContentInvalidPrefixTestDataValues => new[] { - new object[] { $"{Keywords.Safe} {Keywords.Start}" }, - new object[] { $"{Keywords.Safe} {Keywords.End}" }, - new object[] { $"{Keywords.Safe} {Keywords.FlexStart}" }, - new object[] { $"{Keywords.Safe} {Keywords.FlexEnd}" }, - new object[] { $"{Keywords.Safe} {Keywords.SelfStart}" }, - new object[] { $"{Keywords.Safe} {Keywords.SelfEnd}" }, - - new object[] { $"{Keywords.Unsafe} {Keywords.Start}" }, - new object[] { $"{Keywords.Unsafe} {Keywords.End}" }, - new object[] { $"{Keywords.Unsafe} {Keywords.FlexStart}" }, - new object[] { $"{Keywords.Unsafe} {Keywords.FlexEnd}" }, - new object[] { $"{Keywords.Unsafe} {Keywords.SelfStart}" }, - new object[] { $"{Keywords.Unsafe} {Keywords.SelfEnd}" }, + new object[] { $"{Keywords.Safe} {Keywords.SpaceAround}" }, + new object[] { $"{Keywords.Safe} {Keywords.Stretch}" }, + new object[] { $"{Keywords.Unsafe} {Keywords.Normal}" }, + new object[] { $"{Keywords.Unsafe} {Keywords.SpaceEvenly}" }, }; public static IEnumerable<object[]> FlexGrowShrinkTestDataValues => diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssFlexboxTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssFlexboxTests.cs new file mode 100644 index 000000000..bfd558206 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssFlexboxTests.cs @@ -0,0 +1,100 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssFlexboxTests +{ + [TestMethod] + [Ignore("Gap, see Spec/css-flexbox-1.yml")] + [Spec("css-flexbox-1", "#flex-containers")] + [Spec("css-flexbox-1", "#flex-lines")] + public void FlexItems_AreLaidOutSideBySide() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='display:flex;width:300px'>" + + "<div id='a' style='width:100px;height:20px'></div><div id='b' style='width:100px;height:20px'></div></div>")); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + Assert.AreEqual(a.Location.Y, b.Location.Y, 0.5); + Assert.AreEqual(a.Location.X + 100, b.Location.X, 0.5); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-flexbox-1.yml")] + [Spec("css-flexbox-1", "#flexibility")] + [Spec("css-flexbox-1", "#resolve-flexible-lengths")] + public void FlexGrow_DistributesTheFreeSpace() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='display:flex;width:300px'>" + + "<div id='a' style='flex:1;height:20px'></div><div id='b' style='flex:2;height:20px'></div></div>")); + + Assert.AreEqual(100, LayoutHarness.FindById(root, "a")!.Size.Width, 0.5); + Assert.AreEqual(200, LayoutHarness.FindById(root, "b")!.Size.Width, 0.5); + } + + [TestMethod] + [Spec("css-flexbox-1", "#flex-property")] + [DataRow("0", "0", "1", "0")] + [DataRow("30px", "1", "1", "30px")] + [DataRow("2 30px", "2", "1", "30px")] + [DataRow("content", "1", "1", "content")] + public void FlexShorthand_ExpandsOmittedValues(string value, string grow, string shrink, string basis) + { + var style = Rule($"flex: {value}"); + + Assert.AreEqual(grow, style.FlexGrow); + Assert.AreEqual(shrink, style.FlexShrink); + Assert.AreEqual(basis, style.FlexBasis); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-flexbox-1.yml")] + [Spec("css-flexbox-1", "#flex-property")] + public void FlexNone_IsZeroZeroAuto() + { + var style = Rule("flex: none"); + + Assert.AreEqual("0", style.FlexGrow); + Assert.AreEqual("0", style.FlexShrink); + Assert.AreEqual("auto", style.FlexBasis); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-flexbox-1.yml")] + [Spec("css-flexbox-1", "#flex-property")] + public void FlexBasisBeforeTheFactors_IsValid() + { + var style = Rule("flex: 30px 2"); + + Assert.AreEqual("2", style.FlexGrow); + Assert.AreEqual("30px", style.FlexBasis); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-flexbox-1.yml")] + [Spec("css-flexbox-1", "#flex-property")] + [Spec("css-flexbox-1", "#flex-grow-property")] + [Spec("css-flexbox-1", "#flex-shrink-property")] + [Spec("css-flexbox-1", "#flex-basis-property")] + [DataRow("flex: -1")] + [DataRow("flex: initial 1")] + [DataRow("flex-grow: -1")] + [DataRow("flex-shrink: -1")] + [DataRow("flex-basis: -5px")] + public void InvalidValue_IsRejected(string declaration) + { + Assert.IsFalse(CssConstructionFunctions.ParseDeclaration(declaration).HasValue); + } + + private static StyleDeclaration Rule(string declaration) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".t {{ {declaration}; }}"); + return ((StyleRule)sheet.StyleRules.First()).Style; + } +} diff --git a/Spec/css-flexbox-1.yml b/Spec/css-flexbox-1.yml index b2ab29769..ddac61cfb 100644 --- a/Spec/css-flexbox-1.yml +++ b/Spec/css-flexbox-1.yml @@ -5,148 +5,159 @@ snapshot: https://www.w3.org/TR/2025/CRD-css-flexbox-1-20251014/ sections: '#intro': title: 1 Introduction - status: unknown - '#overview': - title: 1.1 Overview - status: unknown - '#placement': - title: 1.2 Module interactions - status: unknown - '#values': - title: 1.3 Value Definitions - status: unknown + status: n/a '#box-model': title: 2 Flex Layout Box Model and Terminology - status: unknown + status: n/a '#flex-containers': title: '3 Flex Containers: the flex and inline-flex display values' - status: unknown + status: unsupported + gaps: + - "'display: flex' and 'inline-flex' are accepted, but the container lays out and paints none of its content, see css-display-3 #inner-model" '#flex-items': title: 4 Flex Items - status: unknown + status: unsupported + gaps: + - No flex items, children aren't blockified and text runs aren't wrapped in anonymous flex items '#abspos-items': title: 4.1 Absolutely-Positioned Flex Children - status: unknown + status: unsupported '#item-margins': title: 4.2 Flex Item Margins and Paddings - status: unknown + status: unsupported '#painting': title: 4.3 Flex Item Z-Ordering - status: unknown + status: unsupported '#visibility-collapse': title: 4.4 Collapsed Items - status: unknown + status: unsupported '#min-size-auto': title: 4.5 Automatic Minimum Size of Flex Items - status: unknown + status: unsupported '#flow-order': title: 5 Ordering and Orientation - status: unknown + status: parsed-only '#flex-direction-property': title: '5.1 Flex Flow Direction: the flex-direction property' - status: unknown + status: parsed-only '#flex-wrap-property': title: '5.2 Flex Line Wrapping: the flex-wrap property' - status: unknown + status: parsed-only '#flex-flow-property': title: '5.3 Flex Direction and Wrap: the flex-flow shorthand' - status: unknown + status: parsed-only '#order-property': title: '5.4 Reordering and Accessibility: the order property' - status: unknown + status: parsed-only '#flex-lines': title: 6 Flex Lines - status: unknown + status: unsupported '#flexibility': title: 7 Flexibility - status: unknown + status: unsupported '#flex-property': title: 7.1 The flex Shorthand - status: unknown + status: parsed-only + gaps: + - "'flex: none' sets the longhands to their initial values (0 1 auto) instead of 0 0 auto" + - "The basis before the factors, e.g. 'flex: 30px 2', is rejected" + - "Negative factors and CSS-wide keywords mixed with values, e.g. 'flex: initial 1', are accepted" '#flex-common': title: 7.1.1 Basic Values of flex - status: unknown + status: n/a '#flex-components': title: 7.2 Components of Flexibility - status: unknown + status: parsed-only '#flex-grow-property': title: 7.2.1 The flex-grow property - status: unknown + status: parsed-only + gaps: + - Negative values are accepted '#flex-shrink-property': title: 7.2.2 The flex-shrink property - status: unknown + status: parsed-only + gaps: + - Negative values are accepted '#flex-basis-property': title: 7.2.3 The flex-basis property - status: unknown + status: parsed-only + gaps: + - Negative lengths are accepted '#alignment': title: 8 Alignment - status: unknown + status: parsed-only '#auto-margins': title: 8.1 Aligning with auto margins - status: unknown + status: unsupported '#justify-content-property': title: '8.2 Axis Alignment: the justify-content property' - status: unknown + status: parsed-only + gaps: + - 'Grammar gaps of the alignment keywords, see css-align-3 #alignment-values' '#align-items-property': title: '8.3 Cross-axis Alignment: the align-items and align-self properties' - status: unknown + status: parsed-only + gaps: + - 'Grammar gaps of the alignment keywords, see css-align-3 #alignment-values' '#align-content-property': title: '8.4 Packing Flex Lines: the align-content property' - status: unknown + status: parsed-only + gaps: + - 'Grammar gaps of the alignment keywords, see css-align-3 #alignment-values' '#flex-baselines': title: 8.5 Flex Container Baselines - status: unknown + status: unsupported '#layout-algorithm': title: 9 Flex Layout Algorithm - status: unknown + status: unsupported '#box-manip': title: 9.1 Initial Setup - status: unknown + status: unsupported '#line-sizing': title: 9.2 Line Length Determination - status: unknown + status: unsupported '#main-sizing': title: 9.3 Main Size Determination - status: unknown + status: unsupported '#cross-sizing': title: 9.4 Cross Size Determination - status: unknown + status: unsupported '#main-alignment': title: 9.5 Main-Axis Alignment - status: unknown + status: unsupported '#cross-alignment': title: 9.6 Cross-Axis Alignment - status: unknown + status: unsupported '#resolve-flexible-lengths': title: 9.7 Resolving Flexible Lengths - status: unknown + status: unsupported '#definite-sizes': title: 9.8 Definite and Indefinite Sizes - status: unknown + status: unsupported '#intrinsic-sizes': title: 9.9 Intrinsic Sizes - status: unknown + status: unsupported '#intrinsic-main-sizes': title: 9.9.1 Flex Container Intrinsic Main Sizes - status: unknown + status: unsupported '#intrinsic-main-sizes-ideal': title: 9.9.1.1 Ideal Algorithm - status: unknown + status: unsupported '#intrinsic-main-sizes-compat': title: 9.9.1.2 Web-compatible Intrinsic Sizing Algorithm - status: unknown + status: unsupported '#intrinsic-main-sizes-multiline': title: 9.9.1.3 Multi-line Min-content Algorithm - status: unknown + status: unsupported '#intrinsic-cross-sizes': title: 9.9.2 Flex Container Intrinsic Cross Sizes - status: unknown + status: unsupported '#intrinsic-item-contributions': title: 9.9.3 Flex Item Intrinsic Size Contributions - status: unknown + status: unsupported '#pagination': title: 10 Fragmenting Flex Layout - status: unknown + status: unsupported '#pagination-algo': title: 10.1 Sample Flex Fragmentation Algorithm - status: unknown + status: n/a From 44bf4550bf68e2b9c69afdabbea6d48ff3ae42d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:32:08 +0100 Subject: [PATCH 085/150] Verify and document CSS Box Alignment compliance --- .../Core/CssEngine/Model/Converters.cs | 3 - .../Grid/JustifyItemsProperty.cs | 2 +- .../Grid/JustifySelfProperty.cs | 2 +- .../Grid/PlaceContentProperty.cs | 2 +- .../Grid/PlaceItemsProperty.cs | 2 +- .../StyleProperties/Grid/PlaceSelfProperty.cs | 2 +- .../HtmlRenderer.Test/Css/FlexboxTests.cs | 7 + .../CssModules/CssAlignTests.cs | 191 ++++++++++++++++++ Spec/css-align-3.yml | 140 +++++++------ 9 files changed, 285 insertions(+), 66 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssAlignTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs b/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs index 8923ca4df..bbd3e2ff2 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs @@ -339,12 +339,9 @@ public static readonly IValueConverter public static readonly IValueConverter AlignSelfConverter = AlignItemsConverter.OrAuto(); - // justify-items / justify-self share align-items/align-self's value grammar for the keywords the - // grid engine honors (start/end/center/stretch/normal; baseline falls back to start at layout). public static readonly IValueConverter JustifyItemsConverter = AlignItemsConverter; public static readonly IValueConverter JustifySelfConverter = AlignSelfConverter; - // place-items / place-content / place-self: <align> <justify>? — one value applies to both axes. public static readonly IValueConverter PlaceItemsConverter = AlignItemsConverter.Periodic(PropertyNames.AlignItems, PropertyNames.JustifyItems); public static readonly IValueConverter PlaceContentConverter = diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/JustifyItemsProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/JustifyItemsProperty.cs index 9014a682d..5f0f1411a 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/JustifyItemsProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/JustifyItemsProperty.cs @@ -1,6 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary>The <c>justify-items</c> property (CSS Box Alignment): default inline-axis alignment of grid items.</summary> + /// <summary>CSS Box Alignment 3 §7.1</summary> internal sealed class JustifyItemsProperty : Property { private static readonly IValueConverter StyleConverter = Converters.JustifyItemsConverter; diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/JustifySelfProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/JustifySelfProperty.cs index 281659998..e22f19827 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/JustifySelfProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/JustifySelfProperty.cs @@ -1,6 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary>The <c>justify-self</c> property (CSS Box Alignment): a grid item's own inline-axis alignment.</summary> + /// <summary>CSS Box Alignment 3 §6.1</summary> internal sealed class JustifySelfProperty : Property { private static readonly IValueConverter StyleConverter = Converters.JustifySelfConverter; diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/PlaceContentProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/PlaceContentProperty.cs index 954b116fe..50559aa1a 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/PlaceContentProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/PlaceContentProperty.cs @@ -1,6 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary>The <c>place-content</c> shorthand (CSS Box Alignment): <c>&lt;align-content&gt; &lt;justify-content&gt;?</c>.</summary> + /// <summary>CSS Box Alignment 3 §5.2</summary> internal sealed class PlaceContentProperty : ShorthandProperty { private static readonly IValueConverter StyleConverter = Converters.PlaceContentConverter.OrGlobalValue(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/PlaceItemsProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/PlaceItemsProperty.cs index 5ec9dc74c..0675eadbe 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/PlaceItemsProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/PlaceItemsProperty.cs @@ -1,6 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary>The <c>place-items</c> shorthand (CSS Box Alignment): <c>&lt;align-items&gt; &lt;justify-items&gt;?</c>.</summary> + /// <summary>CSS Box Alignment 3 §7.3</summary> internal sealed class PlaceItemsProperty : ShorthandProperty { private static readonly IValueConverter StyleConverter = Converters.PlaceItemsConverter.OrGlobalValue(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/PlaceSelfProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/PlaceSelfProperty.cs index 149ac0c7c..2be5023ef 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/PlaceSelfProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/PlaceSelfProperty.cs @@ -1,6 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary>The <c>place-self</c> shorthand (CSS Box Alignment): <c>&lt;align-self&gt; &lt;justify-self&gt;?</c>.</summary> + /// <summary>CSS Box Alignment 3 §6.3</summary> internal sealed class PlaceSelfProperty : ShorthandProperty { private static readonly IValueConverter StyleConverter = Converters.PlaceSelfConverter.OrGlobalValue(); diff --git a/Source/Test/HtmlRenderer.Test/Css/FlexboxTests.cs b/Source/Test/HtmlRenderer.Test/Css/FlexboxTests.cs index 565b7cbac..a0c2a4584 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FlexboxTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FlexboxTests.cs @@ -48,24 +48,28 @@ public void FlexShrinkLegalValues(string value) [TestMethod] [DynamicData(nameof(AlignContentTestDataValues))] [Spec("css-flexbox-1", "#align-content-property")] + [Spec("css-align-3", "#align-justify-content")] public void AlignContentLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<AlignContentProperty>(PropertyNames.AlignContent, value); [TestMethod] [DynamicData(nameof(AlignItemsTestDataValues))] [Spec("css-flexbox-1", "#align-items-property")] + [Spec("css-align-3", "#align-items-property")] public void AlignItemsLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<AlignItemsProperty>(PropertyNames.AlignItems, value); [TestMethod] [DynamicData(nameof(AlignSelfTestDataValues))] [Spec("css-flexbox-1", "#align-items-property")] + [Spec("css-align-3", "#align-self-property")] public void AlignSelfLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<AlignSelfProperty>(PropertyNames.AlignSelf, value); [TestMethod] [DynamicData(nameof(JustifyContentTestDataValues))] [Spec("css-flexbox-1", "#justify-content-property")] + [Spec("css-align-3", "#align-justify-content")] public void JustifyContentLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<JustifyContentProperty>(PropertyNames.JustifyContent, value); @@ -84,6 +88,7 @@ public void FlexLegalValues(string value) [TestMethod] [DynamicData(nameof(AlignContentInvalidPrefixTestDataValues))] [Spec("css-flexbox-1", "#align-content-property")] + [Spec("css-align-3", "#align-justify-content")] public void AlignContentIllegalPrefixValues(string value) { var snippet = $"align-content: {value}"; @@ -95,6 +100,7 @@ public void AlignContentIllegalPrefixValues(string value) [TestMethod] [DynamicData(nameof(AlignItemsInvalidPrefixTestDataValues))] [Spec("css-flexbox-1", "#align-items-property")] + [Spec("css-align-3", "#align-items-property")] public void AlignItemsIllegalPrefixValues(string value) { var snippet = $"align-items: {value}"; @@ -106,6 +112,7 @@ public void AlignItemsIllegalPrefixValues(string value) [TestMethod] [DynamicData(nameof(JustifyContentInvalidPrefixTestDataValues))] [Spec("css-flexbox-1", "#justify-content-property")] + [Spec("css-align-3", "#align-justify-content")] public void JustifyContentIllegalPrefixValues(string value) { var snippet = $"justify-content: {value}"; diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssAlignTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssAlignTests.cs new file mode 100644 index 000000000..e3ad4a1c2 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssAlignTests.cs @@ -0,0 +1,191 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssAlignTests +{ + [TestMethod] + [Spec("css-align-3", "#positional-values")] + [DataRow("align-items: self-start")] + [DataRow("align-self: flex-end")] + [DataRow("justify-content: left")] + [DataRow("justify-items: end")] + [DataRow("justify-self: center")] + public void PositionalKeyword_IsAccepted(string declaration) => AssertAccepted(declaration); + + [TestMethod] + [Spec("css-align-3", "#baseline-values")] + [DataRow("align-content: baseline")] + [DataRow("align-items: first baseline")] + [DataRow("align-self: last baseline")] + [DataRow("justify-items: baseline")] + [DataRow("justify-self: first baseline")] + public void BaselineKeyword_IsAccepted(string declaration) => AssertAccepted(declaration); + + [TestMethod] + [Spec("css-align-3", "#distribution-values")] + [DataRow("align-content: space-between")] + [DataRow("align-content: stretch")] + [DataRow("justify-content: space-around")] + [DataRow("justify-content: space-evenly")] + public void DistributionKeyword_IsAccepted(string declaration) => AssertAccepted(declaration); + + [TestMethod] + [Spec("css-align-3", "#overflow-values")] + [DataRow("align-content: safe center")] + [DataRow("justify-content: unsafe center")] + [DataRow("align-items: safe center")] + [DataRow("justify-self: unsafe center")] + public void OverflowKeywordBeforeCenter_IsAccepted(string declaration) => AssertAccepted(declaration); + + [TestMethod] + [Spec("css-align-3", "#justify-self-property")] + [Spec("css-align-3", "#justify-items-property")] + [DataRow("justify-self: auto")] + [DataRow("justify-self: normal")] + [DataRow("justify-self: stretch")] + [DataRow("justify-items: normal")] + [DataRow("justify-items: stretch")] + public void JustifySelfAndItems_AreAccepted(string declaration) => AssertAccepted(declaration); + + [TestMethod] + [Spec("css-align-3", "#place-content")] + [Spec("css-align-3", "#place-items-property")] + [Spec("css-align-3", "#place-self-property")] + [DataRow("place-content: center", "align-content", "center", "justify-content", "center")] + [DataRow("place-content: space-between end", "align-content", "space-between", "justify-content", "end")] + [DataRow("place-items: stretch", "align-items", "stretch", "justify-items", "stretch")] + [DataRow("place-items: center start", "align-items", "center", "justify-items", "start")] + [DataRow("place-self: auto", "align-self", "auto", "justify-self", "auto")] + [DataRow("place-self: auto center", "align-self", "auto", "justify-self", "center")] + public void PlaceShorthand_SetsBothLonghands(string declaration, string align, string alignValue, string justify, string justifyValue) + { + var style = Rule(declaration); + + Assert.AreEqual(alignValue, style.GetPropertyValue(align)); + Assert.AreEqual(justifyValue, style.GetPropertyValue(justify)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-align-3.yml")] + [Spec("css-align-3", "#overflow-values")] + [DataRow("align-content: safe start")] + [DataRow("justify-content: unsafe left")] + [DataRow("align-items: safe self-end")] + [DataRow("align-self: unsafe flex-start")] + [DataRow("justify-items: safe end")] + public void OverflowKeywordBeforeAnyPosition_IsAccepted(string declaration) => AssertAccepted(declaration); + + [TestMethod] + [Ignore("Gap, see Spec/css-align-3.yml")] + [Spec("css-align-3", "#positional-values")] + [Spec("css-align-3", "#justify-items-property")] + [DataRow("justify-items: legacy")] + [DataRow("justify-items: legacy center")] + [DataRow("justify-items: right")] + [DataRow("justify-self: left")] + public void LeftRightAndLegacy_AreAcceptedForJustifyItemsAndSelf(string declaration) => AssertAccepted(declaration); + + [TestMethod] + [Spec("css-align-3", "#overflow-values")] + [DataRow("align-items: safe")] + public void OverflowKeywordWithoutAPosition_IsRejected(string declaration) + { + Assert.IsFalse(CssConstructionFunctions.ParseDeclaration(declaration).HasValue); + } + + [TestMethod] + [Spec("css-align-3", "#baseline-values")] + [DataRow("align-content: first")] + [DataRow("justify-content: first baseline")] + public void BaselinePrefixWithoutBaselineAlignment_IsRejected(string declaration) + { + Assert.IsFalse(CssConstructionFunctions.ParseDeclaration(declaration).HasValue); + } + + [TestMethod] + [DataRow("align-items: safe")] + [DataRow("justify-self: safe right")] + [DataRow("place-items: start legacy")] + public void PrefixedValue_DoesntBreakTheDocument(string declaration) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap($"<div id='x' style='{declaration}'>text</div>")); + + Assert.IsNotNull(LayoutHarness.FindById(root, "x")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-align-3.yml")] + [Spec("css-align-3", "#place-content")] + [Spec("css-align-3", "#place-items-property")] + [Spec("css-align-3", "#place-self-property")] + [DataRow("place-content: baseline", "align-content", "baseline", "justify-content", "start")] + [DataRow("place-items: start legacy", "align-items", "start", "justify-items", "legacy")] + [DataRow("place-self: center left", "align-self", "center", "justify-self", "left")] + public void PlaceShorthand_WithValidValues_SetsBothLonghands(string declaration, string align, string alignValue, string justify, string justifyValue) + => PlaceShorthand_SetsBothLonghands(declaration, align, alignValue, justify, justifyValue); + + [TestMethod] + [Ignore("Gap, see Spec/css-align-3.yml")] + [Spec("css-align-3", "#place-content")] + public void PlaceContentWithAnInvalidAlignContent_IsRejected() + { + Assert.IsFalse(CssConstructionFunctions.ParseDeclaration("place-content: left").HasValue); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-align-3.yml")] + [Spec("css-align-3", "#distribution-block")] + public void AlignContent_CentersTheContentOfABlockContainer() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div id='p' style='height:100px;align-content:center'><div id='c' style='height:20px'></div></div>")); + + var parent = LayoutHarness.FindById(root, "p")!; + Assert.AreEqual(parent.Location.Y + 40, LayoutHarness.FindById(root, "c")!.Location.Y, 0.5); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-align-3.yml")] + [Spec("css-align-3", "#justify-block")] + public void JustifySelfEnd_MovesABlockLevelBoxToTheEnd() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='width:300px'><div id='c' style='width:100px;height:20px;justify-self:end'></div></div>")); + + Assert.AreEqual(200, LayoutHarness.FindById(root, "c")!.Location.X, 0.5); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-align-3.yml")] + [Spec("css-align-3", "#justify-abspos")] + [Spec("css-align-3", "#align-abspos")] + public void PlaceSelfCenter_CentersAnAbsolutelyPositionedBox() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='position:relative;width:300px;height:100px'>" + + "<div id='c' style='position:absolute;left:0;right:0;top:0;bottom:0;margin:0;width:100px;height:20px;place-self:center'></div></div>")); + + var box = LayoutHarness.FindById(root, "c")!; + Assert.AreEqual(100, box.Location.X, 0.5); + Assert.AreEqual(40, box.Location.Y, 0.5); + } + + private static void AssertAccepted(string declaration) + { + var property = CssConstructionFunctions.ParseDeclaration(declaration); + + Assert.IsTrue(property.HasValue, declaration); + Assert.AreEqual(declaration.Substring(declaration.IndexOf(':') + 1).Trim(), property.Value); + } + + private static StyleDeclaration Rule(string declaration) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".t {{ {declaration}; }}"); + return ((StyleRule)sheet.StyleRules.First()).Style; + } +} diff --git a/Spec/css-align-3.yml b/Spec/css-align-3.yml index 8de817c9a..2007b38a1 100644 --- a/Spec/css-align-3.yml +++ b/Spec/css-align-3.yml @@ -5,157 +5,181 @@ snapshot: https://www.w3.org/TR/2026/WD-css-align-3-20261008/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown - '#partial': - title: 1.3 Partial Implementations - status: unknown + status: n/a '#overview': title: 2 Overview of Alignment Properties - status: unknown + status: n/a '#terms': title: 3 Alignment Terminology - status: unknown + status: n/a '#alignment-values': title: 4 Alignment Keywords - status: unknown + status: parsed-only '#positional-values': title: '4.1 Positional Alignment: the center, start, end, self-start, self-end, flex-start, flex-end, left, and right keywords' - status: unknown + status: parsed-only + gaps: + - "'left' and 'right' are rejected in 'justify-self' and 'justify-items'" '#baseline-values': title: '4.2 Baseline Alignment: the baseline keyword and first/last modifiers' - status: unknown + status: parsed-only '#distribution-values': title: '4.3 Distributed Alignment: the stretch, space-between, space-around, and space-evenly keywords' - status: unknown + status: parsed-only '#overflow-values': title: '4.4 Overflow Alignment: the safe and unsafe keywords and scroll safety limits' - status: unknown + status: parsed-only + gaps: + - "'safe' and 'unsafe' are only accepted before 'center', e.g. 'safe start' is rejected" '#auto-safety': title: 4.4.1 Automatic Overflow Alignment Safety - status: unknown + status: unsupported '#auto-safety-scroll': title: 4.4.1.1 Content Distribution for Scroll Containers - status: unknown + status: unsupported '#auto-safety-position': title: 4.4.1.2 Self-Alignment for Absolutely Positioned Boxes - status: unknown + status: unsupported '#auto-safety-default': title: 4.4.1.3 All Other Alignment - status: unknown + status: unsupported '#content-distribution': title: '5 Content Distribution: Aligning a Box’s Contents Within Itself' - status: unknown + status: parsed-only '#align-justify-content': title: 5.1 The justify-content and align-content Properties - status: unknown + status: parsed-only '#distribution-block': title: 5.1.1 Block Containers (Including Table Cells) - status: unknown + status: parsed-only + gaps: + - "'align-content' is ignored on block containers and table cells" '#distribution-multicol': title: 5.1.2 Multicol Containers - status: unknown + status: unsupported + gaps: + - No multicol layout, see css-multicol-1 '#distribution-flex': title: 5.1.3 Flex Containers - status: unknown + status: unsupported + gaps: + - No flex layout, see css-flexbox-1 '#distribution-grid': title: 5.1.4 Grid Containers - status: unknown + status: unsupported + gaps: + - No grid layout, see css-grid-2 '#place-content': title: '5.2 Content-Distribution Shorthand: the place-content property' - status: unknown + status: parsed-only + gaps: + - "A baseline value, e.g. 'place-content: baseline', is rejected" + - "A first value that's only valid for 'justify-content', e.g. 'place-content: left', is accepted" '#overflow-scroll-position': title: 5.3 Alignment Overflow and Scroll Containers - status: unknown + status: unsupported '#baseline-align-content': title: 5.4 Baseline Content-Alignment - status: unknown + status: unsupported '#self-alignment': title: '6 Self-Alignment: Aligning the Box Within Its Parent' - status: unknown + status: parsed-only '#justify-self-property': title: '6.1 Inline-Axis (or Main-Axis) Self-Alignment: the justify-self property' - status: unknown + status: parsed-only '#justify-block': title: 6.1.1 Block-Level Boxes - status: unknown + status: parsed-only + gaps: + - "'justify-self' is ignored on block-level boxes" '#justify-abspos': title: 6.1.2 Absolutely-Positioned Boxes - status: unknown + status: parsed-only + gaps: + - "'justify-self' is ignored on absolutely positioned boxes" '#justify-cell': title: 6.1.3 Table Cells - status: unknown + status: n/a '#justify-flex': title: 6.1.4 Flex Items - status: unknown + status: n/a '#justify-grid': title: 6.1.5 Grid Items - status: unknown + status: unsupported + gaps: + - No grid layout, see css-grid-2 '#align-self-property': title: '6.2 Block-Axis (or Cross-Axis) Self-Alignment: the align-self property' - status: unknown + status: parsed-only '#align-block': title: 6.2.1 Block-Level Boxes - status: unknown + status: n/a '#align-abspos': title: 6.2.2 Absolutely-Positioned Boxes - status: unknown + status: parsed-only + gaps: + - "'align-self' is ignored on absolutely positioned boxes" '#align-cell': title: 6.2.3 Table Cells - status: unknown + status: n/a '#align-flex': title: 6.2.4 Flex Items - status: unknown + status: unsupported + gaps: + - No flex layout, see css-flexbox-1 '#align-grid': title: 6.2.5 Grid Items - status: unknown + status: unsupported + gaps: + - No grid layout, see css-grid-2 '#place-self-property': title: '6.3 Self-Alignment Shorthand: the place-self property' - status: unknown + status: parsed-only + gaps: + - "A second value that isn't valid for 'align-self', e.g. 'place-self: center left', is rejected" '#baseline-align-self': title: 6.4 Baseline Self-Alignment - status: unknown + status: unsupported '#abspos-sizing': title: 6.5 Effects on Sizing of Absolutely Positioned Boxes with Static-Position Insets - status: unknown + status: unsupported '#default-alignment': title: 7 Default Alignment - status: unknown + status: parsed-only '#justify-items-property': title: '7.1 Inline-Axis (or Main-Axis) Default Alignment: the justify-items property' - status: unknown + status: parsed-only + gaps: + - "'legacy' is rejected" '#align-items-property': title: '7.2 Block-Axis (or Cross-Axis) Default Alignment: the align-items property' - status: unknown + status: parsed-only '#place-items-property': title: '7.3 Default Alignment Shorthand: the place-items property' - status: unknown + status: parsed-only + gaps: + - "A second value that isn't valid for 'align-items', e.g. 'place-items: start legacy', is rejected" '#gaps': title: 8 Gaps Between Boxes - status: unknown + status: n/a '#baseline-rules': title: 9 Baseline Alignment Details - status: unknown + status: unsupported '#baseline-export': title: 9.1 Determining the Baselines of a Box - status: unknown + status: unsupported '#baseline-terms': title: 9.2 Baseline Alignment Grouping - status: unknown + status: unsupported '#align-by-baseline': title: 9.3 Aligning Boxes by Baseline - status: unknown + status: unsupported '#changes': title: 10 Changes - status: unknown + status: n/a '#privacy': title: 11 Privacy Considerations - status: unknown + status: n/a '#security': title: 12 Security Considerations - status: unknown + status: n/a From 2f6c1725e22123c3309ebd978af8a33c144248f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:30:05 +0100 Subject: [PATCH 086/150] Verify and document CSS Grid Layout compliance --- .../CssEngine/Factories/PropertyFactory.cs | 8 +- .../Core/CssEngine/GridLineGrammar.cs | 24 +- .../CssEngine/GridTemplateAreasGrammar.cs | 26 +- .../Core/CssEngine/GridTrackListGrammar.cs | 98 ++----- .../Core/CssEngine/Model/Converters.cs | 6 +- .../StyleProperties/Grid/GridAreaProperty.cs | 6 +- .../Grid/GridAutoColumnsProperty.cs | 2 +- .../Grid/GridAutoFlowProperty.cs | 2 +- .../Grid/GridAutoRowsProperty.cs | 2 +- .../Grid/GridColumnEndProperty.cs | 5 +- .../Grid/GridColumnProperty.cs | 5 +- .../Grid/GridColumnStartProperty.cs | 5 +- .../StyleProperties/Grid/GridProperty.cs | 5 +- .../Grid/GridRowEndProperty.cs | 5 +- .../StyleProperties/Grid/GridRowProperty.cs | 5 +- .../Grid/GridRowStartProperty.cs | 5 +- .../Grid/GridTemplateAreasProperty.cs | 6 +- .../Grid/GridTemplateColumnsProperty.cs | 5 +- .../Grid/GridTemplateProperty.cs | 5 +- .../Grid/GridTemplateRowsProperty.cs | 5 +- .../GridAreaShorthandValueConverter.cs | 10 +- .../GridAutoFlowValueConverter.cs | 8 +- .../GridAutoTracksValueConverter.cs | 6 +- .../GridColumnRowShorthandValueConverter.cs | 8 +- .../ValueConverters/GridLineValueConverter.cs | 6 +- .../ValueConverters/GridPlacementShorthand.cs | 20 +- .../GridShorthandValueConverter.cs | 17 +- .../GridTemplateAreasValueConverter.cs | 7 +- .../ValueConverters/GridTemplateShorthand.cs | 56 +--- .../GridTemplateShorthandValueConverter.cs | 5 +- .../GridTemplateValueConverter.cs | 25 +- .../HtmlRenderer.Test/Css/CssGrid2Tests.cs | 271 ++++++++++++++++++ .../Css/GridLineGrammarTests.cs | 21 +- .../Css/GridTemplateAreasGrammarTests.cs | 11 +- .../Css/GridTemplateShorthandTests.cs | 17 +- .../Css/GridTrackListGrammarTests.cs | 50 ++-- Spec/css-grid-2.yml | 241 +++++++--------- 37 files changed, 520 insertions(+), 489 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Css/CssGrid2Tests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs b/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs index f767eeda4..3096bbd66 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs @@ -370,9 +370,7 @@ private PropertyFactory() _fontsBuilder.Add(PropertyNames.Src, () => new SrcProperty()); _fontsBuilder.Add(PropertyNames.UnicodeRange, () => new UnicodeRangeProperty()); - // CSS Grid Layout Level 1/2. The engine parses and validates the full grid property set; - // the renderer has no grid layout engine yet, so these cascade and are then ignored at the - // box boundary. + // CSS Grid 2, parsed and cascaded, there's no grid layout AddLonghand(PropertyNames.GridTemplateColumns, () => new GridTemplateColumnsProperty()); AddLonghand(PropertyNames.GridTemplateRows, () => new GridTemplateRowsProperty()); AddLonghand(PropertyNames.GridTemplateAreas, () => new GridTemplateAreasProperty()); @@ -393,9 +391,7 @@ private PropertyFactory() AddLonghand(PropertyNames.GridAutoColumns, () => new GridAutoColumnsProperty()); AddLonghand(PropertyNames.GridAutoRows, () => new GridAutoRowsProperty()); - // The grid mega-shorthands parse/expand like any other, but are excluded from serialization - // reconstruction (via _logicalShorthands) - reconstructing a `grid`/`grid-template` from its - // longhands is not worth the complexity and could change existing output. + // Never reconstructed from their longhands when serializing. AddLogicalShorthand(PropertyNames.GridTemplate, () => new GridTemplateProperty(), PropertyNames.GridTemplateRows, PropertyNames.GridTemplateColumns, PropertyNames.GridTemplateAreas); AddLogicalShorthand(PropertyNames.Grid, () => new GridProperty(), diff --git a/Source/HtmlRenderer/Core/CssEngine/GridLineGrammar.cs b/Source/HtmlRenderer/Core/CssEngine/GridLineGrammar.cs index 70682250f..aed72c3e4 100644 --- a/Source/HtmlRenderer/Core/CssEngine/GridLineGrammar.cs +++ b/Source/HtmlRenderer/Core/CssEngine/GridLineGrammar.cs @@ -3,22 +3,15 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// A parsed grid <c>&lt;grid-line&gt;</c> (one edge of <c>grid-column</c>/<c>grid-row</c>/<c>grid-area</c>): - /// <c>auto</c>, an integer line number (may be negative — counting from the end edge), <c>span N</c>, or - /// a named line reference (a bare <c>&lt;custom-ident&gt;</c>, or <c>&lt;custom-ident&gt; &lt;integer&gt;</c> - /// for the Nth line with that name). <c>span &lt;custom-ident&gt;</c> is out of v1 scope. - /// </summary> + /// <summary>A <c>&lt;grid-line&gt;</c>, CSS Grid 2 §8.3.</summary> internal sealed class GridLine { public bool IsAuto { get; private set; } public bool IsSpan { get; private set; } - /// <summary>The line number (for a line reference), the span count (when <see cref="IsSpan"/>), or - /// the 1-based Nth index for a named line (defaults to 1).</summary> + /// <summary>The line number, the span count, or which line of the name it is.</summary> public int Value { get; private set; } = 1; - /// <summary>The custom-ident of a named line reference, or null when this is not a named line.</summary> public string Name { get; private set; } public static readonly GridLine Auto = new GridLine { IsAuto = true }; @@ -28,11 +21,7 @@ internal sealed class GridLine public static GridLine NamedNth(string name, int n) => new GridLine { Name = name, Value = n }; } - /// <summary> - /// Shared grammar for a single grid <c>&lt;grid-line&gt;</c> value. Used by Layer A (to validate the - /// <c>grid-column-start</c>/<c>-end</c>/<c>grid-row-start</c>/<c>-end</c> longhands) and by Layer B (the - /// grid placement engine). - /// </summary> + /// <summary>The <c>&lt;grid-line&gt;</c> of the <c>grid-row-*</c>/<c>grid-column-*</c> longhands, CSS Grid 2 §8.3.</summary> internal static class GridLineGrammar { internal static GridLine TryParse(IReadOnlyList<Token> tokens) @@ -40,24 +29,19 @@ internal static GridLine TryParse(IReadOnlyList<Token> tokens) var toks = tokens.Where(t => t.Type != TokenType.Whitespace).ToArray(); if (toks.Length == 0) return null; - // auto if (toks.Length == 1 && toks[0].Type == TokenType.Ident && toks[0].Data.Isi(Keywords.Auto)) return GridLine.Auto; - // <integer> if (toks.Length == 1 && toks[0] is NumberToken number && number.IsInteger && number.IntegerValue != 0) return GridLine.Line(number.IntegerValue); - // span <integer [1,∞]> if (toks.Length == 2 && toks[0].Type == TokenType.Ident && toks[0].Data.Isi(Keywords.Span) && toks[1] is NumberToken spanCount && spanCount.IsInteger && spanCount.IntegerValue >= 1) return GridLine.Span(spanCount.IntegerValue); - // <custom-ident> — a named line reference. if (toks.Length == 1 && IsCustomIdent(toks[0])) return GridLine.Named(toks[0].Data); - // <custom-ident> <integer> / <integer> <custom-ident> — the Nth line with that name (order-independent). if (toks.Length == 2) { if (IsCustomIdent(toks[0]) && toks[1] is NumberToken n1 && n1.IsInteger && n1.IntegerValue != 0) @@ -69,8 +53,6 @@ internal static GridLine TryParse(IReadOnlyList<Token> tokens) return null; } - /// <summary>Whether the token is a <c>&lt;custom-ident&gt;</c> usable as a grid line name — an ident - /// that is not one of the reserved keywords a grid-line value or the CSS-wide keywords may take.</summary> private static bool IsCustomIdent(Token token) { if (token.Type != TokenType.Ident) return false; diff --git a/Source/HtmlRenderer/Core/CssEngine/GridTemplateAreasGrammar.cs b/Source/HtmlRenderer/Core/CssEngine/GridTemplateAreasGrammar.cs index d03e444c7..55bdf6033 100644 --- a/Source/HtmlRenderer/Core/CssEngine/GridTemplateAreasGrammar.cs +++ b/Source/HtmlRenderer/Core/CssEngine/GridTemplateAreasGrammar.cs @@ -3,28 +3,22 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// A parsed <c>grid-template-areas</c> value (CSS Grid §7.3): a rectangular grid of named cells. Each - /// named area's 0-based inclusive bounding rectangle is recorded; a <c>.</c> (or a run of only dots) is - /// an unnamed empty cell. - /// </summary> + /// <summary>A <c>grid-template-areas</c> value, CSS Grid 2 §7.3.</summary> internal sealed class GridAreas { public int RowCount { get; set; } public int ColCount { get; set; } - /// <summary>The area name at each cell (<c>[row, col]</c>), or null for an empty <c>.</c> cell.</summary> + /// <summary>The area name per <c>[row, col]</c>, null for a null cell token.</summary> public string[,] Cells { get; set; } - /// <summary>Each named area's 0-based inclusive rectangle (rowStart, colStart, rowEnd, colEnd).</summary> + /// <summary>0-based inclusive cell rectangle per area name.</summary> public IReadOnlyDictionary<string, (int R1, int C1, int R2, int C2)> Areas { get; set; } } /// <summary> - /// Shared grammar for a <c>grid-template-areas</c> value (a series of quoted strings). Used by Layer A - /// (accept/reject) and Layer B (the engine derives implicit <c>name-start</c>/<c>name-end</c> lines). - /// Returns null for anything invalid — including the <c>none</c> keyword, which the property accepts - /// separately. + /// The <c>&lt;string&gt;+</c> of <c>grid-template-areas</c>, CSS Grid 2 §7.3. Returns null for <c>none</c> + /// too, the property handles that keyword. /// </summary> internal static class GridTemplateAreasGrammar { @@ -33,7 +27,6 @@ internal static GridAreas TryParse(IReadOnlyList<Token> tokens) var toks = tokens.Where(t => t.Type != TokenType.Whitespace).ToArray(); if (toks.Length == 0) return null; - // Every token must be a string; each string is one row. if (toks.Any(t => t.Type != TokenType.String)) return null; var rows = new List<string[]>(toks.Length); @@ -42,15 +35,14 @@ internal static GridAreas TryParse(IReadOnlyList<Token> tokens) { var cells = ((StringToken)t).Data .Split((char[])null, System.StringSplitOptions.RemoveEmptyEntries); - if (cells.Length == 0) return null; // an empty string is invalid + if (cells.Length == 0) return null; if (colCount < 0) colCount = cells.Length; - else if (cells.Length != colCount) return null; // ragged rows are invalid + else if (cells.Length != colCount) return null; var normalized = new string[colCount]; for (var c = 0; c < colCount; c++) { if (IsEmptyCell(cells[c])) { normalized[c] = null; continue; } - // A named cell is a <custom-ident> — a null token (a mixed "a.b" is invalid, not a name). if (cells[c].Contains('.')) return null; normalized[c] = cells[c]; } @@ -74,8 +66,7 @@ internal static GridAreas TryParse(IReadOnlyList<Token> tokens) bounds[name] = (r, c, r, c); } - // Each named area must be a single filled rectangle: every cell in its bounding box carries the - // name (which also guarantees no cell with the name lies outside the box). + // Every cell in an area's bounding box has to carry its name. foreach (var entry in bounds) { var name = entry.Key; @@ -88,7 +79,6 @@ internal static GridAreas TryParse(IReadOnlyList<Token> tokens) return new GridAreas { RowCount = rowCount, ColCount = colCount, Cells = grid, Areas = bounds }; } - /// <summary>A cell of only U+002E FULL STOP characters (<c>.</c>, <c>...</c>) is an empty cell.</summary> private static bool IsEmptyCell(string cell) => cell.All(ch => ch == '.'); } } diff --git a/Source/HtmlRenderer/Core/CssEngine/GridTrackListGrammar.cs b/Source/HtmlRenderer/Core/CssEngine/GridTrackListGrammar.cs index c344d1a6c..f60120aca 100644 --- a/Source/HtmlRenderer/Core/CssEngine/GridTrackListGrammar.cs +++ b/Source/HtmlRenderer/Core/CssEngine/GridTrackListGrammar.cs @@ -3,28 +3,23 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary>The kind of a single grid <c>&lt;track-size&gt;</c>/<c>&lt;track-breadth&gt;</c> - /// (CSS Grid Layout Module Level 1/2 §7.2).</summary> + /// <summary>CSS Grid 2 §7.2.1.</summary> internal enum GridTrackKind { - Length, // a <length> (raw component string in Value, resolved by Layer B ParseLength) - Percent, // a <percentage> (raw component string in Value) - Flex, // a <flex> / fr value (Flex holds the fr number) + Length, // authored text in Value, a length or a length-percentage math function + Percent, // authored text in Value + Flex, // factor in Flex Auto, MinContent, MaxContent, - FitContent, // fit-content(<length-percentage>) — the argument is in Value - Minmax // minmax(Min, Max) + FitContent, // argument in Value + Minmax // Min, Max } - /// <summary>Which flavour of layout-time <c>repeat()</c> a template carries (CSS Grid §7.2.3.2).</summary> + /// <summary>CSS Grid 2 §7.2.3.2.</summary> internal enum GridAutoRepeatKind { None, AutoFill, AutoFit } - /// <summary> - /// A single grid <c>&lt;track-size&gt;</c>. Fixed/percentage/fit-content values keep their authored - /// component string (<see cref="Value"/>) for Layer B to resolve against the container; <c>fr</c> keeps - /// its numeric factor (<see cref="Flex"/>); <c>minmax()</c> keeps its two sub-breadths. - /// </summary> + /// <summary>A <c>&lt;track-size&gt;</c>, lengths and percentages keep their authored text.</summary> internal sealed class GridTrackSize { public GridTrackKind Kind { get; private set; } @@ -44,62 +39,42 @@ internal sealed class GridTrackSize } /// <summary> - /// A parsed <c>grid-template-columns</c>/<c>grid-template-rows</c> value: the fixed (fully - /// <c>repeat(N,…)</c>-expanded) tracks, plus at most one layout-time <c>repeat(auto-fill|auto-fit,…)</c> - /// section recorded as an insertion point since its count is resolved during layout. + /// A <c>grid-template-columns</c>/<c>-rows</c> value. <c>repeat(&lt;integer&gt;, …)</c> is expanded into + /// <see cref="Tracks"/>, an auto-fill/auto-fit repeat only depends on the container size and is kept apart. /// </summary> internal sealed class GridTemplate { - /// <summary>The fixed tracks (with the auto-repeat section, if any, NOT included here — it is - /// spliced in at <see cref="AutoRepeatInsertIndex"/> at layout time).</summary> + /// <summary>The tracks without the auto-repeat ones, those go in at <see cref="AutoRepeatInsertIndex"/>.</summary> public IReadOnlyList<GridTrackSize> Tracks { get; set; } = new List<GridTrackSize>(); public GridAutoRepeatKind AutoRepeat { get; set; } = GridAutoRepeatKind.None; - /// <summary>The repeated track template for an <c>auto-fill</c>/<c>auto-fit</c> section.</summary> public IReadOnlyList<GridTrackSize> AutoRepeatTracks { get; set; } - /// <summary>The index into <see cref="Tracks"/> at which the auto-repeat section is inserted.</summary> public int AutoRepeatInsertIndex { get; set; } - /// <summary>Named lines declared with <c>[name]</c> in the top-level track list: name → the sorted, - /// deduped 1-based line numbers it labels (line 1 is before the first track). Empty when none.</summary> + /// <summary>Line name → sorted 1-based line numbers, line 1 is before the first track.</summary> public IReadOnlyDictionary<string, IReadOnlyList<int>> LineNames { get; set; } = new Dictionary<string, IReadOnlyList<int>>(); - /// <summary>The <c>subgrid</c> keyword (CSS Grid Layout Module Level 2 §9): this axis adopts the parent - /// grid's tracks instead of defining its own. A subgrid template carries no <see cref="Tracks"/> — only - /// any explicit <c>[name]</c> line names declared after the keyword (in <see cref="LineNames"/>).</summary> + /// <summary>CSS Grid 2 §9, no <see cref="Tracks"/>, only the line names after the keyword.</summary> public bool IsSubgrid { get; set; } public bool IsNone => !IsSubgrid && Tracks.Count == 0 && AutoRepeat == GridAutoRepeatKind.None; } /// <summary> - /// Shared grammar for the grid <c>&lt;track-list&gt;</c> (<c>grid-template-columns</c>/ - /// <c>grid-template-rows</c>) and <c>&lt;track-size&gt;+</c> (<c>grid-auto-columns</c>/ - /// <c>grid-auto-rows</c>). Used by both Layer A (accept/reject at parse) and Layer B (compute used - /// tracks during layout), so the grammar is defined once — the <see cref="AspectRatioGrammar"/>/ - /// <see cref="BasicShapeGrammar"/> precedent. Top-level named lines (<c>[name]</c>) are collected into - /// <see cref="GridTemplate.LineNames"/>; named lines inside <c>repeat()</c> are out of scope and - /// rejected. + /// CSS Grid 2 §7.2 track lists of <c>grid-template-columns</c>/<c>-rows</c> and the + /// <c>&lt;track-size&gt;+</c> of <c>grid-auto-columns</c>/<c>-rows</c>. /// </summary> internal static class GridTrackListGrammar { - /// <summary> - /// Parses a <c>&lt;track-list&gt;</c> (the <c>grid-template-columns</c>/<c>-rows</c> value). Returns - /// null when the value is not a valid track list — <b>including the literal <c>none</c></b>, which - /// the property accepts separately. - /// </summary> + /// <summary>Returns null for <c>none</c> too, the property handles that keyword.</summary> internal static GridTemplate TryParse(IReadOnlyList<Token> tokens) { var toks = tokens.Where(t => t.Type != TokenType.Whitespace).ToArray(); if (toks.Length == 0) return null; - // subgrid (CSS Grid Level 2 §9): the axis adopts the parent grid's tracks. The keyword may be - // followed by an optional <line-name-list> ([name] groups only; repeat() of line names is a v1 - // deferral, mirroring the "named lines inside repeat()" restriction below). No <track-size> is - // allowed after subgrid. if (toks[0].Type == TokenType.Ident && toks[0].Data.Isi(Keywords.Subgrid)) return TryParseSubgrid(toks); @@ -112,8 +87,6 @@ internal static GridTemplate TryParse(IReadOnlyList<Token> tokens) var i = 0; while (i < toks.Length) { - // [name …] — one or more named lines at the current line position (1-based; line 1 is before - // the first track). Whitespace is already stripped, so the bracket group is contiguous. if (toks[i].Type == TokenType.SquareBracketOpen) { var lineIndex = fixedTracks.Count + 1; @@ -138,12 +111,10 @@ internal static GridTemplate TryParse(IReadOnlyList<Token> tokens) if (repeatKind == GridAutoRepeatKind.None) { - // repeat(N, …) — expand inline. fixedTracks.AddRange(repeatTracks); } else { - // repeat(auto-fill|auto-fit, …) — at most one per track list. if (autoRepeat != GridAutoRepeatKind.None) return null; autoRepeat = repeatKind; autoRepeatTracks = repeatTracks; @@ -173,13 +144,7 @@ internal static GridTemplate TryParse(IReadOnlyList<Token> tokens) }; } - /// <summary> - /// Parses a <c>subgrid [ &lt;line-name-list&gt; ]?</c> value (the leading <c>subgrid</c> ident already - /// matched by the caller). The optional line-name list is a run of <c>[name …]</c> bracket groups, one - /// group per grid line (line 1 is before the first adopted track); each group's names are recorded at - /// that 1-based line index. Anything else after <c>subgrid</c> (a track size, an unclosed/ill-formed - /// bracket, a non-ident inside <c>[ ]</c>) is invalid. - /// </summary> + /// <summary><c>subgrid &lt;line-name-list&gt;?</c>, the caller matched the keyword.</summary> private static GridTemplate TryParseSubgrid(Token[] toks) { var lineNames = new Dictionary<string, SortedSet<int>>(); @@ -188,7 +153,7 @@ private static GridTemplate TryParseSubgrid(Token[] toks) while (i < toks.Length) { - if (toks[i].Type != TokenType.SquareBracketOpen) return null; // only [name] groups may follow + if (toks[i].Type != TokenType.SquareBracketOpen) return null; i++; while (i < toks.Length && toks[i].Type != TokenType.SquareBracketClose) { @@ -213,10 +178,7 @@ private static GridTemplate TryParseSubgrid(Token[] toks) }; } - /// <summary> - /// Parses a <c>&lt;track-size&gt;+</c> list (<c>grid-auto-columns</c>/<c>grid-auto-rows</c>) — one or - /// more track sizes, no <c>repeat()</c>. Returns null on any invalid token. - /// </summary> + /// <summary><c>&lt;track-size&gt;+</c>, no <c>repeat()</c>.</summary> internal static IReadOnlyList<GridTrackSize> TryParseTrackSizeList(IReadOnlyList<Token> tokens) { var toks = tokens.Where(t => t.Type != TokenType.Whitespace).ToArray(); @@ -241,7 +203,6 @@ private static bool TryParseRepeat(FunctionToken fn, out GridAutoRepeatKind kind var groups = SplitByComma(args); if (groups.Count < 2) return false; - // First group is the repetition count: an integer, or auto-fill / auto-fit. var first = groups[0]; if (first.Count != 1) return false; @@ -251,8 +212,7 @@ private static bool TryParseRepeat(FunctionToken fn, out GridAutoRepeatKind kind else if (first[0] is NumberToken n && n.IsInteger && n.IntegerValue >= 1) countTokens = n.IntegerValue; else return false; - // Remaining groups are the repeated <track-size>s. (A repeat body may not itself contain a - // repeat(), which SplitByComma naturally enforces since a nested function is one token.) + // A nested repeat() is a single function token, which TryParseTrackSize rejects. var body = new List<GridTrackSize>(); for (var gi = 1; gi < groups.Count; gi++) { @@ -271,7 +231,6 @@ private static bool TryParseRepeat(FunctionToken fn, out GridAutoRepeatKind kind return true; } - // repeat(N, body) — expand N copies now. tracks = new List<GridTrackSize>(body.Count * countTokens); for (var r = 0; r < countTokens; r++) tracks.AddRange(body); @@ -282,8 +241,6 @@ private static bool TryParseTrackSize(Token token, out GridTrackSize track) { track = null; - // minmax(<inflexible-breadth>, <track-breadth>), fit-content(<length-percentage>), and a - // math function (calc()/min()/max()/clamp()) that resolves to a <length-percentage>. if (token is FunctionToken fn) { if (fn.Data.Isi(FunctionNames.Minmax)) return TryParseMinmax(fn, out track); @@ -300,9 +257,6 @@ private static bool TryParseBreadth(Token token, out GridTrackSize track) { track = null; - // A math function (calc()/min()/max()/clamp()) computes to a <length-percentage> at used-value - // time; keep its reconstructed text (Layer B ParseLength evaluates it). This lets a calc() be a - // track breadth anywhere a length/percentage is allowed — bare, and inside minmax()/repeat(). if (token is FunctionToken calc && IsLengthPercentageCalc(calc)) { track = GridTrackSize.Length(calc.ToValue()); @@ -328,12 +282,10 @@ private static bool TryParseBreadth(Token token, out GridTrackSize track) return true; } - // Any other dimension is a <length> — the raw string is resolved by Layer B ParseLength. track = GridTrackSize.Length(token.ToValue()); return true; } - // Unitless zero is a valid length. var zero = token as NumberToken; if (zero != null && zero.Value == 0f) { track = GridTrackSize.Length("0"); return true; } @@ -349,7 +301,6 @@ private static bool TryParseMinmax(FunctionToken fn, out GridTrackSize track) if (!TryParseBreadth(groups[0][0], out var min)) return false; if (!TryParseBreadth(groups[1][0], out var max)) return false; - // The min of a minmax() is an <inflexible-breadth> — a flex value is invalid there. if (min.Kind == GridTrackKind.Flex) return false; track = GridTrackSize.Minmax(min, max); @@ -362,7 +313,6 @@ private static bool TryParseFitContent(FunctionToken fn, out GridTrackSize track var args = fn.ArgumentTokens.Where(t => t.Type != TokenType.Whitespace).ToArray(); if (args.Length != 1) return false; - // fit-content(<length-percentage>). var arg = args[0]; if (arg.Type == TokenType.Percentage) { track = GridTrackSize.FitContentTo(arg.ToValue()); return true; } if (arg is UnitToken && arg.Type == TokenType.Dimension && !((UnitToken)arg).Unit.Isi("fr")) @@ -382,13 +332,7 @@ private static bool TryParseFitContent(FunctionToken fn, out GridTrackSize track return false; } - /// <summary> - /// Whether <paramref name="fn"/> is a math function (<c>calc()</c>/<c>min()</c>/<c>max()</c>/ - /// <c>clamp()</c>) that resolves to a <c>&lt;length-percentage&gt;</c>. Validated at parse time - /// (mirroring <see cref="BasicShapeGrammar"/>'s calc handling) so a wrong-category math function - /// (e.g. an angle) drops the whole track list rather than silently resolving to 0 during layout; - /// its reconstructed text is resolved by Layer B <c>ParseLength</c>. - /// </summary> + /// <summary>Whether <paramref name="fn"/> is a math function of type <c>&lt;length-percentage&gt;</c>.</summary> private static bool IsLengthPercentageCalc(FunctionToken fn) { if (!CalcParser.IsCalcFamily(fn.Data)) return false; diff --git a/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs b/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs index bbd3e2ff2..f0d5a9cbe 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs @@ -460,12 +460,8 @@ public static readonly IValueConverter IntegerConverter.Required(), IntegerConverter.StartsWithDelimiter().Required()); - // A single grid <grid-line> (auto | <integer> | span <integer>), validated by GridLineGrammar. public static readonly IValueConverter GridLineConverter = new GridLineValueConverter(); - // grid-column / grid-row / grid-area: slash-separated <grid-line> components with the CSS Grid - // §8.3.1 omitted-value copy rule (a bare <custom-ident> propagates to the paired/all edges) — the - // generic WithOrder(...).Option() DSL resets omitted slots to auto, which is wrong for named areas. public static readonly IValueConverter GridColumnConverter = new GridColumnRowShorthandValueConverter(PropertyNames.GridColumnStart, PropertyNames.GridColumnEnd); @@ -595,4 +591,4 @@ private static IValueConverter WithArgs(params IValueConverter[] converters) #endregion } -} \ No newline at end of file +} diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAreaProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAreaProperty.cs index b77b527d5..2d3368061 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAreaProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAreaProperty.cs @@ -1,10 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// The <c>grid-area</c> shorthand (CSS Grid §8.3.1): <c>&lt;grid-line&gt; [ / &lt;grid-line&gt; ]{0,3}</c>, - /// expanding to <c>grid-row-start</c> / <c>grid-column-start</c> / <c>grid-row-end</c> / - /// <c>grid-column-end</c>. The single named-area form is out of v1 scope. - /// </summary> + /// <summary><c>grid-area</c>, CSS Grid 2 §8.4.</summary> internal sealed class GridAreaProperty : ShorthandProperty { private static readonly IValueConverter StyleConverter = Converters.GridAreaConverter.OrGlobalValue(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAutoColumnsProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAutoColumnsProperty.cs index 9a6f6105a..a87f81d62 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAutoColumnsProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAutoColumnsProperty.cs @@ -1,6 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary>The <c>grid-auto-columns</c> property (CSS Grid §7.6): the size of implicitly-created columns.</summary> + /// <summary><c>grid-auto-columns</c>, CSS Grid 2 §7.6.</summary> internal sealed class GridAutoColumnsProperty : Property { private static readonly IValueConverter StyleConverter = new GridAutoTracksValueConverter().OrDefault(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAutoFlowProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAutoFlowProperty.cs index da7d1f48f..23b2ee980 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAutoFlowProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAutoFlowProperty.cs @@ -1,6 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary>The <c>grid-auto-flow</c> property (CSS Grid §7.7): the auto-placement direction and packing.</summary> + /// <summary><c>grid-auto-flow</c>, CSS Grid 2 §7.7.</summary> internal sealed class GridAutoFlowProperty : Property { private static readonly IValueConverter StyleConverter = new GridAutoFlowValueConverter().OrDefault(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAutoRowsProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAutoRowsProperty.cs index d558f3a59..3824e2080 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAutoRowsProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridAutoRowsProperty.cs @@ -1,6 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary>The <c>grid-auto-rows</c> property (CSS Grid §7.6): the size of implicitly-created rows.</summary> + /// <summary><c>grid-auto-rows</c>, CSS Grid 2 §7.6.</summary> internal sealed class GridAutoRowsProperty : Property { private static readonly IValueConverter StyleConverter = new GridAutoTracksValueConverter().OrDefault(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridColumnEndProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridColumnEndProperty.cs index 9552efa71..baff211cd 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridColumnEndProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridColumnEndProperty.cs @@ -1,9 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// The <c>grid-column-end</c> longhand (CSS Grid §8.3), one edge of a - /// grid item's placement. Validated by the shared <see cref="GridLineGrammar"/>. - /// </summary> + /// <summary><c>grid-column-end</c>, CSS Grid 2 §8.3.</summary> internal sealed class GridColumnEndProperty : Property { private static readonly IValueConverter StyleConverter = Converters.GridLineConverter.OrDefault(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridColumnProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridColumnProperty.cs index 9a0672ff0..97b789e54 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridColumnProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridColumnProperty.cs @@ -1,9 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// The <c>grid-column</c> shorthand (CSS Grid §8.3.1): <c>&lt;grid-line&gt; [ / &lt;grid-line&gt; ]?</c>, - /// expanding to <c>grid-column-start</c> / <c>grid-column-end</c>. - /// </summary> + /// <summary><c>grid-column</c>, CSS Grid 2 §8.4.</summary> internal sealed class GridColumnProperty : ShorthandProperty { private static readonly IValueConverter StyleConverter = Converters.GridColumnConverter.OrGlobalValue(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridColumnStartProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridColumnStartProperty.cs index fee4a1a02..72bf6b915 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridColumnStartProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridColumnStartProperty.cs @@ -1,9 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// The <c>grid-column-start</c> longhand (CSS Grid §8.3), one edge of a - /// grid item's placement. Validated by the shared <see cref="GridLineGrammar"/>. - /// </summary> + /// <summary><c>grid-column-start</c>, CSS Grid 2 §8.3.</summary> internal sealed class GridColumnStartProperty : Property { private static readonly IValueConverter StyleConverter = Converters.GridLineConverter.OrDefault(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridProperty.cs index c8b932657..9d124d866 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridProperty.cs @@ -1,9 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// The <c>grid</c> shorthand (CSS Grid §7.8): expands to the three <c>grid-template-*</c> longhands plus - /// <c>grid-auto-flow</c> / <c>grid-auto-rows</c> / <c>grid-auto-columns</c>. - /// </summary> + /// <summary><c>grid</c>, CSS Grid 2 §7.8.</summary> internal sealed class GridProperty : ShorthandProperty { private static readonly IValueConverter StyleConverter = Converters.GridConverter.OrGlobalValue(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridRowEndProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridRowEndProperty.cs index 6f3e1986a..e771a471d 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridRowEndProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridRowEndProperty.cs @@ -1,9 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// The <c>grid-row-end</c> longhand (CSS Grid §8.3), one edge of a - /// grid item's placement. Validated by the shared <see cref="GridLineGrammar"/>. - /// </summary> + /// <summary><c>grid-row-end</c>, CSS Grid 2 §8.3.</summary> internal sealed class GridRowEndProperty : Property { private static readonly IValueConverter StyleConverter = Converters.GridLineConverter.OrDefault(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridRowProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridRowProperty.cs index 55839a80f..4d92a4734 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridRowProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridRowProperty.cs @@ -1,9 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// The <c>grid-row</c> shorthand (CSS Grid §8.3.1): <c>&lt;grid-line&gt; [ / &lt;grid-line&gt; ]?</c>, - /// expanding to <c>grid-row-start</c> / <c>grid-row-end</c>. - /// </summary> + /// <summary><c>grid-row</c>, CSS Grid 2 §8.4.</summary> internal sealed class GridRowProperty : ShorthandProperty { private static readonly IValueConverter StyleConverter = Converters.GridRowConverter.OrGlobalValue(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridRowStartProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridRowStartProperty.cs index 6ebbd6256..a4a7d2506 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridRowStartProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridRowStartProperty.cs @@ -1,9 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// The <c>grid-row-start</c> longhand (CSS Grid §8.3), one edge of a - /// grid item's placement. Validated by the shared <see cref="GridLineGrammar"/>. - /// </summary> + /// <summary><c>grid-row-start</c>, CSS Grid 2 §8.3.</summary> internal sealed class GridRowStartProperty : Property { private static readonly IValueConverter StyleConverter = Converters.GridLineConverter.OrDefault(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateAreasProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateAreasProperty.cs index 33f1fcf86..bdf390075 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateAreasProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateAreasProperty.cs @@ -1,10 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// The <c>grid-template-areas</c> property (CSS Grid §7.3). Validated by the shared - /// <see cref="GridTemplateAreasGrammar"/>; the authored text is preserved for the grid layout engine, - /// which derives implicit <c>name-start</c>/<c>name-end</c> lines from each named area. - /// </summary> + /// <summary><c>grid-template-areas</c>, CSS Grid 2 §7.3.</summary> internal sealed class GridTemplateAreasProperty : Property { private static readonly IValueConverter StyleConverter = new GridTemplateAreasValueConverter().OrDefault(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateColumnsProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateColumnsProperty.cs index d7e110c79..3b08f0021 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateColumnsProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateColumnsProperty.cs @@ -1,9 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// The <c>grid-template-columns</c> property (CSS Grid Layout Module Level 1/2 §7.2). Validated by the - /// shared <see cref="GridTrackListGrammar"/>; the authored text is preserved for the grid layout engine. - /// </summary> + /// <summary><c>grid-template-columns</c>, CSS Grid 2 §7.2.</summary> internal sealed class GridTemplateColumnsProperty : Property { private static readonly IValueConverter StyleConverter = new GridTemplateValueConverter().OrDefault(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateProperty.cs index cea068bdf..b920ada57 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateProperty.cs @@ -1,9 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// The <c>grid-template</c> shorthand (CSS Grid §7.4): expands to <c>grid-template-rows</c> / - /// <c>grid-template-columns</c> / <c>grid-template-areas</c>. - /// </summary> + /// <summary><c>grid-template</c>, CSS Grid 2 §7.4.</summary> internal sealed class GridTemplateProperty : ShorthandProperty { private static readonly IValueConverter StyleConverter = Converters.GridTemplateConverter.OrGlobalValue(); diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateRowsProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateRowsProperty.cs index 754dde2b7..bc6958572 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateRowsProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Grid/GridTemplateRowsProperty.cs @@ -1,9 +1,6 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// The <c>grid-template-rows</c> property (CSS Grid Layout Module Level 1/2 §7.2). Validated by the - /// shared <see cref="GridTrackListGrammar"/>; the authored text is preserved for the grid layout engine. - /// </summary> + /// <summary><c>grid-template-rows</c>, CSS Grid 2 §7.2.</summary> internal sealed class GridTemplateRowsProperty : Property { private static readonly IValueConverter StyleConverter = new GridTemplateValueConverter().OrDefault(); diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridAreaShorthandValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridAreaShorthandValueConverter.cs index e989e5811..f07193eac 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridAreaShorthandValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridAreaShorthandValueConverter.cs @@ -2,12 +2,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// The <c>grid-area</c> shorthand: <c>&lt;grid-line&gt; [ / &lt;grid-line&gt; ]{0,3}</c> in the order - /// row-start / column-start / row-end / column-end. Implements the CSS Grid §8.3.1 omitted-value copy - /// rule — a bare <c>&lt;custom-ident&gt;</c> propagates to the paired/all edges, so <c>grid-area: main</c> - /// fills the whole <c>main</c> area, while <c>grid-area: 2</c> leaves the other edges <c>auto</c>. - /// </summary> + /// <summary><c>grid-area</c>, CSS Grid 2 §8.4.</summary> internal sealed class GridAreaShorthandValueConverter : IValueConverter { public IPropertyValue Convert(IEnumerable<Token> value) @@ -16,13 +11,10 @@ public IPropertyValue Convert(IEnumerable<Token> value) if (c is null) return null; var rowStart = c[0]; - // column-start omitted → if row-start is a bare custom-ident, all four take it; else auto. var colStart = c.Count > 1 ? c[1] : rowStart.IsBareCustomIdent ? rowStart : (GridPlacementShorthand.Component?)null; - // row-end omitted → copy a bare custom-ident row-start, else auto. var rowEnd = c.Count > 2 ? c[2] : rowStart.IsBareCustomIdent ? rowStart : (GridPlacementShorthand.Component?)null; - // column-end omitted → copy a bare custom-ident column-start, else auto. var colEnd = c.Count > 3 ? c[3] : colStart.HasValue && colStart.Value.IsBareCustomIdent ? colStart : (GridPlacementShorthand.Component?)null; diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridAutoFlowValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridAutoFlowValueConverter.cs index 9211cc287..baeece372 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridAutoFlowValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridAutoFlowValueConverter.cs @@ -3,11 +3,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// Validates a <c>grid-auto-flow</c> value (CSS Grid §7.7): <c>[ row | column ] || dense</c> — one of - /// <c>row</c>/<c>column</c> optionally combined with <c>dense</c>, in either order. The authored text is - /// preserved for the grid layout engine. - /// </summary> + /// <summary><c>grid-auto-flow</c>, CSS Grid 2 §7.7.</summary> internal sealed class GridAutoFlowValueConverter : IValueConverter { public IPropertyValue Convert(IEnumerable<Token> value) @@ -23,7 +19,7 @@ public IPropertyValue Convert(IEnumerable<Token> value) { if (t.Data.Isi(Keywords.Row) || t.Data.Isi(Keywords.Column)) { - if (hasAxis) return null; // row and column are mutually exclusive + if (hasAxis) return null; hasAxis = true; } else if (t.Data.Isi(Keywords.Dense)) diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridAutoTracksValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridAutoTracksValueConverter.cs index 4003b1d7a..6a35f47ba 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridAutoTracksValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridAutoTracksValueConverter.cs @@ -3,11 +3,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// Validates a <c>grid-auto-columns</c>/<c>grid-auto-rows</c> value: a <c>&lt;track-size&gt;+</c> list - /// (no <c>repeat()</c>) via the shared <see cref="GridTrackListGrammar"/>. The authored text is preserved - /// for the grid layout engine. - /// </summary> + /// <summary><c>grid-auto-columns</c>/<c>-rows</c>, CSS Grid 2 §7.6.</summary> internal sealed class GridAutoTracksValueConverter : IValueConverter { public IPropertyValue Convert(IEnumerable<Token> value) diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridColumnRowShorthandValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridColumnRowShorthandValueConverter.cs index 8287de6c7..c50a47e04 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridColumnRowShorthandValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridColumnRowShorthandValueConverter.cs @@ -3,11 +3,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The <c>grid-column</c> / <c>grid-row</c> shorthand: <c>&lt;grid-line&gt; [ / &lt;grid-line&gt; ]?</c> - /// expanding to the axis's <c>-start</c>/<c>-end</c> longhands. Implements the CSS Grid §8.3.1 - /// omitted-value copy rule that the generic <c>WithOrder(...).Option()</c> DSL cannot: when the end is - /// omitted, it copies a bare <c>&lt;custom-ident&gt;</c> start (so <c>grid-column: main</c> spans the - /// whole <c>main</c> area) but leaves a line number / span as <c>auto</c>. + /// <c>grid-row</c>/<c>grid-column</c>, CSS Grid 2 §8.4. Not built from <c>WithOrder(...).Option()</c>, + /// that resets an omitted end to auto instead of copying a bare <c>&lt;custom-ident&gt;</c>. /// </summary> internal sealed class GridColumnRowShorthandValueConverter : IValueConverter { @@ -26,7 +23,6 @@ public IPropertyValue Convert(IEnumerable<Token> value) if (components is null) return null; var start = components[0]; - // end omitted → copy a bare custom-ident start, else auto. var end = components.Count > 1 ? components[1] : start.IsBareCustomIdent ? start : (GridPlacementShorthand.Component?)null; diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridLineValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridLineValueConverter.cs index 91a845e90..d37e42c28 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridLineValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridLineValueConverter.cs @@ -3,11 +3,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// Validates a single grid <c>&lt;grid-line&gt;</c> (<c>grid-column-start</c>/<c>-end</c>, - /// <c>grid-row-start</c>/<c>-end</c>) through the shared <see cref="GridLineGrammar"/> — <c>auto</c>, an - /// integer line, or <c>span N</c>. The authored text is preserved for the grid placement engine. - /// </summary> + /// <summary><c>grid-row-start</c>/<c>-end</c>, <c>grid-column-start</c>/<c>-end</c>, CSS Grid 2 §8.3.</summary> internal sealed class GridLineValueConverter : IValueConverter { public IPropertyValue Convert(IEnumerable<Token> value) diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridPlacementShorthand.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridPlacementShorthand.cs index a50980dd4..9a6626592 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridPlacementShorthand.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridPlacementShorthand.cs @@ -3,13 +3,10 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary>Shared helpers for the grid placement shorthands (<c>grid-column</c>/<c>grid-row</c>/ - /// <c>grid-area</c>): splitting a value on <c>/</c> into <c>&lt;grid-line&gt;</c> components, validating - /// each, and building the omitted-value copy result (CSS Grid §8.3.1).</summary> + /// <summary>Helpers for <c>grid-row</c>, <c>grid-column</c> and <c>grid-area</c>, CSS Grid 2 §8.4.</summary> internal static class GridPlacementShorthand { - /// <summary>A parsed <c>&lt;grid-line&gt;</c> component: its authored tokens and whether it is a bare - /// <c>&lt;custom-ident&gt;</c> (the only form the omitted-value copy rule propagates).</summary> + /// <summary>A <c>&lt;grid-line&gt;</c>, only a bare <c>&lt;custom-ident&gt;</c> is copied to omitted lines.</summary> internal struct Component { public IReadOnlyList<Token> Tokens { get; set; } @@ -18,9 +15,7 @@ internal struct Component private static readonly Token AutoToken = new Token(TokenType.Ident, Keywords.Auto, TextPosition.Empty); - /// <summary>Splits the value on top-level <c>/</c> delimiters into <c>&lt;grid-line&gt;</c> components, - /// validating each via <see cref="GridLineGrammar"/>. Returns null on any invalid component or an - /// out-of-range component count.</summary> + /// <summary>Splits on <c>/</c>, null if a component is invalid or there are too many.</summary> internal static List<Component> Split(IEnumerable<Token> value, int maxComponents) { var groups = new List<List<Token>> { new List<Token>() }; @@ -37,9 +32,7 @@ internal static List<Component> Split(IEnumerable<Token> value, int maxComponent var significant = group.Where(t => t.Type != TokenType.Whitespace).ToArray(); var parsed = GridLineGrammar.TryParse(significant); if (parsed is null) return null; - // Preserve internal whitespace in the stored tokens (only trim the ends) so a two-token - // component like "span 2" round-trips as "span 2", not "span2" (which would re-lex as one - // ident). A bare <custom-ident> is a single ident token that parsed to a named (non-Nth) line. + // Keep the inner whitespace, "span2" would lex as one ident. components.Add(new Component { Tokens = Trim(group), @@ -50,13 +43,11 @@ internal static List<Component> Split(IEnumerable<Token> value, int maxComponent return components.Count >= 1 && components.Count <= maxComponents ? components : null; } - /// <summary>The tokens for a longhand: the specified component's tokens, or <c>auto</c> when omitted.</summary> internal static IReadOnlyList<Token> Or(Component? component) { return component.HasValue ? component.Value.Tokens : new[] { AutoToken }; } - /// <summary>Drops leading and trailing whitespace tokens, keeping internal whitespace.</summary> private static IReadOnlyList<Token> Trim(List<Token> group) { var start = 0; @@ -67,8 +58,7 @@ private static IReadOnlyList<Token> Trim(List<Token> group) } } - /// <summary>A shorthand <see cref="IPropertyValue"/> that emits a precomputed token stream for each of - /// its longhands (via <see cref="ExtractFor"/>), used by the grid placement shorthands.</summary> + /// <summary>A shorthand value with precomputed tokens per longhand.</summary> internal sealed class MappedShorthandValue : IPropertyValue { private readonly IReadOnlyDictionary<string, IReadOnlyList<Token>> _map; diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridShorthandValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridShorthandValueConverter.cs index 34969d11b..a4be38913 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridShorthandValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridShorthandValueConverter.cs @@ -3,24 +3,14 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// The <c>grid</c> shorthand (CSS Grid §7.8): either a <c>&lt;grid-template&gt;</c>, or one of the two - /// auto-flow forms - /// <c>&lt;grid-template-rows&gt; / [ auto-flow &amp;&amp; dense? ] &lt;grid-auto-columns&gt;?</c> and - /// <c>[ auto-flow &amp;&amp; dense? ] &lt;grid-auto-rows&gt;? / &lt;grid-template-columns&gt;</c>. Expands to - /// the three <c>grid-template-*</c> longhands plus <c>grid-auto-flow</c>/<c>-rows</c>/<c>-columns</c>; - /// every longhand it doesn't set is reset to its initial value (an absent slice — §7.8). - /// </summary> + /// <summary><c>grid</c>, CSS Grid 2 §7.8. A longhand without a slice is reset to its initial value.</summary> internal sealed class GridShorthandValueConverter : IValueConverter { public IPropertyValue Convert(IEnumerable<Token> value) { - // NB: the domain-specific IEnumerable<Token>.ToList() extension splits on commas; materialize the - // flat token list explicitly instead. + // Not ToList(), the IEnumerable<Token> extension of that name splits on commas. var tokens = new List<Token>(value); - // Form 1: <grid-template>. The three template longhands are set; grid-auto-flow/-rows/-columns - // are omitted here so they reset to their initial values (row / auto / auto). if (GridTemplateShorthand.TryParseTemplate(tokens, out var rows, out var columns, out var areas)) { var templateMap = new Dictionary<string, IReadOnlyList<Token>>(); @@ -30,7 +20,6 @@ public IPropertyValue Convert(IEnumerable<Token> value) return new MappedShorthandValue(templateMap, tokens); } - // Forms 2/3: an auto-flow form — requires exactly one top-level '/'. var groups = GridTemplateShorthand.SplitTopLevelSlash(tokens); if (groups.Count != 2) return null; @@ -41,7 +30,7 @@ public IPropertyValue Convert(IEnumerable<Token> value) var leftHasFlow = left.Any(t => t.Type == TokenType.Ident && t.Data.Isi(Keywords.AutoFlow)); var rightHasFlow = right.Any(t => t.Type == TokenType.Ident && t.Data.Isi(Keywords.AutoFlow)); - if (leftHasFlow == rightHasFlow) return null; // exactly one side carries auto-flow + if (leftHasFlow == rightHasFlow) return null; var map = new Dictionary<string, IReadOnlyList<Token>>(); diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateAreasValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateAreasValueConverter.cs index 491ba7ab8..dc646aac5 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateAreasValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateAreasValueConverter.cs @@ -3,12 +3,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// Validates a <c>grid-template-areas</c> value: the keyword <c>none</c> or a rectangular grid of quoted - /// strings accepted by the shared <see cref="GridTemplateAreasGrammar"/> (equal columns per row, each - /// named area a single filled rectangle). The authored text is preserved so the grid layout engine - /// re-parses the same value. - /// </summary> + /// <summary><c>grid-template-areas</c>, CSS Grid 2 §7.3.</summary> internal sealed class GridTemplateAreasValueConverter : IValueConverter { public IPropertyValue Convert(IEnumerable<Token> value) diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateShorthand.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateShorthand.cs index 045d99fe6..85333c25b 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateShorthand.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateShorthand.cs @@ -3,25 +3,15 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { - /// <summary> - /// Shared parsing for the CSS Grid mega-shorthands <c>grid-template</c> (<a - /// href="https://www.w3.org/TR/css-grid-2/#explicit-grid-shorthand">§7.4</a>) and <c>grid</c> (<a - /// href="https://www.w3.org/TR/css-grid-2/#grid-shorthand">§7.8</a>): splitting the value on the - /// top-level <c>/</c>, running the areas-form row scanner, and validating each partitioned slice - /// against the existing longhand grammars (<see cref="GridTrackListGrammar"/>/<see - /// cref="GridTemplateAreasGrammar"/>). The two converters stay thin over this helper. - /// </summary> + /// <summary>Parsing shared by <c>grid-template</c> (CSS Grid 2 §7.4) and <c>grid</c> (§7.8).</summary> internal static class GridTemplateShorthand { internal static readonly Token AutoToken = new Token(TokenType.Ident, Keywords.Auto, TextPosition.Empty); private static readonly Token NoneToken = new Token(TokenType.Ident, Keywords.None, TextPosition.Empty); /// <summary> - /// Splits the flat token stream on top-level <c>/</c> delimiters, preserving each group's - /// whitespace. No bracket/paren depth counter is needed: functions are single <see - /// cref="FunctionToken"/>s (their args nested) and <c>[name]</c> groups hold only idents, so a - /// <c>/</c> never appears nested at the flat level (same reasoning as - /// <see cref="GridPlacementShorthand"/>). + /// No nesting depth needed, a function is a single <see cref="FunctionToken"/> and <c>[name]</c> + /// groups only hold idents. /// </summary> internal static List<List<Token>> SplitTopLevelSlash(IEnumerable<Token> value) { @@ -39,11 +29,7 @@ internal static List<List<Token>> SplitTopLevelSlash(IEnumerable<Token> value) internal static Token[] Significant(IEnumerable<Token> tokens) => tokens.Where(t => t.Type != TokenType.Whitespace).ToArray(); - /// <summary> - /// Validates one axis of the <c>&lt;rows&gt; / &lt;columns&gt;</c> form — a <c>&lt;track-list&gt;</c> or - /// the keyword <c>none</c> (both accepted by the <c>grid-template-rows</c>/<c>-columns</c> longhands) — - /// returning the edge-trimmed slice, or <see langword="null"/> if invalid. - /// </summary> + /// <summary>One side of <c>&lt;rows&gt; / &lt;columns&gt;</c>, trimmed, or null if invalid.</summary> internal static IReadOnlyList<Token> TryTemplateAxis(IReadOnlyList<Token> raw) { var significant = Significant(raw); @@ -55,7 +41,6 @@ internal static IReadOnlyList<Token> TryTemplateAxis(IReadOnlyList<Token> raw) private static bool ContainsRepeat(IEnumerable<Token> significant) => significant.Any(t => t is FunctionToken fn && fn.Data.Isi(FunctionNames.Repeat)); - /// <summary>Drops leading/trailing whitespace tokens, keeping internal whitespace.</summary> internal static IReadOnlyList<Token> Trim(IReadOnlyList<Token> tokens) { var start = 0; @@ -68,7 +53,6 @@ internal static IReadOnlyList<Token> Trim(IReadOnlyList<Token> tokens) return result; } - /// <summary>The <c>grid-auto-flow</c> token slice for the <c>grid</c> auto-flow forms.</summary> internal static IReadOnlyList<Token> BuildAutoFlow(bool column, bool dense) { var flow = new List<Token> { new Token(TokenType.Ident, column ? Keywords.Column : Keywords.Row, TextPosition.Empty) }; @@ -80,25 +64,20 @@ internal static IReadOnlyList<Token> BuildAutoFlow(bool column, bool dense) return flow; } - /// <summary> - /// Parses a <c>grid-template</c> value (§7.4) into the three longhand slices. A <see langword="null"/> - /// out-slice means "omit it" (the longhand resets to its initial value). Returns <see langword="false"/> - /// for an invalid value (the whole declaration is then dropped). - /// </summary> + /// <summary>A null slice means the longhand is reset to its initial value.</summary> internal static bool TryParseTemplate(IEnumerable<Token> value, out IReadOnlyList<Token> rows, out IReadOnlyList<Token> columns, out IReadOnlyList<Token> areas) { rows = columns = areas = null; var groups = SplitTopLevelSlash(value); - if (groups.Count > 2) return false; // at most one top-level '/' + if (groups.Count > 2) return false; var leftRaw = groups[0]; var rightRaw = groups.Count == 2 ? groups[1] : null; var left = Significant(leftRaw); var right = rightRaw is null ? null : Significant(rightRaw); - // grid-template: none → all three longhands none. if (right == null && left.Length == 1 && left[0].Type == TokenType.Ident && left[0].Data.Isi(Keywords.None)) { rows = columns = areas = new[] { NoneToken }; @@ -109,7 +88,6 @@ internal static bool TryParseTemplate(IEnumerable<Token> value, if (left.Any(t => t.Type == TokenType.String)) { - // Areas form: [ <line-names>? <string> <track-size>? <line-names>? ]+ [ / <explicit-track-list> ]? if (!TryScanAreasForm(left, out var rowsTokens, out var areaTokens)) return false; if (GridTemplateAreasGrammar.TryParse(areaTokens) is null) return false; if (GridTrackListGrammar.TryParse(rowsTokens) is null) return false; @@ -119,7 +97,7 @@ internal static bool TryParseTemplate(IEnumerable<Token> value, if (rightRaw != null) { - // The trailing columns are an <explicit-track-list> — a <track-list> with no repeat(). + // <explicit-track-list>, no repeat() if (right.Length == 0 || ContainsRepeat(right) || GridTrackListGrammar.TryParse(rightRaw) is null) return false; columns = Trim(rightRaw); @@ -128,7 +106,6 @@ internal static bool TryParseTemplate(IEnumerable<Token> value, return true; } - // rows / columns form: requires the slash; each side is a <track-list> or `none`. if (rightRaw is null) return false; var rowsSlice = TryTemplateAxis(leftRaw); var columnsSlice = TryTemplateAxis(rightRaw); @@ -140,10 +117,8 @@ internal static bool TryParseTemplate(IEnumerable<Token> value, } /// <summary> - /// Scans the areas-form left side, synthesizing the <c>grid-template-rows</c> track list - /// (<paramref name="rowsOut"/>) and collecting the ordered <c>&lt;string&gt;</c> area tokens - /// (<paramref name="areaStrings"/>). Every emitted token is whitespace-separated so both round-trip - /// serialization (via <c>ToText</c>) and re-parsing are valid. + /// Splits the areas form into the <c>grid-template-rows</c> track list and the area strings, a row + /// without a track size gets <c>auto</c>. /// </summary> private static bool TryScanAreasForm(Token[] toks, out List<Token> rowsOut, out List<Token> areaStrings) { @@ -158,8 +133,6 @@ private static bool TryScanAreasForm(Token[] toks, out List<Token> rowsOut, out if (t.Type == TokenType.SquareBracketOpen) { - // Copy the [ name … ] line-name group (idents only) at the current line position, kept - // compact as `[a b]` (edge spaces omitted, single space between names). if (rowsOut.Count > 0) rowsOut.Add(Token.Whitespace); rowsOut.Add(t); i++; @@ -172,8 +145,8 @@ private static bool TryScanAreasForm(Token[] toks, out List<Token> rowsOut, out firstName = false; i++; } - if (i >= toks.Length) return false; // unclosed '[' - rowsOut.Add(toks[i]); // ']' + if (i >= toks.Length) return false; + rowsOut.Add(toks[i]); i++; continue; } @@ -184,7 +157,6 @@ private static bool TryScanAreasForm(Token[] toks, out List<Token> rowsOut, out sawString = true; i++; - // Optional single-token <track-size> for this row (else the row track is `auto`). if (i < toks.Length && toks[i].Type != TokenType.SquareBracketOpen && toks[i].Type != TokenType.String) { if (GridTrackListGrammar.TryParseTrackSizeList(new[] { toks[i] }) is null) return false; @@ -199,7 +171,6 @@ private static bool TryScanAreasForm(Token[] toks, out List<Token> rowsOut, out continue; } - // A track-size or stray token with no preceding string at this position is invalid. return false; } @@ -213,9 +184,8 @@ private static void Emit(List<Token> tokens, Token token) } /// <summary> - /// Parses the <c>[ auto-flow &amp;&amp; dense? ] &lt;track-size&gt;*</c> side of a <c>grid</c> auto-flow - /// form. Returns <see langword="false"/> when <c>auto-flow</c> is absent or the value is malformed; - /// <paramref name="autoTracks"/> is null when the optional track-size list is omitted (reset to initial). + /// The <c>[ auto-flow &amp;&amp; dense? ] &lt;track-size&gt;*</c> side of <c>grid</c>, a null + /// <paramref name="autoTracks"/> means it's reset to its initial value. /// </summary> internal static bool TryParseAutoFlowSide(IReadOnlyList<Token> raw, out bool dense, out IReadOnlyList<Token> autoTracks) { diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateShorthandValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateShorthandValueConverter.cs index 82537bac0..c2b99c572 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateShorthandValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateShorthandValueConverter.cs @@ -3,9 +3,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The <c>grid-template</c> shorthand (CSS Grid §7.4), expanding to <c>grid-template-rows</c> / - /// <c>grid-template-columns</c> / <c>grid-template-areas</c>. An omitted axis is reset to its initial - /// value (<c>none</c>) by <see cref="ShorthandProperty.Export"/> when its slice is absent. + /// <c>grid-template</c>, CSS Grid 2 §7.4. <see cref="ShorthandProperty.Export"/> resets a longhand without + /// a slice to its initial value. /// </summary> internal sealed class GridTemplateShorthandValueConverter : IValueConverter { diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateValueConverter.cs index a2abf5c83..250c7a98a 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GridTemplateValueConverter.cs @@ -5,14 +5,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Validates a <c>grid-template-columns</c>/<c>grid-template-rows</c> value: accepts the keyword - /// <c>none</c>, the <c>subgrid</c> keyword, or any <c>&lt;track-list&gt;</c> the shared - /// <see cref="GridTrackListGrammar"/> recognizes (lengths, %, <c>fr</c>, <c>auto</c>, - /// <c>min-content</c>/<c>max-content</c>, <c>minmax()</c>, <c>fit-content()</c>, <c>repeat()</c>), - /// rejecting everything else so an invalid template is dropped at parse time. The parse produced here to - /// validate is <b>kept</b> and exposed as a <see cref="CssProperty{T}"/> via - /// <see cref="ITypedPropertyValue{T}"/>, so the grid layout engine (Layer B) reads the already-parsed - /// template instead of re-parsing the same string. + /// <c>grid-template-columns</c>/<c>-rows</c>, CSS Grid 2 §7.2. The parsed template is kept as a typed + /// <see cref="CssProperty{T}"/>. /// </summary> internal sealed class GridTemplateValueConverter : IValueConverter { @@ -38,12 +32,8 @@ public IPropertyValue Construct(Property[] properties) } /// <summary> - /// Builds the box-side <see cref="CssProperty{T}"/> for a grid-template value directly from its authored - /// string, for the cascade paths that apply a plain string rather than a Layer A parse result (the - /// initial-value seed, a resolved global keyword, an inherited copy, a var()-resolved string). A CSS-wide - /// keyword becomes a global value; a string still containing <c>var(</c> stays unresolved (never parsed - /// into a bogus template); otherwise the track list is parsed by the same <see cref="GridTrackListGrammar"/> - /// the converter uses — keeping all <see cref="CssProperty{T}"/> construction in the CSS-OM layer. + /// For the cascade paths that only have a string: the initial value, a CSS-wide keyword, an inherited + /// value or a resolved <c>var()</c>. /// </summary> internal static CssProperty<GridTemplate> FromCssText(string value) { @@ -55,9 +45,7 @@ internal static CssProperty<GridTemplate> FromCssText(string value) return CssProperty<GridTemplate>.FromValue(value, template); } - /// <summary>Tokenizes an authored value string with the CSS-OM <see cref="Lexer"/> (whitespace/EOF - /// stripped) — the CSS-OM equivalent of the Html-layer <c>CssValueParser.GetCssTokens</c>, so this - /// Layer A factory has no upward dependency on <c>Html.Core.Parse</c>.</summary> + /// <summary>Like <c>CssValueParser.GetCssTokens</c>, without depending on <c>Core.Parse</c>.</summary> private static List<Token> Tokenize(string value) { using (var lexer = new Lexer(value) { IsInValue = false }) @@ -81,8 +69,7 @@ private sealed class GridTemplateValue : IPropertyValue, ITypedPropertyValue<Gri public GridTemplateValue(IEnumerable<Token> tokens, GridTemplate template) { Original = new TokenValue(tokens); - // template is null for the `none` keyword — a resolved value with a null GridTemplate, which - // the engine treats as an empty (no explicit tracks) grid, exactly like the string path. + // null template for none _typed = CssProperty<GridTemplate>.FromValue(Original.Text, template); } diff --git a/Source/Test/HtmlRenderer.Test/Css/CssGrid2Tests.cs b/Source/Test/HtmlRenderer.Test/Css/CssGrid2Tests.cs new file mode 100644 index 000000000..aa80e95cb --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/CssGrid2Tests.cs @@ -0,0 +1,271 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; + +namespace HtmlRenderer.Test.Css; + +[TestClass] +public sealed class CssGrid2Tests +{ + private static bool IsAccepted(string declaration) => + CssConstructionFunctions.ParseDeclaration(declaration)?.HasValue == true; + + // ── parsing ────────────────────────────────────────────────────────── + + [TestMethod] + [DataRow("display: grid")] + [DataRow("display: inline-grid")] + [Spec("css-grid-2", "#grid-containers")] + public void GridDisplayValues_AreAccepted(string declaration) + { + Assert.IsTrue(IsAccepted(declaration)); + } + + [TestMethod] + [DataRow("grid-template-columns: none")] + [DataRow("grid-template-columns: 100px 20% auto min-content max-content")] + [DataRow("grid-template-columns: minmax(100px, 1fr) fit-content(200px)")] + [DataRow("grid-template-rows: [header-start] 50px [header-end main-start] 1fr [main-end]")] + [DataRow("grid-template-columns: repeat(3, 1fr 2fr)")] + [DataRow("grid-template-columns: [a] repeat(auto-fill, minmax(100px, 1fr)) [b]")] + [DataRow("grid-template-columns: 100px repeat(auto-fit, 50px) 100px")] + [DataRow("grid-template-columns: 0.5fr 2.5fr")] + [Spec("css-grid-2", "#track-sizing")] + [Spec("css-grid-2", "#track-sizes")] + [Spec("css-grid-2", "#named-lines")] + [Spec("css-grid-2", "#repeat-syntax")] + [Spec("css-grid-2", "#auto-repeat")] + [Spec("css-grid-2", "#fr-unit")] + public void TrackLists_AreAccepted(string declaration) + { + Assert.IsTrue(IsAccepted(declaration)); + } + + [TestMethod] + [DataRow("grid-template-columns: repeat(auto-fill, 50px) repeat(auto-fit, 50px)")] + [DataRow("grid-template-columns: minmax(1fr, 100px)")] + [DataRow("grid-template-columns: -1fr")] + [DataRow("grid-template-columns: repeat(0, 100px)")] + [Spec("css-grid-2", "#track-sizing")] + [Spec("css-grid-2", "#repeat-syntax")] + public void InvalidTrackLists_AreDropped(string declaration) + { + Assert.IsFalse(IsAccepted(declaration)); + } + + [TestMethod] + [DataRow("grid-template-areas: none")] + [DataRow("grid-template-areas: \"head head\" \"nav main\" \". foot\"")] + [Spec("css-grid-2", "#grid-template-areas-property")] + public void TemplateAreas_AreAccepted(string declaration) + { + Assert.IsTrue(IsAccepted(declaration)); + } + + [TestMethod] + [DataRow("grid-template-areas: \"a a\" \"a b a\"")] + [DataRow("grid-template-areas: \"a b\" \"b a\"")] + [Spec("css-grid-2", "#grid-template-areas-property")] + public void NonRectangularTemplateAreas_AreDropped(string declaration) + { + Assert.IsFalse(IsAccepted(declaration)); + } + + [TestMethod] + [DataRow("grid-auto-columns: 100px")] + [DataRow("grid-auto-rows: minmax(10px, auto) 1fr")] + [Spec("css-grid-2", "#auto-tracks")] + public void AutoTracks_AreAccepted(string declaration) + { + Assert.IsTrue(IsAccepted(declaration)); + } + + [TestMethod] + [DataRow("grid-auto-flow: row", true)] + [DataRow("grid-auto-flow: dense column", true)] + [DataRow("grid-auto-flow: row column", false)] + [Spec("css-grid-2", "#grid-auto-flow-property")] + public void AutoFlow_ParsesRowOrColumnWithDense(string declaration, bool accepted) + { + Assert.AreEqual(accepted, IsAccepted(declaration)); + } + + [TestMethod] + [DataRow("grid-column-start: auto")] + [DataRow("grid-column-end: -1")] + [DataRow("grid-row-start: span 2")] + [DataRow("grid-row-end: main-end")] + [DataRow("grid-row-end: 2 col")] + [Spec("css-grid-2", "#line-placement")] + public void GridLines_AreAccepted(string declaration) + { + Assert.IsTrue(IsAccepted(declaration)); + } + + [TestMethod] + [Spec("css-grid-2", "#placement-shorthands")] + public void GridArea_WithOneName_CopiesItToAllFourLines() + { + var style = CssConstructionFunctions.ParseDeclarations("grid-area: main"); + + Assert.AreEqual("main", style.GetPropertyValue("grid-row-start")); + Assert.AreEqual("main", style.GetPropertyValue("grid-column-start")); + Assert.AreEqual("main", style.GetPropertyValue("grid-row-end")); + Assert.AreEqual("main", style.GetPropertyValue("grid-column-end")); + } + + [TestMethod] + [Spec("css-grid-2", "#placement-shorthands")] + public void GridColumn_WithANumber_LeavesTheEndAuto() + { + var style = CssConstructionFunctions.ParseDeclarations("grid-column: 2"); + + Assert.AreEqual("2", style.GetPropertyValue("grid-column-start")); + Assert.AreEqual("auto", style.GetPropertyValue("grid-column-end")); + } + + [TestMethod] + [DataRow("grid-template-columns: subgrid")] + [DataRow("grid-template-rows: subgrid [a] [b c]")] + [Spec("css-grid-2", "#subgrids")] + public void Subgrid_IsAccepted(string declaration) + { + Assert.IsTrue(IsAccepted(declaration)); + } + + [TestMethod] + [DataRow("order: -1")] + [Spec("css-grid-2", "#order-property")] + public void Order_IsAccepted(string declaration) + { + Assert.IsTrue(IsAccepted(declaration)); + } + + [TestMethod] + [DataRow("row-gap: 10px")] + [DataRow("column-gap: 5%")] + [DataRow("gap: 10px 20px")] + [Spec("css-grid-2", "#gutters")] + public void Gaps_AreAccepted(string declaration) + { + Assert.IsTrue(IsAccepted(declaration)); + } + + [TestMethod] + [DataRow("justify-self: center", "#row-align")] + [DataRow("justify-items: end", "#row-align")] + [DataRow("align-self: stretch", "#column-align")] + [DataRow("align-items: start", "#column-align")] + [DataRow("justify-content: space-between", "#grid-align")] + [DataRow("align-content: center", "#grid-align")] + [Spec("css-grid-2", "#row-align")] + [Spec("css-grid-2", "#column-align")] + [Spec("css-grid-2", "#grid-align")] + public void AlignmentProperties_AreAccepted(string declaration, string section) + { + Assert.IsTrue(IsAccepted(declaration), section); + } + + // ── gaps ───────────────────────────────────────────────────────────── + + [Ignore("Gap, see Spec/css-grid-2.yml")] + [TestMethod] + [Spec("css-grid-2", "#grid-containers")] + [Spec("css-grid-2", "#auto-placement-algo")] + public void GridItems_AreAutoPlacedIntoTheColumnTracks() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='display:grid;grid-template-columns:100px 100px'><div id=a>a</div><div id=b>b</div></div>")); + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual(a.Location.Y, b.Location.Y, 0.5); + Assert.AreEqual(a.Location.X + 100, b.Location.X, 0.5); + Assert.AreEqual(100, a.Size.Width, 0.5); + } + + [Ignore("Gap, see Spec/css-grid-2.yml")] + [TestMethod] + [Spec("css-grid-2", "#fr-unit")] + [Spec("css-grid-2", "#algo-flex-tracks")] + public void FlexibleTracks_ShareTheFreeSpaceByFactor() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='display:grid;width:400px;grid-template-columns:1fr 3fr'><div id=a>a</div><div id=b>b</div></div>")); + + Assert.AreEqual(100, LayoutHarness.FindById(root, "a")!.Size.Width, 0.5); + Assert.AreEqual(300, LayoutHarness.FindById(root, "b")!.Size.Width, 0.5); + } + + [Ignore("Gap, see Spec/css-grid-2.yml")] + [TestMethod] + [DataRow("grid-template-columns: -10px")] + [DataRow("grid-template-columns: 10deg")] + [Spec("css-grid-2", "#track-sizes")] + public void NegativeOrNonLengthTrackSizes_AreDropped(string declaration) + { + Assert.IsFalse(IsAccepted(declaration)); + } + + [Ignore("Gap, see Spec/css-grid-2.yml")] + [TestMethod] + [DataRow("grid-template-columns: [span] 100px")] + [DataRow("grid-template-columns: 100px [auto]")] + [Spec("css-grid-2", "#named-lines")] + public void SpanAndAutoLineNames_AreDropped(string declaration) + { + Assert.IsFalse(IsAccepted(declaration)); + } + + [Ignore("Gap, see Spec/css-grid-2.yml")] + [TestMethod] + [Spec("css-grid-2", "#repeat-syntax")] + public void LineNamesInsideRepeat_AreAccepted() + { + Assert.IsTrue(IsAccepted("grid-template-columns: repeat(2, [col-start] 1fr [col-end])")); + } + + [Ignore("Gap, see Spec/css-grid-2.yml")] + [TestMethod] + [DataRow("grid-template-columns: repeat(auto-fill, 1fr)")] + [DataRow("grid-template-columns: repeat(auto-fit, minmax(auto, auto))")] + [DataRow("grid-template-columns: repeat(auto-fill, 100px) 1fr")] + [Spec("css-grid-2", "#auto-repeat")] + public void AutoRepeat_WithIntrinsicOrFlexibleTracks_IsDropped(string declaration) + { + Assert.IsFalse(IsAccepted(declaration)); + } + + [Ignore("Gap, see Spec/css-grid-2.yml")] + [TestMethod] + [Spec("css-grid-2", "#grid-template-areas-property")] + public void TemplateAreas_DotBetweenNames_IsItsOwnCell() + { + Assert.IsTrue(IsAccepted("grid-template-areas: \"a.b c\" \"a.b c\"")); + } + + [Ignore("Gap, see Spec/css-grid-2.yml")] + [TestMethod] + [Spec("css-grid-2", "#grid-template-areas-property")] + public void TemplateAreas_WithATrashToken_IsDropped() + { + Assert.IsFalse(IsAccepted("grid-template-areas: \"a$ b\"")); + } + + [Ignore("Gap, see Spec/css-grid-2.yml")] + [TestMethod] + [DataRow("grid-row-end: span main")] + [DataRow("grid-column-start: span 2 col")] + [Spec("css-grid-2", "#line-placement")] + public void SpanWithALineName_IsAccepted(string declaration) + { + Assert.IsTrue(IsAccepted(declaration)); + } + + [Ignore("Gap, see Spec/css-grid-2.yml")] + [TestMethod] + [Spec("css-grid-2", "#subgrids")] + public void SubgridLineNameRepeat_IsAccepted() + { + Assert.IsTrue(IsAccepted("grid-template-columns: subgrid repeat(2, [a]) [b]")); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/GridLineGrammarTests.cs b/Source/Test/HtmlRenderer.Test/Css/GridLineGrammarTests.cs index 59fe91275..84757f3e1 100644 --- a/Source/Test/HtmlRenderer.Test/Css/GridLineGrammarTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/GridLineGrammarTests.cs @@ -3,12 +3,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/GridLineGrammarTests.cs. -/// Tests for the shared <see cref="GridLineGrammar"/> - the grid <c>&lt;grid-line&gt;</c> value -/// (<c>auto | &lt;integer&gt; | span &lt;integer&gt;</c>), confirmed a field-for-field match against -/// PeachPDF's implementation. -/// </summary> [TestClass] public sealed class GridLineGrammarTests { @@ -16,6 +10,7 @@ public sealed class GridLineGrammarTests GridLineGrammar.TryParse(CssValueParser.GetCssTokens(value)); [TestMethod] + [Spec("css-grid-2", "#line-placement")] public void Auto_Parses() { var line = Parse("auto"); @@ -24,6 +19,7 @@ public void Auto_Parses() } [TestMethod] + [Spec("css-grid-2", "#line-placement")] [DataRow("1", 1)] [DataRow("3", 3)] [DataRow("-1", -1)] @@ -37,6 +33,7 @@ public void IntegerLine_Parses(string value, int expected) } [TestMethod] + [Spec("css-grid-2", "#line-placement")] [DataRow("span 1", 1)] [DataRow("span 3", 3)] public void Span_Parses(string value, int expected) @@ -48,6 +45,7 @@ public void Span_Parses(string value, int expected) } [TestMethod] + [Spec("css-grid-2", "#line-placement")] [DataRow("sidebar")] [DataRow("main-start")] public void NamedLine_Parses(string value) @@ -61,6 +59,12 @@ public void NamedLine_Parses(string value) } [TestMethod] + [Ignore("Gap, see Spec/css-grid-2.yml")] + [Spec("css-grid-2", "#line-placement")] + public void NoneAsALineName_Parses() => NamedLine_Parses("none"); + + [TestMethod] + [Spec("css-grid-2", "#line-placement")] [DataRow("col 2", "col", 2)] [DataRow("2 col", "col", 2)] [DataRow("col -1", "col", -1)] @@ -73,6 +77,7 @@ public void NamedNthLine_Parses(string value, string name, int nth) } [TestMethod] + [Spec("css-grid-2", "#line-placement")] [DataRow("")] [DataRow("0")] // line 0 is invalid [DataRow("span 0")] // span must be >= 1 @@ -80,9 +85,7 @@ public void NamedNthLine_Parses(string value, string name, int nth) [DataRow("span auto")] [DataRow("1.5")] // not an integer [DataRow("[name]")] // bracketed line names belong in a track list, not a <grid-line> - [DataRow("none")] // a CSS-wide/reserved keyword is not a custom-ident line name - [DataRow("initial")] - [DataRow("span foo")] // span <custom-ident> is out of v1 scope + [DataRow("initial")] // a CSS-wide keyword is not a custom-ident line name public void Invalid_ReturnsNull(string value) { Assert.IsNull(Parse(value)); diff --git a/Source/Test/HtmlRenderer.Test/Css/GridTemplateAreasGrammarTests.cs b/Source/Test/HtmlRenderer.Test/Css/GridTemplateAreasGrammarTests.cs index ff3896ca4..060515972 100644 --- a/Source/Test/HtmlRenderer.Test/Css/GridTemplateAreasGrammarTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/GridTemplateAreasGrammarTests.cs @@ -3,12 +3,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/GridTemplateAreasGrammarTests.cs. -/// Tests for the shared <see cref="GridTemplateAreasGrammar"/> - the <c>grid-template-areas</c> value -/// (a rectangular grid of named cells), confirmed a field-for-field match against PeachPDF's -/// implementation. -/// </summary> [TestClass] public sealed class GridTemplateAreasGrammarTests { @@ -16,6 +10,7 @@ public sealed class GridTemplateAreasGrammarTests GridTemplateAreasGrammar.TryParse(CssValueParser.GetCssTokens(value)); [TestMethod] + [Spec("css-grid-2", "#grid-template-areas-property")] public void RectangularGrid_ParsesWithAreaBounds() { var areas = Parse("\"header header header\" \"nav main main\" \"footer footer footer\""); @@ -29,6 +24,7 @@ public void RectangularGrid_ParsesWithAreaBounds() } [TestMethod] + [Spec("css-grid-2", "#grid-template-areas-property")] public void DotCells_AreEmpty_AndNotAreas() { var areas = Parse("\"a . b\" \". . .\""); @@ -42,6 +38,7 @@ public void DotCells_AreEmpty_AndNotAreas() } [TestMethod] + [Spec("css-grid-2", "#grid-template-areas-property")] public void TripleDot_IsAlsoAnEmptyCell() { var areas = Parse("\"a ... b\""); @@ -50,13 +47,13 @@ public void TripleDot_IsAlsoAnEmptyCell() } [TestMethod] + [Spec("css-grid-2", "#grid-template-areas-property")] [DataRow("\"a a\" \"a a a\"")] // ragged rows (2 vs 3 columns) [DataRow("\"a b\" \"b a\"")] // 'a' is not a rectangle (diagonal) [DataRow("\"a a\" \"a .\"")] // 'a' bounding box includes an empty cell → not filled [DataRow("\"a b a\"")] // 'a' occupies cols 0 and 2 with b between → not rectangular [DataRow("100px")] // not a string list [DataRow("\"\"")] // an empty string row - [DataRow("\"a.b c\"")] // a cell mixing a name and a dot is not a valid cell token [DataRow("")] public void Invalid_ReturnsNull(string value) { diff --git a/Source/Test/HtmlRenderer.Test/Css/GridTemplateShorthandTests.cs b/Source/Test/HtmlRenderer.Test/Css/GridTemplateShorthandTests.cs index e2ab66565..26e8ea8ba 100644 --- a/Source/Test/HtmlRenderer.Test/Css/GridTemplateShorthandTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/GridTemplateShorthandTests.cs @@ -4,12 +4,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/GridTemplateShorthandTests.cs. -/// The <c>grid</c> / <c>grid-template</c> mega-shorthands (CSS Grid §7.4 / §7.8, issue #264) parse and -/// expand to the grid longhands at parse time, and — like the other reconstruction-excluded shorthands — -/// are never re-collapsed when a declaration block is serialized. -/// </summary> [TestClass] public sealed class GridTemplateShorthandTests { @@ -19,6 +13,7 @@ private static StyleDeclaration Style(string declaration) => // ── grid-template ──────────────────────────────────────────────────── [TestMethod] + [Spec("css-grid-2", "#explicit-grid-shorthand")] public void GridTemplate_None_ResetsAllThree() { var style = Style("grid-template: none;"); @@ -28,6 +23,7 @@ public void GridTemplate_None_ResetsAllThree() } [TestMethod] + [Spec("css-grid-2", "#explicit-grid-shorthand")] public void GridTemplate_RowsSlashColumns_SetsBothAxesAreasNone() { var style = Style("grid-template: 1fr 2fr / 100px 200px;"); @@ -38,6 +34,7 @@ public void GridTemplate_RowsSlashColumns_SetsBothAxesAreasNone() } [TestMethod] + [Spec("css-grid-2", "#explicit-grid-shorthand")] public void GridTemplate_AreasForm_SynthesizesRowsAndColumns() { var style = Style("grid-template: \"a a\" 40px \"b b\" / 1fr 1fr;"); @@ -47,6 +44,7 @@ public void GridTemplate_AreasForm_SynthesizesRowsAndColumns() } [TestMethod] + [Spec("css-grid-2", "#explicit-grid-shorthand")] public void GridTemplate_AreasForm_WithLineNames_PreservesThem() { var style = Style("grid-template: [r1] \"a\" 10px [r2] \"b\" [r3];"); @@ -57,6 +55,7 @@ public void GridTemplate_AreasForm_WithLineNames_PreservesThem() } [TestMethod] + [Spec("css-grid-2", "#explicit-grid-shorthand")] // `none` is a valid <grid-template-rows>/<grid-template-columns> value on either side of the slash. [DataRow("grid-template: none / 1fr 1fr;", "none", "1fr 1fr")] [DataRow("grid-template: 1fr 2fr / none;", "1fr 2fr", "none")] @@ -70,6 +69,7 @@ public void GridTemplate_NoneOnOneAxis_IsAccepted(string declaration, string exp // ── grid ───────────────────────────────────────────────────────────── [TestMethod] + [Spec("css-grid-2", "#grid-shorthand")] public void Grid_TemplateForm_ResetsAutoProperties() { var style = Style("grid: \"a\" / 1fr;"); @@ -83,6 +83,7 @@ public void Grid_TemplateForm_ResetsAutoProperties() } [TestMethod] + [Spec("css-grid-2", "#grid-shorthand")] public void Grid_ColumnAutoFlowForm_SetsRowsFlowAndAutoColumns() { var style = Style("grid: 1fr / auto-flow 2fr;"); @@ -93,6 +94,7 @@ public void Grid_ColumnAutoFlowForm_SetsRowsFlowAndAutoColumns() } [TestMethod] + [Spec("css-grid-2", "#grid-shorthand")] public void Grid_RowAutoFlowForm_WithDense_SetsColumnsFlowAndAutoRows() { var style = Style("grid: auto-flow dense 10px / 1fr;"); @@ -102,6 +104,7 @@ public void Grid_RowAutoFlowForm_WithDense_SetsColumnsFlowAndAutoRows() } [TestMethod] + [Spec("css-grid-2", "#grid-shorthand")] public void Grid_NoneRows_WithColumnAutoFlow_IsAccepted() { // The <grid-template-rows> side of the auto-flow form also accepts `none`. @@ -114,6 +117,8 @@ public void Grid_NoneRows_WithColumnAutoFlow_IsAccepted() // ── rejection (whole declaration dropped) ──────────────────────────── [TestMethod] + [Spec("css-grid-2", "#explicit-grid-shorthand")] + [Spec("css-grid-2", "#grid-shorthand")] [DataRow("grid-template: 10px \"a\";")] // track-size before the first string [DataRow("grid-template: \"a\" repeat(2, 1fr);")] // repeat() is not a valid row <track-size> [DataRow("grid-template: \"a\" \"b\" / repeat(2, 1fr);")] // trailing columns are an <explicit-track-list> (no repeat()) diff --git a/Source/Test/HtmlRenderer.Test/Css/GridTrackListGrammarTests.cs b/Source/Test/HtmlRenderer.Test/Css/GridTrackListGrammarTests.cs index 0bf78c15f..ffce44a18 100644 --- a/Source/Test/HtmlRenderer.Test/Css/GridTrackListGrammarTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/GridTrackListGrammarTests.cs @@ -4,12 +4,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/GridTrackListGrammarTests.cs. -/// Tests for the shared <see cref="GridTrackListGrammar"/> — the grid <c>&lt;track-list&gt;</c> -/// (<c>grid-template-columns</c>/<c>-rows</c>) and <c>&lt;track-size&gt;+</c> (<c>grid-auto-columns</c>/ -/// <c>-rows</c>) value grammars. -/// </summary> [TestClass] public sealed class GridTrackListGrammarTests { @@ -17,6 +11,11 @@ private static GridTemplate Parse(string value) => GridTrackListGrammar.TryParse(CssValueParser.GetCssTokens(value)); [TestMethod] + [Spec("css-grid-2", "#track-sizing")] + [Spec("css-grid-2", "#track-sizes")] + [Spec("css-grid-2", "#repeat-syntax")] + [Spec("css-grid-2", "#auto-repeat")] + [Spec("css-grid-2", "#fr-unit")] [DataRow("100px")] [DataRow("100px 200px")] [DataRow("1fr 2fr")] @@ -38,6 +37,8 @@ public void ValidTrackLists_Parse(string value) } [TestMethod] + [Spec("css-grid-2", "#track-sizing")] + [Spec("css-grid-2", "#repeat-syntax")] [DataRow("")] [DataRow("none")] // 'none' is accepted by the property, not the grammar [DataRow("banana")] @@ -46,7 +47,6 @@ public void ValidTrackLists_Parse(string value) [DataRow("repeat(0, 100px)")] // repeat count must be >= 1 [DataRow("repeat(auto-fill)")] // repeat needs a track body [DataRow("-1fr")] // negative flex - [DataRow("repeat(2, [x] 1fr)")] // named lines inside repeat() are out of v1 scope [DataRow("[unclosed 100px")] // an unclosed [ is invalid public void InvalidTrackLists_ReturnNull(string value) { @@ -54,6 +54,7 @@ public void InvalidTrackLists_ReturnNull(string value) } [TestMethod] + [Spec("css-grid-2", "#named-lines")] [DataRow("[sidebar-start] 200px [sidebar-end] 1fr")] [DataRow("[a b] 100px [c]")] [DataRow("[start] repeat(3, 100px) [end]")] @@ -63,6 +64,7 @@ public void NamedLines_Parse(string value) } [TestMethod] + [Spec("css-grid-2", "#named-lines")] public void NamedLines_RecordSortedLineNumbers() { // [a] 100px [b] 100px [a] — 'a' labels lines 1 and 3, 'b' labels line 2. @@ -73,6 +75,7 @@ public void NamedLines_RecordSortedLineNumbers() } [TestMethod] + [Spec("css-grid-2", "#repeat-syntax")] public void RepeatFixed_ExpandsInline() { var template = Parse("repeat(3, 100px)"); @@ -85,6 +88,7 @@ public void RepeatFixed_ExpandsInline() } [TestMethod] + [Spec("css-grid-2", "#fr-unit")] public void Fr_ParsesAsFlexFactor() { var template = Parse("1fr 2fr"); @@ -95,6 +99,7 @@ public void Fr_ParsesAsFlexFactor() } [TestMethod] + [Spec("css-grid-2", "#track-sizes")] public void Minmax_CapturesBothBreadths() { var template = Parse("minmax(100px, 1fr)"); @@ -107,6 +112,7 @@ public void Minmax_CapturesBothBreadths() } [TestMethod] + [Spec("css-grid-2", "#auto-repeat")] public void AutoRepeat_RecordedWithKindAndInsertIndex() { var template = Parse("100px repeat(auto-fill, 50px) 100px"); @@ -120,12 +126,14 @@ public void AutoRepeat_RecordedWithKindAndInsertIndex() } [TestMethod] + [Spec("css-grid-2", "#auto-repeat")] public void TwoAutoRepeats_AreRejected() { Assert.IsNull(Parse("repeat(auto-fill, 50px) repeat(auto-fit, 50px)")); } [TestMethod] + [Spec("css-grid-2", "#subgrids")] [DataRow("subgrid")] [DataRow("subgrid [a]")] [DataRow("subgrid [a b] [c]")] @@ -139,6 +147,7 @@ public void Subgrid_Parses(string value) } [TestMethod] + [Spec("css-grid-2", "#subgrids")] public void Subgrid_RecordsLineNamesAtSequentialLines() { // subgrid [a] [b c] [a] — the line-name list positions 'a' at lines 1 and 3, 'b'/'c' at line 2. @@ -151,10 +160,10 @@ public void Subgrid_RecordsLineNamesAtSequentialLines() } [TestMethod] + [Spec("css-grid-2", "#subgrids")] [DataRow("subgrid 1fr")] // no track size may follow subgrid [DataRow("1fr subgrid")] // subgrid must be the first token [DataRow("subgrid 100px [a]")] // a track size mixed into the line-name list - [DataRow("subgrid repeat(2, [a])")] // repeat() in a subgrid line-name list is a v1 deferral [DataRow("subgrid [unclosed")] // an unclosed [ is invalid public void Subgrid_InvalidForms_ReturnNull(string value) { @@ -162,6 +171,7 @@ public void Subgrid_InvalidForms_ReturnNull(string value) } [TestMethod] + [Spec("css-grid-2", "#track-sizes")] [DataRow("calc(10px + 20px)")] // bare calc() track size [DataRow("calc(50% - 10px)")] // length-percentage calc [DataRow("minmax(calc(10px + 10px), 1fr)")] // calc() as a minmax() min breadth @@ -175,6 +185,7 @@ public void CalcTrackSizes_Parse(string value) } [TestMethod] + [Spec("css-grid-2", "#track-sizes")] public void BareCalcTrack_IsStoredAsLength() { var template = Parse("calc(10px + 20px) 1fr"); @@ -184,6 +195,7 @@ public void BareCalcTrack_IsStoredAsLength() } [TestMethod] + [Spec("css-grid-2", "#track-sizes")] [DataRow("calc(10deg + 5deg)")] // an angle-category calc is not a track size [DataRow("minmax(calc(90deg), 1fr)")] // a wrong-category calc inside minmax() [DataRow("fit-content(calc(1s))")] // a time-category calc argument @@ -193,6 +205,7 @@ public void WrongCategoryCalc_ReturnsNull(string value) } [TestMethod] + [Spec("css-grid-2", "#auto-tracks")] [DataRow("100px", 1)] [DataRow("100px 200px auto", 3)] public void TrackSizeList_ParsesForAutoColumns(string value, int expected) @@ -203,6 +216,7 @@ public void TrackSizeList_ParsesForAutoColumns(string value, int expected) } [TestMethod] + [Spec("css-grid-2", "#auto-tracks")] [DataRow("repeat(2, 100px)")] // no repeat() allowed in a track-size list [DataRow("")] [DataRow("banana")] @@ -211,20 +225,10 @@ public void TrackSizeList_RejectsInvalid(string value) Assert.IsNull(GridTrackListGrammar.TryParseTrackSizeList(CssValueParser.GetCssTokens(value))); } - // ── value equality — GridTemplate/GridTrackSize are the CssProperty<T> type argument for - // grid-template-columns/-rows, so the cascade's copy-on-write comparison depends on two separately - // parsed instances of the same authored text comparing equal (see CssProperty<T>'s own remarks). ── - // - // NOTE: In PeachPDF, GridTemplate/GridTrackSize are `record` types with hand-written structural - // Equals/GetHashCode (see PeachPDF's GridTrackListGrammar.cs remarks on LineNames dictionary - // equality). In this port (Source/HtmlRenderer/Core/CssEngine/GridTrackListGrammar.cs), both - // GridTemplate and GridTrackSize are plain `sealed class` types with no Equals/GetHashCode override, - // so two separately parsed instances of identical authored text are NOT equal (reference equality - // only). The four tests below are ported with their original assertions intact but ignored, since - // they fail against the current port until value equality is added. + // The cascade's copy-on-write comparison of CssProperty<GridTemplate> relies on value equality. [TestMethod] - [Ignore("not yet spec compliant — GridTemplate/GridTrackSize have no value-equality override in this port (plain sealed classes, unlike PeachPDF's `record` types), so two separate parses of identical text are not Equal")] + [Ignore("GridTemplate/GridTrackSize have no value equality, two parses of the same text aren't Equal")] [DataRow("100px 1fr auto")] [DataRow("minmax(100px, 1fr) minmax(min-content, max-content)")] [DataRow("[start] 100px [middle] 1fr [end]")] @@ -241,9 +245,7 @@ public void GridTemplate_TwoSeparateParsesOfTheSameText_AreEqual(string value) } [TestMethod] - // NOTE: not ignored — with no Equals override, two different-text parses are still trivially - // "not equal" by reference identity alone, so this assertion holds against the current port even - // though it isn't exercising real structural inequality the way it does in PeachPDF. + // Passes trivially by reference identity until value equality exists. [DataRow("100px 1fr", "100px 2fr")] // different track [DataRow("100px 1fr", "100px 1fr 1fr")] // different track count [DataRow("[a] 100px", "[b] 100px")] // different line name @@ -254,7 +256,7 @@ public void GridTemplate_DifferingText_AreNotEqual(string a, string b) } [TestMethod] - [Ignore("not yet spec compliant — GridTemplate has no value-equality override in this port (plain sealed class), so two separately parsed empty-subgrid instances are not Equal")] + [Ignore("GridTemplate has no value equality, two parses of the same text aren't Equal")] public void GridTemplate_SubgridWithNoNamedLines_EqualsAnotherEmptySubgrid() { var a = Parse("subgrid"); diff --git a/Spec/css-grid-2.yml b/Spec/css-grid-2.yml index 277cae5c8..be5ccdeac 100644 --- a/Spec/css-grid-2.yml +++ b/Spec/css-grid-2.yml @@ -5,277 +5,254 @@ snapshot: https://www.w3.org/TR/2025/CRD-css-grid-2-20250326/ sections: '#intro': title: 1 Introduction - status: unknown - '#background': - title: 1.1 Background and Motivation - status: unknown - '#adapting-to-available-space': - title: 1.1.1 Adapting Layouts to Available Space - status: unknown - '#source-independence': - title: 1.1.2 Source-Order Independence - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#overview': title: 2 Overview - status: unknown - '#overview-grid': - title: 2.1 Declaring the Grid - status: unknown - '#overview-placement': - title: 2.2 Placing Items - status: unknown - '#overview-sizing': - title: 2.3 Sizing the Grid - status: unknown + status: n/a '#grid-concepts': title: 3 Grid Layout Concepts and Terminology - status: unknown - '#grid-line-concept': - title: 3.1 Grid Lines - status: unknown - '#grid-track-concept': - title: 3.2 Grid Tracks and Cells - status: unknown - '#grid-area-concept': - title: 3.3 Grid Areas - status: unknown - '#subgrid-items': - title: 3.4 Nested vs. Subgridded Items - status: unknown + status: n/a '#order-accessibility': title: 4 Reordering and Accessibility - status: unknown + status: n/a '#grid-model': title: 5 Grid Containers - status: unknown + status: unsupported + gaps: + - "No grid layout, a grid container lays out and paints none of its content, see css-display-3 #inner-model" '#grid-containers': title: '5.1 Establishing Grid Containers: the grid and inline-grid display values' - status: unknown + status: unsupported + gaps: + - "'display: grid' and 'inline-grid' are parsed, but the container lays out and paints none of its content" '#intrinsic-sizes': title: 5.2 Sizing Grid Containers - status: unknown + status: unsupported '#overflow': title: 5.3 Scrollable Grid Overflow - status: unknown + status: unsupported '#overlarge-grids': title: 5.4 Limiting Large Grids - status: unknown + status: unsupported '#grid-items': title: 6 Grid Items - status: unknown + status: unsupported + gaps: + - "No grid items, the children of a grid container aren't laid out" '#grid-item-display': title: 6.1 Grid Item Display - status: unknown + status: unsupported '#grid-item-sizing': title: 6.2 Grid Item Sizing - status: unknown + status: unsupported '#order-property': title: '6.3 Reordered Grid Items: the order property' - status: unknown + status: parsed-only + gaps: + - "'order' is parsed and ignored" '#item-margins': title: 6.4 Grid Item Margins and Paddings - status: unknown + status: unsupported '#z-order': title: '6.5 Z-axis Ordering: the z-index property' - status: unknown + status: unsupported '#min-size-auto': title: 6.6 Automatic Minimum Size of Grid Items - status: unknown + status: unsupported '#grid-definition': title: 7 Defining the Grid - status: unknown + status: parsed-only + gaps: + - "All grid properties are parsed and ignored, there's no grid layout" '#explicit-grids': title: 7.1 The Explicit Grid - status: unknown + status: unsupported '#track-sizing': title: '7.2 Explicit Track Sizing: the grid-template-rows and grid-template-columns properties' - status: unknown + status: parsed-only '#track-sizes': title: 7.2.1 Track Sizes - status: unknown + status: parsed-only + gaps: + - "Negative lengths and non-length dimensions (e.g. 10deg) are accepted as track sizes" '#named-lines': title: '7.2.2 Naming Grid Lines: the [<custom-ident>*] syntax' - status: unknown + status: parsed-only + gaps: + - "'span' and 'auto' are accepted as line names" '#repeat-notation': title: '7.2.3 Repeating Rows and Columns: the repeat() notation' - status: unknown + status: parsed-only '#repeat-syntax': title: 7.2.3.1 Syntax of repeat() - status: unknown + status: parsed-only + gaps: + - "Line names inside repeat() make the value invalid" '#auto-repeat': title: '7.2.3.2 Repeat-to-fill: auto-fill and auto-fit repetitions' - status: unknown + status: parsed-only + gaps: + - "auto-fill/auto-fit repeat() is accepted with intrinsic or flexible tracks and next to them" '#repeat-interpolation': title: 7.2.3.3 Interpolation/Combination of repeat() - status: unknown + status: n/a '#fr-unit': title: '7.2.4 Flexible Lengths: the fr unit' - status: unknown + status: parsed-only '#computed-tracks': title: 7.2.5 Computed Value of a Track Listing - status: unknown + status: unsupported + gaps: + - "No computed track listing, the authored value is kept" '#resolved-track-list': title: 7.2.6 Resolved Value of a Track Listing - status: unknown - '#resolved-track-list-standalone': - title: 7.2.6.1 Resolved Value of a Standalone Track Listing - status: unknown - '#resolved-track-list-subgrid': - title: 7.2.6.2 Resolved Value of a Subgridded Track Listing - status: unknown + status: n/a '#grid-template-areas-property': title: '7.3 Named Areas: the grid-template-areas property' - status: unknown + status: parsed-only + gaps: + - 'A dot next to a name ("a.b") makes the value invalid instead of being its own cell' + - "Characters other than ident code points and dots are accepted in cell names instead of being a trash token" '#serialize-template': title: 7.3.1 Serialization Of Template Strings - status: unknown + status: n/a '#implicit-named-lines': title: 7.3.2 Implicitly-Assigned Line Names - status: unknown + status: unsupported '#implicit-named-areas': title: 7.3.3 Implicitly-Named Areas - status: unknown + status: unsupported '#explicit-grid-shorthand': title: '7.4 Explicit Grid Shorthand: the grid-template property' - status: unknown + status: parsed-only '#implicit-grids': title: 7.5 The Implicit Grid - status: unknown + status: unsupported '#auto-tracks': title: '7.6 Implicit Track Sizing: the grid-auto-rows and grid-auto-columns properties' - status: unknown + status: parsed-only '#grid-auto-flow-property': title: '7.7 Automatic Placement: the grid-auto-flow property' - status: unknown + status: parsed-only '#grid-shorthand': title: '7.8 Grid Definition Shorthand: the grid property' - status: unknown + status: parsed-only '#placement': title: 8 Placing Grid Items - status: unknown + status: parsed-only '#common-uses': title: 8.1 Common Patterns for Grid Placement - status: unknown - '#common-uses-named-areas': - title: 8.1.1 Named Areas - status: unknown - '#common-uses-numeric': - title: 8.1.2 Numeric Indexes and Spans - status: unknown - '#common-uses-named-lines': - title: 8.1.3 Named Lines and Spans - status: unknown - '#common-uses-auto-placement': - title: 8.1.4 Auto Placement - status: unknown + status: n/a '#placement-a11y': title: 8.2 Grid Item Placement vs. Source Order - status: unknown + status: n/a '#line-placement': title: '8.3 Line-based Placement: the grid-row-start, grid-column-start, grid-row-end, and grid-column-end properties' - status: unknown + status: parsed-only + gaps: + - "'span <custom-ident>' and 'span <integer> <custom-ident>' are rejected" + - A line name of none is rejected '#grid-placement-errors': title: 8.3.1 Grid Placement Conflict Handling - status: unknown + status: unsupported '#placement-shorthands': title: '8.4 Placement Shorthands: the grid-column, grid-row, and grid-area properties' - status: unknown + status: parsed-only '#auto-placement-algo': title: 8.5 Grid Item Placement Algorithm - status: unknown + status: unsupported '#subgrids': title: 9 Subgrids - status: unknown + status: parsed-only + gaps: + - "repeat() in the subgrid line name list makes the value invalid" + - "Subgrids aren't laid out, there's no grid layout" '#abspos': title: 10 Absolute Positioning - status: unknown + status: unsupported '#abspos-items': title: 10.1 With a Grid Container as Containing Block - status: unknown + status: unsupported '#static-position': title: 10.2 With a Grid Container as Parent - status: unknown + status: unsupported '#alignment': title: 11 Alignment and Spacing - status: unknown + status: parsed-only + gaps: + - "The alignment and gap properties are parsed and ignored" '#gutters': title: '11.1 Gutters: the row-gap, column-gap, and gap properties' - status: unknown + status: parsed-only + gaps: + - "'normal' is rejected by 'row-gap' and 'gap'" '#auto-margins': title: 11.2 Aligning with auto margins - status: unknown + status: unsupported '#row-align': title: '11.3 Inline-axis Alignment: the justify-self and justify-items properties' - status: unknown + status: parsed-only '#column-align': title: '11.4 Block-axis Alignment: the align-self and align-items properties' - status: unknown + status: parsed-only '#grid-align': title: '11.5 Aligning the Grid: the justify-content and align-content properties' - status: unknown + status: parsed-only '#grid-baselines': title: 11.6 Grid Container Baselines - status: unknown + status: unsupported '#layout-algorithm': title: 12 Grid Layout Algorithm - status: unknown + status: unsupported + gaps: + - "No grid sizing algorithm, see 5.1" '#algo-grid-sizing': title: 12.1 Grid Sizing Algorithm - status: unknown + status: unsupported '#algo-terms': title: 12.2 Track Sizing Terminology - status: unknown + status: n/a '#algo-track-sizing': title: 12.3 Track Sizing Algorithm - status: unknown + status: unsupported '#algo-init': title: 12.4 Initialize Track Sizes - status: unknown + status: unsupported '#algo-content': title: 12.5 Resolve Intrinsic Track Sizes - status: unknown + status: unsupported '#extra-space': title: 12.5.1 Distributing Extra Space Across Spanned Tracks - status: unknown + status: unsupported '#algo-grow-tracks': title: 12.6 Maximize Tracks - status: unknown + status: unsupported '#algo-flex-tracks': title: 12.7 Expand Flexible Tracks - status: unknown + status: unsupported '#algo-find-fr-size': title: 12.7.1 Find the Size of an fr - status: unknown + status: unsupported '#algo-stretch': title: 12.8 Stretch auto Tracks - status: unknown + status: unsupported '#pagination': title: 13 Fragmenting Grid Layout - status: unknown + status: unsupported + gaps: + - "No grid layout to fragment" '#fragmentation-alg': title: 13.1 Sample Fragmentation Algorithm - status: unknown + status: n/a '#privacy': title: 14 Privacy Considerations - status: unknown + status: n/a '#security': title: 15 Security Considerations - status: unknown + status: n/a '#changes': title: 16 Changes - status: unknown - '#changes-202012': - title: 16.1 Changes since the 18 December 2020 CR - status: unknown - '#changes-202008': - title: 16.2 Changes since the August 2020 CR - status: unknown - '#changes-2': - title: 16.3 Additions Since Level 1 - status: unknown + status: n/a '#acknowledgements': title: 17 Acknowledgements - status: unknown + status: n/a From 6446fcfda7c8155a0994968f18a6bd8c04af5e2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:26:52 +0100 Subject: [PATCH 087/150] Verify and document CSS Transforms Level 1 compliance --- .../Transform/TransformOriginProperty.cs | 22 +---- .../CssModules/CssTransformsTests.cs | 74 ++++++++++++++ .../Css/TransformPropertyTests.cs | 44 ++++++--- .../CssModules/CssTransformsTests.cs | 70 +++++++++++++ Spec/css-transforms-1.yml | 98 +++++++++---------- 5 files changed, 220 insertions(+), 88 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssTransformsTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Transform/TransformOriginProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Transform/TransformOriginProperty.cs index 244186094..3c36ceacf 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Transform/TransformOriginProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Transform/TransformOriginProperty.cs @@ -4,14 +4,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine internal sealed class TransformOriginProperty : Property { - // CSS Transforms 1 (https://www.w3.org/TR/css-transforms-1/#transform-origin-property): - // [ left | center | right | top | bottom | <length-percentage> ] - // | [ left | center | right | <length-percentage> ] [ top | center | bottom | <length-percentage> ] <length>? - // | [ [ center | left | right ] && [ center | top | bottom ] ] <length>? - // - // The two-value <length-percentage> form is ORDERED (horizontal then vertical). Only the keyword-only - // form may be reordered ("center left" == "left center"). So "2px left" and "top 2px" are invalid: a - // length in one axis forces the ordered form, and the remaining token is a keyword for the wrong axis. + // CSS Transforms 1 §5. A length forces horizontal-then-vertical order, only keyword pairs may be swapped. private static readonly IValueConverter Horizontal = LengthOrPercentConverter .Or(Keywords.Left, Length.Zero) @@ -34,28 +27,19 @@ internal sealed class TransformOriginProperty : Property .Or(Keywords.Bottom, Length.Full) .Or(Keywords.Center, Length.Half); - // A one- or two-value <position> with no z-offset (this is exactly the perspective-origin grammar). private static readonly IValueConverter Position = LengthOrPercentConverter.Or(Keywords.Center, Point.Center) - // Ordered horizontal-then-vertical (rejects a keyword in the wrong axis position). .Or(WithOrder(Horizontal.Option(Length.Half), Vertical.Option(Length.Half))) - // Keyword-only form, any order ("center left"): a token accepted by both axes must not be - // claimed positionally, so this needs order-independent matching. + // "center" fits both axes, so it must not be claimed positionally .Or(WithAnyOrderIndependent(HorizontalKeyword.Option(Length.Half), VerticalKeyword.Option(Length.Half))); - // A genuine two-value position: BOTH a horizontal and a vertical component must be present. The axis - // converters are bare (not .Option), so each must actually consume a token. The <length> z-offset only - // exists in this two-value production, so gating the z-offset behind this (rather than the shared outer - // WithOrder) is what makes a single-value position + length such as "top 2px" invalid. + // Both axes required, the z-offset only follows a two-value position ("top 2px" is invalid) private static readonly IValueConverter TwoValuePosition = WithOrder(Horizontal, Vertical) .Or(WithAnyOrderIndependent(HorizontalKeyword, VerticalKeyword)); private static readonly IValueConverter StyleConverter = - // Two-value position followed by a (required) <length> z-offset - this branch only matches when a - // third length token is actually present... WithOrder(TwoValuePosition, LengthConverter) - // ...otherwise a plain one- or two-value <position> with no z-offset. .Or(Position) .OrDefault(Point.Center); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs new file mode 100644 index 000000000..4d97f804c --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs @@ -0,0 +1,74 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.CssModules; + +[DoNotParallelize] +[TestClass] +public sealed class CssTransformsTests +{ + private const double Delta = 0.5; + + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + + [Ignore("Gap, see Spec/css-transforms-1.yml")] + [TestMethod] + [Spec("css-transforms-1", "#transform-rendering")] + [Spec("css-transforms-1", "#transform-property")] + public void Translate_MovesThePaintedBox() + { + var rect = RedRect("<div style='transform:translate(30px, 20px);width:50px;height:50px;background:rgb(255,0,0)'></div>"); + + Assert.AreEqual(30, rect.X, Delta); + Assert.AreEqual(20, rect.Y, Delta); + } + + [Ignore("Gap, see Spec/css-transforms-1.yml")] + [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] + public void Scale_IsAppliedAroundTheTransformOrigin() + { + var rect = RedRect("<div style='margin-left:100px;transform:scale(2);width:50px;height:50px;background:rgb(255,0,0)'></div>"); + + Assert.AreEqual(75, rect.X, Delta); + Assert.AreEqual(100, rect.Width, Delta); + } + + [Ignore("Gap, see Spec/css-transforms-1.yml")] + [TestMethod] + [Spec("css-transforms-1", "#transform-rendering")] + public void TransformedBox_IsTheContainingBlockForAbsoluteDescendants() + { + var root = Layout("<div style='height:50px'></div><div id='t' style='transform:translate(0, 0);margin-left:30px;width:100px;height:100px'>" + + "<div id='a' style='position:absolute;top:10px;left:10px;width:10px;height:10px'></div></div>"); + + Assert.AreEqual(Box(root, "t").Location.X + 10, Box(root, "a").Location.X, Delta); + Assert.AreEqual(Box(root, "t").Location.Y + 10, Box(root, "a").Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css-transforms-1.yml")] + [TestMethod] + [Spec("css-transforms-1", "#transform-rendering")] + public void TransformedBox_IsTheContainingBlockForFixedDescendants() + { + var root = Layout("<div style='height:50px'></div><div id='t' style='position:relative;transform:translate(0, 0);margin-left:30px;width:100px;height:100px'>" + + "<div id='f' style='position:fixed;top:10px;left:10px;width:10px;height:10px'></div></div>"); + + Assert.AreEqual(Box(root, "t").Location.X + 10, Box(root, "f").Location.X, Delta); + Assert.AreEqual(Box(root, "t").Location.Y + 10, Box(root, "f").Location.Y, Delta); + } + + private static RecordingGraphics.DrawRectCall RedRect(string body) + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap(body), 400, 400); + var g = PaintHarness.PaintPage(container); + + return g.Log.OfType<RecordingGraphics.DrawRectCall>().First(r => r.Color == Red); + } + + private static CssBox Layout(string body) => LayoutHarness.Layout(LayoutHarness.Wrap(body), maxWidth: 400, maxHeight: 400).Root; + + private static CssBox Box(CssBox root, string id) => LayoutHarness.FindById(root, id)!; +} diff --git a/Source/Test/HtmlRenderer.Test/Css/TransformPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/TransformPropertyTests.cs index 19ac8e3ee..1e51c7116 100644 --- a/Source/Test/HtmlRenderer.Test/Css/TransformPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/TransformPropertyTests.cs @@ -4,15 +4,7 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/TransformProperty.cs (source class -/// <c>CssTransformPropertyTests</c>). -/// <see cref="PerspectiveProperty"/>, <see cref="PerspectiveOriginProperty"/>, -/// <see cref="TransformStyleProperty"/>, <see cref="TransformOriginProperty"/>, and -/// <see cref="TransformProperty"/> all exist; <c>TransformOriginProperty</c>/ -/// <c>PerspectiveOriginProperty</c> reproduce the exact ordered-vs-keyword-reorderable -/// &lt;position&gt; grammar (including the "length forces ordered form" invalid cases) PeachPDF's own -/// tests exercise. These are pure parsing tests - no rendered-transform assertions - so the fact that -/// this fork's layout engine doesn't yet apply transforms visually doesn't disqualify anything here. +/// Which transform, transform-origin, transform-style, perspective and perspective-origin values the CSS engine accepts. /// </summary> [TestClass] public sealed class TransformPropertyTests @@ -188,9 +180,7 @@ public void CssPerspectiveOriginTopCenterLegal() [TestMethod] public void CssPerspectiveOriginVerticalKeywordThenLengthIllegal() { - // "top 2px" is invalid (CSS Transforms 1 <position>): a length in the second (vertical) - // position forces the ordered two-value form, but "top" is a vertical-only keyword and cannot - // fill the horizontal position. + // A length forces horizontal-then-vertical order, only keyword pairs may be swapped var property = CssConstructionFunctions.ParseDeclaration("perspective-origin: top 2px "); Assert.AreEqual("perspective-origin", property.Name); Assert.IsInstanceOfType<PerspectiveOriginProperty>(property); @@ -235,6 +225,7 @@ public void CssTransformStyleNoneIllegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginXOffsetLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: 2px "); @@ -248,6 +239,7 @@ public void CssTransformOriginXOffsetLegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginXOffsetKeywordLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: bottom "); @@ -261,6 +253,7 @@ public void CssTransformOriginXOffsetKeywordLegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginYOffsetLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: 3cm 2px"); @@ -274,11 +267,10 @@ public void CssTransformOriginYOffsetLegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginLengthThenWrongAxisKeywordIllegal() { - // "2px left" is invalid (CSS Transforms 1): a length in the first (horizontal) position forces - // the ordered two-value form, but "left" is a horizontal-only keyword and cannot fill the - // vertical position. The keyword-only reorderable form forbids lengths, so it does not apply. + // A length forces horizontal-then-vertical order, only keyword pairs may be swapped var property = CssConstructionFunctions.ParseDeclaration("transform-origin: 2px left"); Assert.AreEqual("transform-origin", property.Name); Assert.IsFalse(property.IsImportant); @@ -288,6 +280,7 @@ public void CssTransformOriginLengthThenWrongAxisKeywordIllegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginVerticalKeywordThenLengthIllegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: top 2px "); @@ -299,6 +292,7 @@ public void CssTransformOriginVerticalKeywordThenLengthIllegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginVerticalKeywordThenLengthWithZIllegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: top 2px 10px "); @@ -310,6 +304,7 @@ public void CssTransformOriginVerticalKeywordThenLengthWithZIllegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginXKeywordYOffsetLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: left 2px "); @@ -323,6 +318,7 @@ public void CssTransformOriginXKeywordYOffsetLegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginXKeywordYKeywordLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: right top "); @@ -336,6 +332,7 @@ public void CssTransformOriginXKeywordYKeywordLegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginYKeywordXKeywordLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: top right "); @@ -349,6 +346,7 @@ public void CssTransformOriginYKeywordXKeywordLegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginXYZLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: 2px 30% 10px "); @@ -362,6 +360,7 @@ public void CssTransformOriginXYZLegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginLengthThenWrongAxisKeywordWithZIllegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: 2px left 10px "); @@ -373,6 +372,7 @@ public void CssTransformOriginLengthThenWrongAxisKeywordWithZIllegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginXKeywordYZLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: left 5px -3px "); @@ -386,6 +386,7 @@ public void CssTransformOriginXKeywordYZLegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginXKeywordYKeywordZLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: right bottom 2cm "); @@ -399,6 +400,7 @@ public void CssTransformOriginXKeywordYKeywordZLegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginYKeywordXKeywordZLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: bottom right 2cm "); @@ -412,6 +414,7 @@ public void CssTransformOriginYKeywordXKeywordZLegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-property")] public void CssTransformNoneLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: none "); @@ -425,6 +428,7 @@ public void CssTransformNoneLegal() } [TestMethod] + [Spec("css-transforms-1", "#two-d-transform-functions")] public void CssTransformMatrixLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: matrix(1.0, 2.0, 3.0, 4.0, 5.0, 6.0) "); @@ -438,6 +442,7 @@ public void CssTransformMatrixLegal() } [TestMethod] + [Spec("css-transforms-1", "#two-d-transform-functions")] public void CssTransformTranslateLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: translate(12px, 50%) "); @@ -451,6 +456,7 @@ public void CssTransformTranslateLegal() } [TestMethod] + [Spec("css-transforms-1", "#two-d-transform-functions")] public void CssTransformTranslateXLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: translateX(2em) "); @@ -464,6 +470,7 @@ public void CssTransformTranslateXLegal() } [TestMethod] + [Spec("css-transforms-1", "#two-d-transform-functions")] public void CssTransformTranslateYLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: translateY(3in) "); @@ -477,6 +484,7 @@ public void CssTransformTranslateYLegal() } [TestMethod] + [Spec("css-transforms-1", "#two-d-transform-functions")] public void CssTransformScaleLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: scale(2, 0.5) "); @@ -490,6 +498,7 @@ public void CssTransformScaleLegal() } [TestMethod] + [Spec("css-transforms-1", "#two-d-transform-functions")] public void CssTransformScaleXLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: scaleX(0.1) "); @@ -503,6 +512,7 @@ public void CssTransformScaleXLegal() } [TestMethod] + [Spec("css-transforms-1", "#two-d-transform-functions")] public void CssTransformScaleYLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: scaleY(1.5) "); @@ -516,6 +526,7 @@ public void CssTransformScaleYLegal() } [TestMethod] + [Spec("css-transforms-1", "#two-d-transform-functions")] public void CssTransformRotateLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: rotate(0.5turn) "); @@ -529,6 +540,7 @@ public void CssTransformRotateLegal() } [TestMethod] + [Spec("css-transforms-1", "#two-d-transform-functions")] public void CssTransformSkewXLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: skewX( 30deg ) "); @@ -542,6 +554,7 @@ public void CssTransformSkewXLegal() } [TestMethod] + [Spec("css-transforms-1", "#two-d-transform-functions")] public void CssTransformSkewYLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: skewY( 1.07rad ) "); @@ -555,6 +568,7 @@ public void CssTransformSkewYLegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-property")] public void CssTransformMultipleLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: translate(50%, 50%) rotate(45deg) scale(1.5)"); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssTransformsTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssTransformsTests.cs new file mode 100644 index 000000000..a11149ee6 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssTransformsTests.cs @@ -0,0 +1,70 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.CssModules; + +/// <summary>Which CSS Transforms values the CSS engine accepts.</summary> +[TestClass] +public sealed class CssTransformsTests +{ + [TestMethod] + [Spec("css-transforms-1", "#transform-property")] + [DataRow("none rotate(5deg)")] + [DataRow("rotate(5deg) none")] + [DataRow("scale(2) banana(1)")] + [DataRow("matrix(1, 2, 3, 4, 5)")] + [DataRow("matrix(1 2 3 4 5 6)")] + public void Transform_InvalidList_IsDropped(string value) + { + Assert.AreEqual("", Value("transform", value)); + } + + [TestMethod] + [Spec("css-transforms-1", "#two-d-transform-functions")] + [DataRow("translate(10px)")] + [DataRow("skew(30deg, 10deg)")] + [DataRow("translateX(calc(10% + 5px))")] + public void TwoDimensionalFunctions_AreParsed(string value) + { + Assert.AreEqual(value, Value("transform", value)); + } + + [Ignore("Gap, see Spec/css-transforms-1.yml")] + [TestMethod] + [Spec("css-transforms-1", "#two-d-transform-functions")] + public void Skew_WithOneAngle_IsParsed() + { + Assert.AreEqual("skew(30deg)", Value("transform", "skew(30deg)")); + } + + [Ignore("Gap, see Spec/css-transforms-1.yml")] + [TestMethod] + [Spec("css-transforms-1", "#two-d-transform-functions")] + [DataRow("rotate(0)")] + [DataRow("skewX(0)")] + [DataRow("skewY(0)")] + public void UnitlessZeroAngle_IsParsed(string value) + { + Assert.AreNotEqual("", Value("transform", value)); + } + + [Ignore("Gap, see Spec/css-transforms-1.yml")] + [TestMethod] + [Spec("css-transforms-1", "#transform-box")] + [DataRow("content-box")] + [DataRow("border-box")] + [DataRow("fill-box")] + [DataRow("stroke-box")] + [DataRow("view-box")] + public void TransformBox_IsParsed(string value) + { + Assert.AreEqual(value, Value("transform-box", value)); + } + + private static string Value(string property, string value) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ {property}: {value}; }}"); + return sheet.Rules.OfType<StyleRule>().Single().Style.GetPropertyValue(property); + } +} diff --git a/Spec/css-transforms-1.yml b/Spec/css-transforms-1.yml index a948ba368..2c4783454 100644 --- a/Spec/css-transforms-1.yml +++ b/Spec/css-transforms-1.yml @@ -5,103 +5,93 @@ snapshot: https://www.w3.org/TR/2019/CR-css-transforms-1-20190214/ sections: '#intro': title: 1 Introduction - status: unknown - '#module-interactions': - title: 1.1 Module Interactions - status: unknown - '#css-values': - title: 1.2 CSS Values - status: unknown + status: n/a '#terminology': title: 2 Terminology - status: unknown + status: n/a '#transform-rendering': title: 3 The Transform Rendering Model - status: unknown + status: unsupported + gaps: + - Transforms aren't applied when painting + - A transformed box doesn't establish a containing block for absolute and fixed descendants + - A transformed box doesn't establish a stacking context, z-index isn't supported either, see CSS 2.1 9.9.1 + - Transforms don't extend the overflow area '#transform-property': title: 4 The transform Property - status: unknown + status: parsed-only + gaps: + - "'transform' is parsed, but layout and painting ignore it" '#serialization-of-transform-functions': title: 4.1 Serialization of <transform-function>s - status: unknown + status: n/a '#serialization-of-the-computed-value': title: 4.2 Serialization of the computed value of <transform-list> - status: unknown + status: n/a '#transform-origin-property': title: 5 The transform-origin Property - status: unknown + status: parsed-only + gaps: + - "'transform-origin' is parsed, but transforms aren't applied" '#transform-box': title: '6 Transform reference box: the transform-box property' - status: unknown + status: unsupported + gaps: + - "'transform-box' isn't parsed" '#svg-transform': title: 7 The SVG transform Attribute - status: unknown + status: unsupported + gaps: + - No SVG support '#transform-attribute-specificity': title: 7.1 SVG presentation attributes - status: unknown + status: unsupported '#svg-syntax': title: 7.2 Syntax of the SVG transform attribute - status: unknown + status: unsupported '#svg-transform-functions': title: 7.3 SVG transform functions - status: unknown + status: unsupported '#svg-user-coordinate-space': title: 7.4 User coordinate space - status: unknown + status: unsupported '#transform-attribute-dom': title: 7.5 SVG DOM interface for the transform attribute - status: unknown + status: n/a '#svg-animation': title: 8 SVG Animation - status: unknown - '#svg-animate-element': - title: 8.1 The animate and set element - status: unknown - '#neutral-element': - title: 8.2 Neutral element for addition - status: unknown - '#svg-attribute-name': - title: 8.3 The SVG 1.1 'attributeName' attribute - status: unknown + status: n/a '#transform-functions': title: 9 The Transform Functions - status: unknown + status: parsed-only '#two-d-transform-functions': title: 9.1 2D Transform Functions - status: unknown + status: parsed-only + gaps: + - skew() with a single angle is rejected + - A unitless 0 isn't accepted as an angle in rotate(), skew(), skewX() and skewY() '#transform-primitives': title: 9.2 Transform function primitives and derivatives - status: unknown + status: n/a '#transform-function-lists': title: 10 The Transform Function Lists - status: unknown + status: unsupported + gaps: + - Transform lists are never turned into a matrix that gets applied, see 3 '#interpolation-of-transforms': title: 11 Interpolation of Transforms - status: unknown + status: n/a '#interpolation-of-transform-functions': title: 12 Interpolation of primitives and derived transform functions - status: unknown + status: n/a '#matrix-interpolation': title: 13 Interpolation of Matrices - status: unknown - '#supporting-functions': - title: 13.1 Supporting functions - status: unknown - '#interpolation-of-2d-matrices': - title: 13.2 Interpolation of 2D matrices - status: unknown - '#decomposing-a-2d-matrix': - title: 13.2.1 Decomposing a 2D matrix - status: unknown - '#interpolation-of-decomposed-2d-matrix-values': - title: 13.2.2 Interpolation of decomposed 2D matrix values - status: unknown - '#recomposing-to-a-2d-matrix': - title: 13.2.3 Recomposing to a 2D matrix - status: unknown + status: n/a '#mathematical-description': title: 14 Mathematical Description of Transform Functions - status: unknown + status: unsupported + gaps: + - Transform matrices are never applied, see 3 '#priv-sec': title: 15 Privacy and Security Considerations - status: unknown + status: n/a From f6b9ce45481a75372237e47db3ae8895b271a221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:30:41 +0100 Subject: [PATCH 088/150] Verify and document CSS Transforms Level 2 compliance --- .../Transform/PerspectiveOriginProperty.cs | 6 +- .../CssModules/CssTransformsTests.cs | 26 ++++ .../Css/TransformPropertyTests.cs | 27 ++++ .../CssModules/CssTransformsTests.cs | 78 +++++++++++ Spec/css-transforms-2.yml | 125 +++++++++--------- 5 files changed, 199 insertions(+), 63 deletions(-) diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Transform/PerspectiveOriginProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Transform/PerspectiveOriginProperty.cs index 1b2d37dec..b9ffb2aa7 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Transform/PerspectiveOriginProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Transform/PerspectiveOriginProperty.cs @@ -4,10 +4,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine internal sealed class PerspectiveOriginProperty : Property { - // perspective-origin is a <position> (CSS Transforms 1). As with transform-origin, the two-value - // <length-percentage> form is ordered (horizontal then vertical) and only the keyword-only form may - // be reordered - so "2px left" / "top 2px" are invalid (a length forces the ordered form and the - // other token is a keyword for the wrong axis). + // CSS Transforms 2 §9, only the one- and two-value <position> forms. A length forces + // horizontal-then-vertical order, only keyword pairs may be swapped. private static readonly IValueConverter Horizontal = LengthOrPercentConverter .Or(Keywords.Left, Length.Zero) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs index 4d97f804c..e0a7798d7 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs @@ -60,6 +60,32 @@ public void TransformedBox_IsTheContainingBlockForFixedDescendants() Assert.AreEqual(Box(root, "t").Location.Y + 10, Box(root, "f").Location.Y, Delta); } + [Ignore("Gap, see Spec/css-transforms-2.yml")] + [TestMethod] + [Spec("css-transforms-2", "#individual-transforms")] + public void TranslateProperty_MovesThePaintedBox() + { + var rect = RedRect("<div style='translate:30px 20px;width:50px;height:50px;background:rgb(255,0,0)'></div>"); + + Assert.AreEqual(30, rect.X, Delta); + Assert.AreEqual(20, rect.Y, Delta); + } + + [Ignore("Gap, see Spec/css-transforms-2.yml")] + [TestMethod] + [DataRow("perspective:100px")] + [DataRow("transform-style:preserve-3d")] + [Spec("css-transforms-2", "#perspective-property")] + [Spec("css-transforms-2", "#transform-style-property")] + public void Box_IsTheContainingBlockForFixedDescendants(string style) + { + var root = Layout($"<div style='height:50px'></div><div id='t' style='{style};margin-left:30px;width:100px;height:100px'>" + + "<div id='f' style='position:fixed;top:10px;left:10px;width:10px;height:10px'></div></div>"); + + Assert.AreEqual(Box(root, "t").Location.X + 10, Box(root, "f").Location.X, Delta); + Assert.AreEqual(Box(root, "t").Location.Y + 10, Box(root, "f").Location.Y, Delta); + } + private static RecordingGraphics.DrawRectCall RedRect(string body) { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap(body), 400, 400); diff --git a/Source/Test/HtmlRenderer.Test/Css/TransformPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/TransformPropertyTests.cs index 1e51c7116..4c0efb204 100644 --- a/Source/Test/HtmlRenderer.Test/Css/TransformPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/TransformPropertyTests.cs @@ -10,6 +10,7 @@ namespace HtmlRenderer.Test.Css; public sealed class TransformPropertyTests { [TestMethod] + [Spec("css-transforms-2", "#perspective-property")] public void CssPerspectiveNoneUppercaseLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective: NONE "); @@ -23,6 +24,7 @@ public void CssPerspectiveNoneUppercaseLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-property")] public void CssPerspectiveLengthPixelLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective: 20px "); @@ -36,6 +38,7 @@ public void CssPerspectiveLengthPixelLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-property")] public void CssPerspectiveLengthEmLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective: 3.5em "); @@ -49,6 +52,7 @@ public void CssPerspectiveLengthEmLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-property")] public void CssPerspectiveZeroLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective: 0 "); @@ -62,6 +66,7 @@ public void CssPerspectiveZeroLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-property")] public void CssPerspectivePercentIllegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective: 10% "); @@ -74,6 +79,7 @@ public void CssPerspectivePercentIllegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginZeroLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective-origin: 0 "); @@ -87,6 +93,7 @@ public void CssPerspectiveOriginZeroLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginLengthLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective-origin: 20px "); @@ -100,6 +107,7 @@ public void CssPerspectiveOriginLengthLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginLeftLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective-origin: left "); @@ -113,6 +121,7 @@ public void CssPerspectiveOriginLeftLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginPercentLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective-origin: 15% "); @@ -126,6 +135,7 @@ public void CssPerspectiveOriginPercentLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginPercentPercentLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective-origin: 15% 25% "); @@ -139,6 +149,7 @@ public void CssPerspectiveOriginPercentPercentLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginLeftCenterLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective-origin: left center "); @@ -152,6 +163,7 @@ public void CssPerspectiveOriginLeftCenterLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginRightBottomLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective-origin: right BOTTOM "); @@ -165,6 +177,7 @@ public void CssPerspectiveOriginRightBottomLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginTopCenterLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective-origin: top center "); @@ -178,6 +191,7 @@ public void CssPerspectiveOriginTopCenterLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginVerticalKeywordThenLengthIllegal() { // A length forces horizontal-then-vertical order, only keyword pairs may be swapped @@ -189,6 +203,7 @@ public void CssPerspectiveOriginVerticalKeywordThenLengthIllegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginLengthThenWrongAxisKeywordIllegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective-origin: 2px left "); @@ -199,6 +214,7 @@ public void CssPerspectiveOriginLengthThenWrongAxisKeywordIllegal() } [TestMethod] + [Spec("css-transforms-2", "#transform-style-property")] public void CssTransformStylePreserve3DLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-style: preserve-3d "); @@ -213,6 +229,7 @@ public void CssTransformStylePreserve3DLegal() } [TestMethod] + [Spec("css-transforms-2", "#transform-style-property")] public void CssTransformStyleNoneIllegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-style: none "); @@ -582,6 +599,7 @@ public void CssTransformMultipleLegal() } [TestMethod] + [Spec("css-transforms-2", "#three-d-transform-functions")] public void CssTransformMatrix3dLegal() { var property = CssConstructionFunctions.ParseDeclaration( @@ -595,6 +613,7 @@ public void CssTransformMatrix3dLegal() } [TestMethod] + [Spec("css-transforms-2", "#three-d-transform-functions")] public void CssTransformTranslate3dLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: translate3d(12px, 50%, 3em)"); @@ -607,6 +626,7 @@ public void CssTransformTranslate3dLegal() } [TestMethod] + [Spec("css-transforms-2", "#three-d-transform-functions")] public void CssTransformTranslateZLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: translateZ(2px)"); @@ -619,6 +639,7 @@ public void CssTransformTranslateZLegal() } [TestMethod] + [Spec("css-transforms-2", "#three-d-transform-functions")] public void CssTransformScale3dLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: scale3d(2.5, 1.2, 0.3)"); @@ -631,6 +652,7 @@ public void CssTransformScale3dLegal() } [TestMethod] + [Spec("css-transforms-2", "#three-d-transform-functions")] public void CssTransformScaleZLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: scaleZ(0.3)"); @@ -643,6 +665,7 @@ public void CssTransformScaleZLegal() } [TestMethod] + [Spec("css-transforms-2", "#three-d-transform-functions")] public void CssTransformRotate3dLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: rotate3d(1, 2.0, 3.0, 10deg)"); @@ -655,6 +678,7 @@ public void CssTransformRotate3dLegal() } [TestMethod] + [Spec("css-transforms-2", "#three-d-transform-functions")] public void CssTransformRotateXLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: rotateX(10deg)"); @@ -667,6 +691,7 @@ public void CssTransformRotateXLegal() } [TestMethod] + [Spec("css-transforms-2", "#three-d-transform-functions")] public void CssTransformRotateYLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: rotateY(10deg)"); @@ -679,6 +704,7 @@ public void CssTransformRotateYLegal() } [TestMethod] + [Spec("css-transforms-2", "#three-d-transform-functions")] public void CssTransformRotateZLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: rotateZ(10deg)"); @@ -691,6 +717,7 @@ public void CssTransformRotateZLegal() } [TestMethod] + [Spec("css-transforms-2", "#three-d-transform-functions")] public void CssTransformPerspectiveLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: perspective(17px)"); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssTransformsTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssTransformsTests.cs index a11149ee6..c676fad40 100644 --- a/Source/Test/HtmlRenderer.Test/CssModules/CssTransformsTests.cs +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssTransformsTests.cs @@ -62,6 +62,84 @@ public void TransformBox_IsParsed(string value) Assert.AreEqual(value, Value("transform-box", value)); } + [TestMethod] + [Spec("css-transforms-2", "#backface-visibility-property")] + [DataRow("visible")] + [DataRow("hidden")] + public void BackfaceVisibility_IsParsed(string value) + { + Assert.AreEqual(value, Value("backface-visibility", value)); + } + + [Ignore("Gap, see Spec/css-transforms-2.yml")] + [TestMethod] + [Spec("css-transforms-2", "#individual-transforms")] + [DataRow("translate", "10px 20%")] + [DataRow("translate", "10px 20px 5px")] + [DataRow("rotate", "45deg")] + [DataRow("rotate", "x 45deg")] + [DataRow("rotate", "1 1 0 45deg")] + [DataRow("scale", "2")] + [DataRow("scale", "50% 2 1")] + public void IndividualTransformProperties_AreParsed(string property, string value) + { + Assert.AreEqual(value, Value(property, value)); + } + + [Ignore("Gap, see Spec/css-transforms-2.yml")] + [TestMethod] + [Spec("css-transforms-2", "#perspective-property")] + public void Perspective_NegativeLength_IsRejected() + { + Assert.AreEqual("", Value("perspective", "-10px")); + } + + [Ignore("Gap, see Spec/css-transforms-2.yml")] + [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] + [DataRow("left 10% bottom")] + [DataRow("left 10px top 5px")] + public void PerspectiveOrigin_ThreeAndFourValuePositions_AreParsed(string value) + { + Assert.AreNotEqual("", Value("perspective-origin", value)); + } + + [Ignore("Gap, see Spec/css-transforms-2.yml")] + [TestMethod] + [Spec("css-transforms-2", "#two-d-transform-functions")] + [DataRow("scale(50%)")] + [DataRow("scaleX(50%)")] + [DataRow("scaleY(50%)")] + public void ScaleFunctions_AcceptPercentages(string value) + { + Assert.AreEqual(value, Value("transform", value)); + } + + [Ignore("Gap, see Spec/css-transforms-2.yml")] + [TestMethod] + [Spec("css-transforms-2", "#three-d-transform-functions")] + [DataRow("scale3d(50%, 50%, 50%)")] + [DataRow("scaleZ(50%)")] + [DataRow("rotateX(0)")] + [DataRow("rotate3d(1, 0, 0, 0)")] + [DataRow("perspective(none)")] + public void ThreeDimensionalFunctions_AreParsed(string value) + { + Assert.AreNotEqual("", Value("transform", value)); + } + + [Ignore("Gap, see Spec/css-transforms-2.yml")] + [TestMethod] + [Spec("css-transforms-2", "#three-d-transform-functions")] + [DataRow("translateZ(10%)")] + [DataRow("translate3d(1px, 2px, 3%)")] + [DataRow("translate3d(1px, 2px)")] + [DataRow("perspective(-10px)")] + public void InvalidThreeDimensionalFunctions_AreRejected(string value) + { + Assert.AreEqual("", Value("transform", value)); + } + private static string Value(string property, string value) { var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ {property}: {value}; }}"); diff --git a/Spec/css-transforms-2.yml b/Spec/css-transforms-2.yml index 1f45a3f44..fd7f02889 100644 --- a/Spec/css-transforms-2.yml +++ b/Spec/css-transforms-2.yml @@ -5,124 +5,131 @@ snapshot: https://www.w3.org/TR/2021/WD-css-transforms-2-20211109/ sections: '#intro': title: 1 Introduction - status: unknown - '#module-interactions': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#terminology': title: 2 Terminology - status: unknown - '#serialization-of-the-computed-value': - title: 2.1 Serialization of the computed value of <transform-list> - status: unknown + status: n/a '#two-dimensional-subset': title: 3 Two Dimensional Subset - status: unknown + status: unsupported + gaps: + - 3D transform functions are accepted although nothing is rendered in 3D, so they override a 2D fallback declaration '#transform-rendering': title: 4 The Transform Rendering Model - status: unknown + status: unsupported + gaps: + - "Transforms aren't applied, see css-transforms-1 #transform-rendering" '#3d-transform-rendering': title: 4.1 3D Transform Rendering - status: unknown + status: unsupported + gaps: + - No 3D rendering '#perspective': title: 4.1.1 Perspective - status: unknown + status: unsupported '#3d-rendering-contexts': title: 4.1.2 3D Rendering Contexts - status: unknown + status: unsupported '#transformed-element-hierarchies': title: 4.1.3 Transformed element hierarchies - status: unknown + status: unsupported '#accumulated-3d-transformation-matrix-computation': title: 4.1.4 Accumulated 3D Transformation Matrix Computation - status: unknown + status: unsupported '#backface-visibility': title: 4.1.5 Backface Visibility - status: unknown + status: unsupported '#processing-of-perspective-transformed-boxes': title: 4.2 Processing of Perspective-Transformed Boxes - status: unknown + status: unsupported '#individual-transforms': title: '5 Individual Transform Properties: the translate, scale, and rotate properties' - status: unknown + status: unsupported + gaps: + - translate, rotate and scale aren't parsed '#individual-transform-serialization': title: 5.1 Serialization - status: unknown + status: n/a '#ctm': title: 6 Current Transformation Matrix - status: unknown + status: unsupported + gaps: + - "Transforms aren't applied, see css-transforms-1 #transform-rendering" '#transform-style-property': title: 7 The transform-style Property - status: unknown + status: parsed-only + gaps: + - "'transform-style' is parsed, but preserve-3d establishes neither a 3D rendering context nor a stacking context or containing block" '#grouping-property-values': title: 7.1 Grouping property values - status: unknown + status: unsupported '#perspective-property': title: 8 The perspective Property - status: unknown + status: parsed-only + gaps: + - "'perspective' is parsed, but establishes neither a perspective nor a stacking context or containing block" + - Negative lengths are accepted '#perspective-origin-property': title: 9 The perspective-origin Property - status: unknown + status: parsed-only + gaps: + - "'perspective-origin' is parsed, but perspective isn't applied" + - Three- and four-value <position> forms are rejected '#backface-visibility-property': title: 10 The backface-visibility Property - status: unknown + status: parsed-only + gaps: + - "'backface-visibility' is parsed, but nothing is rendered in 3D" '#svg-three-dimensional-functions': title: 11 SVG and 3D transform functions - status: unknown + status: unsupported + gaps: + - No SVG support '#transform-functions': title: 12 The Transform Functions - status: unknown + status: parsed-only '#two-d-transform-functions': title: 12.1 2D Transform Functions - status: unknown + status: parsed-only + gaps: + - Percentages in scale(), scaleX() and scaleY() are rejected + - "skew() with one angle and a unitless 0 angle are rejected, see css-transforms-1 #two-d-transform-functions" '#three-d-transform-functions': title: 12.2 3D Transform Functions - status: unknown + status: parsed-only + gaps: + - Percentages in scale3d() and scaleZ() are rejected + - A unitless 0 angle is rejected in rotate3d(), rotateX(), rotateY() and rotateZ() + - translateZ() and the z of translate3d() accept percentages, translate3d() accepts two arguments + - perspective(none) is rejected, a negative length in perspective() is accepted '#transform-primitives': title: 12.3 Transform function primitives and derivatives - status: unknown + status: n/a '#matrix-interpolation': title: 13 Interpolation of Matrices - status: unknown - '#interpolation-of-3d-matrices': - title: 13.1 Interpolation of 3D matrices - status: unknown - '#decomposing-a-3d-matrix': - title: 13.1.1 Decomposing a 3D matrix - status: unknown - '#interpolation-of-decomposed-3d-matrix-values': - title: 13.1.2 Interpolation of decomposed 3D matrix values - status: unknown - '#recomposing-to-a-3d-matrix': - title: 13.1.3 Recomposing to a 3D matrix - status: unknown + status: n/a '#interpolation-of-transform-functions': title: 14 Interpolation of primitives and derived transform functions - status: unknown + status: n/a '#combining-transform-lists': title: 15 Addition and accumulation of transform lists - status: unknown - '#neutral-element': - title: 15.1 Neutral element for addition - status: unknown + status: n/a '#mathematical-description': title: 16 Mathematical Description of Transform Functions - status: unknown + status: unsupported + gaps: + - Transform matrices are never applied, see 4 '#svg-transform': title: 17 The SVG transform Attribute - status: unknown + status: unsupported + gaps: + - No SVG support '#svg-animation': title: 18 SVG Animation - status: unknown - '#svg-animate-element': - title: 18.1 The animate and set element - status: unknown + status: n/a '#more-issues': title: 19 More Issues - status: unknown + status: n/a '#priv-sec': title: 20 Security and Privacy Considerations - status: unknown + status: n/a From e3916ea5caef643695f1bf87d8e2847ccdf6719f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:32:38 +0100 Subject: [PATCH 089/150] Verify and document Motion Path compliance --- .../CssModules/CssTransformsTests.cs | 13 ++++++ .../CssModules/MotionPathTests.cs | 46 +++++++++++++++++++ Spec/motion-1.yml | 42 ++++++++--------- 3 files changed, 79 insertions(+), 22 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/MotionPathTests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs index e0a7798d7..25e3628c1 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs @@ -78,6 +78,19 @@ public void TranslateProperty_MovesThePaintedBox() [Spec("css-transforms-2", "#perspective-property")] [Spec("css-transforms-2", "#transform-style-property")] public void Box_IsTheContainingBlockForFixedDescendants(string style) + { + AssertContainingBlockForFixedDescendants(style); + } + + [Ignore("Gap, see Spec/motion-1.yml")] + [TestMethod] + [Spec("motion-1", "#offset-path-property")] + public void OffsetPath_IsTheContainingBlockForFixedDescendants() + { + AssertContainingBlockForFixedDescendants("offset-path:ray(0deg)"); + } + + private static void AssertContainingBlockForFixedDescendants(string style) { var root = Layout($"<div style='height:50px'></div><div id='t' style='{style};margin-left:30px;width:100px;height:100px'>" + "<div id='f' style='position:fixed;top:10px;left:10px;width:10px;height:10px'></div></div>"); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/MotionPathTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/MotionPathTests.cs new file mode 100644 index 000000000..898a1df97 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/MotionPathTests.cs @@ -0,0 +1,46 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.CssModules; + +/// <summary>Which Motion Path values the CSS engine accepts.</summary> +[TestClass] +public sealed class MotionPathTests +{ + [Ignore("Gap, see Spec/motion-1.yml")] + [TestMethod] + [Spec("motion-1", "#offset-path-property")] + [Spec("motion-1", "#offset-distance-property")] + [Spec("motion-1", "#offset-position-property")] + [Spec("motion-1", "#offset-anchor-property")] + [Spec("motion-1", "#offset-rotate-property")] + [DataRow("offset-path", "ray(45deg closest-side)")] + [DataRow("offset-path", "inset(10px) border-box")] + [DataRow("offset-distance", "50%")] + [DataRow("offset-position", "left top")] + [DataRow("offset-anchor", "right bottom")] + [DataRow("offset-rotate", "auto 90deg")] + public void OffsetProperties_AreParsed(string property, string value) + { + Assert.AreEqual(value, Value(property, value)); + } + + [Ignore("Gap, see Spec/motion-1.yml")] + [TestMethod] + [Spec("motion-1", "#offset-shorthand")] + public void OffsetShorthand_SetsItsLonghands() + { + var sheet = CssConstructionFunctions.ParseStyleSheet(".x { offset: left top ray(45deg) 20px / right bottom; }"); + var style = sheet.Rules.OfType<StyleRule>().Single().Style; + + Assert.AreEqual("20px", style.GetPropertyValue("offset-distance")); + Assert.AreEqual("right bottom", style.GetPropertyValue("offset-anchor")); + } + + private static string Value(string property, string value) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ {property}: {value}; }}"); + return sheet.Rules.OfType<StyleRule>().Single().Style.GetPropertyValue(property); + } +} diff --git a/Spec/motion-1.yml b/Spec/motion-1.yml index ba4a30a08..ad6f5c7fd 100644 --- a/Spec/motion-1.yml +++ b/Spec/motion-1.yml @@ -5,55 +5,53 @@ snapshot: https://www.w3.org/TR/2024/WD-motion-1-20241105/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module interactions - status: unknown - '#values': - title: 1.2 Values - status: unknown + status: n/a '#motion-paths-overview': title: 2 Motion Paths - status: unknown + status: unsupported + gaps: + - No offset property is parsed '#offset-path-property': title: '2.1 Defining A Path: the offset-path property' - status: unknown + status: unsupported + gaps: + - "Also doesn't establish a stacking context or containing block, see css-transforms-1 #transform-rendering" '#ray-function': title: 2.1.1 The ray() Function - status: unknown + status: unsupported '#example-shape': title: 2.1.2 Examples Of <basic-shape> Positioning - status: unknown + status: n/a '#example-coord': title: 2.1.3 Examples of <coord-box> Positioning - status: unknown + status: n/a '#offset-distance-property': title: '2.2 Position On The Path: the offset-distance property' - status: unknown + status: unsupported '#path-distance': title: 2.2.1 Calculating the computed distance along a path - status: unknown + status: unsupported '#offset-position-property': title: '2.3 Starting Point Of The Path: the offset-position property' - status: unknown + status: unsupported '#offset-anchor-property': title: '2.4 The Element’s Anchor Point: the offset-anchor property' - status: unknown + status: unsupported '#offset-rotate-property': title: '2.5 Rotating To Match The Path: the offset-rotate property' - status: unknown + status: unsupported '#offset-shorthand': title: 2.6 The offset Shorthand - status: unknown + status: unsupported '#transform': title: 2.7 Calculating The Offset Transform - status: unknown + status: unsupported '#paths': title: 3 Equivalent Paths For <basic-shape> - status: unknown + status: unsupported '#privacy': title: 4 Privacy Considerations - status: unknown + status: n/a '#security': title: 5 Security Considerations - status: unknown + status: n/a From 6602cb3bd68deb51da8ab475738661d52584b175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:33:53 +0100 Subject: [PATCH 090/150] Verify and document CSS Will Change compliance --- .../CssModules/CssTransformsTests.cs | 8 +++++++ .../CssModules/CssWillChangeTests.cs | 24 +++++++++++++++++++ Spec/css-will-change-1.yml | 21 +++++++--------- 3 files changed, 41 insertions(+), 12 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssWillChangeTests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs index 25e3628c1..144b88b7e 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs @@ -90,6 +90,14 @@ public void OffsetPath_IsTheContainingBlockForFixedDescendants() AssertContainingBlockForFixedDescendants("offset-path:ray(0deg)"); } + [Ignore("Gap, see Spec/css-will-change-1.yml")] + [TestMethod] + [Spec("css-will-change-1", "#will-change")] + public void WillChangeTransform_IsTheContainingBlockForFixedDescendants() + { + AssertContainingBlockForFixedDescendants("will-change:transform"); + } + private static void AssertContainingBlockForFixedDescendants(string style) { var root = Layout($"<div style='height:50px'></div><div id='t' style='{style};margin-left:30px;width:100px;height:100px'>" diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssWillChangeTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssWillChangeTests.cs new file mode 100644 index 000000000..1bd3fe2f1 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssWillChangeTests.cs @@ -0,0 +1,24 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.CssModules; + +/// <summary>Which will-change values the CSS engine accepts.</summary> +[TestClass] +public sealed class CssWillChangeTests +{ + [Ignore("Gap, see Spec/css-will-change-1.yml")] + [TestMethod] + [Spec("css-will-change-1", "#will-change")] + [DataRow("auto")] + [DataRow("scroll-position")] + [DataRow("contents")] + [DataRow("transform, opacity")] + public void WillChange_IsParsed(string value) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ will-change: {value}; }}"); + + Assert.AreEqual(value, sheet.Rules.OfType<StyleRule>().Single().Style.GetPropertyValue("will-change")); + } +} diff --git a/Spec/css-will-change-1.yml b/Spec/css-will-change-1.yml index f87d89395..40d476394 100644 --- a/Spec/css-will-change-1.yml +++ b/Spec/css-will-change-1.yml @@ -5,25 +5,22 @@ snapshot: https://www.w3.org/TR/2022/CRD-css-will-change-1-20220505/ sections: '#intro': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown - '#using': - title: 1.2 Using will-change Well - status: unknown + status: n/a '#will-change': title: '2 Hinting at Future Behavior: the will-change property' - status: unknown + status: unsupported + gaps: + - will-change isn't parsed + - "Naming a property that would create a stacking context or containing block doesn't create one, e.g. will-change: transform" '#security': title: 3 Security Considerations - status: unknown + status: n/a '#privacy': title: 4 Privacy Considerations - status: unknown + status: n/a '#acks': title: 5 Acknowledgements - status: unknown + status: n/a '#changes': title: 6 Changes - status: unknown + status: n/a From 0da77ce037173c28ded531c9bf4a9251730b97ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:35:40 +0100 Subject: [PATCH 091/150] Verify and document Compositing and Blending compliance --- .../Painting/CompositingTests.cs | 33 ++++ .../CssModules/CompositingTests.cs | 26 +++ Spec/compositing-1.yml | 148 ++++++++++-------- 3 files changed, 143 insertions(+), 64 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/CompositingTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CompositingTests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CompositingTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CompositingTests.cs new file mode 100644 index 000000000..c1e268af9 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CompositingTests.cs @@ -0,0 +1,33 @@ +using System.Drawing; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[TestClass] +public sealed class CompositingTests +{ + [TestMethod] + [Spec("compositing-1", "#simplealphacompositing")] + [Spec("compositing-1", "#generalformula")] + [Spec("compositing-1", "#porterduffcompositingoperators_srcover")] + [Spec("compositing-1", "#blendingnormal")] + public async Task TranslucentBoxes_AreDrawnOverEachOtherWithTheirAlpha() + { + using var image = (Bitmap)await HtmlRender.RenderToImageAsync( + "<!DOCTYPE html><html><body style='margin:0'><div style='height:50px;background:rgba(0,0,255,0.5)'><div style='height:20px;background:rgba(255,0,0,0.5)'></div></div></body></html>", + new Size(100, 60), + Color.White); + + // Source-over: co = cs + cb (1 - as), half blue over white, then half red over that + AssertColor(Color.FromArgb(127, 127, 255), image.GetPixel(50, 35)); + AssertColor(Color.FromArgb(191, 64, 127), image.GetPixel(50, 10)); + } + + private static void AssertColor(Color expected, Color actual) + { + Assert.AreEqual(expected.R, actual.R, 2, $"R of {actual}"); + Assert.AreEqual(expected.G, actual.G, 2, $"G of {actual}"); + Assert.AreEqual(expected.B, actual.B, 2, $"B of {actual}"); + } +} diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CompositingTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CompositingTests.cs new file mode 100644 index 000000000..e2bf32472 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CompositingTests.cs @@ -0,0 +1,26 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.CssModules; + +/// <summary>Which Compositing and Blending values the CSS engine accepts.</summary> +[TestClass] +public sealed class CompositingTests +{ + [Ignore("Gap, see Spec/compositing-1.yml")] + [TestMethod] + [Spec("compositing-1", "#mix-blend-mode")] + [Spec("compositing-1", "#isolation")] + [Spec("compositing-1", "#background-blend-mode")] + [DataRow("mix-blend-mode", "multiply")] + [DataRow("mix-blend-mode", "luminosity")] + [DataRow("isolation", "isolate")] + [DataRow("background-blend-mode", "screen, normal")] + public void BlendingProperties_AreParsed(string property, string value) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ {property}: {value}; }}"); + + Assert.AreEqual(value, sheet.Rules.OfType<StyleRule>().Single().Style.GetPropertyValue(property)); + } +} diff --git a/Spec/compositing-1.yml b/Spec/compositing-1.yml index 380bca2d6..c41df8d7e 100644 --- a/Spec/compositing-1.yml +++ b/Spec/compositing-1.yml @@ -5,181 +5,201 @@ snapshot: https://www.w3.org/TR/2024/CRD-compositing-1-20240321/ sections: '#introduction': title: 1 Introduction - status: unknown + status: n/a '#reading-this-document': title: 2 Reading This Document - status: unknown - '#module-interactions': - title: 2.1 Module interactions - status: unknown - '#values': - title: 2.2 Values - status: unknown + status: n/a '#csscompositingandblending': title: 3 Specifying Blending in CSS - status: unknown + status: unsupported '#compositingandblendingorder': title: 3.1 Order of graphical operations - status: unknown + status: unsupported + gaps: + - No filters, clip-path, masks or blending, only overflow clipping and source-over painting '#csscompositingrules_CSS': title: 3.2 Behavior specific to HTML - status: unknown + status: unsupported + gaps: + - Stacking contexts aren't isolated groups, see 8 '#csscompositingrules_SVG': title: 3.3 Behavior specific to SVG - status: unknown + status: unsupported + gaps: + - No SVG support '#csskeywords': title: 3.4 CSS Properties - status: unknown + status: unsupported + gaps: + - mix-blend-mode, isolation and background-blend-mode aren't parsed '#mix-blend-mode': title: 3.4.1 The mix-blend-mode property - status: unknown + status: unsupported '#isolation': title: 3.4.2 The isolation property - status: unknown + status: unsupported '#background-blend-mode': title: 3.4.3 The background-blend-mode property - status: unknown + status: unsupported '#canvascompositingandblending': title: 4 Specifying Compositing and Blending in Canvas 2D - status: unknown + status: n/a '#whatiscompositing': title: 5 Introduction to compositing - status: unknown + status: supported '#simplealphacompositing': title: 5.1 Simple alpha compositing - status: unknown + status: supported '#simplealphacompositingexamples': title: 5.1.1 Examples of simple alpha compositing - status: unknown + status: n/a '#generalformula': title: 6 General Formula for Compositing and Blending - status: unknown + status: partial + gaps: + - Only the normal blend mode, see 10 '#backdropCalc': title: 7 Backdrop calculation - status: unknown + status: unsupported + gaps: + - No blending, see 10 '#backdropexamples': title: 7.1 Examples of backdrop calculation - status: unknown + status: n/a '#groups': title: 8 Compositing Groups - status: unknown + status: unsupported + gaps: + - No compositing groups, everything paints directly onto the canvas '#groupinvariance': title: 8.1 Group invariance - status: unknown + status: unsupported '#isolatedgroups': title: 8.2 Isolated Groups - status: unknown + status: unsupported '#pagebackdrop': title: 8.3 The Root Element Group - status: unknown + status: unsupported '#rootgroup': title: 8.4 The Root Group - status: unknown + status: unsupported '#advancedcompositing': title: 9 Advanced compositing features - status: unknown + status: partial '#porterduffcompositingoperators': title: 9.1 The Porter Duff Compositing Operators - status: unknown + status: partial + gaps: + - Only source-over, the other operators are only reachable through canvas and mask-composite '#porterduffcompositingoperators_clear': title: 9.1.1 Clear - status: unknown + status: n/a '#porterduffcompositingoperators_src': title: 9.1.2 Copy - status: unknown + status: n/a '#porterduffcompositingoperators_dst': title: 9.1.3 Destination - status: unknown + status: n/a '#porterduffcompositingoperators_srcover': title: 9.1.4 Source Over - status: unknown + status: supported '#porterduffcompositingoperators_dstover': title: 9.1.5 Destination Over - status: unknown + status: n/a '#porterduffcompositingoperators_srcin': title: 9.1.6 Source In - status: unknown + status: unsupported + gaps: + - "Only used by mask-composite, see css-masking-1 #the-mask-composite" '#porterduffcompositingoperators_dstin': title: 9.1.7 Destination In - status: unknown + status: n/a '#porterduffcompositingoperators_srcout': title: 9.1.8 Source Out - status: unknown + status: unsupported + gaps: + - "Only used by mask-composite, see css-masking-1 #the-mask-composite" '#porterduffcompositingoperators_dstout': title: 9.1.9 Destination Out - status: unknown + status: n/a '#porterduffcompositingoperators_srcatop': title: 9.1.10 Source Atop - status: unknown + status: n/a '#porterduffcompositingoperators_dstatop': title: 9.1.11 Destination Atop - status: unknown + status: n/a '#porterduffcompositingoperators_xor': title: 9.1.12 XOR - status: unknown + status: unsupported + gaps: + - "Only used by mask-composite, see css-masking-1 #the-mask-composite" '#porterduffcompositingoperators_plus': title: 9.1.13 Lighter - status: unknown + status: n/a '#groupcompositing': title: 9.2 Group compositing behavior with Porter Duff modes - status: unknown + status: unsupported '#blending': title: 10 Blending - status: unknown + status: partial + gaps: + - Only the normal blend mode is used, mix-blend-mode and background-blend-mode aren't parsed '#blendingseparable': title: 10.1 Separable blend modes - status: unknown + status: partial + gaps: + - Only normal '#blendingnormal': title: 10.1.1 normal blend mode - status: unknown + status: supported '#blendingmultiply': title: 10.1.2 multiply blend mode - status: unknown + status: unsupported '#blendingscreen': title: 10.1.3 screen blend mode - status: unknown + status: unsupported '#blendingoverlay': title: 10.1.4 overlay blend mode - status: unknown + status: unsupported '#blendingdarken': title: 10.1.5 darken blend mode - status: unknown + status: unsupported '#blendinglighten': title: 10.1.6 lighten blend mode - status: unknown + status: unsupported '#blendingcolordodge': title: 10.1.7 color-dodge blend mode - status: unknown + status: unsupported '#blendingcolorburn': title: 10.1.8 color-burn blend mode - status: unknown + status: unsupported '#blendinghardlight': title: 10.1.9 hard-light blend mode - status: unknown + status: unsupported '#blendingsoftlight': title: 10.1.10 soft-light blend mode - status: unknown + status: unsupported '#blendingdifference': title: 10.1.11 difference blend mode - status: unknown + status: unsupported '#blendingexclusion': title: 10.1.12 exclusion blend mode - status: unknown + status: unsupported '#blendingnonseparable': title: 10.2 Non-separable blend modes - status: unknown + status: unsupported '#blendinghue': title: 10.2.1 hue blend mode - status: unknown + status: unsupported '#blendingsaturation': title: 10.2.2 saturation blend mode - status: unknown + status: unsupported '#blendingcolor': title: 10.2.3 color blend mode - status: unknown + status: unsupported '#blendingluminosity': title: 10.2.4 luminosity blend mode - status: unknown + status: unsupported '#isolationblending': title: 10.3 Effect of group isolation on blending - status: unknown + status: unsupported From 8d3dca190b3fb51c5b3825aded08d516adce8ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:37:23 +0100 Subject: [PATCH 092/150] Verify and document Filter Effects compliance --- .../Painting/FilterEffectsTests.cs | 23 +++ .../CssModules/FilterEffectsTests.cs | 60 ++++++ Spec/filter-effects-1.yml | 171 +++++++++--------- 3 files changed, 168 insertions(+), 86 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/FilterEffectsTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/FilterEffectsTests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FilterEffectsTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FilterEffectsTests.cs new file mode 100644 index 000000000..1f1effe0e --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FilterEffectsTests.cs @@ -0,0 +1,23 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[TestClass] +public sealed class FilterEffectsTests +{ + [Ignore("Gap, see Spec/filter-effects-1.yml")] + [TestMethod] + [Spec("filter-effects-1", "#FilterProperty")] + [Spec("filter-effects-1", "#opacityEquivalent")] + public void OpacityFilter_MakesTheBoxTransparent() + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( + "<div style='filter:opacity(0);width:50px;height:50px;background:rgb(255,0,0)'></div>"), 400, 400); + var g = PaintHarness.PaintPage(container); + + Assert.IsFalse(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == RColor.FromArgb(255, 0, 0))); + } +} diff --git a/Source/Test/HtmlRenderer.Test/CssModules/FilterEffectsTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/FilterEffectsTests.cs new file mode 100644 index 000000000..2d1a5e2dd --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/FilterEffectsTests.cs @@ -0,0 +1,60 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.CssModules; + +/// <summary>Which Filter Effects values the CSS engine accepts.</summary> +[TestClass] +public sealed class FilterEffectsTests +{ + [Ignore("Gap, see Spec/filter-effects-1.yml")] + [TestMethod] + [Spec("filter-effects-1", "#FilterProperty")] + [Spec("filter-effects-1", "#supported-filter-functions")] + [DataRow("none")] + [DataRow("url(#f)")] + [DataRow("blur(2px)")] + [DataRow("brightness(0.5)")] + [DataRow("contrast(200%)")] + [DataRow("drop-shadow(2px 2px 4px rgb(0, 0, 0))")] + [DataRow("grayscale(1)")] + [DataRow("hue-rotate(90deg)")] + [DataRow("invert(1)")] + [DataRow("opacity(0.5)")] + [DataRow("saturate(2)")] + [DataRow("sepia(1) blur(1px)")] + public void Filter_IsParsed(string value) + { + Assert.AreEqual(value, Value("filter", value)); + } + + [Ignore("Gap, see Spec/filter-effects-1.yml")] + [TestMethod] + [Spec("filter-effects-1", "#FloodColorProperty")] + [Spec("filter-effects-1", "#FloodOpacityProperty")] + [Spec("filter-effects-1", "#ColorInterpolationFiltersProperty")] + [Spec("filter-effects-1", "#LightingColorProperty")] + [DataRow("flood-color", "rgb(255, 0, 0)")] + [DataRow("flood-opacity", "0.5")] + [DataRow("color-interpolation-filters", "linearRGB")] + [DataRow("lighting-color", "rgb(255, 0, 0)")] + public void FilterPrimitiveProperties_AreParsed(string property, string value) + { + Assert.AreEqual(value, Value(property, value)); + } + + [Ignore("Gap, see Spec/filter-effects-1.yml")] + [TestMethod] + [Spec("filter-effects-1", "#FilterCSSImageValue")] + public void FilterImage_IsAcceptedAsBackgroundImage() + { + Assert.AreNotEqual("", Value("background-image", "filter(url(a.png), blur(2px))")); + } + + private static string Value(string property, string value) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ {property}: {value}; }}"); + return sheet.Rules.OfType<StyleRule>().Single().Style.GetPropertyValue(property); + } +} diff --git a/Spec/filter-effects-1.yml b/Spec/filter-effects-1.yml index dcd00f66f..119c41360 100644 --- a/Spec/filter-effects-1.yml +++ b/Spec/filter-effects-1.yml @@ -5,217 +5,216 @@ snapshot: https://www.w3.org/TR/2018/WD-filter-effects-1-20181218/ sections: '#intro': title: 1 Introduction - status: unknown + status: n/a '#placement': title: 2 Module interactions - status: unknown + status: n/a '#values': title: 3 Values - status: unknown + status: n/a '#definitions': title: 4 Terminology - status: unknown + status: n/a '#FilterProperty': title: '5 Graphic filters: the filter property' - status: unknown + status: unsupported + gaps: + - filter isn't parsed '#filter-functions': title: 6 Filter Functions - status: unknown + status: unsupported '#supported-filter-functions': title: 6.1 Supported Filter Functions - status: unknown + status: unsupported '#computed-values-of-filter-functions': title: 6.2 Computed Values of Filter Functions - status: unknown + status: unsupported '#serialization-of-filter-functions': title: 6.3 Serialization of Filter Functions - status: unknown + status: n/a '#interpolation-of-filter-functions': title: 6.4 Interpolation of Filter Functions - status: unknown + status: n/a '#FilterElement': title: '7 SVG Filter Sources: the filter element' - status: unknown + status: unsupported + gaps: + - No SVG support '#FilterEffectsRegion': title: 8 Filter Region - status: unknown + status: unsupported '#FilterPrimitivesOverview': title: 9 Filter primitives - status: unknown + status: unsupported + gaps: + - No SVG support '#FilterPrimitivesOverviewIntro': title: 9.1 Overview - status: unknown + status: unsupported '#CommonAttributes': title: 9.2 Common filter primitive attributes - status: unknown + status: unsupported '#FilterPrimitiveTree': title: 9.3 Filter primitive tree - status: unknown + status: unsupported '#FilterPrimitiveSubRegion': title: 9.4 Filter primitive subregion - status: unknown + status: unsupported '#feBlendElement': title: 9.5 Filter primitive feBlend - status: unknown + status: unsupported '#feColorMatrixElement': title: 9.6 Filter primitive feColorMatrix - status: unknown + status: unsupported '#feComponentTransferElement': title: 9.7 Filter primitive feComponentTransfer - status: unknown + status: unsupported '#feFuncRElement': title: 9.7.1 Transfer function feFuncR - status: unknown + status: unsupported '#feFuncGElement': title: 9.7.2 Transfer function feFuncG - status: unknown + status: unsupported '#feFuncBElement': title: 9.7.3 Transfer function feFuncB - status: unknown + status: unsupported '#feFuncAElement': title: 9.7.4 Transfer function feFuncA - status: unknown + status: unsupported '#feCompositeElement': title: 9.8 Filter primitive feComposite - status: unknown + status: unsupported '#feConvolveMatrixElement': title: 9.9 Filter primitive feConvolveMatrix - status: unknown + status: unsupported '#feDiffuseLightingElement': title: 9.10 Filter primitive feDiffuseLighting - status: unknown + status: unsupported '#feDisplacementMapElement': title: 9.11 Filter primitive feDisplacementMap - status: unknown + status: unsupported '#feDropShadowElement': title: 9.12 Filter primitive feDropShadow - status: unknown + status: unsupported '#feFloodElement': title: 9.13 Filter primitive feFlood - status: unknown + status: unsupported '#FloodColorProperty': title: 9.13.1 The flood-color property - status: unknown + status: unsupported + gaps: + - flood-color isn't parsed '#FloodOpacityProperty': title: 9.13.2 The flood-opacity property - status: unknown + status: unsupported + gaps: + - flood-opacity isn't parsed '#feGaussianBlurElement': title: 9.14 Filter primitive feGaussianBlur - status: unknown + status: unsupported '#feImageElement': title: 9.15 Filter primitive feImage - status: unknown + status: unsupported '#feMergeElement': title: 9.16 Filter primitive feMerge - status: unknown + status: unsupported '#feMergeNodeElement': title: 9.16.1 Merge node feMergeNode - status: unknown + status: unsupported '#feMorphologyElement': title: 9.17 Filter primitive feMorphology - status: unknown + status: unsupported '#feOffsetElement': title: 9.18 Filter primitive feOffset - status: unknown + status: unsupported '#feSpecularLightingElement': title: 9.19 Filter primitive feSpecularLighting - status: unknown + status: unsupported '#feTileElement': title: 9.20 Filter primitive feTile - status: unknown + status: unsupported '#feTurbulenceElement': title: 9.21 Filter primitive feTurbulence - status: unknown + status: unsupported '#ColorInterpolationFiltersProperty': title: 10 The color-interpolation-filters property - status: unknown + status: unsupported + gaps: + - color-interpolation-filters isn't parsed '#LightSourceDefinitions': title: 11 Light source elements and properties - status: unknown + status: unsupported + gaps: + - No SVG support '#LightSourceIntro': title: 11.1 Introduction - status: unknown + status: unsupported '#feDistantLightElement': title: 11.2 Light source feDistantLight - status: unknown + status: unsupported '#fePointLightElement': title: 11.3 Light source fePointLight - status: unknown + status: unsupported '#feSpotLightElement': title: 11.4 Light source feSpotLight - status: unknown + status: unsupported '#LightingColorProperty': title: 11.5 The lighting-color property - status: unknown + status: unsupported + gaps: + - lighting-color isn't parsed '#FilterCSSImageValue': title: 12 Filter CSS <image> values - status: unknown + status: unsupported + gaps: + - filter() isn't accepted as an <image> '#interpolating-filter-image': title: 12.1 Interpolating filter() - status: unknown + status: n/a '#ShorthandEquivalents': title: 13 Shorthands defined in terms of the filter element - status: unknown + status: unsupported + gaps: + - No filter functions, see 6 '#FilterPrimitiveRepresentation': title: 13.1 Filter primitive representation - status: unknown + status: unsupported '#grayscaleEquivalent': title: 13.1.1 grayscale - status: unknown + status: unsupported '#sepiaEquivalent': title: 13.1.2 sepia - status: unknown + status: unsupported '#saturateEquivalent': title: 13.1.3 saturate - status: unknown + status: unsupported '#huerotateEquivalent': title: 13.1.4 hue-rotate - status: unknown + status: unsupported '#invertEquivalent': title: 13.1.5 invert - status: unknown + status: unsupported '#opacityEquivalent': title: 13.1.6 opacity - status: unknown + status: unsupported '#brightnessEquivalent': title: 13.1.7 brightness - status: unknown + status: unsupported '#contrastEquivalent': title: 13.1.8 contrast - status: unknown + status: unsupported '#blurEquivalent': title: 13.1.9 blur - status: unknown + status: unsupported '#dropshadowEquivalent': title: 13.1.10 drop-shadow - status: unknown + status: unsupported '#filter-region-for-shorthands': title: 13.2 Filter region for shorthands - status: unknown + status: unsupported '#animation-of-filters': title: 14 Animation of Filters - status: unknown - '#interpolation-of-filters': - title: 14.1 Interpolation of Filter Function Lists - status: unknown - '#addition': - title: 14.2 Addition - status: unknown - '#accumulation': - title: 14.3 Accumulation - status: unknown + status: n/a '#priv-sec': title: 15 Privacy and Security Considerations - status: unknown - '#tainted-filter-primitives': - title: 15.1 Tainted Filter Primitives - status: unknown - '#fedisplacemnentmap-restrictions': - title: 15.2 feDisplacementMap Restrictions - status: unknown - '#origin-restrictions': - title: 15.3 Origin Restrictions - status: unknown - '#timing-attack': - title: 15.4 Timing Attacks - status: unknown + status: n/a From 2281b6ce8bf71455a69405fecf5dd903192b0433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:40:13 +0100 Subject: [PATCH 093/150] Verify and document CSS Masking compliance --- .../Core/CssEngine/BasicShapeGrammar.cs | 20 +--- .../Visibility/ClipPathProperty.cs | 7 +- .../ValueConverters/ClipPathValueConverter.cs | 7 +- .../Painting/CssMaskingTests.cs | 53 +++++++++++ .../Css/BasicShapeGrammarTests.cs | 11 +-- .../CssModules/CssMaskingTests.cs | 82 ++++++++++++++++ Spec/css-masking-1.yml | 93 +++++++++++-------- 7 files changed, 198 insertions(+), 75 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/CssMaskingTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssMaskingTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/BasicShapeGrammar.cs b/Source/HtmlRenderer/Core/CssEngine/BasicShapeGrammar.cs index b234ccd70..3c18f8a12 100644 --- a/Source/HtmlRenderer/Core/CssEngine/BasicShapeGrammar.cs +++ b/Source/HtmlRenderer/Core/CssEngine/BasicShapeGrammar.cs @@ -4,14 +4,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Shared, layer-agnostic grammar for the CSS <c>&lt;basic-shape&gt;</c> function values used by - /// <c>clip-path</c> (CSS Masking Level 1 / CSS Shapes Level 1): <c>polygon()</c>, <c>inset()</c>, - /// <c>circle()</c> and <c>ellipse()</c>. Like <see cref="BackgroundPositionGrammar"/> / - /// <see cref="BackgroundSizeGrammar"/>, it validates the grammar and captures the value's structure - /// as <b>raw component strings / enums</b> (never resolved numbers), so both Layer A (the CSS-OM - /// converter, which only needs to accept/reject and preserve the authored text) and Layer B (the - /// render-time resolver in <c>TheArtOfDev.HtmlRenderer.Core</c>, which resolves each component against the - /// element's reference box) share a single parser rather than re-implementing the grammar twice. + /// The &lt;basic-shape&gt; functions <c>polygon()</c>, <c>inset()</c>, <c>circle()</c> and <c>ellipse()</c> + /// (CSS Shapes 1 §3), parsed into unresolved component strings and enums. /// </summary> internal static class BasicShapeGrammar { @@ -120,10 +114,7 @@ internal sealed class ParsedBasicShape } /// <summary> - /// Parses a <c>clip-path</c> value's tokens into a <see cref="ParsedBasicShape"/>, or returns - /// null when the value is not a valid basic shape - <b>including the literal <c>none</c></b>, - /// which callers treat as "no clip". (Layer A therefore accepts <c>none</c> separately, since a - /// null result here can't distinguish <c>none</c> from an invalid value.) + /// Null for anything that isn't a basic shape, <c>none</c> included. /// </summary> internal static ParsedBasicShape TryParse(IReadOnlyList<Token> tokens) { @@ -387,10 +378,7 @@ private static bool TryShapeRadius(Token token, out ShapeRadius radius) private static bool IsLengthPercentage(Token token) { if (token.Type == TokenType.Dimension || token.Type == TokenType.Percentage) return true; - // A calc()-family function computes to a <length-percentage> at used-value time. The render-time - // resolver (CssClipPathResolver → CssValueParser.ParseLength) evaluates it, and the token's - // ToValue() reconstructs the full "calc(…)" text, so accept it here rather than invalidating the - // whole shape (this is what lets a Charts.css area/line polygon vertex be a calc() expression). + // calc() stays unevaluated, ToValue() keeps its text var function = token as FunctionToken; if (function != null && CalcParser.IsCalcFamily(function.Data)) return true; // Unitless zero is a valid length. diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Visibility/ClipPathProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Visibility/ClipPathProperty.cs index 77125131d..ea3021c45 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Visibility/ClipPathProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Visibility/ClipPathProperty.cs @@ -1,15 +1,10 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The <c>clip-path</c> property (CSS Masking Module Level 1). The basic-shape grammar - /// (<c>polygon()</c>/<c>inset()</c>/<c>circle()</c>/<c>ellipse()</c>) is resolved against the box's - /// reference box at paint time, so Layer A only needs to accept and preserve the raw value text. + /// clip-path, CSS Masking 1 §5.1. Only <c>none</c> and a &lt;basic-shape&gt; without a &lt;geometry-box&gt;. /// </summary> internal sealed class ClipPathProperty : Property { - // The basic-shape grammar (polygon()/inset()/circle()/ellipse()) plus the "none" keyword is - // validated once by the shared BasicShapeGrammar via ClipPathValueConverter; the authored text - // is preserved so the paint-time resolver (CssClipPathResolver) sees exactly what was written. private static readonly IValueConverter StyleConverter = new ClipPathValueConverter().OrDefault(); internal ClipPathProperty() diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/ClipPathValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/ClipPathValueConverter.cs index 1e60ffd15..b4b509c94 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/ClipPathValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/ClipPathValueConverter.cs @@ -4,11 +4,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Validates a <c>clip-path</c> value: accepts the keyword <c>none</c> or any <c>&lt;basic-shape&gt;</c> - /// the shared <see cref="BasicShapeGrammar"/> recognizes (<c>polygon()</c>/<c>inset()</c>/<c>circle()</c>/ - /// <c>ellipse()</c>), and rejects everything else so an unknown value (e.g. <c>clip-path: banana</c>) - /// is dropped at parse time. The authored value text is preserved verbatim (<see cref="ClipPathValue.CssText"/> - /// returns the original tokens) so the render-time resolver (Layer B) sees exactly what the author wrote. + /// Accepts <c>none</c> or a shape <see cref="BasicShapeGrammar"/> recognizes and keeps the authored tokens as the value. /// </summary> internal sealed class ClipPathValueConverter : IValueConverter { @@ -36,7 +32,6 @@ public ClipPathValue(IEnumerable<Token> tokens) Original = new TokenValue(tokens); } - // Preserve the authored text verbatim - Layer B re-tokenizes and resolves this string. public string CssText => Original.Text; public TokenValue Original { get; } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssMaskingTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssMaskingTests.cs new file mode 100644 index 000000000..a4a00d9e4 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssMaskingTests.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[TestClass] +public sealed class CssMaskingTests +{ + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + + [Ignore("Gap, see Spec/css-masking-1.yml")] + [TestMethod] + [Spec("css-masking-1", "#the-clip-path")] + public void ClipPathInset_ClipsThePaintedBox() + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( + "<div style='clip-path:inset(10px 20px);width:100px;height:100px;background:rgb(255,0,0)'></div>"), 400, 400); + var g = PaintHarness.PaintPage(container); + + var clip = ClipAt(g, e => e is RecordingGraphics.DrawRectCall r && r.Color == Red); + Assert.AreEqual(20, clip.X, 0.5); + Assert.AreEqual(10, clip.Y, 0.5); + Assert.AreEqual(90, clip.Bottom, 0.5); + } + + /// <summary>The intersection of all clips pushed and not yet popped when the first matching call was recorded.</summary> + private static RRect ClipAt(RecordingGraphics g, System.Func<object, bool> match) + { + var stack = new List<RRect> { new RRect(0, 0, 400, 400) }; + foreach (var entry in g.Log) + { + switch (entry) + { + case RecordingGraphics.PushClipCall push: + stack.Add(push.Rect); + break; + case RecordingGraphics.PopClipCall: + stack.RemoveAt(stack.Count - 1); + break; + default: + if (match(entry)) + return stack.Aggregate(RRect.Intersect); + break; + } + } + + Assert.Fail("No matching draw call was recorded"); + return RRect.Empty; + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/BasicShapeGrammarTests.cs b/Source/Test/HtmlRenderer.Test/Css/BasicShapeGrammarTests.cs index c30629cc8..dced5c1f8 100644 --- a/Source/Test/HtmlRenderer.Test/Css/BasicShapeGrammarTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/BasicShapeGrammarTests.cs @@ -6,10 +6,7 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/BasicShapeGrammarTests.cs. -/// Tests for the shared <see cref="BasicShapeGrammar"/> (Layer-agnostic parse of the -/// <c>polygon()/inset()/circle()/ellipse()</c> basic-shape grammar) and the <c>clip-path</c> -/// Layer-A converter (<see cref="ClipPathValueConverter"/>) that accepts/rejects and preserves it. +/// The &lt;basic-shape&gt; grammar of <see cref="BasicShapeGrammar"/> and the clip-path values built on it. /// </summary> [TestClass] public sealed class BasicShapeGrammarTests @@ -267,7 +264,7 @@ public void NonBasicShape_ReturnsNull(string value) Assert.IsNull(Parse(value)); } - // ─── Layer A: converter accept/reject via the parser ──────────────────── + // ─── clip-path ────────────────────────────────────────────────────────── [TestMethod] [DataRow("none")] @@ -275,6 +272,7 @@ public void NonBasicShape_ReturnsNull(string value) [DataRow("inset(10px 20px round 4px)")] [DataRow("circle(50px at center)")] [DataRow("ellipse(closest-side farthest-side)")] + [Spec("css-masking-1", "#the-clip-path")] public void ClipPath_ValidValue_SurvivesParsing(string value) { var property = CssConstructionFunctions.ParseDeclaration($"clip-path: {value}"); @@ -282,7 +280,6 @@ public void ClipPath_ValidValue_SurvivesParsing(string value) Assert.IsInstanceOfType<ClipPathProperty>(property); var concrete = (ClipPathProperty)property; Assert.IsTrue(concrete.HasValue); - // Authored text is preserved verbatim for the render layer to re-parse. Assert.AreEqual(value, concrete.Value); } @@ -290,6 +287,7 @@ public void ClipPath_ValidValue_SurvivesParsing(string value) [DataRow("banana")] [DataRow("polygon(0)")] [DataRow("circle(10px 20px)")] + [Spec("css-masking-1", "#the-clip-path")] public void ClipPath_InvalidValue_IsDropped(string value) { var property = CssConstructionFunctions.ParseDeclaration($"clip-path: {value}"); @@ -299,6 +297,7 @@ public void ClipPath_InvalidValue_IsDropped(string value) } [TestMethod] + [Spec("css-masking-1", "#the-clip-path")] public void ClipPath_InStyleSheet_ValidSurvives_InvalidDropped() { var valid = CssConstructionFunctions.ParseStyleSheet("div{clip-path:polygon(0 0, 100% 0, 50% 100%)}"); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssMaskingTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssMaskingTests.cs new file mode 100644 index 000000000..b486a13e8 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssMaskingTests.cs @@ -0,0 +1,82 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.CssModules; + +/// <summary>Which CSS Masking values the CSS engine accepts.</summary> +[TestClass] +public sealed class CssMaskingTests +{ + [Ignore("Gap, see Spec/css-masking-1.yml")] + [TestMethod] + [Spec("css-masking-1", "#the-clip-path")] + [DataRow("url(#clip)")] + [DataRow("border-box")] + [DataRow("fill-box")] + [DataRow("inset(10px) padding-box")] + [DataRow("content-box circle(10px)")] + public void ClipPath_ClipSourceAndGeometryBox_AreParsed(string value) + { + Assert.AreEqual(value, Value("clip-path", value)); + } + + [Ignore("Gap, see Spec/css-masking-1.yml")] + [TestMethod] + [Spec("css-masking-1", "#the-clip-rule")] + [Spec("css-masking-1", "#the-mask-image")] + [Spec("css-masking-1", "#the-mask-mode")] + [Spec("css-masking-1", "#the-mask-repeat")] + [Spec("css-masking-1", "#the-mask-position")] + [Spec("css-masking-1", "#the-mask-clip")] + [Spec("css-masking-1", "#the-mask-origin")] + [Spec("css-masking-1", "#the-mask-size")] + [Spec("css-masking-1", "#the-mask-composite")] + [Spec("css-masking-1", "#the-mask-type")] + [DataRow("clip-rule", "evenodd")] + [DataRow("mask-image", "url(a.png)")] + [DataRow("mask-mode", "luminance")] + [DataRow("mask-repeat", "no-repeat")] + [DataRow("mask-position", "left top")] + [DataRow("mask-clip", "content-box")] + [DataRow("mask-origin", "padding-box")] + [DataRow("mask-size", "contain")] + [DataRow("mask-composite", "subtract")] + [DataRow("mask-type", "alpha")] + public void MaskProperties_AreParsed(string property, string value) + { + Assert.AreEqual(value, Value(property, value)); + } + + [Ignore("Gap, see Spec/css-masking-1.yml")] + [TestMethod] + [Spec("css-masking-1", "#the-mask")] + public void MaskShorthand_SetsItsLonghands() + { + var style = Style("mask: url(a.png) no-repeat left top / contain"); + + Assert.AreEqual("url(a.png)", style.GetPropertyValue("mask-image")); + Assert.AreEqual("contain", style.GetPropertyValue("mask-size")); + } + + [Ignore("Gap, see Spec/css-masking-1.yml")] + [TestMethod] + [Spec("css-masking-1", "#the-mask-border")] + [Spec("css-masking-1", "#the-mask-border-source")] + [Spec("css-masking-1", "#the-mask-border-slice")] + public void MaskBorderShorthand_SetsItsLonghands() + { + var style = Style("mask-border: url(a.png) 30 fill"); + + Assert.AreEqual("url(a.png)", style.GetPropertyValue("mask-border-source")); + Assert.AreEqual("30 fill", style.GetPropertyValue("mask-border-slice")); + } + + private static string Value(string property, string value) => Style($"{property}: {value}").GetPropertyValue(property); + + private static StyleDeclaration Style(string declarations) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ {declarations}; }}"); + return sheet.Rules.OfType<StyleRule>().Single().Style; + } +} diff --git a/Spec/css-masking-1.yml b/Spec/css-masking-1.yml index 5f4658488..1be828427 100644 --- a/Spec/css-masking-1.yml +++ b/Spec/css-masking-1.yml @@ -5,112 +5,123 @@ snapshot: https://www.w3.org/TR/2021/CRD-css-masking-1-20210805/ sections: '#intro': title: 1 Introduction - status: unknown - '#clipping': - title: 1.1 Clipping - status: unknown - '#masking': - title: 1.2 Masking - status: unknown + status: n/a '#placement': title: 2 Module interactions - status: unknown + status: n/a '#values': title: 3 Values - status: unknown + status: n/a '#terminology': title: 4 Terminology - status: unknown + status: n/a '#clipping-paths': title: 5 Clipping Paths - status: unknown + status: parsed-only + gaps: + - The deprecated 'clip' (Appendix A) is parsed but ignored, see css21 visufx.html#clipping '#the-clip-path': title: '5.1 Clipping Shape: the clip-path property' - status: unknown + status: parsed-only + gaps: + - clip-path is parsed, but nothing is clipped and no stacking context is created + - Only a <basic-shape> on its own, url() references and <geometry-box> are rejected '#svg-clipping-paths': title: 6 SVG Clipping Path Sources - status: unknown + status: unsupported + gaps: + - No SVG support '#ClipPathElement': title: 6.1 The clipPath element - status: unknown + status: unsupported '#the-clip-rule': title: '6.2 Winding Rules: the clip-rule property' - status: unknown + status: unsupported + gaps: + - clip-rule isn't parsed '#positioned-masks': title: 7 Positioned Masks - status: unknown + status: unsupported + gaps: + - None of the mask properties is parsed '#the-mask-image': title: '7.1 Mask Image Source: the mask-image property' - status: unknown + status: unsupported '#the-mask-mode': title: '7.2 Mask Image Interpretation: the mask-mode property' - status: unknown + status: unsupported '#the-mask-repeat': title: '7.3 Tiling Mask Images: the mask-repeat property' - status: unknown + status: unsupported '#the-mask-position': title: '7.4 Positioning Mask Images: the mask-position property' - status: unknown + status: unsupported '#the-mask-clip': title: '7.5 Masking Area: the mask-clip property' - status: unknown + status: unsupported '#the-mask-origin': title: '7.6 Positioning Area: the mask-origin property' - status: unknown + status: unsupported '#the-mask-size': title: '7.7 Sizing Mask Images: the mask-size property' - status: unknown + status: unsupported '#the-mask-composite': title: '7.8 Compositing mask layers: the mask-composite property' - status: unknown + status: unsupported '#the-mask': title: '7.9 Mask Shorthand: the mask property' - status: unknown + status: unsupported '#the-mask-image-rendering-model': title: 7.10 The Mask Image Rendering Model - status: unknown + status: unsupported '#MaskValues': title: 7.10.1 Mask processing - status: unknown + status: unsupported '#layering': title: 7.10.2 Layering Multiple Mask Images - status: unknown + status: unsupported '#mask-borders': title: 8 Border-Box Mask - status: unknown + status: unsupported + gaps: + - None of the mask-border properties is parsed '#the-mask-border-source': title: '8.1 Mask Border Image Source: the mask-border-source property' - status: unknown + status: unsupported '#the-mask-border-mode': title: '8.2 Mask Border Image Interpretation: the mask-border-mode property' - status: unknown + status: unsupported '#the-mask-border-slice': title: '8.3 Mask Border Image Slicing: the mask-border-slice property' - status: unknown + status: unsupported '#the-mask-border-width': title: '8.4 Masking Areas: the mask-border-width property' - status: unknown + status: unsupported '#the-mask-border-outset': title: '8.5 Edge Overhang: the mask-border-outset property' - status: unknown + status: unsupported '#the-mask-border-repeat': title: '8.6 Mask Border Image Tiling: the mask-border-repeat property' - status: unknown + status: unsupported '#the-mask-border': title: '8.7 Mask Border Image Shorthand: the mask-border property' - status: unknown + status: unsupported '#masking-with-the-mask-border-image': title: 8.8 Masking with the mask border image - status: unknown + status: unsupported '#svg-masks': title: 9 SVG Mask Sources - status: unknown + status: unsupported + gaps: + - No SVG support '#MaskElement': title: 9.1 The mask element - status: unknown + status: unsupported '#the-mask-type': title: '9.2 Mask Source Interpretation: the mask-type property' - status: unknown + status: unsupported + gaps: + - mask-type isn't parsed '#priv-sec': title: 10 Privacy and Security Considerations - status: unknown + status: n/a From acc8e3a9a77f86632b3fc8fa982eafb491250b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:28:25 +0100 Subject: [PATCH 094/150] Verify and document CSS Animations compliance --- .../TimeBasedShorthandConverter.cs | 24 +- .../Css/AnimationPropertyTests.cs | 44 ++- .../Css/KeyframeRuleTests.cs | 6 +- .../CssModules/CssAnimationsTests.cs | 287 ++++++++++++++++++ Spec/css-animations-1.yml | 123 +++----- 5 files changed, 377 insertions(+), 107 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssAnimationsTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/TimeBasedShorthandConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/TimeBasedShorthandConverter.cs index f7f115080..49593e7f0 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/TimeBasedShorthandConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/TimeBasedShorthandConverter.cs @@ -4,9 +4,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Specialized converter for CSS properties with time-based disambiguation rules. - /// Handles animation and transition shorthands where two time values must be - /// interpreted as duration (first) and delay (second) per CSS spec. + /// The 'animation' and 'transition' shorthands, where the first time is the duration and the second the + /// delay (CSS Animations 1 §3.10, CSS Transitions 1 §2.5). /// </summary> internal sealed class TimeBasedShorthandConverter : IValueConverter { @@ -32,15 +31,12 @@ public IPropertyValue Convert(IEnumerable<Token> value) var options = new IPropertyValue[_converters.Length]; var timeValuesFound = 0; - // Track which converters have been matched var matched = new bool[_converters.Length]; - // Parse tokens, applying special logic for time values while (list.Count > 0) { var foundMatch = false; - // Try each unmatched converter for (var i = 0; i < _converters.Length; i++) { if (matched[i]) @@ -48,17 +44,13 @@ public IPropertyValue Convert(IEnumerable<Token> value) continue; } - // Special handling for time converters var isTimeConverter = i == _durationIndex || i == _delayIndex; if (isTimeConverter) { - // Try to match a time value (without consuming) var testList = new List<Token>(list); if (TryMatchTime(testList, out var timeValue)) { - // We found a time value - // Assign to duration if it's the first time value, delay if it's the second var targetIndex = timeValuesFound == 0 ? _durationIndex : _delayIndex; if (targetIndex == i) @@ -71,20 +63,17 @@ public IPropertyValue Convert(IEnumerable<Token> value) foundMatch = true; break; } - // else: This time value belongs to a different slot, skip this converter for now } - // else: Not a time value at all, continue to try other converters } else { - // Non-time converter: try normal matching var beforeCount = list.Count; var testList = new List<Token>(list); var result = _converters[i].VaryStart(testList); if (result != null) { - // Check if it actually consumed tokens (not just a default value) + // A match that consumed nothing is only the converter's default var consumed = beforeCount - testList.Count; if (consumed > 0) { @@ -100,12 +89,10 @@ public IPropertyValue Convert(IEnumerable<Token> value) if (!foundMatch) { - // No converter matched the current position break; } } - // Fill in defaults for unmatched optional converters for (var i = 0; i < _converters.Length; i++) { if (options[i] == null) @@ -114,7 +101,6 @@ public IPropertyValue Convert(IEnumerable<Token> value) } } - // Success if all tokens were consumed if (list.Count == 0) { return new OptionsValue(options, value); @@ -124,16 +110,13 @@ public IPropertyValue Convert(IEnumerable<Token> value) } private bool TryMatchTime(List<Token> list, out IPropertyValue timeValue) { - // Use the TimeConverter to try to match a time value var testList = new List<Token>(list); var beforeCount = testList.Count; var result = Converters.TimeConverter.VaryStart(testList); if (result != null && testList.Count < beforeCount) { - // Successfully matched and consumed tokens timeValue = result; - // Update the original list list.Clear(); list.AddRange(testList); return true; @@ -179,7 +162,6 @@ public string CssText { get { - // Output in canonical converter order return string.Join(" ", options.Where(m => !string.IsNullOrEmpty(m == null ? null : m.CssText)).Select(m => m.CssText)); } diff --git a/Source/Test/HtmlRenderer.Test/Css/AnimationPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/AnimationPropertyTests.cs index fff5ca47e..a38f5b345 100644 --- a/Source/Test/HtmlRenderer.Test/Css/AnimationPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/AnimationPropertyTests.cs @@ -3,11 +3,11 @@ namespace HtmlRenderer.Test.Css; -/// <summary>Ported from PeachPDF.Tests/CSS/PropertyTests/AnimationPropertyTests.cs. Pure CSSOM parse tests - no layout involved.</summary> [TestClass] public sealed class AnimationPropertyTests { [TestMethod] + [Spec("css-animations-1", "#animation-duration")] public void AnimationDurationMillisecondsLegal() { var snippet = "animation-duration : 60ms"; @@ -22,6 +22,7 @@ public void AnimationDurationMillisecondsLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-duration")] public void AnimationDurationMultipleSecondsLegal() { var snippet = "animation-duration : 1s , 2s , 3s , 4s"; @@ -36,6 +37,7 @@ public void AnimationDurationMultipleSecondsLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-delay")] public void AnimationDelayMillisecondsLegal() { var snippet = "animation-delay : 0ms"; @@ -50,6 +52,7 @@ public void AnimationDelayMillisecondsLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-delay")] public void AnimationDelayZeroIllegal() { var snippet = "animation-delay : 0"; @@ -63,6 +66,7 @@ public void AnimationDelayZeroIllegal() } [TestMethod] + [Spec("css-animations-1", "#animation-delay")] public void AnimationDelayZeroZeroSecondMillisecondsLegal() { var snippet = "animation-delay : 0s , 0s , 1s , 20ms"; @@ -77,6 +81,7 @@ public void AnimationDelayZeroZeroSecondMillisecondsLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-name")] public void AnimationNameDashSpecificLegal() { var snippet = "animation-name : -specific"; @@ -91,6 +96,7 @@ public void AnimationNameDashSpecificLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-name")] public void AnimationNameSlidingVerticallyLegal() { var snippet = "animation-name : sliding-vertically"; @@ -105,6 +111,7 @@ public void AnimationNameSlidingVerticallyLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-name")] public void AnimationNameTest05Legal() { var snippet = "animation-name : test_05"; @@ -119,6 +126,7 @@ public void AnimationNameTest05Legal() } [TestMethod] + [Spec("css-animations-1", "#animation-name")] public void AnimationNameNumberIllegal() { var snippet = "animation-name : 42"; @@ -132,6 +140,7 @@ public void AnimationNameNumberIllegal() } [TestMethod] + [Spec("css-animations-1", "#animation-name")] public void AnimationNameShouldKeepLetterCasing() { var snippet = "animation-name : MyAnimation"; @@ -146,6 +155,7 @@ public void AnimationNameShouldKeepLetterCasing() } [TestMethod] + [Spec("css-animations-1", "#animation-name")] public void AnimationNameMyAnimationOtherAnimationLegal() { var snippet = "animation-name : my-animation, other-animation"; @@ -160,6 +170,7 @@ public void AnimationNameMyAnimationOtherAnimationLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-iteration-count")] public void AnimationIterationCountZeroLegal() { var snippet = "animation-iteration-count : 0"; @@ -174,6 +185,7 @@ public void AnimationIterationCountZeroLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-iteration-count")] public void AnimationIterationCountInfiniteLegal() { var snippet = "animation-iteration-count : infinite"; @@ -188,6 +200,7 @@ public void AnimationIterationCountInfiniteLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-iteration-count")] public void AnimationIterationCountInfiniteUppercaseLegal() { var snippet = "animation-iteration-count : INFINITE"; @@ -202,6 +215,7 @@ public void AnimationIterationCountInfiniteUppercaseLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-iteration-count")] public void AnimationIterationCountFloatLegal() { var snippet = "animation-iteration-count : 2.3"; @@ -216,6 +230,7 @@ public void AnimationIterationCountFloatLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-iteration-count")] public void AnimationIterationCountTwoZeroInfiniteLegal() { var snippet = "animation-iteration-count : 2, 0, infinite"; @@ -230,6 +245,7 @@ public void AnimationIterationCountTwoZeroInfiniteLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-iteration-count")] public void AnimationIterationCountNegativeIllegal() { var snippet = "animation-iteration-count : -1"; @@ -243,6 +259,7 @@ public void AnimationIterationCountNegativeIllegal() } [TestMethod] + [Spec("css-animations-1", "#animation-timing-function")] public void AnimationTimingFunctionEaseUppercaseLegal() { var snippet = "animation-timing-function : EASE"; @@ -257,6 +274,7 @@ public void AnimationTimingFunctionEaseUppercaseLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-timing-function")] public void AnimationTimingFunctionNoneIllegal() { var snippet = "animation-timing-function : none"; @@ -270,6 +288,7 @@ public void AnimationTimingFunctionNoneIllegal() } [TestMethod] + [Spec("css-animations-1", "#animation-timing-function")] public void AnimationTimingFunctionEaseInOutLegal() { var snippet = "animation-timing-function : ease-IN-out"; @@ -284,6 +303,7 @@ public void AnimationTimingFunctionEaseInOutLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-timing-function")] public void AnimationTimingFunctionStepEndLegal() { var snippet = "animation-timing-function : step-END"; @@ -298,6 +318,7 @@ public void AnimationTimingFunctionStepEndLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-timing-function")] public void AnimationTimingFunctionStepStartLinearLegal() { var snippet = "animation-timing-function : step-start , LINeAr"; @@ -312,6 +333,7 @@ public void AnimationTimingFunctionStepStartLinearLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-timing-function")] public void AnimationTimingFunctionStepStartCubicBezierLegal() { var snippet = "animation-timing-function : step-start , cubic-bezier(0,1,1,1)"; @@ -326,6 +348,7 @@ public void AnimationTimingFunctionStepStartCubicBezierLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-play-state")] public void AnimationPlayStateRunningLegal() { var snippet = "animation-play-state: running"; @@ -340,6 +363,7 @@ public void AnimationPlayStateRunningLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-play-state")] public void AnimationPlayStatePausedUppercaseLegal() { var snippet = "animation-play-state: PAUSED"; @@ -354,6 +378,7 @@ public void AnimationPlayStatePausedUppercaseLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-play-state")] public void AnimationPlayStatePausedRunningPausedLegal() { var snippet = "animation-play-state: paused, Running, paused"; @@ -368,6 +393,7 @@ public void AnimationPlayStatePausedRunningPausedLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-fill-mode")] public void AnimationFillModeNoneLegal() { var snippet = "animation-fill-mode: none"; @@ -382,6 +408,7 @@ public void AnimationFillModeNoneLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-fill-mode")] public void AnimationFillModeZeroIllegal() { var snippet = "animation-fill-mode: 0"; @@ -395,6 +422,7 @@ public void AnimationFillModeZeroIllegal() } [TestMethod] + [Spec("css-animations-1", "#animation-fill-mode")] public void AnimationFillModeBackwardsLegal() { var snippet = "animation-fill-mode: backwards !important"; @@ -409,6 +437,7 @@ public void AnimationFillModeBackwardsLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-fill-mode")] public void AnimationFillModeForwardsUppercaseLegal() { var snippet = "animation-fill-mode: FORWARDS"; @@ -423,6 +452,7 @@ public void AnimationFillModeForwardsUppercaseLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-fill-mode")] public void AnimationFillModeBothBackwardsForwardsNoneLegal() { var snippet = "animation-fill-mode: both , backwards , forwards ,NONE"; @@ -437,6 +467,7 @@ public void AnimationFillModeBothBackwardsForwardsNoneLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-direction")] public void AnimationDirectionNormalLegal() { var snippet = "animation-direction: normal"; @@ -451,6 +482,7 @@ public void AnimationDirectionNormalLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-direction")] public void AnimationDirectionReverseLegal() { var snippet = "animation-direction : reverse"; @@ -465,6 +497,7 @@ public void AnimationDirectionReverseLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-direction")] public void AnimationDirectionNoneIllegal() { var snippet = "animation-direction : none"; @@ -478,6 +511,7 @@ public void AnimationDirectionNoneIllegal() } [TestMethod] + [Spec("css-animations-1", "#animation-direction")] public void AnimationDirectionAlternateReverseUppercaseLegal() { var snippet = "animation-direction : alternate-REVERSE"; @@ -492,6 +526,7 @@ public void AnimationDirectionAlternateReverseUppercaseLegal() } [TestMethod] + [Spec("css-animations-1", "#animation-direction")] public void AnimationDirectionNormalAlternateReverseAlternateReverseLegal() { var snippet = "animation-direction: normal,alternate , reverse ,ALTERNATE-reverse !important"; @@ -506,6 +541,7 @@ public void AnimationDirectionNormalAlternateReverseAlternateReverseLegal() } [TestMethod] + [Spec("css-animations-1", "#animation")] public void AnimationIterationCountLegal() { var snippet = "animation : 5"; @@ -520,6 +556,7 @@ public void AnimationIterationCountLegal() } [TestMethod] + [Spec("css-animations-1", "#animation")] public void AnimationNameLegal() { var snippet = "animation : my-animation"; @@ -534,6 +571,7 @@ public void AnimationNameLegal() } [TestMethod] + [Spec("css-animations-1", "#animation")] public void AnimationNameDurationDelayLegal() { var snippet = "animation : my-animation 2s 0.5s"; @@ -548,6 +586,7 @@ public void AnimationNameDurationDelayLegal() } [TestMethod] + [Spec("css-animations-1", "#animation")] public void AnimationNameDurationDelayEaseLegal() { var snippet = "animation : my-animation 200ms 0.5s ease"; @@ -562,6 +601,7 @@ public void AnimationNameDurationDelayEaseLegal() } [TestMethod] + [Spec("css-animations-1", "#animation")] public void AnimationCountDoubleIllegal() { var snippet = "animation : 10 20"; @@ -578,6 +618,7 @@ public void AnimationCountDoubleIllegal() } [TestMethod] + [Spec("css-animations-1", "#animation")] public void AnimationNameDurationCountEaseInOutLegal() { var snippet = "animation : my-animation 200ms 2.5 ease-in-out"; @@ -592,6 +633,7 @@ public void AnimationNameDurationCountEaseInOutLegal() } [TestMethod] + [Spec("css-animations-1", "#animation")] public void AnimationMultipleLegal() { var snippet = "animation : my-animation 0s 10 ease, other-animation 5 linear,yet-another 0s 1s 10 step-start !important"; diff --git a/Source/Test/HtmlRenderer.Test/Css/KeyframeRuleTests.cs b/Source/Test/HtmlRenderer.Test/Css/KeyframeRuleTests.cs index 72ee47bb4..516e3e22f 100644 --- a/Source/Test/HtmlRenderer.Test/Css/KeyframeRuleTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/KeyframeRuleTests.cs @@ -3,11 +3,11 @@ namespace HtmlRenderer.Test.Css; -/// <summary>Ported from PeachPDF.Tests/CSS/KeyframeRule.cs.</summary> [TestClass] public sealed class CssKeyframeRuleTests { [TestMethod] + [Spec("css-animations-1", "#keyframes")] public void KeyframeRuleWithFromAndMarginLeft() { var rule = CssConstructionFunctions.ParseKeyframeRule(@" from { @@ -21,6 +21,7 @@ public void KeyframeRuleWithFromAndMarginLeft() } [TestMethod] + [Spec("css-animations-1", "#keyframes")] public void KeyframeRuleWith50PercentAndMarginLeftOpacity() { var rule = CssConstructionFunctions.ParseKeyframeRule(@" 50% { @@ -36,6 +37,7 @@ public void KeyframeRuleWith50PercentAndMarginLeftOpacity() } [TestMethod] + [Spec("css-animations-1", "#keyframes")] public void KeyframeRuleWithToAndMarginLeft() { var rule = CssConstructionFunctions.ParseKeyframeRule(@" to { @@ -49,6 +51,7 @@ public void KeyframeRuleWithToAndMarginLeft() } [TestMethod] + [Spec("css-animations-1", "#keyframes")] public void KeyframeRuleWithFromTo255075PercentAndPaddingTopPaddingLeftColor() { var rule = CssConstructionFunctions.ParseKeyframeRule(@" from,to, 25%, 50%,75%{ @@ -66,6 +69,7 @@ public void KeyframeRuleWithFromTo255075PercentAndPaddingTopPaddingLeftColor() } [TestMethod] + [Spec("css-animations-1", "#keyframes")] public void KeyframeRuleWith0AndNoDeclarations() { var rule = CssConstructionFunctions.ParseKeyframeRule(@" 0% { }"); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssAnimationsTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssAnimationsTests.cs new file mode 100644 index 000000000..d432d1471 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssAnimationsTests.cs @@ -0,0 +1,287 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +/// <summary> +/// The static frame is the document at time zero of its timeline, the first frame a browser would paint. +/// </summary> +[TestClass] +public sealed class CssAnimationsTests +{ + private static readonly RColor Green = RColor.FromArgb(0, 128, 0); + + // ── 2 Animations ───────────────────────────────────────────────────── + + [Ignore("Gap, see Spec/css-animations-1.yml")] + [TestMethod] + [Spec("css-animations-1", "#animations")] + [Spec("css-animations-1", "#animation-name")] + public void RunningAnimation_ShowsItsFirstKeyframeAtTimeZero() + { + var x = Box("<style>@keyframes k { from, to { color: green } } p { color: red; animation: k 1s }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-animations-1", "#animations")] + public void AnimationWithoutKeyframes_ChangesNothing() + { + var x = Box("<style>p { color: green; animation: missing 1s both }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + // ── 3 Keyframes ────────────────────────────────────────────────────── + + [TestMethod] + [Spec("css-animations-1", "#keyframes")] + public void KeyframesRule_IsParsedWithItsKeyframes() + { + var sheet = CssConstructionFunctions.ParseStyleSheet( + "@keyframes slide { from { margin-left: 0 } 50%, 75% { margin-left: 10px } to { margin-left: 20px } }"); + + var rule = (KeyframesRule)sheet.Rules.Single(); + Assert.AreEqual("slide", rule.Name); + CollectionAssert.AreEqual( + new[] { "0%", "50%, 75%", "100%" }, + rule.Rules.OfType<KeyframeRule>().Select(r => r.KeyText).ToArray()); + } + + [TestMethod] + [Spec("css-animations-1", "#keyframes")] + public void KeyframesRule_DoesNotAffectOtherRules() + { + Assert.AreEqual(Green, Box("<style>@keyframes k { from { color: red } } p { color: green }</style><p id=x>a</p>").ActualColor); + Assert.AreEqual(Green, Box("<style>p { color: green } @keyframes p { from { color: red } to { color: red } }</style><p id=x>a</p>").ActualColor); + } + + [Ignore("Gap, see Spec/css-animations-1.yml")] + [TestMethod] + [Spec("css-animations-1", "#keyframes")] + public void KeyframesRule_WithAStringName_IsParsed() + { + var sheet = CssConstructionFunctions.ParseStyleSheet("@keyframes \"initial\" { from { color: red } }"); + + Assert.AreEqual("initial", ((KeyframesRule)sheet.Rules.Single()).Name); + } + + [Ignore("Gap, see Spec/css-animations-1.yml")] + [TestMethod] + [Spec("css-animations-1", "#keyframes")] + [DataRow("none")] + [DataRow("initial")] + public void KeyframesRule_WithAReservedName_IsInvalid(string name) + { + var sheet = CssConstructionFunctions.ParseStyleSheet("@keyframes " + name + " { from { color: red } }"); + + Assert.AreEqual(0, sheet.Rules.Length); + } + + [Ignore("Gap, see Spec/css-animations-1.yml")] + [TestMethod] + [Spec("css-animations-1", "#keyframes")] + [DataRow("110%")] + [DataRow("-10%")] + public void KeyframeSelector_OutsideTheRange_DropsTheBlock(string selector) + { + var sheet = CssConstructionFunctions.ParseStyleSheet("@keyframes k { " + selector + " { color: red } to { color: blue } }"); + + var rule = (KeyframesRule)sheet.Rules.Single(); + CollectionAssert.AreEqual(new[] { "100%" }, rule.Rules.OfType<KeyframeRule>().Select(r => r.KeyText).ToArray()); + } + + [TestMethod] + [Spec("css-animations-1", "#keyframes")] + public void KeyframeSelector_WithoutAUnit_DropsTheBlock() + { + var sheet = CssConstructionFunctions.ParseStyleSheet("@keyframes k { 0 { color: red } to { color: blue } }"); + + var rule = (KeyframesRule)sheet.Rules.Single(); + CollectionAssert.AreEqual(new[] { "100%" }, rule.Rules.OfType<KeyframeRule>().Select(r => r.KeyText).ToArray()); + } + + [Ignore("Gap, see Spec/css-animations-1.yml")] + [TestMethod] + [Spec("css-animations-1", "#keyframes")] + public void ImportantDeclarationInAKeyframe_IsIgnored() + { + var rule = CssConstructionFunctions.ParseKeyframeRule("from { color: red !important; margin-left: 1px }"); + + CollectionAssert.AreEqual(new[] { "margin-left" }, rule.Style.Declarations.Select(d => d.Name).ToArray()); + } + + [TestMethod] + [Spec("css-animations-1", "#timing-functions")] + public void KeyframeBlock_AcceptsATimingFunction() + { + var rule = CssConstructionFunctions.ParseKeyframeRule("from { top: 100px; animation-timing-function: ease-out }"); + + Assert.AreEqual("ease-out", rule.Style.GetPropertyValue("animation-timing-function")); + } + + // ── 3.2 - 3.9 Animation properties ────────────────────────────────── + + [Ignore("Gap, see Spec/css-animations-1.yml")] + [TestMethod] + [Spec("css-animations-1", "#animation-name")] + public void AnimationName_AcceptsAString() + { + var style = CssConstructionFunctions.ParseDeclarations("animation-name: \"initial\""); + + Assert.AreNotEqual(string.Empty, style.GetPropertyValue("animation-name")); + } + + [Ignore("Gap, see Spec/css-animations-1.yml")] + [TestMethod] + [Spec("css-animations-1", "#animation-name")] + public void AnimationName_WithACssWideKeywordInAList_IsInvalid() + { + var style = CssConstructionFunctions.ParseDeclarations("animation-name: foo, initial"); + + Assert.AreEqual(string.Empty, style.GetPropertyValue("animation-name")); + } + + [TestMethod] + [Spec("css-animations-1", "#animation-name")] + public void AnimationName_AcceptsNoneInAList() + { + var style = CssConstructionFunctions.ParseDeclarations("animation-name: none, foo"); + + Assert.AreEqual("none, foo", style.GetPropertyValue("animation-name")); + } + + [Ignore("Gap, see Spec/css-animations-1.yml")] + [TestMethod] + [Spec("css-animations-1", "#animation-duration")] + [Spec("css-animations-1", "#animation-fill-mode")] + public void ZeroDuration_WithForwardsFill_ShowsTheLastKeyframe() + { + var x = Box("<style>@keyframes k { to { color: green } } p { color: red; animation: k 0s forwards }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [Ignore("Gap, see Spec/css-animations-1.yml")] + [TestMethod] + [Spec("css-animations-1", "#animation-duration")] + public void AnimationDuration_Negative_IsInvalid() + { + var style = CssConstructionFunctions.ParseDeclarations("animation-duration: -1s"); + + Assert.AreEqual(string.Empty, style.GetPropertyValue("animation-duration")); + } + + [Ignore("Gap, see Spec/css-animations-1.yml")] + [TestMethod] + [Spec("css-animations-1", "#animation-timing-function")] + public void StepStartTimingFunction_ShowsTheEndOfTheFirstStepAtTimeZero() + { + var x = Box("<style>@keyframes k { from { color: red } to { color: green } } p { color: red; animation: k 1s step-start }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [Ignore("Gap, see Spec/css-animations-1.yml")] + [TestMethod] + [Spec("css-animations-1", "#animation-direction")] + public void ReverseDirection_ShowsTheLastKeyframeAtTimeZero() + { + var x = Box("<style>@keyframes k { from { color: red } to { color: green } } p { color: red; animation: k 1s reverse }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [Ignore("Gap, see Spec/css-animations-1.yml")] + [TestMethod] + [Spec("css-animations-1", "#animation-delay")] + public void NegativeDelay_StartsPartwayThroughTheAnimation() + { + var x = Box("<style>@keyframes k { from { margin-left: 0 } to { margin-left: 100px } } p { animation: k 2s linear -1s }</style><p id=x>a</p>"); + + Assert.AreEqual(50, x.ActualMarginLeft, 0.5); + } + + [Ignore("Gap, see Spec/css-animations-1.yml")] + [TestMethod] + [Spec("css-animations-1", "#animation-fill-mode")] + [Spec("css-animations-1", "#animation-delay")] + public void BackwardsFill_AppliesTheFirstKeyframeDuringTheDelay() + { + var x = Box("<style>@keyframes k { from { color: green } to { color: red } } p { color: red; animation: k 1s 1s backwards }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-animations-1", "#animation-fill-mode")] + [Spec("css-animations-1", "#animation-delay")] + public void NoFill_LeavesTheElementAloneDuringTheDelay() + { + var x = Box("<style>@keyframes k { from, to { color: red } } p { color: green; animation: k 1s 1s }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + // ── 3.10 animation ─────────────────────────────────────────────────── + + [TestMethod] + [Spec("css-animations-1", "#animation")] + public void Shorthand_SetsEveryLonghand() + { + var style = CssConstructionFunctions.ParseDeclarations("animation: slide 1s ease-in 2s infinite alternate both paused"); + + Assert.AreEqual("slide", style.GetPropertyValue("animation-name")); + Assert.AreEqual("1s", style.GetPropertyValue("animation-duration")); + Assert.AreEqual("ease-in", style.GetPropertyValue("animation-timing-function")); + Assert.AreEqual("2s", style.GetPropertyValue("animation-delay")); + Assert.AreEqual("infinite", style.GetPropertyValue("animation-iteration-count")); + Assert.AreEqual("alternate", style.GetPropertyValue("animation-direction")); + Assert.AreEqual("both", style.GetPropertyValue("animation-fill-mode")); + Assert.AreEqual("paused", style.GetPropertyValue("animation-play-state")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-animations-1.yml")] + [Spec("css-animations-1", "#animation")] + public void Shorthand_ListItemWithoutADelay_UsesTheInitialDelay() + { + var style = CssConstructionFunctions.ParseDeclarations("animation: a 1s 2s, b 3s"); + + Assert.AreEqual("2s, 0s", style.GetPropertyValue("animation-delay")); + } + + [TestMethod] + [Spec("css-animations-1", "#animation")] + public void Shorthand_WithANameAndADuration_SetsOnlyThose() + { + var style = CssConstructionFunctions.ParseDeclarations("animation: slide 1s"); + + Assert.AreEqual("slide", style.GetPropertyValue("animation-name")); + Assert.AreEqual("1s", style.GetPropertyValue("animation-duration")); + Assert.AreNotEqual("1s", style.GetPropertyValue("animation-delay")); + } + + [TestMethod] + [Spec("css-animations-1", "#animation")] + public void Shorthand_GivesKeywordsToTheOtherPropertiesFirst() + { + var style = CssConstructionFunctions.ParseDeclarations("animation: 3s none backwards"); + + Assert.AreEqual("none", style.GetPropertyValue("animation-fill-mode")); + Assert.AreEqual("backwards", style.GetPropertyValue("animation-name")); + } + + private static CssBox Box(string body, string id = "x") + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(body)); + var box = LayoutHarness.FindById(root, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box; + } +} diff --git a/Spec/css-animations-1.yml b/Spec/css-animations-1.yml index 3ba42188c..18db6f53b 100644 --- a/Spec/css-animations-1.yml +++ b/Spec/css-animations-1.yml @@ -5,127 +5,82 @@ snapshot: https://www.w3.org/TR/2023/WD-css-animations-1-20230302/ sections: '#intro': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown + status: n/a '#animations': title: 2 Animations - status: unknown + status: unsupported + gaps: + - "Animations are never applied. The static frame is time zero, which shows a running animation's first keyframe, a backwards fill during a positive delay and a forwards fill of a 0s animation" + - Ignoring animations is allowed only for non-interactive media like print, the controls render as screen '#keyframes': title: 3 Keyframes - status: unknown + status: parsed-only + gaps: + - "'@keyframes' is parsed and doesn't affect other rules, its keyframes are never applied, see 2" + - "A string name, e.g. '@keyframes \"foo\"', invalidates the rule" + - "The names 'none' and CSS-wide keywords are accepted" + - Keyframe selectors below 0% or above 100% are accepted + - "'!important' declarations in a keyframe are kept" '#timing-functions': title: 3.1 Timing functions for keyframes - status: unknown + status: parsed-only '#animation-name': title: 3.2 The animation-name property - status: unknown + status: parsed-only + gaps: + - "A string name isn't accepted" + - "A CSS-wide keyword in a list, e.g. 'foo, initial', is accepted" '#animation-duration': title: 3.3 The animation-duration property - status: unknown + status: parsed-only + gaps: + - Negative times are accepted '#animation-timing-function': title: 3.4 The animation-timing-function property - status: unknown + status: parsed-only + gaps: + - Only the easing functions css-easing-1 lists as supported are accepted '#animation-iteration-count': title: 3.5 The animation-iteration-count property - status: unknown + status: parsed-only '#animation-direction': title: 3.6 The animation-direction property - status: unknown + status: parsed-only '#animation-play-state': title: 3.7 The animation-play-state property - status: unknown + status: parsed-only '#animation-delay': title: 3.8 The animation-delay property - status: unknown + status: parsed-only '#animation-fill-mode': title: 3.9 The animation-fill-mode property - status: unknown + status: parsed-only '#animation': title: 3.10 The animation shorthand property - status: unknown + status: parsed-only + gaps: + - "A list item that leaves out a time other items have makes that longhand invalid, e.g. 'animation: a 1s 2s, b 3s' drops animation-delay" '#events': title: 4 Animation Events - status: unknown - '#interface-animationevent': - title: 4.1 The AnimationEvent Interface - status: unknown - '#interface-animationevent-idl': - title: 4.1.1 IDL Definition - status: unknown - '#interface-animationevent-attributes': - title: 4.1.2 Attributes - status: unknown - '#event-animationevent': - title: 4.2 Types of AnimationEvent - status: unknown - '#event-handlers-on-elements-document-objects-and-window-objects': - title: 4.3 Event handlers on elements, Document objects, and Window objects - status: unknown + status: n/a '#interface-dom': title: 5 DOM Interfaces - status: unknown - '#interface-cssrule': - title: 5.1 The CSSRule Interface - status: unknown - '#interface-cssrule-idl': - title: 5.1.1 IDL Definition - status: unknown - '#interface-csskeyframerule': - title: 5.2 The CSSKeyframeRule Interface - status: unknown - '#interface-csskeyframerule-idl': - title: 5.2.1 IDL Definition - status: unknown - '#interface-csskeyframerule-attributes': - title: 5.2.2 Attributes - status: unknown - '#interface-csskeyframesrule': - title: 5.3 The CSSKeyframesRule Interface - status: unknown - '#interface-csskeyframesrule-idl': - title: 5.3.1 IDL Definition - status: unknown - '#interface-csskeyframesrule-attributes': - title: 5.3.2 Attributes - status: unknown - '#interface-csskeyframesrule-indexed-property-getter': - title: 5.3.3 The indexed property getter - status: unknown - '#interface-csskeyframesrule-appendrule': - title: 5.3.4 The appendRule method - status: unknown - '#interface-csskeyframesrule-deleterule': - title: 5.3.5 The deleteRule method - status: unknown - '#interface-csskeyframesrule-findrule': - title: 5.3.6 The findRule method - status: unknown - '#interface-globaleventhandlers': - title: 5.4 Extensions to the GlobalEventHandlers Interface Mixin - status: unknown - '#interface-globaleventhandlers-idl': - title: 5.4.1 IDL Definition - status: unknown + status: n/a '#priv': title: 6 Privacy Considerations - status: unknown + status: n/a '#sec': title: 7 Security Considerations - status: unknown + status: n/a '#changes': title: 8 Changes - status: unknown - '#changes-20181011': - title: 8.1 Changes since the Working Draft of 11 October 2018 - status: unknown + status: n/a '#acknowledgements': title: 9 Acknowledgements - status: unknown + status: n/a '#other-open-issues': title: 10 Other open issues - status: unknown + status: n/a '#wg-resolutions-pending': title: 11 Working Group Resolutions that are pending editing - status: unknown + status: n/a From 41f1491cd6f3445a68004a2ac8cf982c63863b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:31:25 +0100 Subject: [PATCH 095/150] Verify and document CSS Transitions compliance --- .../Css/TransitionPropertyTests.cs | 53 +++++--- .../CssModules/CssTransitionsTests.cs | 116 ++++++++++++++++++ Spec/css-transitions-1.yml | 78 ++++-------- 3 files changed, 180 insertions(+), 67 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssTransitionsTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Css/TransitionPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/TransitionPropertyTests.cs index 78f8e9b27..4d72777e0 100644 --- a/Source/Test/HtmlRenderer.Test/Css/TransitionPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/TransitionPropertyTests.cs @@ -3,18 +3,11 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/TransitionProperty.cs (source class -/// <c>CssTransitionPropertyTests</c>). -/// <see cref="TransitionPropertyProperty"/> (via a restricted known-property-name list matching the -/// "-specific"/"sliding-vertically"/"test_05"-illegal cases), <see cref="TransitionTimingFunctionProperty"/>, -/// <see cref="TransitionDurationProperty"/>, <see cref="TransitionDelayProperty"/>, and the -/// <see cref="TransitionProperty"/> shorthand all exist and match PeachPDF's grammar. -/// </summary> [TestClass] public sealed class TransitionPropertyTests { [TestMethod] + [Spec("css-transitions-1", "#transition-property-property")] public void CssTransitionPropertyNoneLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-property : none"); @@ -28,6 +21,7 @@ public void CssTransitionPropertyNoneLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-property-property")] public void CssTransitionPropertyAllLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-property : ALL"); @@ -41,6 +35,7 @@ public void CssTransitionPropertyAllLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-property-property")] public void CssTransitionPropertyWidthHeightLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-property : width , height"); @@ -53,8 +48,10 @@ public void CssTransitionPropertyWidthHeightLegal() Assert.AreEqual("width, height", concrete.Value); } + [Ignore("Gap, see Spec/css-transitions-1.yml")] [TestMethod] - public void CssTransitionPropertyDashSpecificIllegal() + [Spec("css-transitions-1", "#transition-property-property")] + public void CssTransitionPropertyDashSpecificLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-property : -specific"); Assert.AreEqual("transition-property", property.Name); @@ -62,11 +59,14 @@ public void CssTransitionPropertyDashSpecificIllegal() Assert.IsInstanceOfType<TransitionPropertyProperty>(property); var concrete = (TransitionPropertyProperty)property; Assert.IsFalse(concrete.IsInherited); - Assert.IsFalse(concrete.HasValue); + Assert.IsTrue(concrete.HasValue); + Assert.AreEqual("-specific", concrete.Value); } + [Ignore("Gap, see Spec/css-transitions-1.yml")] [TestMethod] - public void CssTransitionPropertySlidingVerticallyIllegal() + [Spec("css-transitions-1", "#transition-property-property")] + public void CssTransitionPropertySlidingVerticallyLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-property : sliding-vertically"); Assert.AreEqual("transition-property", property.Name); @@ -74,11 +74,14 @@ public void CssTransitionPropertySlidingVerticallyIllegal() Assert.IsInstanceOfType<TransitionPropertyProperty>(property); var concrete = (TransitionPropertyProperty)property; Assert.IsFalse(concrete.IsInherited); - Assert.IsFalse(concrete.HasValue); + Assert.IsTrue(concrete.HasValue); + Assert.AreEqual("sliding-vertically", concrete.Value); } + [Ignore("Gap, see Spec/css-transitions-1.yml")] [TestMethod] - public void CssTransitionPropertyTest05Illegal() + [Spec("css-transitions-1", "#transition-property-property")] + public void CssTransitionPropertyTest05Legal() { var property = CssConstructionFunctions.ParseDeclaration("transition-property : test_05"); Assert.AreEqual("transition-property", property.Name); @@ -86,10 +89,12 @@ public void CssTransitionPropertyTest05Illegal() Assert.IsInstanceOfType<TransitionPropertyProperty>(property); var concrete = (TransitionPropertyProperty)property; Assert.IsFalse(concrete.IsInherited); - Assert.IsFalse(concrete.HasValue); + Assert.IsTrue(concrete.HasValue); + Assert.AreEqual("test_05", concrete.Value); } [TestMethod] + [Spec("css-transitions-1", "#transition-timing-function-property")] public void CssTransitionTimingFunctionEaseLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-timing-function : ease"); @@ -103,6 +108,7 @@ public void CssTransitionTimingFunctionEaseLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-timing-function-property")] public void CssTransitionTimingFunctionEaseInLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-timing-function : ease-IN"); @@ -116,6 +122,7 @@ public void CssTransitionTimingFunctionEaseInLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-timing-function-property")] public void CssTransitionTimingFunctionStepStartLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-timing-function : step-start"); @@ -129,6 +136,7 @@ public void CssTransitionTimingFunctionStepStartLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-timing-function-property")] public void CssTransitionTimingFunctionStepStartStepEndLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-timing-function : step-start , step-end"); @@ -142,6 +150,7 @@ public void CssTransitionTimingFunctionStepStartStepEndLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-timing-function-property")] public void CssTransitionTimingFunctionStepStartStepEndLinearEaseInOutLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-timing-function : step-start , step-end,linear,ease-IN-OUT"); @@ -155,6 +164,7 @@ public void CssTransitionTimingFunctionStepStartStepEndLinearEaseInOutLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-timing-function-property")] public void CssTransitionTimingFunctionCubicBezierLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-timing-function : cubic-bezier(0, 1, 0.5, 1)"); @@ -168,6 +178,7 @@ public void CssTransitionTimingFunctionCubicBezierLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-timing-function-property")] public void CssTransitionTimingFunctionStepsStartLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-timing-function : steps(10, start)"); @@ -181,6 +192,7 @@ public void CssTransitionTimingFunctionStepsStartLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-timing-function-property")] public void CssTransitionTimingFunctionStepsEndLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-timing-function : steps(25, end)"); @@ -194,6 +206,7 @@ public void CssTransitionTimingFunctionStepsEndLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-timing-function-property")] public void CssTransitionTimingFunctionStepsLinearCubicBezierLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-timing-function : steps(25), linear, cubic-bezier(0.25, 1, 0.5, 1)"); @@ -207,6 +220,7 @@ public void CssTransitionTimingFunctionStepsLinearCubicBezierLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-duration-property")] public void CssTransitionDurationSecondsLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-duration : 6s"); @@ -220,6 +234,7 @@ public void CssTransitionDurationSecondsLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-duration-property")] public void CssTransitionDurationMillisecondsLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-duration : 60ms"); @@ -233,6 +248,7 @@ public void CssTransitionDurationMillisecondsLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-duration-property")] public void CssTransitionDurationMillisecondsSecondsSecondsLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-duration : 60ms, 1s, 2s"); @@ -246,6 +262,7 @@ public void CssTransitionDurationMillisecondsSecondsSecondsLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-delay-property")] public void CssTransitionDelayMillisecondsLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-delay : 60ms"); @@ -259,6 +276,7 @@ public void CssTransitionDelayMillisecondsLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-delay-property")] public void CssTransitionDelayMillisecondsSecondsSecondsLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition-delay : 60ms, 1s, 2s"); @@ -272,6 +290,7 @@ public void CssTransitionDelayMillisecondsSecondsSecondsLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-shorthand-property")] public void CssTransitionMillisecondsSecondsSecondsLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition : 60ms, 1s, 2s"); @@ -285,6 +304,7 @@ public void CssTransitionMillisecondsSecondsSecondsLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-shorthand-property")] public void CssTransitionStepsLinearCubicBezierLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition : steps(25), linear, cubic-bezier(0.25, 1, 0.5, 1)"); @@ -298,6 +318,7 @@ public void CssTransitionStepsLinearCubicBezierLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-shorthand-property")] public void CssTransitionWidthHeightLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition : width , height"); @@ -311,6 +332,7 @@ public void CssTransitionWidthHeightLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-shorthand-property")] public void CssTransitionEaseLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition : ease"); @@ -324,6 +346,7 @@ public void CssTransitionEaseLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-shorthand-property")] public void CssTransitionSecondsEaseAllLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition : all 1s ease"); @@ -337,6 +360,7 @@ public void CssTransitionSecondsEaseAllLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-shorthand-property")] public void CssTransitionSecondsEaseAllHeightMsStepsLegal() { var property = CssConstructionFunctions.ParseDeclaration("transition : all 1s ease, height steps(5) 50ms"); @@ -350,6 +374,7 @@ public void CssTransitionSecondsEaseAllHeightMsStepsLegal() } [TestMethod] + [Spec("css-transitions-1", "#transition-shorthand-property")] public void CssTransitionSecondsEaseAllHeightMsStepsWidthCubicBezierLegal() { var property = CssConstructionFunctions.ParseDeclaration( diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssTransitionsTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssTransitionsTests.cs new file mode 100644 index 000000000..cacf74e8a --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssTransitionsTests.cs @@ -0,0 +1,116 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssTransitionsTests +{ + private static readonly RColor Green = RColor.FromArgb(0, 128, 0); + + // ── 2 Transitions ──────────────────────────────────────────────────── + + [TestMethod] + [Spec("css-transitions-1", "#transitions")] + public void Transition_DoesNotChangeTheFirstRendering() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<style>p { color: green; margin-left: 10px; transition: all 1s }</style><p id=x>a</p>")); + var x = LayoutHarness.FindById(root, "x")!; + + Assert.AreEqual(Green, x.ActualColor); + Assert.AreEqual(10, x.ActualMarginLeft); + } + + // ── 2.1 transition-property ────────────────────────────────────────── + + [Ignore("Gap, see Spec/css-transitions-1.yml")] + [TestMethod] + [Spec("css-transitions-1", "#transition-property-property")] + [DataRow("foo-bar")] + [DataRow("--my-var")] + [DataRow("color, foo-bar")] + public void TransitionProperty_KeepsUnknownNames(string value) + { + var style = CssConstructionFunctions.ParseDeclarations("transition-property: " + value); + + Assert.AreEqual(value, style.GetPropertyValue("transition-property")); + } + + [TestMethod] + [Spec("css-transitions-1", "#transition-property-property")] + [DataRow("none, color")] + [DataRow("color, initial")] + public void TransitionProperty_WithAReservedKeywordInAList_IsInvalid(string value) + { + var style = CssConstructionFunctions.ParseDeclarations("transition-property: " + value); + + Assert.AreEqual(string.Empty, style.GetPropertyValue("transition-property")); + } + + // ── 2.2 transition-duration ────────────────────────────────────────── + + [Ignore("Gap, see Spec/css-transitions-1.yml")] + [TestMethod] + [Spec("css-transitions-1", "#transition-duration-property")] + public void TransitionDuration_Negative_IsInvalid() + { + var style = CssConstructionFunctions.ParseDeclarations("transition-duration: -1s"); + + Assert.AreEqual(string.Empty, style.GetPropertyValue("transition-duration")); + } + + // ── 2.4 transition-delay ───────────────────────────────────────────── + + [TestMethod] + [Spec("css-transitions-1", "#transition-delay-property")] + public void TransitionDelay_Negative_IsValid() + { + var style = CssConstructionFunctions.ParseDeclarations("transition-delay: -1s, 2s"); + + Assert.AreEqual("-1s, 2s", style.GetPropertyValue("transition-delay")); + } + + // ── 2.5 transition ─────────────────────────────────────────────────── + + [TestMethod] + [Spec("css-transitions-1", "#transition-shorthand-property")] + public void Shorthand_WithAPropertyAndADuration_SetsOnlyThose() + { + var style = CssConstructionFunctions.ParseDeclarations("transition: color 1s"); + + Assert.AreEqual("color", style.GetPropertyValue("transition-property")); + Assert.AreEqual("1s", style.GetPropertyValue("transition-duration")); + Assert.AreNotEqual("1s", style.GetPropertyValue("transition-delay")); + } + + [TestMethod] + [Spec("css-transitions-1", "#transition-shorthand-property")] + public void Shorthand_AssignsTheSecondTimeToTheDelay() + { + var style = CssConstructionFunctions.ParseDeclarations("transition: 1s 2s"); + + Assert.AreEqual("1s", style.GetPropertyValue("transition-duration")); + Assert.AreEqual("2s", style.GetPropertyValue("transition-delay")); + } + + [TestMethod] + [Spec("css-transitions-1", "#transition-shorthand-property")] + public void Shorthand_None_IsValid() + { + var style = CssConstructionFunctions.ParseDeclarations("transition: none"); + + Assert.AreEqual("none", style.GetPropertyValue("transition-property")); + } + + [TestMethod] + [Spec("css-transitions-1", "#transition-shorthand-property")] + public void Shorthand_WithNoneInAList_IsInvalid() + { + var style = CssConstructionFunctions.ParseDeclarations("transition: none, color 1s"); + + Assert.AreEqual(string.Empty, style.GetPropertyValue("transition")); + Assert.AreEqual(string.Empty, style.GetPropertyValue("transition-property")); + } +} diff --git a/Spec/css-transitions-1.yml b/Spec/css-transitions-1.yml index a270a13ff..5c2bebdff 100644 --- a/Spec/css-transitions-1.yml +++ b/Spec/css-transitions-1.yml @@ -5,88 +5,60 @@ snapshot: https://www.w3.org/TR/2026/WD-css-transitions-1-20260108/ sections: '#introduction': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown + status: n/a '#transitions': title: 2 Transitions - status: unknown + status: parsed-only '#transition-property-property': title: 2.1 The transition-property Property - status: unknown + status: parsed-only + gaps: + - Only known property names are accepted, unknown names and custom properties invalidate the declaration '#transition-duration-property': title: 2.2 The transition-duration Property - status: unknown + status: parsed-only + gaps: + - Negative times are accepted '#transition-timing-function-property': title: 2.3 The transition-timing-function Property - status: unknown + status: parsed-only + gaps: + - Only the easing functions css-easing-1 lists as supported are accepted '#transition-delay-property': title: 2.4 The transition-delay Property - status: unknown + status: parsed-only '#transition-shorthand-property': title: 2.5 The transition Shorthand Property - status: unknown + status: parsed-only '#starting': title: 3 Starting of transitions - status: unknown - '#reversing': - title: 3.1 Faster reversing of interrupted transitions - status: unknown + status: n/a + gaps: + - "A static render has no style change event: the first style computation never starts transitions, and a re-cascade after a viewport change renders a new frame of the changed style" '#application': title: 4 Application of transitions - status: unknown + status: n/a '#complete': title: 5 Completion of transitions - status: unknown + status: n/a '#transition-events': title: 6 Transition Events - status: unknown - '#interface-transitionevent': - title: 6.1 Interface TransitionEvent - status: unknown - '#interface-transitionevent-idl': - title: 6.1.1 IDL Definition - status: unknown - '#interface-transitionevent-attributes': - title: 6.1.2 Attributes - status: unknown - '#event-transitionevent': - title: 6.2 Types of TransitionEvent - status: unknown - '#event-handlers-on-elements-document-objects-and-window-objects': - title: 6.3 Event handlers on elements, Document objects, and Window objects - status: unknown + status: n/a '#interface-dom': title: 7 DOM Interfaces - status: unknown - '#interface-globaleventhandlers-idl': - title: 7.1 IDL Definition - status: unknown + status: n/a '#security': title: 8 Security Considerations - status: unknown + status: n/a '#privacy': title: 9 Privacy Considerations - status: unknown + status: n/a '#accessibility': title: 10 Accessibility Considerations - status: unknown - '#accessibility-motion': - title: 10.1 Motion - status: unknown - '#accessibility-cascade': - title: 10.2 Cascade - status: unknown + status: n/a '#changes': title: 11 Changes - status: unknown - '#changes-2018': - title: 11.1 Changes since Working Draft of 11 October 2018 - status: unknown - '#changes-earlier': - title: 11.2 Earlier changes - status: unknown + status: n/a '#acknowledgments': title: 12 Acknowledgments - status: unknown + status: n/a From f1682b491a3d211309eb62a40f2b05ecb5248c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:33:07 +0100 Subject: [PATCH 096/150] Verify and document CSS Easing Functions compliance --- .../CssModules/CssEasingTests.cs | 114 ++++++++++++++++++ Spec/css-easing-1.yml | 34 +++--- 2 files changed, 134 insertions(+), 14 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssEasingTests.cs diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssEasingTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssEasingTests.cs new file mode 100644 index 000000000..203cbc52b --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssEasingTests.cs @@ -0,0 +1,114 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssEasingTests +{ + // ── 2 Easing functions ─────────────────────────────────────────────── + + [TestMethod] + [Spec("css-easing-1", "#the-linear-easing-function")] + [Spec("css-easing-1", "#cubic-bezier-easing-functions")] + [Spec("css-easing-1", "#step-easing-functions")] + [DataRow("linear")] + [DataRow("ease")] + [DataRow("ease-in")] + [DataRow("ease-out")] + [DataRow("ease-in-out")] + [DataRow("step-start")] + [DataRow("step-end")] + public void Keyword_IsAccepted(string keyword) + { + Assert.AreEqual(keyword, Easing(keyword)); + } + + // ── 2.2 Cubic Bézier ───────────────────────────────────────────────── + + [TestMethod] + [Spec("css-easing-1", "#cubic-bezier-easing-functions")] + public void CubicBezier_AcceptsYValuesOutsideTheUnitRange() + { + Assert.AreEqual("cubic-bezier(0.1, -2, 0.3, 3)", Easing("cubic-bezier(0.1, -2, 0.3, 3)")); + } + + [TestMethod] + [Spec("css-easing-1", "#cubic-bezier-easing-functions")] + public void CubicBezier_WithThreeNumbers_IsInvalid() + { + Assert.AreEqual(string.Empty, Easing("cubic-bezier(0.1, 0.2, 0.3)")); + } + + [Ignore("Gap, see Spec/css-easing-1.yml")] + [TestMethod] + [Spec("css-easing-1", "#cubic-bezier-easing-functions")] + [DataRow("cubic-bezier(2, 0, 0, 1)")] + [DataRow("cubic-bezier(0, 0, -0.1, 1)")] + public void CubicBezier_WithXOutsideTheUnitRange_IsInvalid(string value) + { + Assert.AreEqual(string.Empty, Easing(value)); + } + + [Ignore("Gap, see Spec/css-easing-1.yml")] + [TestMethod] + [Spec("css-easing-1", "#cubic-bezier-algo")] + public void CubicBezier_EasesTheProgress() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<style>@keyframes k { from { margin-left: 0 } to { margin-left: 100px } } p { animation: k 2s ease-in -1s }</style><p id=x>a</p>")); + + Assert.AreEqual(31.5, LayoutHarness.FindById(root, "x")!.ActualMarginLeft, 0.5); + } + + // ── 2.3 Steps ──────────────────────────────────────────────────────── + + [TestMethod] + [Spec("css-easing-1", "#step-easing-functions")] + [DataRow("steps(3)")] + [DataRow("steps(3, start)")] + [DataRow("steps(3, end)")] + public void Steps_IsAccepted(string value) + { + Assert.AreNotEqual(string.Empty, Easing(value)); + } + + [Ignore("Gap, see Spec/css-easing-1.yml")] + [TestMethod] + [Spec("css-easing-1", "#step-easing-functions")] + [DataRow("steps(3, jump-start)")] + [DataRow("steps(3, jump-end)")] + [DataRow("steps(3, jump-none)")] + [DataRow("steps(3, jump-both)")] + public void Steps_AcceptsTheJumpPositions(string value) + { + Assert.AreEqual(value, Easing(value)); + } + + [Ignore("Gap, see Spec/css-easing-1.yml")] + [TestMethod] + [Spec("css-easing-1", "#step-easing-functions")] + [DataRow("steps(0)")] + [DataRow("steps(-1, start)")] + public void Steps_WithoutAPositiveCount_IsInvalid(string value) + { + Assert.AreEqual(string.Empty, Easing(value)); + } + + [Ignore("Gap, see Spec/css-easing-1.yml")] + [TestMethod] + [Spec("css-easing-1", "#step-easing-algo")] + public void Steps_JumpTheProgress() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<style>@keyframes k { from { margin-left: 0 } to { margin-left: 100px } } p { animation: k 2s steps(4) -1.2s }</style><p id=x>a</p>")); + + Assert.AreEqual(50, LayoutHarness.FindById(root, "x")!.ActualMarginLeft, 0.5); + } + + private static string Easing(string value) + { + var style = CssConstructionFunctions.ParseDeclarations("transition-timing-function: " + value); + return style.GetPropertyValue("transition-timing-function"); + } +} diff --git a/Spec/css-easing-1.yml b/Spec/css-easing-1.yml index 991064eee..8d9f2dfc4 100644 --- a/Spec/css-easing-1.yml +++ b/Spec/css-easing-1.yml @@ -5,37 +5,43 @@ snapshot: https://www.w3.org/TR/2023/CRD-css-easing-1-20230213/ sections: '#introduction': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown + status: n/a '#easing-functions': title: 2 Easing functions - status: unknown + status: parsed-only '#the-linear-easing-function': title: '2.1 The linear easing function: linear' - status: unknown + status: parsed-only '#cubic-bezier-easing-functions': title: '2.2 Cubic Bézier easing functions: ease, ease-in, ease-out, ease-in-out, cubic-bezier()' - status: unknown + status: parsed-only + gaps: + - x values outside [0, 1] are accepted '#cubic-bezier-algo': title: 2.2.1 Output of a cubic bézier easing function - status: unknown + status: unsupported + gaps: + - "Easing functions are never evaluated, animations aren't applied, see css-animations-1 2" '#step-easing-functions': title: '2.3 Step easing functions: step-start, step-end, steps()' - status: unknown + status: parsed-only + gaps: + - The jump-start, jump-end, jump-none and jump-both positions invalidate the declaration + - A step count of 0 or below is accepted '#step-easing-algo': title: 2.3.1 Output of a step easing function - status: unknown + status: unsupported + gaps: + - "Easing functions are never evaluated, animations aren't applied, see css-animations-1 2" '#serialization': title: 2.4 Serialization - status: unknown + status: n/a '#priv-sec': title: 3 Privacy and Security Considerations - status: unknown + status: n/a '#changes': title: 4 Changes - status: unknown + status: n/a '#acknowledgements': title: 5 Acknowledgements - status: unknown + status: n/a From 5f5d768a43d60e1c358a7bfa33ab4760d67d8d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:34:34 +0100 Subject: [PATCH 097/150] Verify and document Web Animations compliance --- .../CssModules/CssAnimationsTests.cs | 7 +- Spec/web-animations-1.yml | 294 ++++-------------- 2 files changed, 68 insertions(+), 233 deletions(-) diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssAnimationsTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssAnimationsTests.cs index d432d1471..ff92d8f47 100644 --- a/Source/Test/HtmlRenderer.Test/CssModules/CssAnimationsTests.cs +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssAnimationsTests.cs @@ -20,7 +20,8 @@ public sealed class CssAnimationsTests [Ignore("Gap, see Spec/css-animations-1.yml")] [TestMethod] [Spec("css-animations-1", "#animations")] - [Spec("css-animations-1", "#animation-name")] + [Spec("web-animations-1", "#the-effect-value-of-a-keyframe-animation-effect")] + [Spec("web-animations-1", "#applying-the-composited-result")] public void RunningAnimation_ShowsItsFirstKeyframeAtTimeZero() { var x = Box("<style>@keyframes k { from, to { color: green } } p { color: red; animation: k 1s }</style><p id=x>a</p>"); @@ -160,6 +161,7 @@ public void AnimationName_AcceptsNoneInAList() [TestMethod] [Spec("css-animations-1", "#animation-duration")] [Spec("css-animations-1", "#animation-fill-mode")] + [Spec("web-animations-1", "#calculating-the-overall-progress")] public void ZeroDuration_WithForwardsFill_ShowsTheLastKeyframe() { var x = Box("<style>@keyframes k { to { color: green } } p { color: red; animation: k 0s forwards }</style><p id=x>a</p>"); @@ -190,6 +192,7 @@ public void StepStartTimingFunction_ShowsTheEndOfTheFirstStepAtTimeZero() [Ignore("Gap, see Spec/css-animations-1.yml")] [TestMethod] [Spec("css-animations-1", "#animation-direction")] + [Spec("web-animations-1", "#calculating-the-directed-progress")] public void ReverseDirection_ShowsTheLastKeyframeAtTimeZero() { var x = Box("<style>@keyframes k { from { color: red } to { color: green } } p { color: red; animation: k 1s reverse }</style><p id=x>a</p>"); @@ -200,6 +203,7 @@ public void ReverseDirection_ShowsTheLastKeyframeAtTimeZero() [Ignore("Gap, see Spec/css-animations-1.yml")] [TestMethod] [Spec("css-animations-1", "#animation-delay")] + [Spec("web-animations-1", "#calculating-the-active-time")] public void NegativeDelay_StartsPartwayThroughTheAnimation() { var x = Box("<style>@keyframes k { from { margin-left: 0 } to { margin-left: 100px } } p { animation: k 2s linear -1s }</style><p id=x>a</p>"); @@ -211,6 +215,7 @@ public void NegativeDelay_StartsPartwayThroughTheAnimation() [TestMethod] [Spec("css-animations-1", "#animation-fill-mode")] [Spec("css-animations-1", "#animation-delay")] + [Spec("web-animations-1", "#fill-modes")] public void BackwardsFill_AppliesTheFirstKeyframeDuringTheDelay() { var x = Box("<style>@keyframes k { from { color: green } to { color: red } } p { color: red; animation: k 1s 1s backwards }</style><p id=x>a</p>"); diff --git a/Spec/web-animations-1.yml b/Spec/web-animations-1.yml index 118502723..20a47631a 100644 --- a/Spec/web-animations-1.yml +++ b/Spec/web-animations-1.yml @@ -5,349 +5,179 @@ snapshot: https://www.w3.org/TR/2023/WD-web-animations-1-20230605/ sections: '#introduction': title: 1 Introduction - status: unknown - '#use-cases': - title: 1.1 Use cases - status: unknown - '#relationship-to-other-specifications': - title: 1.2 Relationship to other specifications - status: unknown - '#overview-of-this-specification': - title: 1.3 Overview of this specification - status: unknown + status: n/a '#spec-conventions': title: 2 Specification conventions - status: unknown + status: n/a '#web-animations-model-overview': title: 3 Web Animations model overview - status: unknown + status: n/a '#timing-model': title: 4 Timing model - status: unknown + status: unsupported + gaps: + - "No animation model, CSS animations are never applied, see css-animations-1 2. Timelines, playback control, promises and events are n/a: the static frame is time zero of the document timeline" '#timing-model-overview': title: 4.1 Timing model overview - status: unknown - '#stateless': - title: 4.1.1 Stateless - status: unknown - '#hierarchical': - title: 4.1.2 Hierarchical - status: unknown + status: n/a '#time-value-section': title: 4.2 Time values - status: unknown + status: n/a '#timelines': title: 4.3 Timelines - status: unknown - '#document-timelines': - title: 4.3.1 Document timelines - status: unknown - '#the-documents-default-timeline': - title: 4.3.2 The default document timeline - status: unknown + status: n/a '#animations': title: 4.4 Animations - status: unknown - '#setting-the-timeline': - title: 4.4.1 Setting the timeline of an animation - status: unknown - '#setting-the-associated-effect': - title: 4.4.2 Setting the associated effect of an animation - status: unknown - '#the-current-time-of-an-animation': - title: 4.4.3 The current time of an animation - status: unknown - '#setting-the-current-time-of-an-animation': - title: 4.4.4 Setting the current time of an animation - status: unknown - '#setting-the-start-time-of-an-animation': - title: 4.4.5 Setting the start time of an animation - status: unknown - '#waiting-for-the-associated-effect': - title: 4.4.6 Waiting for the associated effect - status: unknown - '#the-current-ready-promise': - title: 4.4.7 The current ready promise - status: unknown - '#playing-an-animation-section': - title: 4.4.8 Playing an animation - status: unknown - '#pausing-an-animation-section': - title: 4.4.9 Pausing an animation - status: unknown - '#reaching-the-end': - title: 4.4.10 Reaching the end - status: unknown - '#the-current-finished-promise': - title: 4.4.11 The current finished promise - status: unknown - '#updating-the-finished-state': - title: 4.4.12 Updating the finished state - status: unknown - '#finishing-an-animation-section': - title: 4.4.13 Finishing an animation - status: unknown - '#canceling-an-animation-section': - title: 4.4.14 Canceling an animation - status: unknown - '#speed-control': - title: 4.4.15 Speed control - status: unknown - '#setting-the-playback-rate-of-an-animation': - title: 4.4.15.1 Setting the playback rate of an animation - status: unknown - '#seamlessly-updating-the-playback-rate-of-an-animation': - title: 4.4.15.2 Seamlessly updating the playback rate of an animation - status: unknown - '#reversing-an-animation-section': - title: 4.4.16 Reversing an animation - status: unknown - '#play-states': - title: 4.4.17 Play states - status: unknown - '#animation-events-section': - title: 4.4.18 Animation events - status: unknown - '#sorting-animation-events': - title: 4.4.18.1 Sorting animation events - status: unknown - '#animation-playback-events-section': - title: 4.4.18.2 Animation playback events - status: unknown - '#animation-playback-event-types': - title: 4.4.18.3 Types of animation playback events - status: unknown + status: n/a '#animation-effects': title: 4.5 Animation effects - status: unknown + status: unsupported '#animation-effects-and-animations': title: 4.5.1 Relationship between animation effects and animations - status: unknown + status: unsupported '#types-of-animation-effects': title: 4.5.2 Types of animation effects - status: unknown + status: unsupported '#the-active-interval': title: 4.5.3 The active interval - status: unknown + status: unsupported '#local-time-section': title: 4.5.4 Local time - status: unknown + status: unsupported '#animation-effect-phases-and-states': title: 4.5.5 Animation effect phases and states - status: unknown + status: unsupported '#relevant-animations-section': title: 4.5.6 Relevant animations - status: unknown + status: n/a '#fill-behavior': title: 4.6 Fill behavior - status: unknown + status: unsupported '#fill-modes': title: 4.6.1 Fill modes - status: unknown + status: unsupported '#repeating': title: 4.7 Repeating - status: unknown + status: unsupported '#iteration-intervals': title: 4.7.1 Iteration intervals - status: unknown + status: unsupported '#controlling-iteration': title: 4.7.2 Controlling iteration - status: unknown + status: unsupported '#iteration-time-space': title: 4.7.3 Iteration time space - status: unknown + status: unsupported '#interval-timing': title: 4.7.4 Interval timing - status: unknown + status: unsupported '#core-animation-effect-calculations': title: 4.8 Core animation effect calculations - status: unknown + status: unsupported '#animation-effect-calculations-overview': title: 4.8.1 Overview - status: unknown + status: n/a '#calculating-the-active-duration': title: 4.8.2 Calculating the active duration - status: unknown + status: unsupported '#transforming-the-local-time': title: 4.8.3 Transforming the local time - status: unknown + status: unsupported '#calculating-the-active-time': title: 4.8.3.1 Calculating the active time - status: unknown + status: unsupported '#calculating-the-overall-progress': title: 4.8.3.2 Calculating the overall progress - status: unknown + status: unsupported '#calculating-the-simple-iteration-progress': title: 4.8.3.3 Calculating the simple iteration progress - status: unknown + status: unsupported '#calculating-the-current-iteration': title: 4.8.4 Calculating the current iteration - status: unknown + status: unsupported '#direction-control': title: 4.9 Direction control - status: unknown + status: unsupported '#calculating-the-directed-progress': title: 4.9.1 Calculating the directed progress - status: unknown + status: unsupported '#time-transformations': title: 4.10 Time transformations - status: unknown + status: unsupported '#calculating-the-transformed-progress': title: 4.10.1 Calculating the transformed progress - status: unknown + status: unsupported '#the-iteration-progress': title: 4.11 The iteration progress - status: unknown + status: unsupported '#animation-model': title: 5 Animation model - status: unknown + status: unsupported + gaps: + - Keyframe effects are never computed or composited into the cascade, see css-animations-1 2 '#introduction-to-the-animation-model': title: 5.1 Introduction - status: unknown + status: n/a '#animating-properties': title: 5.2 Animating properties - status: unknown + status: unsupported '#custom-properties': title: 5.2.1 Custom Properties - status: unknown + status: unsupported '#keyframe-effects': title: 5.3 Keyframe effects - status: unknown + status: unsupported '#keyframes-section': title: 5.3.1 Keyframes - status: unknown + status: unsupported '#computing-property-values': title: 5.3.2 Computing property values - status: unknown + status: unsupported '#calculating-computed-keyframes': title: 5.3.3 Calculating computed keyframes - status: unknown + status: unsupported '#the-effect-value-of-a-keyframe-animation-effect': title: 5.3.4 The effect value of a keyframe effect - status: unknown + status: unsupported '#combining-effects': title: 5.4 Combining effects - status: unknown + status: unsupported '#animation-classes': title: 5.4.1 Animation classes - status: unknown + status: unsupported '#the-effect-stack': title: 5.4.2 The effect stack - status: unknown + status: unsupported '#calculating-the-result-of-an-effect-stack': title: 5.4.3 Calculating the result of an effect stack - status: unknown + status: unsupported '#effect-composition': title: 5.4.4 Effect composition - status: unknown + status: unsupported '#applying-the-composited-result': title: 5.4.5 Applying the composited result - status: unknown + status: unsupported '#replacing-animations': title: 5.5 Replacing animations - status: unknown - '#animation-replace-state': - title: 5.5.1 Replace state - status: unknown - '#removing-replaced-animations': - title: 5.5.2 Removing replaced animations - status: unknown + status: n/a '#side-effects-section': title: 5.6 Side effects of animation - status: unknown + status: n/a '#programming-interface': title: 6 Programming interface - status: unknown - '#time-values-in-the-programming-interface': - title: 6.1 Time values in the programming interface - status: unknown - '#the-animationtimeline-interface': - title: 6.2 The AnimationTimeline interface - status: unknown - '#the-documenttimeline-interface': - title: 6.3 The DocumentTimeline interface - status: unknown - '#the-animation-interface': - title: 6.4 The Animation interface - status: unknown - '#the-animationplaystate-enumeration': - title: 6.4.1 The AnimationPlayState enumeration - status: unknown - '#the-animationreplacestate-enumeration': - title: 6.4.2 The AnimationReplaceState enumeration - status: unknown - '#the-animationeffect-interface': - title: 6.5 The AnimationEffect interface - status: unknown - '#the-effecttiming-dictionaries': - title: 6.5.1 The EffectTiming and OptionalEffectTiming dictionaries - status: unknown - '#the-fillmode-enumeration': - title: 6.5.2 The FillMode enumeration - status: unknown - '#the-playbackdirection-enumeration': - title: 6.5.3 The PlaybackDirection enumeration - status: unknown - '#updating-animationeffect-timing': - title: 6.5.4 Updating the timing of an AnimationEffect - status: unknown - '#the-computedeffecttiming-dictionary': - title: 6.5.5 The ComputedEffectTiming dictionary - status: unknown - '#the-keyframeeffect-interface': - title: 6.6 The KeyframeEffect interface - status: unknown - '#creating-a-new-keyframeeffect-object': - title: 6.6.1 Creating a new KeyframeEffect object - status: unknown - '#property-name-conversion': - title: 6.6.2 Property names and IDL names - status: unknown - '#processing-a-keyframes-argument': - title: 6.6.3 Processing a keyframes argument - status: unknown - '#the-keyframeeffectoptions-dictionary': - title: 6.6.4 The KeyframeEffectOptions dictionary - status: unknown - '#the-compositeoperation-enumeration': - title: 6.7 The CompositeOperation and CompositeOperationOrAuto enumerations - status: unknown - '#the-animatable-interface-mixin': - title: 6.8 The Animatable interface mixin - status: unknown - '#extensions-to-the-document-interface': - title: 6.9 Extensions to the Document interface - status: unknown - '#extensions-to-the-documentorshadowroot-interface-mixin': - title: 6.10 Extensions to the DocumentOrShadowRoot interface mixin - status: unknown - '#extensions-to-the-element-interface': - title: 6.11 Extensions to the Element interface - status: unknown - '#the-animationplaybackevent-interface': - title: 6.12 The AnimationPlaybackEvent interface - status: unknown - '#model-liveness': - title: 6.13 Model liveness - status: unknown + status: n/a '#integration-with-media-fragments': title: 7 Integration with Media Fragments - status: unknown + status: n/a '#interaction-with-page-display': title: 8 Interaction with page display - status: unknown + status: n/a '#implementation-requirements': title: 9 Implementation requirements - status: unknown - '#precision-of-time-values': - title: 9.1 Precision of time values - status: unknown - '#conformance-criteria': - title: 9.2 Conformance criteria - status: unknown + status: n/a '#acknowledgements': title: 10 Acknowledgements - status: unknown + status: n/a '#changes-since-last-publication': title: 11 Changes since last publication - status: unknown + status: n/a From bcb469a0f52742bc806259d59183b17e4e84d0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:36:06 +0100 Subject: [PATCH 098/150] Verify and document CSS View Transitions compliance --- .../CssModules/CssViewTransitionsTests.cs | 71 ++++++++++ Spec/css-view-transitions-1.yml | 133 ++++-------------- 2 files changed, 100 insertions(+), 104 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssViewTransitionsTests.cs diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssViewTransitionsTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssViewTransitionsTests.cs new file mode 100644 index 000000000..b5d2e9a64 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssViewTransitionsTests.cs @@ -0,0 +1,71 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssViewTransitionsTests +{ + private static readonly RColor Green = RColor.FromArgb(0, 128, 0); + + // ── 2.1 view-transition-name ───────────────────────────────────────── + + [Ignore("Gap, see Spec/css-view-transitions-1.yml")] + [TestMethod] + [Spec("css-view-transitions-1", "#view-transition-name-prop")] + [DataRow("none")] + [DataRow("hero")] + public void ViewTransitionName_IsParsed(string value) + { + var style = CssConstructionFunctions.ParseDeclarations("view-transition-name: " + value); + + Assert.AreEqual(value, style.GetPropertyValue("view-transition-name")); + } + + [TestMethod] + [DataRow("auto")] + [DataRow("a b")] + public void ViewTransitionName_InvalidValue_IsRejected(string value) + { + var style = CssConstructionFunctions.ParseDeclarations("view-transition-name: " + value); + + Assert.AreEqual(string.Empty, style.GetPropertyValue("view-transition-name")); + } + + // ── 3.2 View transition pseudo-elements ────────────────────────────── + + [TestMethod] + [Spec("css-view-transitions-1", "#view-transition-pseudos")] + [DataRow("::view-transition")] + [DataRow("::view-transition-group(*)")] + [DataRow("::view-transition-image-pair(hero)")] + [DataRow("::view-transition-old(hero)")] + [DataRow("::view-transition-new(root)")] + public void Pseudo_NeverMatchesInAStaticRendering(string pseudo) + { + Assert.AreEqual(Green, Box("<style>p { color: green } :root" + pseudo + " { color: red }</style><p id=x>a</p>").ActualColor); + } + + [Ignore("Gap, see Spec/css-view-transitions-1.yml")] + [TestMethod] + [Spec("css-view-transitions-1", "#view-transition-pseudos")] + [DataRow("::view-transition")] + [DataRow("::view-transition-group(*)")] + [DataRow("::view-transition-image-pair(hero)")] + [DataRow("::view-transition-old(hero)")] + [DataRow("::view-transition-new(root)")] + public void Pseudo_KeepsTheRestOfTheSelectorListValid(string pseudo) + { + Assert.AreEqual(Green, Box("<style>p, :root" + pseudo + " { color: green }</style><p id=x>a</p>").ActualColor); + } + + private static CssBox Box(string body, string id = "x") + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(body)); + var box = LayoutHarness.FindById(root, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box; + } +} diff --git a/Spec/css-view-transitions-1.yml b/Spec/css-view-transitions-1.yml index a7620dbf1..d363b4f6c 100644 --- a/Spec/css-view-transitions-1.yml +++ b/Spec/css-view-transitions-1.yml @@ -5,139 +5,64 @@ snapshot: https://www.w3.org/TR/2024/CRD-css-view-transitions-1-20240328/ sections: '#intro': title: 1 Introduction - status: unknown - '#separating-transitions': - title: 1.1 Separating Visual Transitions from DOM Updates - status: unknown - '#customizing': - title: 1.2 View Transition Customization - status: unknown - '#lifecycle': - title: 1.3 View Transition Lifecycle - status: unknown - '#transitions-as-enhancements': - title: 1.4 Transitions as an enhancement - status: unknown - '#rendering-model': - title: 1.5 Rendering Model - status: unknown - '#examples': - title: 1.6 Examples - status: unknown + status: n/a '#css-properties': title: 2 CSS properties - status: unknown + status: unsupported '#view-transition-name-prop': title: '2.1 Tagging Individually Transitioning Subtrees: the view-transition-name property' - status: unknown + status: unsupported + gaps: + - "'view-transition-name' isn't parsed" '#named-and-transitioning': title: 2.1.1 Rendering Consolidation - status: unknown + status: unsupported + gaps: + - "An element with a 'view-transition-name' other than none doesn't form a stacking context" '#pseudo': title: 3 Pseudo-elements - status: unknown + status: unsupported '#pseudo-root': title: 3.1 Pseudo-element Trees - status: unknown + status: n/a + gaps: + - The pseudo-element tree only exists during a view transition, which needs script '#view-transition-pseudos': title: 3.2 View Transition Pseudo-elements - status: unknown + status: unsupported + gaps: + - "The view transition pseudo-elements aren't recognized, a rule whose selector list contains one is dropped. Matching nothing is correct, they only exist during a view transition" '#named-view-transition-pseudo': title: 3.2.1 Named View Transition Pseudo-elements - status: unknown + status: unsupported '#view-transition-pseudo': title: '3.2.2 View Transition Tree Root: the ::view-transition pseudo-element' - status: unknown + status: unsupported '#::view-transition-group': title: '3.2.3 View Transition Named Subtree Root: the ::view-transition-group() pseudo-element' - status: unknown + status: unsupported '#::view-transition-image-pair': title: '3.2.4 View Transition Image Pair Isolation: the ::view-transition-image-pair() pseudo-element' - status: unknown + status: unsupported '#::view-transition-old': title: '3.2.5 View Transition Old State Image: the ::view-transition-old() pseudo-element' - status: unknown + status: unsupported '#::view-transition-new': title: '3.2.6 View Transition New State Image: the ::view-transition-new() pseudo-element' - status: unknown + status: unsupported '#view-transition-rendering': title: 4 View Transition Layout - status: unknown - '#snapshot-containing-block-concept': - title: 4.1 The Snapshot Containing Block - status: unknown - '#view-transition-stacking-layer': - title: 4.2 View Transition Painting Order - status: unknown + status: n/a + gaps: + - Only applies during a view transition, which needs script '#ua-styles': title: 5 User Agent Stylesheet - status: unknown + status: n/a + gaps: + - "Only styles the view transition pseudo-elements, the 'root' name on the root element changes nothing without a transition" '#api': title: 6 API - status: unknown - '#additions-to-document-api': - title: 6.1 Additions to Document - status: unknown - '#ViewTransition-prepare': - title: 6.1.1 startViewTransition() Method Steps - status: unknown - '#the-domtransition-interface': - title: 6.2 The ViewTransition interface - status: unknown - '#ViewTransition-skipTransition': - title: 6.2.1 skipTransition() Method Steps - status: unknown + status: n/a '#algorithms': title: 7 Algorithms - status: unknown - '#concepts': - title: 7.1 Data Structures - status: unknown - '#additions-to-document': - title: 7.1.1 Additions to Document - status: unknown - '#elements-concept': - title: 7.1.2 Additions to Elements - status: unknown - '#captured-elements': - title: 7.1.3 Captured elements - status: unknown - '#perform-pending-transition-operations-algorithm': - title: 7.2 Perform pending transition operations - status: unknown - '#setup-view-transition-algorithm': - title: 7.3 Setup view transition - status: unknown - '#capture-old-state-algorithm': - title: 7.3.1 Capture the old state - status: unknown - '#capture-new-state-algorithm': - title: 7.3.2 Capture the new state - status: unknown - '#setup-transition-pseudo-elements-algorithm': - title: 7.3.3 Setup transition pseudo-elements - status: unknown - '#call-dom-update-callback-algorithm': - title: 7.4 Call the update callback - status: unknown - '#skip-the-view-transition-algorithm': - title: 7.5 Skip the view transition - status: unknown - '#page-visibility-change-steps': - title: 7.6 View transition page-visibility change steps - status: unknown - '#capture-the-image-algorithm': - title: 7.7 Capture the image - status: unknown - '#capture-rendering-characteristics-algorithm': - title: 7.7.1 Capture rendering characteristics - status: unknown - '#handle-transition-frame-algorithm': - title: 7.8 Handle transition frame - status: unknown - '#style-transition-pseudo-elements-algorithm': - title: 7.9 Update pseudo-element styles - status: unknown - '#clear-view-transition-algorithm': - title: 7.10 Clear view transition - status: unknown + status: n/a From cc6c7b076d1b559d2d9ec6be4826d4e817c73ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:26:00 +0100 Subject: [PATCH 099/150] Verify and document CSS Writing Modes compliance --- .../HtmlRenderer/Core/Dom/CssLayoutEngine.cs | 16 +- .../BoxModel/BorderIntegrationTests.cs | 1 + .../BoxModel/WidthIntegrationTests.cs | 1 + .../BackgroundPaintIntegrationTests.cs | 13 ++ .../Text/DirectionIntegrationTests.cs | 5 + .../Text/LineBaselineIntegrationTests.cs | 2 + .../Text/LineHeightIntegrationTests.cs | 1 + .../CssModules/CssWritingModesTests.cs | 208 ++++++++++++++++++ Spec/css-writing-modes-4.yml | 155 +++++++------ 9 files changed, 321 insertions(+), 81 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssWritingModesTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs index 4b5b0b1cc..a9637f558 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs @@ -614,11 +614,8 @@ private static void ApplyHorizontalAlignment(RGraphics g, CssLineBox lineBox) } } - /// <summary> - /// Applies right to left direction to words - /// </summary> - /// <param name="blockBox"></param> - /// <param name="lineBox"></param> + // CSS Writing Modes 4 §2.4. Mirrors the word order instead of running the Unicode bidi algorithm, + // so every word counts as right-to-left. private static void ApplyRightToLeft(CssBox blockBox, CssLineBox lineBox) { if (blockBox.Direction == CssConstants.Rtl) @@ -637,10 +634,6 @@ private static void ApplyRightToLeft(CssBox blockBox, CssLineBox lineBox) } } - /// <summary> - /// Applies RTL direction to all the words on the line. - /// </summary> - /// <param name="line">the line to apply RTL to</param> private static void ApplyRightToLeftOnLine(CssLineBox line) { if (line.Words.Count > 0) @@ -657,11 +650,6 @@ private static void ApplyRightToLeftOnLine(CssLineBox line) } } - /// <summary> - /// Applies RTL direction to specific box words on the line. - /// </summary> - /// <param name="lineBox"></param> - /// <param name="box"></param> private static void ApplyRightToLeftOnSingleBox(CssLineBox lineBox, CssBox box) { int leftWordIdx = -1; diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs index 3fb2dc0d4..013e0f8b0 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs @@ -105,6 +105,7 @@ public void BorderShorthand_SetsAllFourSides() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "box.html#bidi-box-model")] + [Spec("css-writing-modes-4", "#bidi-fragment-boxes")] public void SplitRtlInline_PutsTheRightBorderOnTheFirstLine() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs index 088ce16c1..c804fd5b7 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs @@ -153,6 +153,7 @@ public void AutoMarginLeft_TakesTheRemainingSpace() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#blockwidth")] + [Spec("css-writing-modes-4", "#logical-direction-layout")] public void OverConstrained_InRtl_IgnoresMarginLeft() { var root = Layout("<div style='width:200px;direction:rtl'><div id='c' style='width:100px;margin:0 10px;height:5px'></div></div>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs index 4fa00ccbf..3b746f167 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs @@ -231,6 +231,19 @@ public void BackgroundPosition_Keywords(string position, double x, double y) Assert.AreEqual(y, image.DestRect.Y, 0.5); } + [TestMethod] + [DataRow("left top", 0, 0)] + [DataRow("right bottom", 60, 70)] + [Spec("css-writing-modes-4", "#physical-only")] + public void BackgroundPosition_StaysPhysicalInRtl(string position, double x, double y) + { + var g = PaintBackground($"direction:rtl;width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-position:{position}"); + + var image = g.DrawImageCalls.Single(); + Assert.AreEqual(x, image.DestRect.X, 0.5); + Assert.AreEqual(y, image.DestRect.Y, 0.5); + } + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [DataRow("50% 50%", 30, 35)] diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/DirectionIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/DirectionIntegrationTests.cs index f0f4dc34c..d5c91f5ad 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/DirectionIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/DirectionIntegrationTests.cs @@ -9,6 +9,7 @@ public sealed class DirectionIntegrationTests { [TestMethod] [Spec("css21", "visuren.html#direction")] + [Spec("css-writing-modes-4", "#direction")] public void Direction_IsInherited() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div style='direction:rtl'><p id='p'>text</p></div>")); @@ -18,6 +19,8 @@ public void Direction_IsInherited() [TestMethod] [Spec("css21", "visuren.html#direction")] + [Spec("css-writing-modes-4", "#bidi-para-direction")] + [Spec("css-writing-modes-4", "#logical-direction-layout")] public void RightToLeftWords_InAnRtlBlock_RunFromRightToLeft() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -32,6 +35,7 @@ public void RightToLeftWords_InAnRtlBlock_RunFromRightToLeft() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#direction")] + [Spec("css-writing-modes-4", "#bidi-algo")] public void LeftToRightWords_InAnRtlBlock_KeepTheirOrder() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -43,6 +47,7 @@ public void LeftToRightWords_InAnRtlBlock_KeepTheirOrder() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#direction")] + [Spec("css-writing-modes-4", "#bidi-control-codes")] public void DirectionOnAnInline_WithoutUnicodeBidi_DoesNotReorder() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs index d45cce0e8..b5d787f81 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs @@ -15,6 +15,7 @@ public sealed class LineBaselineIntegrationTests [TestMethod] [Spec("css21", "visudet.html#leading")] + [Spec("css-writing-modes-4", "#baseline-alignment")] public void SmallerText_SitsOnTheBaselineOfTheLargerText() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -28,6 +29,7 @@ public void SmallerText_SitsOnTheBaselineOfTheLargerText() [TestMethod] [Spec("css21", "visudet.html#leading")] + [Spec("css-writing-modes-4", "#text-baselines")] public void SmallerText_SitsOnTheBaselineOfTheBlocksOwnFont() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineHeightIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineHeightIntegrationTests.cs index 47dccd7ff..4356496ef 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineHeightIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineHeightIntegrationTests.cs @@ -71,6 +71,7 @@ public void InlineLineHeight_MakesTheLineTaller() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#line-height")] + [Spec("css-writing-modes-4", "#replaced-baselines")] public void Image_SitsOnTheBaseline() { var root = TestImages.Layout($"<div id='p' style='font-size:20px'><span id='t'>x</span><img id='i' src='{Image}'></div>"); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssWritingModesTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssWritingModesTests.cs new file mode 100644 index 000000000..ce219d056 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssWritingModesTests.cs @@ -0,0 +1,208 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssWritingModesTests +{ + private const double Delta = 0.5; + + [TestMethod] + [Spec("css-writing-modes-4", "#direction")] + [DataRow("ltr")] + [DataRow("rtl")] + public void Direction_IsParsed(string value) + { + Assert.AreEqual(value, Value("direction", value)); + } + + [TestMethod] + [Spec("css-writing-modes-4", "#direction")] + public void DirAttribute_SetsTheDirection() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div dir='rtl'><p id='p'>a</p></div>")); + + Assert.AreEqual("rtl", LayoutHarness.FindById(root, "p")!.Direction); + } + + [TestMethod] + [Spec("css-writing-modes-4", "#unicode-bidi")] + [DataRow("normal")] + [DataRow("embed")] + [DataRow("isolate")] + [DataRow("bidi-override")] + [DataRow("isolate-override")] + [DataRow("plaintext")] + public void UnicodeBidi_IsParsed(string value) + { + Assert.AreEqual(value, Value("unicode-bidi", value)); + } + + [Ignore("Gap, see Spec/css-writing-modes-4.yml")] + [TestMethod] + [Spec("css-writing-modes-4", "#unicode-bidi")] + [Spec("css-writing-modes-4", "#bidi-para-direction")] + public void Plaintext_TakesTheParagraphDirectionFromTheText() + { + var root = Layout("<div style='direction:rtl;unicode-bidi:plaintext;width:300px'>one two</div>"); + + Assert.IsLessThan(Word(root, "two").Left, Word(root, "one").Left); + } + + [Ignore("Gap, see Spec/css-writing-modes-4.yml")] + [TestMethod] + [Spec("css-writing-modes-4", "#unicode-bidi")] + [Spec("css-writing-modes-4", "#bidi-control-codes")] + public void IsolatedLtrSpan_InAnRtlBlock_KeepsItsWordOrder() + { + var root = Layout("<div style='direction:rtl;width:300px'>אבג <span style='direction:ltr;unicode-bidi:isolate'>one two</span> דהו</div>"); + + Assert.IsLessThan(Word(root, "two").Left, Word(root, "one").Left); + } + + [TestMethod] + [Spec("css-writing-modes-4", "#bidi-atomic-inlines")] + public void Image_InAnRtlBlock_IsReorderedLikeAWord() + { + var root = Layout("<div style='direction:rtl;width:300px'><img id='i' style='width:20px;height:10px'> אבג</div>"); + var image = LayoutHarness.FindById(root, "i")!.Words.Single(); + + Assert.IsGreaterThan(Word(root, "אבג").Right, image.Left); + } + + [Ignore("Gap, see Spec/css-writing-modes-4.yml")] + [TestMethod] + [Spec("css-writing-modes-4", "#bidi-atomic-inlines")] + public void InlineBlock_InAnRtlBlock_IsReorderedLikeAWord() + { + var root = Layout("<div style='direction:rtl;width:300px'><span id='b' style='display:inline-block;width:20px;height:10px'></span> אבג</div>"); + + Assert.IsGreaterThan(Word(root, "אבג").Right, LayoutHarness.FindById(root, "b")!.Location.X); + } + + [Ignore("Gap, see Spec/css-writing-modes-4.yml")] + [TestMethod] + [Spec("css-writing-modes-4", "#block-flow")] + [DataRow("horizontal-tb")] + [DataRow("vertical-rl")] + [DataRow("vertical-lr")] + [DataRow("sideways-rl")] + [DataRow("sideways-lr")] + public void WritingMode_IsParsed(string value) + { + Assert.AreEqual(value, Value("writing-mode", value)); + } + + [Ignore("Gap, see Spec/css-writing-modes-4.yml")] + [TestMethod] + [Spec("css-writing-modes-4", "#block-flow")] + [Spec("css-writing-modes-4", "#vertical-layout")] + public void VerticalRl_StacksBlocksFromRightToLeft() + { + var root = Layout("<div style='writing-mode:vertical-rl;width:200px;height:100px'><div id='a' style='width:20px'></div><div id='b' style='width:20px'></div></div>"); + + Assert.IsLessThan(LayoutHarness.FindById(root, "a")!.Location.X, LayoutHarness.FindById(root, "b")!.Location.X); + } + + [Ignore("Gap, see Spec/css-writing-modes-4.yml")] + [TestMethod] + [Spec("css-writing-modes-4", "#text-orientation")] + [DataRow("mixed")] + [DataRow("upright")] + [DataRow("sideways")] + public void TextOrientation_IsParsed(string value) + { + Assert.AreEqual(value, Value("text-orientation", value)); + } + + [Ignore("Gap, see Spec/css-writing-modes-4.yml")] + [TestMethod] + [Spec("css-writing-modes-4", "#text-combine-upright")] + [DataRow("none")] + [DataRow("all")] + public void TextCombineUpright_IsParsed(string value) + { + Assert.AreEqual(value, Value("text-combine-upright", value)); + } + + [TestMethod] + [Spec("css-writing-modes-4", "#logical-to-physical")] + [Spec("css-logical-1", "#margin-properties")] + public void FlowRelativeMargins_MapToThePhysicalSidesInHorizontalLtr() + { + var box = Styled("#x{margin-block-start:1px;margin-inline-end:2px;margin-block-end:3px;margin-inline-start:4px}"); + + Assert.AreEqual(1, box.ActualMarginTop, Delta); + Assert.AreEqual(2, box.ActualMarginRight, Delta); + Assert.AreEqual(3, box.ActualMarginBottom, Delta); + Assert.AreEqual(4, box.ActualMarginLeft, Delta); + } + + [Ignore("Gap, see Spec/css-writing-modes-4.yml")] + [TestMethod] + [Spec("css-writing-modes-4", "#logical-to-physical")] + [Spec("css-logical-1", "#margin-properties")] + public void MarginInlineStart_IsTheRightMarginInRtl() + { + var box = Styled("#x{direction:rtl;margin-inline-start:4px}"); + + Assert.AreEqual(4, box.ActualMarginRight, Delta); + Assert.AreEqual(0, box.ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css-logical-1", "#box")] + public void PhysicalMargin_StaysOnItsSideInRtl() + { + var root = Layout("<div style='direction:rtl;width:200px'><div id='x' style='margin-left:30px;height:10px'></div></div>"); + var box = LayoutHarness.FindById(root, "x")!; + + Assert.AreEqual(30, box.Location.X, Delta); + Assert.AreEqual(170, box.Size.Width, Delta); + } + + [TestMethod] + [Spec("css-writing-modes-4", "#replaced-baselines")] + public void InlineBlock_IsAlignedByTheBaselineOfItsLastLine() + { + var root = Layout("<div style='width:300px'>x <span style='display:inline-block;padding-top:20px'>y</span></div>"); + + Assert.AreEqual(Word(root, "x").Top, Word(root, "y").Top, Delta); + } + + [TestMethod] + [Spec("css-writing-modes-4", "#line-mappings")] + public void TextAlignLeft_IsTheLineLeftInRtl() + { + var root = Layout("<div style='direction:rtl;text-align:left;width:200px'>אבג</div>"); + + Assert.AreEqual(0, Word(root, "אבג").Left, Delta); + } + + private static CssBox Layout(string body) => + LayoutHarness.Layout($"<html><head></head><body style='margin:0'>{body}</body></html>", 800, 600).Root; + + private static CssBox Styled(string css, string body = "<div id=x>a</div>") + { + var (root, _) = LayoutHarness.Layout($"<html><head><style>{css}</style></head><body style='margin:0'>{body}</body></html>", 800, 600); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } + + private static CssRect Word(CssBox root, string text) + { + var word = LayoutHarness.Descendants(root).SelectMany(b => b.Words).FirstOrDefault(w => w.Text == text); + Assert.IsNotNull(word, $"No word '{text}'"); + return word; + } + + private static string Value(string property, string value) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ {property}: {value}; }}"); + return sheet.Rules.OfType<StyleRule>().Single().Style.GetPropertyValue(property); + } +} diff --git a/Spec/css-writing-modes-4.yml b/Spec/css-writing-modes-4.yml index 8699b1eb6..898cbace5 100644 --- a/Spec/css-writing-modes-4.yml +++ b/Spec/css-writing-modes-4.yml @@ -5,172 +5,193 @@ snapshot: https://www.w3.org/TR/2019/CR-css-writing-modes-4-20190730/ sections: '#text-flow': title: 1 Introduction to Writing Modes - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Value Types and Terminology - status: unknown + status: n/a '#text-direction': title: 2 Inline Direction and Bidirectionality - status: unknown + status: partial '#direction': title: '2.1 Specifying Directionality: the direction property' - status: unknown + status: partial + gaps: + - No Unicode bidirectional algorithm, see 2.4 '#unicode-bidi': title: '2.2 Embeddings and Overrides: the unicode-bidi property' - status: unknown + status: parsed-only + gaps: + - unicode-bidi is ignored, embed, isolate, bidi-override and plaintext don't change the reordering '#bidi-example': title: 2.3 Example of Bidirectional Text - status: unknown + status: n/a '#bidi-algo': title: 2.4 Applying the Bidirectional Reordering Algorithm - status: unknown + status: partial + gaps: + - No Unicode bidirectional algorithm, rtl reverses the order of all words on a line, left-to-right runs included, see CSS 2.1 9.10 '#bidi-para-direction': title: 2.4.1 Bidi Paragraph Embedding Levels - status: unknown + status: partial + gaps: + - plaintext doesn't take the paragraph direction from the text '#bidi-control-codes': title: 2.4.2 CSS–Unicode Bidi Control Translation, Text Reordering - status: unknown + status: unsupported + gaps: + - An inline element's direction reorders its words although unicode-bidi is normal, see CSS 2.1 9.10 + - isolate and embed don't keep an ltr run together in an rtl line '#bidi-atomic-inlines': title: 2.4.3 Bidi Treatment of Atomic Inlines - status: unknown + status: partial + gaps: + - An inline-block keeps its place in an rtl line instead of being reordered with the words '#bidi-embedding-breaks': title: 2.4.4 Paragraph Breaks Within Embeddings and Isolates - status: unknown + status: unsupported '#bidi-box-model': title: 2.4.5 Reordering-induced Box Fragmentation - status: unknown + status: unsupported '#bidi-fragmentation': title: 2.4.5.1 Conditions of Reordering-induced Box Fragmentation - status: unknown + status: unsupported '#bidi-fragment-boxes': title: 2.4.5.2 Box Model of Reordering-induced Box Fragments - status: unknown + status: unsupported + gaps: + - A split inline box in rtl text gets its left border on the first line, see CSS 2.1 8.6 '#vertical-modes': title: 3 Vertical Writing Modes - status: unknown + status: unsupported '#vertical-intro': title: 3.1 Introduction to Vertical Writing - status: unknown + status: n/a '#block-flow': title: '3.2 Block Flow Direction: the writing-mode property' - status: unknown + status: unsupported + gaps: + - writing-mode isn't accepted, layout is always horizontal-tb '#svg-writing-mode': title: 3.2.1 Obsolete SVG1.1 writing-mode Values - status: unknown + status: unsupported '#svg-writing-mode-css': title: 3.2.1.1 Supporting SVG1.1 writing-mode values in CSS syntax - status: unknown + status: unsupported '#svg-writing-mode-markup': title: 3.2.1.2 Supporting SVG1.1 writing-mode values in presentational attributes - status: unknown + status: unsupported '#inline-alignment': title: 4 Inline-level Alignment - status: unknown + status: partial '#intro-baselines': title: 4.1 Introduction to Baselines - status: unknown + status: n/a '#text-baselines': title: 4.2 Text Baselines - status: unknown + status: partial + gaps: + - Only horizontal text, so only the alphabetic baseline is used '#replaced-baselines': title: 4.3 Atomic Inline Baselines - status: unknown + status: partial + gaps: + - Images are aligned with the top of the line instead of sitting on the baseline, see CSS 2.1 10.8 '#baseline-alignment': title: 4.4 Baseline Alignment - status: unknown + status: partial + gaps: + - vertical-align values other than baseline align like baseline or the top of the line, see CSS 2.1 10.8.1 '#intro-text-layout': title: 5 Introduction to Vertical Text Layout - status: unknown + status: unsupported '#text-orientation': title: '5.1 Orienting Text: the text-orientation property' - status: unknown + status: unsupported + gaps: + - text-orientation isn't accepted '#vertical-font-features': title: 5.1.1 Vertical Typesetting and Font Features - status: unknown + status: unsupported '#vertical-orientations': title: 5.1.2 Mixed Vertical Orientations - status: unknown + status: unsupported '#glyph-orientation': title: '5.1.3 Obsolete: the SVG1.1 glyph-orientation-vertical property' - status: unknown + status: unsupported '#abstract-box': title: 6 Abstract Box Terminology - status: unknown - '#abstract-axes': - title: 6.1 Abstract Dimensions - status: unknown - '#logical-directions': - title: 6.2 Flow-relative Directions - status: unknown - '#line-directions': - title: 6.3 Line-relative Directions - status: unknown + status: n/a '#logical-to-physical': title: 6.4 Abstract-to-Physical Mappings - status: unknown + status: partial + gaps: + - Flow-relative properties always map as horizontal-tb ltr, also when direction is rtl, see css-logical-1 '#abstract-layout': title: 7 Abstract Box Layout - status: unknown + status: partial '#vertical-layout': title: 7.1 Principles of Layout in Vertical Writing Modes - status: unknown + status: unsupported '#dimension-mapping': title: 7.2 Dimensional Mapping - status: unknown + status: unsupported '#orthogonal-flows': title: 7.3 Orthogonal Flows - status: unknown + status: unsupported '#orthogonal-auto': title: 7.3.1 Available Space in Orthogonal Flows - status: unknown + status: unsupported '#auto-multicol': title: 7.3.2 Auto-sizing Block Containers in Orthogonal Flows - status: unknown + status: unsupported '#orthogonal-layout': title: 7.3.3 Auto-sizing Other Orthogonal Flow Roots - status: unknown + status: unsupported '#orthogonal-pagination': title: 7.3.4 Fragmenting Orthogonal Flows - status: unknown + status: unsupported '#logical-direction-layout': title: 7.4 Flow-Relative Mappings - status: unknown + status: partial + gaps: + - In rtl an over-constrained block ignores margin-right instead of margin-left, see CSS 2.1 10.3.3 + - The initial text-align acts as left in rtl, see CSS 2.1 16.2 '#line-mappings': title: 7.5 Line-Relative Mappings - status: unknown + status: supported '#physical-only': title: 7.6 Purely Physical Mappings - status: unknown + status: supported '#principal-flow': title: 8 The Principal Writing Mode - status: unknown + status: unsupported '#icb': title: 8.1 Propagation to the Initial Containing Block - status: unknown + status: unsupported + gaps: + - Neither writing-mode nor direction of the root or body is propagated to the initial containing block '#page-direction': title: '8.2 Page Flow: the page progression direction' - status: unknown + status: unsupported + gaps: + - Pages aren't classified as left or right pages, see css-page-3 '#text-combine': title: 9 Glyph Composition - status: unknown + status: unsupported '#text-combine-upright': title: '9.1 Horizontal-in-Vertical Composition: the text-combine-upright property' - status: unknown + status: unsupported + gaps: + - text-combine-upright isn't accepted '#text-combine-runs': title: 9.1.1 Text Run Rules - status: unknown + status: unsupported '#text-combine-layout': title: 9.1.2 Layout Rules - status: unknown + status: unsupported '#text-combine-compression': title: 9.1.3 Compression Rules - status: unknown + status: unsupported '#text-combine-fullwidth': title: 9.1.3.1 Full-width Characters - status: unknown + status: unsupported '#priv-sec': title: 10 Privacy and Security Considerations - status: unknown + status: n/a From cdcb6c2f9e7d8dad380fe82862b5899d0d0c4547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:31:07 +0100 Subject: [PATCH 100/150] Verify and document CSS Logical Properties compliance --- .../CssEngine/Enumerations/PropertyNames.cs | 5 +- .../CssEngine/Factories/PropertyFactory.cs | 27 +-- .../CssModules/CssPositionTests.cs | 3 + .../CssModules/CssSizingTests.cs | 3 + .../Css/CssText3PropertyTests.cs | 1 + .../Css/LogicalPropertySerializationTests.cs | 19 +- .../CssModules/CssLogicalTests.cs | 228 ++++++++++++++++++ Spec/css-logical-1.yml | 73 ++++-- 8 files changed, 292 insertions(+), 67 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssLogicalTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/Enumerations/PropertyNames.cs b/Source/HtmlRenderer/Core/CssEngine/Enumerations/PropertyNames.cs index 518dd65fa..c8ffd137f 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Enumerations/PropertyNames.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Enumerations/PropertyNames.cs @@ -272,10 +272,7 @@ internal static class PropertyNames public static readonly string ObjectPosition = "object-position"; public static readonly string Size = "size"; - // CSS Logical Properties and Values Level 1. Layout here is always LTR / horizontal-tb, - // so each logical property maps 1:1 to a physical one (block-start = top, - // block-end = bottom, inline-start = left, inline-end = right) and is registered to - // produce/export the existing physical longhands. + // CSS Logical 1 §4 public static readonly string MarginBlock = "margin-block"; public static readonly string MarginBlockStart = "margin-block-start"; public static readonly string MarginBlockEnd = "margin-block-end"; diff --git a/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs b/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs index 3096bbd66..f1ba34662 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs @@ -18,11 +18,8 @@ internal sealed class PropertyFactory private readonly Dictionary<string, ShorthandCreator> _shorthandsBuilder = new Dictionary<string, ShorthandCreator>(StringComparer.OrdinalIgnoreCase); - // Shorthands that parse and expand normally but are excluded from serialization reconstruction: - // the logical shorthands, whose longhands are the physical ones (margin-top/-bottom/…), so letting - // the serializer collapse e.g. margin-top+margin-bottom into `margin-block` would change existing - // output. Excluded from GetShorthands (the serialization query) only; CreateShorthand, - // GetLonghands and IsShorthand still work. + // Shorthands that parse and expand but are never rebuilt from their longhands when serializing: + // the flow-relative ones, whose longhands are the physical ones, and grid/grid-template. private readonly HashSet<string> _logicalShorthands = new HashSet<string>(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, LonghandCreator> _fonts; @@ -407,18 +404,12 @@ private PropertyFactory() AddShorthand(PropertyNames.PlaceSelf, () => new PlaceSelfProperty(), PropertyNames.AlignSelf, PropertyNames.JustifySelf); - // CSS Logical Properties and Values Level 1. Layout here is always LTR / horizontal-tb, - // so each logical property is registered to produce/export the existing physical - // longhands (block-start = top, block-end = bottom, inline-start = left, - // inline-end = right). Nothing downstream needs to change, since it already handles - // every physical longhand. - - // Logical margin longhands (aliases to the physical longhand classes). + // CSS Logical 1 §4. The flow-relative properties are aliases of the physical ones, mapped as + // horizontal-tb ltr whatever the element's direction. AddLonghand(PropertyNames.MarginBlockStart, () => new MarginTopProperty(), true); AddLonghand(PropertyNames.MarginBlockEnd, () => new MarginBottomProperty(), true); AddLonghand(PropertyNames.MarginInlineStart, () => new MarginLeftProperty(), true); AddLonghand(PropertyNames.MarginInlineEnd, () => new MarginRightProperty(), true); - // Logical margin shorthands. AddLogicalShorthand(PropertyNames.MarginBlock, () => new MarginBlockProperty(), PropertyNames.MarginTop, PropertyNames.MarginBottom); @@ -426,12 +417,10 @@ private PropertyFactory() PropertyNames.MarginLeft, PropertyNames.MarginRight); - // Logical padding longhands (aliases to the physical longhand classes). AddLonghand(PropertyNames.PaddingBlockStart, () => new PaddingTopProperty(), true); AddLonghand(PropertyNames.PaddingBlockEnd, () => new PaddingBottomProperty(), true); AddLonghand(PropertyNames.PaddingInlineStart, () => new PaddingLeftProperty(), true); AddLonghand(PropertyNames.PaddingInlineEnd, () => new PaddingRightProperty(), true); - // Logical padding shorthands. AddLogicalShorthand(PropertyNames.PaddingBlock, () => new PaddingBlockProperty(), PropertyNames.PaddingTop, PropertyNames.PaddingBottom); @@ -439,12 +428,10 @@ private PropertyFactory() PropertyNames.PaddingLeft, PropertyNames.PaddingRight); - // Logical inset longhands (aliases to the physical coordinate longhand classes). AddLonghand(PropertyNames.InsetBlockStart, () => new TopProperty(), true); AddLonghand(PropertyNames.InsetBlockEnd, () => new BottomProperty(), true); AddLonghand(PropertyNames.InsetInlineStart, () => new LeftProperty(), true); AddLonghand(PropertyNames.InsetInlineEnd, () => new RightProperty(), true); - // Inset shorthands. AddLogicalShorthand(PropertyNames.Inset, () => new InsetProperty(), PropertyNames.Top, PropertyNames.Right, @@ -457,7 +444,6 @@ private PropertyFactory() PropertyNames.Left, PropertyNames.Right); - // Logical per-edge border longhands (aliases to the physical longhand classes). AddLonghand(PropertyNames.BorderBlockStartWidth, () => new BorderTopWidthProperty(), true); AddLonghand(PropertyNames.BorderBlockStartStyle, () => new BorderTopStyleProperty()); AddLonghand(PropertyNames.BorderBlockStartColor, () => new BorderTopColorProperty(), true); @@ -471,7 +457,6 @@ private PropertyFactory() AddLonghand(PropertyNames.BorderInlineEndStyle, () => new BorderRightStyleProperty()); AddLonghand(PropertyNames.BorderInlineEndColor, () => new BorderRightColorProperty(), true); - // Logical per-edge border shorthands (aliases to the physical edge shorthand classes). AddLogicalShorthand(PropertyNames.BorderBlockStart, () => new BorderTopProperty(), PropertyNames.BorderTopWidth, PropertyNames.BorderTopStyle, @@ -489,7 +474,6 @@ private PropertyFactory() PropertyNames.BorderRightStyle, PropertyNames.BorderRightColor); - // Logical two-edge border shorthands (both block/inline edges the same value). AddLogicalShorthand(PropertyNames.BorderBlock, () => new BorderBlockProperty(), PropertyNames.BorderTopWidth, PropertyNames.BorderTopStyle, @@ -505,7 +489,6 @@ private PropertyFactory() PropertyNames.BorderRightStyle, PropertyNames.BorderRightColor); - // Logical two-edge border width/style/color shorthands. AddLogicalShorthand(PropertyNames.BorderBlockWidth, () => new BorderBlockWidthProperty(), PropertyNames.BorderTopWidth, PropertyNames.BorderBottomWidth); @@ -554,8 +537,6 @@ private void AddShorthand(string name, ShorthandCreator creator, params string[] _mappingsBuilder.Add(name, longhands); } - // A logical shorthand: registered for parsing/expansion, but excluded from serialization - // reconstruction (see the _logicalShorthands field comment). private void AddLogicalShorthand(string name, ShorthandCreator creator, params string[] longhands) { AddShorthand(name, creator, longhands); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssPositionTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssPositionTests.cs index 7fb17bc4a..e5480230f 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssPositionTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssPositionTests.cs @@ -75,6 +75,7 @@ public void Inset_SetsTheFourPhysicalInsetsLikeMargin() [TestMethod] [Spec("css-position-3", "#inset-shorthands")] + [Spec("css-logical-1", "#position-properties")] public void InsetBlockAndInsetInline_SetTheirAxis() { var root = Layout(string.Format(Container, "<div id='a' style='position:absolute;inset-block:10px auto;inset-inline:20px auto;width:30px;height:30px'></div>")); @@ -85,6 +86,7 @@ public void InsetBlockAndInsetInline_SetTheirAxis() [TestMethod] [Spec("css-position-3", "#insets")] + [Spec("css-logical-1", "#position-properties")] public void FlowRelativeInsets_MapToThePhysicalSidesInLtr() { var root = Layout(string.Format(Container, "<div id='a' style='position:absolute;inset-block-end:10px;inset-inline-end:20px;width:30px;height:30px'></div>")); @@ -96,6 +98,7 @@ public void FlowRelativeInsets_MapToThePhysicalSidesInLtr() [Ignore("Gap, see Spec/css-position-3.yml")] [TestMethod] [Spec("css-position-3", "#insets")] + [Spec("css-logical-1", "#position-properties")] public void InsetInlineStart_IsTheRightSideInRtl() { var root = Layout("<div style='position:relative;direction:rtl;width:200px;height:100px'>" diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs index 9f097afa1..66c4b85b8 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs @@ -66,6 +66,7 @@ public void BorderBox_AppliesToMaxHeight() [DataRow("inline-size:100px", "width")] [DataRow("block-size:40px", "height")] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-logical-1", "#dimension-properties")] public void FlowRelativePreferredSize_MapsToThePhysicalSize(string style, string axis) { var root = Layout($"<div style='width:300px'><div id='b' style='{style}'><div style='height:10px'></div></div></div>"); @@ -77,6 +78,7 @@ public void FlowRelativePreferredSize_MapsToThePhysicalSize(string style, string [Ignore("Gap, see Spec/css-sizing-3.yml")] [TestMethod] [Spec("css-sizing-3", "#min-size-properties")] + [Spec("css-logical-1", "#dimension-properties")] public void MinBlockSize_GrowsTheBox() { var root = Layout("<div id='b' style='min-block-size:40px'></div>"); @@ -87,6 +89,7 @@ public void MinBlockSize_GrowsTheBox() [Ignore("Gap, see Spec/css-sizing-3.yml")] [TestMethod] [Spec("css-sizing-3", "#max-size-properties")] + [Spec("css-logical-1", "#dimension-properties")] public void MaxInlineSize_LimitsTheBox() { var root = Layout("<div style='width:300px'><div id='b' style='max-inline-size:100px;height:10px'></div></div>"); diff --git a/Source/Test/HtmlRenderer.Test/Css/CssText3PropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssText3PropertyTests.cs index 1ccfd129b..7046f95cc 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CssText3PropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CssText3PropertyTests.cs @@ -37,6 +37,7 @@ public void TextTransform_FullSizeKanaAndCombinations_AreParsed(string value) [DataRow("end")] [DataRow("match-parent")] [DataRow("justify-all")] + [Spec("css-logical-1", "#text-align")] public void TextAlign_Text3Keywords_AreParsed(string value) { Assert.AreEqual(value, Value("text-align", value)); diff --git a/Source/Test/HtmlRenderer.Test/Css/LogicalPropertySerializationTests.cs b/Source/Test/HtmlRenderer.Test/Css/LogicalPropertySerializationTests.cs index 8952e3eca..4664b4765 100644 --- a/Source/Test/HtmlRenderer.Test/Css/LogicalPropertySerializationTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/LogicalPropertySerializationTests.cs @@ -6,17 +6,8 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/LogicalPropertySerializationTests.cs. -/// PeachPDF resolves a logical box-model shorthand (e.g. <c>margin-block</c>) to its own *logical* -/// longhands (margin-block-start/margin-block-end) at parse time, deferring physical-edge resolution to -/// layout (against the box's own direction/writing-mode). HTML-Renderer's CSS engine port takes a -/// different, simpler path: since this renderer is always LTR / horizontal-tb (confirmed in -/// Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs:414-419), each logical property is -/// registered directly against the existing *physical* longhand classes (block-start = top, block-end = -/// bottom, inline-start = left, inline-end = right) - so margin-block expands straight to margin-top/ -/// margin-bottom, not to margin-block-start/margin-block-end. The second test below is adapted -/// accordingly; the other two (which only assert about physical properties / the border shorthand) port -/// unchanged. +/// The flow-relative properties expand to the physical longhands at parse time, so serialization must +/// not fold physical longhands back into a flow-relative shorthand. /// </summary> [TestClass] public sealed class LogicalPropertySerializationTests @@ -38,22 +29,16 @@ public void LogicalShorthand_ParsesAndExpandsToPhysicalLonghands() var sheet = ParseStyleSheet("div { margin-block: 3pt 4pt; }"); var style = (StyleDeclaration)sheet.Rules.OfType<StyleRule>().Single().Style; - // HTML-Renderer resolves logical properties to physical classes at registration time (LTR / - // horizontal-tb only design - PropertyFactory.cs:414-419), so margin-block expands directly to - // margin-top/margin-bottom rather than to the logical longhands margin-block-start/-end. Assert.AreEqual("3pt", style.GetPropertyValue("margin-top")); Assert.AreEqual("4pt", style.GetPropertyValue("margin-bottom")); Assert.AreEqual(string.Empty, style.GetPropertyValue("margin-block-start")); Assert.AreEqual(string.Empty, style.GetPropertyValue("margin-block-end")); - // And the serialized form uses the physical longhands, never `margin-block`. Assert.IsFalse(sheet.ToCss().Contains("margin-block:")); } [TestMethod] public void PhysicalBorderStillReconstructs() { - // The physical `border` shorthand must still reconstruct - the logical exclusion is scoped to the - // logical shorthands only. var sheet = ParseStyleSheet( "div { border-top: 1pt solid red; border-right: 1pt solid red; border-bottom: 1pt solid red; border-left: 1pt solid red; }"); var css = sheet.ToCss(); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssLogicalTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssLogicalTests.cs new file mode 100644 index 000000000..f0cdc386e --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssLogicalTests.cs @@ -0,0 +1,228 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssLogicalTests +{ + private const double Delta = 0.5; + + [Ignore("Gap, see Spec/css-logical-1.yml")] + [TestMethod] + [Spec("css-logical-1", "#float-clear")] + [DataRow("float", "inline-start")] + [DataRow("float", "inline-end")] + [DataRow("clear", "inline-start")] + [DataRow("clear", "inline-end")] + public void FloatAndClear_FlowRelativeValues_AreParsed(string property, string value) + { + Assert.AreEqual(value, Value(property, value)); + } + + [Ignore("Gap, see Spec/css-logical-1.yml")] + [TestMethod] + [Spec("css-logical-1", "#float-clear")] + public void FloatInlineEnd_FloatsToTheRightInLtr() + { + var root = Layout("<div style='width:200px'><div id='x' style='float:inline-end;width:50px;height:10px'></div></div>"); + + Assert.AreEqual(150, LayoutHarness.FindById(root, "x")!.Location.X, Delta); + } + + [Ignore("Gap, see Spec/css-logical-1.yml")] + [TestMethod] + [Spec("css-logical-1", "#page")] + [DataRow("break-before", "recto")] + [DataRow("break-after", "verso")] + [DataRow("page-break-before", "recto")] + public void RectoAndVerso_AreParsed(string property, string value) + { + Assert.AreEqual(value, Value(property, value)); + } + + [TestMethod] + [Spec("css-logical-1", "#box")] + public void FlowRelativeAndPhysicalDeclarations_CascadeTogetherInDeclarationOrder() + { + var box = Styled("#x{margin-inline-start:1px;margin-left:2px;margin-inline-end:3px;margin-right:4px;padding-block-start:5px}#x{padding-top:6px}"); + + Assert.AreEqual(2, box.ActualMarginLeft, Delta); + Assert.AreEqual(4, box.ActualMarginRight, Delta); + Assert.AreEqual(6, box.ActualPaddingTop, Delta); + } + + [TestMethod] + [Spec("css-logical-1", "#margin-properties")] + public void MarginShorthands_SetBothSidesOfTheirAxis() + { + var box = Styled("#x{margin-block:1px 2px;margin-inline:3px}"); + + Assert.AreEqual(1, box.ActualMarginTop, Delta); + Assert.AreEqual(2, box.ActualMarginBottom, Delta); + Assert.AreEqual(3, box.ActualMarginLeft, Delta); + Assert.AreEqual(3, box.ActualMarginRight, Delta); + } + + [TestMethod] + [Spec("css-logical-1", "#margin-properties")] + public void MarginInlineAuto_SetsBothInlineMarginsToAuto() + { + var sheet = CssConstructionFunctions.ParseStyleSheet(".x { margin-inline: auto; }"); + var style = sheet.Rules.OfType<StyleRule>().Single().Style; + + Assert.AreEqual("auto", style.GetPropertyValue("margin-left")); + Assert.AreEqual("auto", style.GetPropertyValue("margin-right")); + } + + [TestMethod] + [Spec("css-logical-1", "#padding-properties")] + public void FlowRelativePadding_MapsToThePhysicalSidesInLtr() + { + var box = Styled("#x{padding-block-start:1px;padding-inline-end:2px;padding-block-end:3px;padding-inline-start:4px}"); + + Assert.AreEqual(1, box.ActualPaddingTop, Delta); + Assert.AreEqual(2, box.ActualPaddingRight, Delta); + Assert.AreEqual(3, box.ActualPaddingBottom, Delta); + Assert.AreEqual(4, box.ActualPaddingLeft, Delta); + } + + [TestMethod] + [Spec("css-logical-1", "#padding-properties")] + public void PaddingShorthands_SetBothSidesOfTheirAxis() + { + var box = Styled("#x{padding-block:1px 2px;padding-inline:3px 4px}"); + + Assert.AreEqual(1, box.ActualPaddingTop, Delta); + Assert.AreEqual(2, box.ActualPaddingBottom, Delta); + Assert.AreEqual(3, box.ActualPaddingLeft, Delta); + Assert.AreEqual(4, box.ActualPaddingRight, Delta); + } + + [Ignore("Gap, see Spec/css-logical-1.yml")] + [TestMethod] + [Spec("css-logical-1", "#padding-properties")] + public void PaddingInlineStart_IsTheRightPaddingInRtl() + { + var box = Styled("#x{direction:rtl;padding-inline-start:4px}"); + + Assert.AreEqual(4, box.ActualPaddingRight, Delta); + Assert.AreEqual(0, box.ActualPaddingLeft, Delta); + } + + [TestMethod] + [Spec("css-logical-1", "#border-width")] + public void FlowRelativeBorderWidths_MapToThePhysicalSidesInLtr() + { + var box = Styled("#x{border-style:solid;border-block-start-width:1px;border-inline-end-width:2px;border-block-end-width:3px;border-inline-start-width:4px}"); + + Assert.AreEqual(1, box.ActualBorderTopWidth, Delta); + Assert.AreEqual(2, box.ActualBorderRightWidth, Delta); + Assert.AreEqual(3, box.ActualBorderBottomWidth, Delta); + Assert.AreEqual(4, box.ActualBorderLeftWidth, Delta); + } + + [TestMethod] + [Spec("css-logical-1", "#border-width")] + public void BorderWidthShorthands_SetBothSidesOfTheirAxis() + { + var box = Styled("#x{border-style:solid;border-block-width:1px 2px;border-inline-width:3px}"); + + Assert.AreEqual(1, box.ActualBorderTopWidth, Delta); + Assert.AreEqual(2, box.ActualBorderBottomWidth, Delta); + Assert.AreEqual(3, box.ActualBorderLeftWidth, Delta); + Assert.AreEqual(3, box.ActualBorderRightWidth, Delta); + } + + [TestMethod] + [Spec("css-logical-1", "#border-style")] + public void FlowRelativeBorderStyles_MapToThePhysicalSidesInLtr() + { + var box = Styled("#x{border-block-style:solid dashed;border-inline-start-style:dotted;border-inline-end-style:double}"); + + Assert.AreEqual("solid", box.BorderTopStyle); + Assert.AreEqual("dashed", box.BorderBottomStyle); + Assert.AreEqual("dotted", box.BorderLeftStyle); + Assert.AreEqual("double", box.BorderRightStyle); + } + + [TestMethod] + [Spec("css-logical-1", "#border-color")] + public void FlowRelativeBorderColors_MapToThePhysicalSidesInLtr() + { + var box = Styled("#x{border-style:solid;border-inline-color:red blue;border-block-start-color:lime;border-block-end-color:black}"); + + Assert.AreEqual(255, box.ActualBorderLeftColor.R); + Assert.AreEqual(255, box.ActualBorderRightColor.B); + Assert.AreEqual(255, box.ActualBorderTopColor.G); + Assert.AreEqual(0, box.ActualBorderBottomColor.R); + } + + [TestMethod] + [Spec("css-logical-1", "#border-shorthands")] + public void FlowRelativeBorderShorthands_SetTheirSides() + { + var box = Styled("#x{border-block:2px solid red;border-inline-start:5px dashed blue}"); + + Assert.AreEqual(2, box.ActualBorderTopWidth, Delta); + Assert.AreEqual(2, box.ActualBorderBottomWidth, Delta); + Assert.AreEqual(5, box.ActualBorderLeftWidth, Delta); + Assert.AreEqual("dashed", box.BorderLeftStyle); + Assert.AreEqual(0, box.ActualBorderRightWidth, Delta); + } + + [Ignore("Gap, see Spec/css-logical-1.yml")] + [TestMethod] + [Spec("css-logical-1", "#border-shorthands")] + public void BorderInlineStart_IsTheRightBorderInRtl() + { + var box = Styled("#x{direction:rtl;border-inline-start:5px solid red}"); + + Assert.AreEqual(5, box.ActualBorderRightWidth, Delta); + Assert.AreEqual(0, box.ActualBorderLeftWidth, Delta); + } + + [Ignore("Gap, see Spec/css-logical-1.yml")] + [TestMethod] + [Spec("css-logical-1", "#border-radius-properties")] + [DataRow("border-start-start-radius")] + [DataRow("border-start-end-radius")] + [DataRow("border-end-start-radius")] + [DataRow("border-end-end-radius")] + public void FlowRelativeCornerRadius_IsParsed(string property) + { + Assert.AreEqual("5px", Value(property, "5px")); + } + + [Ignore("Gap, see Spec/css-logical-1.yml")] + [TestMethod] + [Spec("css-logical-1", "#logical-shorthand-keyword")] + public void LogicalKeyword_AssignsTheValuesToTheFlowRelativeSides() + { + var box = Styled("#x{margin:logical 1px 2px 3px 4px}"); + + Assert.AreEqual(1, box.ActualMarginTop, Delta); + Assert.AreEqual(2, box.ActualMarginLeft, Delta); + Assert.AreEqual(3, box.ActualMarginBottom, Delta); + Assert.AreEqual(4, box.ActualMarginRight, Delta); + } + + private static CssBox Layout(string body) => + LayoutHarness.Layout($"<html><head></head><body style='margin:0'>{body}</body></html>", 800, 600).Root; + + private static CssBox Styled(string css, string body = "<div id=x>a</div>") + { + var (root, _) = LayoutHarness.Layout($"<html><head><style>{css}</style></head><body style='margin:0'>{body}</body></html>", 800, 600); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } + + private static string Value(string property, string value) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ {property}: {value}; }}"); + return sheet.Rules.OfType<StyleRule>().Single().Style.GetPropertyValue(property); + } +} diff --git a/Spec/css-logical-1.yml b/Spec/css-logical-1.yml index 8d5e6136b..70a2c744e 100644 --- a/Spec/css-logical-1.yml +++ b/Spec/css-logical-1.yml @@ -5,64 +5,91 @@ snapshot: https://www.w3.org/TR/2025/WD-css-logical-1-20251204/ sections: '#intro': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown + status: n/a '#directional-keywords': title: '2 Flow-Relative Values: block-start, block-end, inline-start, inline-end' - status: unknown + status: unsupported '#caption-side': title: 2.1 Logical Values for the caption-side Property - status: unknown + status: n/a + gaps: + - Only for implementations with caption-side left and right, which isn't accepted '#float-clear': title: 2.2 Flow-Relative Values for the float and clear Properties - status: unknown + status: unsupported + gaps: + - inline-start and inline-end aren't accepted for float and clear '#text-align': title: 2.3 Flow-Relative Values for the text-align Property - status: unknown + status: unsupported + gaps: + - 'start and end aren''t accepted, see css-text-3 #text-align-property' '#page': title: 3 Flow-Relative Page Classifications - status: unknown + status: unsupported + gaps: + - recto and verso aren't accepted for the break properties, and @page :left and :right aren't applied, see css-page-3 '#box': title: 4 Flow-Relative Box Model Properties - status: unknown + status: partial + gaps: + - Flow-relative properties always map as horizontal-tb ltr, direction rtl doesn't swap inline-start and inline-end '#dimension-properties': title: '4.1 Logical Height and Logical Width: the block-size/inline-size, min-block-size/min-inline-size, and max-block-size/max-inline-size properties' - status: unknown + status: unsupported + gaps: + - inline-size, block-size and their min and max variants are ignored, see css-sizing-3 '#margin-properties': title: '4.2 Flow-Relative Margins: the margin-block-start, margin-block-end, margin-inline-start, margin-inline-end properties and margin-block and margin-inline shorthands' - status: unknown + status: partial + gaps: + - margin-inline-start is the left margin also in rtl '#position-properties': title: '4.3 Flow-Relative Offsets: the inset-block-start, inset-block-end, inset-inline-start, inset-inline-end properties and inset-block, inset-inline, and inset shorthands' - status: unknown + status: partial + gaps: + - inset-inline-start is left also in rtl, see css-position-3 '#padding-properties': title: '4.4 Flow-Relative Padding: the padding-block-start, padding-block-end, padding-inline-start, padding-inline-end properties and padding-block and padding-inline shorthands' - status: unknown + status: partial + gaps: + - padding-inline-start is the left padding also in rtl '#border-properties': title: 4.5 Flow-Relative Borders - status: unknown + status: partial '#border-width': title: '4.5.1 Flow-Relative Border Widths: the border-block-start-width, border-block-end-width, border-inline-start-width, border-inline-end-width properties and border-block-width and border-inline-width shorthands' - status: unknown + status: partial + gaps: + - border-inline-start-width is the left side also in rtl '#border-style': title: '4.5.2 Flow-Relative Border Styles: the border-block-start-style, border-block-end-style, border-inline-start-style, border-inline-end-style properties and border-block-style and border-inline-style shorthands' - status: unknown + status: partial + gaps: + - border-inline-start-style is the left side also in rtl '#border-color': title: '4.5.3 Flow-Relative Border Colors: the border-block-start-color, border-block-end-color, border-inline-start-color, border-inline-end-color properties and border-block-color and border-inline-color shorthands' - status: unknown + status: partial + gaps: + - border-inline-start-color is the left side also in rtl '#border-shorthands': title: '4.5.4 Flow-Relative Border Shorthands: the border-block-start, border-block-end, border-inline-start, border-inline-end properties and border-block and border-inline shorthands' - status: unknown + status: partial + gaps: + - border-inline-start is the left side also in rtl '#border-radius-properties': title: '4.6 Flow-Relative Corner Rounding: the border-start-start-radius, border-start-end-radius, border-end-start-radius, border-end-end-radius properties' - status: unknown + status: unsupported + gaps: + - border-start-start-radius and the other flow-relative radii aren't accepted '#logical-shorthand-keyword': title: '4.7 Four-Directional Shorthand Properties: the margin, padding, border-width, border-style, and border-color shorthands' - status: unknown + status: unsupported + gaps: + - The logical keyword isn't accepted '#acknowledgements': title: 5 Acknowledgements - status: unknown + status: n/a '#changes': title: 6 Changes - status: unknown + status: n/a From 3a65ec895c6bbae150725d67aa77fe5d46c676e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:37:53 +0100 Subject: [PATCH 101/150] Verify and document CSS Inline Layout compliance --- .../HtmlRenderer/Core/Dom/CssLayoutEngine.cs | 7 +- .../CssModules/CssInlineTests.cs | 48 ++++ .../Layout/NormalFlowIntegrationTests.cs | 5 + .../Text/LineBaselineIntegrationTests.cs | 4 + .../Text/LineHeightIntegrationTests.cs | 22 +- .../Text/VerticalAlignIntegrationTests.cs | 5 + .../CssModules/CssInlineTests.cs | 212 ++++++++++++++++++ .../CssModules/CssWritingModesTests.cs | 19 -- Spec/css-inline-3.yml | 170 ++++++++------ 9 files changed, 394 insertions(+), 98 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssInlineTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssInlineTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs index a9637f558..34b9807f5 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs @@ -745,11 +745,7 @@ private static double GetFontHeight(RGraphics g, RFont font) return font.Height; } - /// <summary> - /// Applies vertical alignment to the linebox - /// </summary> - /// <param name="g"></param> - /// <param name="lineBox"></param> + // CSS 2.1 §10.8.1, CSS Inline 3 §4.2. baseline is done by ApplyBaselineAlignment. private static void ApplyVerticalAlignment(RGraphics g, CssLineBox lineBox) { double baseline = Single.MinValue; @@ -761,7 +757,6 @@ private static void ApplyVerticalAlignment(RGraphics g, CssLineBox lineBox) var boxes = new List<CssBox>(lineBox.Rectangles.Keys); foreach (CssBox box in boxes) { - // CSS 2.1 §10.8.1 switch (box.VerticalAlign) { case CssConstants.Sub: diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssInlineTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssInlineTests.cs new file mode 100644 index 000000000..d3f9caae4 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssInlineTests.cs @@ -0,0 +1,48 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.CssModules; + +[DoNotParallelize] +[TestClass] +public sealed class CssInlineTests +{ + private const double Delta = 1.0; + + [TestMethod] + [Spec("css-writing-modes-4", "#replaced-baselines")] + [Spec("css-inline-3", "#baseline-tables")] + public void InlineBlock_IsAlignedByTheBaselineOfItsLine() + { + var root = Layout("<div style='width:300px'>x <span style='display:inline-block;padding-top:20px'>y</span></div>"); + + Assert.AreEqual(Word(root, "y").Bottom, Word(root, "x").Bottom, Delta); + Assert.IsGreaterThan(19, Word(root, "x").Top); + } + + [Ignore("Gap, see Spec/css-inline-3.yml")] + [TestMethod] + [Spec("css-inline-3", "#baseline-tables")] + public void InlineBlock_IsAlignedByTheBaselineOfItsLastLine() + { + var root = Layout("<div style='width:300px'>x <span style='display:inline-block'>y<br>z</span></div>"); + + Assert.AreEqual(Word(root, "z").Bottom, Word(root, "x").Bottom, Delta); + } + + [Ignore("Gap, see Spec/css-writing-modes-4.yml")] + [TestMethod] + [Spec("css-writing-modes-4", "#bidi-atomic-inlines")] + public void InlineBlock_InAnRtlBlock_IsReorderedLikeAWord() + { + var root = Layout("<div style='direction:rtl;width:300px'><span id='b' style='display:inline-block;width:20px;height:10px'></span> אבג</div>"); + + Assert.IsGreaterThan(Word(root, "אבג").Right, LayoutHarness.FindById(root, "b")!.Location.X); + } + + private static CssBox Layout(string body) => LayoutHarness.Layout(LayoutHarness.Wrap(body)).Item1; + + private static CssRect Word(CssBox root, string text) => + LayoutHarness.Descendants(root).SelectMany(b => b.Words).First(w => w.Text == text); +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Layout/NormalFlowIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Layout/NormalFlowIntegrationTests.cs index 9c7152ed1..ea968bf32 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Layout/NormalFlowIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Layout/NormalFlowIntegrationTests.cs @@ -60,6 +60,7 @@ public void FloatsInsideAnOverflowHiddenBlock_DoNotShortenLinesOutsideIt() [TestMethod] [Spec("css21", "visuren.html#inline-formatting")] + [Spec("css-inline-3", "#line-boxes")] public void InlineBoxes_AreLaidOutHorizontallyRespectingMarginsBordersAndPadding() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -74,6 +75,7 @@ public void InlineBoxes_AreLaidOutHorizontallyRespectingMarginsBordersAndPadding [TestMethod] [Spec("css21", "visuren.html#inline-formatting")] + [Spec("css-inline-3", "#line-boxes")] public void ContentThatDoesNotFit_IsDistributedOverStackedLineBoxes() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -104,6 +106,7 @@ public void AnUnbreakableWord_OverflowsTheLineBox() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#inline-formatting")] + [Spec("css-inline-3", "#line-boxes")] public void AnUnbreakableWord_StaysOnTheFirstLine() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -117,6 +120,7 @@ public void AnUnbreakableWord_StaysOnTheFirstLine() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visuren.html#inline-formatting")] + [Spec("css-inline-3", "#line-boxes")] public void ASplitInlineBox_HasNoPaddingWhereItIsSplit() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -131,6 +135,7 @@ public void ASplitInlineBox_HasNoPaddingWhereItIsSplit() [TestMethod] [Spec("css21", "visuren.html#inline-formatting")] + [Spec("css-inline-3", "#invisible-line-boxes")] public void ALineBoxWithoutContent_HasZeroHeight() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs index b5d787f81..613199eaa 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs @@ -16,6 +16,8 @@ public sealed class LineBaselineIntegrationTests [TestMethod] [Spec("css21", "visudet.html#leading")] [Spec("css-writing-modes-4", "#baseline-alignment")] + [Spec("css-inline-3", "#alignment")] + [Spec("css-inline-3", "#ascent-descent")] public void SmallerText_SitsOnTheBaselineOfTheLargerText() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -30,6 +32,7 @@ public void SmallerText_SitsOnTheBaselineOfTheLargerText() [TestMethod] [Spec("css21", "visudet.html#leading")] [Spec("css-writing-modes-4", "#text-baselines")] + [Spec("css-inline-3", "#alignment")] public void SmallerText_SitsOnTheBaselineOfTheBlocksOwnFont() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -42,6 +45,7 @@ public void SmallerText_SitsOnTheBaselineOfTheBlocksOwnFont() [TestMethod] [Spec("css21", "visudet.html#leading")] + [Spec("css-inline-3", "#inline-height")] public void LineWithOnlySmallerText_IsAsTallAsTheBlocksFont() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineHeightIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineHeightIntegrationTests.cs index 4356496ef..92f8c013a 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineHeightIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineHeightIntegrationTests.cs @@ -21,6 +21,8 @@ public sealed class LineHeightIntegrationTests [TestMethod] [Spec("css21", "visudet.html#line-height")] + [Spec("css-inline-3", "#line-layout")] + [Spec("css-inline-3", "#inline-height")] public void TallerInlineText_MakesTheLineTaller() { var root = Layout("<div id='p'>x<span id='s' style='font-size:60px'>T</span></div>"); @@ -28,17 +30,24 @@ public void TallerInlineText_MakesTheLineTaller() Assert.AreEqual(Box(root, "s").ActualFont.Height, Height(Box(root, "p")), Delta); } + // The image sits on the baseline, so the text's descent hangs below it. + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#line-height")] + [Spec("css-inline-3", "#line-layout")] public void TallImage_MakesTheLineTaller() { + var plain = Layout("<div id='p'>x</div>"); + var plainP = Box(plain, "p"); + var descent = Height(plainP) - (FirstWord(plainP).Top - plainP.Location.Y) - plainP.ActualFont.UnderlineOffset; var root = TestImages.Layout($"<div id='p'>x<img style='width:10px;height:100px' src='{Image}'>y</div>"); - Assert.AreEqual(100, Height(Box(root, "p")), Delta); + Assert.AreEqual(100 + descent, Height(Box(root, "p")), Delta); } [TestMethod] [Spec("css21", "visudet.html#line-height")] + [Spec("css-inline-3", "#line-layout")] public void InlineBlockVerticalPadding_MakesTheLineTaller() { var plain = Layout("<div id='p'>a<div style='display:inline-block'>x</div></div>"); @@ -50,6 +59,7 @@ public void InlineBlockVerticalPadding_MakesTheLineTaller() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#line-height")] + [Spec("css-inline-3", "#line-layout")] public void InlineBlockVerticalMargins_MakeTheLineTaller() { var plain = Layout("<div id='p'>a<div style='display:inline-block'>x</div></div>"); @@ -61,6 +71,7 @@ public void InlineBlockVerticalMargins_MakeTheLineTaller() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#line-height")] + [Spec("css-inline-3", "#inline-height")] public void InlineLineHeight_MakesTheLineTaller() { var root = Layout("<div id='p'>x<span style='line-height:60px'>y</span></div>"); @@ -72,6 +83,7 @@ public void InlineLineHeight_MakesTheLineTaller() [TestMethod] [Spec("css21", "visudet.html#line-height")] [Spec("css-writing-modes-4", "#replaced-baselines")] + [Spec("css-inline-3", "#baseline-tables")] public void Image_SitsOnTheBaseline() { var root = TestImages.Layout($"<div id='p' style='font-size:20px'><span id='t'>x</span><img id='i' src='{Image}'></div>"); @@ -83,6 +95,7 @@ public void Image_SitsOnTheBaseline() [TestMethod] [Spec("css21", "visudet.html#leading")] + [Spec("css-inline-3", "#line-height-property")] public void LineHeightLargerThanTheFont_MakesTheLineTaller() { var root = Layout("<div id='p' style='line-height:40px'>x</div>"); @@ -93,6 +106,7 @@ public void LineHeightLargerThanTheFont_MakesTheLineTaller() [TestMethod] [Spec("css21", "visudet.html#leading")] + [Spec("css-inline-3", "#line-height-property")] public void NumberLineHeight_ScalesWithTheFontSize() { var small = Layout("<div id='p' style='font-size:20px;line-height:2'>x</div>"); @@ -103,6 +117,7 @@ public void NumberLineHeight_ScalesWithTheFontSize() [TestMethod] [Spec("css21", "visudet.html#leading")] + [Spec("css-inline-3", "#line-height-property")] public void EveryLine_GetsTheLineHeight() { var one = Layout("<div id='p' style='line-height:40px'>x</div>"); @@ -114,6 +129,7 @@ public void EveryLine_GetsTheLineHeight() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#leading")] + [Spec("css-inline-3", "#line-height-property")] public void LengthLineHeight_IsTheHeightOfTheLine() { var root = Layout("<div id='p' style='line-height:40px'>x</div>"); @@ -124,6 +140,7 @@ public void LengthLineHeight_IsTheHeightOfTheLine() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#leading")] + [Spec("css-inline-3", "#inline-height")] public void LineHeightSmallerThanTheFont_MakesTheLineShorter() { var root = Layout("<div id='p' style='line-height:5px'>x</div>"); @@ -134,6 +151,7 @@ public void LineHeightSmallerThanTheFont_MakesTheLineShorter() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#leading")] + [Spec("css-inline-3", "#inline-height")] public void HalfLeading_CentersTheTextInTheLine() { var root = Layout("<div id='p' style='line-height:60px'>x</div>"); @@ -145,6 +163,7 @@ public void HalfLeading_CentersTheTextInTheLine() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#leading")] + [Spec("css-inline-3", "#line-height-property")] public void PercentageLineHeight_ResolvesAgainstTheFontSize() { var root = Layout("<div id='p' style='font-size:20px;line-height:300%'>x</div>"); @@ -155,6 +174,7 @@ public void PercentageLineHeight_ResolvesAgainstTheFontSize() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "visudet.html#leading")] + [Spec("css-inline-3", "#line-height-property")] public void NumberLineHeight_IsInheritedAsTheNumber() { var root = Layout("<div style='font-size:10px;line-height:3'><div id='p' style='font-size:20px'>x</div></div>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs index 6dbc904df..0752089c0 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs @@ -33,6 +33,7 @@ private static double GetAlignedTop(string verticalAlign, string? lineHeight = n [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "visudet.html#leading")] [TestMethod] + [Spec("css-inline-3", "#transverse-alignment")] public void Top_PositionsHigherThanBottom() { var topY = GetAlignedTop("top"); @@ -44,6 +45,7 @@ public void Top_PositionsHigherThanBottom() [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "visudet.html#leading")] [TestMethod] + [Spec("css-inline-3", "#transverse-alignment")] public void Middle_PositionsBetweenTopAndBottom() { var topY = GetAlignedTop("top"); @@ -56,6 +58,7 @@ public void Middle_PositionsBetweenTopAndBottom() [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "visudet.html#leading")] [TestMethod] + [Spec("css-inline-3", "#transverse-alignment")] public void TextTop_PositionsAboveTextBottom() { var textTopY = GetAlignedTop("text-top"); @@ -67,6 +70,7 @@ public void TextTop_PositionsAboveTextBottom() [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "visudet.html#leading")] [TestMethod] + [Spec("css-inline-3", "#transverse-alignment")] public void Sub_PositionsBelowSuper() { var subY = GetAlignedTop("sub"); @@ -184,6 +188,7 @@ public void Middle_OnATableCellWithExplicitHeight_CentersShortContent() [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "visudet.html#leading")] [TestMethod] + [Spec("css-inline-3", "#transverse-alignment")] public void Length_PositiveValue_RaisesTheBoxAboveBaseline() { var baselineY = GetAlignedTop("baseline"); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssInlineTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssInlineTests.cs new file mode 100644 index 000000000..bc979bd3c --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssInlineTests.cs @@ -0,0 +1,212 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssInlineTests +{ + private const double Delta = 0.5; + + [TestMethod] + [Spec("css-inline-3", "#paint-order")] + public void InlineBoxes_PaintInDocumentOrder() + { + var g = Paint("<div><span style='background:rgb(255,0,0)'>a</span><span style='background:rgb(0,0,255);margin-left:-5px'>b</span></div>"); + + Assert.IsLessThan(RectIndex(g, RColor.FromArgb(0, 0, 255)), RectIndex(g, RColor.FromArgb(255, 0, 0))); + } + + [Ignore("Gap, see Spec/css-inline-3.yml")] + [TestMethod] + [Spec("css-inline-3", "#dominant-baseline-property")] + [DataRow("auto")] + [DataRow("alphabetic")] + [DataRow("central")] + [DataRow("hanging")] + public void DominantBaseline_IsParsed(string value) + { + Assert.AreEqual(value, Value("dominant-baseline", value)); + } + + [TestMethod] + [Spec("css-inline-3", "#transverse-alignment")] + [DataRow("baseline")] + [DataRow("sub")] + [DataRow("super")] + [DataRow("top")] + [DataRow("text-top")] + [DataRow("middle")] + [DataRow("bottom")] + [DataRow("text-bottom")] + [DataRow("5px")] + [DataRow("10%")] + public void VerticalAlign_Css2Values_AreParsed(string value) + { + Assert.AreEqual(value, Value("vertical-align", value)); + } + + [Ignore("Gap, see Spec/css-inline-3.yml")] + [TestMethod] + [Spec("css-inline-3", "#transverse-alignment")] + [DataRow("first")] + [DataRow("central")] + [DataRow("last top")] + public void VerticalAlign_Level3Values_AreParsed(string value) + { + Assert.AreEqual(value, Value("vertical-align", value)); + } + + [Ignore("Gap, see Spec/css-inline-3.yml")] + [TestMethod] + [Spec("css-inline-3", "#baseline-source")] + [DataRow("auto")] + [DataRow("first")] + [DataRow("last")] + public void BaselineSource_IsParsed(string value) + { + Assert.AreEqual(value, Value("baseline-source", value)); + } + + [Ignore("Gap, see Spec/css-inline-3.yml")] + [TestMethod] + [Spec("css-inline-3", "#alignment-baseline-property")] + [DataRow("baseline")] + [DataRow("middle")] + [DataRow("text-top")] + public void AlignmentBaseline_IsParsed(string value) + { + Assert.AreEqual(value, Value("alignment-baseline", value)); + } + + [Ignore("Gap, see Spec/css-inline-3.yml")] + [TestMethod] + [Spec("css-inline-3", "#baseline-shift-property")] + [DataRow("5px")] + [DataRow("sub")] + [DataRow("top")] + public void BaselineShift_IsParsed(string value) + { + Assert.AreEqual(value, Value("baseline-shift", value)); + } + + [TestMethod] + [Spec("css-inline-3", "#line-height-property")] + [DataRow("normal")] + [DataRow("1.5")] + [DataRow("20px")] + [DataRow("150%")] + public void LineHeight_IsParsed(string value) + { + Assert.AreEqual(value, Value("line-height", value)); + } + + [Ignore("Gap, see Spec/css-inline-3.yml")] + [TestMethod] + [Spec("css-inline-3", "#line-height-property")] + [DataRow("-1px")] + [DataRow("-1")] + public void LineHeight_RejectsNegativeValues(string value) + { + Assert.AreEqual(string.Empty, Value("line-height", value)); + } + + [Ignore("Gap, see Spec/css-inline-3.yml")] + [TestMethod] + [Spec("css-inline-3", "#text-edges")] + public void LineFitEdge_IsParsed() + { + Assert.AreEqual("cap alphabetic", Value("line-fit-edge", "cap alphabetic")); + } + + [Ignore("Gap, see Spec/css-inline-3.yml")] + [TestMethod] + [Spec("css-inline-3", "#text-box-trim")] + public void TextBoxTrim_TrimsTheHalfLeadingAboveTheFirstLine() + { + var plain = Layout("<div id='x' style='line-height:40px'>a</div>"); + var trimmed = Layout("<div id='x' style='line-height:40px;text-box-trim:trim-start'>a</div>"); + + Assert.IsLessThan(LayoutHarness.FindById(plain, "x")!.Size.Height, LayoutHarness.FindById(trimmed, "x")!.Size.Height); + } + + [Ignore("Gap, see Spec/css-inline-3.yml")] + [TestMethod] + [Spec("css-inline-3", "#text-box-shorthand")] + [Spec("css-inline-3", "#text-box-edge")] + public void TextBox_IsParsed() + { + Assert.AreEqual("trim-both cap alphabetic", Value("text-box", "trim-both cap alphabetic")); + } + + [Ignore("Gap, see Spec/css-inline-3.yml")] + [TestMethod] + [Spec("css-inline-3", "#line-fill")] + public void InlineSizing_IsParsed() + { + Assert.AreEqual("stretch", Value("inline-sizing", "stretch")); + } + + [Ignore("Gap, see Spec/css-inline-3.yml")] + [TestMethod] + [Spec("css-inline-3", "#sizing-drop-initials")] + [DataRow("3")] + [DataRow("3 2")] + public void InitialLetter_IsParsed(string value) + { + Assert.AreEqual(value, Value("initial-letter", value)); + } + + [Ignore("Gap, see Spec/css-inline-3.yml")] + [TestMethod] + [Spec("css-inline-3", "#aligning-initial-letter")] + public void InitialLetterAlign_IsParsed() + { + Assert.AreEqual("alphabetic", Value("initial-letter-align", "alphabetic")); + } + + [Ignore("Gap, see Spec/css-inline-3.yml")] + [TestMethod] + [Spec("css-inline-3", "#initial-letter-wrapping")] + public void InitialLetterWrap_IsParsed() + { + Assert.AreEqual("first", Value("initial-letter-wrap", "first")); + } + + private static CssBox Layout(string body) => + LayoutHarness.Layout($"<html><head></head><body style='margin:0'>{body}</body></html>", 800, 600).Root; + + private static RecordingGraphics Paint(string body) + { + var container = new HtmlContainerInt(new MockAdapter()) { MaxSize = new RSize(800, 600), PageSize = new RSize(800, 600) }; + container.SetHtml($"<html><head></head><body style='margin:0'>{body}</body></html>"); + var graphics = new RecordingGraphics(); + container.PerformLayout(graphics); + container.PerformPaint(graphics); + return graphics; + } + + private static int RectIndex(RecordingGraphics g, RColor color) + { + var index = g.Log.FindIndex(e => e is RecordingGraphics.DrawRectCall r && r.Color == color); + Assert.IsGreaterThanOrEqualTo(0, index, $"No rectangle painted in {color}"); + return index; + } + + private static CssRect Word(CssBox root, string text) + { + var word = LayoutHarness.Descendants(root).SelectMany(b => b.Words).FirstOrDefault(w => w.Text == text); + Assert.IsNotNull(word, $"No word '{text}'"); + return word; + } + + private static string Value(string property, string value) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ {property}: {value}; }}"); + return sheet.Rules.OfType<StyleRule>().Single().Style.GetPropertyValue(property); + } +} diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssWritingModesTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssWritingModesTests.cs index ce219d056..bfabfab8f 100644 --- a/Source/Test/HtmlRenderer.Test/CssModules/CssWritingModesTests.cs +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssWritingModesTests.cs @@ -73,16 +73,6 @@ public void Image_InAnRtlBlock_IsReorderedLikeAWord() Assert.IsGreaterThan(Word(root, "אבג").Right, image.Left); } - [Ignore("Gap, see Spec/css-writing-modes-4.yml")] - [TestMethod] - [Spec("css-writing-modes-4", "#bidi-atomic-inlines")] - public void InlineBlock_InAnRtlBlock_IsReorderedLikeAWord() - { - var root = Layout("<div style='direction:rtl;width:300px'><span id='b' style='display:inline-block;width:20px;height:10px'></span> אבג</div>"); - - Assert.IsGreaterThan(Word(root, "אבג").Right, LayoutHarness.FindById(root, "b")!.Location.X); - } - [Ignore("Gap, see Spec/css-writing-modes-4.yml")] [TestMethod] [Spec("css-writing-modes-4", "#block-flow")] @@ -164,15 +154,6 @@ public void PhysicalMargin_StaysOnItsSideInRtl() Assert.AreEqual(170, box.Size.Width, Delta); } - [TestMethod] - [Spec("css-writing-modes-4", "#replaced-baselines")] - public void InlineBlock_IsAlignedByTheBaselineOfItsLastLine() - { - var root = Layout("<div style='width:300px'>x <span style='display:inline-block;padding-top:20px'>y</span></div>"); - - Assert.AreEqual(Word(root, "x").Top, Word(root, "y").Top, Delta); - } - [TestMethod] [Spec("css-writing-modes-4", "#line-mappings")] public void TextAlignLeft_IsTheLineLeftInRtl() diff --git a/Spec/css-inline-3.yml b/Spec/css-inline-3.yml index 0870cf5a0..45f7c9770 100644 --- a/Spec/css-inline-3.yml +++ b/Spec/css-inline-3.yml @@ -5,187 +5,213 @@ snapshot: https://www.w3.org/TR/2024/WD-css-inline-3-20241218/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#model': title: 2 Inline Layout Model - status: unknown + status: partial '#line-boxes': title: 2.1 Layout of Line Boxes - status: unknown + status: partial + gaps: + - The start padding, border and margin of an inline box are repeated on every line it's split onto, see CSS 2.1 9.4.2 + - A word wider than the line box starts a new line, leaving an empty line above it, see CSS 2.1 9.4.2 '#line-layout': title: 2.2 Layout Within Line Boxes - status: unknown + status: partial + gaps: + - Vertical margins of an inline-block don't make the line box taller, see CSS 2.1 10.8 '#invisible-line-boxes': title: 2.3 Phantom Line Boxes - status: unknown + status: supported '#paint-order': title: 2.4 Painting Order - status: unknown + status: supported '#css-metrics': title: 3 Baselines and Alignment Metrics - status: unknown + status: partial '#baseline-intro': title: 3.1 Introduction to Baselines - status: unknown + status: n/a '#baseline-types': title: 3.2 Baselines and Metrics - status: unknown + status: partial + gaps: + - Only the alphabetic baseline, there are no ideographic, central, hanging or math baselines '#ascent-descent': title: 3.2.1 Ascent and Descent Metrics - status: unknown + status: supported '#font-line-gap': title: 3.2.2 Line Gap Metrics - status: unknown + status: n/a + gaps: + - Optional, the adapter's font height decides whether the line gap is included '#baseline-tables': title: 3.3 Baselines of Glyphs and Boxes - status: unknown + status: partial + gaps: + - Images are aligned with the top of the line instead of sitting on the baseline, see CSS 2.1 10.8 + - An inline-block is aligned by its first line instead of its last line '#alignment': title: 4 Baseline Alignment - status: unknown + status: partial '#dominant-baseline-property': title: '4.1 Dominant Baselines: the dominant-baseline property' - status: unknown + status: unsupported + gaps: + - dominant-baseline isn't accepted '#transverse-alignment': title: '4.2 Transverse Box Alignment: the vertical-align property' - status: unknown + status: partial + gaps: + - first, last and the alignment-baseline keywords such as central aren't accepted + - top, bottom, middle, text-top, text-bottom, lengths and percentages align like baseline, see CSS 2.1 10.8.1 + - sub and super both move the text to the top of the line, see CSS 2.1 10.8.1 '#baseline-source': title: '4.2.1 Alignment Baseline Source: the baseline-source longhand' - status: unknown + status: unsupported + gaps: + - baseline-source isn't accepted '#alignment-baseline-property': title: '4.2.2 Alignment Baseline Type: the alignment-baseline longhand' - status: unknown + status: unsupported + gaps: + - alignment-baseline isn't accepted '#alignment-baseline-svg-legacy': title: 4.2.2.1 Legacy Values for SVG - status: unknown + status: unsupported '#baseline-shift-property': title: '4.2.3 Post-Alignment Shift: the baseline-shift longhand' - status: unknown + status: unsupported + gaps: + - baseline-shift isn't accepted '#baseline-shift-svg-legacy': title: 4.2.3.1 Legacy Values for SVG - status: unknown + status: unsupported '#line-height': title: 5 Logical Heights and Inter-line Spacing - status: unknown + status: partial '#line-height-property': title: '5.1 Line Spacing: the line-height property' - status: unknown + status: partial + gaps: + - Negative values are accepted instead of being invalid + - The used line height is 90% of line-height, see CSS 2.1 10.8.1 + - Percentages compute to normal, see CSS 2.1 10.8.1 + - A number is inherited as the parent's computed length instead of the number, see CSS 2.1 10.8.1 '#text-edges': title: '5.2 Text Edge Metrics: the line-fit-edge property' - status: unknown + status: unsupported + gaps: + - line-fit-edge isn't accepted '#inline-height': title: 5.3 Calculating the Logical Height Contributions (“Layout Bounds”) of Inline Boxes - status: unknown + status: partial + gaps: + - line-height on an inline box is ignored, only the block's own line-height sizes the line, see CSS 2.1 10.8 + - A line-height smaller than the font doesn't make the line shorter, see CSS 2.1 10.8.1 + - No half-leading, the text sits at the top of the line instead of in its middle, see CSS 2.1 10.8.1 '#leading-trim': title: 6 Trimming Leading Over/Under Text - status: unknown + status: unsupported + gaps: + - text-box, text-box-trim and text-box-edge aren't accepted '#text-box-shorthand': title: '6.1 Shorthand for Text Box Trimming: the text-box property' - status: unknown + status: unsupported '#text-box-trim': title: '6.2 Trimming Over/Under Text: the text-box-trim property' - status: unknown + status: unsupported '#text-box-edge': title: '6.3 Text Trimming Metrics: the text-box-edge property' - status: unknown + status: unsupported '#line-fill': title: '6.4 Inline Box Drawing Height: the inline-sizing property' - status: unknown + status: unsupported + gaps: + - inline-sizing isn't accepted '#initial-letter-styling': title: 7 Initial Letters - status: unknown + status: unsupported + gaps: + - initial-letter, initial-letter-align and initial-letter-wrap aren't accepted '#initial-letter-intro': title: 7.1 An Introduction to Initial Letters - status: unknown - '#drop-initial': - title: 7.1.1 Drop Initial - status: unknown - '#sunk-initial': - title: 7.1.2 Sunken Initial Letters - status: unknown - '#raise-initial': - title: 7.1.3 Raised Initial Letters - status: unknown + status: n/a '#selecting-drop-initials': title: 7.2 Selecting Initial Letters - status: unknown + status: unsupported '#sizing-drop-initials': title: '7.3 Creating Initial Letters: the initial-letter property' - status: unknown + status: unsupported '#first-most-inline-level': title: 7.3.1 Applicability - status: unknown + status: unsupported '#aligning-initial-letter': title: '7.4 Alignment of Initial Letters: the initial-letter-align property' - status: unknown + status: unsupported '#initial-letter-align-defaults': title: 7.4.1 UA Default Stylesheet for initial-letter-align - status: unknown + status: unsupported '#initial-letter-layout': title: 7.5 Initial Letter Layout - status: unknown + status: unsupported '#initial-letter-properties': title: 7.5.1 Properties Applying to Initial Letters - status: unknown + status: unsupported '#initial-letter-box': title: 7.5.2 Margins, Borders, and Padding - status: unknown + status: unsupported '#sizing-initial-letter': title: 7.5.3 Font Sizing of Initial Letters - status: unknown + status: unsupported '#initial-letter-shaping': title: 7.5.4 Shaping and Glyph Selection - status: unknown + status: unsupported '#initial-letter-box-size': title: 7.5.5 Sizing the Initial Letter Box - status: unknown + status: unsupported '#initial-letter-content-align': title: 7.5.6 Alignment Within an Initial Letter Box - status: unknown + status: unsupported '#initial-letter-position': title: 7.6 Initial Letter Positioning and Spacing - status: unknown + status: unsupported '#initial-letter-block-position': title: 7.6.1 Block-axis Positioning - status: unknown + status: unsupported '#initial-letter-inline-position': title: 7.6.2 Inline Kerning - status: unknown + status: unsupported '#initial-letter-wrapping': title: '7.7 Initial Letter Wrapping: the initial-letter-wrap property' - status: unknown + status: unsupported '#initial-letter-line-layout': title: 7.8 Line Layout - status: unknown + status: unsupported '#initial-letter-inline-flow': title: '7.8.1 Inline Flow Layout: Alignment, Justification, and White Space' - status: unknown + status: unsupported '#initial-letter-indentation': title: '7.8.2 Edge Effects: Indentation and Hanging Punctuation' - status: unknown + status: unsupported '#initial-letter-ancestors': title: 7.8.3 Ancestor Inlines - status: unknown + status: unsupported '#initial-letter-multi-line': title: 7.8.4 Multi-line Initial Letters - status: unknown + status: unsupported '#initial-letter-paragraphs': title: 7.9 Clearing Initial Letters - status: unknown + status: unsupported '#raised-sunken-caps': title: 7.9.1 Raised and sunken caps - status: unknown + status: unsupported '#short-para-initial-letter': title: 7.9.2 Short paragraphs with initial letters - status: unknown + status: unsupported '#initial-letter-floats': title: 7.9.3 Interaction with floats - status: unknown + status: unsupported '#initial-letter-breaking': title: 7.9.4 Interaction with Fragmentation (Pagination) - status: unknown + status: unsupported From 5335425d6389925746863b75c1a7d82c67db72c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:24:12 +0100 Subject: [PATCH 102/150] Verify and document CSS Text Level 4 compliance --- .../Text/CssText3IntegrationTests.cs | 12 +- .../HtmlEntityDecodingIntegrationTests.cs | 1 + .../Text/TextAlignIntegrationTests.cs | 9 + .../Text/TextAlignStartEndIntegrationTests.cs | 7 + ...tSpacingAndIndentLayoutIntegrationTests.cs | 15 + .../Text/TextTransformIntegrationTests.cs | 1 + .../Text/WhiteSpaceLayoutIntegrationTests.cs | 44 ++- .../Css/CssText3PropertyTests.cs | 19 +- .../Css/FontKeywordPropertyTests.cs | 4 + .../Css/TextPropertyTests.cs | 58 +++- .../Css/WhiteSpacePropertyTests.cs | 2 + .../CssModules/CssText4Tests.cs | 283 ++++++++++++++++++ Spec/css-text-3.yml | 5 + Spec/css-text-4.yml | 206 ++++++++----- 14 files changed, 567 insertions(+), 99 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssText4Tests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/CssText3IntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/CssText3IntegrationTests.cs index cb0294d9b..7709aa9d5 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/CssText3IntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/CssText3IntegrationTests.cs @@ -17,7 +17,7 @@ public sealed class CssText3IntegrationTests [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#white-space-property")] - [Spec("css-text-3", "#white-space-phase-1")] + [Spec("css-text-4", "#white-space-property")] public void BreakSpaces_PreservesSpaces() { var p = Paragraph("<p id='p' style='white-space:break-spaces'>A B</p>"); @@ -28,6 +28,7 @@ public void BreakSpaces_PreservesSpaces() [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#word-break-property")] + [Spec("css-text-4", "#word-break-property")] public void KeepAll_DoesntBreakBetweenCjkCharacters() { var p = Paragraph($"<p id='p' style='width:50px;word-break:keep-all'>{Cjk}</p>"); @@ -37,7 +38,9 @@ public void KeepAll_DoesntBreakBetweenCjkCharacters() [TestMethod] [Spec("css-text-3", "#word-break-property")] + [Spec("css-text-4", "#word-break-property")] [Spec("css-text-3", "#line-break-details")] + [Spec("css-text-4", "#line-break-details")] public void Normal_BreaksBetweenCjkCharacters() { var p = Paragraph($"<p id='p' style='width:50px'>{Cjk}</p>"); @@ -48,6 +51,7 @@ public void Normal_BreaksBetweenCjkCharacters() [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#word-break-property")] + [Spec("css-text-4", "#word-break-property")] public void BreakWord_BreaksAWordThatOverflows() { var p = Paragraph($"<p id='p' style='width:50px;word-break:break-word'>{LongWord}</p>"); @@ -58,6 +62,7 @@ public void BreakWord_BreaksAWordThatOverflows() [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#overflow-wrap-property")] + [Spec("css-text-4", "#overflow-wrap-property")] [DataRow("overflow-wrap:break-word")] [DataRow("overflow-wrap:anywhere")] [DataRow("word-wrap:break-word")] @@ -71,6 +76,7 @@ public void OverflowWrap_BreaksAWordThatOverflows(string declaration) [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#hyphenation")] + [Spec("css-text-4", "#hyphens-property")] public void SoftHyphen_IsABreakOpportunity() { var p = Paragraph("<p id='p' style='width:60px'>abcdefghijkl­mnopqrstuvwx</p>"); @@ -80,6 +86,7 @@ public void SoftHyphen_IsABreakOpportunity() [TestMethod] [Spec("css-text-3", "#line-break-details")] + [Spec("css-text-4", "#line-break-details")] public void HyphenMinus_IsABreakOpportunity() { var p = Paragraph("<p id='p' style='width:60px'>abcdefghijkl-mnopqrstuvwx</p>"); @@ -89,6 +96,7 @@ public void HyphenMinus_IsABreakOpportunity() [TestMethod] [Spec("css-text-3", "#justify-algos")] + [Spec("css-text-4", "#justify-algos")] public void Justify_ExpandsBetweenCjkCharacters() { var p = Paragraph($"<p id='p' style='width:100px;text-align:justify'>{Cjk}</p>"); @@ -101,6 +109,7 @@ public void Justify_ExpandsBetweenCjkCharacters() [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#text-align-last-property")] + [Spec("css-text-4", "#text-align-last-property")] public void TextAlignLast_AlignsTheLastLine() { var p = Paragraph("<p id='p' style='width:200px;text-align:justify;text-align-last:right'>hi</p>"); @@ -111,6 +120,7 @@ public void TextAlignLast_AlignsTheLastLine() [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] public void JustifyAll_JustifiesTheLastLine() { var p = Paragraph("<p id='p' style='width:200px;text-align:justify-all'>hi there</p>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs index 2c4491c02..42b78a1b6 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs @@ -67,6 +67,7 @@ public void DoubleEscapedNumericEntity_RendersAsLiteralEntityReference() [TestMethod] [Spec("css21", "text.html#white-space-model")] [Spec("css-text-3", "#white-space-phase-1")] + [Spec("css-text-4", "#white-space-phase-1")] public void SingleEscapedNbsp_RendersAsNonBreakingSpace() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p'>Use&nbsp;here</p>")); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs index c8f90b3a0..bc5ea41d5 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs @@ -12,6 +12,7 @@ public sealed class TextAlignIntegrationTests [TestMethod] [Spec("css21", "text.html#alignment-prop")] [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] public void Left_PacksTextAgainstTheLeftEdge() { var word = Words("<p id='p' style='margin:0;text-align:left;width:200px'>hi</p>").Single(); @@ -22,6 +23,7 @@ public void Left_PacksTextAgainstTheLeftEdge() [TestMethod] [Spec("css21", "text.html#alignment-prop")] [Spec("css-text-3", "#white-space-phase-2")] + [Spec("css-text-4", "#white-space-phase-2")] public void Right_PacksTextAgainstTheRightEdge_IgnoringTheTrailingSpace() { var word = Words("<p id='p' style='margin:0;text-align:right;width:200px'>hi </p>").Single(); @@ -32,6 +34,7 @@ public void Right_PacksTextAgainstTheRightEdge_IgnoringTheTrailingSpace() [TestMethod] [Spec("css21", "text.html#alignment-prop")] [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] public void Center_CentersTheTextInTheLineBox() { var word = Words("<p id='p' style='margin:0;text-align:center;width:200px'>hi</p>").Single(); @@ -42,6 +45,7 @@ public void Center_CentersTheTextInTheLineBox() [TestMethod] [Spec("css21", "text.html#alignment-prop")] [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] public void Right_AppliesToEveryLine() { var words = Words("<p id='p' style='margin:0;width:60px;text-align:right'>AAA BBB CCC DDD</p>"); @@ -53,6 +57,7 @@ public void Right_AppliesToEveryLine() [TestMethod] [Spec("css21", "text.html#alignment-prop")] [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] public void TextAlign_IsInherited() { var word = Words("<div style='text-align:right;width:200px'><p id='p' style='margin:0'>hi</p></div>").Single(); @@ -63,7 +68,9 @@ public void TextAlign_IsInherited() [TestMethod] [Spec("css21", "text.html#alignment-prop")] [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] [Spec("css-text-3", "#expanding-text")] + [Spec("css-text-4", "#expanding-text")] public void Justify_MakesAllButTheLastLineFlushWithBothSides() { var lines = Words("<p id='p' style='margin:0;text-align:justify;width:100px'>aa bb cc dd ee ff gg hh ii jj</p>") @@ -80,6 +87,7 @@ public void Justify_MakesAllButTheLastLineFlushWithBothSides() [TestMethod] [Spec("css21", "text.html#alignment-prop")] [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] public void InitialValue_InRtl_ActsAsRight() { var word = Words("<p id='p' dir='rtl' style='margin:0;width:200px'>hi</p>").Single(); @@ -91,6 +99,7 @@ public void InitialValue_InRtl_ActsAsRight() [TestMethod] [Spec("css21", "text.html#alignment-prop")] [Spec("css-text-3", "#expanding-text")] + [Spec("css-text-4", "#expanding-text")] public void Justify_DoesntAlterPreservedWhiteSpace() { var words = Words("<p id='p' style='margin:0;text-align:justify;white-space:pre;width:300px'>a b\nc d</p>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs index d15b783d6..8dd4dfb3c 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs @@ -15,6 +15,7 @@ private static CssRect FirstWord(CssBox box) => [TestMethod] [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] public void Start_InLtrBlock_PacksTextAgainstTheLeftEdge() { var html = LayoutHarness.Wrap("<p id='p' style='text-align: start; width: 200px'>hi</p>"); @@ -29,7 +30,9 @@ public void Start_InLtrBlock_PacksTextAgainstTheLeftEdge() [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] [Spec("css-text-3", "#bidi-linebox")] + [Spec("css-text-4", "#bidi-linebox")] public void Start_InRtlBlock_PacksTextAgainstTheRightEdge() { var html = LayoutHarness.Wrap("<p id='p' dir='rtl' style='text-align: start; width: 200px'>hi</p>"); @@ -44,6 +47,7 @@ public void Start_InRtlBlock_PacksTextAgainstTheRightEdge() [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] public void End_InLtrBlock_PacksTextAgainstTheRightEdge() { var html = LayoutHarness.Wrap("<p id='p' style='text-align: end; width: 200px'>hi</p>"); @@ -57,7 +61,9 @@ public void End_InLtrBlock_PacksTextAgainstTheRightEdge() [TestMethod] [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] [Spec("css-text-3", "#bidi-linebox")] + [Spec("css-text-4", "#bidi-linebox")] public void End_InRtlBlock_PacksTextAgainstTheLeftEdge() { var html = LayoutHarness.Wrap("<p id='p' dir='rtl' style='text-align: end; width: 200px'>hi</p>"); @@ -72,6 +78,7 @@ public void End_InRtlBlock_PacksTextAgainstTheLeftEdge() [TestMethod] [Spec("css21", "text.html#alignment-prop")] [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] public void DefaultsToStart_UnsetTextAlign_BehavesLikeLeftInLtr() { var html = LayoutHarness.Wrap("<p id='p' style='width: 200px'>hi</p>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs index bb1e02c78..4b7b61e5a 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs @@ -13,6 +13,7 @@ public sealed class TextSpacingAndIndentLayoutIntegrationTests [TestMethod] [Spec("css21", "text.html#spacing-props")] [Spec("css-text-3", "#word-spacing-property")] + [Spec("css-text-4", "#word-spacing-property")] public void WordSpacing_IncreasesGapBetweenAdjacentWords() { var spacedGap = InterWordGap("<p id='p' style='word-spacing:10px'>AA BB</p>"); @@ -25,6 +26,7 @@ public void WordSpacing_IncreasesGapBetweenAdjacentWords() [TestMethod] [Spec("css21", "text.html#spacing-props")] [Spec("css-text-3", "#word-spacing-property")] + [Spec("css-text-4", "#word-spacing-property")] public void WordSpacing_OnAncestor_AffectsDescendantGap() { var spacedGap = InterWordGap("<div style='word-spacing:10px'><p id='p'>AA BB</p></div>"); @@ -36,6 +38,7 @@ public void WordSpacing_OnAncestor_AffectsDescendantGap() [TestMethod] [Spec("css21", "text.html#spacing-props")] [Spec("css-text-3", "#word-spacing-property")] + [Spec("css-text-4", "#word-spacing-property")] public void WordSpacingNormal_LeavesGapUnchanged() { var normalGap = InterWordGap("<p id='p' style='word-spacing:normal'>AA BB</p>"); @@ -48,6 +51,7 @@ public void WordSpacingNormal_LeavesGapUnchanged() [TestMethod] [Spec("css21", "text.html#spacing-props")] [Spec("css-text-3", "#word-spacing-property")] + [Spec("css-text-4", "#word-spacing-property")] public void WordSpacingNegative_ReducesGap() { var negativeGap = InterWordGap("<p id='p' style='word-spacing:-5px'>AA BB</p>"); @@ -60,6 +64,7 @@ public void WordSpacingNegative_ReducesGap() [TestMethod] [Spec("css21", "text.html#spacing-props")] [Spec("css-text-3", "#letter-spacing-property")] + [Spec("css-text-4", "#letter-spacing-property")] public void LetterSpacing_WidensTheWordByTheSpacingPerCharacter() { var spaced = FirstWordWidth("<p id='p' style='letter-spacing:10px'>AAAA</p>"); @@ -71,6 +76,7 @@ public void LetterSpacing_WidensTheWordByTheSpacingPerCharacter() [TestMethod] [Spec("css21", "text.html#indentation-prop")] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndent_IndentsOnlyTheFirstLine() { var html = LayoutHarness.Wrap( @@ -91,6 +97,7 @@ public void TextIndent_IndentsOnlyTheFirstLine() [TestMethod] [Spec("css21", "text.html#indentation-prop")] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndentZero_NoIndentOnAnyLine() { var html = LayoutHarness.Wrap("<p id='p' style='margin:0;'>Hello world</p>"); @@ -104,6 +111,7 @@ public void TextIndentZero_NoIndentOnAnyLine() [TestMethod] [Spec("css21", "text.html#indentation-prop")] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void NegativeTextIndent_PullsTheFirstLineOutOfTheBlock() { Assert.AreEqual(30, FirstWordLeft( @@ -113,6 +121,7 @@ public void NegativeTextIndent_PullsTheFirstLineOutOfTheBlock() [TestMethod] [Spec("css21", "text.html#indentation-prop")] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndent_IsInheritedByABlockChild() { Assert.AreEqual(40, FirstWordLeft( @@ -122,6 +131,7 @@ public void TextIndent_IsInheritedByABlockChild() [TestMethod] [Spec("css21", "text.html#indentation-prop")] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndent_DoesntApplyAfterALineBreak() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='p' style='text-indent:40px'>aa<br>bb</div>")); @@ -135,6 +145,7 @@ public void TextIndent_DoesntApplyAfterALineBreak() [TestMethod] [Spec("css21", "text.html#indentation-prop")] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void PercentageTextIndent_ResolvesAgainstTheContainingBlockWidth() { Assert.AreEqual(40, FirstWordLeft( @@ -145,6 +156,7 @@ public void PercentageTextIndent_ResolvesAgainstTheContainingBlockWidth() [TestMethod] [Spec("css21", "text.html#indentation-prop")] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndent_DoesntApplyToAnAnonymousBlockThatIsntTheFirstChild() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='p' style='text-indent:40px'><div>bb</div>cc</div>")); @@ -157,7 +169,9 @@ public void TextIndent_DoesntApplyToAnAnonymousBlockThatIsntTheFirstChild() [TestMethod] [Spec("css21", "text.html#indentation-prop")] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] [Spec("css-text-3", "#bidi-linebox")] + [Spec("css-text-4", "#bidi-linebox")] public void TextIndent_InRtl_IndentsFromTheRightEdge() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -171,6 +185,7 @@ public void TextIndent_InRtl_IndentsFromTheRightEdge() [TestMethod] [Spec("css21", "text.html#indentation-prop")] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndent_IndentsTheFirstLineOfAnInlineBlock() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextTransformIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextTransformIntegrationTests.cs index 3104b7bbd..b897faca4 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextTransformIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextTransformIntegrationTests.cs @@ -10,6 +10,7 @@ public sealed class TextTransformIntegrationTests [TestMethod] [Spec("css21", "text.html#caps-prop")] [Spec("css-text-3", "#text-transform-property")] + [Spec("css-text-4", "#text-transform-property")] [DataRow("uppercase", "abc dEF", "ABC DEF")] [DataRow("lowercase", "abc dEF", "abc def")] [DataRow("capitalize", "abc dEF", "Abc DEF")] diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs index be05eb2ff..63d696f08 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs @@ -13,6 +13,7 @@ public sealed class WhiteSpaceLayoutIntegrationTests [TestMethod] [Spec("css21", "text.html#white-space-prop")] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] public void Pre_PreservesMultipleConsecutiveSpacesAsLiteralWord() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p' style='white-space:pre'>A B</p>")); @@ -25,6 +26,7 @@ public void Pre_PreservesMultipleConsecutiveSpacesAsLiteralWord() [TestMethod] [Spec("css21", "text.html#white-space-prop")] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] public void Normal_CollapsesConsecutiveSpaces_NoLiteralSpaceWord() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p'>A B</p>")); @@ -37,6 +39,7 @@ public void Normal_CollapsesConsecutiveSpaces_NoLiteralSpaceWord() [TestMethod] [Spec("css21", "text.html#white-space-prop")] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] public void Pre_TreatsExplicitNewlineAsForcedLineBreak() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p' style='white-space:pre'>A\nB</p>")); @@ -48,6 +51,7 @@ public void Pre_TreatsExplicitNewlineAsForcedLineBreak() [TestMethod] [Spec("css21", "text.html#white-space-prop")] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] public void Pre_TreatsCrLfAsASingleNewline() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p' style='white-space:pre'>A\r\nB</p>")); @@ -70,6 +74,7 @@ public void Pre_TreatsALoneCarriageReturnAsANewline() [TestMethod] [Spec("css21", "text.html#white-space-prop")] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] public void Pre_DoesntWrapAtTheLineBoxEdge() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p' style='margin:0;white-space:pre;width:30px'>AAAA BBBB</p>")); @@ -81,6 +86,7 @@ public void Pre_DoesntWrapAtTheLineBoxEdge() [TestMethod] [Spec("css21", "text.html#white-space-prop")] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] public void Normal_IgnoresEmbeddedNewline_NoForcedBreak() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p'>A\nB</p>")); @@ -92,6 +98,8 @@ public void Normal_IgnoresEmbeddedNewline_NoForcedBreak() [TestMethod] [Spec("css21", "text.html#white-space-prop")] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] + [Spec("css-text-4", "#text-wrapping")] public void NoWrap_PreventsWrapping_EvenWhenNarrowerThanContent() { var html = LayoutHarness.Wrap("<p id='p' style='white-space:nowrap; width:50px'>a long run of unwrapped text here</p>"); @@ -104,6 +112,8 @@ public void NoWrap_PreventsWrapping_EvenWhenNarrowerThanContent() [TestMethod] [Spec("css21", "text.html#white-space-prop")] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] + [Spec("css-text-4", "#text-wrapping")] public void Normal_WrapsAtNarrowWidth_ForContrastWithNoWrap() { var html = LayoutHarness.Wrap("<p id='p' style='width:50px'>a long run of unwrapped text here</p>"); @@ -116,6 +126,7 @@ public void Normal_WrapsAtNarrowWidth_ForContrastWithNoWrap() [TestMethod] [Spec("css21", "text.html#white-space-prop")] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] public void PreWrap_PreservesSpacesAndWraps() { var words = Words("<p id='p' style='margin:0;white-space:pre-wrap;width:40px'>AAAA BBBB CCCC</p>"); @@ -127,6 +138,7 @@ public void PreWrap_PreservesSpacesAndWraps() [TestMethod] [Spec("css21", "text.html#white-space-prop")] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] public void PreLine_CollapsesSpacesButKeepsNewlines() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p' style='margin:0;white-space:pre-line'>A B\nC</p>")); @@ -141,6 +153,7 @@ public void PreLine_CollapsesSpacesButKeepsNewlines() [TestMethod] [Spec("css21", "text.html#white-space-prop")] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] public void PreLine_WrapsAtTheLineBoxEdge() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id='p' style='margin:0;white-space:pre-line;width:40px'>AAAA BBBB CCCC</p>")); @@ -152,6 +165,7 @@ public void PreLine_WrapsAtTheLineBoxEdge() [TestMethod] [Spec("css21", "text.html#white-space-prop")] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] public void WhiteSpace_IsInherited() { var words = Words("<div style='white-space:pre'><p id='p' style='margin:0'>A B</p></div>"); @@ -162,6 +176,7 @@ public void WhiteSpace_IsInherited() [TestMethod] [Spec("css21", "text.html#white-space-model")] [Spec("css-text-3", "#line-break-transform")] + [Spec("css-text-4", "#line-break-transform")] public void Normal_TransformsALinefeedAndTheSpacesAroundItIntoOneSpace() { var expected = GapBetween("<p id='p' style='margin:0'>AA BB</p>", "AA", "BB"); @@ -172,6 +187,7 @@ public void Normal_TransformsALinefeedAndTheSpacesAroundItIntoOneSpace() [TestMethod] [Spec("css21", "text.html#white-space-model")] [Spec("css-text-3", "#white-space-phase-1")] + [Spec("css-text-4", "#white-space-phase-1")] public void Normal_ConvertsATabToASpace() { var expected = GapBetween("<p id='p' style='margin:0'>AA BB</p>", "AA", "BB"); @@ -182,6 +198,7 @@ public void Normal_ConvertsATabToASpace() [TestMethod] [Spec("css21", "text.html#white-space-model")] [Spec("css-text-3", "#white-space-phase-2")] + [Spec("css-text-4", "#white-space-phase-2")] public void Normal_RemovesASpaceAtTheBeginningOfALine() { var word = Words("<p id='p' style='margin:0'> AA</p>").Single(); @@ -192,6 +209,7 @@ public void Normal_RemovesASpaceAtTheBeginningOfALine() [TestMethod] [Spec("css21", "text.html#white-space-model")] [Spec("css-text-3", "#white-space-phase-1")] + [Spec("css-text-4", "#white-space-phase-1")] public void Normal_CollapsesASpaceAtTheStartOfAnInlineWithOneBeforeIt_WhenTheFirstHasNoSpaceAfter() { var expected = GapBetween("<p id='p' style='margin:0'>AA BB</p>", "AA", "BB"); @@ -203,6 +221,7 @@ public void Normal_CollapsesASpaceAtTheStartOfAnInlineWithOneBeforeIt_WhenTheFir [TestMethod] [Spec("css21", "text.html#white-space-model")] [Spec("css-text-3", "#white-space-phase-1")] + [Spec("css-text-4", "#white-space-phase-1")] public void Normal_RemovesASpaceFollowingAnotherSpaceAcrossAnElementBoundary() { var expected = GapBetween("<p id='p' style='margin:0'>AA BB</p>", "AA", "BB"); @@ -214,6 +233,7 @@ public void Normal_RemovesASpaceFollowingAnotherSpaceAcrossAnElementBoundary() [TestMethod] [Spec("css21", "text.html#white-space-model")] [Spec("css-text-3", "#white-space-phase-2")] + [Spec("css-text-4", "#white-space-phase-2")] public void Pre_RendersATabUpToTheNextTabStop() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -228,6 +248,7 @@ public void Pre_RendersATabUpToTheNextTabStop() [TestMethod] [Spec("css21", "text.html#white-space-model")] [Spec("css-text-3", "#white-space-phase-1")] + [Spec("css-text-4", "#white-space-phase-1")] public void NoBreakSpaces_ArentCollapsed() { var expected = GapBetween("<p id='p' style='margin:0'>A B</p>", "A", "B"); @@ -239,6 +260,7 @@ public void NoBreakSpaces_ArentCollapsed() [TestMethod] [Spec("css21", "text.html#white-space-model")] [Spec("css-text-3", "#white-space-phase-1")] + [Spec("css-text-4", "#white-space-phase-1")] public void Nbsp_OnlyContent_ProducesNonZeroHeight_MatchingRealText() { var (nbspRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='b'>&nbsp;</div>")); @@ -255,7 +277,8 @@ public void Nbsp_OnlyContent_ProducesNonZeroHeight_MatchingRealText() [TestMethod] [Spec("css21", "text.html#white-space-model")] - [Spec("css-text-3", "#white-space-phase-1")] + [Spec("css-text-3", "#white-space-phase-2")] + [Spec("css-text-4", "#white-space-phase-2")] public void OrdinaryWhitespaceOnlyContent_StillProducesZeroHeight_NoRegression() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='b'> </div>")); @@ -269,6 +292,7 @@ public void OrdinaryWhitespaceOnlyContent_StillProducesZeroHeight_NoRegression() [TestMethod] [Spec("css21", "text.html#white-space-model")] [Spec("css-text-3", "#white-space-phase-1")] + [Spec("css-text-4", "#white-space-phase-1")] public void Nbsp_BetweenTokens_PreventsLineWrap_ContrastOrdinarySpace() { // Narrow enough that an ordinary space between "10" and "km" wraps. @@ -287,6 +311,7 @@ public void Nbsp_BetweenTokens_PreventsLineWrap_ContrastOrdinarySpace() [TestMethod] [Spec("css21", "text.html#white-space-model")] [Spec("css-text-3", "#line-break-details")] + [Spec("css-text-4", "#line-break-details")] public void ElementBoundaryWithoutWhiteSpace_IsntALineBreakingOpportunity() { var words = Words("<p id='p' style='margin:0;width:60px'>AAAA<b>BBBB</b></p>"); @@ -298,6 +323,7 @@ public void ElementBoundaryWithoutWhiteSpace_IsntALineBreakingOpportunity() [TestMethod] [Spec("css21", "text.html#white-space-model")] [Spec("css-text-3", "#line-break-details")] + [Spec("css-text-4", "#line-break-details")] public void Float_DoesntIntroduceALineBreakingOpportunity() { var words = Words("<p id='p' style='margin:0;width:60px'>AAAA<span style='float:right'>x</span>BBBB</p>"); @@ -309,7 +335,8 @@ public void Float_DoesntIntroduceALineBreakingOpportunity() [TestMethod] [Spec("css21", "text.html#ctrlchars")] - [Spec("css-text-3", "#white-space-processing")] + [Spec("css-text-3", "#characters")] + [Spec("css-text-4", "#characters")] public void CombiningCharacter_StaysWithItsBaseCharacter() { var word = Words("<p id='p' style='margin:0'>öx</p>").Single(); @@ -317,10 +344,22 @@ public void CombiningCharacter_StaysWithItsBaseCharacter() Assert.AreEqual("öx", word.Text); } + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#characters")] + [Spec("css-text-4", "#characters")] + public void BreakAll_DoesntSeparateACombiningMarkFromItsBase() + { + var words = Words("<p id='p' style='margin:0;width:1px;word-break:break-all'>öx</p>"); + + Assert.IsTrue(words.Any(w => w.Text == "ö")); + } + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "text.html#ctrlchars")] [Spec("css-text-3", "#white-space-processing")] + [Spec("css-text-4", "#white-space-processing")] [DataRow("\u000B")] [DataRow("\u0085")] public void ControlCharacter_IsRenderedLikeANormalCharacter(string control) @@ -332,6 +371,7 @@ public void ControlCharacter_IsRenderedLikeANormalCharacter(string control) [TestMethod] [Spec("css-text-3", "#word-break-property")] + [Spec("css-text-4", "#word-break-property")] public void BreakAll_ForcesMidWordBreak_ContrastNormal() { // No space anywhere: "normal" has no break opportunity and overflows, "break-all" wraps. diff --git a/Source/Test/HtmlRenderer.Test/Css/CssText3PropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssText3PropertyTests.cs index 7046f95cc..a9adc6da2 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CssText3PropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CssText3PropertyTests.cs @@ -10,6 +10,7 @@ public sealed class CssText3PropertyTests { [TestMethod] [Spec("css-text-3", "#text-transform-property")] + [Spec("css-text-4", "#text-transform-property")] [DataRow("none")] [DataRow("capitalize")] [DataRow("uppercase")] @@ -23,6 +24,7 @@ public void TextTransform_SingleKeywords_AreParsed(string value) [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#text-transform-property")] + [Spec("css-text-4", "#text-transform-property")] [DataRow("full-size-kana")] [DataRow("uppercase full-width")] public void TextTransform_FullSizeKanaAndCombinations_AreParsed(string value) @@ -33,11 +35,21 @@ public void TextTransform_FullSizeKanaAndCombinations_AreParsed(string value) [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] [DataRow("start")] [DataRow("end")] + [Spec("css-logical-1", "#text-align")] + public void TextAlign_FlowRelativeKeywords_AreParsed(string value) + { + Assert.AreEqual(value, Value("text-align", value)); + } + + [Ignore("Gap, see Spec/css-text-3.yml")] + [TestMethod] + [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] [DataRow("match-parent")] [DataRow("justify-all")] - [Spec("css-logical-1", "#text-align")] public void TextAlign_Text3Keywords_AreParsed(string value) { Assert.AreEqual(value, Value("text-align", value)); @@ -46,6 +58,7 @@ public void TextAlign_Text3Keywords_AreParsed(string value) [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#text-align-last-property")] + [Spec("css-text-4", "#text-align-last-property")] public void TextAlignLast_MatchParent_IsParsed() { Assert.AreEqual("match-parent", Value("text-align-last", "match-parent")); @@ -54,6 +67,7 @@ public void TextAlignLast_MatchParent_IsParsed() [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] public void WhiteSpace_BreakSpaces_IsParsed() { Assert.AreEqual("break-spaces", Value("white-space", "break-spaces")); @@ -61,6 +75,7 @@ public void WhiteSpace_BreakSpaces_IsParsed() [TestMethod] [Spec("css-text-3", "#hyphenation")] + [Spec("css-text-4", "#hyphens-property")] [DataRow("none")] [DataRow("manual")] [DataRow("auto")] @@ -72,6 +87,7 @@ public void Hyphens_IsParsed(string value) [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#overflow-wrap-property")] + [Spec("css-text-4", "#overflow-wrap-property")] public void OverflowWrap_Anywhere_IsParsed() { Assert.AreEqual("anywhere", Value("overflow-wrap", "anywhere")); @@ -80,6 +96,7 @@ public void OverflowWrap_Anywhere_IsParsed() [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#word-break-property")] + [Spec("css-text-4", "#word-break-property")] public void WordBreak_BreakWord_IsParsed() { Assert.AreEqual("break-word", Value("word-break", "break-word")); diff --git a/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs index 8628de491..20f636401 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontKeywordPropertyTests.cs @@ -429,6 +429,7 @@ public void CssFontStretchSemiExpandedSpaceBetweenIllegal() [TestMethod] [Spec("css-text-3", "#letter-spacing-property")] + [Spec("css-text-4", "#letter-spacing-property")] public void CssLetterSpacingLengthPxLegal() { var property = ParseDeclaration("letter-spacing: 3px "); @@ -443,6 +444,7 @@ public void CssLetterSpacingLengthPxLegal() [TestMethod] [Spec("css-text-3", "#letter-spacing-property")] + [Spec("css-text-4", "#letter-spacing-property")] public void CssLetterSpacingLengthFloatPxLegal() { var property = ParseDeclaration("letter-spacing: .3px "); @@ -457,6 +459,7 @@ public void CssLetterSpacingLengthFloatPxLegal() [TestMethod] [Spec("css-text-3", "#letter-spacing-property")] + [Spec("css-text-4", "#letter-spacing-property")] public void CssLetterSpacingLengthFloatEmLegal() { var property = ParseDeclaration("letter-spacing: 0.3em "); @@ -471,6 +474,7 @@ public void CssLetterSpacingLengthFloatEmLegal() [TestMethod] [Spec("css-text-3", "#letter-spacing-property")] + [Spec("css-text-4", "#letter-spacing-property")] public void CssLetterSpacingNormalLegal() { var property = ParseDeclaration("letter-spacing: normal "); diff --git a/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs index d13341e99..729e79382 100644 --- a/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs @@ -10,6 +10,7 @@ public sealed class TextPropertyTests { [TestMethod] [Spec("css-text-3", "#word-spacing-property")] + [Spec("css-text-4", "#word-spacing-property")] public void WordSpacingZeroLengthLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-spacing: 0"); @@ -24,6 +25,7 @@ public void WordSpacingZeroLengthLegal() [TestMethod] [Spec("css-text-3", "#word-spacing-property")] + [Spec("css-text-4", "#word-spacing-property")] public void WordSpacingLengthFloatRemLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-spacing: .3rem "); @@ -38,6 +40,7 @@ public void WordSpacingLengthFloatRemLegal() [TestMethod] [Spec("css-text-3", "#word-spacing-property")] + [Spec("css-text-4", "#word-spacing-property")] public void WordSpacingLengthFloatEmLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-spacing: 0.3em "); @@ -52,6 +55,7 @@ public void WordSpacingLengthFloatEmLegal() [TestMethod] [Spec("css-text-3", "#word-spacing-property")] + [Spec("css-text-4", "#word-spacing-property")] public void WordSpacingNormalLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-spacing: normal "); @@ -142,6 +146,7 @@ public void TextShadowLegalColorRgbaLast() [TestMethod] [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] public void TextAlignLegalJustify() { var property = CssConstructionFunctions.ParseDeclaration("text-align:justify"); @@ -156,6 +161,7 @@ public void TextAlignLegalJustify() [TestMethod] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndentLegalLength() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:3em"); @@ -170,6 +176,7 @@ public void TextIndentLegalLength() [TestMethod] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndentLegalZero() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:0"); @@ -184,6 +191,7 @@ public void TextIndentLegalZero() [TestMethod] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndentLegalPercent() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:10%"); @@ -198,6 +206,7 @@ public void TextIndentLegalPercent() [TestMethod] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndentIllegalNone() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:none"); @@ -210,6 +219,7 @@ public void TextIndentIllegalNone() [TestMethod] [Ignore("Gap, see Spec/css-text-3.yml")] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndentLegalHanging() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:40pt hanging"); @@ -225,6 +235,7 @@ public void TextIndentLegalHanging() [TestMethod] [Ignore("Gap, see Spec/css-text-3.yml")] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndentLegalEachLine() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:40pt each-line"); @@ -240,6 +251,7 @@ public void TextIndentLegalEachLine() [TestMethod] [Ignore("Gap, see Spec/css-text-3.yml")] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndentLegalHangingEachLine() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:40pt hanging each-line"); @@ -255,7 +267,8 @@ public void TextIndentLegalHangingEachLine() [TestMethod] [Ignore("Gap, see Spec/css-text-3.yml")] [Spec("css-text-3", "#text-indent-property")] - public void TextIndentLegalReorderedKeywordsPreservesAuthoredOrder() + [Spec("css-text-4", "#text-indent-property")] + public void TextIndentLegalReorderedKeywordsSerializeInCanonicalOrder() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:each-line hanging 40pt"); Assert.AreEqual("text-indent", property.Name); @@ -269,6 +282,7 @@ public void TextIndentLegalReorderedKeywordsPreservesAuthoredOrder() [TestMethod] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndentIllegalHangingAloneMissingLength() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:hanging"); @@ -280,6 +294,7 @@ public void TextIndentIllegalHangingAloneMissingLength() [TestMethod] [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndentIllegalDuplicateKeyword() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:40pt hanging hanging"); @@ -435,6 +450,7 @@ public void TextDecorationExpansionAndRecombination() [TestMethod] [Spec("css-text-3", "#word-break-property")] + [Spec("css-text-4", "#word-break-property")] public void WordBreakNormalLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-break : normal"); @@ -449,6 +465,7 @@ public void WordBreakNormalLegal() [TestMethod] [Spec("css-text-3", "#word-break-property")] + [Spec("css-text-4", "#word-break-property")] public void WordBreakBreakAllLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-break : break-all"); @@ -463,6 +480,7 @@ public void WordBreakBreakAllLegal() [TestMethod] [Spec("css-text-3", "#word-break-property")] + [Spec("css-text-4", "#word-break-property")] public void WordBreakKeepAllLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-break : keep-all"); @@ -477,6 +495,7 @@ public void WordBreakKeepAllLegal() [TestMethod] [Spec("css-text-3", "#word-break-property")] + [Spec("css-text-4", "#word-break-property")] public void WordBreakNoneIllegal() { var property = CssConstructionFunctions.ParseDeclaration("word-break : none"); @@ -489,6 +508,7 @@ public void WordBreakNoneIllegal() [TestMethod] [Spec("css-text-3", "#text-align-last-property")] + [Spec("css-text-4", "#text-align-last-property")] public void TextAlignLastAutoLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: auto"); @@ -503,6 +523,7 @@ public void TextAlignLastAutoLegal() [TestMethod] [Spec("css-text-3", "#text-align-last-property")] + [Spec("css-text-4", "#text-align-last-property")] public void TextAlignLastStartLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: start"); @@ -517,6 +538,7 @@ public void TextAlignLastStartLegal() [TestMethod] [Spec("css-text-3", "#text-align-last-property")] + [Spec("css-text-4", "#text-align-last-property")] public void TextAlignLastEndLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: end"); @@ -531,6 +553,7 @@ public void TextAlignLastEndLegal() [TestMethod] [Spec("css-text-3", "#text-align-last-property")] + [Spec("css-text-4", "#text-align-last-property")] public void TextAlignLastRightLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: right"); @@ -545,6 +568,7 @@ public void TextAlignLastRightLegal() [TestMethod] [Spec("css-text-3", "#text-align-last-property")] + [Spec("css-text-4", "#text-align-last-property")] public void TextAlignLastLeftLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: left"); @@ -559,6 +583,7 @@ public void TextAlignLastLeftLegal() [TestMethod] [Spec("css-text-3", "#text-align-last-property")] + [Spec("css-text-4", "#text-align-last-property")] public void TextAlignLastCenterLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: center"); @@ -573,6 +598,7 @@ public void TextAlignLastCenterLegal() [TestMethod] [Spec("css-text-3", "#text-align-last-property")] + [Spec("css-text-4", "#text-align-last-property")] public void TextAlignLastJustifyLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: justify"); @@ -587,6 +613,7 @@ public void TextAlignLastJustifyLegal() [TestMethod] [Spec("css-text-3", "#text-align-last-property")] + [Spec("css-text-4", "#text-align-last-property")] public void TextAlignLastNoneIllegal() { var property = CssConstructionFunctions.ParseDeclaration("text-align-last: none"); @@ -649,6 +676,7 @@ public void TextAnchorNoneIllegal() [TestMethod] [Spec("css-text-3", "#text-justify-property")] + [Spec("css-text-4", "#text-justify-property")] public void TextJustifyAutoLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-justify: auto"); @@ -663,6 +691,7 @@ public void TextJustifyAutoLegal() [TestMethod] [Spec("css-text-3", "#text-justify-property")] + [Spec("css-text-4", "#text-justify-property")] public void TextJustifyDistributeLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-justify: distribute"); @@ -676,31 +705,27 @@ public void TextJustifyDistributeLegal() } [TestMethod] + [Ignore("Gap, see Spec/css-text-3.yml")] [Spec("css-text-3", "#text-justify-property")] - public void TextJustifyDistributeAllLinesLegal() + [Spec("css-text-4", "#text-justify-property")] + public void TextJustifyDistributeAllLinesIllegal() { var property = CssConstructionFunctions.ParseDeclaration("text-justify: distribute-all-lines"); Assert.AreEqual("text-justify", property.Name); - Assert.IsFalse(property.IsInherited); - Assert.IsFalse(property.IsImportant); Assert.IsInstanceOfType<TextJustifyProperty>(property); - var concrete = (TextJustifyProperty)property; - Assert.IsTrue(property.HasValue); - Assert.AreEqual("distribute-all-lines", concrete.Value); + Assert.IsFalse(property.HasValue); } [TestMethod] + [Ignore("Gap, see Spec/css-text-3.yml")] [Spec("css-text-3", "#text-justify-property")] - public void TextJustifyDistributeCenterLastLegal() + [Spec("css-text-4", "#text-justify-property")] + public void TextJustifyDistributeCenterLastIllegal() { var property = CssConstructionFunctions.ParseDeclaration("text-justify: distribute-center-last"); Assert.AreEqual("text-justify", property.Name); - Assert.IsFalse(property.IsInherited); - Assert.IsFalse(property.IsImportant); Assert.IsInstanceOfType<TextJustifyProperty>(property); - var concrete = (TextJustifyProperty)property; - Assert.IsTrue(property.HasValue); - Assert.AreEqual("distribute-center-last", concrete.Value); + Assert.IsFalse(property.HasValue); } [TestMethod] @@ -731,6 +756,7 @@ public void TextJustifyInterIdeographLegal() [TestMethod] [Spec("css-text-3", "#text-justify-property")] + [Spec("css-text-4", "#text-justify-property")] public void TextJustifyInterWordLegal() { var property = CssConstructionFunctions.ParseDeclaration("text-justify: inter-word"); @@ -783,6 +809,7 @@ public void TextJustifyNoneIllegal() [TestMethod] [Spec("css-text-3", "#overflow-wrap-property")] + [Spec("css-text-4", "#overflow-wrap-property")] public void OverflowWrapNormalLegal() { var property = CssConstructionFunctions.ParseDeclaration("overflow-wrap: normal"); @@ -797,6 +824,7 @@ public void OverflowWrapNormalLegal() [TestMethod] [Spec("css-text-3", "#overflow-wrap-property")] + [Spec("css-text-4", "#overflow-wrap-property")] public void OverflowWrapAlternateNameNormalLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-wrap: normal"); @@ -811,6 +839,7 @@ public void OverflowWrapAlternateNameNormalLegal() [TestMethod] [Spec("css-text-3", "#overflow-wrap-property")] + [Spec("css-text-4", "#overflow-wrap-property")] public void OverflowWrapBreakWordLegal() { var property = CssConstructionFunctions.ParseDeclaration("overflow-wrap: break-word"); @@ -825,6 +854,7 @@ public void OverflowWrapBreakWordLegal() [TestMethod] [Spec("css-text-3", "#overflow-wrap-property")] + [Spec("css-text-4", "#overflow-wrap-property")] public void OverflowWrapAlternateNameBreakWordLegal() { var property = CssConstructionFunctions.ParseDeclaration("word-wrap: break-word"); @@ -839,6 +869,7 @@ public void OverflowWrapAlternateNameBreakWordLegal() [TestMethod] [Spec("css-text-3", "#overflow-wrap-property")] + [Spec("css-text-4", "#overflow-wrap-property")] public void OverflowWrapNoneIllegal() { var property = CssConstructionFunctions.ParseDeclaration("overflow-wrap: none"); @@ -851,6 +882,7 @@ public void OverflowWrapNoneIllegal() [TestMethod] [Spec("css-text-3", "#overflow-wrap-property")] + [Spec("css-text-4", "#overflow-wrap-property")] public void OverflowWrapAlternateNameNoneIllegal() { var property = CssConstructionFunctions.ParseDeclaration("word-wrap: none"); diff --git a/Source/Test/HtmlRenderer.Test/Css/WhiteSpacePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/WhiteSpacePropertyTests.cs index b570a63c8..f599427b4 100644 --- a/Source/Test/HtmlRenderer.Test/Css/WhiteSpacePropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/WhiteSpacePropertyTests.cs @@ -16,6 +16,7 @@ private static string GetWhiteSpace(string declaration) [TestMethod] [Spec("css21", "text.html#white-space-prop")] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] [DataRow("normal")] [DataRow("pre")] [DataRow("nowrap")] @@ -29,6 +30,7 @@ public void WhiteSpaceKeywordLegal(string keyword) [TestMethod] [Spec("css21", "text.html#white-space-prop")] [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] public void WhiteSpaceInvalidKeywordIllegal() { Assert.IsTrue(string.IsNullOrEmpty(GetWhiteSpace("white-space: wavy"))); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssText4Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssText4Tests.cs new file mode 100644 index 000000000..4b74841b0 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssText4Tests.cs @@ -0,0 +1,283 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.CssModules; + +/// <summary>The values CSS Text Level 4 adds over Level 3, which the CSS engine doesn't accept yet.</summary> +[TestClass] +public sealed class CssText4Tests +{ + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#text-transform-property")] + public void TextTransform_MathAuto_IsParsed() + { + Assert.AreEqual("math-auto", Value("text-transform", "math-auto")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#word-space-transform")] + [DataRow("space")] + [DataRow("ideographic-space auto-phrase")] + public void WordSpaceTransform_IsParsed(string value) + { + Assert.AreEqual(value, Value("word-space-transform", value)); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#white-space-property")] + [DataRow("collapse")] + [DataRow("preserve nowrap")] + public void WhiteSpace_LonghandCombinations_AreParsed(string value) + { + Assert.AreEqual(value, Value("white-space", value)); + } + + [TestMethod] + [Spec("css-text-4", "#white-space-property")] + public void WhiteSpace_KeywordWithALonghandValue_IsRejected() + { + Assert.AreEqual("", Value("white-space", "pre-line discard-after")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#white-space-collapsing")] + [DataRow("preserve")] + [DataRow("preserve-breaks")] + [DataRow("preserve-spaces")] + [DataRow("discard")] + public void WhiteSpaceCollapse_IsParsed(string value) + { + Assert.AreEqual(value, Value("white-space-collapse", value)); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#white-space-trim")] + public void WhiteSpaceTrim_IsParsed() + { + Assert.AreEqual("discard-before discard-after", Value("white-space-trim", "discard-before discard-after")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#tab-size-property")] + [DataRow("4")] + [DataRow("20px")] + public void TabSize_IsParsed(string value) + { + Assert.AreEqual(value, Value("tab-size", value)); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#text-wrap-mode")] + public void TextWrapMode_IsParsed() + { + Assert.AreEqual("nowrap", Value("text-wrap-mode", "nowrap")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#wrap-inside")] + public void WrapInside_IsParsed() + { + Assert.AreEqual("avoid", Value("wrap-inside", "avoid")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#wrap-before")] + [DataRow("wrap-before")] + [DataRow("wrap-after")] + public void WrapBeforeAfter_IsParsed(string property) + { + Assert.AreEqual("avoid-line", Value(property, "avoid-line")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#text-wrap-style")] + [DataRow("balance")] + [DataRow("pretty")] + [DataRow("stable")] + public void TextWrapStyle_IsParsed(string value) + { + Assert.AreEqual(value, Value("text-wrap-style", value)); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#text-wrap-shorthand")] + [DataRow("nowrap")] + [DataRow("wrap balance")] + public void TextWrap_IsParsed(string value) + { + Assert.AreEqual(value, Value("text-wrap", value)); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#word-break-property")] + [DataRow("manual")] + [DataRow("auto-phrase")] + public void WordBreak_Level4Keywords_AreParsed(string value) + { + Assert.AreEqual(value, Value("word-break", value)); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#line-break-property")] + [DataRow("strict")] + [DataRow("anywhere")] + public void LineBreak_IsParsed(string value) + { + Assert.AreEqual(value, Value("line-break", value)); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#hyphenate-character")] + public void HyphenateCharacter_IsParsed() + { + Assert.AreEqual("\"-\"", Value("hyphenate-character", "\"-\"")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#hyphenate-size-limits")] + public void HyphenateLimitZone_IsParsed() + { + Assert.AreEqual("10%", Value("hyphenate-limit-zone", "10%")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#hyphenate-char-limits")] + public void HyphenateLimitChars_IsParsed() + { + Assert.AreEqual("6 3 2", Value("hyphenate-limit-chars", "6 3 2")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#hyphenate-line-limits")] + [DataRow("hyphenate-limit-lines", "2")] + [DataRow("hyphenate-limit-last", "always")] + public void HyphenateLineLimits_AreParsed(string property, string value) + { + Assert.AreEqual(value, Value(property, value)); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#character-alignment")] + public void TextAlign_String_IsParsed() + { + Assert.AreEqual("\".\" center", Value("text-align", "\".\" center")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#text-align-all-property")] + public void TextAlignAll_IsParsed() + { + Assert.AreEqual("center", Value("text-align-all", "center")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#text-justify-property")] + [DataRow("none")] + [DataRow("inter-character")] + [DataRow("ruby")] + [DataRow("auto no-compress")] + public void TextJustify_Level4Keywords_AreParsed(string value) + { + Assert.AreEqual(value, Value("text-justify", value)); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#text-group-align-property")] + public void TextGroupAlign_IsParsed() + { + Assert.AreEqual("center", Value("text-group-align", "center")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#word-spacing-property")] + public void WordSpacing_Percentage_IsParsed() + { + Assert.AreEqual("10%", Value("word-spacing", "10%")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#letter-spacing-property")] + public void LetterSpacing_Percentage_IsParsed() + { + Assert.AreEqual("10%", Value("letter-spacing", "10%")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#line-padding-property")] + public void LinePadding_IsParsed() + { + Assert.AreEqual("2px", Value("line-padding", "2px")); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#text-autospace-property")] + [DataRow("normal")] + [DataRow("ideograph-alpha")] + [DataRow("no-autospace")] + public void TextAutospace_IsParsed(string value) + { + Assert.AreEqual(value, Value("text-autospace", value)); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#text-spacing-trim-property")] + [DataRow("trim-start")] + [DataRow("space-all")] + public void TextSpacingTrim_IsParsed(string value) + { + Assert.AreEqual(value, Value("text-spacing-trim", value)); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#text-spacing-property")] + [DataRow("none")] + [DataRow("auto")] + public void TextSpacing_IsParsed(string value) + { + Assert.AreEqual(value, Value("text-spacing", value)); + } + + [Ignore("Gap, see Spec/css-text-4.yml")] + [TestMethod] + [Spec("css-text-4", "#hanging-punctuation-property")] + [DataRow("first")] + [DataRow("first allow-end last")] + public void HangingPunctuation_IsParsed(string value) + { + Assert.AreEqual(value, Value("hanging-punctuation", value)); + } + + private static string Value(string property, string value) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ {property}: {value}; }}"); + return sheet.Rules.OfType<StyleRule>().Single().Style.GetPropertyValue(property); + } +} diff --git a/Spec/css-text-3.yml b/Spec/css-text-3.yml index 4cc3643e4..b9125767b 100644 --- a/Spec/css-text-3.yml +++ b/Spec/css-text-3.yml @@ -6,6 +6,11 @@ sections: '#intro': title: 1 Introduction status: n/a + '#characters': + title: 1.4 Characters and Letters + status: partial + gaps: + - "Text is split into UTF-16 code units, not extended grapheme clusters, e.g. 'word-break: break-all' separates a combining mark from its base" '#transforming': title: 2 Transforming Text status: parsed-only diff --git a/Spec/css-text-4.yml b/Spec/css-text-4.yml index df6f3d155..80ec4c45d 100644 --- a/Spec/css-text-4.yml +++ b/Spec/css-text-4.yml @@ -5,223 +5,265 @@ snapshot: https://www.w3.org/TR/2026/WD-css-text-4-20260814/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown - '#languages': - title: 1.3 Languages and Typesetting - status: unknown + status: n/a '#characters': title: 1.4 Characters and Letters - status: unknown - '#text-encoding': - title: 1.5 Text Processing - status: unknown + status: partial + gaps: + - "Text is split into UTF-16 code units, not extended grapheme clusters, see css-text-3 #characters" '#transforming': title: 2 Transforming Text - status: unknown + status: parsed-only '#text-transform-property': title: '2.1 Case Transforms: the text-transform property' - status: unknown + status: parsed-only + gaps: + - "text-transform is ignored, see css-text-3 #text-transform-property" + - full-size-kana, math-auto and combinations such as 'uppercase full-width' aren't accepted '#text-transform-mapping': title: 2.1.1 Mapping Rules - status: unknown + status: unsupported '#word-space-transform': title: '2.2 Expanding Between Words: the word-space-transform property' - status: unknown + status: unsupported '#text-transform-order': title: 2.3 Order of Operations - status: unknown + status: unsupported '#white-space-property': title: '3 White Space and Wrapping: the white-space property' - status: unknown + status: partial + gaps: + - Only the keywords normal, pre, nowrap, pre-wrap and pre-line are accepted, not longhand values such as 'preserve nowrap' + - break-spaces isn't accepted '#white-space-processing': title: 4 White Space Processing & Control Characters - status: unknown + status: partial + gaps: + - "Control characters such as U+000B are treated as white space instead of being rendered, see css-text-3 #white-space-processing" '#white-space-collapsing': title: '4.1 White Space Collapsing: the white-space-collapse property' - status: unknown + status: unsupported + gaps: + - white-space-collapse isn't accepted, collapsing is only controlled through the white-space keywords '#white-space-trim': title: '4.2 White Space Trimming: the white-space-trim property' - status: unknown + status: unsupported '#white-space-rules': title: 4.3 The White Space Processing Rules - status: unknown + status: partial '#white-space-phase-1': title: '4.3.1 Phase I: Collapsing and Transformation' - status: unknown + status: partial + gaps: + - "A space following another space isn't removed when an element boundary is between them, see css-text-3 #white-space-phase-1" + - "U+00A0 is collapsed and breakable like a space, see css-text-3 #white-space-phase-1" '#white-space-phase-2': title: '4.3.2 Phase II: Trimming and Positioning' - status: unknown + status: partial + gaps: + - Preserved tabs are as wide as their glyph instead of reaching the next tab stop '#line-break-transform': title: 4.3.3 Segment Break Transformation Rules - status: unknown + status: supported '#tab-size-property': title: '4.4 Tab Character Size: the tab-size property' - status: unknown + status: unsupported '#text-wrapping': title: 5 Text Wrapping - status: unknown + status: partial + gaps: + - Wrapping can only be turned off through white-space, text-wrap-mode and text-wrap aren't accepted '#text-wrap-mode': title: '5.1 Deciding Whether to Wrap: the text-wrap-mode property' - status: unknown + status: unsupported '#wrap-inside': title: '5.2 Controlling Breaks Within Boxes: the wrap-inside property' - status: unknown + status: unsupported '#example-avoid': title: "5.2.1 Example of using 'wrap-inside: avoid' in presenting a footer" - status: unknown + status: n/a '#wrap-before': title: '5.3 Controlling Breaks Between Boxes: the wrap-before/wrap-after properties' - status: unknown + status: unsupported '#text-wrap-style': title: '5.4 Selecting How to Wrap: the text-wrap-style property' - status: unknown + status: unsupported '#text-wrap-shorthand': title: '5.5 Joint Wrapping Control: the text-wrap shorthand property' - status: unknown + status: unsupported '#line-breaking': title: 6 Line Breaking and Word Boundaries - status: unknown + status: partial '#word-break-property': title: '6.1 Breaking Rules for Letters: the word-break property' - status: unknown + status: partial + gaps: + - keep-all doesn't keep CJK text together + - manual, auto-phrase and break-word aren't accepted '#analytical-word-breaking': title: 6.1.1 Analytical Word Breaking - status: unknown + status: unsupported '#lexical-breaking': title: 6.1.1.1 Lexical Word Breaking - status: unknown + status: unsupported '#orthographic-breaking': title: 6.1.1.2 Orthographic Breaking - status: unknown + status: unsupported '#fallback-breaking': title: 6.1.1.3 Fallback Breaking - status: unknown + status: unsupported '#phrase-pref': title: 6.1.2 Expressing User Preferences for Phrase-based Line Breaking - status: unknown + status: n/a + gaps: + - There are no user preferences or user style sheets '#line-break-property': title: '6.2 Line Breaking Strictness: the line-break property' - status: unknown + status: unsupported '#hyphenation': title: '6.3 Hyphenation: Morphological Breaking Within Words' - status: unknown + status: parsed-only '#hyphens-property': title: '6.3.1 Hyphenation Control: the hyphens property' - status: unknown + status: parsed-only + gaps: + - Soft hyphens (U+00AD) aren't break opportunities '#hyphenate-character': title: '6.3.2 Hyphens: the hyphenate-character property' - status: unknown + status: unsupported '#hyphenate-size-limits': title: '6.3.3 Hyphenation Size Limit: the hyphenate-limit-zone property' - status: unknown + status: unsupported '#hyphenate-char-limits': title: '6.3.4 Hyphenation Character Limits: the hyphenate-limit-chars property' - status: unknown + status: unsupported '#hyphenate-line-limits': title: '6.3.5 Hyphenation Line Limits: the hyphenate-limit-lines and hyphenate-limit-last properties' - status: unknown + status: unsupported '#overflow-wrap-property': title: '6.4 Overflow Wrapping: the overflow-wrap (word-wrap) property' - status: unknown + status: parsed-only + gaps: + - overflow-wrap and word-wrap are ignored, a word that doesn't fit overflows + - anywhere isn't accepted '#line-break-details': title: 6.5 Line Breaking Details - status: unknown + status: partial + gaps: + - A line can break at an element boundary or a float without white space in the text, see CSS 2.1 16.6.1 '#justification': title: 7 Alignment and Justification - status: unknown + status: partial '#text-align-property': title: '7.1 Text Alignment: the text-align shorthand' - status: unknown + status: partial + gaps: + - "start, end, match-parent, justify-all and <string> aren't accepted, see css-text-3 #text-align-property" + - The initial value acts as left also when direction is rtl, see CSS 2.1 16.2 '#character-alignment': title: 7.2 Character-based Alignment in a Table Column - status: unknown + status: unsupported '#text-align-all-property': title: '7.3 Default Text Alignment: the text-align-all property' - status: unknown + status: unsupported '#text-align-last-property': title: '7.4 Last Line Alignment: the text-align-last property' - status: unknown + status: parsed-only + gaps: + - text-align-last is ignored, the last line of justified text is aligned left + - match-parent isn't accepted '#text-justify-property': title: '7.5 Justification Method: the text-justify property' - status: unknown + status: parsed-only + gaps: + - none, inter-character, ruby and no-compress aren't accepted + - "The non-standard values distribute-all-lines and distribute-center-last are accepted, see css-text-3 #text-justify-property" '#expanding-text': title: 7.5.1 Expanding and Compressing Text - status: unknown + status: partial + gaps: + - Preserved spaces in pre and pre-wrap text are stretched too, see CSS 2.1 16.2 '#justify-symbols': title: 7.5.2 Handling Symbols and Punctuation - status: unknown + status: n/a '#justify-limits': title: 7.5.3 Unexpandable Text - status: unknown + status: n/a '#justify-cursive': title: 7.5.4 Cursive Scripts - status: unknown + status: n/a '#justify-algos': title: 7.5.5 Minimum Requirements for auto Justification - status: unknown + status: supported '#text-group-align-property': title: '7.6 Aligning a block of text within its container: the text-group-align property' - status: unknown + status: unsupported '#spacing': title: 8 Spacing - status: unknown + status: parsed-only '#word-spacing-property': title: '8.1 Word Spacing: the word-spacing property' - status: unknown + status: parsed-only + gaps: + - word-spacing has no effect, see CSS 2.1 16.4 + - Percentages aren't accepted '#letter-spacing-property': title: '8.2 Tracking: the letter-spacing property' - status: unknown + status: parsed-only + gaps: + - letter-spacing is ignored, see CSS 2.1 16.4 + - Percentages aren't accepted '#cursive-tracking': title: 8.2.1 Cursive Scripts - status: unknown + status: n/a '#line-padding-property': title: '8.3 Line Start/End Padding: the line-padding property' - status: unknown + status: unsupported '#text-autospace-property': title: '8.4 Automatic Contextual Spacing: the text-autospace property' - status: unknown + status: unsupported '#inter-script-spacing': title: 8.4.1 Inter-script Spacing - status: unknown + status: unsupported '#autospace-bidi': title: 8.4.2 Interaction with Bidirectional Text - status: unknown + status: unsupported '#text-spacing-trim-property': title: '8.5 CJK Punctuation Spacing: the text-spacing-trim property' - status: unknown + status: unsupported '#fullwidth-collapsing': title: 8.5.1 Fullwidth Punctuation Collapsing - status: unknown + status: unsupported '#text-spacing-classes': title: 8.5.2 Text Spacing Trim Character Classes - status: unknown + status: unsupported '#japanese-start-edges': title: 8.5.3 Japanese Paragraph-start Conventions in CSS - status: unknown + status: n/a '#text-spacing-property': title: '8.6 Character Class Spacing Shorthand: the text-spacing property' - status: unknown + status: unsupported '#boundary-shaping': title: 8.7 Shaping Across Element Boundaries - status: unknown + status: unsupported '#edge-effects': title: 9 Edge Effects - status: unknown + status: partial '#text-indent-property': title: '9.1 First Line Indentation: the text-indent property' - status: unknown + status: partial + gaps: + - hanging and each-line aren't accepted + - "The gaps of CSS 2.1 16.1 apply: percentages, anonymous blocks, rtl and inline-blocks" '#hanging': title: 9.2 Hanging Glyphs - status: unknown + status: unsupported '#hanging-punctuation-property': title: '9.2.1 Hanging Punctuation: the hanging-punctuation property' - status: unknown + status: unsupported '#bidi-linebox': title: 9.3 Bidirectionality and Line Boxes - status: unknown + status: partial + gaps: + - The line's direction doesn't change which side start and end refer to, see 7.1 + - In rtl text-indent is applied at the left edge, see CSS 2.1 16.1 From 25ad034605ae8bbc9874e319bcdcdbb461f3ee51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:31:09 +0100 Subject: [PATCH 103/150] Verify and document CSS Text Decoration Level 4 compliance --- .../CssTextDecor3PaintIntegrationTests.cs | 8 ++ .../CssTextDecor4PaintIntegrationTests.cs | 52 +++++++ .../TextDecorationPaintIntegrationTests.cs | 17 +++ .../Css/TextPropertyTests.cs | 17 ++- .../CssModules/CssTextDecor4Tests.cs | 134 ++++++++++++++++++ Spec/css-text-decor-4.yml | 103 ++++++++------ 6 files changed, 289 insertions(+), 42 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/CssTextDecor4PaintIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssTextDecor4Tests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssTextDecor3PaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssTextDecor3PaintIntegrationTests.cs index 73be781b6..b45ca1e9d 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssTextDecor3PaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssTextDecor3PaintIntegrationTests.cs @@ -12,6 +12,7 @@ public sealed class CssTextDecor3PaintIntegrationTests [TestMethod] [Spec("css-text-decor-3", "#text-decoration-property")] + [Spec("css-text-decor-4", "#text-decoration-property")] public void Shorthand_WithStyleAndColor_SetsTheLine() { var lines = Log("<span style='text-decoration:dotted underline rgb(255,0,0)'>text</span>").OfType<RecordingGraphics.DrawLineCall>(); @@ -22,6 +23,7 @@ public void Shorthand_WithStyleAndColor_SetsTheLine() [Ignore("Gap, see Spec/css-text-decor-3.yml")] [TestMethod] [Spec("css-text-decor-3", "#text-decoration-property")] + [Spec("css-text-decor-4", "#text-decoration-property")] public void Shorthand_SetsTheColor() { var line = Log("<span style='color:rgb(0,0,255);text-decoration:underline rgb(255,0,0)'>text</span>").OfType<RecordingGraphics.DrawLineCall>().Single(); @@ -32,6 +34,7 @@ public void Shorthand_SetsTheColor() [Ignore("Gap, see Spec/css-text-decor-3.yml")] [TestMethod] [Spec("css-text-decor-3", "#text-decoration-color-property")] + [Spec("css-text-decor-4", "#text-decoration-color-property")] public void DecorationColor_IsTheColorOfTheLine() { var line = Log("<span style='color:rgb(0,0,255);text-decoration-line:underline;text-decoration-color:rgb(255,0,0)'>text</span>").OfType<RecordingGraphics.DrawLineCall>().Single(); @@ -42,6 +45,7 @@ public void DecorationColor_IsTheColorOfTheLine() [Ignore("Gap, see Spec/css-text-decor-3.yml")] [TestMethod] [Spec("css-text-decor-3", "#text-decoration-style-property")] + [Spec("css-text-decor-4", "#text-decoration-style-property")] public void Double_DrawsTwoLines() { var lines = Log("<span style='text-decoration-line:underline;text-decoration-style:double'>text</span>").OfType<RecordingGraphics.DrawLineCall>(); @@ -52,6 +56,7 @@ public void Double_DrawsTwoLines() [Ignore("Gap, see Spec/css-text-decor-3.yml")] [TestMethod] [Spec("css-text-decor-3", "#text-shadow-property")] + [Spec("css-text-decor-4", "#text-shadow-property")] public void TextShadow_PaintsTheTextInTheShadowColor() { var strings = Log("<span style='color:black;text-shadow:2px 2px rgb(255,0,0)'>text</span>").OfType<RecordingGraphics.DrawStringCall>(); @@ -62,6 +67,7 @@ public void TextShadow_PaintsTheTextInTheShadowColor() [Ignore("Gap, see Spec/css-text-decor-3.yml")] [TestMethod] [Spec("css-text-decor-3", "#painting-order")] + [Spec("css-text-decor-4", "#painting-order")] [DataRow("underline")] [DataRow("overline")] public void UnderlineAndOverline_ArePaintedBelowTheText(string line) @@ -73,6 +79,7 @@ public void UnderlineAndOverline_ArePaintedBelowTheText(string line) [TestMethod] [Spec("css-text-decor-3", "#painting-order")] + [Spec("css-text-decor-4", "#painting-order")] public void LineThrough_IsPaintedOverTheText() { var log = Log("<span style='text-decoration:line-through'>text</span>"); @@ -82,6 +89,7 @@ public void LineThrough_IsPaintedOverTheText() [TestMethod] [Spec("css-text-decor-3", "#overflow")] + [Spec("css-text-decor-4", "#overflow")] public void Decorations_DontChangeTheLayout() { var (plain, _) = PaintHarness.Layout(PaintHarness.Wrap("<div id='d'>text</div>")); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssTextDecor4PaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssTextDecor4PaintIntegrationTests.cs new file mode 100644 index 000000000..5d60023e6 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssTextDecor4PaintIntegrationTests.cs @@ -0,0 +1,52 @@ +using HtmlRenderer.IntegrationTest.TestSupport; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[TestClass] +public sealed class CssTextDecor4PaintIntegrationTests +{ + [TestMethod] + [Spec("css-text-decor-4", "#line-position")] + [Spec("css-text-decor-4", "#text-line-constancy")] + [DataRow("font-size:30px")] + [DataRow("vertical-align:super")] + public void Underline_HasOnePositionPerLine(string style) + { + var lines = PageLines($"<div style='text-decoration:underline'>a <span style='{style}'>B</span> c</div>"); + + Assert.IsGreaterThan(0, lines.Count); + Assert.AreEqual(1, lines.Select(l => l.Y1).Distinct().Count()); + } + + [TestMethod] + [Spec("css-text-decor-4", "#text-decoration-skip-spaces-property")] + [DataRow("<div style='text-decoration:underline'>aaa bbb ccc</div>")] + [DataRow("<span style='text-decoration:underline'>aaa bbb ccc</span>")] + public void Underline_IsContinuousAcrossSpacesBetweenWords(string html) + { + AssertContinuous(PageLines(html)); + } + + [Ignore("Gap, see Spec/css-text-decor-4.yml")] + [TestMethod] + [Spec("css-text-decor-4", "#text-decoration-skip-spaces-property")] + public void Underline_IsContinuousAcrossSpacesNextToAnElementBoundary() + { + AssertContinuous(PageLines("<div style='text-decoration:underline'>aaa <span>bbb</span> ccc</div>")); + } + + private static void AssertContinuous(List<RecordingGraphics.DrawLineCall> lines) + { + lines = lines.OrderBy(l => l.X1).ToList(); + Assert.IsGreaterThan(0, lines.Count); + for (var i = 1; i < lines.Count; i++) + Assert.IsLessThanOrEqualTo(lines[i - 1].X2 + 0.5, lines[i].X1); + } + + private static List<RecordingGraphics.DrawLineCall> PageLines(string bodyHtml) + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap(bodyHtml)); + return PaintHarness.PaintPage(container).Log.OfType<RecordingGraphics.DrawLineCall>().ToList(); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs index 5b7e41c0f..2fecb352b 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs @@ -11,6 +11,8 @@ public sealed class TextDecorationPaintIntegrationTests [TestMethod] [Spec("css21", "text.html#lining-striking-props")] [Spec("css-text-decor-3", "#text-decoration-line-property")] + [Spec("css-text-decor-4", "#text-decoration-line-property")] + [Spec("css-text-decor-4", "#line-auto-offset")] public void Underline_DrawsLineBelowRectangleTop() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -25,11 +27,13 @@ public void Underline_DrawsLineBelowRectangleTop() var rect = s.Rectangles.Values.Single(); Assert.IsTrue(line.Y1 > rect.Top, "underline should sit below the rectangle's top edge"); Assert.IsTrue(line.Y1 <= rect.Bottom, "underline should still sit within the rectangle"); + Assert.AreEqual(rect.Top + s.ActualFont.UnderlineOffset, line.Y1, 1); } [TestMethod] [Spec("css21", "text.html#lining-striking-props")] [Spec("css-text-decor-3", "#text-decoration-line-property")] + [Spec("css-text-decor-4", "#text-decoration-line-property")] public void Overline_DrawsLineAtRectangleTop() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -47,6 +51,7 @@ public void Overline_DrawsLineAtRectangleTop() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] [Spec("css-text-decor-3", "#text-decoration-line-property")] + [Spec("css-text-decor-4", "#text-decoration-line-property")] public void LineThrough_DrawsLineNearRectangleMiddle() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -65,6 +70,7 @@ public void LineThrough_DrawsLineNearRectangleMiddle() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] [Spec("css-text-decor-3", "#text-decoration-line-property")] + [Spec("css-text-decor-4", "#text-decoration-line-property")] public void Underline_OverlineLineThrough_ProduceDifferentYPositions() { var underlineY = GetDecorationY("underline"); @@ -78,6 +84,7 @@ public void Underline_OverlineLineThrough_ProduceDifferentYPositions() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] [Spec("css-text-decor-3", "#text-decoration-line-property")] + [Spec("css-text-decor-4", "#text-decoration-line-property")] public void TextDecorationNone_DrawsNoLine() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -92,6 +99,7 @@ public void TextDecorationNone_DrawsNoLine() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] [Spec("css-text-decor-3", "#text-decoration-line-property")] + [Spec("css-text-decor-4", "#text-decoration-line-property")] public void SeveralLines_AreAllDrawn() { var lines = PageLines("<span style='text-decoration:underline overline'>text</span>"); @@ -102,6 +110,7 @@ public void SeveralLines_AreAllDrawn() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] [Spec("css-text-decor-3", "#text-decoration-line-property")] + [Spec("css-text-decor-4", "#text-decoration-line-property")] public void Blink_DrawsNoLine() { Assert.AreEqual(0, PageLines("<span style='text-decoration:blink'>text</span>").Count); @@ -110,6 +119,7 @@ public void Blink_DrawsNoLine() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] [Spec("css-text-decor-3", "#line-decoration")] + [Spec("css-text-decor-4", "#line-decoration")] public void Block_PropagatesTheDecorationToItsText() { Assert.AreEqual(1, PageLines("<div style='text-decoration:underline'>text</div>").Count); @@ -118,6 +128,7 @@ public void Block_PropagatesTheDecorationToItsText() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] [Spec("css-text-decor-3", "#line-decoration")] + [Spec("css-text-decor-4", "#line-decoration")] public void Inline_PropagatesTheDecorationToTheBlockThatSplitsIt() { Assert.AreEqual(3, PageLines("<span style='text-decoration:underline'>a<div>b</div>c</span>").Count); @@ -126,6 +137,7 @@ public void Inline_PropagatesTheDecorationToTheBlockThatSplitsIt() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] [Spec("css-text-decor-3", "#line-decoration")] + [Spec("css-text-decor-4", "#line-decoration")] public void Underline_SkipsMarginAndPadding() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -142,6 +154,8 @@ public void Underline_SkipsMarginAndPadding() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] [Spec("css-text-decor-3", "#line-decoration")] + [Spec("css-text-decor-4", "#line-decoration")] + [Spec("css-text-decor-4", "#text-decoration-skip-self-property")] public void Image_IsntUnderlined() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -158,6 +172,7 @@ public void Image_IsntUnderlined() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] [Spec("css-text-decor-3", "#line-decoration")] + [Spec("css-text-decor-4", "#line-decoration")] public void PropagatedDecoration_KeepsTheColorOfTheElementItIsSetOn() { var lines = PageLines("<div style='text-decoration:underline;color:rgb(0,0,255)'>a <span style='color:rgb(255,0,0)'>b</span></div>"); @@ -170,6 +185,7 @@ public void PropagatedDecoration_KeepsTheColorOfTheElementItIsSetOn() [TestMethod] [Spec("css21", "text.html#lining-striking-props")] [Spec("css-text-decor-3", "#line-decoration")] + [Spec("css-text-decor-4", "#line-decoration")] public void Descendant_CantRemoveTheDecorationOfAnAncestor() { Assert.AreEqual(2, PageLines("<div style='text-decoration:underline'>a <span style='text-decoration:none'>b</span></div>").Count); @@ -182,6 +198,7 @@ public void Descendant_CantRemoveTheDecorationOfAnAncestor() [DataRow("position:absolute;top:100px")] [DataRow("display:inline-block")] [Spec("css-text-decor-3", "#line-decoration")] + [Spec("css-text-decor-4", "#line-decoration")] public void Decoration_IsntPropagatedToFloatsAbsolutesAndInlineBlocks(string style) { Assert.AreEqual(1, PageLines($"<div style='text-decoration:underline'>a <span style='{style}'>b</span></div>").Count); diff --git a/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs index 729e79382..43a41ea50 100644 --- a/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs @@ -69,7 +69,7 @@ public void WordSpacingNormalLegal() } [TestMethod] - [Spec("css-text-decor-3", "#text-shadow-property")] + [Spec("css-text-decor-4", "#text-shadow-property")] public void TextShadowLegalInsetAtLast() { var property = CssConstructionFunctions.ParseDeclaration("text-shadow: 0 0 2px black inset"); @@ -84,6 +84,7 @@ public void TextShadowLegalInsetAtLast() [TestMethod] [Spec("css-text-decor-3", "#text-shadow-property")] + [Spec("css-text-decor-4", "#text-shadow-property")] public void TextShadowLegalColorInFront() { var property = CssConstructionFunctions.ParseDeclaration("text-shadow: rgba(255,255,255,0.5) 0px 3px 3px"); @@ -98,6 +99,7 @@ public void TextShadowLegalColorInFront() [TestMethod] [Spec("css-text-decor-3", "#text-shadow-property")] + [Spec("css-text-decor-4", "#text-shadow-property")] public void TextShadowLegalMultipleMultilines() { const string snippet = @"text-shadow: 0px 3px 0px #b2a98f, @@ -118,6 +120,7 @@ 0px 24px 2px rgba(0,0,0,0.1), [TestMethod] [Spec("css-text-decor-3", "#text-shadow-property")] + [Spec("css-text-decor-4", "#text-shadow-property")] public void TextShadowLegalMultipleInline() { var property = CssConstructionFunctions.ParseDeclaration("text-shadow: 4px 3px 0px #fff, 9px 8px 0px rgba(0,0,0,0.15)"); @@ -132,6 +135,7 @@ public void TextShadowLegalMultipleInline() [TestMethod] [Spec("css-text-decor-3", "#text-shadow-property")] + [Spec("css-text-decor-4", "#text-shadow-property")] public void TextShadowLegalColorRgbaLast() { var property = CssConstructionFunctions.ParseDeclaration("text-shadow: 2px 4px 3px rgba(0,0,0,0.3)"); @@ -306,6 +310,7 @@ public void TextIndentIllegalDuplicateKeyword() [TestMethod] [Spec("css-text-decor-3", "#text-decoration-property")] + [Spec("css-text-decor-4", "#text-decoration-property")] public void TextDecorationIllegal() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration: line-pass"); @@ -318,6 +323,7 @@ public void TextDecorationIllegal() [TestMethod] [Spec("css-text-decor-3", "#text-decoration-property")] + [Spec("css-text-decor-4", "#text-decoration-property")] public void TextDecorationLegalLineThrough() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration: line-Through"); @@ -332,6 +338,7 @@ public void TextDecorationLegalLineThrough() [TestMethod] [Spec("css-text-decor-3", "#text-decoration-property")] + [Spec("css-text-decor-4", "#text-decoration-property")] public void TextDecorationLegalUnderlineOverline() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration: underline overline"); @@ -346,6 +353,7 @@ public void TextDecorationLegalUnderlineOverline() [TestMethod] [Spec("css-text-decor-3", "#text-decoration-color-property")] + [Spec("css-text-decor-4", "#text-decoration-color-property")] public void TextDecorationColorLegalHex() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration-color: #F00"); @@ -360,6 +368,7 @@ public void TextDecorationColorLegalHex() [TestMethod] [Spec("css-text-decor-3", "#text-decoration-color-property")] + [Spec("css-text-decor-4", "#text-decoration-color-property")] public void TextDecorationColorLegalRed() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration-color: red"); @@ -374,6 +383,7 @@ public void TextDecorationColorLegalRed() [TestMethod] [Spec("css-text-decor-3", "#text-decoration-line-property")] + [Spec("css-text-decor-4", "#text-decoration-line-property")] public void TextDecorationLineIllegalInteger() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration-line: 5"); @@ -386,6 +396,7 @@ public void TextDecorationLineIllegalInteger() [TestMethod] [Spec("css-text-decor-3", "#text-decoration-line-property")] + [Spec("css-text-decor-4", "#text-decoration-line-property")] public void TextDecorationLineLegalNone() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration-line: none"); @@ -400,6 +411,7 @@ public void TextDecorationLineLegalNone() [TestMethod] [Spec("css-text-decor-3", "#text-decoration-line-property")] + [Spec("css-text-decor-4", "#text-decoration-line-property")] public void TextDecorationLineLegalOverlineUnderlineLineThrough() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration-line: overline underline line-through "); @@ -414,6 +426,7 @@ public void TextDecorationLineLegalOverlineUnderlineLineThrough() [TestMethod] [Spec("css-text-decor-3", "#text-decoration-style-property")] + [Spec("css-text-decor-4", "#text-decoration-style-property")] public void TextDecorationStyleLegalWavyUppercase() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration-style: WAVY "); @@ -428,6 +441,7 @@ public void TextDecorationStyleLegalWavyUppercase() [TestMethod] [Spec("css-text-decor-3", "#text-decoration-style-property")] + [Spec("css-text-decor-4", "#text-decoration-style-property")] public void TextDecorationStyleIllegalMultiple() { var property = CssConstructionFunctions.ParseDeclaration("text-decoration-style: wavy dotted"); @@ -440,6 +454,7 @@ public void TextDecorationStyleIllegalMultiple() [TestMethod] [Spec("css-text-decor-3", "#text-decoration-property")] + [Spec("css-text-decor-4", "#text-decoration-property")] public void TextDecorationExpansionAndRecombination() { const string snippet = ".centered {text-decoration:underline;}"; diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssTextDecor4Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssTextDecor4Tests.cs new file mode 100644 index 000000000..a9af0f0a9 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssTextDecor4Tests.cs @@ -0,0 +1,134 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.CssModules; + +/// <summary>The values CSS Text Decoration Level 4 adds over Level 3, which the CSS engine doesn't accept yet.</summary> +[TestClass] +public sealed class CssTextDecor4Tests +{ + [Ignore("Gap, see Spec/css-text-decor-4.yml")] + [TestMethod] + [Spec("css-text-decor-4", "#text-decoration-line-property")] + [DataRow("spelling-error")] + [DataRow("grammar-error")] + public void TextDecorationLine_ErrorKeywords_AreParsed(string value) + { + Assert.AreEqual(value, Value("text-decoration-line", value)); + } + + [Ignore("Gap, see Spec/css-text-decor-4.yml")] + [TestMethod] + [Spec("css-text-decor-4", "#text-decoration-thickness-property")] + [DataRow("from-font")] + [DataRow("3px")] + [DataRow("10%")] + public void TextDecorationThickness_IsParsed(string value) + { + Assert.AreEqual(value, Value("text-decoration-thickness", value)); + } + + [Ignore("Gap, see Spec/css-text-decor-4.yml")] + [TestMethod] + [Spec("css-text-decor-4", "#text-decoration-property")] + public void TextDecoration_TakesTheThickness() + { + Assert.AreEqual("3px", Value("text-decoration", "underline 3px", "text-decoration-thickness")); + } + + [Ignore("Gap, see Spec/css-text-decor-4.yml")] + [TestMethod] + [Spec("css-text-decor-4", "#text-underline-position-property")] + [DataRow("under")] + [DataRow("from-font")] + [DataRow("under left")] + public void TextUnderlinePosition_IsParsed(string value) + { + Assert.AreEqual(value, Value("text-underline-position", value)); + } + + [Ignore("Gap, see Spec/css-text-decor-4.yml")] + [TestMethod] + [Spec("css-text-decor-4", "#underline-offset")] + [DataRow("3px")] + [DataRow("10%")] + public void TextUnderlineOffset_IsParsed(string value) + { + Assert.AreEqual(value, Value("text-underline-offset", value)); + } + + [Ignore("Gap, see Spec/css-text-decor-4.yml")] + [TestMethod] + [Spec("css-text-decor-4", "#text-decoration-skipping")] + public void TextDecorationSkip_IsParsed() + { + Assert.AreEqual("none", Value("text-decoration-skip", "none")); + } + + [Ignore("Gap, see Spec/css-text-decor-4.yml")] + [TestMethod] + [Spec("css-text-decor-4", "#text-decoration-skip-self-property")] + public void TextDecorationSkipSelf_IsParsed() + { + Assert.AreEqual("none", Value("text-decoration-skip-self", "none")); + } + + [Ignore("Gap, see Spec/css-text-decor-4.yml")] + [TestMethod] + [Spec("css-text-decor-4", "#text-decoration-skip-box-property")] + public void TextDecorationSkipBox_IsParsed() + { + Assert.AreEqual("all", Value("text-decoration-skip-box", "all")); + } + + [Ignore("Gap, see Spec/css-text-decor-4.yml")] + [TestMethod] + [Spec("css-text-decor-4", "#text-decoration-skip-inset-property")] + public void TextDecorationSkipInset_IsParsed() + { + Assert.AreEqual("auto", Value("text-decoration-skip-inset", "auto")); + } + + [Ignore("Gap, see Spec/css-text-decor-4.yml")] + [TestMethod] + [Spec("css-text-decor-4", "#text-decoration-skip-spaces-property")] + public void TextDecorationSkipSpaces_IsParsed() + { + Assert.AreEqual("all", Value("text-decoration-skip-spaces", "all")); + } + + [Ignore("Gap, see Spec/css-text-decor-4.yml")] + [TestMethod] + [Spec("css-text-decor-4", "#text-decoration-skip-ink-property")] + [DataRow("none")] + [DataRow("all")] + public void TextDecorationSkipInk_IsParsed(string value) + { + Assert.AreEqual(value, Value("text-decoration-skip-ink", value)); + } + + [Ignore("Gap, see Spec/css-text-decor-4.yml")] + [TestMethod] + [Spec("css-text-decor-4", "#text-emphasis-style-property")] + [DataRow("filled dot")] + [DataRow("\"x\"")] + public void TextEmphasisStyle_IsParsed(string value) + { + Assert.AreEqual(value, Value("text-emphasis-style", value)); + } + + [Ignore("Gap, see Spec/css-text-decor-4.yml")] + [TestMethod] + [Spec("css-text-decor-4", "#text-emphasis-skip")] + public void TextEmphasisSkip_IsParsed() + { + Assert.AreEqual("spaces symbols", Value("text-emphasis-skip", "spaces symbols")); + } + + private static string Value(string property, string value, string? longhand = null) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ {property}: {value}; }}"); + return sheet.Rules.OfType<StyleRule>().Single().Style.GetPropertyValue(longhand ?? property); + } +} diff --git a/Spec/css-text-decor-4.yml b/Spec/css-text-decor-4.yml index 75a236779..e0b67bf83 100644 --- a/Spec/css-text-decor-4.yml +++ b/Spec/css-text-decor-4.yml @@ -5,106 +5,127 @@ snapshot: https://www.w3.org/TR/2022/WD-css-text-decor-4-20220504/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown - '#terms': - title: 1.3 Terminology - status: unknown + status: n/a '#line-decoration': title: '2 Line Decoration: Underline, Overline, and Strike-Through' - status: unknown + status: partial + gaps: + - A propagated decoration is drawn in the color of the descendant instead of the decorating box, see CSS 2.1 16.3.1 + - "A descendant's text-decoration: none removes the decoration of its ancestor, see CSS 2.1 16.3.1" + - Decorations propagate to floats, absolutely positioned boxes and inline-blocks, see CSS 2.1 16.3.1 '#text-decoration-line-property': title: '2.1 Text Decoration Lines: the text-decoration-line property' - status: unknown + status: partial + gaps: + - spelling-error and grammar-error aren't accepted '#text-decoration-style-property': title: '2.2 Text Decoration Style: the text-decoration-style property' - status: unknown + status: parsed-only + gaps: + - Every line is drawn solid '#text-decoration-color-property': title: '2.3 Text Decoration Color: the text-decoration-color property' - status: unknown + status: parsed-only + gaps: + - The lines are drawn in the color of the text '#text-decoration-thickness-property': title: '2.4 Text Decoration Line Thickness: the text-decoration-thickness property' - status: unknown + status: unsupported '#text-decoration-thickness': title: 2.4.1 Automatic Thickness of Text Decoration Lines - status: unknown + status: unsupported + gaps: + - Lines are always 1px thick, the font's thickness isn't used '#line-position': title: 2.5 Determining the Position and Thickness of Line Decorations - status: unknown + status: partial + gaps: + - The thickness doesn't depend on the decorated text, see 2.4.1 '#text-decoration-property': title: '2.6 Text Decoration Shorthand: the text-decoration property' - status: unknown + status: partial + gaps: + - Only the line is applied, the style and color are ignored, see 2.2 and 2.3 + - A thickness isn't accepted '#text-underline-position-property': title: '2.7 Text Underline Position: the text-underline-position property' - status: unknown + status: unsupported '#underline-offset': title: '2.8 Text Underline Offset: the text-underline-offset property' - status: unknown + status: unsupported '#line-offset-zero': title: 2.8.1 Underline Offset Origin (Zero Position) - status: unknown + status: unsupported '#line-auto-offset': title: 2.8.2 Using Font Metrics for Automatic Positioning - status: unknown + status: partial + gaps: + - from-font isn't accepted, see 2.7 '#text-line-constancy': title: 2.9 Text Decoration Line Uniformity - status: unknown + status: supported '#text-decoration-skipping': title: '2.10 Text Decoration Line Continuity: the text-decoration-skip shorthand and its sub-properties' - status: unknown + status: partial + gaps: + - text-decoration-skip and its longhands aren't accepted, only their initial values apply '#text-decoration-skip-self-property': title: '2.10.1 Skipping Spaces: the text-decoration-skip-self property' - status: unknown + status: partial + gaps: + - The property isn't accepted, atomic inlines such as images are always skipped '#text-decoration-skip-box-property': title: '2.10.2 Skipping Spaces: the text-decoration-skip-box property' - status: unknown + status: unsupported '#text-decoration-skip-inset-property': title: '2.10.3 Inset Edges: the text-decoration-skip-inset property' - status: unknown + status: unsupported '#text-decoration-skip-spaces-property': title: '2.10.4 Skipping Spaces: the text-decoration-skip-spaces property' - status: unknown + status: partial + gaps: + - A space next to an element boundary inside the decorating box isn't decorated + - The property isn't accepted, only its initial value applies '#text-decoration-skip-ink-property': title: '2.10.5 Skipping Glyphs: the text-decoration-skip-ink property' - status: unknown + status: unsupported '#ink-skip-shape': title: 2.10.6 Shaping Interruptions - status: unknown + status: unsupported '#emphasis-marks': title: 3 Additional Controls for Emphasis Marks - status: unknown + status: unsupported '#text-emphasis-style-property': title: '3.1 Emphasis Mark Style: the text-emphasis-style property' - status: unknown + status: unsupported '#text-emphasis-color-property': title: '3.2 Emphasis Mark Color: the text-emphasis-color property' - status: unknown + status: unsupported '#text-emphasis-property': title: '3.3 Emphasis Mark Shorthand: the text-emphasis property' - status: unknown + status: unsupported '#text-emphasis-position-property': title: '3.4 Emphasis Mark Position: the text-emphasis-position property' - status: unknown + status: unsupported '#text-emphasis-skip': title: '3.5 Emphasis Mark Skip: the text-emphasis-skip property' - status: unknown + status: unsupported '#text-shadow-property': title: '4 Text Shadows: the text-shadow property' - status: unknown + status: parsed-only + gaps: + - No shadow is painted '#painting': title: 5 Painting Text Decorations - status: unknown + status: partial '#painting-order': title: 5.1 Painting Order of Text Decorations - status: unknown + status: partial + gaps: + - Underlines and overlines are painted over the text instead of below it '#overflow': title: 5.2 Overflow of Text Decorations - status: unknown + status: supported '#priv-sec': title: 6 Privacy and Security Considerations - status: unknown + status: n/a From 0061a28833bebaf78004ec68e571e33652a5aa2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:34:30 +0100 Subject: [PATCH 104/150] Verify and document CSS Fonts Level 5 compliance --- .../Text/CssFonts4IntegrationTests.cs | 1 + .../HtmlRenderer.Test/Css/FontFaceTests.cs | 2 + .../CssModules/CssFonts5Tests.cs | 91 +++++++++++++++ Spec/css-fonts-5.yml | 108 +++++++++--------- 4 files changed, 147 insertions(+), 55 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssFonts5Tests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/CssFonts4IntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/CssFonts4IntegrationTests.cs index 448bb4773..7d5a44f95 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/CssFonts4IntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/CssFonts4IntegrationTests.cs @@ -85,6 +85,7 @@ public void FontFace_SourceInASupportedFormat_IsLoaded() [Ignore("Gap, see Spec/css-fonts-4.yml")] [TestMethod] [Spec("css-fonts-4", "#font-face-src-formats")] + [Spec("css-fonts-5", "#font-face-src-parsing")] public void FontFace_SourceInAnUnsupportedFormat_IsSkipped() { var font = FontOf( diff --git a/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs index ad0bb53d3..c9de54966 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs @@ -14,6 +14,7 @@ public sealed class FontFaceTests [Spec("css-fonts-4", "#font-face-rule")] [Spec("css-fonts-4", "#src-desc")] [Spec("css-fonts-4", "#font-face-src-parsing")] + [Spec("css-fonts-5", "#font-face-src-parsing")] public void FontFaceOpenSansWithSource() { var src = "@font-face{font-family:'Open Sans';src:url(fonts/OpenSans-Light.eot);src:local('Open Sans Light'),local('OpenSans-Light'),url(fonts/OpenSans-Light.ttf) format('truetype'),url(fonts/OpenSans-Light.woff) format('woff');font-style:normal}"; @@ -35,6 +36,7 @@ public void FontFaceOpenSansWithSource() [TestMethod] [Spec("css-fonts-4", "#src-desc")] [Spec("css-fonts-4", "#font-face-src-parsing")] + [Spec("css-fonts-5", "#font-face-src-parsing")] public void FontFaceWithWoff2Source() { var src = "@font-face{font-family:'Inter';src:url(fonts/Inter-Medium.woff2) format('woff2');font-weight:500}"; diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssFonts5Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssFonts5Tests.cs new file mode 100644 index 000000000..ac082b9d5 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssFonts5Tests.cs @@ -0,0 +1,91 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssFonts5Tests +{ + [TestMethod] + [Spec("css-fonts-5", "#font-size-adjust-prop")] + [DataRow("none")] + [DataRow("0.5")] + public void FontSizeAdjust_NoneAndNumber_AreParsed(string value) + { + Assert.AreEqual(value, Value("font-size-adjust", value)); + } + + [Ignore("Gap, see Spec/css-fonts-5.yml")] + [TestMethod] + [Spec("css-fonts-5", "#font-size-adjust-prop")] + [DataRow("from-font")] + [DataRow("ex-height 0.5")] + [DataRow("cap-height from-font")] + [DataRow("ic-height 0.5")] + public void FontSizeAdjust_MetricKeywordsAndFromFont_AreParsed(string value) + { + Assert.AreEqual(value, Value("font-size-adjust", value)); + } + + [TestMethod] + [Spec("css-fonts-5", "#font-face-src-parsing")] + public void Src_WithFormatKeywordAndTech_IsKept() + { + var src = Descriptor("src", "url(a.woff2) format(woff2) tech(variations, avar2), url(b.ttf)"); + + StringAssert.Contains(src, "a.woff2"); + StringAssert.Contains(src, "b.ttf"); + } + + [Ignore("Gap, see Spec/css-fonts-5.yml")] + [TestMethod] + [Spec("css-fonts-5", "#font-size-desc")] + public void FontSizeDescriptor_IsParsed() + { + Assert.AreEqual("8 12", Descriptor("font-size", "8 12")); + } + + [Ignore("Gap, see Spec/css-fonts-5.yml")] + [TestMethod] + [Spec("css-fonts-5", "#size-adjust-desc")] + public void SizeAdjustDescriptor_IsParsed() + { + Assert.AreEqual("90%", Descriptor("size-adjust", "90%")); + } + + [Ignore("Gap, see Spec/css-fonts-5.yml")] + [TestMethod] + [Spec("css-fonts-5", "#font-metrics-override-desc")] + [DataRow("ascent-override")] + [DataRow("descent-override")] + [DataRow("line-gap-override")] + public void MetricsOverrideDescriptors_AreParsed(string descriptor) + { + Assert.AreEqual("80%", Descriptor(descriptor, "80%")); + } + + [Ignore("Gap, see Spec/css-fonts-5.yml")] + [TestMethod] + [Spec("css-fonts-5", "#font-sup-sub-override-desc")] + [DataRow("superscript-position-override")] + [DataRow("subscript-position-override")] + [DataRow("superscript-size-override")] + [DataRow("subscript-size-override")] + public void SuperscriptAndSubscriptOverrideDescriptors_AreParsed(string descriptor) + { + Assert.AreEqual("from-font", Descriptor(descriptor, "from-font")); + } + + private static string Value(string property, string value) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($".x {{ {property}: {value}; }}"); + return sheet.Rules.OfType<StyleRule>().Single().Style.GetPropertyValue(property); + } + + private static string Descriptor(string descriptor, string value) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($"@font-face {{ font-family: x; {descriptor}: {value}; }}"); + return sheet.Rules.OfType<FontFaceRule>().Single().GetPropertyValue(descriptor); + } +} diff --git a/Spec/css-fonts-5.yml b/Spec/css-fonts-5.yml index 4a153c575..aedf24d93 100644 --- a/Spec/css-fonts-5.yml +++ b/Spec/css-fonts-5.yml @@ -5,112 +5,110 @@ snapshot: https://www.w3.org/TR/2026/WD-css-fonts-5-20261104/ sections: '#introduction': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown + status: n/a '#text-scale-meta': title: 2 Text-Scale meta element - status: unknown - '#text-scale-meta-keywords': - title: 2.1 Keywords - status: unknown - '#legacy-keyword': - title: 2.2 The legacy keyword - status: unknown - '#scale-keyword': - title: 2.3 The scale keyword - status: unknown + status: n/a + gaps: + - There are no user font size preferences, the text scale factor is always 1 '#basic-font-props': title: 3 Basic Font Properties - status: unknown + status: parsed-only '#font-family-prop': title: '3.1 Font family: the font-family property' - status: unknown + status: n/a + gaps: + - Only an open issue, the property is tracked in css-fonts-4 '#generic-font-families': title: 3.1.1 Generic font families - status: unknown + status: n/a + gaps: + - Only a placeholder family '#font-weight-prop': title: '3.2 Font weight: the font-weight property' - status: unknown + status: n/a + gaps: + - Only an open issue, the property is tracked in css-fonts-4 '#font-style-prop': title: '3.3 Font style: the font-style property' - status: unknown + status: n/a + gaps: + - Only an open issue, the property is tracked in css-fonts-4 '#font-size-adjust-prop': title: '3.4 Relative sizing: the font-size-adjust property' - status: unknown + status: parsed-only + gaps: + - font-size-adjust is ignored + - Only none and a number are accepted, not from-font or a metric keyword such as ex-height '#font-resources': title: 4 Font Resources - status: unknown + status: partial '#font-face-rule': title: 4.1 The @font-face rule - status: unknown + status: n/a + gaps: + - Only a note, the rule is tracked in css-fonts-4 '#src-desc': title: '4.2 Font reference: the src descriptor' - status: unknown + status: partial '#font-face-src-parsing': title: 4.2.1 Parsing the src descriptor - status: unknown + status: partial + gaps: + - "A source with an unsupported format() or tech() is still used, see css-fonts-4 #font-face-src-formats" '#font-size-desc': title: '4.3 Font property descriptors: the font-size' - status: unknown + status: unsupported '#size-adjust-desc': title: '4.4 Glyph Size Multiplier: the size-adjust descriptor' - status: unknown + status: unsupported '#font-metrics-override-desc': title: '4.5 Line Height Font Metrics Overrides: the ascent-override, descent-override, and line-gap-override descriptors' - status: unknown + status: unsupported '#font-sup-sub-override-desc': title: '4.6 Superscript and subscript metrics overrides: the superscript-position-override, subscript-position-override,superscript-size-override and subscript-size-override descriptors' - status: unknown + status: unsupported '#font-rend-props': title: 5 Font Feature Properties - status: unknown + status: n/a '#font-language-override-prop': title: '5.1 Font language override: the font-language-override property' - status: unknown + status: n/a + gaps: + - Only an open issue, the property is tracked in css-fonts-4 '#font-feature-variation-resolution': title: 6 Font Feature and Variation Resolution - status: unknown + status: n/a + gaps: + - Only an open issue, see css-fonts-4 '#font-variation-props': title: 7 Font Variation Properties - status: unknown + status: n/a '#font-optical-sizing-def': title: '7.1 Optical sizing control: the font-optical-sizing property' - status: unknown + status: n/a + gaps: + - Only an open issue, the property is tracked in css-fonts-4 '#font-technologies-formats': title: 8 Font Technologies and Formats - status: unknown + status: unsupported '#font-tech-definitions': title: 8.1 Font tech - status: unknown + status: unsupported + gaps: + - "tech() is ignored, see css-fonts-4 #font-face-src-formats" '#object-model': title: 9 Object Model - status: unknown - '#om-fontface': - title: 9.1 The CSSFontFaceRule interface - status: unknown + status: n/a '#security': title: 10 Security Considerations - status: unknown + status: n/a '#privacy': title: 11 Privacy Considerations - status: unknown + status: n/a '#acknowledgments': title: 12 Acknowledgments - status: unknown + status: n/a '#changes': title: 13 Changes - status: unknown - '#changes-20260206': - title: 13.1 Changes since the WD of 6 February 2024 - status: unknown - '#changes-20211221': - title: 13.2 Changes since the WD of 21 December 2021 - status: unknown - '#changes-20210729': - title: 13.3 Changes since the WD of 2021-07-29 - status: unknown - '#changes-20210629': - title: 13.4 Changes since the FPWD of 2021-06-29 - status: unknown + status: n/a From 2c85c3c143012ae7f2c1d06732f576af93a5f5d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:34:50 +0100 Subject: [PATCH 105/150] Verify and document CSS Font Loading compliance --- Spec/css-font-loading-3.yml | 51 +++++-------------------------------- 1 file changed, 7 insertions(+), 44 deletions(-) diff --git a/Spec/css-font-loading-3.yml b/Spec/css-font-loading-3.yml index e2369cc98..c80ae6903 100644 --- a/Spec/css-font-loading-3.yml +++ b/Spec/css-font-loading-3.yml @@ -5,55 +5,18 @@ snapshot: https://www.w3.org/TR/2023/WD-css-font-loading-3-20230406/ sections: '#introduction': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Values - status: unknown - '#task-source': - title: 1.2 Task Sources - status: unknown + status: n/a + gaps: + - A script API and there's no scripting, loading the fonts of @font-face rules is tracked in css-fonts-4 '#fontface-interface': title: 2 The FontFace Interface - status: unknown - '#font-face-constructor': - title: 2.1 The Constructor - status: unknown - '#font-face-load': - title: 2.2 The load() method - status: unknown - '#font-face-css-connection': - title: 2.3 Interaction with CSS’s @font-face Rule - status: unknown - '#discovery': - title: 2.4 Discovery of information about a font - status: unknown + status: n/a '#FontFaceSet-interface': title: 3 The FontFaceSet Interface - status: unknown - '#FontFaceSet-events': - title: 3.1 Events - status: unknown - '#font-face-set-load': - title: 3.2 The load() method - status: unknown - '#font-face-set-check': - title: 3.3 The check() method - status: unknown - '#font-face-set-ready': - title: 3.4 The ready attribute - status: unknown - '#font-face-set-css': - title: 3.5 Interaction with CSS Font Loading and Matching - status: unknown + status: n/a '#font-face-source': title: 4 The FontFaceSource Mixin - status: unknown - '#fontfacesource-workers': - title: 4.1 Worker FontFaceSources - status: unknown - '#document-font-face-set': - title: 4.2 Interaction with CSS’s @font-face Rule - status: unknown + status: n/a '#font-load-event-examples': title: 5 API Examples - status: unknown + status: n/a From 16f3fb2acfcc9faba9c9e2048fb061b8f8759564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:36:45 +0100 Subject: [PATCH 106/150] Verify and document CSS Speech compliance --- Spec/css-speech-1.yml | 94 +++++++++---------------------------------- 1 file changed, 19 insertions(+), 75 deletions(-) diff --git a/Spec/css-speech-1.yml b/Spec/css-speech-1.yml index 985d5dbe8..fa19ebd2f 100644 --- a/Spec/css-speech-1.yml +++ b/Spec/css-speech-1.yml @@ -5,106 +5,50 @@ snapshot: https://www.w3.org/TR/2023/CRD-css-speech-1-20230214/ sections: '#intro': title: 1 Introduction, design goals - status: unknown + status: n/a + gaps: + - The library is a visual renderer and doesn't speak, none of the speech properties are parsed '#background': title: 2 Background information, CSS 2.1 - status: unknown + status: n/a '#ssml-rel': title: 3 Relationship with SSML - status: unknown - '#values': - title: 3.1 Value Definitions - status: unknown + status: n/a '#example': title: 4 Example - status: unknown + status: n/a '#aural-model': title: 5 The aural formatting model - status: unknown + status: n/a + gaps: + - There's no aural rendering, the speech properties don't change the visual rendering '#mixing-props': title: 6 Mixing properties - status: unknown - '#mixing-props-voice-volume': - title: 6.1 The voice-volume property - status: unknown - '#mixing-props-voice-balance': - title: 6.2 The voice-balance property - status: unknown + status: n/a '#speaking-props': title: 7 Speaking properties - status: unknown - '#speaking-props-speak': - title: 7.1 The speak property - status: unknown - '#speaking-props-speak-as': - title: 7.2 The speak-as property - status: unknown + status: n/a '#pause-props': title: 8 Pause properties - status: unknown - '#pause-props-pause-before-after': - title: 8.1 The pause-before and pause-after properties - status: unknown - '#pause-props-pause': - title: 8.2 The pause shorthand property - status: unknown - '#collapsed-pauses': - title: 8.3 Collapsing pauses - status: unknown + status: n/a '#rest-props': title: 9 Rest properties - status: unknown - '#rest-props-rest-before-after': - title: 9.1 The rest-before and rest-after properties - status: unknown - '#rest-props-rest': - title: 9.2 The rest shorthand property - status: unknown + status: n/a '#cue-props': title: 10 Cue properties - status: unknown - '#cue-props-cue-before-after': - title: 10.1 The cue-before and cue-after properties - status: unknown - '#cue-props-volume': - title: 10.2 Relation between audio cues and speech synthesis volume levels - status: unknown - '#cue-props-cue': - title: 10.3 The cue shorthand property - status: unknown + status: n/a '#voice-char-props': title: 11 Voice characteristic properties - status: unknown - '#voice-props-voice-family': - title: 11.1 The voice-family property - status: unknown - '#voice-selection': - title: 11.1.1 Voice selection, content language - status: unknown - '#voice-props-voice-rate': - title: 11.2 The voice-rate property - status: unknown - '#voice-props-voice-pitch': - title: 11.3 The voice-pitch property - status: unknown - '#voice-props-voice-range': - title: 11.4 The voice-range property - status: unknown - '#voice-props-voice-stress': - title: 11.5 The voice-stress property - status: unknown + status: n/a '#duration-props': title: 12 Voice duration property - status: unknown - '#mixing-props-voice-duration': - title: 12.1 The voice-duration property - status: unknown + status: n/a '#lists': title: 13 List items and counters styles - status: unknown + status: n/a '#content': title: 14 Inserted and replaced content - status: unknown + status: n/a '#pronunciation': title: 15 Pronunciation, phonemes - status: unknown + status: n/a From 45aec34586695ff1dc4414a9b621f38a12df43a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:23:50 +0100 Subject: [PATCH 107/150] Verify and document CSS Box Model Level 4 compliance --- .../BoxModel/BoxDimensionsIntegrationTests.cs | 3 + .../BoxModel/MarginIntegrationTests.cs | 3 + .../BoxModel/PaddingIntegrationTests.cs | 2 + .../CssModules/CssBoxTests.cs | 4 ++ .../Css/MarginPropertyTests.cs | 8 +++ .../Css/PaddingPropertyTests.cs | 7 +++ .../CssModules/CssBox4Tests.cs | 45 ++++++++++++++ Spec/css-box-4.yml | 60 ++++++++++--------- 8 files changed, 105 insertions(+), 27 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssBox4Tests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BoxDimensionsIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BoxDimensionsIntegrationTests.cs index 0ce67f4e4..fb2e6d797 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BoxDimensionsIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BoxDimensionsIntegrationTests.cs @@ -11,7 +11,9 @@ public sealed class BoxDimensionsIntegrationTests [TestMethod] [Spec("css21", "box.html#box-dimensions")] [Spec("css-box-3", "#box-edges")] + [Spec("css-box-4", "#box-edges")] [Spec("css-box-3", "#borders")] + [Spec("css-box-4", "#borders")] public void BorderBox_IsContentPlusPaddingPlusBorder_AndTheMarginSurroundsIt() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(Box)); @@ -26,6 +28,7 @@ public void BorderBox_IsContentPlusPaddingPlusBorder_AndTheMarginSurroundsIt() [TestMethod] [Spec("css21", "box.html#box-dimensions")] [Spec("css-box-3", "#box-model")] + [Spec("css-box-4", "#box-model")] public void Background_CoversThePaddingAndBorderArea() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap(Box)); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs index 3a2933a50..30f7748a3 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MarginIntegrationTests.cs @@ -12,6 +12,7 @@ public sealed class MarginIntegrationTests [TestMethod] [Spec("css21", "box.html#margin-properties")] [Spec("css-box-3", "#margin-physical")] + [Spec("css-box-4", "#margin-physical")] public void NegativeMargin_PullsTheBoxOutOfTheContentArea() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -25,6 +26,7 @@ public void NegativeMargin_PullsTheBoxOutOfTheContentArea() [TestMethod] [Spec("css21", "box.html#margin-properties")] [Spec("css-box-3", "#margin-physical")] + [Spec("css-box-4", "#margin-physical")] public void VerticalPercentageMargin_ResolvesAgainstTheContainingBlockWidth() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -38,6 +40,7 @@ public void VerticalPercentageMargin_ResolvesAgainstTheContainingBlockWidth() [TestMethod] [Spec("css21", "box.html#margin-properties")] [Spec("css-box-3", "#margin-physical")] + [Spec("css-box-4", "#margin-physical")] public void HorizontalPercentageMargin_ResolvesAgainstTheContainingBlockWidth() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/PaddingIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/PaddingIntegrationTests.cs index 960a02015..f0b8590ce 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/PaddingIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/PaddingIntegrationTests.cs @@ -11,6 +11,7 @@ public sealed class PaddingIntegrationTests [TestMethod] [Spec("css21", "box.html#padding-properties")] [Spec("css-box-3", "#padding-physical")] + [Spec("css-box-4", "#padding-physical")] public void PercentagePadding_ResolvesAgainstTheContainingBlockWidth() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -26,6 +27,7 @@ public void PercentagePadding_ResolvesAgainstTheContainingBlockWidth() [TestMethod] [Spec("css21", "box.html#padding-properties")] [Spec("css-box-3", "#padding-physical")] + [Spec("css-box-4", "#padding-physical")] public void NegativePadding_IsIgnored() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='b' style='padding-left:-10px'></div>")); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssBoxTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssBoxTests.cs index 3b0a9b2b5..cde51d719 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssBoxTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssBoxTests.cs @@ -11,6 +11,7 @@ public sealed class CssBoxTests [TestMethod] [Spec("css-box-3", "#margin-physical")] + [Spec("css-box-4", "#margin-physical")] public void MarginLonghands_SetTheirOwnSideOnly() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -26,6 +27,7 @@ public void MarginLonghands_SetTheirOwnSideOnly() [TestMethod] [Spec("css-box-3", "#margin-physical")] + [Spec("css-box-4", "#margin-physical")] public void Margin_DoesNotApplyToTableCells() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -38,6 +40,7 @@ public void Margin_DoesNotApplyToTableCells() [TestMethod] [Spec("css-box-3", "#padding-physical")] + [Spec("css-box-4", "#padding-physical")] public void PaddingLonghands_SetTheirOwnSideOnly() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -53,6 +56,7 @@ public void PaddingLonghands_SetTheirOwnSideOnly() [TestMethod] [Spec("css-box-3", "#padding-physical")] + [Spec("css-box-4", "#padding-physical")] public void Padding_AppliesToTableCells() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.Test/Css/MarginPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/MarginPropertyTests.cs index 393d29f71..f090b9ca8 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MarginPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MarginPropertyTests.cs @@ -15,6 +15,7 @@ private static string GetProperty(string declaration, string propertyName) [TestMethod] [Spec("css-box-3", "#margin-shorthand")] + [Spec("css-box-4", "#margin-shorthand")] public void MarginAllZeroLegal() { const string declaration = "margin: 0"; @@ -27,6 +28,7 @@ public void MarginAllZeroLegal() [TestMethod] [Spec("css-box-3", "#margin-shorthand")] + [Spec("css-box-4", "#margin-shorthand")] public void MarginAllPercentLegal() { const string declaration = "margin: 25%"; @@ -39,6 +41,7 @@ public void MarginAllPercentLegal() [TestMethod] [Spec("css-box-3", "#margin-shorthand")] + [Spec("css-box-4", "#margin-shorthand")] public void MarginAutoLegal() { const string declaration = "margin: auto"; @@ -51,6 +54,7 @@ public void MarginAutoLegal() [TestMethod] [Spec("css-box-3", "#margin-shorthand")] + [Spec("css-box-4", "#margin-shorthand")] public void MarginSidesLengthLegal() { const string declaration = "margin: 10px 3em"; @@ -63,6 +67,7 @@ public void MarginSidesLengthLegal() [TestMethod] [Spec("css-box-3", "#margin-shorthand")] + [Spec("css-box-4", "#margin-shorthand")] public void MarginSidesLengthAndAutoLegal() { const string declaration = "margin: 10px auto"; @@ -75,6 +80,7 @@ public void MarginSidesLengthAndAutoLegal() [TestMethod] [Spec("css-box-3", "#margin-shorthand")] + [Spec("css-box-4", "#margin-shorthand")] public void MarginThreeValuesLegal() { const string declaration = "margin: 10px 3em 5px"; @@ -87,6 +93,7 @@ public void MarginThreeValuesLegal() [TestMethod] [Spec("css-box-3", "#margin-shorthand")] + [Spec("css-box-4", "#margin-shorthand")] public void MarginAllValuesWithPercentAndAutoLegal() { const string declaration = "margin: 10px 5% auto 2%"; @@ -99,6 +106,7 @@ public void MarginAllValuesWithPercentAndAutoLegal() [TestMethod] [Spec("css-box-3", "#margin-shorthand")] + [Spec("css-box-4", "#margin-shorthand")] public void MarginTooManyValuesIllegal() { const string declaration = "margin: 10px 5% 8px 2% 3px auto"; diff --git a/Source/Test/HtmlRenderer.Test/Css/PaddingPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/PaddingPropertyTests.cs index 147caa433..82c3a9c7e 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PaddingPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PaddingPropertyTests.cs @@ -15,6 +15,7 @@ private static string GetProperty(string declaration, string propertyName) [TestMethod] [Spec("css-box-3", "#padding-shorthand")] + [Spec("css-box-4", "#padding-shorthand")] public void CssPaddingAllZeroLegal() { const string declaration = "padding: 0"; @@ -27,6 +28,7 @@ public void CssPaddingAllZeroLegal() [TestMethod] [Spec("css-box-3", "#padding-shorthand")] + [Spec("css-box-4", "#padding-shorthand")] public void CssPaddingAllPercentLegal() { const string declaration = "padding: 25%"; @@ -39,6 +41,7 @@ public void CssPaddingAllPercentLegal() [TestMethod] [Spec("css-box-3", "#padding-shorthand")] + [Spec("css-box-4", "#padding-shorthand")] public void CssPaddingSidesLengthLegal() { const string declaration = "padding: 10px 3em"; @@ -51,6 +54,7 @@ public void CssPaddingSidesLengthLegal() [TestMethod] [Spec("css-box-3", "#padding-shorthand")] + [Spec("css-box-4", "#padding-shorthand")] public void CssPaddingAutoIllegal() { const string declaration = "padding: auto"; @@ -63,6 +67,7 @@ public void CssPaddingAutoIllegal() [TestMethod] [Spec("css-box-3", "#padding-shorthand")] + [Spec("css-box-4", "#padding-shorthand")] public void CssPaddingThreeValuesLegal() { const string declaration = "padding: 10px 3em 5px"; @@ -75,6 +80,7 @@ public void CssPaddingThreeValuesLegal() [TestMethod] [Spec("css-box-3", "#padding-shorthand")] + [Spec("css-box-4", "#padding-shorthand")] public void CssPaddingAllValuesWithPercentLegal() { const string declaration = "padding: 10px 5% 8px 2%"; @@ -87,6 +93,7 @@ public void CssPaddingAllValuesWithPercentLegal() [TestMethod] [Spec("css-box-3", "#padding-shorthand")] + [Spec("css-box-4", "#padding-shorthand")] public void CssPaddingTooManyValuesIllegal() { const string declaration = "padding: 10px 5% 8px 2% 3px"; diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssBox4Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssBox4Tests.cs new file mode 100644 index 000000000..04e07a3ef --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssBox4Tests.cs @@ -0,0 +1,45 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssBox4Tests +{ + private const double Delta = 0.5; + + [TestMethod] + [DataRow("background-clip: content-box")] + [DataRow("background-origin: border-box")] + [Spec("css-box-4", "#keywords")] + public void VisualBoxKeywords_AreAccepted(string declaration) + { + Assert.IsTrue(CssConstructionFunctions.ParseDeclarations(declaration).Length > 0); + } + + [Ignore("Gap, see Spec/css-box-4.yml")] + [TestMethod] + [DataRow("margin-trim: block")] + [DataRow("margin-trim: inline-start block-end")] + [Spec("css-box-4", "#margin-trim")] + public void MarginTrim_IsAccepted(string declaration) + { + Assert.IsTrue(CssConstructionFunctions.ParseDeclarations(declaration).Length > 0); + } + + [Ignore("Gap, see Spec/css-box-4.yml")] + [TestMethod] + [Spec("css-box-4", "#margin-trim-block")] + public void MarginTrimBlock_TrimsTheFirstAndLastChildMargins() + { + var root = Layout("<div id='c' style='margin-trim:block;padding:1px 0'><div id='a' style='margin-top:20px;height:10px'></div><div style='margin-bottom:20px;height:10px'></div></div>"); + + Assert.AreEqual(1, Box(root, "a").Location.Y - Box(root, "c").Location.Y, Delta); + Assert.AreEqual(22, Box(root, "c").Size.Height, Delta); + } + + private static CssBox Layout(string body) => LayoutHarness.Layout(LayoutHarness.Wrap(body)).Root; + + private static CssBox Box(CssBox root, string id) => LayoutHarness.FindById(root, id)!; +} diff --git a/Spec/css-box-4.yml b/Spec/css-box-4.yml index 37117b0b5..86779e141 100644 --- a/Spec/css-box-4.yml +++ b/Spec/css-box-4.yml @@ -5,70 +5,76 @@ snapshot: https://www.w3.org/TR/2024/WD-css-box-4-20240804/ sections: '#intro': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown - '#placement': - title: 1.2 Module Interactions - status: unknown + status: n/a '#box-model': title: 2 The CSS Box Model - status: unknown + status: partial '#box-edges': title: 2.1 Boxes and Edges - status: unknown + status: supported '#fragmentation': title: 2.2 Fragmentation - status: unknown + status: n/a '#keywords': title: 2.3 Box-edge Keywords - status: unknown + status: parsed-only + gaps: + - margin-box, fill-box, stroke-box and view-box are rejected, see css-box-3 '#keywords' '#margins': title: 3 Margins - status: unknown + status: partial '#margin-physical': title: '3.1 Page-relative (Physical) Margin Properties: the margin-top, margin-right, margin-bottom, and margin-left properties' - status: unknown + status: partial + gaps: + - Horizontal percentage margins resolve against the box's own width, see CSS 2.1 8.3 '#margin-shorthand': title: '3.2 Margin Shorthand: the margin property' - status: unknown + status: supported '#margin-trim': title: '3.3 Margins at Container Edges: the margin-trim property' - status: unknown + status: unsupported + gaps: + - "'margin-trim' is dropped by the parser" '#margin-trim-block': title: 3.3.1 Trimming Block Container Content - status: unknown + status: unsupported '#margin-trim-flex': title: 3.3.2 Trimming Flex Container Content - status: unknown + status: unsupported + gaps: + - Flex layout isn't supported, see css-display-3 '#margin-trim-grid': title: 3.3.3 Trimming Grid Container Content - status: unknown + status: unsupported + gaps: + - Grid layout isn't supported, see css-display-3 '#paddings': title: 4 Padding - status: unknown + status: partial '#padding-physical': title: '4.1 Page-relative (Physical) Padding Properties: the padding-top, padding-right, padding-bottom, and padding-left properties' - status: unknown + status: partial + gaps: + - Negative padding is applied instead of being rejected, see CSS 2.1 8.4 '#padding-shorthand': title: '4.2 Padding Shorthand: the padding property' - status: unknown + status: supported '#borders': title: 5 Borders - status: unknown + status: supported '#changes': title: 6 Recent Changes - status: unknown + status: n/a '#changes-since-3': title: 7 Changes Since CSS Level 3 - status: unknown + status: n/a '#changes-since-2': title: 8 Changes Since CSS Level 2 - status: unknown + status: n/a '#priv': title: 9 Privacy Considerations - status: unknown + status: n/a '#sec': title: 10 Security Considerations - status: unknown + status: n/a From 37d90c3aa73f627069a5269a90635f267b183c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:28:44 +0100 Subject: [PATCH 108/150] Verify and document CSS Sizing Level 4 compliance --- .../Core/CssEngine/AspectRatioGrammar.cs | 24 +---- .../Box/AspectRatioProperty.cs | 4 +- .../AspectRatioValueConverter.cs | 5 +- .../BoxModel/HeightIntegrationTests.cs | 7 ++ .../BoxModel/MinMaxHeightIntegrationTests.cs | 1 + .../BoxModel/WidthIntegrationTests.cs | 10 ++ .../CssModules/CssSizingTests.cs | 23 ++++- .../Css/AspectRatioGrammarTests.cs | 7 +- .../Css/CoordinatePropertyTests.cs | 13 +++ .../CssModules/CssSizing4Tests.cs | 97 +++++++++++++++++++ Spec/css-sizing-4.yml | 77 +++++++++------ 11 files changed, 205 insertions(+), 63 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssSizing4Tests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/AspectRatioGrammar.cs b/Source/HtmlRenderer/Core/CssEngine/AspectRatioGrammar.cs index 2570eafff..54e2e7907 100644 --- a/Source/HtmlRenderer/Core/CssEngine/AspectRatioGrammar.cs +++ b/Source/HtmlRenderer/Core/CssEngine/AspectRatioGrammar.cs @@ -4,29 +4,19 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The shared grammar for the <c>aspect-ratio</c> value (<c>[ auto || &lt;ratio&gt; ]</c>, where - /// <c>&lt;ratio&gt; = &lt;number [0,∞]&gt; [ / &lt;number [0,∞]&gt; ]?</c>). Used by both Layer A (to - /// validate/accept-or-reject at parse time) and Layer B (to compute the used ratio during layout), so the - /// grammar is defined once — the <see cref="BasicShapeGrammar"/> precedent. + /// The <c>aspect-ratio</c> grammar (CSS Sizing 4 §4.1) and the <c>&lt;ratio&gt;</c> type (CSS Values 4 §5.7). /// </summary> internal static class AspectRatioGrammar { /// <summary> - /// Validates the token stream as an <c>aspect-ratio</c> value. Returns false for anything that is not a - /// valid value. On success, <paramref name="ratio"/> is the used width/height ratio, or null when there - /// is no usable ratio (a bare <c>auto</c>, or a ratio with a zero term — both of which mean "no - /// preferred aspect ratio" for a non-replaced box). + /// <paramref name="ratio"/> is width / height, or null for a bare <c>auto</c> or a degenerate ratio. /// </summary> internal static bool TryParse(IReadOnlyList<Token> tokens, out double? ratio) => TryParse(tokens, out ratio, out _); /// <summary> - /// Validates the token stream as an <c>aspect-ratio</c> value, additionally reporting whether the - /// <c>auto</c> keyword was present (<c>[ auto || &lt;ratio&gt; ]</c>). <paramref name="hasAuto"/> lets a - /// replaced element prefer its natural aspect ratio and fall back to the specified <c>&lt;ratio&gt;</c> - /// (CSS Box Sizing 4 §5): <c>auto &lt;ratio&gt;</c> ⇒ <c>hasAuto=true</c> with a usable - /// <paramref name="ratio"/>; a bare <c>&lt;ratio&gt;</c> ⇒ <c>hasAuto=false</c> (the ratio always - /// overrides); a bare <c>auto</c> ⇒ <c>hasAuto=true</c> with a null <paramref name="ratio"/>. + /// Like <see cref="TryParse(IReadOnlyList{Token}, out double?)"/>, <paramref name="hasAuto"/> tells + /// <c>auto &lt;ratio&gt;</c>, where a replaced element's natural ratio wins, from a bare <c>&lt;ratio&gt;</c>. /// </summary> internal static bool TryParse(IReadOnlyList<Token> tokens, out double? ratio, out bool hasAuto) { @@ -54,11 +44,7 @@ internal static bool TryParse(IReadOnlyList<Token> tokens, out double? ratio, ou } /// <summary> - /// Validates a bare <c>&lt;ratio&gt;</c> value (<c>&lt;number [0,∞]&gt; [ / &lt;number [0,∞]&gt; ]?</c>), - /// with no <c>auto</c> — the CSS Values 4 <c>&lt;ratio&gt;</c> data type, as used by the <c>@property</c> - /// <c>syntax: "&lt;ratio&gt;"</c> matcher. Distinct from <see cref="TryParse(IReadOnlyList{Token}, out double?)"/>, whose <c>aspect-ratio</c> - /// grammar additionally permits <c>auto</c>. On success <paramref name="ratio"/> is the used width/height - /// ratio, or null when a term is zero. + /// A bare <c>&lt;ratio&gt;</c> without <c>auto</c>, for the <c>@property</c> syntax matcher. /// </summary> internal static bool TryParseRatio(IReadOnlyList<Token> tokens, out double? ratio) { diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Box/AspectRatioProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Box/AspectRatioProperty.cs index 7e0cb5e87..2917e270e 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Box/AspectRatioProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Box/AspectRatioProperty.cs @@ -1,9 +1,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The <c>aspect-ratio</c> property (CSS Box Sizing Level 4). The value is validated by the shared - /// <see cref="AspectRatioGrammar"/> and the authored text preserved for the layout engine, which computes - /// an auto dimension from the definite one. + /// <c>aspect-ratio</c>, CSS Sizing 4 §4.1. /// </summary> internal sealed class AspectRatioProperty : Property { diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/AspectRatioValueConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/AspectRatioValueConverter.cs index 0abcced98..0de3402d5 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/AspectRatioValueConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/AspectRatioValueConverter.cs @@ -4,10 +4,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// Validates an <c>aspect-ratio</c> value through the shared <see cref="AspectRatioGrammar"/> — accepting - /// <c>auto</c>, a <c>&lt;ratio&gt;</c>, or <c>auto &amp;&amp; &lt;ratio&gt;</c>, and rejecting everything - /// else so an invalid value is dropped at parse time. The authored text is preserved so Layer B - /// (<see cref="Html.Core.Dom.CssLayoutEngine"/>) re-parses the same value during layout. + /// Validates an <c>aspect-ratio</c> value (CSS Sizing 4 §4.1) and keeps the authored text. /// </summary> internal sealed class AspectRatioValueConverter : IValueConverter { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs index afa91961c..692531e14 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HeightIntegrationTests.cs @@ -22,6 +22,7 @@ public sealed class HeightIntegrationTests [TestMethod] [Spec("css21", "visudet.html#the-height-property")] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void LengthHeight_IsTheContentHeight() { var root = Layout("<div id='c' style='height:50px;padding:10px 0;border-top:5px solid'></div>"); @@ -43,6 +44,7 @@ public void PercentageHeight_AgainstAnAutoHeightContainingBlock_IsAuto() [TestMethod] [Spec("css21", "visudet.html#the-height-property")] [Spec("css-sizing-3", "#percentage-sizing")] + [Spec("css-sizing-4", "#percentage-sizing")] public void PercentageHeight_ResolvesAgainstAnExplicitContainingBlockHeight() { var root = Layout("<div style='height:200px'><div id='c' style='height:50%'></div></div>"); @@ -75,6 +77,7 @@ public void HeightOnAnInline_DoesNotApply() [TestMethod] [Spec("css21", "visudet.html#the-height-property")] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void HeightOnAnInlineBlock_IsApplied() { var root = Layout("<div id='p'>a<span id='s' style='display:inline-block;height:100px'>x</span></div>"); @@ -289,6 +292,7 @@ public void Float_AutoHeight_CoversItsFloatingDescendants() [TestMethod] [Spec("css21", "visudet.html#min-max-heights")] [Spec("css-sizing-3", "#min-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void MinHeight_GrowsAnAutoHeightBlock() { var root = Layout("<div id='c' style='min-height:50px'>x</div>"); @@ -299,6 +303,7 @@ public void MinHeight_GrowsAnAutoHeightBlock() [TestMethod] [Spec("css21", "visudet.html#min-max-heights")] [Spec("css-sizing-3", "#max-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void MaxHeight_LimitsAnAutoHeightBlock() { var root = Layout("<div id='c' style='max-height:5px'>x<br>y<br>z</div>"); @@ -319,6 +324,7 @@ public void PercentageMinHeight_AgainstAnAutoHeightContainingBlock_IsZero() [TestMethod] [Spec("css21", "visudet.html#min-max-heights")] [Spec("css-sizing-3", "#percentage-sizing")] + [Spec("css-sizing-4", "#percentage-sizing")] public void PercentageMaxHeight_ResolvesAgainstAnExplicitContainingBlockHeight() { var root = Layout("<div style='height:200px'><div id='c' style='height:150px;max-height:50%'></div></div>"); @@ -330,6 +336,7 @@ public void PercentageMaxHeight_ResolvesAgainstAnExplicitContainingBlockHeight() [TestMethod] [Spec("css21", "visudet.html#min-max-heights")] [Spec("css-sizing-3", "#max-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void MaxHeight_ScalesDownAnImageKeepingItsRatio() { var root = TestImages.Layout($"<img id='i' style='max-height:10px' src='{Image}'>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MinMaxHeightIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MinMaxHeightIntegrationTests.cs index cc0701578..e641cab5a 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MinMaxHeightIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/MinMaxHeightIntegrationTests.cs @@ -11,6 +11,7 @@ public sealed class MinMaxHeightIntegrationTests [TestMethod] [Spec("css21", "visudet.html#min-max-heights")] [Spec("css-sizing-3", "#min-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void MinHeightWinsOverConflictingMaxHeight() { var html = LayoutHarness.Wrap("<div id='b' style='height:8px; min-height:20px; max-height:5px;'></div>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs index c804fd5b7..20d72ccc3 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/WidthIntegrationTests.cs @@ -18,6 +18,7 @@ public sealed class WidthIntegrationTests [TestMethod] [Spec("css21", "visudet.html#the-width-property")] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void LengthWidth_IsTheContentWidth() { var root = Layout("<div id='c' style='width:100px;padding:0 10px;border:0 solid;border-width:0 5px;height:5px'></div>"); @@ -28,6 +29,7 @@ public void LengthWidth_IsTheContentWidth() [TestMethod] [Spec("css21", "visudet.html#the-width-property")] [Spec("css-sizing-3", "#percentage-sizing")] + [Spec("css-sizing-4", "#percentage-sizing")] public void PercentageWidth_ResolvesAgainstTheContainingBlockWidth() { var root = Layout("<div style='width:200px;padding:0 50px'><div id='c' style='width:50%;padding:0 5px;height:5px'></div></div>"); @@ -109,6 +111,7 @@ public void EmWidth_IsApplied() [TestMethod] [Spec("css21", "visudet.html#blockwidth")] [Spec("css-sizing-3", "#stretch-fit-sizing")] + [Spec("css-sizing-4", "#extrinsic")] public void AutoWidth_FillsTheContainingBlockMinusTheMargins() { var root = Layout("<div style='width:200px'><div id='c' style='margin-left:10px;margin-right:20px;height:5px'></div></div>"); @@ -196,6 +199,7 @@ public void Float_WithAutoMargins_HasZeroMargins() [TestMethod] [Spec("css21", "visudet.html#float-width")] [Spec("css-sizing-3", "#intrinsic-sizes")] + [Spec("css-sizing-4", "#intrinsic-sizes")] public void AutoWidthFloat_ShrinksToFitItsContent() { var root = Layout("<div id='f' style='float:left'>xx yy</div><div id='t'>zz</div>"); @@ -313,6 +317,7 @@ public void InlineBlock_WithAWidth_TakesThatWidthOnTheLine() [TestMethod] [Spec("css21", "visudet.html#inlineblock-width")] [Spec("css-sizing-3", "#intrinsic-sizes")] + [Spec("css-sizing-4", "#intrinsic-sizes")] public void InlineBlock_WithAutoWidth_IsAsWideAsItsContent() { var root = Layout("<div>a<span id='s' style='display:inline-block'>xx</span><span id='t'>y</span></div>"); @@ -326,6 +331,7 @@ public void InlineBlock_WithAutoWidth_IsAsWideAsItsContent() [TestMethod] [Spec("css21", "visudet.html#inlineblock-width")] [Spec("css-sizing-3", "#intrinsic-sizes")] + [Spec("css-sizing-4", "#intrinsic-sizes")] public void InlineBlock_TooWideForTheLine_ShrinksToTheAvailableWidthAsOneBox() { var root = Layout("<div style='width:200px'>a <span id='s' style='display:inline-block'>aaaa bbbb cccc dddd eeee ffff gggg hhhh</span></div>"); @@ -347,6 +353,7 @@ public void InlineBlockImage_KeepsItsIntrinsicWidth() [TestMethod] [Spec("css21", "visudet.html#min-max-widths")] [Spec("css-sizing-3", "#max-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void MaxWidth_ScalesDownAnImageKeepingItsRatio() { var root = TestImages.Layout($"<img id='i' style='max-width:20px' src='{Image}'>"); @@ -359,6 +366,7 @@ public void MaxWidth_ScalesDownAnImageKeepingItsRatio() [TestMethod] [Spec("css21", "visudet.html#min-max-widths")] [Spec("css-sizing-3", "#max-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void MaxWidth_LimitsABlock() { var root = Layout("<div style='width:200px'><div id='c' style='max-width:100px;height:5px'></div></div>"); @@ -370,6 +378,7 @@ public void MaxWidth_LimitsABlock() [TestMethod] [Spec("css21", "visudet.html#min-max-widths")] [Spec("css-sizing-3", "#min-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void MinWidth_WidensABlock() { var root = Layout("<div style='width:200px'><div id='c' style='width:50px;min-width:100px;height:5px'></div></div>"); @@ -381,6 +390,7 @@ public void MinWidth_WidensABlock() [TestMethod] [Spec("css21", "visudet.html#min-max-widths")] [Spec("css-sizing-3", "#min-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void MinWidth_WidensAnImage() { var root = TestImages.Layout($"<img id='i' style='min-width:80px' src='{Image}'>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs index 66c4b85b8..40fc8f599 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs @@ -67,6 +67,7 @@ public void BorderBox_AppliesToMaxHeight() [DataRow("block-size:40px", "height")] [Spec("css-sizing-3", "#preferred-size-properties")] [Spec("css-logical-1", "#dimension-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void FlowRelativePreferredSize_MapsToThePhysicalSize(string style, string axis) { var root = Layout($"<div style='width:300px'><div id='b' style='{style}'><div style='height:10px'></div></div></div>"); @@ -79,6 +80,7 @@ public void FlowRelativePreferredSize_MapsToThePhysicalSize(string style, string [TestMethod] [Spec("css-sizing-3", "#min-size-properties")] [Spec("css-logical-1", "#dimension-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void MinBlockSize_GrowsTheBox() { var root = Layout("<div id='b' style='min-block-size:40px'></div>"); @@ -90,6 +92,7 @@ public void MinBlockSize_GrowsTheBox() [TestMethod] [Spec("css-sizing-3", "#max-size-properties")] [Spec("css-logical-1", "#dimension-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void MaxInlineSize_LimitsTheBox() { var root = Layout("<div style='width:300px'><div id='b' style='max-inline-size:100px;height:10px'></div></div>"); @@ -100,16 +103,28 @@ public void MaxInlineSize_LimitsTheBox() [TestMethod] [Spec("css-sizing-3", "#sizing-values")] [Spec("css-sizing-3", "#percentage-sizing")] + [Spec("css-sizing-4", "#percentage-sizing")] public void PercentageSizes_ResolveAgainstADefiniteContainingBlock() { - var root = Layout("<div style='width:300px'><div id='b' style='width:50%;max-width:40%;height:10px'></div></div>"); + var root = Layout("<div style='width:300px'><div id='b' style='width:50%;height:10px'></div></div>"); Assert.AreEqual(150, Box(root, "b").Size.Width, Delta); } + [Ignore("Gap, see Spec/css-sizing-3.yml")] + [TestMethod] + [Spec("css-sizing-3", "#max-size-properties")] + public void PercentageMaxWidth_ClampsThePercentageWidth() + { + var root = Layout("<div style='width:300px'><div id='b' style='width:50%;max-width:40%;height:10px'></div></div>"); + + Assert.AreEqual(120, Box(root, "b").Size.Width, Delta); + } + [Ignore("Gap, see Spec/css-sizing-3.yml")] [TestMethod] [Spec("css-sizing-3", "#percentage-sizing")] + [Spec("css-sizing-4", "#percentage-sizing")] public void NestedPercentageHeights_ResolveAgainstTheResolvedPercentage() { var root = Layout("<div style='height:200px'><div id='f' style='height:50%'><div id='i' style='height:50%'></div></div></div>"); @@ -135,6 +150,7 @@ public void IntrinsicSizeKeywords_SizeABlockToItsContent(string keyword) [TestMethod] [Spec("css-sizing-3", "#sizing-values")] [Spec("css-sizing-3", "#stretch-fit-sizing")] + [Spec("css-sizing-4", "#extrinsic")] public void Stretch_FillsTheContainingBlock() { var root = Layout("<div style='width:300px'><span id='b' style='display:inline-block;width:stretch;margin:0 10px;height:10px'></span></div>"); @@ -153,6 +169,7 @@ public void PercentageHeight_AgainstAnIndefiniteHeight_BehavesAsAuto() [TestMethod] [Spec("css-sizing-3", "#stretch-fit-sizing")] + [Spec("css-sizing-4", "#extrinsic")] public void AutoWidthBlock_IsStretchFit() { var root = Layout("<div style='width:300px'><div id='b' style='margin:0 10px 0 20px;padding:0 5px;border:0 solid;border-left-width:3px;height:10px'></div></div>"); @@ -164,7 +181,9 @@ public void AutoWidthBlock_IsStretchFit() [TestMethod] [Spec("css-sizing-3", "#intrinsic-sizes")] + [Spec("css-sizing-4", "#intrinsic-sizes")] [Spec("css-sizing-3", "#intrinsic-contribution")] + [Spec("css-sizing-4", "#intrinsic-contribution")] public void TableColumns_UseTheMinAndMaxContentOfTheirCells() { var root = Layout("<table id='t' style='border-spacing:0'><tr><td id='a' style='padding:0'><span style='display:inline-block;width:80px;height:10px'></span></td>" @@ -176,6 +195,7 @@ public void TableColumns_UseTheMinAndMaxContentOfTheirCells() [TestMethod] [Spec("css-sizing-3", "#intrinsic-sizes")] + [Spec("css-sizing-4", "#intrinsic-sizes")] public void NarrowTable_ShrinksColumnsToTheirMinContent() { var root = Layout("<table id='t' style='width:10px;border-spacing:0'><tr><td id='a' style='padding:0'>aaaa bbbb cccc</td></tr></table>"); @@ -188,6 +208,7 @@ public void NarrowTable_ShrinksColumnsToTheirMinContent() [TestMethod] [Spec("css-sizing-3", "#cyclic-percentage-contribution")] + [Spec("css-sizing-4", "#intrinsic-contribution")] public void CyclicPercentageWidth_IsAutoForTheContribution_ThenResolves() { var root = Layout("<div style='width:400px'><div id='f' style='float:left'><div id='c' style='width:50%'><span style='display:inline-block;width:100px;height:10px'></span></div></div></div>"); diff --git a/Source/Test/HtmlRenderer.Test/Css/AspectRatioGrammarTests.cs b/Source/Test/HtmlRenderer.Test/Css/AspectRatioGrammarTests.cs index ad4300bc0..e24de7e3c 100644 --- a/Source/Test/HtmlRenderer.Test/Css/AspectRatioGrammarTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/AspectRatioGrammarTests.cs @@ -5,11 +5,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/AspectRatioGrammarTests.cs. -/// Tests for the shared <see cref="AspectRatioGrammar"/> (the <c>aspect-ratio</c> value grammar -/// <c>[ auto || &lt;ratio&gt; ]</c>) and its Layer-A accept/reject via the full parser. -/// </summary> [TestClass] public sealed class AspectRatioGrammarTests { @@ -26,6 +21,7 @@ private static bool TryParse(string value, out double? ratio) => [DataRow("3 / 2", 1.5)] [DataRow("auto 21 / 9", 21.0 / 9.0)] [DataRow("21 / 9 auto", 21.0 / 9.0)] + [Spec("css-sizing-4", "#aspect-ratio")] public void ValidRatio_ParsesToWidthOverHeight(string value, double expected) { Assert.IsTrue(TryParse(value, out var ratio)); @@ -91,6 +87,7 @@ public void HasAuto_BareAuto_HasAutoTrueAndNullRatio() [DataRow("aspect-ratio: 2", true)] [DataRow("aspect-ratio: banana", false)] [DataRow("aspect-ratio: 2px", false)] + [Spec("css-sizing-4", "#aspect-ratio")] public void LayerA_AcceptsValid_RejectsInvalid(string declaration, bool shouldApply) { var sheet = CssConstructionFunctions.ParseStyleSheet($"div {{ {declaration}; }}"); diff --git a/Source/Test/HtmlRenderer.Test/Css/CoordinatePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/CoordinatePropertyTests.cs index 797c3b6f6..71e189bc5 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CoordinatePropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CoordinatePropertyTests.cs @@ -15,6 +15,7 @@ private static string GetProperty(string declaration, string propertyName) [TestMethod] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void CssHeightLegalPercentage() { Assert.AreEqual("28%", GetProperty("height: 28%", "height")); @@ -22,6 +23,7 @@ public void CssHeightLegalPercentage() [TestMethod] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void CssHeightLegalLengthInEm() { Assert.AreEqual("0.3em", GetProperty("height: 0.3em", "height")); @@ -29,6 +31,7 @@ public void CssHeightLegalLengthInEm() [TestMethod] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void CssHeightLegalLengthInPx() { Assert.AreEqual("144px", GetProperty("height: 144px", "height")); @@ -36,6 +39,7 @@ public void CssHeightLegalLengthInPx() [TestMethod] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void CssHeightLegalAutoUppercase() { Assert.AreEqual("auto", GetProperty("height: AUTO", "height")); @@ -43,6 +47,7 @@ public void CssHeightLegalAutoUppercase() [TestMethod] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void CssWidthLegalLengthInCm() { Assert.AreEqual("0.5cm", GetProperty("width: 0.5cm", "width")); @@ -50,6 +55,7 @@ public void CssWidthLegalLengthInCm() [TestMethod] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void CssWidthLegalLengthInMm() { Assert.AreEqual("1.5mm", GetProperty("width: 1.5mm", "width")); @@ -57,6 +63,7 @@ public void CssWidthLegalLengthInMm() [TestMethod] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void CssWidthIllegalLength() { Assert.IsTrue(string.IsNullOrEmpty(GetProperty("width: 1.5 meter", "width"))); @@ -64,6 +71,7 @@ public void CssWidthIllegalLength() [TestMethod] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void CssWidthPercentLegal() { Assert.AreEqual("20.5%", GetProperty("width: 20.5%", "width")); @@ -71,6 +79,7 @@ public void CssWidthPercentLegal() [TestMethod] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void CssWidthLegalLengthInInches() { Assert.AreEqual("3in", GetProperty("width: 3in", "width")); @@ -78,6 +87,7 @@ public void CssWidthLegalLengthInInches() [TestMethod] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void CssHeightAngleIllegal() { Assert.IsTrue(string.IsNullOrEmpty(GetProperty("height: 3deg", "height"))); @@ -85,6 +95,7 @@ public void CssHeightAngleIllegal() [TestMethod] [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void CssHeightResolutionIllegal() { Assert.IsTrue(string.IsNullOrEmpty(GetProperty("height: 3dpi", "height"))); @@ -92,6 +103,7 @@ public void CssHeightResolutionIllegal() [TestMethod] [Spec("css-sizing-3", "#max-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] [DataRow("max-width")] [DataRow("max-height")] public void MaxSizeNone_IsAccepted(string property) @@ -102,6 +114,7 @@ public void MaxSizeNone_IsAccepted(string property) [Ignore("Gap, see Spec/css-sizing-3.yml")] [TestMethod] [Spec("css-sizing-3", "#min-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] [DataRow("min-width")] [DataRow("min-height")] public void MinSizeAuto_IsAccepted(string property) diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssSizing4Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssSizing4Tests.cs new file mode 100644 index 000000000..4429cabda --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssSizing4Tests.cs @@ -0,0 +1,97 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssSizing4Tests +{ + private const double Delta = 0.5; + + [Ignore("Gap, see Spec/css-sizing-4.yml")] + [TestMethod] + [DataRow("size:100px 40px", 100, 40)] + [DataRow("size:100px", 100, 100)] + [Spec("css-sizing-4", "#sizing-properties")] + public void SizeShorthands_SetWidthAndHeight(string style, double width, double height) + { + var root = Layout($"<div style='width:300px'><div id='b' style='{style}'></div></div>"); + + Assert.AreEqual(width, Box(root, "b").Size.Width, Delta); + Assert.AreEqual(height, Box(root, "b").Size.Height, Delta); + } + + [Ignore("Gap, see Spec/css-sizing-4.yml")] + [TestMethod] + [DataRow("width: contain")] + [DataRow("width: fit-content(100px)")] + [DataRow("height: calc-size(auto, size + 10px)")] + [Spec("css-sizing-4", "#sizing-values")] + public void NewSizingValues_AreAccepted(string declaration) + { + Assert.IsTrue(Accepted(declaration)); + } + + [Ignore("Gap, see Spec/css-sizing-4.yml")] + [TestMethod] + [Spec("css-sizing-4", "#sizing-values")] + public void FitContentFunction_ClampsBetweenMinAndMaxContent() + { + var root = Layout("<div style='width:300px'><div id='b' style='width:fit-content(100px)'>aaaa bbbb cccc dddd eeee ffff</div></div>"); + + Assert.AreEqual(100, Box(root, "b").Size.Width, Delta); + } + + [TestMethod] + [DataRow("aspect-ratio: 16 / 9")] + [DataRow("aspect-ratio: auto 2")] + [Spec("css-sizing-4", "#aspect-ratio")] + public void AspectRatio_IsAccepted(string declaration) + { + Assert.IsTrue(Accepted(declaration)); + } + + [Ignore("Gap, see Spec/css-sizing-4.yml")] + [TestMethod] + [Spec("css-sizing-4", "#aspect-ratio")] + [Spec("css-sizing-4", "#aspect-ratio-automatic")] + public void AspectRatio_SizesTheAutoHeightFromTheWidth() + { + var root = Layout("<div id='b' style='width:100px;aspect-ratio:2/1'></div>"); + + Assert.AreEqual(50, Box(root, "b").Size.Height, Delta); + } + + [Ignore("Gap, see Spec/css-sizing-4.yml")] + [TestMethod] + [DataRow("contain-intrinsic-size: 100px 50px")] + [DataRow("contain-intrinsic-width: auto 100px")] + [Spec("css-sizing-4", "#intrinsic-size-override")] + public void ContainIntrinsicSize_IsAccepted(string declaration) + { + Assert.IsTrue(Accepted(declaration)); + } + + [Ignore("Gap, see Spec/css-sizing-4.yml")] + [TestMethod] + [Spec("css-sizing-4", "#responsive-iframes")] + public void FrameSizing_IsAccepted() + { + Assert.IsTrue(Accepted("frame-sizing: content-height")); + } + + [Ignore("Gap, see Spec/css-sizing-4.yml")] + [TestMethod] + [Spec("css-sizing-4", "#intrinsic-contribution-override")] + public void MinIntrinsicSizing_IsAccepted() + { + Assert.IsTrue(Accepted("min-intrinsic-sizing: zero-if-scroll")); + } + + private static bool Accepted(string declaration) => CssConstructionFunctions.ParseDeclarations(declaration).Length > 0; + + private static CssBox Layout(string body) => LayoutHarness.Layout(LayoutHarness.Wrap(body)).Root; + + private static CssBox Box(CssBox root, string id) => LayoutHarness.FindById(root, id)!; +} diff --git a/Spec/css-sizing-4.yml b/Spec/css-sizing-4.yml index b06bc7471..425defab3 100644 --- a/Spec/css-sizing-4.yml +++ b/Spec/css-sizing-4.yml @@ -5,82 +5,97 @@ snapshot: https://www.w3.org/TR/2026/WD-css-sizing-4-20260904/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#terms': title: 2 Terminology - status: unknown + status: n/a '#specifying-sizes': title: 3 Specifying Box Sizes - status: unknown + status: partial '#sizing-properties': title: 3.1 Sizing Properties - status: unknown + status: partial + gaps: + - The 'size', 'min-size' and 'max-size' shorthands are ignored + - The sizing properties have the gaps listed in css-sizing-3 3.1 '#sizing-values': title: '3.2 New Sizing Values: the contain, fit-content(), and calc-size() values' - status: unknown + status: unsupported + gaps: + - contain, fit-content() and calc-size() are rejected '#ratios': title: 4 Aspect Ratios - status: unknown + status: parsed-only '#aspect-ratio': title: '4.1 Preferred Aspect Ratios: the aspect-ratio property' - status: unknown + status: parsed-only + gaps: + - Layout ignores 'aspect-ratio', only a replaced element's natural aspect ratio is used, see CSS 2.1 10.3.2 '#aspect-ratio-automatic': title: 4.2 Effects of Preferred Aspect Ratio on Automatic Sizes - status: unknown + status: unsupported '#aspect-ratio-margin-collapse': title: 4.2.1 Margin-collapsing - status: unknown + status: unsupported '#aspect-ratio-minimum': title: 4.3 Automatic Content-based Minimum Sizes - status: unknown + status: unsupported '#aspect-ratio-size-transfers': title: 4.4 Min/Max Size Transfers - status: unknown + status: unsupported '#intrinsic': title: 5 Intrinsic Size Determination - status: unknown + status: partial '#intrinsic-sizes': title: 5.1 Intrinsic Sizes - status: unknown + status: partial + gaps: + - Floats and inline-blocks aren't sized to their content as specified, see css-sizing-3 5.1 '#intrinsic-size-override': title: '5.2 Overriding Contained Intrinsic Sizes: the contain-intrinsic-* properties' - status: unknown + status: unsupported + gaps: + - The contain-intrinsic-* properties are dropped by the parser '#last-remembered': title: 5.2.1 Last Remembered Size - status: unknown + status: unsupported '#cis-scrollbars': title: '5.2.2 Interaction With overflow: auto' - status: unknown + status: n/a + gaps: + - No scrollbars are rendered, see css-overflow-3 4 '#responsive-iframes': title: '5.3 Responsively-sized iframes: the frame-sizing property' - status: unknown + status: unsupported + gaps: + - "'frame-sizing' is dropped and an iframe's document isn't rendered, see HTML 4.8.5" '#iframe-frame-sizing': title: 5.3.1 HTML iframe Details - status: unknown + status: unsupported '#window-requestresize': title: 5.3.2 Extensions to the Window Interface - status: unknown + status: n/a '#intrinsic-contribution': title: 5.4 Intrinsic Size Contributions - status: unknown + status: supported '#intrinsic-contribution-override': title: '5.5 Zeroing Min-Content Size Contributions: the min-intrinsic-sizing property' - status: unknown + status: unsupported + gaps: + - "'min-intrinsic-sizing' is dropped by the parser" '#column-sizing': title: '5.6 New Column Sizing Values: the stretch, min-content, max-content, fit-content, and fit-content() values' - status: unknown + status: unsupported + gaps: + - Multi-column layout isn't supported, see css-multicol-1 '#extrinsic': title: 6 Extrinsic Size Determination - status: unknown + status: partial '#contain-fit-sizing': title: '6.1 Contain-fit Sizing: stretching while maintaining an aspect ratio' - status: unknown + status: unsupported '#percentage-sizing': title: 6.2 Percentage Sizing - status: unknown + status: partial + gaps: + - Percentage heights compute to auto even against a definite height, see css-sizing-3 4.1 From cec9182c805331d48b6bc67a60a63ad019647348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:31:58 +0100 Subject: [PATCH 109/150] Verify and document CSS Positioned Layout Level 4 compliance --- .../BackgroundPaintIntegrationTests.cs | 1 + .../Painting/FragmentPaintIntegrationTests.cs | 1 + .../Painting/StackingContextTests.cs | 11 ++++ .../CssModules/CssPosition4Tests.cs | 51 +++++++++++++++++++ Spec/css-position-4.yml | 30 +++++------ 5 files changed, 79 insertions(+), 15 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssPosition4Tests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs index 3b746f167..12d11cbe1 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs @@ -64,6 +64,7 @@ public void BackgroundColorDefault_PaintsNothing() [TestMethod] [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#painting-order")] public void BackgroundColor_PaintsBeforeBorderAndOutline() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs index a4b92bcc5..3a66ca0ec 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs @@ -114,6 +114,7 @@ public void FixedBox_PaintsAtTheSameCoordinatesOnEveryPage() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#painting-order")] public void StackingOrder_IsPreservedWhenPaintingFromFragments() { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs index 878f6bcda..ef2fb9ec5 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs @@ -16,6 +16,7 @@ public sealed class StackingContextTests [TestMethod] [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#painting-order")] public void BlockBackgrounds_PaintInTreeOrder() { var g = Paint("<div style='height:40px;background:rgb(255,0,0)'><div style='height:20px;background:rgb(0,0,255)'></div></div>"); @@ -25,6 +26,7 @@ public void BlockBackgrounds_PaintInTreeOrder() [TestMethod] [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#painting-order")] public void Background_PaintsBeforeText() { var g = Paint("<div style='background:rgb(0,0,255)'>Text</div>"); @@ -34,6 +36,7 @@ public void Background_PaintsBeforeText() [TestMethod] [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#painting-order")] [Spec("css21", "visuren.html#z-index")] [Spec("css-position-3", "#stacking")] public void AbsoluteBox_PaintsAfterLaterInFlowSiblings() @@ -60,6 +63,7 @@ public void HigherZIndex_PaintsInFrontRegardlessOfDocumentOrder() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#painting-order")] [Spec("css21", "visuren.html#z-index")] public void PositionedZIndex_PaintsOverFixedPositionedContent() { @@ -72,6 +76,7 @@ public void PositionedZIndex_PaintsOverFixedPositionedContent() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#painting-order")] [Spec("css21", "visuren.html#z-index")] public void NegativeZIndex_PaintsBelowInFlowBlocks() { @@ -84,6 +89,7 @@ public void NegativeZIndex_PaintsBelowInFlowBlocks() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#painting-order")] [Spec("css21", "visuren.html#z-index")] [Spec("css-position-3", "#stacking")] public void RelativeBox_PaintsAfterLaterInFlowSiblings() @@ -97,6 +103,7 @@ public void RelativeBox_PaintsAfterLaterInFlowSiblings() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#painting-order")] public void AbsoluteBox_PaintsAfterInFlowContentOutsideItsParent() { var g = Paint("<div style='position:relative'><div style='position:absolute;width:20px;height:20px;background:rgb(255,0,0)'></div></div>" @@ -108,6 +115,7 @@ public void AbsoluteBox_PaintsAfterInFlowContentOutsideItsParent() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#painting-order")] public void Float_PaintsAfterLaterBlockBackgrounds() { var g = Paint("<div style='float:left;width:50px;height:50px;background:rgb(255,0,0)'></div>" @@ -119,6 +127,7 @@ public void Float_PaintsAfterLaterBlockBackgrounds() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#painting-order")] public void BlockBackgrounds_PaintBeforeInlineContent() { var g = Paint("<div style='height:20px'>Text</div>" @@ -130,6 +139,7 @@ public void BlockBackgrounds_PaintBeforeInlineContent() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#painting-order")] public void UnderlineAndOverline_PaintBeforeTheText_LineThroughAfter() { var g = Paint("<div><span style='text-decoration:underline overline line-through'>Deco</span></div>"); @@ -151,6 +161,7 @@ public void UnderlineAndOverline_PaintBeforeTheText_LineThroughAfter() [Ignore("Gap, see Spec/css21.yml")] [TestMethod] [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#painting-order")] public void RootBackground_CoversTheWholeCanvas() { var (_, container) = PaintHarness.Layout( diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssPosition4Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssPosition4Tests.cs new file mode 100644 index 000000000..c22b7f11d --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssPosition4Tests.cs @@ -0,0 +1,51 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssPosition4Tests +{ + private const double Delta = 0.5; + + private const string ScrollContainer = "<div id='c' style='position:relative;overflow:hidden;width:200px;height:100px;padding:10px;border:5px solid'>" + + "<div style='height:300px'></div><div id='a' style='position:absolute;bottom:0;left:0;width:10px;height:10px'></div></div>"; + + [TestMethod] + [Spec("css-position-4", "#scrollable-cb")] + public void AbsoluteBoxInAScrollContainer_IgnoresTheScrollableOverflow() + { + var root = Layout(ScrollContainer); + + Assert.IsTrue(Box(root, "a").Location.Y - Box(root, "c").Location.Y < 130); + } + + [Ignore("Gap, see Spec/css-position-4.yml")] + [TestMethod] + [Spec("css-position-4", "#scrollable-cb")] + public void AbsoluteBoxInAScrollContainer_UsesThePaddingBox() + { + var root = Layout(ScrollContainer); + var c = Box(root, "c"); + var a = Box(root, "a"); + + Assert.AreEqual(5, a.Location.X - c.Location.X, Delta); + Assert.AreEqual(115, a.Location.Y - c.Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css-position-4.yml")] + [TestMethod] + [Spec("css-position-4", "#backdrop")] + public void BackdropInASelectorList_KeepsTheRuleValid() + { + var html = "<!DOCTYPE html><html><head><style>dialog::backdrop, #x { color: #0000ff }</style></head><body><p id='x'>a</p></body></html>"; + var (root, _) = LayoutHarness.Layout(html); + + Assert.AreEqual(RColor.FromArgb(255, 0, 0, 255), Box(root, "x").ActualColor); + } + + private static CssBox Layout(string body) => LayoutHarness.Layout(LayoutHarness.Wrap(body)).Root; + + private static CssBox Box(CssBox root, string id) => LayoutHarness.FindById(root, id)!; +} diff --git a/Spec/css-position-4.yml b/Spec/css-position-4.yml index 282d1126e..2d0bb1385 100644 --- a/Spec/css-position-4.yml +++ b/Spec/css-position-4.yml @@ -5,28 +5,28 @@ snapshot: https://www.w3.org/TR/2025/WD-css-position-4-20251007/ sections: '#intro': title: 1 Introduction - status: unknown + status: n/a '#scrollable-cb': title: 2 Scrollable Containing Block - status: unknown + status: partial + gaps: + - The local containing block is the content box instead of the padding box, see CSS 2.1 10.1 '#top-layer': title: 3 Top Layer - status: unknown - '#top-styling': - title: 3.1 Top Layer Styling - status: unknown + status: n/a + gaps: + - Elements only enter the top layer through script or popover and dialog activation, which the library doesn't have '#backdrop': title: 3.2 The ::backdrop Pseudo-Element - status: unknown - '#top-manip': - title: 3.3 Top Layer Manipulation - status: unknown - '#overlay': - title: '3.4 Controlling the Top Layer: the overlay property' - status: unknown + status: unsupported + gaps: + - "::backdrop invalidates the selector, so a selector list containing it drops the whole rule" '#painting-order': title: 4 Painting Order and Stacking Contexts - status: unknown + status: partial + gaps: + - "'z-index' is ignored and positioned boxes, floats and inline content don't paint in the specified order, see CSS 2.1 E.2" + - The root element's background covers only the root's border box instead of the whole canvas, see CSS 2.1 E.2 '#changes': title: 5 Changes - status: unknown + status: n/a From b187d55151931301e81e78e07a0d505da4528a03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:33:23 +0100 Subject: [PATCH 110/150] Verify and document CSS Display Level 4 compliance --- .../CssModules/CssDisplayTests.cs | 21 ++++++ .../Layout/BoxGenerationIntegrationTests.cs | 5 ++ .../VisibilityPaintIntegrationTests.cs | 3 + .../CssModules/CssDisplay4Tests.cs | 15 +++++ Spec/css-display-4.yml | 64 +++++++++++-------- 5 files changed, 83 insertions(+), 25 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssDisplay4Tests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssDisplayTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssDisplayTests.cs index d867fa840..ca7938dd3 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssDisplayTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssDisplayTests.cs @@ -14,6 +14,7 @@ public sealed class CssDisplayTests [TestMethod] [Spec("css-display-3", "#outer-role")] + [Spec("css-display-4", "#outer-role")] public void BlockAndInlineOuterRoles_StackOrShareTheLine() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -27,7 +28,9 @@ public void BlockAndInlineOuterRoles_StackOrShareTheLine() [Ignore("Gap, see Spec/css-display-3.yml")] [TestMethod] [Spec("css-display-3", "#outer-role")] + [Spec("css-display-4", "#outer-role")] [Spec("css-display-3", "#run-in-layout")] + [Spec("css-display-4", "#run-in-layout")] public void RunIn_IsInsertedAtTheStartOfTheFollowingBlock() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -46,6 +49,7 @@ public void RunIn_IsInsertedAtTheStartOfTheFollowingBlock() [DataRow("inline table", "inline-table")] [DataRow("flow", "block")] [Spec("css-display-3", "#the-display-properties")] + [Spec("css-display-4", "#the-display-properties")] public void TwoValueSyntax_ComputesToTheEquivalentShortValue(string value, string expected) { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -56,6 +60,7 @@ public void TwoValueSyntax_ComputesToTheEquivalentShortValue(string value, strin [TestMethod] [Spec("css-display-3", "#inner-model")] + [Spec("css-display-4", "#inner-model")] public void FlowInnerDisplay_LaysTheChildrenOutInNormalFlow() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -71,6 +76,7 @@ public void FlowInnerDisplay_LaysTheChildrenOutInNormalFlow() [Ignore("Gap, see Spec/css-display-3.yml")] [TestMethod] [Spec("css-display-3", "#inner-model")] + [Spec("css-display-4", "#inner-model")] public void FlowRoot_IsBlockLevelAndContainsItsFloats() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -85,7 +91,9 @@ public void FlowRoot_IsBlockLevelAndContainsItsFloats() [Ignore("Gap, see Spec/css-display-3.yml")] [TestMethod] [Spec("css-display-3", "#inner-model")] + [Spec("css-display-4", "#inner-model")] [Spec("css-display-3", "#layout-specific-display")] + [Spec("css-display-4", "#layout-specific-display")] public void Ruby_KeepsTheAnnotationOffTheBaseLine() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -96,6 +104,7 @@ public void Ruby_KeepsTheAnnotationOffTheBaseLine() [TestMethod] [Spec("css-display-3", "#list-items")] + [Spec("css-display-4", "#list-items")] public void ListItem_GeneratesAMarker() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -109,6 +118,7 @@ public void ListItem_GeneratesAMarker() [Ignore("Gap, see Spec/css-display-3.yml")] [TestMethod] [Spec("css-display-3", "#list-items")] + [Spec("css-display-4", "#list-items")] public void InlineListItem_IsInlineAndGeneratesAMarker() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -122,6 +132,7 @@ public void InlineListItem_IsInlineAndGeneratesAMarker() [TestMethod] [Spec("css-display-3", "#layout-specific-display")] + [Spec("css-display-4", "#layout-specific-display")] public void TableInternalDisplayTypes_BuildATable() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -138,6 +149,7 @@ public void TableInternalDisplayTypes_BuildATable() [TestMethod] [Spec("css-display-3", "#box-generation")] + [Spec("css-display-4", "#box-generation")] public void DisplayNone_PaintsNothingOfTheSubtree() { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -152,6 +164,7 @@ public void DisplayNone_PaintsNothingOfTheSubtree() [Ignore("Gap, see Spec/css-display-3.yml")] [TestMethod] [Spec("css-display-3", "#box-generation")] + [Spec("css-display-4", "#box-generation")] public void DisplayContents_ReplacesTheElementByItsChildren() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -164,6 +177,7 @@ public void DisplayContents_ReplacesTheElementByItsChildren() [Ignore("Gap, see Spec/css-display-3.yml")] [TestMethod] [Spec("css-display-3", "#box-generation")] + [Spec("css-display-4", "#box-generation")] public void DisplayContents_PaintsNoBoxOfItsOwn() { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -177,6 +191,7 @@ public void DisplayContents_PaintsNoBoxOfItsOwn() [Ignore("Gap, see Spec/css-display-3.yml")] [TestMethod] [Spec("css-display-3", "#legacy-display")] + [Spec("css-display-4", "#legacy-display")] public void InlineBlockWithBlockChildren_StaysOnTheLine() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -193,8 +208,10 @@ public void InlineBlockWithBlockChildren_StaysOnTheLine() [DataRow("inline-flex")] [DataRow("inline-grid")] [Spec("css-display-3", "#inner-model")] + [Spec("css-display-4", "#inner-model")] [Spec("css-display-3", "#legacy-display")] [Spec("css-flexbox-1", "#flex-containers")] + [Spec("css-display-4", "#legacy-display")] public void FlexAndGridContainers_PaintTheirContent(string display) { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -208,6 +225,7 @@ public void FlexAndGridContainers_PaintTheirContent(string display) [Ignore("Gap, see Spec/css-display-3.yml")] [TestMethod] [Spec("css-display-3", "#transformations")] + [Spec("css-display-4", "#transformations")] public void FloatedInlineBlock_BlockifiesToBlock() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -220,6 +238,7 @@ public void FloatedInlineBlock_BlockifiesToBlock() [DataRow("")] [DataRow("display:inline")] [Spec("css-display-3", "#root")] + [Spec("css-display-4", "#root")] public void RootElement_IsBlockLevelAndFillsTheViewport(string style) { var (root, _) = LayoutHarness.Layout($"<!DOCTYPE html><html id='h' style='{style}'><body style='margin:0'><div style='height:20px'></div></body></html>", maxWidth: 400); @@ -235,6 +254,7 @@ public void RootElement_IsBlockLevelAndFillsTheViewport(string style) [DataRow("inline-block")] [DataRow("table-cell")] [Spec("css-display-3", "#root")] + [Spec("css-display-4", "#root")] public void RootElement_IsBlockified(string display) { var (root, _) = LayoutHarness.Layout($"<!DOCTYPE html><html id='h' style='display:{display}'><body style='margin:0'><div style='height:20px'></div></body></html>", maxWidth: 400); @@ -247,6 +267,7 @@ public void RootElement_IsBlockified(string display) [TestMethod] [Spec("css-display-3", "#visibility")] + [Spec("css-display-4", "#visibility")] public void VisibilityCollapse_OutsideTables_HidesLikeHidden() { var (_, collapsed) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs index b68f45a2f..37b024781 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs @@ -130,6 +130,7 @@ public void InlineLevelDisplayValues_SitOnTheSameLine(string display) [TestMethod] [Spec("css21", "visuren.html#inline-boxes")] [Spec("css-display-3", "#legacy-display")] + [Spec("css-display-4", "#legacy-display")] public void InlineTable_SitsOnTheSameLine() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -170,6 +171,7 @@ public void CollapsibleWhiteSpaceBetweenBlocks_GeneratesNoBox() [TestMethod] [Spec("css21", "visuren.html#display-prop")] [Spec("css-display-3", "#box-generation")] + [Spec("css-display-4", "#box-generation")] public void DisplayNone_GeneratesNoBoxAndTakesNoSpace() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -190,6 +192,7 @@ public void UnknownElement_IsInlineByDefault() [TestMethod] [Spec("css21", "visuren.html#display-prop")] [Spec("css-display-3", "#legacy-display")] + [Spec("css-display-4", "#legacy-display")] public void InlineBlock_TakesItsWidthAndHeightOnTheLine() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -217,6 +220,7 @@ public void DisplayNone_IgnoresPositionAndFloat() [TestMethod] [Spec("css21", "visuren.html#dis-pos-flo")] [Spec("css-display-3", "#transformations")] + [Spec("css-display-4", "#transformations")] public void FloatedInline_IsBlockified() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -232,6 +236,7 @@ public void FloatedInline_IsBlockified() [TestMethod] [Spec("css21", "visuren.html#dis-pos-flo")] [Spec("css-display-3", "#transformations")] + [Spec("css-display-4", "#transformations")] [Spec("css-position-3", "#position-property")] public void AbsolutelyPositionedInline_IsBlockified() { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/VisibilityPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/VisibilityPaintIntegrationTests.cs index 07e3e4e3b..b5cc3f810 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/VisibilityPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/VisibilityPaintIntegrationTests.cs @@ -11,6 +11,7 @@ public sealed class VisibilityPaintIntegrationTests [TestMethod] [Spec("css21", "visufx.html#visibility")] [Spec("css-display-3", "#visibility")] + [Spec("css-display-4", "#visibility")] public void Hidden_IsInheritedByDescendants() { var recording = Paint("<div style='visibility:hidden'><div style='height:10px;background:rgb(255,0,0)'>Child</div></div>"); @@ -22,6 +23,7 @@ public void Hidden_IsInheritedByDescendants() [TestMethod] [Spec("css21", "visufx.html#visibility")] [Spec("css-display-3", "#visibility")] + [Spec("css-display-4", "#visibility")] public void HiddenInline_PaintsNothingButKeepsItsSpace() { var hidden = Paint("<div>aaa <span style='visibility:hidden'>Hid</span> bbb</div>"); @@ -35,6 +37,7 @@ public void HiddenInline_PaintsNothingButKeepsItsSpace() [TestMethod] [Spec("css21", "visufx.html#visibility")] [Spec("css-display-3", "#visibility")] + [Spec("css-display-4", "#visibility")] public void VisibleDescendantOfAHiddenElement_IsPainted() { var recording = Paint("<div style='visibility:hidden'><div style='visibility:visible;height:10px;background:rgb(255,0,0)'>Shown</div></div>"); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssDisplay4Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssDisplay4Tests.cs new file mode 100644 index 000000000..be4711344 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssDisplay4Tests.cs @@ -0,0 +1,15 @@ +using HtmlRenderer.Test.CssEngineSupport; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssDisplay4Tests +{ + [Ignore("Gap, see Spec/css-display-4.yml")] + [TestMethod] + [Spec("css-display-4", "#visibility")] + public void ForceHidden_IsAccepted() + { + Assert.IsTrue(CssConstructionFunctions.ParseDeclarations("visibility: force-hidden").Length > 0); + } +} diff --git a/Spec/css-display-4.yml b/Spec/css-display-4.yml index 047bcfc4c..47fa50be7 100644 --- a/Spec/css-display-4.yml +++ b/Spec/css-display-4.yml @@ -5,58 +5,72 @@ snapshot: https://www.w3.org/TR/2025/WD-css-display-4-20251106/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#the-display-properties': title: '2 Box Layout Modes: the display property' - status: unknown + status: partial + gaps: + - The two-value syntax is rejected, see css-display-3 2 '#outer-role': title: '2.1 Outer Display Roles for Flow Layout: the block, inline, and run-in keywords' - status: unknown + status: partial + gaps: + - "'run-in' is rejected" '#inner-model': title: '2.2 Inner Display Layout Models: the flow, flow-root, table, flex, grid, and ruby keywords' - status: unknown + status: partial + gaps: + - "'flow-root' and 'ruby' are rejected, flex and grid containers lose their content, see css-display-3 2.2" '#list-items': title: '2.3 Generating Marker Boxes: the list-item keyword' - status: unknown + status: partial + gaps: + - "'list-item' combined with an outer or inner display type is rejected" '#layout-specific-display': title: '2.4 Layout-Internal Display Types: the table-* and ruby-* keywords' - status: unknown + status: partial + gaps: + - The ruby-* keywords are rejected '#box-generation': title: '2.5 Box Generation: the none and contents keywords' - status: unknown + status: partial + gaps: + - "'contents' is rejected, the element keeps its own box" '#legacy-display': title: 2.6 Precomposed Inline-level Display Values - status: unknown + status: partial + gaps: + - "'inline-table', inline-blocks with block-level content, 'inline-flex' and 'inline-grid' are laid out wrong, see css-display-3 2.6" '#transformations': title: 2.7 Automatic Box Type Transformations - status: unknown + status: unsupported + gaps: + - Floated and absolutely positioned boxes keep their inline-level display type '#root': title: 2.8 The Root Element’s Principal Box - status: unknown + status: partial + gaps: + - An inline-level root other than 'inline', e.g. 'inline-block', or a layout-internal root isn't blockified '#display-animation': title: 2.9 Animating and Interpolating display - status: unknown + status: n/a '#order-property': title: '3 Display Order: the order property' - status: unknown + status: parsed-only '#order-accessibility': title: 3.1 Reordering and Accessibility - status: unknown + status: n/a '#reading-flow': title: '4 Reading Order: the reading-flow property' - status: unknown - '#reading-order': - title: '4.1 Overriding Reading Flow: the reading-order property' - status: unknown + status: n/a + gaps: + - Only affects speech and sequential navigation, which the library doesn't have, not the rendering '#visibility': title: '5 Invisibility: the visibility property' - status: unknown + status: partial + gaps: + - "Descendants with 'visibility: visible' of a hidden element are not painted, see CSS 2.1 11.2" + - "'force-hidden' is rejected" '#run-in-layout': title: 6 Run-In Layout - status: unknown + status: unsupported From 92b157912986528fb889d46e428a3ce57f06f283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:35:04 +0100 Subject: [PATCH 111/150] Verify and document CSS Containment Level 2 compliance --- .../CssModules/CssContainTests.cs | 57 +++++++++++++++++++ Spec/css-contain-2.yml | 50 ++++++++-------- 2 files changed, 81 insertions(+), 26 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssContainTests.cs diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssContainTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssContainTests.cs new file mode 100644 index 000000000..91d90b79c --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssContainTests.cs @@ -0,0 +1,57 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssContainTests +{ + private const double Delta = 0.5; + + [Ignore("Gap, see Spec/css-contain-2.yml")] + [TestMethod] + [DataRow("contain: strict")] + [DataRow("contain: layout paint")] + [DataRow("content-visibility: hidden")] + [Spec("css-contain-2", "#contain-property")] + [Spec("css-contain-2", "#content-visibility")] + public void ContainmentProperties_AreAccepted(string declaration) + { + Assert.IsTrue(CssConstructionFunctions.ParseDeclarations(declaration).Length > 0); + } + + [Ignore("Gap, see Spec/css-contain-2.yml")] + [TestMethod] + [Spec("css-contain-2", "#containment-size")] + public void SizeContainment_SizesTheBoxAsIfEmpty() + { + var root = Layout("<div id='b' style='contain:size'><div style='height:50px'></div></div>"); + + Assert.AreEqual(0, Box(root, "b").Size.Height, Delta); + } + + [Ignore("Gap, see Spec/css-contain-2.yml")] + [TestMethod] + [Spec("css-contain-2", "#containment-layout")] + public void LayoutContainment_IsTheContainingBlockOfAbsoluteDescendants() + { + var root = Layout("<div style='height:40px'></div><div id='c' style='contain:layout;height:50px'><div id='a' style='position:absolute;top:0;height:10px'></div></div>"); + + Assert.AreEqual(Box(root, "c").Location.Y, Box(root, "a").Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css-contain-2.yml")] + [TestMethod] + [Spec("css-contain-2", "#content-visibility")] + public void ContentVisibilityHidden_SkipsTheContents() + { + var root = Layout("<div id='b' style='content-visibility:hidden'><div style='height:50px'></div></div>"); + + Assert.AreEqual(0, Box(root, "b").Size.Height, Delta); + } + + private static CssBox Layout(string body) => LayoutHarness.Layout(LayoutHarness.Wrap(body)).Root; + + private static CssBox Box(CssBox root, string id) => LayoutHarness.FindById(root, id)!; +} diff --git a/Spec/css-contain-2.yml b/Spec/css-contain-2.yml index dda5457cd..d7d4e4f34 100644 --- a/Spec/css-contain-2.yml +++ b/Spec/css-contain-2.yml @@ -5,67 +5,65 @@ snapshot: https://www.w3.org/TR/2022/WD-css-contain-2-20220917/ sections: '#intro': title: 1 Introduction - status: unknown - '#interaction': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#contain-property': title: '2 Strong Containment: the contain property' - status: unknown + status: unsupported + gaps: + - "'contain' is dropped by the parser" '#containment-types': title: 3 Types of Containment - status: unknown + status: unsupported '#containment-size': title: 3.1 Size Containment - status: unknown + status: unsupported '#containment-size-opt': title: 3.1.1 Possible Size-Containment Optimizations - status: unknown + status: n/a '#containment-layout': title: 3.2 Layout Containment - status: unknown + status: unsupported '#containment-layout-opt': title: 3.2.1 Possible Layout-Containment Optimizations - status: unknown + status: n/a '#containment-style': title: 3.3 Style Containment - status: unknown + status: unsupported '#containment-style-opt': title: 3.3.1 Possible Style-Containment Optimizations - status: unknown + status: n/a '#containment-paint': title: 3.4 Paint Containment - status: unknown + status: unsupported '#containment-paint-opt': title: 3.4.1 Possible Paint-Containment Optimizations - status: unknown + status: n/a '#content-visibility': title: '4 Suppressing An Element’s Contents Entirely: the content-visibility property' - status: unknown + status: unsupported + gaps: + - "'content-visibility' is dropped by the parser" '#using-cv-hidden': title: '4.1 Using content-visibility: hidden' - status: unknown + status: n/a '#using-cv-auto': title: '4.2 Using content-visibility: auto' - status: unknown + status: n/a '#content-visibility-auto-state-changed': title: '4.3 Detecting content-visibility: auto state changes: the contentvisibilityautostatechanged event' - status: unknown + status: n/a '#cv-notes': title: 4.4 Restrictions and Clarifications - status: unknown + status: unsupported '#cv-a11y': title: 4.5 Accessibility Implications - status: unknown + status: n/a '#cv-examples': title: 4.6 Examples - status: unknown + status: n/a '#privacy': title: 5 Privacy Considerations - status: unknown + status: n/a '#security': title: 6 Security Considerations - status: unknown + status: n/a From b92cb15b25fbe3d75567c635c955ce9728aae505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:37:38 +0100 Subject: [PATCH 112/150] Verify and document CSS Fragmentation Level 4 compliance --- .../Core/Fragmentation/BreakValues.cs | 4 +- .../BreakPropagationIntegrationTests.cs | 7 +- .../PageBreakIntegrationTests.cs | 4 +- .../PagedMediaIntegrationTests.cs | 25 ++++++ .../BoxDecorationBreakIntegrationTests.cs | 2 + .../Painting/FragmentPaintIntegrationTests.cs | 1 + .../HtmlRenderer.Test/Css/PropertyBoxTests.cs | 33 ++------ .../Css/PropertyBreakTests.cs | 24 +++++- .../Css/PropertyPaginationTests.cs | 5 ++ .../Fragmentation/BreakValuesTests.cs | 2 + .../Fragmentation/CssBreak4Tests.cs | 57 +++++++++++++ .../Fragmentation/CssBreakTests.cs | 24 ++++++ .../ForcedBreakTargetIsTheFramesTests.cs | 6 ++ .../Fragmentation/MonolithicContentTests.cs | 3 + Spec/css-break-4.yml | 83 ++++++++++++------- 15 files changed, 217 insertions(+), 63 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Fragmentation/CssBreak4Tests.cs diff --git a/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs b/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs index 920b49ac6..dc2439dbe 100644 --- a/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs +++ b/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs @@ -9,8 +9,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation internal static class BreakValues { /// <summary> - /// Whether <paramref name="value"/> forces a page break. The legacy <c>always</c> is accepted on - /// <c>break-before</c>/<c>break-after</c> too, they share their value with <c>page-break-*</c>. + /// Whether <paramref name="value"/> forces a page break. <c>always</c> comes from <c>page-break-*</c> + /// and from css-break-4 §3.1. /// </summary> internal static bool IsForcedBreak(string value) => value is CssConstants.Page or CssConstants.Always; diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BreakPropagationIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BreakPropagationIntegrationTests.cs index 8e683e9d5..dd1654529 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BreakPropagationIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/BreakPropagationIntegrationTests.cs @@ -64,8 +64,8 @@ private static CssBox FindById(CssBox root, string id) [TestMethod] [Ignore("Gap, see Spec/css21.yml")] - [Spec("css21", "page.html#forced")] [Spec("css-break-3", "#break-propagation")] + [Spec("css-break-4", "#break-propagation")] public async Task ForcedBreakBeforeAFirstChild_MovesTheContainer() { var (root, container) = await BuildAsync( @@ -87,6 +87,7 @@ public async Task ForcedBreakBeforeAFirstChild_MovesTheContainer() [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#forced")] [Spec("css-break-3", "#break-propagation")] + [Spec("css-break-4", "#break-propagation")] public async Task ForcedBreakBeforeANestedFirstChild_MovesTheOutermostContainerItBegins() { var (root, container) = await BuildAsync( @@ -104,6 +105,7 @@ public async Task ForcedBreakBeforeANestedFirstChild_MovesTheOutermostContainerI [TestMethod] [Spec("css21", "page.html#forced")] [Spec("css-break-3", "#forced-breaks")] + [Spec("css-break-4", "#forced-breaks")] public async Task ForcedBreakBeforeALaterChild_LeavesTheContainerWhereItIs() { var (root, container) = await BuildAsync( @@ -123,6 +125,7 @@ public async Task ForcedBreakBeforeALaterChild_LeavesTheContainerWhereItIs() [TestMethod] [Spec("css-break-3", "#break-propagation")] + [Spec("css-break-4", "#break-propagation")] public async Task ForcedBreakBeforeTheFirstBoxInTheFlow_ManufacturesNoBlankPage() { var (root, container) = await BuildAsync( @@ -143,6 +146,7 @@ public async Task ForcedBreakBeforeTheFirstBoxInTheFlow_ManufacturesNoBlankPage( [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#forced")] [Spec("css-break-3", "#break-propagation")] + [Spec("css-break-4", "#break-propagation")] public async Task BreakAfterOnALastChild_ForcesTheBreakBeforeTheFollowingSibling() { var (root, container) = await BuildAsync( @@ -162,6 +166,7 @@ public async Task BreakAfterOnALastChild_ForcesTheBreakBeforeTheFollowingSibling [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] [Spec("css-break-3", "#unforced-breaks")] + [Spec("css-break-4", "#unforced-breaks")] public async Task BreakInsideAvoidOnAFirstChild_PullsTheRunAcrossTheContainer() { var (root, container) = await BuildAsync( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageBreakIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageBreakIntegrationTests.cs index b6a55178e..e22cc8600 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageBreakIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PageBreakIntegrationTests.cs @@ -122,9 +122,9 @@ public async Task BreakBeforePage_ForcesBreak() $"break-before: page should start the box on page 2, but it starts at y={second.Location.Y}"); } - // 'always' is also accepted on the unprefixed break-before (css-break-4). [TestMethod] - public async Task BreakBeforeAlways_IsAcceptedAsAForcedBreak_UnlikePeachPDF() + [Spec("css-break-4", "#break-between")] + public async Task BreakBeforeAlways_ForcesBreak() { var (root, container) = await BuildAsync(ForcedBreakHtml("break-before:always")); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs index adfcd67ca..d1b08de71 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs @@ -102,6 +102,7 @@ public void FarAwayAbsoluteBox_DoesNotProduceBlankPages() [TestMethod] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void PageBreakBeforeAlways_StartsTheBoxOnANewPage() { var (root, container) = Paginate( @@ -113,6 +114,7 @@ public void PageBreakBeforeAlways_StartsTheBoxOnANewPage() [TestMethod] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void PageBreakAfterAlways_StartsTheNextBoxOnANewPage() { var (root, container) = Paginate( @@ -125,6 +127,7 @@ public void PageBreakAfterAlways_StartsTheNextBoxOnANewPage() [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] [DataRow("<div style='height:50px'></div><div id='b' style='page-break-before:left;height:10px'></div>")] [DataRow("<div style='height:50px'></div><div id='b' style='page-break-before:right;height:10px'></div>")] [DataRow("<div style='height:50px;page-break-after:left'></div><div id='b' style='height:10px'></div>")] @@ -139,6 +142,7 @@ public void LeftAndRight_ForceABreak(string body) [TestMethod] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#forced-breaks")] + [Spec("css-break-4", "#forced-breaks")] [DataRow("<div style='height:50px;page-break-after:avoid'></div><div id='b' style='page-break-before:always;height:10px'></div>")] [DataRow("<div style='height:50px;page-break-after:always'></div><div id='b' style='page-break-before:avoid;height:10px'></div>")] public void Always_TakesPrecedenceOverAvoid(string body) @@ -151,6 +155,7 @@ public void Always_TakesPrecedenceOverAvoid(string body) [TestMethod] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#break-within")] + [Spec("css-break-4", "#break-within")] public void PageBreakInsideAvoid_KeepsTheBoxOnOnePage() { var (root, container) = Paginate( @@ -165,6 +170,7 @@ public void PageBreakInsideAvoid_KeepsTheBoxOnOnePage() [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#break-within")] + [Spec("css-break-4", "#break-within")] public void PageBreakInsideAvoid_KeepsABoxWithoutTextOnOnePage() { var (root, container) = Paginate( @@ -177,6 +183,7 @@ public void PageBreakInsideAvoid_KeepsABoxWithoutTextOnOnePage() [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#break-decoration")] + [Spec("css-break-4", "#break-decoration")] public void ASplitBox_HasNoBorderWhereItIsSplit() { var (_, container) = PaintHarness.LayoutPaginated(PaintHarness.Wrap( @@ -201,6 +208,7 @@ public void ASplitBox_HasNoBorderWhereItIsSplit() [TestMethod] [Spec("css21", "page.html#break-inside")] [Spec("css-break-3", "#widows-orphans")] + [Spec("css-break-4", "#widows-orphans")] public void OrphansAndWidows_DefaultToTwoAndInherit() { var (root, _) = Paginate("<div style='orphans:5;widows:4'><p id='c'>c</p></div><p id='d'>d</p>"); @@ -215,6 +223,7 @@ public void OrphansAndWidows_DefaultToTwoAndInherit() [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#break-inside")] [Spec("css-break-3", "#widows-orphans")] + [Spec("css-break-4", "#widows-orphans")] [DataRow("orphans:0;widows:0")] [DataRow("orphans:-3;widows:-3")] public void NonPositiveOrphansAndWidows_AreIgnored(string declaration) @@ -228,6 +237,7 @@ public void NonPositiveOrphansAndWidows_AreIgnored(string declaration) [TestMethod] [Spec("css21", "page.html#break-inside")] [Spec("css-break-3", "#widows-orphans")] + [Spec("css-break-4", "#widows-orphans")] public void Orphans_MovesTheParagraphWhenTooFewLinesFit() { var filler = PageHeight - 2.5 * LineHeight; @@ -239,6 +249,7 @@ public void Orphans_MovesTheParagraphWhenTooFewLinesFit() [TestMethod] [Spec("css21", "page.html#break-inside")] [Spec("css-break-3", "#widows-orphans")] + [Spec("css-break-4", "#widows-orphans")] public void Widows_PullsLinesToTheNextPage() { var filler = PageHeight - 5.5 * LineHeight; @@ -254,6 +265,7 @@ public void Widows_PullsLinesToTheNextPage() [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] [Spec("css-break-3", "#break-margins")] + [Spec("css-break-4", "#break-margins")] public void UnforcedBreakInTheMargin_SetsTheMarginToZero() { var (root, container) = Paginate( @@ -267,6 +279,7 @@ public void UnforcedBreakInTheMargin_SetsTheMarginToZero() [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#allowed-page-breaks")] [Spec("css-break-3", "#possible-breaks")] + [Spec("css-break-4", "#possible-breaks")] public void ABlockWhoseLinesAllMoveToTheNextPage_MovesWithThem() { var filler = PageHeight - 1.5 * LineHeight; @@ -279,6 +292,7 @@ public void ABlockWhoseLinesAllMoveToTheNextPage_MovesWithThem() [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] [Spec("css-break-3", "#possible-breaks")] + [Spec("css-break-4", "#possible-breaks")] public void Breaks_FallBetweenLineBoxes() { var (root, container) = Paginate("<div style='height:5px'></div>" + Paragraph("p", 40)); @@ -292,6 +306,7 @@ public void Breaks_FallBetweenLineBoxes() [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] [Spec("css-break-3", "#unforced-breaks")] + [Spec("css-break-4", "#unforced-breaks")] [DataRow("page-break-after:avoid", "")] [DataRow("", "page-break-before:avoid")] public void AvoidBetweenSiblings_MovesTheFirstAlongWithTheSecond(string headStyle, string nextStyle) @@ -309,6 +324,7 @@ public void AvoidBetweenSiblings_MovesTheFirstAlongWithTheSecond(string headStyl [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#allowed-page-breaks")] [Spec("css-break-3", "#unforced-breaks")] + [Spec("css-break-4", "#unforced-breaks")] public void AvoidBetweenSiblings_AlsoMovesABlockWithoutText() { var (root, container) = Paginate( @@ -322,6 +338,7 @@ public void AvoidBetweenSiblings_AlsoMovesABlockWithoutText() [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] [Spec("css-break-3", "#unforced-breaks")] + [Spec("css-break-4", "#unforced-breaks")] public void PageBreakInsideAvoidOnAnAncestor_ForbidsBreaksBetweenItsChildren() { var (root, container) = Paginate( @@ -336,6 +353,7 @@ public void PageBreakInsideAvoidOnAnAncestor_ForbidsBreaksBetweenItsChildren() [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] [Spec("css-break-3", "#unforced-breaks")] + [Spec("css-break-4", "#unforced-breaks")] public void AvoidBoxTallerThanAPage_IsStillBroken() { var (root, container) = Paginate("<div style='height:100px'></div>" + Paragraph("p", 25, "page-break-inside:avoid")); @@ -348,6 +366,7 @@ public void AvoidBoxTallerThanAPage_IsStillBroken() [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] [Spec("css-break-3", "#unforced-breaks")] + [Spec("css-break-4", "#unforced-breaks")] public void OrphansAndWidowsThatCannotBeSatisfied_AreDropped() { // Fewer than 20 lines fit on a page, so no split of 25 lines satisfies both. @@ -362,6 +381,7 @@ public void OrphansAndWidowsThatCannotBeSatisfied_AreDropped() [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] [Spec("css-break-3", "#break-margins")] + [Spec("css-break-4", "#break-margins")] public void ForcedBreak_RetainsTheMarginTop() { var (root, container) = Paginate( @@ -374,6 +394,7 @@ public void ForcedBreak_RetainsTheMarginTop() [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "page.html#allowed-page-breaks")] [Spec("css-break-3", "#break-margins")] + [Spec("css-break-4", "#break-margins")] public void ForcedBreak_SetsTheMarginBottomToZero() { var (root, container) = Paginate( @@ -389,6 +410,7 @@ public void ForcedBreak_SetsTheMarginBottomToZero() [TestMethod] [Spec("css21", "page.html#forced")] [Spec("css-break-3", "#forced-breaks")] + [Spec("css-break-4", "#forced-breaks")] [Spec("css-page-3", "#page-breaks")] public void ForcedBreak_OccursEvenWhereTheContentWouldFit() { @@ -407,6 +429,7 @@ public void ForcedBreak_OccursEvenWhereTheContentWouldFit() [TestMethod] [Spec("css21", "page.html#best-page-breaks")] [Spec("css-break-3", "#possible-breaks")] + [Spec("css-break-4", "#possible-breaks")] public void ReplacedElement_IsNotBroken() { var (root, container) = Paginate( @@ -420,6 +443,7 @@ public void ReplacedElement_IsNotBroken() [TestMethod] [Spec("css21", "page.html#best-page-breaks")] [Spec("css-break-3", "#best-breaks")] + [Spec("css-break-4", "#best-breaks")] [DataRow(20, new[] { 20, 0 })] [DataRow(21, new[] { 19, 2 })] [DataRow(22, new[] { 20, 2 })] @@ -435,6 +459,7 @@ public void Example_Orphans4Widows2With20LinesAvailable(int lines, int[] expecte [TestMethod] [Spec("css21", "page.html#best-page-breaks")] [Spec("css-break-3", "#best-breaks")] + [Spec("css-break-4", "#best-breaks")] [DataRow(8, new[] { 8, 0, 0 })] [DataRow(9, new[] { 0, 9, 0 })] [DataRow(30, new[] { 0, 10, 20 })] diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BoxDecorationBreakIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BoxDecorationBreakIntegrationTests.cs index 95768ad6e..141179a8b 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BoxDecorationBreakIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BoxDecorationBreakIntegrationTests.cs @@ -22,6 +22,7 @@ private static int HorizontalEdges(RecordingGraphics recording) => [TestMethod] [Spec("css-break-3", "#break-decoration")] + [Spec("css-break-4", "#break-decoration")] public void Slice_InlineBrokenAcrossLines_HasNoBorderAtTheBrokenEdges() { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -36,6 +37,7 @@ public void Slice_InlineBrokenAcrossLines_HasNoBorderAtTheBrokenEdges() [TestMethod] [Ignore("Gap, see Spec/css-break-3.yml")] [Spec("css-break-3", "#break-decoration")] + [Spec("css-break-4", "#break-decoration")] public void Clone_InlineBrokenAcrossLines_HasBordersOnEveryFragment() { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs index 3a66ca0ec..a182e81b6 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs @@ -20,6 +20,7 @@ public sealed class FragmentPaintIntegrationTests [TestMethod] [Spec("css21", "page.html#forced")] [Spec("css-break-3", "#fragmentation-model")] + [Spec("css-break-4", "#fragmentation-model")] public void EachPage_PaintsOnlyItsOwnFragmentainersContent() { var (_, container) = PaintHarness.LayoutPaginated(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyBoxTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyBoxTests.cs index fb7070dda..c904676cc 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PropertyBoxTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PropertyBoxTests.cs @@ -6,32 +6,7 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/Property.cs (the BoxShadow/Clip/Cursor/BoxDecorationBreak cases). -/// -/// box-shadow (StyleProperties/Box/BoxShadowProperty.cs) is parsed by -/// Source/HtmlRenderer/Core/CssEngine/BoxShadowGrammar.cs, which validates the -/// <c>none | [ inset? &amp;&amp; &lt;length&gt;{2,4} &amp;&amp; &lt;color&gt;? ]#</c> grammar but then -/// stores the token stream's own reconstructed text verbatim as <c>Value</c>/<c>Original</c> -/// (BoxShadowValueConverter.cs: <c>CssText => Original.Text</c>) rather than a normalized -/// representation - so authored casing like "NONE"/"INITIAL" survives unchanged in both Value and -/// Original, while multi-space runs between tokens still collapse to one space because the tokenizer -/// itself only ever emits a single whitespace token between significant tokens. These assertions are -/// parse-only per the port task's caveat: box-shadow is a layout/paint no-op in this renderer, but that -/// does not affect what the parser records here. -/// -/// clip (StyleProperties/Visibility/ClipProperty.cs) uses <c>Converters.ShapeConverter</c> -/// (Model/Converters.cs line 122), a <c>rect(&lt;length&gt;{4})</c> function grammar via the shared -/// LengthConverter, which normalizes zero-with-unit lengths ("0cm") down to unitless "0" the same way -/// the rest of the length grammar does (see BorderPropertyTests.cs's border-width port), while non-zero -/// units and comma placement are preserved from the authored value. -/// -/// cursor (StyleProperties/CursorProperty.cs) accepts a list of <c>url()</c> image sources (each -/// optionally followed by an x/y hotspot pair) that must end in a plain keyword from <c>Map.Cursors</c> -/// (Model/Map.cs) - a url()-only list with no trailing keyword fallback is invalid, matching PeachPDF. -/// -/// box-decoration-break (StyleProperties/Box/BoxDecorationBreak.cs) is a -/// <c>Toggle(Keywords.Clone, Keywords.Slice)</c> (Model/Converters.cs line 543) plus the standard -/// CSS-wide keyword chain, both value sets confirmed identical to the source file's expectations. +/// box-shadow keeps its authored text, so "NONE" keeps its casing, while clip turns a zero length into a unitless 0. /// </summary> [TestClass] public sealed class PropertyBoxTests @@ -446,6 +421,7 @@ public void CssCursorUrlsLegal() [TestMethod] [Spec("css-break-3", "#break-decoration")] + [Spec("css-break-4", "#break-decoration")] public void CssBoxDecorationBreakNumberIllegal() { var snippet = "box-decoration-break : 1.5 "; @@ -460,6 +436,7 @@ public void CssBoxDecorationBreakNumberIllegal() [TestMethod] [Spec("css-break-3", "#break-decoration")] + [Spec("css-break-4", "#break-decoration")] public void CssBoxDecorationBreakSliceLegal() { var snippet = "box-decoration-break : slice "; @@ -476,6 +453,7 @@ public void CssBoxDecorationBreakSliceLegal() [TestMethod] [Spec("css-break-3", "#break-decoration")] + [Spec("css-break-4", "#break-decoration")] public void CssBoxDecorationBreakClonePascalLegal() { var snippet = "box-decoration-break : Clone "; @@ -491,7 +469,6 @@ public void CssBoxDecorationBreakClonePascalLegal() } [TestMethod] - [Spec("css-break-3", "#break-decoration")] public void CssBoxDecorationBreakInheritLegal() { var snippet = "box-decoration-break : inherit!important "; @@ -512,7 +489,6 @@ public void CssBoxDecorationBreakInheritLegal() [DataRow("unset")] [DataRow("revert")] [DataRow("revert-layer")] - [Spec("css-break-3", "#break-decoration")] public void CssBoxDecorationBreakGlobalKeywordLegal(string keyword) { var property = ParseDeclaration($"box-decoration-break:{keyword}"); @@ -530,6 +506,7 @@ public void CssBoxDecorationBreakGlobalKeywordLegal(string keyword) [DataRow("both")] [DataRow("slice clone")] [Spec("css-break-3", "#break-decoration")] + [Spec("css-break-4", "#break-decoration")] public void CssBoxDecorationBreakRejectsNonSpecValue(string value) { var property = ParseDeclaration($"box-decoration-break:{value}"); diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs index 025a682df..73b4f556d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs @@ -14,6 +14,7 @@ public sealed class PropertyBreakTests { [TestMethod] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void CssBreakAfterLegalAvoid() { var snippet = "break-after:avoid"; @@ -31,6 +32,7 @@ public void CssBreakAfterLegalAvoid() [TestMethod] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void CssPageBreakAfterLegalAvoid() { var snippet = "page-break-after:avoid"; @@ -47,6 +49,7 @@ public void CssPageBreakAfterLegalAvoid() [TestMethod] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void CssBreakAfterLegalPageCapital() { var snippet = "break-after:Page"; @@ -64,6 +67,7 @@ public void CssBreakAfterLegalPageCapital() [TestMethod] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void CssPageBreakAfterIllegalAvoidColumn() { var snippet = "page-break-after:avoid-column"; @@ -78,6 +82,7 @@ public void CssPageBreakAfterIllegalAvoidColumn() [TestMethod] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void CssBreakAfterLegalAvoidColumn() { var snippet = "break-after:avoid-column"; @@ -94,6 +99,7 @@ public void CssBreakAfterLegalAvoidColumn() [TestMethod] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void CssBreakBeforeLegalAuto() { var snippet = "break-before:AUTO"; @@ -111,6 +117,7 @@ public void CssBreakBeforeLegalAuto() [TestMethod] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void CssPageBreakBeforeLegalAuto() { var snippet = "page-break-before:AUTO"; @@ -128,6 +135,7 @@ public void CssPageBreakBeforeLegalAuto() [TestMethod] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void CssPageBreakBeforeLegalLeft() { var snippet = "page-break-before:left"; @@ -144,6 +152,7 @@ public void CssPageBreakBeforeLegalLeft() [TestMethod] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void CssBreakBeforeIllegalValue() { var snippet = "break-before:whatever"; @@ -158,6 +167,7 @@ public void CssBreakBeforeIllegalValue() [TestMethod] [Spec("css-break-3", "#break-within")] + [Spec("css-break-4", "#break-within")] public void CssBreakInsideIllegalPage() { var snippet = "break-inside:page"; @@ -172,6 +182,7 @@ public void CssBreakInsideIllegalPage() [TestMethod] [Spec("css-break-3", "#break-within")] + [Spec("css-break-4", "#break-within")] public void CssBreakInsideLegalAvoidRegionUppercase() { var snippet = "break-inside:avoid-REGION"; @@ -189,6 +200,7 @@ public void CssBreakInsideLegalAvoidRegionUppercase() [TestMethod] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void CssPageBreakInsideLegalAvoid() { var snippet = "page-break-inside:avoid"; @@ -206,6 +218,7 @@ public void CssPageBreakInsideLegalAvoid() [TestMethod] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void CssPageBreakInsideLegalAutoUppercase() { var snippet = "page-break-inside:AUTO"; @@ -224,6 +237,7 @@ public void CssPageBreakInsideLegalAutoUppercase() [DataRow("break-before")] [DataRow("break-after")] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void BreakBeforeAfter_AcceptsSupportedSpecValues(string name) { string[] supportedValues = @@ -250,6 +264,7 @@ public void BreakBeforeAfter_AcceptsSupportedSpecValues(string name) [DataRow("break-after", "avoid-region")] [DataRow("break-after", "region")] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void BreakBeforeAfter_AcceptsRectoVersoAndRegionValues(string name, string value) { var property = ParseDeclaration($"{name}:{value}"); @@ -261,6 +276,7 @@ public void BreakBeforeAfter_AcceptsRectoVersoAndRegionValues(string name, strin [DataRow("break-before")] [DataRow("break-after")] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void BreakBeforeAfter_IsCaseInsensitiveAndKeepsAuthoredText(string name) { var property = ParseDeclaration($"{name}:AvOiD-PaGe"); @@ -280,10 +296,10 @@ public void BreakBeforeAfter_RejectsNonSpecValue(string name, string value) Assert.IsFalse(property.HasValue); } - // Not a css-break-3 value, css-break-4 adds it. [TestMethod] [DataRow("break-before")] [DataRow("break-after")] + [Spec("css-break-4", "#break-between")] public void BreakBeforeAfter_AcceptsAlways(string name) { var property = ParseDeclaration($"{name}:always"); @@ -298,6 +314,7 @@ public void BreakBeforeAfter_AcceptsAlways(string name) [DataRow("avoid-column")] [DataRow("avoid-region")] [Spec("css-break-3", "#break-within")] + [Spec("css-break-4", "#break-within")] public void BreakInside_AcceptsSpecValue(string value) { var property = ParseDeclaration($"break-inside:{value}"); @@ -313,6 +330,7 @@ public void BreakInside_AcceptsSpecValue(string value) [DataRow("recto")] [DataRow("always")] [Spec("css-break-3", "#break-within")] + [Spec("css-break-4", "#break-within")] public void BreakInside_RejectsNonSpecValue(string value) { var property = ParseDeclaration($"break-inside:{value}"); @@ -332,6 +350,7 @@ public void BreakInside_RejectsNonSpecValue(string value) [DataRow("page-break-after", "right")] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void PageBreakBeforeAfter_AcceptsLegacyValue(string name, string value) { var property = ParseDeclaration($"{name}:{value}"); @@ -350,6 +369,7 @@ public void PageBreakBeforeAfter_AcceptsLegacyValue(string name, string value) [DataRow("page-break-after", "verso")] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void PageBreakBeforeAfter_RejectsModernOnlyValue(string name, string value) { var property = ParseDeclaration($"{name}:{value}"); @@ -361,6 +381,7 @@ public void PageBreakBeforeAfter_RejectsModernOnlyValue(string name, string valu [DataRow("avoid")] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void PageBreakInside_AcceptsLegacyValue(string value) { var property = ParseDeclaration($"page-break-inside:{value}"); @@ -375,6 +396,7 @@ public void PageBreakInside_AcceptsLegacyValue(string value) [DataRow("page")] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void PageBreakInside_RejectsNonLegacyValue(string value) { var property = ParseDeclaration($"page-break-inside:{value}"); diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyPaginationTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyPaginationTests.cs index 78c7f982f..945980206 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PropertyPaginationTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PropertyPaginationTests.cs @@ -26,6 +26,7 @@ public void CssOrphansZeroLegal() [TestMethod] [Spec("css21", "page.html#break-inside")] [Spec("css-break-3", "#widows-orphans")] + [Spec("css-break-4", "#widows-orphans")] public void CssOrphansTwoLegal() { var snippet = "orphans : 2 "; @@ -43,6 +44,7 @@ public void CssOrphansTwoLegal() [TestMethod] [Spec("css21", "page.html#break-inside")] [Spec("css-break-3", "#widows-orphans")] + [Spec("css-break-4", "#widows-orphans")] public void CssOrphansNegativeIllegal() { var snippet = "orphans : -2 "; @@ -58,6 +60,7 @@ public void CssOrphansNegativeIllegal() [TestMethod] [Spec("css21", "page.html#break-inside")] [Spec("css-break-3", "#widows-orphans")] + [Spec("css-break-4", "#widows-orphans")] public void CssOrphansFloatingIllegal() { var snippet = "orphans : 1.5 "; @@ -88,6 +91,7 @@ public void CssWidowsZeroLegal() [TestMethod] [Spec("css21", "page.html#break-inside")] [Spec("css-break-3", "#widows-orphans")] + [Spec("css-break-4", "#widows-orphans")] public void CssWidowsThreeLegal() { var snippet = "widows: 3"; @@ -105,6 +109,7 @@ public void CssWidowsThreeLegal() [TestMethod] [Spec("css21", "page.html#break-inside")] [Spec("css-break-3", "#widows-orphans")] + [Spec("css-break-4", "#widows-orphans")] public void CssWidowsLengthIllegal() { var snippet = "widows: 5px"; diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/BreakValuesTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/BreakValuesTests.cs index b63b798c1..0e3a63490 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/BreakValuesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/BreakValuesTests.cs @@ -14,12 +14,14 @@ public sealed class BreakValuesTests [DataRow(null, false)] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void IsForcedBreak_MatchesThisPortsReducedValueSet(string value, bool expected) => Assert.AreEqual(expected, BreakValues.IsForcedBreak(value)); [TestMethod] [Spec("css21", "page.html#page-break-props")] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] [DataRow("avoid", true)] [DataRow("avoid-page", true)] [DataRow("auto", false)] diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/CssBreak4Tests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/CssBreak4Tests.cs new file mode 100644 index 000000000..3b9321e3f --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/CssBreak4Tests.cs @@ -0,0 +1,57 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.Fragmentation; + +[TestClass] +public sealed class CssBreak4Tests +{ + private const double Delta = 0.5; + private const double PageHeight = 300; + + [TestMethod] + [Spec("css-break-4", "#break-between")] + public void BreakAfterAlways_ForcesAPageBreak() + { + var (root, container) = Paginate("<div style='height:50px;break-after:always'></div><div id='b' style='height:10px'></div>"); + + Assert.AreEqual(1, container.PageIndexOf(Find(root, "b").Location.Y)); + } + + [Ignore("Gap, see Spec/css-break-4.yml")] + [TestMethod] + [Spec("css-break-4", "#break-between")] + public void BreakBeforeAll_ForcesAPageBreak() + { + var (root, container) = Paginate("<div style='height:50px'></div><div id='b' style='break-before:all;height:10px'></div>"); + + Assert.AreEqual(1, container.PageIndexOf(Find(root, "b").Location.Y)); + } + + [Ignore("Gap, see Spec/css-break-4.yml")] + [TestMethod] + [DataRow("margin-break: keep")] + [DataRow("margin-break: discard")] + [Spec("css-break-4", "#break-margins")] + public void MarginBreak_IsAccepted(string declaration) + { + Assert.IsTrue(CssConstructionFunctions.ParseDeclarations(declaration).Length > 0); + } + + [Ignore("Gap, see Spec/css-break-4.yml")] + [TestMethod] + [Spec("css-break-4", "#break-margins")] + public void MarginBreakKeep_KeepsTheMarginAfterAnUnforcedBreak() + { + var (root, _) = Paginate("<div style='height:290px'></div><div id='b' style='margin-break:keep;margin-top:20px;height:50px'></div>"); + + Assert.AreEqual(PageHeight + 20, Find(root, "b").Location.Y, Delta); + } + + private static (CssBox Root, HtmlContainerInt Container) Paginate(string body) => + LayoutHarness.Layout($"<html><head></head><body style='margin:0'>{body}</body></html>", 400, pageHeight: PageHeight, margin: 0); + + private static CssBox Find(CssBox root, string id) => LayoutHarness.FindById(root, id)!; +} diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/CssBreakTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/CssBreakTests.cs index 1ead2abbc..c56f19dbb 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/CssBreakTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/CssBreakTests.cs @@ -38,6 +38,7 @@ private static string Paragraph(string id, int lines, string style = "") => [TestMethod] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void ColumnBreak_DoesNotBreakThePage() { var (root, container) = Paginate("<div style='height:50px'></div><div id='b' style='break-before:column;height:10px'></div>"); @@ -48,6 +49,7 @@ public void ColumnBreak_DoesNotBreakThePage() [TestMethod] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] [DataRow("avoid", true)] [DataRow("avoid-page", true)] [DataRow("avoid-column", false)] @@ -64,6 +66,7 @@ public void AvoidValues_KeepTheBoxWithTheNextOnlyForPageBreaks(string value, boo [TestMethod] [Spec("css-break-3", "#break-within")] + [Spec("css-break-4", "#break-within")] [DataRow("avoid", 1)] [DataRow("avoid-page", 1)] [DataRow("avoid-column", 0)] @@ -78,6 +81,7 @@ public void BreakInside_AvoidsOnlyPageBreaks(string value, int expectedPage) [TestMethod] [Ignore("Gap, see Spec/css-break-3.yml")] [Spec("css-break-3", "#break-propagation")] + [Spec("css-break-4", "#break-propagation")] public void BreakBeforeAvoidOnAFirstChild_AppliesBeforeItsContainer() { var (root, container) = Paginate( @@ -92,6 +96,7 @@ public void BreakBeforeAvoidOnAFirstChild_AppliesBeforeItsContainer() [TestMethod] [Ignore("Gap, see Spec/css-break-3.yml")] [Spec("css-break-3", "#possible-breaks")] + [Spec("css-break-4", "#possible-breaks")] public void ForcedBreakInsideAMonolithicBox_IsIgnored() { var (root, container) = Paginate( @@ -104,6 +109,7 @@ public void ForcedBreakInsideAMonolithicBox_IsIgnored() [TestMethod] [Spec("css-break-3", "#fragmentation-model")] + [Spec("css-break-4", "#fragmentation-model")] public void BoxSpanningTwoPages_HasOneFragmentPerPage() { var (root, container) = Paginate("<div style='height:250px'></div><div id='b' style='height:100px'></div>"); @@ -119,6 +125,7 @@ public void BoxSpanningTwoPages_HasOneFragmentPerPage() [TestMethod] [Ignore("Gap, see Spec/css-break-3.yml")] [Spec("css-break-3", "#forced-breaks")] + [Spec("css-break-4", "#forced-breaks")] [Spec("css-page-3", "#using-named-pages")] public void DifferentPageValues_ForceABreak() { @@ -130,6 +137,7 @@ public void DifferentPageValues_ForceABreak() [TestMethod] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void AbsolutelyPositionedBox_IgnoresBreakBefore() { var (root, container) = Paginate( @@ -140,6 +148,7 @@ public void AbsolutelyPositionedBox_IgnoresBreakBefore() [TestMethod] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void Float_HonorsBreakBefore() { var (root, container) = Paginate( @@ -151,6 +160,7 @@ public void Float_HonorsBreakBefore() [TestMethod] [Ignore("Gap, see Spec/css-break-3.yml")] [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] [DataRow("recto")] [DataRow("verso")] public void RectoAndVerso_ForceABreak(string value) @@ -162,6 +172,7 @@ public void RectoAndVerso_ForceABreak(string value) [TestMethod] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void BreakBefore_OverridesAnEarlierPageBreakBefore() { var (root, container) = Paginate( @@ -173,6 +184,7 @@ public void BreakBefore_OverridesAnEarlierPageBreakBefore() [TestMethod] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] public void PageBreakBefore_OverridesAnEarlierBreakBefore() { var (root, container) = Paginate( @@ -184,6 +196,7 @@ public void PageBreakBefore_OverridesAnEarlierBreakBefore() [TestMethod] [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] [DataRow("page-break-before:always")] [DataRow("break-before:page")] public void PageBreakAlwaysAndBreakPage_ForceTheSameBreak(string declaration) @@ -195,6 +208,7 @@ public void PageBreakAlwaysAndBreakPage_ForceTheSameBreak(string declaration) [TestMethod] [Spec("css-break-3", "#forced-breaks")] + [Spec("css-break-4", "#forced-breaks")] public void ForcedBreak_HasNoEffectWithoutPages() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -205,6 +219,7 @@ public void ForcedBreak_HasNoEffectWithoutPages() [TestMethod] [Spec("css-break-3", "#parallel-flows")] + [Spec("css-break-4", "#parallel-flows")] public void ForcedBreakInsideAFloat_DoesNotMoveContentBesideIt() { var (root, container) = Paginate( @@ -216,6 +231,7 @@ public void ForcedBreakInsideAFloat_DoesNotMoveContentBesideIt() [TestMethod] [Spec("css-break-3", "#parallel-flows")] + [Spec("css-break-4", "#parallel-flows")] public void TableCellsInOneRow_BreakIndependently() { var filler = PageHeight - 1.5 * LineHeight; @@ -231,6 +247,7 @@ public void TableCellsInOneRow_BreakIndependently() [TestMethod] [Spec("css-break-3", "#best-breaks")] + [Spec("css-break-4", "#best-breaks")] public void ZeroHeightBoxAtTheEndOfAFullPage_StaysOnThatPage() { var (root, container) = Paginate($"<div style='height:{Px(PageHeight - 1)}'></div><div id='z'></div>"); @@ -241,6 +258,7 @@ public void ZeroHeightBoxAtTheEndOfAFullPage_StaysOnThatPage() [TestMethod] [Spec("css-break-3", "#box-splitting")] + [Spec("css-break-4", "#box-splitting")] public void SplitBox_FillsTheRestOfThePage() { var filler = PageHeight - 1.5 * LineHeight; @@ -255,6 +273,7 @@ public void SplitBox_FillsTheRestOfThePage() [TestMethod] [Spec("css-break-3", "#box-splitting")] + [Spec("css-break-4", "#box-splitting")] public void BreakGap_CountsTowardsAFixedHeight() { var filler = PageHeight - 1.5 * LineHeight; @@ -267,6 +286,7 @@ public void BreakGap_CountsTowardsAFixedHeight() [TestMethod] [Ignore("Gap, see Spec/css-break-3.yml")] [Spec("css-break-3", "#break-decoration")] + [Spec("css-break-4", "#break-decoration")] public void Clone_RepeatsTheBorderAndPaddingAfterTheBreak() { var filler = PageHeight - 1.5 * LineHeight; @@ -282,6 +302,7 @@ public void Clone_RepeatsTheBorderAndPaddingAfterTheBreak() [TestMethod] [Ignore("Gap, see Spec/css-break-3.yml")] [Spec("css-break-3", "#transforms")] + [Spec("css-break-4", "#transforms")] public void RelativeOffset_IsAppliedAfterFragmentation() { var filler = PageHeight - 1.5 * LineHeight; @@ -295,6 +316,7 @@ public void RelativeOffset_IsAppliedAfterFragmentation() [TestMethod] [Spec("css-break-3", "#transforms")] + [Spec("css-break-4", "#transforms")] public void AbsoluteBoxSpanningABreak_IsSplitAcrossBothPages() { var (root, container) = Paginate( @@ -310,6 +332,7 @@ public void AbsoluteBoxSpanningABreak_IsSplitAcrossBothPages() [TestMethod] [Ignore("Gap, see Spec/css-break-3.yml")] [Spec("css-break-3", "#transforms")] + [Spec("css-break-4", "#transforms")] public void AbsoluteBoxReachingPastTheLastPage_GetsAPageOfItsOwn() { var (_, container) = Paginate( @@ -321,6 +344,7 @@ public void AbsoluteBoxReachingPastTheLastPage_GetsAPageOfItsOwn() [TestMethod] [Ignore("Gap, see Spec/css-break-3.yml")] [Spec("css-break-3", "#parallel-flows")] + [Spec("css-break-4", "#parallel-flows")] public void ContentOverflowingAFixedHeightBox_SpillsOntoMorePages() { var (_, container) = Paginate(Paragraph("p", 60, "height:50px")); diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/ForcedBreakTargetIsTheFramesTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/ForcedBreakTargetIsTheFramesTests.cs index 7f55f1b3a..7308cba1e 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/ForcedBreakTargetIsTheFramesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/ForcedBreakTargetIsTheFramesTests.cs @@ -38,6 +38,7 @@ public sealed class ForcedBreakTargetIsTheFramesTests [TestMethod] [Spec("css21", "page.html#forced")] [Spec("css-break-3", "#forced-breaks")] + [Spec("css-break-4", "#forced-breaks")] public void PlainForcedBreak_TargetsTheNextSlotsContentTop_AndIsWhereTheBoxLanded() { var (root, container) = LayoutHarness.Layout( @@ -55,6 +56,7 @@ public void PlainForcedBreak_TargetsTheNextSlotsContentTop_AndIsWhereTheBoxLande [TestMethod] [Spec("css21", "page.html#forced")] [Spec("css-break-3", "#forced-breaks")] + [Spec("css-break-4", "#forced-breaks")] public void PredecessorEndingFlushOnABoundary_TargetsThatBoundary_NotTheSlotAfterIt() { var (root, container) = LayoutHarness.Layout( @@ -77,6 +79,7 @@ public void PredecessorEndingFlushOnABoundary_TargetsThatBoundary_NotTheSlotAfte [Ignore("Gap, see Spec/css-break-3.yml")] [TestMethod] [Spec("css-break-3", "#forced-breaks")] + [Spec("css-break-4", "#forced-breaks")] public void ConsecutiveForcedBreaks_StepPastTheMarkerRatherThanCollapsingOntoIt() { var (root, container) = LayoutHarness.Layout( @@ -101,6 +104,7 @@ public void ConsecutiveForcedBreaks_StepPastTheMarkerRatherThanCollapsingOntoIt( [TestMethod] [Spec("css21", "page.html#allowed-page-breaks")] [Spec("css-break-3", "#break-margins")] + [Spec("css-break-4", "#break-margins")] public void TargetIsTheBoundary_AndThePreservedMarginIsAddedToIt() { var (root, container) = LayoutHarness.Layout( @@ -118,6 +122,7 @@ public void TargetIsTheBoundary_AndThePreservedMarginIsAddedToIt() [TestMethod] [Spec("css21", "page.html#forced")] [Spec("css-break-3", "#break-propagation")] + [Spec("css-break-4", "#break-propagation")] public void BreakBeforeAFirstInFlowChild_IsTakenByTheContainerItBegins() { var (root, container) = LayoutHarness.Layout( @@ -137,6 +142,7 @@ public void BreakBeforeAFirstInFlowChild_IsTakenByTheContainerItBegins() // Nothing precedes the box, so there is no break to take and no leading blank page. [TestMethod] [Spec("css-break-3", "#break-propagation")] + [Spec("css-break-4", "#break-propagation")] public void BoxThatBeginsTheFlow_HasNoTargetAtAll() { var (root, container) = LayoutHarness.Layout( diff --git a/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs b/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs index 6f87ec025..bc9c8b2a3 100644 --- a/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs +++ b/Source/Test/HtmlRenderer.Test/Fragmentation/MonolithicContentTests.cs @@ -17,6 +17,7 @@ public sealed class MonolithicContentTests [DataRow("<img id='t' src='x.png' style='width:10pt;height:10pt'>")] [DataRow("<iframe id='t' style='width:10pt;height:10pt'></iframe>")] [Spec("css-break-3", "#possible-breaks")] + [Spec("css-break-4", "#possible-breaks")] public void ReplacedElement_IsMonolithic(string markup) { var box = BoxOf(markup); @@ -27,6 +28,7 @@ public void ReplacedElement_IsMonolithic(string markup) [TestMethod] [Spec("css-break-3", "#possible-breaks")] + [Spec("css-break-4", "#possible-breaks")] public void OrdinaryBlock_IsNotMonolithic() { var box = BoxOf("<div id='t'>text</div>"); @@ -45,6 +47,7 @@ public void OrdinaryBlock_IsNotMonolithic() // 'clip' isn't a known overflow value, the box stays 'visible', which is also what §2 wants. [DataRow("clip", false)] [Spec("css-break-3", "#possible-breaks")] + [Spec("css-break-4", "#possible-breaks")] public void Overflow_DecidesScrollContainer(string overflow, bool expected) { var box = BoxOf($"<div id='t' style='overflow:{overflow}'>text</div>"); diff --git a/Spec/css-break-4.yml b/Spec/css-break-4.yml index e07b78f86..822d1f8c3 100644 --- a/Spec/css-break-4.yml +++ b/Spec/css-break-4.yml @@ -5,76 +5,101 @@ snapshot: https://www.w3.org/TR/2018/WD-css-break-4-20181218/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Values - status: unknown + status: n/a '#fragmentation-model': title: 2 Fragmentation Model and Terminology - status: unknown + status: partial '#parallel-flows': title: 2.1 Parallel Fragmentation Flows - status: unknown + status: partial + gaps: + - Content overflowing past the end of the root box is cut off, see css-break-3 2.1 '#nested-flows': title: 2.2 Nested Fragmentation Flows - status: unknown + status: unsupported + gaps: + - Pages are the only fragmentation context, there's no multi-column or region fragmentation '#breaking-controls': title: 3 Controlling Breaks - status: unknown + status: partial '#break-between': title: '3.1 Breaks Between Boxes: the break-before and break-after properties' - status: unknown + status: partial + gaps: + - "'all' is rejected" + - "'left' and 'right' don't force a break and 'recto', 'verso', 'region' and 'avoid-region' are rejected, see css-break-3 3.1" '#break-propagation': title: 3.1.1 Child→Parent Break Propagation - status: unknown + status: partial + gaps: + - Breaks on a first or last child aren't propagated to its container, see css-break-3 3.1.1 '#break-within': title: '3.2 Breaks Within Boxes: the break-inside property' - status: unknown + status: partial + gaps: + - "A 'break-inside: avoid' block without text is split, see css-break-3 3.2" '#widows-orphans': title: '3.3 Breaks Between Lines: orphans, widows' - status: unknown + status: partial + gaps: + - "'orphans' and 'widows' of 0 or less are accepted, see css-break-3 3.3" '#page-break-properties': title: '3.4 Page Break Aliases: the page-break-before, page-break-after, and page-break-inside properties' - status: unknown + status: supported '#breaking-rules': title: 4 Rules for Breaking - status: unknown + status: partial '#possible-breaks': title: 4.1 Possible Break Points - status: unknown + status: partial + gaps: + - Forced breaks inside monolithic boxes aren't ignored and a block's top edge can stay behind its lines, see css-break-3 4.1 '#break-types': title: 4.2 Types of Breaks - status: unknown + status: n/a '#forced-breaks': title: 4.3 Forced Breaks - status: unknown + status: partial + gaps: + - Some forced breaks make no new page, see css-break-3 4.3 '#unforced-breaks': title: 4.4 Unforced Breaks - status: unknown + status: partial + gaps: + - "'break-after: avoid' doesn't pull a block without text along, see css-break-3 4.4" '#best-breaks': title: 4.5 Optimizing Unforced Breaks - status: unknown + status: supported '#breaking-boxes': title: 5 Box Model for Breaking - status: unknown + status: partial '#varying-size-boxes': title: 5.1 Breaking into Varying-size Fragmentainers - status: unknown + status: unsupported + gaps: + - Every page has the same size, see css-break-3 5.1 '#break-margins': title: '5.2 Adjoining Margins at Breaks: the margin-break property' - status: unknown + status: partial + gaps: + - "'margin-break' is dropped by the parser" + - "At a forced break the previous box's 'margin-bottom' isn't truncated, see CSS 2.1 13.3.3" '#box-splitting': title: 5.3 Splitting Boxes - status: unknown + status: supported '#break-decoration': title: '5.4 Fragmented Borders and Backgrounds: the box-decoration-break property' - status: unknown + status: partial + gaps: + - A box split across pages draws its border at the split, see CSS 2.1 13.3.1 + - "'clone' is ignored, no border or padding is added at a break" '#joining-boxes': title: 5.4.1 Joining Boxes for slice - status: unknown + status: unsupported + gaps: + - Backgrounds of split boxes aren't positioned across the whole box, see css-break-3 5.4.1 '#transforms': title: 5.5 Transforms, Positioning, and Pagination - status: unknown + status: partial + gaps: + - Relatively and absolutely positioned boxes aren't fragmented as specified, see css-break-3 5.5 From 5e951ec4c38cc4cbe2a3c35bf7e8818517e56229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:39:30 +0100 Subject: [PATCH 113/150] Verify and document CSS Multi-column Layout compliance --- .../Columns/ColumnsProperty.cs | 5 +- .../Css/ColumnGapPropertyTests.cs | 7 +-- .../Css/ColumnsPropertyTests.cs | 45 +++++++++++++-- .../Css/PropertyBreakTests.cs | 1 + .../CssModules/CssMulticolTests.cs | 40 +++++++++++++ Spec/css-multicol-1.yml | 57 ++++++++++--------- 6 files changed, 118 insertions(+), 37 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssMulticolTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Columns/ColumnsProperty.cs b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Columns/ColumnsProperty.cs index cf90c3d2a..0e71c5b2b 100644 --- a/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Columns/ColumnsProperty.cs +++ b/Source/HtmlRenderer/Core/CssEngine/StyleProperties/Columns/ColumnsProperty.cs @@ -4,9 +4,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine internal sealed class ColumnsProperty : ShorthandProperty { - // columns is order-independent (CSS Multi-column 1 - "<'column-width'> || <'column-count'>"), and - // both longhands accept "auto", so a positional match lets column-width claim "auto" and strand the - // length in "columns: auto 12em". Match in any order, like list-style. + // CSS Multi-column 1 §3.3. Both longhands take "auto", a positional match would let column-width + // claim the "auto" of "columns: auto 12em" and strand the length. private static readonly IValueConverter StyleConverter = WithAnyOrderIndependent( AutoLengthConverter.Option().For(PropertyNames.ColumnWidth), OptionalIntegerConverter.Option().For(PropertyNames.ColumnCount)).OrDefault(); diff --git a/Source/Test/HtmlRenderer.Test/Css/ColumnGapPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/ColumnGapPropertyTests.cs index 5dda0259b..17fdd8fa0 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ColumnGapPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ColumnGapPropertyTests.cs @@ -4,19 +4,18 @@ namespace HtmlRenderer.Test.Css; -/// <summary>Ported from PeachPDF.Tests/CSS/PropertyTests/ColumnGapProperty.cs. Pure CSSOM parse tests - no layout involved.</summary> [TestClass] public sealed class ColumnGapPropertyTests { [TestMethod] [DynamicData(nameof(ColumnGapTestDataValues))] + [Spec("css-multicol-1", "#cg")] public void ColumnGapLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<ColumnGapProperty>(PropertyNames.ColumnGap, value); - [Ignore("not yet spec compliant: ColumnGapProperty's converter (StyleProperties/Columns/ColumnGapProperty.cs) " + - "is Converters.LengthOrPercentConverter.OrGlobalValue().OrDefault(1em) - there is no 'normal'-accepting " + - "branch anywhere in the chain, so 'normal' is rejected instead of accepted as the spec's default keyword.")] + [Ignore("Gap, see Spec/css-multicol-1.yml")] [TestMethod] + [Spec("css-multicol-1", "#cg")] public void ColumnGapAcceptsNormalKeyword() => CssConstructionFunctions.TestForLegalValue<ColumnGapProperty>(PropertyNames.ColumnGap, "normal"); diff --git a/Source/Test/HtmlRenderer.Test/Css/ColumnsPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/ColumnsPropertyTests.cs index 430371965..e7db0ed9d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ColumnsPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ColumnsPropertyTests.cs @@ -4,11 +4,11 @@ namespace HtmlRenderer.Test.Css; -/// <summary>Ported from PeachPDF.Tests/CSS/PropertyTests/ColumnsProperty.cs. Pure CSSOM parse tests - no layout involved.</summary> [TestClass] public sealed class ColumnsPropertyTests { [TestMethod] + [Spec("css-multicol-1", "#cw")] public void CssColumnWidthLengthLegal() { var snippet = "column-width: 300px"; @@ -23,6 +23,7 @@ public void CssColumnWidthLengthLegal() } [TestMethod] + [Spec("css-multicol-1", "#cw")] public void CssColumnWidthPercentIllegal() { var snippet = "column-width: 30%"; @@ -36,6 +37,7 @@ public void CssColumnWidthPercentIllegal() } [TestMethod] + [Spec("css-multicol-1", "#cw")] public void CssColumnWidthVwLegal() { var snippet = "column-width: 0.3vw"; @@ -50,6 +52,7 @@ public void CssColumnWidthVwLegal() } [TestMethod] + [Spec("css-multicol-1", "#cw")] public void CssColumnWidthAutoUppercaseLegal() { var snippet = "column-width: AUTO"; @@ -64,6 +67,7 @@ public void CssColumnWidthAutoUppercaseLegal() } [TestMethod] + [Spec("css-multicol-1", "#cc")] public void CssColumnCountAutoLowercaseLegal() { var snippet = "column-count: auto"; @@ -78,6 +82,7 @@ public void CssColumnCountAutoLowercaseLegal() } [TestMethod] + [Spec("css-multicol-1", "#cc")] public void CssColumnCountNumberLegal() { var snippet = "column-count: 3"; @@ -92,7 +97,9 @@ public void CssColumnCountNumberLegal() } [TestMethod] - public void CssColumnCountZeroLegal() + [Ignore("Gap, see Spec/css-multicol-1.yml")] + [Spec("css-multicol-1", "#cc")] + public void CssColumnCountZeroIllegal() { var snippet = "column-count: 0"; var property = CssConstructionFunctions.ParseDeclaration(snippet); @@ -101,11 +108,11 @@ public void CssColumnCountZeroLegal() Assert.IsInstanceOfType<ColumnCountProperty>(property); var concrete = (ColumnCountProperty)property; Assert.IsFalse(concrete.IsInherited); - Assert.IsTrue(concrete.HasValue); - Assert.AreEqual("0", concrete.Value); + Assert.IsFalse(concrete.HasValue); } [TestMethod] + [Spec("css-multicol-1", "#columns")] public void CssColumsZeroLegal() { var snippet = "columns: 0"; @@ -120,6 +127,7 @@ public void CssColumsZeroLegal() } [TestMethod] + [Spec("css-multicol-1", "#columns")] public void CssColumsLengthLegal() { var snippet = "columns: 10px"; @@ -134,6 +142,7 @@ public void CssColumsLengthLegal() } [TestMethod] + [Spec("css-multicol-1", "#columns")] public void CssColumsNumberLegal() { var snippet = "columns: 4"; @@ -148,6 +157,7 @@ public void CssColumsNumberLegal() } [TestMethod] + [Spec("css-multicol-1", "#columns")] public void CssColumsLengthNumberLegal() { var snippet = "columns: 25em 5"; @@ -162,6 +172,7 @@ public void CssColumsLengthNumberLegal() } [TestMethod] + [Spec("css-multicol-1", "#columns")] public void CssColumsNumberLengthLegal() { var snippet = "columns : 5 25em "; @@ -176,6 +187,7 @@ public void CssColumsNumberLengthLegal() } [TestMethod] + [Spec("css-multicol-1", "#columns")] public void CssColumsAutoAutoLegal() { var snippet = "columns : auto auto"; @@ -190,6 +202,7 @@ public void CssColumsAutoAutoLegal() } [TestMethod] + [Spec("css-multicol-1", "#columns")] public void CssColumsAutoWidthOrderIndependentLegal() { // "auto" is valid for both column-count and column-width, so a positional match lets @@ -205,6 +218,7 @@ public void CssColumsAutoWidthOrderIndependentLegal() } [TestMethod] + [Spec("css-multicol-1", "#columns")] public void CssColumsWidthAutoLegal() { // Control: the already-ordered form parses through the fast path. @@ -218,6 +232,7 @@ public void CssColumsWidthAutoLegal() } [TestMethod] + [Spec("css-multicol-1", "#columns")] public void CssColumsAutoLegal() { var snippet = "columns : auto "; @@ -232,6 +247,7 @@ public void CssColumsAutoLegal() } [TestMethod] + [Spec("css-multicol-1", "#columns")] public void CssColumsNumberPercenIllegal() { var snippet = "columns : 5 25% "; @@ -245,6 +261,7 @@ public void CssColumsNumberPercenIllegal() } [TestMethod] + [Spec("css-multicol-1", "#column-span")] public void CssColumSpanAllLegal() { var snippet = "column-span: all"; @@ -259,6 +276,7 @@ public void CssColumSpanAllLegal() } [TestMethod] + [Spec("css-multicol-1", "#column-span")] public void CssColumSpanNoneUppercaseLegal() { var snippet = "column-span: None"; @@ -273,6 +291,7 @@ public void CssColumSpanNoneUppercaseLegal() } [TestMethod] + [Spec("css-multicol-1", "#column-span")] public void CssColumSpanLengthIllegal() { var snippet = "column-span: 10px"; @@ -287,10 +306,12 @@ public void CssColumSpanLengthIllegal() [TestMethod] [DynamicData(nameof(ColumnGapTestDataValues))] + [Spec("css-multicol-1", "#cg")] public void ColumnGapLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<ColumnGapProperty>(PropertyNames.ColumnGap, value); [TestMethod] + [Spec("css-multicol-1", "#cf")] public void CssColumFillBalanceLegal() { var snippet = "column-fill: balance;"; @@ -305,6 +326,7 @@ public void CssColumFillBalanceLegal() } [TestMethod] + [Spec("css-multicol-1", "#cf")] public void CssColumFillAutoLegal() { var snippet = "column-fill: auto;"; @@ -319,6 +341,7 @@ public void CssColumFillAutoLegal() } [TestMethod] + [Spec("css-multicol-1", "#crc")] public void CssColumRuleColorTransparentLegal() { var snippet = "column-rule-color: transparent"; @@ -333,6 +356,7 @@ public void CssColumRuleColorTransparentLegal() } [TestMethod] + [Spec("css-multicol-1", "#crc")] public void CssColumRuleColorRgbLegal() { var snippet = "column-rule-color: rgb(192, 56, 78)"; @@ -347,6 +371,7 @@ public void CssColumRuleColorRgbLegal() } [TestMethod] + [Spec("css-multicol-1", "#crc")] public void CssColumRuleColorRedLegal() { var snippet = "column-rule-color: red"; @@ -361,6 +386,7 @@ public void CssColumRuleColorRedLegal() } [TestMethod] + [Spec("css-multicol-1", "#crc")] public void CssColumRuleColorNoneIllegal() { var snippet = "column-rule-color: none"; @@ -374,6 +400,7 @@ public void CssColumRuleColorNoneIllegal() } [TestMethod] + [Spec("css-multicol-1", "#crs")] public void CssColumRuleStyleInsetTailUpperLegal() { var snippet = "column-rule-style: inSET"; @@ -388,6 +415,7 @@ public void CssColumRuleStyleInsetTailUpperLegal() } [TestMethod] + [Spec("css-multicol-1", "#crs")] public void CssColumRuleStyleNoneLegal() { var snippet = "column-rule-style: none"; @@ -402,6 +430,7 @@ public void CssColumRuleStyleNoneLegal() } [TestMethod] + [Spec("css-multicol-1", "#crs")] public void CssColumRuleStyleAutoIllegal() { var snippet = "column-rule-style: auto "; @@ -415,6 +444,7 @@ public void CssColumRuleStyleAutoIllegal() } [TestMethod] + [Spec("css-multicol-1", "#crw")] public void CssColumRuleWidthLengthLegal() { var snippet = "column-rule-width: 2px"; @@ -429,6 +459,7 @@ public void CssColumRuleWidthLengthLegal() } [TestMethod] + [Spec("css-multicol-1", "#crw")] public void CssColumRuleWidthThickLegal() { var snippet = "column-rule-width: thick"; @@ -443,6 +474,7 @@ public void CssColumRuleWidthThickLegal() } [TestMethod] + [Spec("css-multicol-1", "#crw")] public void CssColumRuleWidthMediumLegal() { var snippet = "column-rule-width : medium !important "; @@ -457,6 +489,7 @@ public void CssColumRuleWidthMediumLegal() } [TestMethod] + [Spec("css-multicol-1", "#crw")] public void CssColumRuleWidthThinUppercaseLegal() { var snippet = "column-rule-width: THIN"; @@ -471,6 +504,7 @@ public void CssColumRuleWidthThinUppercaseLegal() } [TestMethod] + [Spec("css-multicol-1", "#cr")] public void CssColumRuleDottedLegal() { var snippet = "column-rule: dotted"; @@ -485,6 +519,7 @@ public void CssColumRuleDottedLegal() } [TestMethod] + [Spec("css-multicol-1", "#cr")] public void CssColumRuleSolidBlueLegal() { var snippet = "column-rule: solid blue"; @@ -499,6 +534,7 @@ public void CssColumRuleSolidBlueLegal() } [TestMethod] + [Spec("css-multicol-1", "#cr")] public void CssColumRuleSolidLengthLegal() { var snippet = "column-rule: solid 8px"; @@ -513,6 +549,7 @@ public void CssColumRuleSolidLengthLegal() } [TestMethod] + [Spec("css-multicol-1", "#cr")] public void CssColumRuleThickInsetBlueLegal() { var snippet = "column-rule: thick inset blue"; diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs index 73b4f556d..2fb880536 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs @@ -238,6 +238,7 @@ public void CssPageBreakInsideLegalAutoUppercase() [DataRow("break-after")] [Spec("css-break-3", "#break-between")] [Spec("css-break-4", "#break-between")] + [Spec("css-multicol-1", "#break-before-break-after-break-inside")] public void BreakBeforeAfter_AcceptsSupportedSpecValues(string name) { string[] supportedValues = diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssMulticolTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssMulticolTests.cs new file mode 100644 index 000000000..54b7bba5c --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssMulticolTests.cs @@ -0,0 +1,40 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssMulticolTests +{ + private const double Delta = 0.5; + + [Ignore("Gap, see Spec/css-multicol-1.yml")] + [TestMethod] + [Spec("css-multicol-1", "#the-multi-column-model")] + [Spec("css-multicol-1", "#cc")] + [Spec("css-multicol-1", "#cg")] + public void ColumnCount_FlowsTheContentIntoColumns() + { + var root = Layout("<div id='m' style='width:400px;column-count:2;column-gap:0;column-fill:auto;height:50px'>" + + "<div id='a' style='height:50px'></div><div id='b' style='height:50px'></div></div>"); + + Assert.AreEqual(200, Box(root, "a").Size.Width, Delta); + Assert.AreEqual(Box(root, "a").Location.Y, Box(root, "b").Location.Y, Delta); + Assert.AreEqual(200, Box(root, "b").Location.X - Box(root, "a").Location.X, Delta); + } + + [Ignore("Gap, see Spec/css-multicol-1.yml")] + [TestMethod] + [Spec("css-multicol-1", "#cw")] + [Spec("css-multicol-1", "#pseudo-algorithm")] + public void ColumnWidth_DeterminesTheColumnCount() + { + var root = Layout("<div style='width:400px;column-width:150px;column-gap:0'><div id='a' style='height:10px'></div></div>"); + + Assert.AreEqual(200, Box(root, "a").Size.Width, Delta); + } + + private static CssBox Layout(string body) => LayoutHarness.Layout(LayoutHarness.Wrap(body)).Root; + + private static CssBox Box(CssBox root, string id) => LayoutHarness.FindById(root, id)!; +} diff --git a/Spec/css-multicol-1.yml b/Spec/css-multicol-1.yml index 8afe012a6..ef64c7e34 100644 --- a/Spec/css-multicol-1.yml +++ b/Spec/css-multicol-1.yml @@ -5,73 +5,78 @@ snapshot: https://www.w3.org/TR/2024/CR-css-multicol-1-20240516/ sections: '#introduction': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown + status: n/a '#the-multi-column-model': title: 2 The Multi-Column Model - status: unknown + status: unsupported + gaps: + - No multi-column container is established, the content lays out in a single column '#the-number-and-width-of-columns': title: 3 The Number and Width of Columns - status: unknown + status: parsed-only '#cw': title: '3.1 The Inline Size of Columns: the column-width property' - status: unknown + status: parsed-only '#cc': title: '3.2 The Number of Columns: the column-count property' - status: unknown + status: parsed-only + gaps: + - "column-count: 0 is accepted" '#columns': title: '3.3 The column-width and column-count Shorthand: The columns Property' - status: unknown + status: parsed-only '#pseudo-algorithm': title: 3.4 The Pseudo-algorithm - status: unknown + status: unsupported '#stacking-context': title: 3.5 Stacking Context - status: unknown + status: unsupported '#column-gaps-and-rules': title: 4 Column Gaps and Rules - status: unknown + status: parsed-only '#cg': title: '4.1 Gutters Between Columns: the column-gap property' - status: unknown + status: parsed-only + gaps: + - "'normal' is rejected" '#crc': title: '4.2 The Color of Column Rules: the column-rule-color property' - status: unknown + status: parsed-only '#crs': title: '4.3 The Style Of Column Rules: the column-rule-style property' - status: unknown + status: parsed-only '#crw': title: '4.4 The Width Of Column Rules: the column-rule-width property' - status: unknown + status: parsed-only '#cr': title: '4.5 Column Rule Shorthand: the column-rule property' - status: unknown + status: parsed-only '#column-breaks': title: 5 Column Breaks - status: unknown + status: parsed-only '#break-before-break-after-break-inside': title: '5.1 Controlling Fragmentation: the break-before, break-after, break-inside properties' - status: unknown + status: parsed-only + gaps: + - "The column values are accepted but there are no columns to break, see css-break-3 2.2" '#spanning-columns': title: 6 Spanning Columns - status: unknown + status: parsed-only '#column-span': title: '6.1 Spanning An Element Across Columns: the column-span property' - status: unknown + status: parsed-only '#filling-columns': title: 7 Filling Columns - status: unknown + status: parsed-only '#cf': title: '7.1 Column Balancing: the column-fill property' - status: unknown + status: parsed-only '#overflow': title: 8 Overflow - status: unknown + status: unsupported '#overflow-inside-multicol-elements': title: 8.1 Overflow Inside Multicol Containers - status: unknown + status: unsupported '#pagination-and-overflow-outside-multicol': title: 8.2 Pagination and Overflow Outside Multicol Containers - status: unknown + status: unsupported From 9de24062fcc83187a3ba0b3a1b71260bcbb4be6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:41:53 +0100 Subject: [PATCH 114/150] Verify and document CSS Generated Content for Paged Media compliance --- .../Content/GeneratedContentModuleTests.cs | 3 + .../Css/ContentPropertyTests.cs | 17 ++++ .../Css/StringFunctionConverterTests.cs | 22 +++-- .../Css/StringSetPropertyTests.cs | 28 +++++- .../Css/StringSetWithStringFunctionTests.cs | 15 +-- .../CssModules/CssGcpmTests.cs | 95 +++++++++++++++++++ Spec/css-gcpm-3.yml | 79 +++++++++------ 7 files changed, 206 insertions(+), 53 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssGcpmTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Content/GeneratedContentModuleTests.cs b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentModuleTests.cs index 6ae2ad4ae..5559cf8fd 100644 --- a/Source/Test/HtmlRenderer.Test/Content/GeneratedContentModuleTests.cs +++ b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentModuleTests.cs @@ -50,7 +50,9 @@ public void QuotesKeyword_IsAccepted(string keyword) [TestMethod] [Ignore("Gap, see Spec/css-content-3.yml")] [Spec("css-content-3", "#string-set")] + [Spec("css-gcpm-3", "#setting-named-strings-the-string-set-pro")] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void StringFunction_InsertsTheNamedString() { var html = "<h1 style='string-set: title \"Loomings\"'>a</h1><p id=x>b</p><style>#x::before { content: '[' string(title) ']' }</style>"; @@ -61,6 +63,7 @@ public void StringFunction_InsertsTheNamedString() [TestMethod] [Ignore("Gap, see Spec/css-content-3.yml")] [Spec("css-content-3", "#content-function")] + [Spec("css-gcpm-3", "#content-function-header")] public void ContentFunction_InsertsTheElementText() { Assert.AreEqual("[abc]", Before("<p id=x>abc</p><style>#x::before { content: '[' content(text) ']' }</style>").Text); diff --git a/Source/Test/HtmlRenderer.Test/Css/ContentPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/ContentPropertyTests.cs index ac5c05395..a66fff65f 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ContentPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ContentPropertyTests.cs @@ -53,6 +53,7 @@ public void CssContentParseStringWithSingleQuoteMultipleEscapes() [TestMethod] [Spec("css-content-3", "#content-function")] + [Spec("css-gcpm-3", "#content-function-header")] public void CssContentParseContentFunctionText() { var source = "a::before{content:content(text)}"; @@ -62,6 +63,7 @@ public void CssContentParseContentFunctionText() [TestMethod] [Spec("css-content-3", "#content-function")] + [Spec("css-gcpm-3", "#content-function-header")] public void CssContentParseContentFunctionBefore() { var source = "a::before{content:content(before)}"; @@ -71,6 +73,7 @@ public void CssContentParseContentFunctionBefore() [TestMethod] [Spec("css-content-3", "#content-function")] + [Spec("css-gcpm-3", "#content-function-header")] public void CssContentParseContentFunctionAfter() { var source = "a::before{content:content(after)}"; @@ -80,6 +83,7 @@ public void CssContentParseContentFunctionAfter() [TestMethod] [Spec("css-content-3", "#content-function")] + [Spec("css-gcpm-3", "#content-function-header")] public void CssContentParseContentFunctionFirstLetter() { var source = "a::before{content:content(first-letter)}"; @@ -89,6 +93,7 @@ public void CssContentParseContentFunctionFirstLetter() [TestMethod] [Spec("css-content-3", "#content-function")] + [Spec("css-gcpm-3", "#content-function-header")] public void CssContentParseContentFunctionWithString() { var source = "a::before{content:\"Chapter \" content(text)}"; @@ -98,6 +103,7 @@ public void CssContentParseContentFunctionWithString() [TestMethod] [Spec("css-content-3", "#content-function")] + [Spec("css-gcpm-3", "#content-function-header")] public void CssContentParseContentFunctionWithCounter() { var source = "a::before{content:content(before) \" - \" counter(page)}"; @@ -107,6 +113,7 @@ public void CssContentParseContentFunctionWithCounter() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void CssContentParseStringFunction() { var source = "a::before{content:string(chapter)}"; @@ -116,6 +123,7 @@ public void CssContentParseStringFunction() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void CssContentParseStringFunctionWithFirstKeyword() { var source = "a::before{content:string(chapter, first)}"; @@ -125,6 +133,7 @@ public void CssContentParseStringFunctionWithFirstKeyword() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void CssContentParseStringFunctionWithLastKeyword() { var source = "a::before{content:string(chapter, last)}"; @@ -134,6 +143,7 @@ public void CssContentParseStringFunctionWithLastKeyword() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void CssContentParseStringFunctionWithStartKeyword() { var source = "a::before{content:string(chapter, start)}"; @@ -143,6 +153,7 @@ public void CssContentParseStringFunctionWithStartKeyword() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void CssContentParseStringFunctionWithFirstExceptKeyword() { var source = "a::before{content:string(chapter, first-except)}"; @@ -152,6 +163,7 @@ public void CssContentParseStringFunctionWithFirstExceptKeyword() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void CssContentParseStringFunctionWithStringLiteral() { var source = "a::before{content:\"Chapter: \" string(chapter)}"; @@ -161,6 +173,7 @@ public void CssContentParseStringFunctionWithStringLiteral() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void CssContentParseStringFunctionWithCounter() { var source = "a::before{content:string(chapter) \" - Page \" counter(page)}"; @@ -170,6 +183,7 @@ public void CssContentParseStringFunctionWithCounter() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void CssContentParseMultipleStringFunctions() { var source = "a::before{content:string(chapter) \" / \" string(section)}"; @@ -179,6 +193,7 @@ public void CssContentParseMultipleStringFunctions() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void CssContentParseStringFunctionWithContentFunction() { var source = "a::before{content:string(chapter) \": \" content(text)}"; @@ -188,6 +203,7 @@ public void CssContentParseStringFunctionWithContentFunction() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void CssContentParseStringFunctionWithAttr() { var source = "a::before{content:string(chapter) \" - \" attr(title)}"; @@ -197,6 +213,7 @@ public void CssContentParseStringFunctionWithAttr() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void CssContentParseComplexCombination() { var source = "a::before{content:\"Part \" string(part, first) \" - Chapter \" string(chapter) \" (Page \" counter(page) \")\"}"; diff --git a/Source/Test/HtmlRenderer.Test/Css/StringFunctionConverterTests.cs b/Source/Test/HtmlRenderer.Test/Css/StringFunctionConverterTests.cs index e6aafa706..33602bc5d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/StringFunctionConverterTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/StringFunctionConverterTests.cs @@ -11,7 +11,7 @@ namespace HtmlRenderer.Test.Css; public sealed class StringFunctionConverterTests { [TestMethod] - [Spec("css-content-3", "#string-function")] + [Spec("css-syntax-3", "#consume-function")] public void StringFunction_WithNameOnly_ParsesCorrectly() { const string input = "string(chapter)"; @@ -32,7 +32,7 @@ public void StringFunction_WithNameOnly_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-function")] + [Spec("css-syntax-3", "#consume-function")] public void StringFunction_WithFirstKeyword_ParsesCorrectly() { const string input = "string(chapter, first)"; @@ -55,7 +55,7 @@ public void StringFunction_WithFirstKeyword_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-function")] + [Spec("css-syntax-3", "#consume-function")] public void StringFunction_WithLastKeyword_ParsesCorrectly() { const string input = "string(chapter, last)"; @@ -72,7 +72,7 @@ public void StringFunction_WithLastKeyword_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-function")] + [Spec("css-syntax-3", "#consume-function")] public void StringFunction_WithStartKeyword_ParsesCorrectly() { const string input = "string(chapter, start)"; @@ -89,7 +89,7 @@ public void StringFunction_WithStartKeyword_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-function")] + [Spec("css-syntax-3", "#consume-function")] public void StringFunction_WithFirstExceptKeyword_ParsesCorrectly() { const string input = "string(chapter, first-except)"; @@ -106,7 +106,7 @@ public void StringFunction_WithFirstExceptKeyword_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-function")] + [Spec("css-syntax-3", "#consume-function")] public void StringFunction_WithHyphenatedName_ParsesCorrectly() { const string input = "string(my-chapter-title)"; @@ -122,7 +122,7 @@ public void StringFunction_WithHyphenatedName_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-function")] + [Spec("css-syntax-3", "#consume-function")] public void StringFunction_WithUnderscoreName_ParsesCorrectly() { const string input = "string(chapter_title)"; @@ -139,6 +139,7 @@ public void StringFunction_WithUnderscoreName_ParsesCorrectly() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void StringFunction_InContentProperty_ParsesCorrectly() { const string input = "content: string(chapter)"; @@ -152,6 +153,7 @@ public void StringFunction_InContentProperty_ParsesCorrectly() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void StringFunction_CombinedWithStringLiteral_ParsesCorrectly() { const string input = "content: \"Chapter: \" string(chapter)"; @@ -165,6 +167,7 @@ public void StringFunction_CombinedWithStringLiteral_ParsesCorrectly() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void StringFunction_CombinedWithCounter_ParsesCorrectly() { const string input = "content: string(chapter) \" - Page \" counter(page)"; @@ -178,6 +181,7 @@ public void StringFunction_CombinedWithCounter_ParsesCorrectly() [TestMethod] [Spec("css-content-3", "#string-function")] + [Spec("css-gcpm-3", "#using-named-strings")] public void StringFunction_MultipleInContent_ParsesCorrectly() { const string input = "content: string(chapter) \" / \" string(section)"; @@ -190,7 +194,7 @@ public void StringFunction_MultipleInContent_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-function")] + [Spec("css-syntax-3", "#consume-function")] public void StringFunction_WithWhitespaceAroundComma_ParsesCorrectly() { const string input = "string(chapter , last)"; @@ -207,7 +211,7 @@ public void StringFunction_WithWhitespaceAroundComma_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-function")] + [Spec("css-syntax-3", "#consume-function")] public void StringFunction_CaseInsensitiveKeyword_ParsesCorrectly() { const string input = "string(chapter, LAST)"; diff --git a/Source/Test/HtmlRenderer.Test/Css/StringSetPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/StringSetPropertyTests.cs index db7d6914f..fefd9ca3f 100644 --- a/Source/Test/HtmlRenderer.Test/Css/StringSetPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/StringSetPropertyTests.cs @@ -11,6 +11,7 @@ public sealed class StringSetPropertyTests { [TestMethod] [Spec("css-content-3", "#string-set")] + [Spec("css-gcpm-3", "#setting-named-strings-the-string-set-pro")] public void StringSetNoneLegal() { var property = CssConstructionFunctions.ParseDeclaration("string-set: none"); @@ -25,6 +26,7 @@ public void StringSetNoneLegal() [TestMethod] [Spec("css-content-3", "#string-set")] + [Spec("css-gcpm-3", "#setting-named-strings-the-string-set-pro")] public void StringSetSimpleTextLegal() { var property = CssConstructionFunctions.ParseDeclaration("string-set: chapter content(text)"); @@ -39,6 +41,7 @@ public void StringSetSimpleTextLegal() [TestMethod] [Spec("css-content-3", "#string-set")] + [Spec("css-gcpm-3", "#setting-named-strings-the-string-set-pro")] public void StringSetWithStringLiteral() { var property = CssConstructionFunctions.ParseDeclaration("string-set: header \"Page \" counter(page)"); @@ -53,6 +56,7 @@ public void StringSetWithStringLiteral() [TestMethod] [Spec("css-content-3", "#string-set")] + [Spec("css-gcpm-3", "#setting-named-strings-the-string-set-pro")] public void StringSetWithAttrFunction() { var property = CssConstructionFunctions.ParseDeclaration("string-set: title attr(title)"); @@ -67,6 +71,7 @@ public void StringSetWithAttrFunction() [TestMethod] [Spec("css-content-3", "#string-set")] + [Spec("css-gcpm-3", "#setting-named-strings-the-string-set-pro")] public void StringSetContentBefore() { var property = CssConstructionFunctions.ParseDeclaration("string-set: heading content(before) \":\" content(text)"); @@ -81,6 +86,7 @@ public void StringSetContentBefore() [TestMethod] [Spec("css-content-3", "#string-set")] + [Spec("css-gcpm-3", "#setting-named-strings-the-string-set-pro")] public void StringSetContentAfter() { var property = CssConstructionFunctions.ParseDeclaration("string-set: heading content(after)"); @@ -95,20 +101,22 @@ public void StringSetContentAfter() [TestMethod] [Spec("css-content-3", "#string-set")] + [Spec("css-gcpm-3", "#setting-named-strings-the-string-set-pro")] public void StringSetContentFirstLetter() { - var property = CssConstructionFunctions.ParseDeclaration("string-set: initial content(first-letter)"); + var property = CssConstructionFunctions.ParseDeclaration("string-set: heading content(first-letter)"); Assert.AreEqual("string-set", property.Name); Assert.IsFalse(property.IsImportant); Assert.IsInstanceOfType<StringSetProperty>(property); var concrete = (StringSetProperty)property; Assert.IsFalse(concrete.IsInherited); Assert.IsTrue(concrete.HasValue); - Assert.AreEqual("initial content(first-letter)", concrete.Value); + Assert.AreEqual("heading content(first-letter)", concrete.Value); } [TestMethod] [Spec("css-content-3", "#string-set")] + [Spec("css-gcpm-3", "#setting-named-strings-the-string-set-pro")] public void StringSetMultiplePairs() { var property = CssConstructionFunctions.ParseDeclaration("string-set: header content(text), footer counter(page)"); @@ -123,6 +131,7 @@ public void StringSetMultiplePairs() [TestMethod] [Spec("css-content-3", "#string-set")] + [Spec("css-gcpm-3", "#setting-named-strings-the-string-set-pro")] public void StringSetInvalidNoContentListIllegal() { var property = CssConstructionFunctions.ParseDeclaration("string-set: header"); @@ -136,6 +145,7 @@ public void StringSetInvalidNoContentListIllegal() [TestMethod] [Spec("css-content-3", "#string-set")] + [Spec("css-gcpm-3", "#setting-named-strings-the-string-set-pro")] public void StringSetInvalidNumberIllegal() { var property = CssConstructionFunctions.ParseDeclaration("string-set: 123 content(text)"); @@ -146,4 +156,18 @@ public void StringSetInvalidNumberIllegal() Assert.IsFalse(concrete.IsInherited); Assert.IsFalse(concrete.HasValue); } + + // A CSS-wide keyword isn't a <custom-ident>. + [TestMethod] + [Ignore("Gap, see Spec/css-content-3.yml")] + [Spec("css-content-3", "#string-set")] + [Spec("css-gcpm-3", "#setting-named-strings-the-string-set-pro")] + public void StringSetCssWideKeywordNameIllegal() + { + var property = CssConstructionFunctions.ParseDeclaration("string-set: initial content(text)"); + Assert.AreEqual("string-set", property.Name); + Assert.IsInstanceOfType<StringSetProperty>(property); + var concrete = (StringSetProperty)property; + Assert.IsFalse(concrete.HasValue); + } } diff --git a/Source/Test/HtmlRenderer.Test/Css/StringSetWithStringFunctionTests.cs b/Source/Test/HtmlRenderer.Test/Css/StringSetWithStringFunctionTests.cs index adb3918d0..1cf71a6df 100644 --- a/Source/Test/HtmlRenderer.Test/Css/StringSetWithStringFunctionTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/StringSetWithStringFunctionTests.cs @@ -4,13 +4,12 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Parsing of string() inside string-set. +/// Parsing of string() inside string-set. Neither css-content-3 nor css-gcpm-3 allows it there, it's an extension. /// </summary> [TestClass] public sealed class StringSetWithStringFunctionTests { [TestMethod] - [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunction_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: section string(chapter)"); @@ -22,7 +21,6 @@ public void StringSet_WithStringFunction_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionAndKeyword_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: section string(chapter, first)"); @@ -35,7 +33,6 @@ public void StringSet_WithStringFunctionAndKeyword_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionAndLiteral_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: section string(chapter) \" - \" content(text)"); @@ -47,7 +44,6 @@ public void StringSet_WithStringFunctionAndLiteral_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-set")] public void StringSet_WithMultipleStringFunctions_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: full-title string(chapter) \" > \" string(section)"); @@ -59,7 +55,6 @@ public void StringSet_WithMultipleStringFunctions_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionLastKeyword_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: current-section string(section, last)"); @@ -71,7 +66,6 @@ public void StringSet_WithStringFunctionLastKeyword_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionStartKeyword_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: page-header string(chapter, start)"); @@ -83,7 +77,6 @@ public void StringSet_WithStringFunctionStartKeyword_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionFirstExceptKeyword_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: heading string(chapter, first-except)"); @@ -95,7 +88,6 @@ public void StringSet_WithStringFunctionFirstExceptKeyword_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionAndCounter_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: page-title string(chapter) \" (\" counter(page) \")\""); @@ -107,7 +99,6 @@ public void StringSet_WithStringFunctionAndCounter_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionAndAttr_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: full-heading string(chapter) \" - \" attr(title)"); @@ -119,7 +110,6 @@ public void StringSet_WithStringFunctionAndAttr_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-set")] public void StringSet_WithStringFunctionAndContentFunction_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: combined string(chapter) \": \" content(text)"); @@ -131,7 +121,6 @@ public void StringSet_WithStringFunctionAndContentFunction_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-set")] public void StringSet_MultipleNamesWithStringFunction_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: header string(chapter), footer string(section, last)"); @@ -143,7 +132,6 @@ public void StringSet_MultipleNamesWithStringFunction_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-set")] public void StringSet_ComplexNestedExample_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: breadcrumb string(part, first) \" / \" string(chapter) \" / \" content(text)"); @@ -159,7 +147,6 @@ public void StringSet_ComplexNestedExample_ParsesCorrectly() } [TestMethod] - [Spec("css-content-3", "#string-set")] public void StringSet_WithHyphenatedIdentifiers_ParsesCorrectly() { var property = CssConstructionFunctions.ParseDeclaration("string-set: my-heading string(my-chapter)"); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssGcpmTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssGcpmTests.cs new file mode 100644 index 000000000..3c4db3a2e --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssGcpmTests.cs @@ -0,0 +1,95 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssGcpmTests +{ + private const double Delta = 0.5; + private const double PageHeight = 300; + + [Ignore("Gap, see Spec/css-gcpm-3.yml")] + [TestMethod] + [Spec("css-gcpm-3", "#running-syntax")] + public void PositionRunning_IsAccepted() + { + Assert.IsTrue(CssConstructionFunctions.ParseDeclaration("position: running(header)").HasValue); + } + + [Ignore("Gap, see Spec/css-gcpm-3.yml")] + [TestMethod] + [Spec("css-gcpm-3", "#running-syntax")] + public void RunningElement_IsRemovedFromTheFlow() + { + var (root, _) = Paginate("<p id='h' style='position:running(header);margin:0;height:20px'>Header</p><div id='b' style='height:10px'></div>"); + + Assert.AreEqual(0, Find(root, "b").Location.Y, Delta); + } + + [Ignore("Gap, see Spec/css-gcpm-3.yml")] + [TestMethod] + [DataRow("content: element(header)")] + [DataRow("content: element(header, last)")] + [Spec("css-gcpm-3", "#element-syntax")] + public void ElementFunction_IsAccepted(string declaration) + { + Assert.IsTrue(CssConstructionFunctions.ParseDeclaration(declaration).HasValue); + } + + [Ignore("Gap, see Spec/css-gcpm-3.yml")] + [TestMethod] + [Spec("css-gcpm-3", "#creating-footnotes")] + public void FloatFootnote_IsAccepted() + { + Assert.IsTrue(CssConstructionFunctions.ParseDeclaration("float: footnote").HasValue); + } + + [Ignore("Gap, see Spec/css-gcpm-3.yml")] + [TestMethod] + [Spec("css-gcpm-3", "#creating-footnotes")] + [Spec("css-gcpm-3", "#footnote-area-position")] + public void Footnote_IsMovedToTheBottomOfThePage() + { + var (root, container) = Paginate("<p style='margin:0'>Text<span id='f' style='float:footnote'>Note</span></p><div id='b' style='height:10px'></div>"); + var f = Find(root, "f"); + + Assert.AreEqual(0, container.PageIndexOf(f.Location.Y)); + Assert.IsTrue(f.Location.Y > PageHeight / 2); + } + + [Ignore("Gap, see Spec/css-gcpm-3.yml")] + [TestMethod] + [DataRow("::footnote-call")] + [DataRow("::footnote-marker")] + [Spec("css-gcpm-3", "#footnote-call")] + [Spec("css-gcpm-3", "#footnote-marker")] + public void FootnotePseudoElements_KeepTheRuleValid(string pseudo) + { + var sheet = CssConstructionFunctions.ParseStyleSheet($"span{pseudo}, p {{ color: red }}"); + + Assert.AreEqual(1, sheet.Rules.OfType<StyleRule>().Count()); + } + + [Ignore("Gap, see Spec/css-gcpm-3.yml")] + [TestMethod] + [DataRow("@page :nth(1) { margin-top: 0 }")] + [DataRow("@page :nth(2n+1 of chapter) { margin-top: 0 }")] + [Spec("css-gcpm-3", "#document-page-selectors")] + [Spec("css-gcpm-3", "#document-sequence-selectors")] + public void NthPageSelector_IsAccepted(string css) + { + var sheet = CssConstructionFunctions.ParseStyleSheet(css); + + Assert.AreEqual(1, sheet.Rules.OfType<PageRule>().Count()); + } + + private static (CssBox Root, HtmlContainerInt Container) Paginate(string body) => + LayoutHarness.Layout($"<html><head></head><body style='margin:0'>{body}</body></html>", 400, pageHeight: PageHeight, margin: 0); + + private static CssBox Find(CssBox root, string id) => LayoutHarness.FindById(root, id)!; +} diff --git a/Spec/css-gcpm-3.yml b/Spec/css-gcpm-3.yml index 76ab9f725..a6cfd3d92 100644 --- a/Spec/css-gcpm-3.yml +++ b/Spec/css-gcpm-3.yml @@ -5,85 +5,108 @@ snapshot: https://www.w3.org/TR/2024/WD-css-gcpm-3-20240125/ sections: '#running-headers-and-footers': title: 1 Running headers and footers - status: unknown + status: parsed-only + gaps: + - Page-margin boxes aren't rendered, so nothing can show a running header or footer, see css-page-3 5 '#named-strings': title: 1.1 Named strings - status: unknown + status: parsed-only '#setting-named-strings-the-string-set-pro': title: 1.1.1 The string-set property - status: unknown + status: parsed-only + gaps: + - string-set never assigns a named string, see css-content-3 2.7.1 + - A CSS-wide keyword is accepted as the name, see css-content-3 2.7.1 '#content-function-header': title: 1.1.1.1 The content() function - status: unknown + status: parsed-only + gaps: + - content() inserts nothing, see css-content-3 2.7.3 '#using-named-strings': title: 1.1.2 The string() function - status: unknown + status: parsed-only + gaps: + - string() inserts nothing, see css-content-3 2.7.2 '#running-elements': title: 1.2 Running elements - status: unknown + status: unsupported '#running-syntax': title: 1.2.1 The running() value - status: unknown + status: unsupported + gaps: + - "'position: running()' is rejected, the element stays in the flow" '#element-syntax': title: 1.2.2 The element() value - status: unknown + status: unsupported + gaps: + - "element() is rejected by 'content'" '#footnotes': title: 2 Footnotes - status: unknown + status: unsupported '#footnote-terms': title: 2.1 Terminology - status: unknown + status: n/a '#creating-footnotes': title: 2.2 Creating footnotes - status: unknown + status: unsupported + gaps: + - "'float: footnote' is rejected, the element stays in the flow" '#footnote-types': title: 2.3 Types of footnotes - status: unknown + status: unsupported '#footnote-area': title: 2.4 The footnote area - status: unknown + status: unsupported + gaps: + - "No footnote area is created, an @footnote rule is kept like a page-margin box and never rendered" '#footnote-area-position': title: 2.4.1 Positioning of the footnote area - status: unknown + status: unsupported '#footnote-area-size': title: 2.4.2 Size of the footnote area - status: unknown + status: unsupported '#footnote-counters': title: 2.5 The Footnote Counter - status: unknown + status: unsupported '#footnote-counter-values': title: 2.5.1 Values of the footnote counter - status: unknown + status: unsupported '#resetting-footnote-counter': title: 2.5.2 Resetting the footnote counter - status: unknown + status: unsupported '#footnote-call': title: 2.6 The footnote-call pseudo-element - status: unknown + status: unsupported + gaps: + - "::footnote-call invalidates the selector" '#footnote-marker': title: 2.7 The footnote-marker pseudo-element - status: unknown + status: unsupported + gaps: + - "::footnote-marker invalidates the selector" '#footnote-policy': title: 2.8 Rendering footnotes and footnote policy - status: unknown + status: unsupported '#future': title: 2.9 Future directions - status: unknown + status: n/a '#the-first-page-pseudo-element': title: 3 Selecting Pages - status: unknown + status: unsupported '#document-page-selectors': title: 3.1 Page Selectors - status: unknown + status: unsupported + gaps: + - "An @page rule with :nth() is dropped" '#document-sequence-selectors': title: 3.2 Page groups - status: unknown + status: unsupported '#leaders': title: 4 Leaders (moved) - status: unknown + status: n/a '#cross-references': title: 5 Cross-references (moved) - status: unknown + status: n/a '#bookmarks': title: 6 Bookmarks (moved) - status: unknown + status: n/a From 5d67115526bbcc0284692e7ecbc0e821ddafa954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:23:18 +0100 Subject: [PATCH 115/150] Verify and document CSS Backgrounds Level 4 compliance --- .../BackgroundPaintIntegrationTests.cs | 6 + .../Painting/CssBackgrounds3Tests.cs | 4 + .../Painting/CssBackgrounds4Tests.cs | 56 ++++++++ .../Css/CssBackgrounds4ParseTests.cs | 134 ++++++++++++++++++ Spec/css-backgrounds-4.yml | 92 +++++++----- 5 files changed, 260 insertions(+), 32 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds4Tests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Css/CssBackgrounds4ParseTests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs index 12d11cbe1..f53a6a957 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs @@ -20,6 +20,7 @@ public sealed class BackgroundPaintIntegrationTests [TestMethod] [Spec("css21", "colors.html#background-properties")] [Spec("css-backgrounds-3", "#background-color")] + [Spec("css-backgrounds-4", "#background-color")] public void BackgroundColor_PaintsASolidFillRect() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -133,6 +134,7 @@ public void RootBackground_CoversTheWholeCanvas() [TestMethod] [Spec("css21", "colors.html#background")] [Spec("css-backgrounds-3", "#body-background")] + [Spec("css-backgrounds-4", "#body-background")] public void BodyBackground_PropagatesToTheCanvas() { var (_, container) = PaintHarness.Layout( @@ -150,6 +152,7 @@ public void BodyBackground_PropagatesToTheCanvas() [TestMethod] [Spec("css21", "colors.html#background-properties")] [Spec("css-backgrounds-3", "#background-image")] + [Spec("css-backgrounds-4", "#background-image")] public void BackgroundImage_PaintsOnTopOfTheBackgroundColor() { var g = PaintBackground($"width:100px;height:50px;background-color:rgb(10,20,30);background-image:{Image};background-repeat:no-repeat"); @@ -188,6 +191,7 @@ public void BackgroundRepeatNoRepeat_DrawsOneCopyAtTheTopLeft() [DataRow("repeat-y", 0, 0, ImageWidth, 50)] [Spec("css21", "colors.html#background-properties")] [Spec("css-backgrounds-3", "#background-repeat")] + [Spec("css-backgrounds-4", "#background-repeat")] public void BackgroundRepeat_TilesAlongTheGivenAxes(string repeat, double x, double y, double width, double height) { var g = PaintBackground($"width:100px;height:50px;background-image:{Image};background-repeat:{repeat}"); @@ -223,6 +227,7 @@ public void BackgroundRepeat_TilingCoversTheBorderArea() [Spec("css21", "colors.html#background-properties")] [Spec("css-values-4", "#position-parsing")] [Spec("css-backgrounds-3", "#background-position")] + [Spec("css-backgrounds-4", "#background-position")] public void BackgroundPosition_Keywords(string position, double x, double y) { var g = PaintBackground($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-position:{position}"); @@ -308,6 +313,7 @@ public void BackgroundAttachmentFixed_PositionsTheImageAgainstTheViewport() [TestMethod] [Spec("css21", "colors.html#background-properties")] [Spec("css-backgrounds-3", "#background")] + [Spec("css-backgrounds-4", "#background")] public void BackgroundShorthand_SetsAllGivenLonghands() { var g = PaintBackground($"width:100px;height:100px;background:rgb(10,20,30) {Image} no-repeat right bottom"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds3Tests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds3Tests.cs index 10ab9ed59..bbc70cf47 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds3Tests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds3Tests.cs @@ -19,6 +19,7 @@ public sealed class CssBackgrounds3Tests [Ignore("Gap, see Spec/css-backgrounds-3.yml")] [TestMethod] [Spec("css-backgrounds-3", "#layering")] + [Spec("css-backgrounds-4", "#layering")] public void MultipleLayers_PaintEveryImage() { var g = Paint($"width:100px;height:100px;background-image:{Image}, {Image};background-repeat:no-repeat;background-position:left top, right bottom"); @@ -105,6 +106,7 @@ public void BackgroundRepeatRound_ScalesTheTileToFitAWholeNumberOfTimes() [TestMethod] [Spec("css-backgrounds-3", "#background-attachment")] + [Spec("css-backgrounds-4", "#background-attachment")] public void BackgroundAttachmentScroll_FixesTheImageToTheBox() { var g = Paint($"margin-top:100px;width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-attachment:scroll"); @@ -133,6 +135,7 @@ public void BackgroundPosition_EdgeOffsets(string position, double x, double y) [DataRow("padding-box", 5, 5, 100, 50)] [DataRow("content-box", 15, 15, 80, 30)] [Spec("css-backgrounds-3", "#background-clip")] + [Spec("css-backgrounds-4", "#background-clip")] public void BackgroundClip_ClipsTheBackgroundToTheGivenBox(string clip, double x, double y, double width, double height) { var g = Paint($"width:80px;height:30px;padding:10px;border:5px solid black;background-color:rgb(10,20,30);background-clip:{clip}"); @@ -183,6 +186,7 @@ public void BackgroundShorthand_AcceptsPositionAndSize() [TestMethod] [Spec("css-backgrounds-3", "#root-background")] + [Spec("css-backgrounds-4", "#root-background")] public void RootBackground_IsPainted() { var (_, container) = PaintHarness.Layout( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds4Tests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds4Tests.cs new file mode 100644 index 000000000..23d136de7 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds4Tests.cs @@ -0,0 +1,56 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[TestClass] +public sealed class CssBackgrounds4Tests +{ + // MockAdapter decodes every image as 40x30 + private const string Image = "url(data:image/png;base64,iVBORw0KGgo=)"; + + [Ignore("Gap, see Spec/css-backgrounds-4.yml")] + [TestMethod] + [DataRow("background-position-x:right", 60, 0)] + [DataRow("background-position-y:bottom", 0, 70)] + [DataRow("background-position:right bottom;background-position-x:left", 0, 70)] + [Spec("css-backgrounds-4", "#background-position-longhands")] + public void BackgroundPositionLonghands_PositionTheImage(string position, double x, double y) + { + var g = Paint($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;{position}"); + + var image = g.DrawImageCalls.Single(); + Assert.AreEqual(x, image.DestRect.X, 0.5); + Assert.AreEqual(y, image.DestRect.Y, 0.5); + } + + [Ignore("Gap, see Spec/css-backgrounds-4.yml")] + [TestMethod] + [Spec("css-backgrounds-4", "#background-repeat-longhands")] + public void BackgroundRepeatY_NoRepeatTilesOnlyHorizontally() + { + var g = Paint($"width:100px;height:50px;background-image:{Image};background-repeat-y:no-repeat"); + + var tiles = g.Log.OfType<RecordingGraphics.DrawRectCall>().Single(r => r.Color == RColor.Empty); + Assert.AreEqual(new RRect(0, 0, 100, 30), new RRect(tiles.X, tiles.Y, tiles.Width, tiles.Height)); + } + + private static RecordingGraphics Paint(string style) + { + var container = new HtmlContainerInt(new MockAdapter()) + { + MaxSize = new RSize(1000, 4000), + Location = RPoint.Empty, + PageSize = new RSize(1000, 4000), + AvoidAsyncImagesLoading = true + }; + container.SetHtml(PaintHarness.Wrap($"<div id='b' style='{style}'></div>")).GetAwaiter().GetResult(); + + using (var layoutGraphics = new RecordingGraphics()) + container.PerformLayout(layoutGraphics); + + return PaintHarness.PaintBox(container, PaintHarness.FindById(container.Root!, "b")!); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/CssBackgrounds4ParseTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssBackgrounds4ParseTests.cs new file mode 100644 index 000000000..1287cf9ea --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/CssBackgrounds4ParseTests.cs @@ -0,0 +1,134 @@ +using HtmlRenderer.Test.CssEngineSupport; + +namespace HtmlRenderer.Test.Css; + +[TestClass] +public sealed class CssBackgrounds4ParseTests +{ + [Ignore("Gap, see Spec/css-backgrounds-4.yml")] + [TestMethod] + [DataRow("background-repeat-x")] + [DataRow("background-repeat-y")] + [DataRow("background-repeat-inline")] + [DataRow("background-repeat-block")] + [Spec("css-backgrounds-4", "#background-repeat-longhands")] + public void BackgroundRepeatLonghands_AreAccepted(string property) + { + var style = CssConstructionFunctions.ParseDeclarations($"{property}: no-repeat"); + + Assert.AreEqual("no-repeat", style.GetPropertyValue(property)); + } + + [TestMethod] + [DataRow("repeat-x")] + [DataRow("space round")] + [DataRow("no-repeat, repeat-y")] + [Spec("css-backgrounds-4", "#background-repeat")] + public void BackgroundRepeat_IsAccepted(string repeat) + { + var property = CssConstructionFunctions.ParseDeclaration($"background-repeat: {repeat}"); + + Assert.IsTrue(property.HasValue); + } + + [Ignore("Gap, see Spec/css-backgrounds-4.yml")] + [TestMethod] + [DataRow("repeat-block")] + [DataRow("repeat-inline")] + [Spec("css-backgrounds-4", "#background-repeat")] + public void BackgroundRepeat_LogicalKeywordsAreAccepted(string repeat) + { + var property = CssConstructionFunctions.ParseDeclaration($"background-repeat: {repeat}"); + + Assert.IsTrue(property.HasValue); + } + + [Ignore("Gap, see Spec/css-backgrounds-4.yml")] + [TestMethod] + [DataRow("background-position-x", "right 10px")] + [DataRow("background-position-y", "bottom")] + [DataRow("background-position-inline", "end")] + [DataRow("background-position-block", "center")] + [Spec("css-backgrounds-4", "#background-position-longhands")] + public void BackgroundPositionLonghands_AreAccepted(string property, string value) + { + var style = CssConstructionFunctions.ParseDeclarations($"{property}: {value}"); + + Assert.AreEqual(value, style.GetPropertyValue(property)); + } + + [TestMethod] + [DataRow("center bottom 10px")] + [DataRow("right 10px top")] + [DataRow("left 10px top 15px")] + [Spec("css-backgrounds-4", "#background-position")] + public void BackgroundPosition_ThreeAndFourValueFormsAreAccepted(string position) + { + var property = CssConstructionFunctions.ParseDeclaration($"background-position: {position}"); + + Assert.IsTrue(property.HasValue); + } + + [Ignore("Gap, see Spec/css-backgrounds-4.yml")] + [TestMethod] + [DataRow("bottom 10px right 20px")] + [DataRow("top 10px left")] + [DataRow("bottom right 5px")] + [Spec("css-backgrounds-4", "#background-position")] + public void BackgroundPosition_VerticalEdgeFirstIsAccepted(string position) + { + var property = CssConstructionFunctions.ParseDeclaration($"background-position: {position}"); + + Assert.IsTrue(property.HasValue); + } + + [Ignore("Gap, see Spec/css-backgrounds-4.yml")] + [TestMethod] + [DataRow("left center 10px")] + [DataRow("center 10px top")] + [Spec("css-backgrounds-4", "#background-position")] + public void BackgroundPosition_CenterWithAnOffsetIsRejected(string position) + { + var property = CssConstructionFunctions.ParseDeclaration($"background-position: {position}"); + + Assert.IsFalse(property.HasValue); + } + + [TestMethod] + [DataRow("background-clip: padding-box, content-box")] + [DataRow("background-origin: content-box")] + [DataRow("background-size: cover, 10px auto")] + [Spec("css-backgrounds-4", "#background-clip")] + [Spec("css-backgrounds-4", "#background-origin")] + [Spec("css-backgrounds-4", "#background-size")] + public void BoxAndSizeValues_AreAccepted(string declaration) + { + var property = CssConstructionFunctions.ParseDeclaration(declaration); + + Assert.IsTrue(property.HasValue); + } + + [Ignore("Gap, see Spec/css-backgrounds-4.yml")] + [TestMethod] + [DataRow("text")] + [DataRow("border-area")] + [DataRow("border-area text")] + [Spec("css-backgrounds-4", "#background-clip")] + public void BackgroundClip_TextAndBorderAreaAreAccepted(string clip) + { + var property = CssConstructionFunctions.ParseDeclaration($"background-clip: {clip}"); + + Assert.IsTrue(property.HasValue); + } + + [Ignore("Gap, see Spec/css-backgrounds-4.yml")] + [TestMethod] + [Spec("css-backgrounds-4", "#background")] + public void BackgroundShorthand_AClipOnlyValueSetsOriginToBorderBox() + { + var style = CssConstructionFunctions.ParseDeclarations("background: url(a.png) text"); + + Assert.AreEqual("text", style.BackgroundClip); + Assert.AreEqual("border-box", style.BackgroundOrigin); + } +} diff --git a/Spec/css-backgrounds-4.yml b/Spec/css-backgrounds-4.yml index fc86dd531..9ebabae17 100644 --- a/Spec/css-backgrounds-4.yml +++ b/Spec/css-backgrounds-4.yml @@ -5,79 +5,107 @@ snapshot: https://www.w3.org/TR/2025/WD-css-backgrounds-4-20251125/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#backgrounds': title: 2 Defining Backgrounds - status: unknown + status: partial '#background-color': title: '2.1 Base Color: the background-color property' - status: unknown + status: partial + gaps: + - 'The color isn''t painted beneath a gradient, see css-backgrounds-3 #background-color' '#background-image': title: '2.2 Image Sources: the background-image property' - status: unknown + status: partial + gaps: + - 'Only url() images and linear gradients are painted, see css-backgrounds-3 #background-image' '#background-repeat-longhands': title: '2.3 Tiling Images: the background-repeat-x, background-repeat-y, background-repeat-block, and background-repeat-inline properties' - status: unknown + status: unsupported + gaps: + - The longhands aren't known properties and are dropped '#background-repeat': title: '2.4 Tiling Images Shorthand: the background-repeat property' - status: unknown + status: partial + gaps: + - "'repeat-block' and 'repeat-inline' are rejected" + - 'The two-value syntax, ''space'' and ''round'' aren''t painted, see css-backgrounds-3 #background-repeat' '#background-attachment': title: '2.5 Affixing Images: the background-attachment property' - status: unknown + status: partial + gaps: + - "'fixed' is ignored, see css-backgrounds-3 #background-attachment" '#background-position-longhands': title: '2.6 Background Positioning: the background-position-x, background-position-y, background-position-inline, and background-position-block properties' - status: unknown + status: unsupported + gaps: + - The longhands aren't known properties and are dropped '#background-position': title: '2.7 Background Positioning Shorthand: the background-position shorthand property' - status: unknown + status: partial + gaps: + - 'Only keywords are painted, see css-backgrounds-3 #background-position' + - Three and four value forms that start with 'top' or 'bottom' are rejected + - "'center' followed by an offset, like 'left center 10px', is accepted" '#bg-position-serialization': title: 2.7.1 Serialization of background-position values - status: unknown + status: n/a '#background-clip': title: '2.8 Painting Area: the background-clip property' - status: unknown + status: parsed-only + gaps: + - "'background-clip' is ignored, see css-backgrounds-3 #background-clip" + - "'text' and 'border-area' are rejected" '#background-origin': title: '2.9 Positioning Area: the background-origin property' - status: unknown + status: parsed-only + gaps: + - "'background-origin' is ignored, see css-backgrounds-3 #background-origin" '#background-size': title: '2.10 Sizing Images: the background-size property' - status: unknown + status: parsed-only + gaps: + - "'background-size' is ignored, see css-backgrounds-3 #background-size" '#bg-size-serialization': title: 2.10.1 Serialization of background-size values - status: unknown + status: n/a '#background-layers': title: '2.11 Background Image Layers: the background-tbd shorthand property' - status: unknown + status: unsupported + gaps: + - Not implemented, the section is marked not ready for implementation '#background': title: '2.12 Backgrounds Shorthand: the background property' - status: unknown + status: partial + gaps: + - 'A single box value only sets ''background-origin'', see css-backgrounds-3 #background' + - "The clip-only values 'text' and 'border-area' are rejected" '#layering': title: 3 Layering Multiple Background Images - status: unknown + status: unsupported + gaps: + - 'Only the first layer is painted, see css-backgrounds-3 #layering' '#special-backgrounds': title: 4 Backgrounds of Special Elements - status: unknown + status: partial '#root-background': title: 4.1 The Canvas Background and the Root Element - status: unknown + status: partial + gaps: + - 'The root''s background only covers the root box, see css-backgrounds-3 #root-background' '#body-background': title: 4.2 The Canvas Background and the HTML <body> Element - status: unknown + status: unsupported + gaps: + - 'The body''s background isn''t propagated to the canvas, see css-backgrounds-3 #body-background' '#first-line-background': title: 4.3 The ::first-line Pseudo-element‘s Background - status: unknown + status: unsupported + gaps: + - "::first-line declarations never apply, see css-backgrounds-3 #first-line-background" '#changes': title: 5 Changes - status: unknown - '#level-changes': - title: 5.1 Additions since [CSS3BG] - status: unknown + status: n/a '#acknowledgments': title: 6 Acknowledgments - status: unknown + status: n/a From 63ad0a79a0215c6c8e20904136ea6123e1da723c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:29:47 +0100 Subject: [PATCH 116/150] Verify and document CSS Borders Level 4 compliance --- .../BoxModel/BorderIntegrationTests.cs | 3 + .../Painting/BorderRadiusIntegrationTests.cs | 3 + .../BorderStylePaintIntegrationTests.cs | 1 + .../Painting/CssBackgrounds3Tests.cs | 6 + .../Painting/CssBorders4Tests.cs | 34 ++++ .../Css/BorderImagePropertyTests.cs | 19 ++- .../Css/BoxShadowGrammarTests.cs | 1 + .../Css/CssBorders4ParseTests.cs | 154 ++++++++++++++++++ Spec/css-borders-4.yml | 151 ++++++++++------- 9 files changed, 307 insertions(+), 65 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBorders4Tests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs index 013e0f8b0..e171f25a7 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BorderIntegrationTests.cs @@ -10,6 +10,7 @@ public sealed class BorderIntegrationTests [TestMethod] [Spec("css21", "box.html#border-width-properties")] [Spec("css-backgrounds-3", "#border-width")] + [Spec("css-borders-4", "#border-width")] public void WidthKeywords_GrowFromThinOverMediumToThick() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -62,6 +63,7 @@ public void InitialWidth_IsMedium() [TestMethod] [Spec("css21", "box.html#border-color-properties")] [Spec("css-backgrounds-3", "#border-color")] + [Spec("css-borders-4", "#border-color")] public void TransparentColor_PaintsNothingVisible() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='b' style='border:2px solid transparent'></div>")); @@ -83,6 +85,7 @@ public void InitialColor_IsTheElementsColor() [TestMethod] [Spec("css21", "box.html#border-shorthand-properties")] [Spec("css-backgrounds-3", "#border-shorthands")] + [Spec("css-borders-4", "#border-shorthands")] public void BorderShorthand_SetsAllFourSides() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div id='b' style='border:3px dashed red'></div>")); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderRadiusIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderRadiusIntegrationTests.cs index 5daeb43f6..741dc5d9e 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderRadiusIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderRadiusIntegrationTests.cs @@ -43,6 +43,7 @@ public void BorderRadius_TwoValues_SetsOpposingCorners() [TestMethod] [Spec("css-backgrounds-3", "#border-radius")] + [Spec("css-borders-4", "#corner-sizing-shorthand")] public void BorderRadius_FourValues_SetsAllCornersIndividually() { var box = FindDivBox("border-radius:5px 10px 15px 20px"); @@ -96,6 +97,7 @@ public void BorderRadius_EllipticalShorthand_SetsAllCornersXAndY() [TestMethod] [Spec("css-backgrounds-3", "#border-radius")] + [Spec("css-borders-4", "#border-radius")] public void BorderTopLeftRadius_Longhand_EllipticalValues() { // border-top-left-radius: 15px 25px -> X=15, Y=25 @@ -123,6 +125,7 @@ public void BorderRadius_Percentage_ResolvesRelativeToDimensions() [TestMethod] [Spec("css-backgrounds-3", "#corner-overlap")] + [Spec("css-borders-4", "#corner-overlap")] public void BorderRadius_OverlappingRadii_AreReducedProportionally() { // 100x100 box; border-radius: 60px - adjacent radii sum to 120 > 100, so all must scale by diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs index f2126d468..ded88db60 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderStylePaintIntegrationTests.cs @@ -32,6 +32,7 @@ public void BorderStyle_AllCss1Keywords_DoNotThrowWhenPainted(string style) [DataRow("outset", 100, 50)] [Spec("css21", "box.html#border-style-properties")] [Spec("css-backgrounds-3", "#border-style")] + [Spec("css-borders-4", "#border-style")] public void InsetAndOutset_DarkenOppositeSides(string style, int topLeft, int bottomRight) { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds3Tests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds3Tests.cs index bbc70cf47..fc782fc5a 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds3Tests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds3Tests.cs @@ -210,6 +210,7 @@ public void BorderRadius_PercentagesReferToTheBorderBox() [TestMethod] [Spec("css-backgrounds-3", "#corner-shaping")] + [Spec("css-borders-4", "#corners")] public void RoundedBackground_FollowsTheOuterBorderEdgeCurve() { var g = PaintShapes("width:100px;height:100px;border:4px solid black;background-color:rgb(10,20,30);border-radius:10px 20px"); @@ -234,6 +235,7 @@ public void RoundedBorder_FollowsTheOuterAndInnerBorderCurves() [TestMethod] [Spec("css-backgrounds-3", "#corner-clipping")] + [Spec("css-borders-4", "#corner-clipping")] public void RoundedCorners_ClipTheBackgroundColor() { var g = PaintShapes("width:100px;height:100px;background-color:rgb(10,20,30);border-radius:10px"); @@ -271,6 +273,7 @@ public void RoundedCorners_ClipOverflowingContentToThePaddingEdgeCurve() [TestMethod] [Spec("css-backgrounds-3", "#corner-transitions")] + [Spec("css-borders-4", "#corner-transitions")] public void AdjoiningInsetBorders_MeetAtTheDiagonal() { var g = PaintShapes("width:20px;height:20px;border-style:inset;border-width:10px;border-color:red blue"); @@ -318,6 +321,7 @@ public void OverlappingRadii_AreAllScaledByTheSameFactor() [TestMethod] [Spec("css-backgrounds-3", "#border-radius-tables")] + [Spec("css-borders-4", "#border-radius-tables")] public void BorderRadius_RoundsASeparatedBordersTable() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -346,6 +350,7 @@ public void BorderRadius_DoesNotApplyToACollapsingBordersTable() [Ignore("Gap, see Spec/css-backgrounds-3.yml")] [TestMethod] [Spec("css-backgrounds-3", "#border-images")] + [Spec("css-borders-4", "#border-images")] public void BorderImage_ReplacesTheBorderStyle() { var g = Paint($"width:100px;height:100px;border:10px solid red;border-image:{Image} 10"); @@ -357,6 +362,7 @@ public void BorderImage_ReplacesTheBorderStyle() [Ignore("Gap, see Spec/css-backgrounds-3.yml")] [TestMethod] [Spec("css-backgrounds-3", "#box-shadow")] + [Spec("css-borders-4", "#box-shadow")] public void BoxShadow_PaintsAShadowBeneathTheBackground() { var g = Paint("width:100px;height:50px;background-color:rgb(10,20,30);box-shadow:5px 5px blue"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBorders4Tests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBorders4Tests.cs new file mode 100644 index 000000000..ec8bb1e2f --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBorders4Tests.cs @@ -0,0 +1,34 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.Painting; + +// Parallel full layout passes race on the adapter singletons. +[TestClass] +[DoNotParallelize] +public sealed class CssBorders4Tests +{ + [TestMethod] + [Spec("css-borders-4", "#border-width")] + public void LogicalBorderWidth_MapsToThePhysicalSide() + { + var box = Layout("<div id='b' style='border-inline-start:5px solid red;border-block-end:3px solid red'>x</div>"); + + Assert.AreEqual(5, box.ActualBorderLeftWidth, 0.01); + Assert.AreEqual(3, box.ActualBorderBottomWidth, 0.01); + } + + [Ignore("Gap, see Spec/css-borders-4.yml")] + [TestMethod] + [Spec("css-borders-4", "#border-width")] + public void LogicalBorderWidth_FollowsTheDirection() + { + var box = Layout("<div id='b' style='direction:rtl;border-inline-start:5px solid red'>x</div>"); + + Assert.AreEqual(5, box.ActualBorderRightWidth, 0.01); + Assert.AreEqual(0, box.ActualBorderLeftWidth, 0.01); + } + + private static CssBox Layout(string body) => + LayoutHarness.FindById(LayoutHarness.Layout(LayoutHarness.Wrap(body), maxWidth: 400).Root, "b")!; +} diff --git a/Source/Test/HtmlRenderer.Test/Css/BorderImagePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/BorderImagePropertyTests.cs index c08a74f7f..0c76ae204 100644 --- a/Source/Test/HtmlRenderer.Test/Css/BorderImagePropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/BorderImagePropertyTests.cs @@ -23,6 +23,7 @@ public void BorderImageSourceNoneLegal() [TestMethod] [Spec("css-backgrounds-3", "#border-image-source")] + [Spec("css-borders-4", "#border-image-source")] public void BorderImageSourceUrlLegal() { var snippet = "border-image-source: url(image.jpg)"; @@ -53,6 +54,7 @@ public void BorderImageSourceLinearGradientLegal() [TestMethod] [Spec("css-backgrounds-3", "#border-image-outset")] + [Spec("css-borders-4", "#border-image-outset")] public void BorderImageOutsetZeroLegal() { var snippet = "border-image-outset: 0"; @@ -142,6 +144,7 @@ public void BorderImageWidthZeroLegal() [TestMethod] [Spec("css-backgrounds-3", "#border-image-width")] + [Spec("css-borders-4", "#border-image-width")] public void BorderImageWidthAutoLegal() { var snippet = "border-image-width: auto"; @@ -261,6 +264,7 @@ public void BorderImageRepeatRepeatLegal() [TestMethod] [Spec("css-backgrounds-3", "#border-image-repeat")] + [Spec("css-borders-4", "#border-image-repeat")] public void BorderImageRepeatRoundLegal() { var snippet = "border-image-repeat: round"; @@ -334,8 +338,10 @@ public void BorderImageSlicePercentLegal() } [TestMethod] + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] [Spec("css-backgrounds-3", "#border-image-slice")] - public void BorderImageSliceFillLegal() + [Spec("css-borders-4", "#border-image-slice")] + public void BorderImageSliceFillIllegal() { var snippet = "border-image-slice: fill"; var property = CssConstructionFunctions.ParseDeclaration(snippet); @@ -344,12 +350,7 @@ public void BorderImageSliceFillLegal() Assert.IsInstanceOfType<BorderImageSliceProperty>(property); var concrete = (BorderImageSliceProperty)property; Assert.IsFalse(concrete.IsInherited); - Assert.IsTrue(concrete.HasValue); - //Assert.AreEqual(true, concrete.IsFilled); - //Assert.AreEqual(Length.Full, concrete.SliceLeft); - //Assert.AreEqual(Length.Full, concrete.SliceRight); - //Assert.AreEqual(Length.Full, concrete.SliceTop); - //Assert.AreEqual(Length.Full, concrete.SliceBottom); + Assert.IsFalse(concrete.HasValue); } [TestMethod] @@ -394,8 +395,7 @@ public void BorderImageSliceFillBetweenNumbersIllegal() Assert.IsInstanceOfType<BorderImageSliceProperty>(property); var concrete = (BorderImageSliceProperty)property; Assert.IsFalse(concrete.IsInherited); - Assert.IsTrue(concrete.HasValue); - Assert.AreEqual("10% 30 0 0 fill", concrete.Value); + Assert.IsFalse(concrete.HasValue); } [TestMethod] @@ -443,6 +443,7 @@ public void BorderImageNoneLegal() [TestMethod] [Spec("css-backgrounds-3", "#border-image")] + [Spec("css-borders-4", "#border-image")] public void BorderImageUrlOffsetLegal() { var snippet = "border-image: url(image.png) 50 50"; diff --git a/Source/Test/HtmlRenderer.Test/Css/BoxShadowGrammarTests.cs b/Source/Test/HtmlRenderer.Test/Css/BoxShadowGrammarTests.cs index 4f0851f34..5f74d3caa 100644 --- a/Source/Test/HtmlRenderer.Test/Css/BoxShadowGrammarTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/BoxShadowGrammarTests.cs @@ -49,6 +49,7 @@ public void ThreeLengths_HasBlur() [TestMethod] [Spec("css-backgrounds-3", "#box-shadow")] + [Spec("css-borders-4", "#box-shadow")] public void FourLengths_HasBlurAndSpread() { var layers = Parse("1px 1px 2px 3px"); diff --git a/Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs new file mode 100644 index 000000000..366208f05 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs @@ -0,0 +1,154 @@ +using HtmlRenderer.Test.CssEngineSupport; + +namespace HtmlRenderer.Test.Css; + +[TestClass] +public sealed class CssBorders4ParseTests +{ + [TestMethod] + [DataRow("border-block-start-color: red", "border-top-color", "red")] + [DataRow("border-inline-end-color: red", "border-right-color", "red")] + [DataRow("border-block-color: red blue", "border-bottom-color", "blue")] + [Spec("css-borders-4", "#border-color")] + public void LogicalBorderColor_SetsThePhysicalSide(string declaration, string physical, string value) + { + var style = CssConstructionFunctions.ParseDeclarations(declaration); + var expected = CssConstructionFunctions.ParseDeclarations($"{physical}: {value}"); + + Assert.AreEqual(expected.GetPropertyValue(physical), style.GetPropertyValue(physical)); + Assert.AreNotEqual("", style.GetPropertyValue(physical)); + } + + [TestMethod] + [DataRow("border-inline-start-style: dotted", "border-left-style", "dotted")] + [DataRow("border-block-style: solid dashed", "border-bottom-style", "dashed")] + [Spec("css-borders-4", "#border-style")] + public void LogicalBorderStyle_SetsThePhysicalSide(string declaration, string physical, string value) + { + var style = CssConstructionFunctions.ParseDeclarations(declaration); + + Assert.AreEqual(value, style.GetPropertyValue(physical)); + } + + [TestMethod] + [DataRow("border-block-end-width: 5px", "border-bottom-width", "5px")] + [DataRow("border-inline-width: 1px 2px", "border-right-width", "2px")] + [Spec("css-borders-4", "#border-width")] + public void LogicalBorderWidth_SetsThePhysicalSide(string declaration, string physical, string value) + { + var style = CssConstructionFunctions.ParseDeclarations(declaration); + + Assert.AreEqual(value, style.GetPropertyValue(physical)); + } + + [TestMethod] + [DataRow("border-inline: 2px solid red", "border-right-width", "2px")] + [DataRow("border-block-start: 3px dotted", "border-top-style", "dotted")] + [Spec("css-borders-4", "#border-shorthands")] + public void LogicalBorderShorthands_SetThePhysicalSides(string declaration, string physical, string value) + { + var style = CssConstructionFunctions.ParseDeclarations(declaration); + + Assert.AreEqual(value, style.GetPropertyValue(physical)); + } + + [Ignore("Gap, see Spec/css-borders-4.yml")] + [TestMethod] + [Spec("css-borders-4", "#border-color")] + public void BorderColor_StripesAreAccepted() + { + var property = CssConstructionFunctions.ParseDeclaration("border-top-color: stripes(red, blue)"); + + Assert.IsTrue(property.HasValue); + } + + [Ignore("Gap, see Spec/css-borders-4.yml")] + [TestMethod] + [Spec("css-borders-4", "#border-shorthands")] + public void BorderShorthand_ResetsTheBorderImage() + { + var style = CssConstructionFunctions.ParseDeclarations("border-image-source: url(a.png); border: solid red"); + + Assert.IsFalse(style.GetPropertyValue("border-image-source").Contains("a.png")); + } + + [Ignore("Gap, see Spec/css-borders-4.yml")] + [TestMethod] + [Spec("css-borders-4", "#border-radius")] + public void BorderRadiusLonghand_AcceptsTheSlashSyntax() + { + var property = CssConstructionFunctions.ParseDeclaration("border-top-left-radius: 10px / 20px"); + + Assert.IsTrue(property.HasValue); + } + + [Ignore("Gap, see Spec/css-borders-4.yml")] + [TestMethod] + [DataRow("border-start-start-radius", "border-top-left-radius")] + [DataRow("border-start-end-radius", "border-top-right-radius")] + [DataRow("border-end-end-radius", "border-bottom-right-radius")] + [Spec("css-borders-4", "#border-radius")] + public void LogicalBorderRadius_SetsThePhysicalCorner(string logical, string physical) + { + var style = CssConstructionFunctions.ParseDeclarations($"{logical}: 5px"); + + Assert.AreEqual("5px", style.GetPropertyValue(physical)); + } + + [Ignore("Gap, see Spec/css-borders-4.yml")] + [TestMethod] + [DataRow("border-top-radius: 5px 10px", "border-top-right-radius", "10px")] + [DataRow("border-left-radius: 5px 10px", "border-bottom-left-radius", "10px")] + [Spec("css-borders-4", "#corner-sizing-side-shorthands")] + public void SideRadiusShorthands_SetBothCorners(string declaration, string physical, string value) + { + var style = CssConstructionFunctions.ParseDeclarations(declaration); + + Assert.AreEqual(value, style.GetPropertyValue(physical)); + } + + [Ignore("Gap, see Spec/css-borders-4.yml")] + [TestMethod] + [DataRow("corner-top-left-shape: bevel")] + [DataRow("corner-top-left-shape: superellipse(2)")] + [DataRow("corner-shape: scoop notch")] + [DataRow("corner: 10px squircle")] + [Spec("css-borders-4", "#corner-shaping")] + [Spec("css-borders-4", "#corner-shaping-shorthand")] + [Spec("css-borders-4", "#corner-shorthand")] + public void CornerShape_IsAccepted(string declaration) + { + var property = CssConstructionFunctions.ParseDeclaration(declaration); + + Assert.IsTrue(property.HasValue); + } + + [Ignore("Gap, see Spec/css-borders-4.yml")] + [TestMethod] + [DataRow("box-shadow-color: red")] + [DataRow("box-shadow-offset: 5px 5px")] + [DataRow("box-shadow-blur: 2px")] + [DataRow("box-shadow-spread: 1px")] + [DataRow("box-shadow-position: inset")] + [Spec("css-borders-4", "#box-shadow-color")] + [Spec("css-borders-4", "#box-shadow-offset")] + [Spec("css-borders-4", "#box-shadow-blur")] + [Spec("css-borders-4", "#box-shadow-spread")] + [Spec("css-borders-4", "#box-shadow-position")] + public void BoxShadowLonghands_AreAccepted(string declaration) + { + var property = CssConstructionFunctions.ParseDeclaration(declaration); + + Assert.IsTrue(property.HasValue); + } + + [Ignore("Gap, see Spec/css-borders-4.yml")] + [TestMethod] + [Spec("css-borders-4", "#border-shape-func")] + public void BorderShape_IsAccepted() + { + var property = CssConstructionFunctions.ParseDeclaration("border-shape: circle(50%)"); + + Assert.IsTrue(property.HasValue); + } +} diff --git a/Spec/css-borders-4.yml b/Spec/css-borders-4.yml index 83656f9b0..053a5622a 100644 --- a/Spec/css-borders-4.yml +++ b/Spec/css-borders-4.yml @@ -5,157 +5,196 @@ snapshot: https://www.w3.org/TR/2025/WD-css-borders-4-20251216/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#borders': title: 2 Borders - status: unknown + status: partial '#border-color': title: '2.1 Line Colors: the border-color properties' - status: unknown + status: partial + gaps: + - "The initial color is black instead of currentcolor, see css-backgrounds-3 #border-color" + - "<image-1D> values like stripes() are rejected" + - Flow-relative properties always map as in horizontal-tb ltr, direction and writing-mode are ignored '#border-style': title: '2.2 Line Patterns: the border-style properties' - status: unknown + status: partial + gaps: + - "double, groove and ridge are painted as solid, see css-backgrounds-3 #border-style" + - Flow-relative properties always map as in horizontal-tb ltr, direction and writing-mode are ignored '#border-width': title: '2.3 Line Thickness: the border-width properties' - status: unknown + status: partial + gaps: + - "'border-style: hidden' keeps the width, see css-backgrounds-3 #border-width" + - Flow-relative properties always map as in horizontal-tb ltr, direction and writing-mode are ignored '#border-shorthands': title: 2.4 Border Shorthand Properties - status: unknown + status: partial + gaps: + - "'border' doesn't reset 'border-image'" + - Flow-relative properties always map as in horizontal-tb ltr, direction and writing-mode are ignored '#corners': title: 3 Corners - status: unknown + status: partial + gaps: + - "A rounded border isn't painted between the outer and inner border curves, see css-backgrounds-3 #corner-shaping" '#corner-clipping': title: 3.1 Corner Clipping - status: unknown + status: partial + gaps: + - "Background images and overflowing content aren't clipped to the rounded corners, see css-backgrounds-3 #corner-clipping" '#corner-transitions': title: 3.2 Color and Style Transitions - status: unknown + status: partial + gaps: + - "Adjoining borders overlap at the corners and a rounded corner takes one border's color, see css-backgrounds-3 #corner-transitions" '#corner-overlap': title: 3.3 Overlapping Curves - status: unknown + status: partial + gaps: + - "The radii are scaled by separate factors per axis, see css-backgrounds-3 #corner-overlap" '#border-radius-tables': title: 3.4 Effect on Tables - status: unknown + status: partial + gaps: + - "'border-radius' also rounds a table with collapsing borders, see css-backgrounds-3 #border-radius-tables" '#border-radius': title: '3.5 Corner Sizing: the border-*-*-radius properties' - status: unknown + status: partial + gaps: + - Negative radii are accepted instead of being rejected + - "The slash syntax on a longhand, like '10px / 20px', is rejected" + - The flow-relative border-start-start-radius etc. aren't known properties '#corner-sizing': title: '3.6 Corner Sizing Shorthands: the border-radius and border-*-radius shorthand properties' - status: unknown + status: partial '#corner-sizing-side-shorthands': title: '3.6.1 Sizing The Corners Of One Side: The border-top-radius, border-right-radius, border-bottom-radius, border-left-radius, border-block-start-radius, border-block-end-radius, border-inline-start-radius, border-inline-end-radius shorthands' - status: unknown + status: unsupported + gaps: + - border-top-radius and the other side shorthands aren't known properties '#corner-sizing-shorthand': title: '3.6.2 Sizing All Corners At Once: The border-radius shorthand' - status: unknown + status: partial + gaps: + - Negative radii are accepted instead of being rejected '#corner-shaping': title: '3.7 Corner Shaping: the corner-*-shape properties' - status: unknown + status: unsupported + gaps: + - corner-*-shape properties aren't known properties, every corner is round '#corner-shaping-shorthands': title: '3.8 Corner Shaping Shorthands: the corner-shape and corner-*-shape shorthand properties' - status: unknown + status: unsupported '#corner-shaping-side-shorthands': title: '3.8.1 Shaping The Corners Of One Side: The corner-*-shape shorthands:' - status: unknown + status: unsupported '#corner-shaping-shorthand': title: '3.8.2 Shaping All Corners At Once: The corner-shape shorthand' - status: unknown + status: unsupported '#corner-shorthands': title: '3.9 Corner Sizing & Shaping Shorthands: the corner and corner-* shorthand properties' - status: unknown + status: unsupported '#single-corner-shorthands': title: '3.9.1 Sizing & shaping a corner: The corner-top-left, corner-top-right, corner-bottom-right, corner-bottom-left, corner-start-start, corner-start-end, corner-end-start, corner-end-end shorthands' - status: unknown + status: unsupported '#corner-side-shorthands': title: '3.9.2 Sizing & shaping rhe Corners Of One Side: The corner-top, corner-right, corner-bottom, corner-left, corner-block-start, corner-block-end, corner-inline-start, corner-inline-end shorthands' - status: unknown + status: unsupported '#corner-shorthand': title: '3.9.3 Sizing & Shaping All Corners At Once: The corner shorthand' - status: unknown + status: unsupported '#corner-shape-rendering': title: 3.9.4 Rendering corner-shape - status: unknown + status: unsupported '#corner-shape-constrain-radii': title: 3.9.5 Constraining opposite radii - status: unknown + status: unsupported '#corner-shape-interpolation': title: 3.9.6 Interpolating corner shapes - status: unknown + status: n/a '#border-images': title: 4 Border Images - status: unknown + status: parsed-only + gaps: + - "'border-image' and its longhands are parsed but never painted, see css-backgrounds-3 #border-images" '#border-image-source': title: '4.1 Image Source: the border-image-source property' - status: unknown + status: parsed-only '#border-image-slice': title: '4.2 Image Slicing: the border-image-slice property' - status: unknown + status: parsed-only + gaps: + - "'fill' on its own and 'fill' between the numbers are accepted, see css-backgrounds-3 #border-image-slice" '#border-image-width': title: '4.3 Drawing Areas: the border-image-width property' - status: unknown + status: parsed-only '#border-image-outset': title: '4.4 Edge Overhang: the border-image-outset property' - status: unknown + status: parsed-only '#border-image-repeat': title: '4.5 Image Tiling: the border-image-repeat property' - status: unknown + status: parsed-only '#border-image-process': title: 4.6 Drawing the Border Image - status: unknown + status: unsupported '#border-image': title: '4.7 Border Image Shorthand: the border-image property' - status: unknown + status: parsed-only '#border-image-tables': title: 4.8 Effect on Tables - status: unknown + status: unsupported '#partial-borders': title: 5 Partial borders - status: unknown + status: unsupported + gaps: + - Not implemented, the section is marked not ready for implementation '#border-limit': title: '5.1 Partial Borders: the border-limit property' - status: unknown + status: unsupported '#border-clip': title: 5.2 The border-clip properties - status: unknown + status: unsupported '#drop-shadows': title: 6 Drop Shadows - status: unknown + status: parsed-only '#box-shadow-color': title: '6.1 Coloring shadows: the box-shadow-color property' - status: unknown + status: unsupported + gaps: + - The box-shadow-* longhands aren't known properties '#box-shadow-offset': title: '6.2 Offsetting shadows: the box-shadow-offset property' - status: unknown + status: unsupported '#box-shadow-blur': title: '6.3 Blurring shadows: the box-shadow-blur property' - status: unknown + status: unsupported '#box-shadow-spread': title: '6.4 Spreading shadows: the box-shadow-spread property' - status: unknown + status: unsupported '#box-shadow-position': title: '6.5 Spreading shadows: the box-shadow-position property' - status: unknown + status: unsupported '#box-shadow': title: '6.6 Drop Shadows Shorthand: the box-shadow property' - status: unknown + status: parsed-only + gaps: + - "'box-shadow' is parsed but never painted, see css-backgrounds-3 #box-shadow" '#shadow-shape': title: 6.6.1 Shadow Shape, Spread, and Knockout - status: unknown + status: unsupported '#shadow-blur': title: 6.6.2 Blurring Shadow Edges - status: unknown + status: unsupported '#shadow-layers': title: 6.7 Layering, Layout, and Other Details - status: unknown + status: unsupported '#border-shape': title: 7 Border Shaping - status: unknown + status: unsupported '#border-shape-func': title: 7.1 The border-shape property - status: unknown + status: unsupported + gaps: + - "'border-shape' isn't a known property" From 3f98d9eb99e69616136dd0a78fece0645e7d8c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:37:30 +0100 Subject: [PATCH 117/150] Verify and document CSS Images Level 4 compliance --- .../Core/CssEngine/Model/Converters.cs | 6 +- .../ExtendedImageConverters.cs | 23 +-- .../ValueConverters/GradientConverter.cs | 51 ++--- .../Painting/CssImages3Tests.cs | 21 ++- .../Painting/CssImages4Tests.cs | 178 ++++++++++++++++++ .../Css/CssBorders4ParseTests.cs | 1 + .../Css/CssImages4ParseTests.cs | 178 ++++++++++++++++++ .../HtmlRenderer.Test/Css/GradientTests.cs | 41 ++-- .../Css/ObjectSizingPropertyTests.cs | 2 + Spec/css-images-4.yml | 150 +++++++++------ 10 files changed, 503 insertions(+), 148 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/CssImages4Tests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Css/CssImages4ParseTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs b/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs index f0d5a9cbe..893bec5ca 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs @@ -481,11 +481,7 @@ public static readonly IValueConverter public static readonly IValueConverter MultipleShadowConverter = ShadowConverter.FromList().OrNone(); - // The image functions the engine validates but the renderer does not paint (CSS Images 4). Composed into - // ImageSourceConverter so every <image> property (background-image, list-style-image, cursor, - // content, and @property syntax:"<image>") accepts them syntactically; the render path - // (CssValueParser.ParseImage → CssImagePainter) still handles only url()/gradients, so they paint - // nothing (an unchanged engine-wide gap). See ExtendedImageConverters.cs. + // Accepted wherever an <image> is, but never painted public static readonly IValueConverter ImageSetImageConverter = Construct(() => new FunctionValueConverter(FunctionNames.ImageSet, new ImageSetConverter())); public static readonly IValueConverter CrossFadeImageConverter = diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/ExtendedImageConverters.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/ExtendedImageConverters.cs index 908e59aea..990f69815 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/ExtendedImageConverters.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/ExtendedImageConverters.cs @@ -6,11 +6,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine using static Converters; /// <summary> - /// A validated-but-not-rendered image function (<c>image-set()</c>/<c>cross-fade()</c>/<c>element()</c>, - /// CSS Images 4 §2/§3). The engine accepts these as syntactically-valid <c>&lt;image&gt;</c> values (so a - /// property or <c>@property</c> registration using one is valid, per spec) but paints nothing for them — - /// the render path (<c>CssValueParser.ParseImage</c> → <c>CssImagePainter</c>) only handles url()/gradients. - /// This value carries the original tokens for serialization only. + /// An <c>image-set()</c>, <c>cross-fade()</c> or <c>element()</c> value, CSS Images 4 §2.4, §2.6 and §2.7. + /// Only validated and serialized, the painter doesn't handle them. /// </summary> internal sealed class ImageFunctionValue : IPropertyValue { @@ -26,12 +23,13 @@ public ImageFunctionValue(IEnumerable<Token> tokens) public TokenValue ExtractFor(string name) => Original; } - /// <summary>The inner <c>&lt;image&gt;</c> of an <c>image-set()</c>/<c>cross-fade()</c> option — a url() or a - /// gradient. Nested <c>image-set()</c> isn't modeled (not a real authoring pattern); referencing the two - /// base converters directly also avoids a construction cycle with <c>ImageSourceConverter</c>.</summary> + /// <summary> + /// The <c>&lt;image&gt;</c> inside <c>image-set()</c> and <c>cross-fade()</c>, only a url() or a gradient. + /// Using <c>ImageSourceConverter</c> here would be a construction cycle. + /// </summary> internal static class ExtendedImage { - // Qualify GradientConverter — the bare name also resolves to the abstract GradientConverter *type*. + // Qualified, the bare name is the GradientConverter type public static readonly IValueConverter InnerImage = UrlConverter.Or(Converters.GradientConverter); } @@ -40,11 +38,10 @@ internal sealed class ElementImageConverter : IValueConverter { public IPropertyValue Convert(IEnumerable<Token> value) { - // ToArray (LINQ) — a bare ToList() on IEnumerable<Token> resolves to the CSS comma-group splitter. + // ToList() on tokens is the comma group splitter var tokens = value.Where(t => t.Type != TokenType.Whitespace).ToArray(); - // An '#id' selector is a single <hash-token> (CSS Syntax §4). A '#f00'-shaped id lexes as a Color - // token, any other id as a Hash token; both are the single '#'-prefixed form. + // An id that looks like a hex color, like #f00, is tokenized as a color return tokens.Length == 1 && (tokens[0].Type == TokenType.Hash || tokens[0].Type == TokenType.Color) ? new ImageFunctionValue(value) : null; @@ -72,8 +69,6 @@ private static bool IsOption(List<Token> option) if (StringConverter.Convert(items[0]) == null && ExtendedImage.InnerImage.Convert(items[0]) == null) return false; - // Any remaining items are a <resolution> (incl. the `x` dppx alias, modeled by Resolution.GetUnit) - // and/or a type(<string>), in either order. for (var i = 1; i < items.Count; i++) if (ResolutionConverter.Convert(items[i]) == null && !IsTypeFunction(items[i])) return false; diff --git a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GradientConverter.cs b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GradientConverter.cs index 4090331f1..6475ccdaa 100644 --- a/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GradientConverter.cs +++ b/Source/HtmlRenderer/Core/CssEngine/ValueConverters/GradientConverter.cs @@ -20,11 +20,7 @@ public IPropertyValue Construct(Property[] properties) return properties.Guard<GradientValue>(); } - /// <summary> - /// The converter used to validate a colour stop's position. Length/percentage for linear and - /// radial gradients (the default); overridden by <see cref="ConicGradientConverter"/>, whose - /// angular stops are positioned by an &lt;angle&gt; or &lt;percentage&gt; instead. - /// </summary> + /// <summary>Validates a color stop position, a length or percentage unless overridden.</summary> protected virtual IValueConverter StopPositionConverter => LengthOrPercentConverter; private IPropertyValue[] ToGradientStops(List<List<Token>> values, int offset) @@ -55,13 +51,8 @@ private IPropertyValue ToGradientStop(List<Token> value) if (firstPosition != null) items.RemoveAt(items.Count - 1); } - // <color-stop-length> = <length-percentage>{1,2} (CSS Images 4 §3.5.1): a stop may carry two - // positions, equivalent to two same-colour stops, one at each position. Parsing right-to-left, - // the position taken above is the second (rightmost); a position immediately before it is the - // first. Both are kept, in source order, so the value round-trips as authored - the render - // layer (CssValueParser.ParseLinearGradient) reads the property's serialized value and already - // expands two positions into two stops, so dropping one here collapsed the solid band it draws. - // (For conic gradients the same two-position shorthand applies, positioned by <angle>.) + // CSS Images 4 §3.5.1: a stop can have two positions. Both are kept, the painter reads the + // serialized value. if (firstPosition != null && items.Count != 0) { var earlier = StopPositionConverter.Convert(items[items.Count - 1]); @@ -81,8 +72,7 @@ private IPropertyValue ToGradientStop(List<Token> value) if (color != null) items.RemoveAt(items.Count - 1); } - // The two-position form is only defined for a <color-stop>; a bare <length-percentage> with no - // colour is a <linear-color-hint>, which takes exactly one position. + // A position without a color is a color hint, which only takes one position if (secondPosition != null && color == null) return null; return items.Count == 0 ? new StopValue(color, firstPosition, secondPosition, value) : null; @@ -127,9 +117,7 @@ public TokenValue ExtractFor(string name) } } - // Factory used by derived classes to return an opaque placeholder IPropertyValue that echoes - // its tokens back as CssText - for a group whose grammar is accepted but not further modelled - // here (e.g. an "in <colorspace>" modifier, or a conic bare-0 / calc() angular stop position). + // A validated value that isn't modeled further, serialized as written protected static IPropertyValue CreatePlaceholder(IEnumerable<Token> tokens) => new PlaceholderValue(tokens); @@ -193,8 +181,7 @@ public LinearGradientConverter() protected override IPropertyValue ConvertFirstArgument(IEnumerable<Token> value) { - // "[ <angle> | to <side-or-corner> ] || <color-interpolation-method>" (CSS Images 4). Validate - // any "in <colorspace> [<hue-method>]" prelude, then validate what remains as the direction. + // CSS Images 4 §3.1 if (!ColorInterpolationMethodGrammar.TryExtractInterpolationMethod(value, out var remainder, out var hasIn)) return null; @@ -205,32 +192,24 @@ protected override IPropertyValue ConvertFirstArgument(IEnumerable<Token> value) } } - // Validates conic-gradient() at parse time (mirroring RadialGradientConverter), so a malformed - // value is dropped per CSS Cascade §4.1 instead of silently painting nothing at render. The - // grammar matches CssValueParser.ParseConicGradient exactly. + // CSS Images 4 §3.3.1 internal sealed class ConicGradientConverter : GradientConverter { private readonly IValueConverter _prelude; public ConicGradientConverter() { - // [ from <angle> ]? [ at <position> ]? — same combinator shape RadialGradientConverter - // uses for its own prelude, just with conic's from/at keywords. _prelude = WithAny( AngleConverter.StartsWithKeyword(Keywords.From).Option(), PointConverter.StartsWithKeyword(Keywords.At).Option(Point.Center)); } - // A conic <angular-color-stop> is positioned by <angle> | <percentage>, with the same bare-0 - // and calc()-family acceptance the renderer's TryParseConicAngle allows (AngleConverter already - // covers calc()-typed angles; a length such as 5px is correctly rejected). + // <angle-percentage> | <zero> protected override IValueConverter StopPositionConverter { get; } = AngleConverter.Or(PercentConverter).Or(new ZeroOrCalcPositionConverter()); protected override IPropertyValue ConvertFirstArgument(IEnumerable<Token> value) { - // "[ from <angle> ]? [ at <position> ]? || <color-interpolation-method>" — validate any - // "in <colorspace> [<hue-method>]" prelude, then validate what remains as from/at. if (!ColorInterpolationMethodGrammar.TryExtractInterpolationMethod(value, out var remainder, out var hasIn)) return null; @@ -242,8 +221,7 @@ protected override IPropertyValue ConvertFirstArgument(IEnumerable<Token> value) private IPropertyValue ConvertPrelude(IEnumerable<Token> value) { - // If the first non-whitespace token is an ident that is not a prelude keyword, this comma - // group is a color stop, not a prelude — return null so it flows to stop validation. + // A group starting with any other ident is the first color stop Token first = null; foreach (var t in value) { @@ -260,9 +238,7 @@ private IPropertyValue ConvertPrelude(IEnumerable<Token> value) return _prelude.Convert(value); } - // Accepts a bare `0` (the universal zero, which <angle> otherwise requires a unit for) or a - // single calc()-family function used as an angular stop position — matching the corresponding - // branches of CssValueParser.TryParseConicAngle, using the same primitives. + // A unitless 0, or any calc()-family function private sealed class ZeroOrCalcPositionConverter : IValueConverter { public IPropertyValue Convert(IEnumerable<Token> value) @@ -306,8 +282,7 @@ public RadialGradientConverter() protected override IPropertyValue ConvertFirstArgument(IEnumerable<Token> value) { - // "[ <ending-shape> || <size> ]? [ at <position> ]? || <color-interpolation-method>" — validate - // any "in <colorspace> [<hue-method>]" prelude, then validate what remains as shape/size/position. + // CSS Images 4 §3.2.1 if (!ColorInterpolationMethodGrammar.TryExtractInterpolationMethod(value, out var remainder, out var hasIn)) return null; @@ -319,9 +294,7 @@ protected override IPropertyValue ConvertFirstArgument(IEnumerable<Token> value) private IPropertyValue ConvertShape(IEnumerable<Token> value) { - // If the first non-whitespace token is an ident that is not a known gradient - // shape/size keyword, it must be a CSS color name (e.g. "red" in "red 0 8px"). - // In that case, this comma group is a color stop, not a shape/size modifier. + // A group starting with any other ident is the first color stop Token first = null; foreach (var t in value) { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssImages3Tests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssImages3Tests.cs index 48c472346..2a391e0a1 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssImages3Tests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssImages3Tests.cs @@ -18,6 +18,9 @@ public sealed class CssImages3Tests [TestMethod] [Spec("css-images-3", "#url-notation")] [Spec("css-images-3", "#default-sizing")] + [Spec("css-images-4", "#image-values")] + [Spec("css-images-4", "#url-notation")] + [Spec("css-images-4", "#sizing")] public void UrlImage_IsDrawnAtItsNaturalSize() { var (g, _) = Paint($"<div id='b' style='width:100px;height:100px;background-image:{Image};background-repeat:no-repeat'></div>"); @@ -27,6 +30,7 @@ public void UrlImage_IsDrawnAtItsNaturalSize() [TestMethod] [Spec("css-images-3", "#default-sizing")] + [Spec("css-images-4", "#sizing")] public void ImageWithOnlyAWidth_KeepsItsNaturalAspectRatio() { var root = TestImages.Layout($"<img id='i' src='{TestImages.Png(40, 30)}' style='width:80px'>"); @@ -55,6 +59,7 @@ public void Gradient_IsSizedToThePaddingBox() [DataRow("linear-gradient(90deg, red, blue)", 0, 25, 100, 25)] [DataRow("linear-gradient(0.25turn, red, blue)", 0, 25, 100, 25)] [Spec("css-images-3", "#linear-gradient-syntax")] + [Spec("css-images-4", "#linear-gradients")] public void LinearGradient_DirectionSetsTheGradientLine(string image, double x1, double y1, double x2, double y2) { var (_, gradient) = Paint($"<div id='b' style='width:100px;height:50px;background-image:{image}'></div>"); @@ -85,14 +90,16 @@ public void LinearGradient_CornerDirectionDependsOnTheBoxShape() { var (_, gradient) = Paint("<div id='b' style='width:200px;height:100px;background-image:linear-gradient(to bottom right, red, blue)'></div>"); + // Perpendicular to the top right to bottom left diagonal: direction (1, 2), length 400 / sqrt 5 Assert.AreEqual(60, gradient!.P1.X, 0.5); - Assert.AreEqual(10, gradient.P1.Y, 0.5); + Assert.AreEqual(-30, gradient.P1.Y, 0.5); Assert.AreEqual(140, gradient.P2.X, 0.5); - Assert.AreEqual(90, gradient.P2.Y, 0.5); + Assert.AreEqual(130, gradient.P2.Y, 0.5); } [TestMethod] - [Spec("css-images-3", "#radial-gradients")] + [Spec("css-images-3", "#radial-gradient-syntax")] + [Spec("css-images-4", "#radial-gradients")] public void RadialGradient_IsAccepted() { var (root, _) = PaintHarness.Layout(PaintHarness.Wrap("<div id='b' style='background-image:radial-gradient(circle at top left, red, blue)'></div>")); @@ -105,6 +112,7 @@ public void RadialGradient_IsAccepted() [DataRow("radial-gradient(red, blue)")] [DataRow("repeating-radial-gradient(red, blue 10px)")] [Spec("css-images-3", "#radial-gradients")] + [Spec("css-images-4", "#radial-gradients")] public void RadialGradient_IsPainted(string image) { var (g, _) = Paint($"<div id='b' style='width:100px;height:50px;background-image:{image}'></div>"); @@ -114,6 +122,7 @@ public void RadialGradient_IsPainted(string image) [TestMethod] [Spec("css-images-3", "#repeating-gradients")] + [Spec("css-images-4", "#repeating-gradients")] public void RepeatingLinearGradient_RepeatsTheStopsAlongTheLine() { var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:repeating-linear-gradient(to right, red, blue 25px)'></div>"); @@ -124,6 +133,7 @@ public void RepeatingLinearGradient_RepeatsTheStopsAlongTheLine() [TestMethod] [Spec("css-images-3", "#color-stop-syntax")] + [Spec("css-images-4", "#color-stop-syntax")] public void ColorStops_TakeLengthsPercentagesAndTwoPositions() { var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:linear-gradient(to right, red 10px, blue 40% 60%, red)'></div>"); @@ -144,6 +154,7 @@ public void ColorStops_ResolveEmAgainstTheElementsFontSize() [TestMethod] [Spec("css-images-3", "#coloring-gradient-line")] + [Spec("css-images-4", "#coloring-gradient-line")] public void ColorHint_ShiftsTheMidpointOfTheTransition() { var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:linear-gradient(to right, rgb(0,0,0), 25%, rgb(200,200,200))'></div>"); @@ -156,6 +167,7 @@ public void ColorHint_ShiftsTheMidpointOfTheTransition() [Ignore("Gap, see Spec/css-images-3.yml")] [TestMethod] [Spec("css-images-3", "#coloring-gradient-line")] + [Spec("css-images-4", "#coloring-gradient-line")] public void ColorsAreInterpolatedPremultiplied() { var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:linear-gradient(to right, red, 50%, transparent)'></div>"); @@ -167,6 +179,7 @@ public void ColorsAreInterpolatedPremultiplied() [TestMethod] [Spec("css-images-3", "#color-stop-fixup")] + [Spec("css-images-4", "#color-stop-fixup")] public void ColorStopFixup_DefaultsTheEndsAndSpacesStopsEvenly() { var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:linear-gradient(to right, red, blue, red 60%, blue, red)'></div>"); @@ -177,6 +190,7 @@ public void ColorStopFixup_DefaultsTheEndsAndSpacesStopsEvenly() [Ignore("Gap, see Spec/css-images-3.yml")] [TestMethod] [Spec("css-images-3", "#color-stop-fixup")] + [Spec("css-images-4", "#color-stop-fixup")] public void ColorStopFixup_ClampsAStopBeforeThePreviousOne() { var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:linear-gradient(to right, red 50%, blue 20%)'></div>"); @@ -191,6 +205,7 @@ public void ColorStopFixup_ClampsAStopBeforeThePreviousOne() [DataRow("none", 30, 35, 40, 30)] [DataRow("scale-down", 30, 35, 40, 30)] [Spec("css-images-3", "#the-object-fit")] + [Spec("css-images-4", "#the-object-fit")] public void ObjectFit_SizesTheImageInTheContentBox(string fit, double x, double y, double width, double height) { var (g, _) = Paint($"<img id='b' src='{TestImages.Png(40, 30)}' style='display:block;width:100px;height:100px;object-fit:{fit}'>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssImages4Tests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssImages4Tests.cs new file mode 100644 index 000000000..8423aef7a --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssImages4Tests.cs @@ -0,0 +1,178 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[TestClass] +public sealed class CssImages4Tests +{ + // The adapter decodes every image as 40x30 + private const string Image = "url(data:image/png;base64,iVBORw0KGgo=)"; + + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + private static readonly RColor Blue = RColor.FromArgb(0, 0, 255); + + [TestMethod] + [DataRow("png")] + [DataRow("gif")] + [DataRow("jpeg")] + [Spec("css-images-4", "#image-file-formats")] + public async Task RasterImage_IsDecoded(string format) + { + string uri; + using (var red = new System.Drawing.Bitmap(20, 10)) + using (var stream = new MemoryStream()) + { + using (var graphics = System.Drawing.Graphics.FromImage(red)) + graphics.Clear(System.Drawing.Color.Red); + red.Save(stream, format switch { "png" => System.Drawing.Imaging.ImageFormat.Png, "gif" => System.Drawing.Imaging.ImageFormat.Gif, _ => System.Drawing.Imaging.ImageFormat.Jpeg }); + uri = $"data:image/{format};base64,{Convert.ToBase64String(stream.ToArray())}"; + } + + using var page = (System.Drawing.Bitmap)await TheArtOfDev.HtmlRenderer.WinForms.HtmlRender.RenderToImageAsync( + $"<!DOCTYPE html><html><body style='margin:0'><img src='{uri}'></body></html>", + new System.Drawing.Size(40, 20), + System.Drawing.Color.White); + + var inside = page.GetPixel(15, 5); + Assert.IsTrue(inside.R > 200 && inside.G < 50 && inside.B < 50, $"inside the image: {inside}"); + Assert.AreEqual(System.Drawing.Color.White.ToArgb(), page.GetPixel(30, 5).ToArgb()); + } + + [Ignore("Gap, see Spec/css-images-4.yml")] + [TestMethod] + [Spec("css-images-4", "#image-file-formats")] + public void SvgImage_IsDecoded() + { + const string svg = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSc0MCcgaGVpZ2h0PSczMCcvPg=="; + var root = TestImages.Layout($"<img id='i' src='{svg}'>"); + + var rect = TestImages.ImageRect(root, "i"); + + Assert.AreEqual(40, rect.Width, 0.5); + Assert.AreEqual(30, rect.Height, 0.5); + } + + [Ignore("Gap, see Spec/css-images-4.yml")] + [TestMethod] + [DataRow($"image-set({Image} 1x, {Image} 2x)")] + [DataRow($"cross-fade({Image})")] + [Spec("css-images-4", "#image-set-notation")] + [Spec("css-images-4", "#cross-fade-painting")] + public void ImageFunction_PaintsTheImage(string image) + { + var (g, _) = Paint($"<div id='b' style='width:100px;height:100px;background-image:{image};background-repeat:no-repeat'></div>"); + + Assert.AreEqual(1, g.DrawImageCalls.Count); + } + + [Ignore("Gap, see Spec/css-images-4.yml")] + [TestMethod] + [Spec("css-images-4", "#element-notation")] + public void Element_PaintsTheReferencedElement() + { + var (g, _) = Paint("<div id='src' style='width:10px;height:10px;background-color:red'></div>" + + "<div id='b' style='width:100px;height:50px;background-image:element(#src)'></div>"); + + Assert.IsTrue(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(), "nothing is painted for the element() image"); + } + + [Ignore("Gap, see Spec/css-images-4.yml")] + [TestMethod] + [DataRow("conic-gradient(red, blue)")] + [DataRow("repeating-conic-gradient(red 0deg 30deg, blue 30deg 60deg)")] + [Spec("css-images-4", "#conic-gradients")] + [Spec("css-images-4", "#repeating-gradients")] + public void ConicGradient_IsPainted(string image) + { + var (g, _) = Paint($"<div id='b' style='width:100px;height:50px;background-image:{image}'></div>"); + + Assert.IsTrue(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any()); + } + + [TestMethod] + [Spec("css-images-4", "#linear-gradients")] + public void LinearGradient_InterpolationMethodAfterTheDirectionKeepsTheDirection() + { + var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:linear-gradient(to right in srgb, red, blue)'></div>"); + + Assert.AreEqual(0, gradient!.P1.X, 0.5); + Assert.AreEqual(100, gradient.P2.X, 0.5); + Assert.AreEqual(Red, gradient.Stops.First().Color); + Assert.AreEqual(Blue, gradient.Stops.Last().Color); + } + + [TestMethod] + [DataRow("linear-gradient(in srgb, red, blue)")] + [DataRow("linear-gradient(in srgb to right, red, blue)")] + [Spec("css-images-4", "#linear-gradients")] + public void LinearGradient_LeadingInterpolationMethodIsNotAColorStop(string image) + { + var (_, gradient) = Paint($"<div id='b' style='width:100px;height:50px;background-image:{image}'></div>"); + + Assert.AreEqual(Red, gradient!.Stops.First().Color); + Assert.AreEqual(Blue, gradient.Stops.Last().Color); + Assert.IsTrue(gradient.Stops.All(s => s.Color == Red || s.Color == Blue || s.Position > 0 && s.Position < 1)); + } + + [Ignore("Gap, see Spec/css-images-4.yml")] + [TestMethod] + [Spec("css-images-4", "#coloring-gradient-line")] + public void Gradient_InterpolatesInOklabByDefault() + { + var (_, gradient) = Paint("<div id='b' style='width:100px;height:50px;background-image:linear-gradient(to right, red, blue)'></div>"); + + // Halfway between red and blue Oklab gives about rgb(140, 83, 162), sRGB gives rgb(128, 0, 128) + Assert.IsTrue(ColorAt(gradient!, 0.5).G > 50, $"midpoint {ColorAt(gradient!, 0.5)}"); + } + + [Ignore("Gap, see Spec/css-images-4.yml")] + [TestMethod] + [Spec("css-images-4", "#color-stop-syntax")] + public void Gradient_ASingleColorStopPaintsASolidColor() + { + var (g, _) = Paint("<div id='b' style='width:100px;height:50px;background-image:linear-gradient(red)'></div>"); + + Assert.IsTrue(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == Red)); + } + + private static RColor ColorAt(CssImages3Tests.GradientAdapter.Gradient gradient, double position) + { + var stops = gradient.Stops.OrderBy(s => s.Position).ToList(); + var after = stops.FindIndex(s => s.Position >= position); + if (after <= 0) return stops[Math.Max(after, 0)].Color; + + var (c0, p0) = stops[after - 1]; + var (c1, p1) = stops[after]; + var t = p1 > p0 ? (position - p0) / (p1 - p0) : 0; + return RColor.FromArgb( + (int)Math.Round(c0.A + (c1.A - c0.A) * t), + (int)Math.Round(c0.R + (c1.R - c0.R) * t), + (int)Math.Round(c0.G + (c1.G - c0.G) * t), + (int)Math.Round(c0.B + (c1.B - c0.B) * t)); + } + + /// <summary>Lays out and paints the element with id "b", returns the draw calls and the last gradient brush.</summary> + private static (RecordingGraphics Graphics, CssImages3Tests.GradientAdapter.Gradient? Gradient) Paint(string body) + { + var adapter = new CssImages3Tests.GradientAdapter(); + var container = new HtmlContainerInt(adapter) + { + MaxSize = new RSize(1000, 4000), + Location = RPoint.Empty, + PageSize = new RSize(1000, 4000), + AvoidAsyncImagesLoading = true, + AvoidImagesLateLoading = true + }; + container.SetHtml(PaintHarness.Wrap(body)).GetAwaiter().GetResult(); + + using (var layoutGraphics = new RecordingGraphics(adapter)) + container.PerformLayout(layoutGraphics); + + var g = new RecordingGraphics(adapter); + PaintHarness.PaintBox(container, PaintHarness.FindById(container.Root!, "b")!, g); + return (g, adapter.Gradients.LastOrDefault()); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs index 366208f05..9b364286e 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs @@ -55,6 +55,7 @@ public void LogicalBorderShorthands_SetThePhysicalSides(string declaration, stri [Ignore("Gap, see Spec/css-borders-4.yml")] [TestMethod] [Spec("css-borders-4", "#border-color")] + [Spec("css-images-4", "#stripes")] public void BorderColor_StripesAreAccepted() { var property = CssConstructionFunctions.ParseDeclaration("border-top-color: stripes(red, blue)"); diff --git a/Source/Test/HtmlRenderer.Test/Css/CssImages4ParseTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssImages4ParseTests.cs new file mode 100644 index 000000000..934ab7189 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/CssImages4ParseTests.cs @@ -0,0 +1,178 @@ +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.Css; + +[TestClass] +public sealed class CssImages4ParseTests +{ + [TestMethod] + [DataRow("image-set(url(a.png) 1x, url(b.png) 2x)")] + [DataRow("image-set(\"a.png\" 1x type(\"image/png\"), \"b.png\" 192dpi)")] + [DataRow("image-set(linear-gradient(red, blue) 1x, url(a.png) 3x)")] + [Spec("css-images-4", "#image-set-notation")] + public void ImageSet_IsAccepted(string image) + { + Assert.IsTrue(Parse($"background-image: {image}").HasValue); + } + + [TestMethod] + [DataRow("image-set()")] + [DataRow("image-set(1x)")] + [Spec("css-images-4", "#image-set-notation")] + public void ImageSet_InvalidValuesAreRejected(string image) + { + Assert.IsFalse(Parse($"background-image: {image}").HasValue); + } + + [Ignore("Gap, see Spec/css-images-4.yml")] + [TestMethod] + [DataRow("image-set(url(a.png) 1x 2x)")] + [DataRow("image-set(url(a.png) type(\"image/png\") type(\"image/gif\"))")] + [Spec("css-images-4", "#image-set-notation")] + public void ImageSet_AnOptionTakesOneResolutionAndOneType(string image) + { + Assert.IsFalse(Parse($"background-image: {image}").HasValue); + } + + [Ignore("Gap, see Spec/css-images-4.yml")] + [TestMethod] + [DataRow("image(\"a.png\")")] + [DataRow("image(\"a.png\", red)")] + [DataRow("image(rgba(0, 0, 255, .5))")] + [DataRow("image(ltr \"arrow.png\")")] + [Spec("css-images-4", "#image-notation")] + [Spec("css-images-4", "#image-fallbacks")] + [Spec("css-images-4", "#color-images")] + [Spec("css-images-4", "#bidi-images")] + public void ImageFunction_IsAccepted(string image) + { + Assert.IsTrue(Parse($"background-image: {image}").HasValue); + } + + [TestMethod] + [DataRow("cross-fade(url(a.png) 30%, red)")] + [DataRow("cross-fade(url(a.png), url(b.png))")] + [DataRow("cross-fade(url(a.png))")] + [DataRow("cross-fade(50% linear-gradient(red, blue), url(a.png) 50%)")] + [Spec("css-images-4", "#cross-fade-function")] + public void CrossFade_IsAccepted(string image) + { + Assert.IsTrue(Parse($"background-image: {image}").HasValue); + } + + [Ignore("Gap, see Spec/css-images-4.yml")] + [TestMethod] + [DataRow("cross-fade(url(a.png) 150%, red)")] + [DataRow("cross-fade(url(a.png) -10%, red)")] + [DataRow("cross-fade(url(a.png) 10% 20%)")] + [Spec("css-images-4", "#cross-fade-function")] + public void CrossFade_InvalidPercentagesAreRejected(string image) + { + Assert.IsFalse(Parse($"background-image: {image}").HasValue); + } + + [TestMethod] + [DataRow("element(#src)", true)] + [DataRow("element(src)", false)] + [DataRow("element(#a, #b)", false)] + [Spec("css-images-4", "#element-notation")] + public void Element_TakesAnIdSelector(string image, bool valid) + { + Assert.AreEqual(valid, Parse($"background-image: {image}").HasValue); + } + + [TestMethod] + [DataRow("linear-gradient(0, red, blue)")] + [DataRow("linear-gradient(in oklab 45deg, red, blue)")] + [Spec("css-images-4", "#linear-gradients")] + public void LinearGradient_AcceptsZeroAndAnInterpolationMethod(string image) + { + Assert.IsTrue(Parse($"background-image: {image}").HasValue); + } + + [TestMethod] + [Spec("css-images-4", "#conic-gradient-syntax")] + [Spec("css-images-4", "#conic-color-stops")] + public void ConicGradient_AcceptsZeroAnglesAndPercentages() + { + Assert.IsTrue(Parse("background-image: conic-gradient(red 0, blue 0.5turn, green 100%)").HasValue); + } + + [Ignore("Gap, see Spec/css-images-4.yml")] + [TestMethod] + [Spec("css-images-4", "#conic-gradient-syntax")] + public void ConicGradient_AcceptsAZeroRotation() + { + Assert.IsTrue(Parse("background-image: conic-gradient(from 0, red, blue)").HasValue); + } + + [TestMethod] + [DataRow("radial-gradient(in lab farthest-side at left bottom, red, blue)")] + [DataRow("repeating-radial-gradient(in oklch longer hue, red, blue 10px)")] + [Spec("css-images-4", "#radial-color-interpolation")] + public void RadialGradient_AcceptsAnInterpolationMethod(string image) + { + Assert.IsTrue(Parse($"background-image: {image}").HasValue); + } + + [Ignore("Gap, see Spec/css-images-4.yml")] + [TestMethod] + [DataRow("radial-gradient(circle 50%, red, blue)")] + [DataRow("radial-gradient(closest-side farthest-side, red, blue)")] + [Spec("css-images-4", "#radial-size")] + public void RadialGradient_AcceptsTheExpandedSizes(string image) + { + Assert.IsTrue(Parse($"background-image: {image}").HasValue); + } + + [TestMethod] + [Spec("css-images-4", "#radial-size")] + public void RadialGradient_RejectsAnExtentMixedWithALength() + { + Assert.IsFalse(Parse("background-image: radial-gradient(ellipse closest-corner 20px, red, blue)").HasValue); + } + + [TestMethod] + [DataRow("linear-gradient(red)")] + [DataRow("linear-gradient(to right, red 10px)")] + [DataRow("conic-gradient(red)")] + [Spec("css-images-4", "#color-stop-syntax")] + public void Gradient_ASingleColorStopIsAccepted(string image) + { + Assert.IsTrue(Parse($"background-image: {image}").HasValue); + } + + [Ignore("Gap, see Spec/css-images-4.yml")] + [TestMethod] + [DataRow("linear-gradient(in a98-rgb, red, blue)")] + [DataRow("linear-gradient(in prophoto-rgb, red, blue)")] + [DataRow("linear-gradient(in rec2020, red, blue)")] + [Spec("css-images-4", "#linear-gradients")] + public void Gradient_AcceptsEveryPredefinedRgbInterpolationSpace(string image) + { + Assert.IsTrue(Parse($"background-image: {image}").HasValue); + } + + [Ignore("Gap, see Spec/css-images-4.yml")] + [TestMethod] + [DataRow("object-fit: contain scale-down")] + [DataRow("object-fit: scale-down cover")] + [Spec("css-images-4", "#the-object-fit")] + public void ObjectFit_CombinesScaleDownWithContainOrCover(string declaration) + { + Assert.IsTrue(CssConstructionFunctions.ParseDeclaration(declaration).HasValue); + } + + [Ignore("Gap, see Spec/css-images-4.yml")] + [TestMethod] + [DataRow("image-resolution: 300dpi")] + [DataRow("image-resolution: from-image 2dppx snap")] + [Spec("css-images-4", "#the-image-resolution")] + public void ImageResolution_IsAccepted(string declaration) + { + Assert.IsTrue(CssConstructionFunctions.ParseDeclaration(declaration).HasValue); + } + + private static Property Parse(string declaration) => CssConstructionFunctions.ParseDeclaration(declaration); +} diff --git a/Source/Test/HtmlRenderer.Test/Css/GradientTests.cs b/Source/Test/HtmlRenderer.Test/Css/GradientTests.cs index 6fe1f4cfc..6e38ef83e 100644 --- a/Source/Test/HtmlRenderer.Test/Css/GradientTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/GradientTests.cs @@ -341,11 +341,7 @@ public void BackgroundImageRepeatingRadialGradientFunky() Assert.IsFalse(backgroundImage.IsInitial); } - // <color-stop-length> = <length-percentage>{1,2} (CSS Images 4 §3.5.1). Both positions must survive - // serialization: the render layer (CssValueParser.ParseLinearGradient) reads the property's value - // and expands two positions into two stops, so dropping one here collapsed the solid band. This - // asserts the non-lossy value directly - the LinearGradientIntegrationTests only check that a - // stitching function is present, which is true with or without the dropped position. + // The painter reads the serialized value, so both positions of a stop have to survive it [TestMethod] [DataRow("background-image: linear-gradient(red 0 50%, blue 50% 100%)", "linear-gradient(rgb(255, 0, 0) 0 50%, rgb(0, 0, 255) 50% 100%)")] @@ -363,8 +359,6 @@ public void GradientTwoPositionColorStopRoundTrips(string snippet, string expect Assert.AreEqual(expected, backgroundImage.Value); } - // Single-position and no-position stops, plus a bare-position colour hint, are unaffected; three - // positions on one stop and two bare positions with no colour remain invalid. [TestMethod] [DataRow("background-image: linear-gradient(red 50%, blue)", true)] [DataRow("background-image: linear-gradient(red, 25%, blue)", true)] @@ -377,9 +371,6 @@ public void GradientColorStopValidity(string snippet, bool valid) Assert.AreEqual(valid, ((BackgroundImageProperty)property).HasValue); } - // conic-gradient() is now validated at parse time (issue #244) instead of accepting anything. - // Every form CssValueParser.ParseConicGradient renders must still be accepted here — these mirror - // the values in ConicGradientIntegrationTests / ColorSpaceGradientIntegrationTests. [TestMethod] [DataRow("conic-gradient(red, blue)")] [DataRow("conic-gradient(red, yellow, blue)")] @@ -389,8 +380,6 @@ public void GradientColorStopValidity(string snippet, bool valid) [DataRow("conic-gradient(red 0deg, blue 180deg, green 360deg)")] [DataRow("conic-gradient(red 0%, blue 50%, green 100%)")] [DataRow("conic-gradient(red calc(1turn * 0.35), blue calc(1turn * 0.7), green 1turn)")] - // A non-angle-typed calc() position (percentage here) is also accepted, matching the renderer's - // TryParseConicAngle calc branch which resolves any calc()-family against a full turn. [DataRow("conic-gradient(red calc(50%), blue)")] [DataRow("conic-gradient(red 0 90deg, blue 90deg 180deg, green 180deg 360deg)")] [DataRow("conic-gradient(rgba(255,0,0,0), red)")] @@ -398,6 +387,9 @@ public void GradientColorStopValidity(string snippet, bool valid) [DataRow("repeating-conic-gradient(from 45deg, red 0deg, blue 60deg)")] [DataRow("repeating-conic-gradient(#000 0 25%, #fff 25% 50%)")] [DataRow("conic-gradient(in oklch, red, blue)")] + [Spec("css-images-4", "#conic-gradient-syntax")] + [Spec("css-images-4", "#conic-color-stops")] + [Spec("css-images-4", "#repeating-gradients")] public void BackgroundImageConicGradientAccepted(string gradient) { var property = CssConstructionFunctions.ParseDeclaration($"background-image: {gradient}"); @@ -407,23 +399,18 @@ public void BackgroundImageConicGradientAccepted(string gradient) Assert.IsFalse(backgroundImage.IsInitial); } - // Malformed conic gradients must now be DROPPED at parse time (HasValue == false) so a prior - // background-image (or the initial value) wins, per CSS Cascade §4.1 — the bug in issue #244. [TestMethod] - [DataRow("conic-gradient(!!! 5px \"garbage\")")] // the issue's example - [DataRow("conic-gradient(red 5px, blue)")] // a <length> is not a valid angular position - [DataRow("conic-gradient(from red, blue, green)")] // "from" prelude needs an <angle>, not a colour - [DataRow("conic-gradient(red 5, blue)")] // a bare non-zero number is not a valid position + [DataRow("conic-gradient(!!! 5px \"garbage\")")] + [DataRow("conic-gradient(red 5px, blue)")] + [DataRow("conic-gradient(from red, blue, green)")] + [DataRow("conic-gradient(red 5, blue)")] + [Spec("css-images-4", "#conic-gradient-syntax")] public void BackgroundImageConicGradientRejected(string gradient) { var property = CssConstructionFunctions.ParseDeclaration($"background-image: {gradient}"); Assert.IsFalse(((BackgroundImageProperty)property).HasValue); } - // A gradient's "in <color-interpolation-method>" prelude (CSS Images 4 §3.1) is now validated at - // parse time (issue #245) instead of accepting any comma group that merely contains an "in" ident. - // Every space PeachPDF supports - alone, with a polar hue method, and combined with a direction/ - // shape in either order - must stay accepted, across linear/radial/conic. [TestMethod] [DataRow("linear-gradient(in srgb, red, blue)")] [DataRow("linear-gradient(in srgb-linear, red, blue)")] @@ -447,6 +434,8 @@ public void BackgroundImageConicGradientRejected(string gradient) [DataRow("conic-gradient(in oklab, red, blue)")] [DataRow("conic-gradient(in oklch from 45deg, red, blue)")] [DataRow("conic-gradient(from 45deg in oklch longer hue, red, blue)")] + [Spec("css-images-4", "#linear-gradients")] + [Spec("css-images-4", "#radial-color-interpolation")] public void BackgroundImageGradientInterpolationMethodAccepted(string gradient) { var property = CssConstructionFunctions.ParseDeclaration($"background-image: {gradient}"); @@ -456,21 +445,17 @@ public void BackgroundImageGradientInterpolationMethodAccepted(string gradient) Assert.IsFalse(backgroundImage.IsInitial); } - // A malformed or unsupported "in <color-interpolation-method>" prelude must now be DROPPED at parse - // time (HasValue == false), instead of being accepted unvalidated and silently ignored (issue #245). [TestMethod] - [DataRow("linear-gradient(in nonsense garbage, red, blue)")] // the issue's example + [DataRow("linear-gradient(in nonsense garbage, red, blue)")] [DataRow("linear-gradient(in, red, blue)")] // "in" with no color space [DataRow("linear-gradient(in oklab longer hue, red, blue)")] // hue method on a rectangular space [DataRow("linear-gradient(in oklch longer, red, blue)")] // hue direction without the "hue" keyword [DataRow("linear-gradient(in oklab to bogus, red, blue)")] // the direction half is invalid - [DataRow("linear-gradient(in a98-rgb, red, blue)")] // valid CSS space, unsupported here - [DataRow("linear-gradient(in prophoto-rgb, red, blue)")] - [DataRow("linear-gradient(in rec2020, red, blue)")] [DataRow("radial-gradient(in bogus, red, blue)")] [DataRow("radial-gradient(in oklab nonsense, red, blue)")] // in <space> then an invalid shape [DataRow("conic-gradient(in bogus, red, blue)")] [DataRow("conic-gradient(in oklab from red, red, blue)")] // in <space> then an invalid from-angle + [Spec("css-images-4", "#linear-gradients")] public void BackgroundImageGradientInterpolationMethodRejected(string gradient) { var property = CssConstructionFunctions.ParseDeclaration($"background-image: {gradient}"); diff --git a/Source/Test/HtmlRenderer.Test/Css/ObjectSizingPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/ObjectSizingPropertyTests.cs index 26ed1d98f..88f0a99d2 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ObjectSizingPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ObjectSizingPropertyTests.cs @@ -38,6 +38,7 @@ public void ObjectFitScaledownIllegal() [TestMethod] [Spec("css-images-3", "#the-object-fit")] + [Spec("css-images-4", "#the-object-fit")] public void ObjectFitScaleDownLegal() { var property = ParseDeclaration("object-fit : scale-DOWN"); @@ -53,6 +54,7 @@ public void ObjectFitScaleDownLegal() [TestMethod] [Spec("css-images-3", "#the-object-fit")] + [Spec("css-images-4", "#the-object-fit")] public void CssObjectFitCoverLegal() { var property = ParseDeclaration("object-fit : cover"); diff --git a/Spec/css-images-4.yml b/Spec/css-images-4.yml index 699e77d46..5d4422a2b 100644 --- a/Spec/css-images-4.yml +++ b/Spec/css-images-4.yml @@ -5,148 +5,180 @@ snapshot: https://www.w3.org/TR/2025/WD-css-images-4-20250930/ sections: '#intro': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown + status: n/a '#image-values': title: '2 2D Image Values: the <image> type' - status: unknown + status: partial + gaps: + - image() isn't supported, image-set(), cross-fade() and element() are parsed but never painted + - "Only 'background-image' paints images, see css-images-3 #image-values" '#image-file-formats': title: 2.1 Image File Formats - status: unknown + status: partial + gaps: + - SVG images aren't decoded, they're treated as failed images '#url-notation': title: '2.2 Image References: the url() notation' - status: unknown + status: supported '#fetching-images': title: 2.3 Fetching External Images - status: unknown + status: n/a '#image-set-notation': title: '2.4 Resolution/Type Negotiation: the image-set() notation' - status: unknown + status: parsed-only + gaps: + - image-set() is never painted, not even its first option + - An option with two resolutions or two type() functions is accepted '#image-notation': title: '2.5 Image Fallbacks and Annotations: the image() notation' - status: unknown + status: unsupported + gaps: + - image() isn't parsed, a declaration using it is dropped '#image-fallbacks': title: 2.5.1 Image Fallbacks - status: unknown + status: unsupported '#image-fragments': title: 2.5.2 Image Fragments - status: unknown + status: unsupported '#color-images': title: 2.5.3 Solid-color Images - status: unknown + status: unsupported '#bidi-images': title: 2.5.4 Bidi-sensitive Images - status: unknown + status: unsupported '#cross-fade-function': title: '2.6 Combining images: the cross-fade() notation' - status: unknown + status: parsed-only + gaps: + - Percentages outside 0% to 100% and more than one percentage per argument are accepted '#cross-fade-sizing': title: 2.6.1 cross-fade() Sizing - status: unknown + status: unsupported '#cross-fade-painting': title: 2.6.2 cross-fade() Painting - status: unknown + status: unsupported + gaps: + - cross-fade() is never painted '#cross-fade-complex': title: 2.6.3 Simplifying Complex cross-fade() - status: unknown + status: unsupported '#element-notation': title: '2.7 Using Elements as Images: the element() notation' - status: unknown + status: parsed-only + gaps: + - element() is never painted '#paint-sources': title: 2.7.1 Paint Sources - status: unknown + status: unsupported '#elementsources': title: '2.7.2 Using Out-Of-Document Sources: the ElementSources interface' - status: unknown + status: n/a '#element-cycles': title: 2.7.3 Cycle Detection - status: unknown + status: unsupported '#gradients': title: 3 Gradients - status: unknown + status: partial '#linear-gradients': title: '3.1 Linear Gradients: the linear-gradient() notation' - status: unknown + status: partial + gaps: + - The interpolation method is ignored, see 3.5.2 + - "a98-rgb, prophoto-rgb and rec2020 are rejected, see css-color-4 #interpolation-space" + - "A corner direction doesn't depend on the box's aspect ratio, see css-images-3 #linear-gradient-syntax" '#color-interpolation': title: '3.1.1 Effects of color space on interpolation: examples' - status: unknown + status: n/a '#radial-gradients': title: '3.2 Radial Gradients: the radial-gradient() notation' - status: unknown + status: parsed-only + gaps: + - "Radial gradients aren't painted, see css-images-3 #radial-gradients" '#radial-color-interpolation': title: 3.2.1 Adding <color-interpolation-method> - status: unknown + status: parsed-only '#radial-size': title: 3.2.2 Expanding <radial-size> - status: unknown + status: unsupported + gaps: + - Two extent keywords and a percentage circle radius are rejected '#conic-gradients': title: '3.3 Conic Gradients: the conic-gradient() notation' - status: unknown + status: parsed-only + gaps: + - Conic gradients are parsed but never painted '#conic-gradient-syntax': title: 3.3.1 conic-gradient() Syntax - status: unknown + status: parsed-only + gaps: + - "'from 0' is rejected, the zero needs a unit" '#conic-color-stops': title: 3.3.2 Placing Color Stops - status: unknown + status: parsed-only '#conic-gradient-examples': title: 3.3.3 Conic Gradient Examples - status: unknown + status: n/a '#repeating-gradients': title: '3.4 Repeating Gradients: the repeating-linear-gradient(), repeating-radial-gradient(), and repeating-conic-gradient() notations' - status: unknown + status: partial + gaps: + - repeating-radial-gradient() and repeating-conic-gradient() aren't painted '#gradient-colors': title: 3.5 Defining Gradient Color - status: unknown + status: partial '#color-stop-syntax': title: 3.5.1 Color Stop Lists - status: unknown + status: partial + gaps: + - A gradient with a single color stop is accepted but not painted + - "em positions are resolved against 16px, see css-images-3 #color-stop-syntax" '#coloring-gradient-line': title: 3.5.2 Coloring the Gradient Line - status: unknown + status: partial + gaps: + - Colors are interpolated in sRGB, not in Oklab or the given color space + - "Colors aren't interpolated with premultiplied alpha, see css-images-3 #coloring-gradient-line" '#color-stop-fixup': title: 3.5.3 Color Stop “Fixup” - status: unknown + status: partial + gaps: + - "A stop positioned before a previous stop isn't moved up to it, see css-images-3 #color-stop-fixup" '#stripes': title: '4 1D Image Values: the <image-1D> type and stripes() notation' - status: unknown + status: unsupported + gaps: + - "stripes() isn't parsed, see css-borders-4 #border-color" '#sizing': title: 5 Sizing Images and Objects in CSS - status: unknown + status: partial + gaps: + - "Gradients aren't sized to the background positioning area and nothing is sized with contain or cover, see css-images-3 #sizing" '#the-object-fit': title: '5.1 Sizing Objects: the object-fit property' - status: unknown + status: parsed-only + gaps: + - "'object-fit' is ignored, see css-images-3 #the-object-fit" + - "'contain scale-down' and 'scale-down cover' are rejected" '#image-processing': title: 6 Image Processing - status: unknown + status: unsupported '#the-image-resolution': title: '6.1 Overriding Image Resolutions: the image-resolution property' - status: unknown + status: unsupported + gaps: + - "'image-resolution' isn't a known property" '#interpolation': title: 7 Interpolation - status: unknown - '#interpolating-images': - title: 7.1 Interpolating <image> - status: unknown - '#interpolating-image-combinations': - title: 7.2 Interpolating cross-fade() - status: unknown - '#interpolating-gradients': - title: 7.3 Interpolating <gradient> - status: unknown - '#interpolating-stripes': - title: 7.4 Interpolating stripes() - status: unknown + status: n/a '#serialization': title: 8 Serialization - status: unknown + status: n/a '#privacy': title: 9 Privacy Considerations - status: unknown + status: n/a '#security': title: 10 Security Considerations - status: unknown + status: n/a '#changes': title: 11 Changes - status: unknown + status: n/a From b2db3809c9f2896587cc999d9bf0b6a829719419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:38:41 +0100 Subject: [PATCH 118/150] Verify and document CSS Color Adjustment compliance --- .../CssModules/CssColorAdjustTests.cs | 45 ++++++++++++++++ Spec/css-color-adjust-1.yml | 53 ++++++++++--------- 2 files changed, 73 insertions(+), 25 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssColorAdjustTests.cs diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssColorAdjustTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssColorAdjustTests.cs new file mode 100644 index 000000000..922623fe9 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssColorAdjustTests.cs @@ -0,0 +1,45 @@ +using HtmlRenderer.Test.CssEngineSupport; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssColorAdjustTests +{ + [Ignore("Gap, see Spec/css-color-adjust-1.yml")] + [TestMethod] + [DataRow("normal")] + [DataRow("light dark")] + [DataRow("only light")] + [DataRow("dark future-scheme")] + [Spec("css-color-adjust-1", "#color-scheme-prop")] + public void ColorScheme_IsAccepted(string value) + { + var style = CssConstructionFunctions.ParseDeclarations($"color-scheme: {value}"); + + Assert.AreEqual(value, style.GetPropertyValue("color-scheme")); + } + + [Ignore("Gap, see Spec/css-color-adjust-1.yml")] + [TestMethod] + [DataRow("forced-color-adjust", "preserve-parent-color")] + [Spec("css-color-adjust-1", "#forced-color-adjust-prop")] + public void ForcedColorAdjust_IsAccepted(string property, string value) + { + var style = CssConstructionFunctions.ParseDeclarations($"{property}: {value}"); + + Assert.AreEqual(value, style.GetPropertyValue(property)); + } + + [Ignore("Gap, see Spec/css-color-adjust-1.yml")] + [TestMethod] + [DataRow("print-color-adjust: exact", "print-color-adjust", "exact")] + [DataRow("color-adjust: exact", "print-color-adjust", "exact")] + [Spec("css-color-adjust-1", "#print-color-adjust")] + [Spec("css-color-adjust-1", "#color-adjust")] + public void PrintColorAdjust_IsAccepted(string declaration, string property, string value) + { + var style = CssConstructionFunctions.ParseDeclarations(declaration); + + Assert.AreEqual(value, style.GetPropertyValue(property)); + } +} diff --git a/Spec/css-color-adjust-1.yml b/Spec/css-color-adjust-1.yml index 8fc377da9..26e0e9d4c 100644 --- a/Spec/css-color-adjust-1.yml +++ b/Spec/css-color-adjust-1.yml @@ -5,58 +5,61 @@ snapshot: https://www.w3.org/TR/2025/CR-css-color-adjust-1-20251216/ sections: '#intro': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown + status: n/a '#preferred': title: 2 Preferred Color Schemes - status: unknown + status: unsupported '#color-scheme-prop': title: '2.1 Opting Into a Preferred Color Scheme: the color-scheme property' - status: unknown + status: unsupported + gaps: + - "'color-scheme' isn't a known property, only the prefers-color-scheme media feature follows the adapter's color scheme" '#color-scheme-effect': title: 2.2 Effects of the Used Color Scheme - status: unknown + status: unsupported + gaps: + - The used color scheme doesn't change the canvas or the default colors of form controls '#color-scheme-override': title: 2.3 Overriding the Color Scheme - status: unknown + status: n/a '#forced': title: 3 Forced Color Palettes - status: unknown + status: unsupported + gaps: + - There's no forced colors mode, a high contrast system palette is never applied '#forced-colors-properties': title: 3.1 Properties Affected by Forced Colors Mode - status: unknown + status: unsupported '#forced-color-adjust-prop': title: '3.2 Opting Out of a Forced Color Palette: the forced-color-adjust property' - status: unknown + status: unsupported + gaps: + - "'forced-color-adjust' isn't a known property" '#perf': title: 4 Performance-based Color Adjustments - status: unknown + status: unsupported '#print-color-adjust': title: '4.1 Ink Economy: the print-color-adjust property' - status: unknown + status: unsupported + gaps: + - "'print-color-adjust' isn't a known property, colors are never adjusted for printing, as with 'exact'" '#color-adjust': title: 4.2 The color-adjust Shorthand - status: unknown + status: unsupported + gaps: + - "'color-adjust' isn't a known property" '#color-adjust-emulation': title: 5 Emulation - status: unknown - '#emulate-forced-colors-mode': - title: 5.1 Emulate Forced Colors Mode - status: unknown - '#forced-color-palettes': - title: 5.2 Forced Colors Mode Color Palettes - status: unknown + status: n/a '#privacy': title: 6 Privacy Considerations - status: unknown + status: n/a '#security': title: 7 Security Considerations - status: unknown + status: n/a '#acknowledgements': title: 8 Acknowledgements - status: unknown + status: n/a '#changes': title: 9 Changes - status: unknown + status: n/a From 81b3fadea95b3cf5025082f43911c1440d731f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:43:03 +0100 Subject: [PATCH 119/150] Verify and document CSS Shapes compliance --- .../Core/CssEngine/BasicShapeGrammar.cs | 7 +-- .../CssModules/CssShapesTests.cs | 24 ++++++++ .../Css/BasicShapeGrammarTests.cs | 40 +++++++++++-- .../CssModules/CssShapesTests.cs | 26 ++++++++ Spec/css-shapes-1.yml | 60 ++++++++++--------- 5 files changed, 121 insertions(+), 36 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssShapesTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssShapesTests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/BasicShapeGrammar.cs b/Source/HtmlRenderer/Core/CssEngine/BasicShapeGrammar.cs index 3c18f8a12..451ee9959 100644 --- a/Source/HtmlRenderer/Core/CssEngine/BasicShapeGrammar.cs +++ b/Source/HtmlRenderer/Core/CssEngine/BasicShapeGrammar.cs @@ -63,9 +63,7 @@ internal sealed class ParsedBasicShape /// each an authored length-percentage component string.</summary> public IReadOnlyList<string> InsetEdges { get; private set; } - /// <summary>Whether an <c>inset(... round &lt;border-radius&gt;)</c> corner radius was present. - /// The radius itself is captured in <see cref="InsetRoundRadius"/> but not rendered - inset is - /// drawn as a rectangle (see the resolver / docs).</summary> + /// <summary>Whether an <c>inset(... round &lt;border-radius&gt;)</c> corner radius was present.</summary> public bool InsetHasRound { get; private set; } public IReadOnlyList<Token> InsetRoundRadius { get; private set; } @@ -357,8 +355,7 @@ private static bool TryShapeRadius(Token token, out ShapeRadius radius) if (IsLengthPercentage(token)) { - // A <shape-radius> is a non-negative <length-percentage> (CSS Shapes 1 §3.2); a negative - // radius makes the whole clip-path value invalid. + // <length-percentage [0,∞]>, CSS Shapes 1 §3.1 var unitToken = token as UnitToken; var numberToken = token as NumberToken; if ((unitToken != null && unitToken.Value < 0f) || (numberToken != null && numberToken.Value < 0f)) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssShapesTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssShapesTests.cs new file mode 100644 index 000000000..eecb0e711 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssShapesTests.cs @@ -0,0 +1,24 @@ +using HtmlRenderer.IntegrationTest.TestSupport; + +namespace HtmlRenderer.IntegrationTest.CssModules; + +// Parallel full layout passes race on the adapter singletons. +[TestClass] +[DoNotParallelize] +public sealed class CssShapesTests +{ + [Ignore("Gap, see Spec/css-shapes-1.yml")] + [TestMethod] + [DataRow("inset(0 50% 0 0)")] + [DataRow("polygon(0 0, 50% 0, 50% 100%, 0 100%)")] + [Spec("css-shapes-1", "#shape-outside-property")] + [Spec("css-shapes-1", "#relation-to-box-model-and-float-behavior")] + public void ShapeOutside_NarrowsTheFloatArea(string shape) + { + var root = LayoutHarness.Layout(LayoutHarness.Wrap( + $"<div style='width:300px'><div style='float:left;width:100px;height:100px;shape-outside:{shape}'></div>" + + "<span id='t' style='display:inline-block;width:10px;height:10px'></span></div>"), maxWidth: 400).Root; + + Assert.AreEqual(50, LayoutHarness.FindById(root, "t")!.Rectangles.Values.Single().X, 0.5); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/BasicShapeGrammarTests.cs b/Source/Test/HtmlRenderer.Test/Css/BasicShapeGrammarTests.cs index dced5c1f8..7f98ffd47 100644 --- a/Source/Test/HtmlRenderer.Test/Css/BasicShapeGrammarTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/BasicShapeGrammarTests.cs @@ -7,6 +7,7 @@ namespace HtmlRenderer.Test.Css; /// <summary> /// The &lt;basic-shape&gt; grammar of <see cref="BasicShapeGrammar"/> and the clip-path values built on it. +/// The <see cref="BasicShapeGrammar"/> and the <c>clip-path</c> values it validates. /// </summary> [TestClass] public sealed class BasicShapeGrammarTests @@ -17,6 +18,7 @@ private static BasicShapeGrammar.ParsedBasicShape Parse(string value) => // ─── polygon() ───────────────────────────────────────────────────────── [TestMethod] + [Spec("css-shapes-1", "#supported-basic-shapes")] public void Polygon_ThreePoints_ParsesWithDefaultNonzeroFill() { var shape = Parse("polygon(0 0, 100% 0, 50% 100%)"); @@ -100,6 +102,7 @@ public void Polygon_Malformed_ReturnsNull(string value) [DataRow("inset(10px 20px)", "10px", "20px", "10px", "20px")] [DataRow("inset(10px 20px 30px)", "10px", "20px", "30px", "20px")] [DataRow("inset(1px 2px 3px 4px)", "1px", "2px", "3px", "4px")] + [Spec("css-shapes-1", "#supported-basic-shapes")] public void Inset_ShorthandFill_ExpandsToTopRightBottomLeft(string value, string top, string right, string bottom, string left) { var shape = Parse(value); @@ -202,6 +205,7 @@ public void Circle_PositionRightBottom_ResolveToHundredPercent() [DataRow("circle(-5px)")] // negative <shape-radius> is invalid [DataRow("circle(-10% at center)")] [DataRow("ellipse(-5px 10px)")] // a negative axis radius invalidates the whole shape + [Spec("css-shapes-1", "#supported-basic-shapes")] public void Circle_Malformed_ReturnsNull(string value) { Assert.IsNull(Parse(value)); @@ -221,6 +225,7 @@ public void Ellipse_Empty_DefaultsBothRadiiClosestSide() } [TestMethod] + [Spec("css-shapes-1", "#supported-basic-shapes")] public void Ellipse_TwoRadii_AndPosition() { var shape = Parse("ellipse(40px 20% at 25% 75%)"); @@ -245,20 +250,47 @@ public void Ellipse_MixedKeywordRadii() } [TestMethod] - [DataRow("ellipse(40px)")] // exactly one radius is invalid - [DataRow("ellipse(40px 20px 10px)")] // three radii + [DataRow("ellipse(40px 20px 10px)")] + [Spec("css-shapes-1", "#supported-basic-shapes")] public void Ellipse_Malformed_ReturnsNull(string value) { Assert.IsNull(Parse(value)); } - // ─── top-level rejection ──────────────────────────────────────────────── + [Ignore("Gap, see Spec/css-shapes-1.yml")] + [TestMethod] + [DataRow("ellipse(40px)")] + [DataRow("ellipse(closest-side)")] + [Spec("css-shapes-1", "#supported-basic-shapes")] + public void Ellipse_OneRadiusIsValid(string value) + { + Assert.IsNotNull(Parse(value)); + } + + [Ignore("Gap, see Spec/css-shapes-1.yml")] + [TestMethod] + [DataRow("rect(0 auto 10px 5%)")] + [DataRow("xywh(10px 10px 50% 20px round 4px)")] + [DataRow("polygon(round 5px, 0 0, 100% 0, 50% 100%)")] + [DataRow("path(\"M 0 0 L 10 0 L 0 10 Z\")")] + [Spec("css-shapes-1", "#supported-basic-shapes")] + public void RectXywhPathAndRoundedPolygon_AreBasicShapes(string value) + { + Assert.IsNotNull(Parse(value)); + } + + [Ignore("Gap, see Spec/css-shapes-1.yml")] + [TestMethod] + [Spec("css-shapes-1", "#shape-function")] + public void ShapeFunction_IsABasicShape() + { + Assert.IsNotNull(Parse("shape(from 0 0, line to 100% 0, line to 50% 100%, close)")); + } [TestMethod] [DataRow("none")] [DataRow("banana")] [DataRow("url(#clip)")] - [DataRow("rect(0 0 0 0)")] public void NonBasicShape_ReturnsNull(string value) { Assert.IsNull(Parse(value)); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssShapesTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssShapesTests.cs new file mode 100644 index 000000000..854187168 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssShapesTests.cs @@ -0,0 +1,26 @@ +using HtmlRenderer.Test.CssEngineSupport; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssShapesTests +{ + [Ignore("Gap, see Spec/css-shapes-1.yml")] + [TestMethod] + [DataRow("shape-outside", "circle(50%)")] + [DataRow("shape-outside", "inset(10px round 5px) margin-box")] + [DataRow("shape-outside", "url(shape.png)")] + [DataRow("shape-margin", "10px")] + [DataRow("shape-image-threshold", "0.5")] + [Spec("css-shapes-1", "#shape-outside-property")] + [Spec("css-shapes-1", "#shapes-from-box-values")] + [Spec("css-shapes-1", "#shapes-from-image")] + [Spec("css-shapes-1", "#shape-margin-property")] + [Spec("css-shapes-1", "#shape-image-threshold-property")] + public void ShapeProperties_AreAccepted(string property, string value) + { + var style = CssConstructionFunctions.ParseDeclarations($"{property}: {value}"); + + Assert.AreNotEqual("", style.GetPropertyValue(property)); + } +} diff --git a/Spec/css-shapes-1.yml b/Spec/css-shapes-1.yml index 50accd964..c04ee555c 100644 --- a/Spec/css-shapes-1.yml +++ b/Spec/css-shapes-1.yml @@ -5,64 +5,70 @@ snapshot: https://www.w3.org/TR/2025/CRD-css-shapes-1-20250612/ sections: '#intro': title: 1 Introduction - status: unknown - '#module-interactions': - title: 1.1 Module Interactions - status: unknown - '#values': - title: 1.2 Values - status: unknown - '#terminology': - title: 1.3 Terminology - status: unknown + status: n/a '#relation-to-box-model-and-float-behavior': title: 2 Relation to the box model and float behavior - status: unknown + status: unsupported + gaps: + - Floats always use their rectangular margin box as the float area '#basic-shape-functions': title: 3 Basic Shapes - status: unknown + status: parsed-only + gaps: + - "Basic shapes are only parsed for 'clip-path', which isn't painted, nothing in this module uses them" '#supported-basic-shapes': title: 3.1 Supported Shapes - status: unknown + status: parsed-only + gaps: + - rect(), xywh(), path() and a rounded polygon() are rejected + - An ellipse() with a single radius is rejected '#shape-function': title: 3.1.1 The shape() Function - status: unknown + status: unsupported + gaps: + - shape() is rejected '#shape-examples': title: 3.1.1.1 Using shape() to create responsive, parametric speech bubble - status: unknown + status: n/a '#interpolating-shape': title: 3.1.1.2 Interpolating the shape() Function - status: unknown + status: n/a '#basic-shape-computed-values': title: 3.2 Computed Values of Basic Shapes - status: unknown + status: unsupported '#basic-shape-serialization': title: 3.3 Serialization of Basic Shapes - status: unknown + status: n/a '#basic-shape-interpolation': title: 3.4 Interpolation of Basic Shapes - status: unknown + status: n/a '#shapes-from-image': title: 4 Shapes from Image - status: unknown + status: unsupported '#shapes-from-box-values': title: 5 Shapes from Box Values - status: unknown + status: unsupported '#declaring-shapes': title: 6 Declaring Shapes - status: unknown + status: unsupported '#shape-outside-property': title: '6.1 Float Area Shape: the shape-outside property' - status: unknown + status: unsupported + gaps: + - "'shape-outside' isn't a known property" '#shape-image-threshold-property': title: '6.2 Choosing Image Pixels: the shape-image-threshold property' - status: unknown + status: unsupported + gaps: + - "'shape-image-threshold' isn't a known property" '#shape-margin-property': title: '6.3 Expanding a Shape: the shape-margin property' - status: unknown + status: unsupported + gaps: + - "'shape-margin' isn't a known property" '#privacy': title: 7 Privacy Considerations - status: unknown + status: n/a '#security': title: 8 Security Considerations - status: unknown + status: n/a From e6402414fc1df1270d703b52030eafd0ddbcd42c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:23:34 +0100 Subject: [PATCH 120/150] Verify and document CSS Basic User Interface Level 4 compliance --- .../HtmlRenderer/Core/Dom/CssBoxProperties.cs | 4 +- .../Painting/CssUi3Tests.cs | 3 + .../OutlineStylePaintIntegrationTests.cs | 10 ++ .../Test/HtmlRenderer.Test/Css/CssUi3Tests.cs | 4 + .../Css/OutlinePropertyTests.cs | 5 + .../CssModules/CssUi4Tests.cs | 125 +++++++++++++++++ Spec/css-ui-4.yml | 126 +++++++++--------- 7 files changed, 213 insertions(+), 64 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssUi4Tests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index a43fa9286..868e5343b 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs @@ -768,8 +768,8 @@ public double ActualOutlineWidth } /// <summary> - /// The resolved paint color for <c>outline-color</c>. Without support for <c>invert</c> the initial - /// value is the element's <c>color</c>, CSS 2.1 §18.4. + /// The resolved paint color for <c>outline-color</c>, CSS UI 4 §3.4. The initial value and <c>invert</c> + /// paint as <c>currentcolor</c>. /// </summary> public RColor ActualOutlineColor { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssUi3Tests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssUi3Tests.cs index a5a1e8aa3..28aafdcc6 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssUi3Tests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssUi3Tests.cs @@ -35,6 +35,7 @@ public void BoxSizingBorderBox_ContentSizeIsNeverNegative() [TestMethod] [Spec("css-ui-3", "#outline-width")] + [Spec("css-ui-4", "#outline-width")] public void OutlineWidth_TakesTheBorderWidthKeywords() { var g = Paint("<div id='b' style='width:100px;height:50px;outline:thick solid rgb(51,51,51)'>x</div>"); @@ -46,6 +47,7 @@ public void OutlineWidth_TakesTheBorderWidthKeywords() [Ignore("Gap, see Spec/css-ui-3.yml")] [TestMethod] [Spec("css-ui-3", "#outline-width")] + [Spec("css-ui-4", "#outline-width")] public void OutlineWidth_InitialValueIsMedium() { var g = Paint("<div id='b' style='width:100px;height:50px;outline-style:solid;outline-color:rgb(51,51,51)'>x</div>"); @@ -57,6 +59,7 @@ public void OutlineWidth_InitialValueIsMedium() [Ignore("Gap, see Spec/css-ui-3.yml")] [TestMethod] [Spec("css-ui-3", "#outline-props")] + [Spec("css-ui-4", "#outline")] public void Outline_FollowsTheBorderRadius() { var g = Paint("<div id='b' style='width:100px;height:50px;border-radius:10px;outline:5px solid rgb(51,51,51)'>x</div>"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs index 37dba369b..e61d5240e 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs @@ -11,6 +11,7 @@ public sealed class OutlineStylePaintIntegrationTests [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] [Spec("css-ui-3", "#outline")] + [Spec("css-ui-4", "#outline")] public void SolidOutline_PaintsAFourSidedRingAroundTheBorderBox() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -31,6 +32,7 @@ public void SolidOutline_PaintsAFourSidedRingAroundTheBorderBox() [Ignore("Gap, see Spec/css-ui-3.yml")] [TestMethod] [Spec("css-ui-3", "#outline-style")] + [Spec("css-ui-4", "#outline-style")] public void OutlineAuto_PaintsTheSameAsSolid() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -47,6 +49,7 @@ public void OutlineAuto_PaintsTheSameAsSolid() [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] [Spec("css-ui-3", "#outline-color")] + [Spec("css-ui-4", "#outline-color")] public void OutlineColorUnset_FallsBackToCurrentColor() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -82,6 +85,7 @@ public void Outline_PaintsAfterBorder() [DataRow("hidden")] [Spec("css21", "ui.html#dynamic-outlines")] [Spec("css-ui-3", "#outline-style")] + [Spec("css-ui-4", "#outline-style")] public void OutlineStyleNoneOrHidden_PaintsNothing(string style) { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -96,6 +100,7 @@ public void OutlineStyleNoneOrHidden_PaintsNothing(string style) [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] [Spec("css-ui-3", "#outline-width")] + [Spec("css-ui-4", "#outline-width")] public void ZeroWidthOutline_PaintsNothing() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -110,6 +115,7 @@ public void ZeroWidthOutline_PaintsNothing() [Ignore("Gap, see Spec/css-ui-3.yml")] [TestMethod] [Spec("css-ui-3", "#outline-offset")] + [Spec("css-ui-4", "#outline-offset")] public void OutlineOffset_PushesTheRingFurtherFromTheBorderBox() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -169,6 +175,7 @@ public void InlineSplitAroundABlock_KeepsTheOutlineOnEveryPart() [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] [Spec("css-ui-3", "#outline-props")] + [Spec("css-ui-4", "#outline-props")] public void Outline_DoesNotTakeUpSpace() { var (root, _) = PaintHarness.Layout(PaintHarness.Wrap( @@ -193,6 +200,7 @@ public void Outline_DoesNotTakeUpSpace() [DataRow("outset")] [Spec("css21", "ui.html#dynamic-outlines")] [Spec("css-ui-3", "#outline-style")] + [Spec("css-ui-4", "#outline-style")] public void OutlineStyle_AcceptsEveryBorderStyle(string style) { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -209,6 +217,7 @@ public void OutlineStyle_AcceptsEveryBorderStyle(string style) [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] [Spec("css-ui-3", "#outline-props")] + [Spec("css-ui-4", "#outline")] public void Outline_IsPaintedOnTopOfTheContent() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -228,6 +237,7 @@ public void Outline_IsPaintedOnTopOfTheContent() [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] [Spec("css-ui-3", "#outline-props")] + [Spec("css-ui-4", "#outline")] public void InlineBrokenAcrossLines_GetsOneOutlineEnclosingAllItsBoxes() { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.Test/Css/CssUi3Tests.cs b/Source/Test/HtmlRenderer.Test/Css/CssUi3Tests.cs index 111878450..b2deff046 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CssUi3Tests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CssUi3Tests.cs @@ -21,6 +21,7 @@ public void BoxSizing_IsNotInherited() [Ignore("Gap, see Spec/css-ui-3.yml")] [TestMethod] [Spec("css-ui-3", "#outline-style")] + [Spec("css-ui-4", "#outline-style")] public void OutlineStyleAuto_IsAccepted() { var property = CssConstructionFunctions.ParseDeclaration("outline-style: auto"); @@ -31,6 +32,7 @@ public void OutlineStyleAuto_IsAccepted() [Ignore("Gap, see Spec/css-ui-3.yml")] [TestMethod] [Spec("css-ui-3", "#outline-color")] + [Spec("css-ui-4", "#outline-color")] public void OutlineColorInvert_IsRejectedWithoutColorInversion() { var property = CssConstructionFunctions.ParseDeclaration("outline-color: invert"); @@ -42,6 +44,7 @@ public void OutlineColorInvert_IsRejectedWithoutColorInversion() [DataRow("cursor: pointer")] [DataRow("cursor: url(hand.cur) 2 3, pointer")] [Spec("css-ui-3", "#cursor")] + [Spec("css-ui-4", "#cursor")] public void Cursor_IsAccepted(string declaration) { var property = CssConstructionFunctions.ParseDeclaration(declaration); @@ -52,6 +55,7 @@ public void Cursor_IsAccepted(string declaration) [Ignore("Gap, see Spec/css-ui-3.yml")] [TestMethod] [Spec("css-ui-3", "#cursor")] + [Spec("css-ui-4", "#cursor")] public void Cursor_SetsThePointerOverTheElement() { var (_, container) = LayoutHarness.Layout(LayoutHarness.Wrap("<div style='cursor:pointer;height:100px'></div>")); diff --git a/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs index c9b443981..6450f11c8 100644 --- a/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs @@ -24,6 +24,7 @@ public void CssOutlineStyleDottedLegal() [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] [Spec("css-ui-3", "#outline-style")] + [Spec("css-ui-4", "#outline-style")] public void CssOutlineStyleSolidLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-style : solid"); @@ -53,6 +54,7 @@ public void CssOutlineStyleNoIllegal() [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] [Spec("css-ui-3", "#outline-style")] + [Spec("css-ui-4", "#outline-style")] public void CssOutlineStyleHiddenIllegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-style : hidden"); @@ -105,6 +107,7 @@ public void CssOutlineColorHexLegal() [TestMethod] [Spec("css-ui-3", "#outline-color")] + [Spec("css-ui-4", "#outline-color")] public void CssOutlineColorRedLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-color : red "); @@ -132,6 +135,7 @@ public void CssOutlineColorIllegal() [TestMethod] [Spec("css-ui-3", "#outline-width")] + [Spec("css-ui-4", "#outline-width")] public void CssOutlineWidthThinImportantLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-width : thin !important"); @@ -230,6 +234,7 @@ public void CssOutlineDoubleColorIllegal() [TestMethod] [Spec("css21", "ui.html#dynamic-outlines")] [Spec("css-ui-3", "#outline")] + [Spec("css-ui-4", "#outline")] public void CssOutlineAllSolidLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline : 1px solid #000"); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssUi4Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssUi4Tests.cs new file mode 100644 index 000000000..c5e8326d7 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssUi4Tests.cs @@ -0,0 +1,125 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssUi4Tests +{ + [TestMethod] + [Ignore("Gap, see Spec/css-ui-4.yml")] + [Spec("css-ui-4", "#outline")] + public void OutlineAuto_SetsStyleAndColorToAuto() + { + var property = CssConstructionFunctions.ParseDeclaration("outline: auto"); + + Assert.IsTrue(property.HasValue); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-ui-4.yml")] + [Spec("css-ui-4", "#outline-color")] + public void OutlineColorAuto_IsAccepted() + { + var property = CssConstructionFunctions.ParseDeclaration("outline-color: auto"); + + Assert.IsTrue(property.HasValue); + } + + [TestMethod] + [DataRow("cursor: url(example.svg), url(hyper.cur), url(hyper.png) 2 3, pointer")] + [DataRow("cursor: zoom-in")] + [DataRow("cursor: grabbing")] + [Spec("css-ui-4", "#cursor")] + [Spec("css-ui-4", "#predefined-cursors")] + public void Cursor_IsAccepted(string declaration) + { + var property = CssConstructionFunctions.ParseDeclaration(declaration); + + Assert.IsTrue(property.HasValue); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-ui-4.yml")] + [Spec("css-ui-4", "#content-selection")] + public void UserSelectNone_ExcludesTheElementFromTheSelection() + { + var (_, container) = LayoutHarness.Layout(LayoutHarness.Wrap("<p>selectable</p><p style='user-select:none'>excluded</p>")); + + container.HandleKeyDown(new TestControl(container.Adapter), new RKeyEvent(true, true, false)); + + StringAssert.Contains(container.SelectedText, "selectable"); + Assert.DoesNotContain("excluded", container.SelectedText); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-ui-4.yml")] + [Spec("css-ui-4", "#pointer-events-control")] + public void PointerEventsNone_TakesTheLinkOutOfHitTesting() + { + var (_, container) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<a href='a.html' style='display:block;height:50px;pointer-events:none'>link</a>")); + + Assert.IsNull(container.GetLinkAt(new RPoint(5, 5))); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-ui-4.yml")] + [Spec("css-ui-4", "#inertness")] + public void InteractivityInert_TakesTheLinkOutOfHitTesting() + { + var (_, container) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='interactivity:inert'><a href='a.html' style='display:block;height:50px'>link</a></div>")); + + Assert.IsNull(container.GetLinkAt(new RPoint(5, 5))); + } + + [TestMethod] + [DataRow("user-select: none")] + [DataRow("pointer-events: none")] + [DataRow("interactivity: inert")] + [DataRow("accent-color: red")] + [DataRow("appearance: none")] + [DataRow("outline-offset: 2px")] + public void UnsupportedProperties_AreDropped(string declaration) + { + var style = CssConstructionFunctions.ParseDeclarations(declaration); + + Assert.AreEqual(0, style.Length); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-ui-4.yml")] + [DataRow("accent-color: red")] + [DataRow("appearance: none")] + [DataRow("-webkit-appearance: none")] + [Spec("css-ui-4", "#widget-accent")] + [Spec("css-ui-4", "#appearance-switching")] + public void WidgetProperties_AreAccepted(string declaration) + { + var style = CssConstructionFunctions.ParseDeclarations(declaration); + + Assert.AreEqual(1, style.Length); + } + + private sealed class TestControl(RAdapter adapter) : RControl(adapter) + { + public override bool LeftMouseButton => false; + public override bool RightMouseButton => false; + public override RPoint MouseLocation => RPoint.Empty; + public override void SetCursorDefault() { } + public override void SetCursorHand() { } + public override void SetCursorIBeam() { } + public override void DoDragDropCopy(object dragDropData) { } + + public override void MeasureString(string str, RFont font, double maxWidth, out int charFit, out double charFitWidth) + { + charFit = str.Length; + charFitWidth = 0; + } + + public override void Invalidate() { } + } +} diff --git a/Spec/css-ui-4.yml b/Spec/css-ui-4.yml index f5a746053..82d02ffa0 100644 --- a/Spec/css-ui-4.yml +++ b/Spec/css-ui-4.yml @@ -5,121 +5,123 @@ snapshot: https://www.w3.org/TR/2026/WD-css-ui-4-20260120/ sections: '#intro': title: 1 Introduction - status: unknown - '#purpose': - title: 1.1 Purpose - status: unknown + status: n/a '#interaction': title: 2 Module Interactions - status: unknown - '#values': - title: 2.1 Value Definitions - status: unknown + status: n/a '#outline-props': title: 3 Outline properties - status: unknown + status: partial '#outline': title: '3.1 Outlines Shorthand: the outline property' - status: unknown + status: partial + gaps: + - "'auto' isn't accepted" + - The outline is painted right after the box's border, descendants can cover it, see CSS 2.1 18.4 + - An inline broken across lines gets a separate ring per line box, see CSS 2.1 18.4 + - The outline doesn't follow the 'border-radius' curve '#outline-width': title: '3.2 Outline Thickness: the outline-width property' - status: unknown + status: partial + gaps: + - The initial width is 2px instead of 'medium' '#outline-style': title: '3.3 Outline Patterns: the outline-style property' - status: unknown + status: partial + gaps: + - Only solid outlines are painted, see CSS 2.1 18.4 + - "'hidden' is accepted, see CSS 2.1 18.4" + - "'auto' isn't accepted" '#outline-color': title: '3.4 Outline Colors: the outline-color property' - status: unknown + status: partial + gaps: + - "'auto' isn't accepted, the initial value still paints as currentcolor" + - "'invert' is accepted and painted as currentcolor" '#outline-offset': title: '3.5 Offsetting the Outline: the outline-offset property' - status: unknown + status: unsupported + gaps: + - "'outline-offset' isn't parsed" '#resizing': title: 4 Resizing - status: unknown - '#resize': - title: '4.1 Resizing Boxes: the resize property' - status: unknown + status: n/a '#pointing-keyboard': title: 5 Pointing Devices and Keyboards - status: unknown + status: parsed-only '#pointer-interaction': title: 5.1 Pointer interaction - status: unknown + status: parsed-only '#cursor': title: '5.1.1 Styling the Cursor: the cursor property' - status: unknown + status: parsed-only + gaps: + - "'cursor' is ignored, the controls show a hand over links, an I-beam over text and the arrow elsewhere" '#predefined-cursors': title: 5.1.1.1 Predefined Cursors - status: unknown + status: parsed-only '#canvas_cursor': title: 5.1.1.2 Cursor of the canvas - status: unknown + status: parsed-only '#the-insertion-caret': title: 5.2 Insertion caret - status: unknown - '#caret-color': - title: '5.2.1 Coloring the Insertion Caret: the caret-color property' - status: unknown - '#caret-animation': - title: '5.2.2 Animation of the insertion caret: caret-animation' - status: unknown - '#caret-shape': - title: '5.2.3 Shape of the insertion caret: caret-shape' - status: unknown - '#caret': - title: '5.2.4 Insertion caret shorthand: caret' - status: unknown + status: n/a '#keyboard': title: 5.3 Keyboard control - status: unknown - '#nav-dir': - title: '5.3.1 Directional Focus Navigation: the nav-up, nav-right, nav-down, nav-left properties' - status: unknown - '#example-positioned-buttons': - title: '5.3.1.1 Example: positioned buttons' - status: unknown - '#example-moving-focus-to-inside-a-frame': - title: '5.3.1.2 Example: moving focus to inside a frame' - status: unknown - '#input-method-editor': - title: '5.3.2 Obsolete: the ime-mode property' - status: unknown + status: n/a '#user-interaction': title: 6 User Interaction - status: unknown + status: unsupported '#content-selection': title: 6.1 Controlling content selection - status: unknown + status: unsupported + gaps: + - "'user-select' and '-webkit-user-select' aren't parsed, the text selection of the controls covers every element" + - The user-agent stylesheet doesn't set 'user-select' on button, meter, progress and select '#pointer-events-control': title: '6.2 Exclusion from Hit-testing: the pointer-events property' - status: unknown + status: unsupported + gaps: + - "'pointer-events' isn't parsed, links stay clickable" '#inertness': title: '6.3 Preventing All Interaction: the interactivity property' - status: unknown + status: unsupported + gaps: + - "'interactivity' isn't parsed, inert content stays clickable and selectable" '#interest': title: '6.4 User Interest Delay: the interest-delay-* properties' - status: unknown + status: n/a '#styling-widgets': title: 7 Styling Widgets - status: unknown + status: unsupported '#widget-accent': title: '7.1 Widget Accent Colors: the accent-color property' - status: unknown + status: unsupported + gaps: + - "'accent-color' isn't parsed, widgets aren't drawn, see HTML 15.5.1" '#appearance-switching': title: '7.2 Switching appearance: the appearance property' - status: unknown + status: unsupported + gaps: + - "'appearance' and '-webkit-appearance' aren't parsed, widgets have no native appearance, see HTML 15.5.1" '#appearance-disabling-properties': title: 7.2.1 Properties Disabling Native Appearance - status: unknown + status: unsupported + gaps: + - Widgets have no native appearance to devolve, see HTML 15.5.1 '#appearance-decorative': title: 7.2.2 Effects of appearance on Decorative Aspects of Elements - status: unknown + status: unsupported + gaps: + - Widgets have no native appearance, see HTML 15.5.1 '#appearance-semantics': title: 7.2.3 Effects of appearance on Semantic Aspects of Elements - status: unknown + status: n/a '#control-specific-rules': title: 7.3 Control-Specific Rules - status: unknown + status: unsupported '#input-rules': title: 7.3.1 Single-Line Text Input Controls - status: unknown + status: unsupported + gaps: + - A text input is an empty inline-block without its value, see HTML 15.5.6 From 1fc82bf7e6c2179990e08d4577543f3c44c7904c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:27:12 +0100 Subject: [PATCH 121/150] Verify and document CSS Scrollbars compliance --- .../CssModules/CssScrollbarsTests.cs | 39 +++++++++++++++++++ Spec/css-scrollbars-1.yml | 19 ++++----- 2 files changed, 46 insertions(+), 12 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssScrollbarsTests.cs diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssScrollbarsTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssScrollbarsTests.cs new file mode 100644 index 000000000..6636ad9a5 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssScrollbarsTests.cs @@ -0,0 +1,39 @@ +using HtmlRenderer.Test.CssEngineSupport; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssScrollbarsTests +{ + [TestMethod] + [DataRow("scrollbar-color: red blue")] + [DataRow("scrollbar-width: thin")] + public void ScrollbarProperties_AreDropped(string declaration) + { + var style = CssConstructionFunctions.ParseDeclarations(declaration); + + Assert.AreEqual(0, style.Length); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-scrollbars-1.yml")] + [Spec("css-scrollbars-1", "#scrollbar-color")] + public void ScrollbarColor_IsAccepted() + { + var style = CssConstructionFunctions.ParseDeclarations("scrollbar-color: red blue"); + + Assert.AreEqual(1, style.Length); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-scrollbars-1.yml")] + [DataRow("scrollbar-width: thin")] + [DataRow("scrollbar-width: none")] + [Spec("css-scrollbars-1", "#scrollbar-width")] + public void ScrollbarWidth_IsAccepted(string declaration) + { + var style = CssConstructionFunctions.ParseDeclarations(declaration); + + Assert.AreEqual(1, style.Length); + } +} diff --git a/Spec/css-scrollbars-1.yml b/Spec/css-scrollbars-1.yml index 46f75437a..36b688c2f 100644 --- a/Spec/css-scrollbars-1.yml +++ b/Spec/css-scrollbars-1.yml @@ -5,19 +5,14 @@ snapshot: https://www.w3.org/TR/2021/CR-css-scrollbars-1-20211209/ sections: '#intro': title: 1 Introduction - status: unknown - '#scope': - title: 1.1 Scope - status: unknown - '#out-of-scope': - title: 1.1.1 Out Of Scope - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#scrollbar-color': title: '2 Scrollbar Colors: the scrollbar-color property' - status: unknown + status: unsupported + gaps: + - "'scrollbar-color' isn't parsed, the scrollbars of the panel controls keep the platform colors" '#scrollbar-width': title: '3 Scrollbar Thickness: the scrollbar-width property' - status: unknown + status: unsupported + gaps: + - "'scrollbar-width' isn't parsed, the scrollbars of the panel controls keep the platform width and can't be hidden" From 8641dee6bf4fb8599229874c9baf499488368bd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:27:13 +0100 Subject: [PATCH 122/150] Verify and document CSS Scroll Snap compliance --- .../CssModules/CssScrollSnapTests.cs | 89 +++++++++++++++++++ Spec/css-scroll-snap-1.yml | 78 +++++++--------- 2 files changed, 121 insertions(+), 46 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssScrollSnapTests.cs diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssScrollSnapTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssScrollSnapTests.cs new file mode 100644 index 000000000..8d4346fee --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssScrollSnapTests.cs @@ -0,0 +1,89 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssScrollSnapTests +{ + [TestMethod] + [DataRow("scroll-snap-type: y mandatory")] + [DataRow("scroll-padding: 10px")] + [DataRow("scroll-margin: 10px")] + [DataRow("scroll-snap-align: start")] + [DataRow("scroll-snap-stop: always")] + public void ScrollSnapProperties_AreDropped(string declaration) + { + var style = CssConstructionFunctions.ParseDeclarations(declaration); + + Assert.AreEqual(0, style.Length); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-scroll-snap-1.yml")] + [DataRow("scroll-snap-type: y mandatory")] + [DataRow("scroll-snap-align: start")] + [DataRow("scroll-snap-stop: always")] + [Spec("css-scroll-snap-1", "#scroll-snap-type")] + [Spec("css-scroll-snap-1", "#scroll-snap-align")] + [Spec("css-scroll-snap-1", "#scroll-snap-stop")] + public void SnapProperties_AreAccepted(string declaration) + { + var style = CssConstructionFunctions.ParseDeclarations(declaration); + + Assert.AreEqual(1, style.Length); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-scroll-snap-1.yml")] + [Spec("css-scroll-snap-1", "#scroll-margin")] + public void ScrollMargin_OutsetsTheFragmentTargetArea() + { + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<a id=x href=#target>a</a><p id=target style='margin-top:100px;scroll-margin-top:30px'>b</p>")); + + Assert.AreEqual(LayoutHarness.FindById(root, "target")!.Location.Y - 30, ScrollTarget(container, "x", root)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-scroll-snap-1.yml")] + [Spec("css-scroll-snap-1", "#scroll-padding")] + public void ScrollPaddingOnTheRoot_InsetsTheViewportForTheFragmentTarget() + { + var (root, container) = LayoutHarness.Layout( + "<html style='scroll-padding-top:30px'><head></head><body style='margin:0'>" + + "<a id=x href=#target>a</a><p id=target style='margin-top:100px'>b</p></body></html>"); + + Assert.AreEqual(LayoutHarness.FindById(root, "target")!.Location.Y - 30, ScrollTarget(container, "x", root)); + } + + private static double? ScrollTarget(HtmlContainerInt container, string linkId, TheArtOfDev.HtmlRenderer.Core.Dom.CssBox root) + { + double? y = null; + container.ScrollChange += (_, e) => y = e.Y; + container.HandleLinkClicked(new TestControl(container.Adapter), RPoint.Empty, LayoutHarness.FindById(root, linkId)!); + return y; + } + + private sealed class TestControl(RAdapter adapter) : RControl(adapter) + { + public override bool LeftMouseButton => false; + public override bool RightMouseButton => false; + public override RPoint MouseLocation => RPoint.Empty; + public override void SetCursorDefault() { } + public override void SetCursorHand() { } + public override void SetCursorIBeam() { } + public override void DoDragDropCopy(object dragDropData) { } + + public override void MeasureString(string str, RFont font, double maxWidth, out int charFit, out double charFitWidth) + { + charFit = str.Length; + charFitWidth = 0; + } + + public override void Invalidate() { } + } +} diff --git a/Spec/css-scroll-snap-1.yml b/Spec/css-scroll-snap-1.yml index 7775e5241..e53816fbb 100644 --- a/Spec/css-scroll-snap-1.yml +++ b/Spec/css-scroll-snap-1.yml @@ -5,91 +5,77 @@ snapshot: https://www.w3.org/TR/2021/CR-css-scroll-snap-1-20210311/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module interactions - status: unknown - '#values': - title: 1.2 Value Definitions - status: unknown + status: n/a '#examples': title: 2 Motivating Examples - status: unknown + status: n/a '#overview': title: 3 Scroll Snap Model - status: unknown + status: unsupported '#properties-on-the-scroll-container': title: '4 Capturing Scroll Snap Areas: Properties on the scroll container' - status: unknown + status: unsupported '#scroll-snap-type': title: '4.1 Scroll Snapping Rules: the scroll-snap-type property' - status: unknown + status: unsupported + gaps: + - "'scroll-snap-type' isn't parsed, the viewport of the panel controls never snaps" '#snap-axis': title: '4.1.1 Scroll Snap Axis: the x, y, block, inline, and both values' - status: unknown + status: unsupported '#snap-strictness': title: '4.1.2 Scroll Snap Strictness: the none, proximity, and mandatory values' - status: unknown + status: unsupported '#re-snap': title: 4.1.3 Re-snapping After Layout Changes - status: unknown + status: unsupported '#scroll-padding': title: '4.2 Scroll Snapport: the scroll-padding property' - status: unknown + status: unsupported + gaps: + - "'scroll-padding' isn't parsed, fragment navigation and ScrollToElement put the target at the top edge of the viewport" '#properties-on-the-elements': title: '5 Aligning Scroll Snap Areas: Properties on the elements' - status: unknown + status: unsupported '#scroll-margin': title: '5.1 Scroll Snapping Area: the scroll-margin property' - status: unknown + status: unsupported + gaps: + - "'scroll-margin' isn't parsed, fragment navigation and ScrollToElement scroll to the border box" '#scroll-snap-align': title: '5.2 Scroll Snapping Alignment: the scroll-snap-align property' - status: unknown + status: unsupported + gaps: + - "'scroll-snap-align' isn't parsed" '#snap-scope': title: 5.2.1 Scoping Valid Snap Positions to Visible Boxes - status: unknown + status: unsupported '#snap-overflow': title: 5.2.2 Snapping Boxes that Overflow the Scrollport - status: unknown + status: unsupported '#unreachable': title: 5.2.3 Unreachable Snap Positions - status: unknown + status: unsupported '#scroll-snap-stop': title: '5.3 Scroll Snap Limits: the scroll-snap-stop property' - status: unknown + status: unsupported + gaps: + - "'scroll-snap-stop' isn't parsed" '#snap-concepts': title: 6 Snapping Mechanics - status: unknown + status: unsupported '#scroll-types': title: 6.1 Types of Scrolling Methods - status: unknown + status: n/a '#choosing': title: 6.2 Choosing Snap Positions - status: unknown + status: unsupported '#priv-sec': title: 7 Privacy and Security Considerations - status: unknown + status: n/a '#acknowledgements': title: 8 Acknowledgements - status: unknown + status: n/a '#changes': title: 9 Changes - status: unknown - '#changes-20190319': - title: 9.1 Changes Since 19 March 2019 CR - status: unknown - '#changes-20190131': - title: 9.2 Changes Since 31 January 2019 CR - status: unknown - '#changes-20180814': - title: 9.3 Changes Since 14 August 2018 CR - status: unknown - '#changes-20171214': - title: 9.4 Changes Since 14 December 2017 CR - status: unknown - '#changes-20170824': - title: 9.5 Changes Since 24 August 2017 CR - status: unknown - '#changes-20161020': - title: 9.6 Changes Since 20 October 2016 CR - status: unknown + status: n/a From efbe90a08d9222da4677bac3cb0560a935f269ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:27:13 +0100 Subject: [PATCH 123/150] Verify and document CSS Scroll Anchoring compliance --- .../CssModules/CssScrollAnchoringTests.cs | 25 +++++++++++++++++++ Spec/css-scroll-anchoring-1.yml | 23 +++++++++-------- 2 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssScrollAnchoringTests.cs diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssScrollAnchoringTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssScrollAnchoringTests.cs new file mode 100644 index 000000000..a7d33ff98 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssScrollAnchoringTests.cs @@ -0,0 +1,25 @@ +using HtmlRenderer.Test.CssEngineSupport; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssScrollAnchoringTests +{ + [TestMethod] + public void OverflowAnchor_IsDropped() + { + var style = CssConstructionFunctions.ParseDeclarations("overflow-anchor: none"); + + Assert.AreEqual(0, style.Length); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-scroll-anchoring-1.yml")] + [Spec("css-scroll-anchoring-1", "#exclusion-api")] + public void OverflowAnchor_IsAccepted() + { + var style = CssConstructionFunctions.ParseDeclarations("overflow-anchor: none"); + + Assert.AreEqual(1, style.Length); + } +} diff --git a/Spec/css-scroll-anchoring-1.yml b/Spec/css-scroll-anchoring-1.yml index 994ed031c..3a62d65e6 100644 --- a/Spec/css-scroll-anchoring-1.yml +++ b/Spec/css-scroll-anchoring-1.yml @@ -5,28 +5,29 @@ snapshot: https://www.w3.org/TR/2020/WD-css-scroll-anchoring-1-20201111/ sections: '#intro': title: 1 Introduction - status: unknown - '#values': - title: 1.1 Value Definitions - status: unknown + status: n/a '#description': title: 2 Description - status: unknown + status: unsupported + gaps: + - The panel controls keep their scroll offset when content above the viewport changes size, e.g. when an image loads '#anchor-node-selection': title: 2.1 Anchor Node Selection - status: unknown + status: unsupported '#scroll-adjustment': title: 2.2 Scroll Adjustment - status: unknown + status: unsupported '#suppression-windows': title: 2.2.1 Suppression Window - status: unknown + status: unsupported '#suppression-triggers': title: 2.2.2 Suppression Triggers - status: unknown + status: unsupported '#exclusion-api': title: 3 Exclusion API - status: unknown + status: unsupported + gaps: + - "'overflow-anchor' isn't parsed" '#priv-sec': title: 4 Privacy and Security Considerations - status: unknown + status: n/a From 8f05288aa29d802b54f9867f2f506632a06749cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:27:31 +0100 Subject: [PATCH 124/150] Verify and document Resize Observer compliance --- Spec/resize-observer-1.yml | 69 ++------------------------------------ 1 file changed, 3 insertions(+), 66 deletions(-) diff --git a/Spec/resize-observer-1.yml b/Spec/resize-observer-1.yml index 46a0ffec3..699df5d2b 100644 --- a/Spec/resize-observer-1.yml +++ b/Spec/resize-observer-1.yml @@ -5,73 +5,10 @@ snapshot: https://www.w3.org/TR/2020/WD-resize-observer-1-20200211/ sections: '#intro': title: 1 Introduction - status: unknown + status: n/a '#api': title: 2 Resize Observer API - status: unknown - '#resize-observer-interface': - title: 2.1 ResizeObserver interface - status: unknown - '#resize-observer-callback': - title: 2.2 ResizeObserverCallback - status: unknown - '#resize-observer-entry-interface': - title: 2.3 ResizeObserverEntry - status: unknown + status: n/a '#processing-model': title: 3 Processing Model - status: unknown - '#resize-observation-interface': - title: 3.1 ResizeObservation example struct - status: unknown - '#internal-slot-definitions': - title: 3.2 Internal Slot Definitions - status: unknown - '#document-slots': - title: 3.2.1 Document - status: unknown - '#resize-observer-slots': - title: 3.2.2 ResizeObserver - status: unknown - '#css-definitions': - title: 3.3 CSS Definitions - status: unknown - '#content-rect-h': - title: 3.3.1 content rect - status: unknown - '#algorithms': - title: 3.4 Algorithms - status: unknown - '#gather-active-observations-h': - title: 3.4.1 Gather active observations at depth - status: unknown - '#has-active-observations-h': - title: 3.4.2 Has active observations - status: unknown - '#has-skipped-observations-h': - title: 3.4.3 Has skipped observations - status: unknown - '#create-and-populate-resizeobserverentry-h': - title: 3.4.4 Create and populate a ResizeObserverEntry - status: unknown - '#broadcast-resize-notifications-h': - title: 3.4.5 Broadcast active observations - status: unknown - '#deliver-resize-error': - title: 3.4.6 Deliver Resize Loop Error - status: unknown - '#calculate-depth-for-node-h': - title: 3.4.7 Calculate depth for node - status: unknown - '#calculate-box-size': - title: 3.4.8 Calculate box size, given target and observed box - status: unknown - '#lifetime': - title: 3.5 ResizeObserver Lifetime - status: unknown - '#integrations': - title: 3.6 External Spec Integrations - status: unknown - '#html-event-loop': - title: '3.6.1 HTML Processing Model: Event Loop' - status: unknown + status: n/a From 509682cc8c8a46e8dd0ebf2ec4d3b7914820ce11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:31:31 +0100 Subject: [PATCH 125/150] Verify and document CSSOM compliance --- .../HtmlRenderer.Test/Css/MediaTypeTests.cs | 3 + .../CssModules/CssomTests.cs | 88 ++++++++++ .../Html/DocumentMetadataTests.cs | 2 + .../Test/HtmlRenderer.Test/Html/LinksTests.cs | 1 + Spec/cssom-1.yml | 154 +++++------------- 5 files changed, 131 insertions(+), 117 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssomTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs index efdd8b782..d2298eeb4 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs @@ -26,6 +26,7 @@ private static string DataCss(string css) => [TestMethod] [Spec("css21", "media.html#media-sheets")] + [Spec("cssom-1", "#css-style-sheets")] public void LinkWithMatchingMedia_Applies() { Assert.AreEqual(Green, ColorOf($"<link rel=stylesheet media=screen href='{DataCss("p { color: green }")}'>")); @@ -35,6 +36,7 @@ public void LinkWithMatchingMedia_Applies() [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "media.html#media-sheets")] [Spec("html", "semantics.html#processing-the-media-attribute")] + [Spec("cssom-1", "#css-style-sheets")] public void LinkWithOtherMedia_IsIgnored() { Assert.AreNotEqual(Green, ColorOf($"<link rel=stylesheet media='print, handheld' href='{DataCss("p { color: green }")}'>")); @@ -44,6 +46,7 @@ public void LinkWithOtherMedia_IsIgnored() [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "media.html#media-sheets")] [Spec("html", "semantics.html#the-style-element")] + [Spec("cssom-1", "#css-style-sheets")] public void StyleWithOtherMedia_IsIgnored() { Assert.AreNotEqual(Green, ColorOf("<style media=print>p { color: green }</style>")); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssomTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssomTests.cs new file mode 100644 index 000000000..d743086cc --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssomTests.cs @@ -0,0 +1,88 @@ +using System.Text; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Network; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssomTests +{ + private const string DocumentUrl = "https://example.com/doc/index.html"; + + private static readonly RColor Green = RColor.FromArgb(0, 128, 0); + + [TestMethod] + [Spec("cssom-1", "#css-style-sheet-collections")] + [DataRow("<style>p { color: red }</style><link rel=stylesheet href=green.css>")] + [DataRow("<link rel=stylesheet href=red.css><style>p { color: green }</style>")] + [DataRow("<link rel=stylesheet href=red.css><link rel=stylesheet href=green.css>")] + public void StyleSheets_CascadeInTreeOrder(string head) + { + Assert.AreEqual(Green, ColorOf($"<!DOCTYPE html><html><head>{head}</head><body><p id=x>a</p></body></html>")); + } + + [TestMethod] + [Spec("cssom-1", "#css-style-sheet-collections")] + public void PersistentStyleSheet_AppliesNextToTheTitledSet() + { + Assert.AreEqual(Green, ColorOf( + "<!DOCTYPE html><html><head><style title=a>p { font-weight: bold }</style><style>p { color: green }</style></head>" + + "<body><p id=x>a</p></body></html>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/cssom-1.yml")] + [Spec("cssom-1", "#css-style-sheet-collections")] + public void DefaultStylePragma_SelectsThePreferredStyleSheetSet() + { + Assert.AreEqual(Green, ColorOf( + "<!DOCTYPE html><html><head><meta http-equiv=default-style content=b>" + + "<style title=b>p { color: green }</style><style title=a>p { color: red }</style></head>" + + "<body><p id=x>a</p></body></html>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/cssom-1.yml")] + [Spec("cssom-1", "#fetching-css-style-sheets")] + public void QuirksModeDocument_AppliesASameOriginStyleSheetOfAnotherContentType() + { + Assert.AreEqual(Green, ColorOf("<html><head><link rel=stylesheet href=green.css></head><body><p id=x>a</p></body></html>", "text/plain")); + } + + private static RColor ColorOf(string html, string contentType = "text/css") + { + var resources = new Dictionary<string, string> + { + ["https://example.com/doc/green.css"] = "p { color: green }", + ["https://example.com/doc/red.css"] = "p { color: red }", + }; + var container = new HtmlContainerInt(new MockAdapter { NetworkLoader = new StubLoader(resources, contentType) }) + { + MaxSize = new RSize(1000, 4000), + AvoidAsyncImagesLoading = true + }; + container.SetHtml(html).GetAwaiter().GetResult(); + using (var graphics = new RecordingGraphics()) + container.PerformLayout(graphics); + + return LayoutHarness.FindById(container.Root!, "x")!.ActualColor; + } + + private sealed class StubLoader(Dictionary<string, string> resources, string contentType) : RNetworkLoader + { + public override RUri? BaseUri => new(DocumentUrl); + + public override Task<string> GetPrimaryContents() => Task.FromResult(string.Empty); + + public override Task<RNetworkResponse?> GetResourceStream(RUri uri) + { + if (!resources.TryGetValue(uri.AbsoluteUri, out var content)) + return Task.FromResult<RNetworkResponse?>(null); + + var headers = new Dictionary<string, string[]> { ["Content-Type"] = [contentType] }; + return Task.FromResult<RNetworkResponse?>(new RNetworkResponse(new MemoryStream(Encoding.UTF8.GetBytes(content)), headers)); + } + } +} diff --git a/Source/Test/HtmlRenderer.Test/Html/DocumentMetadataTests.cs b/Source/Test/HtmlRenderer.Test/Html/DocumentMetadataTests.cs index 56072a78b..1a19a4849 100644 --- a/Source/Test/HtmlRenderer.Test/Html/DocumentMetadataTests.cs +++ b/Source/Test/HtmlRenderer.Test/Html/DocumentMetadataTests.cs @@ -126,6 +126,7 @@ public void RelWithSeveralKeywords_StillLinksTheStyleSheet() [Ignore("Gap, see Spec/html.yml")] [Spec("html", "semantics.html#the-link-element")] [Spec("html", "semantics.html#the-style-element")] + [Spec("cssom-1", "#css-style-sheet-collections")] [DataRow("<link rel=stylesheet title=a href=a.css><link rel=stylesheet title=b href=b.css>")] [DataRow("<style title=a>p { color: green }</style><style title=b>p { color: red }</style>")] public void StyleSheetWithAnotherTitle_ThanThePreferredSet_IsDisabled(string head) @@ -140,6 +141,7 @@ public void StyleSheetWithAnotherTitle_ThanThePreferredSet_IsDisabled(string hea [TestMethod] [Spec("html", "semantics.html#processing-the-type-attribute")] + [Spec("cssom-1", "#fetching-css-style-sheets")] public void LinkedResourceOfAnotherContentType_IsNotApplied() { var doc = Load( diff --git a/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs b/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs index 5961a4fe1..b70492bb3 100644 --- a/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs +++ b/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs @@ -119,6 +119,7 @@ public void DisabledStylesheet_IsNotApplied() [TestMethod] [Spec("html", "links.html#rel-alternate")] + [Spec("cssom-1", "#css-style-sheet-collections")] public void AlternateStylesheet_IsNotApplied() { Assert.AreEqual("black", ColorWithStylesheet("<link rel='alternate stylesheet' title=Red href=a.css>")); diff --git a/Spec/cssom-1.yml b/Spec/cssom-1.yml index c15bbccb3..b51650d85 100644 --- a/Spec/cssom-1.yml +++ b/Spec/cssom-1.yml @@ -5,178 +5,98 @@ snapshot: https://www.w3.org/TR/2021/WD-cssom-1-20210826/ sections: '#introduction': title: 1 Introduction - status: unknown + status: n/a '#terminology': title: 2 Terminology - status: unknown - '#common-serializing-idioms': - title: 2.1 Common Serializing Idioms - status: unknown + status: n/a '#cssomstring-type': title: 3 CSSOMString - status: unknown + status: n/a '#media-queries': title: 4 Media Queries - status: unknown - '#parsing-media-queries': - title: 4.1 Parsing Media Queries - status: unknown - '#serializing-media-queries': - title: 4.2 Serializing Media Queries - status: unknown - '#serializing-media-feature-values': - title: 4.2.1 Serializing Media Feature Values - status: unknown - '#comparing-media-queries': - title: 4.3 Comparing Media Queries - status: unknown - '#the-medialist-interface': - title: 4.4 The MediaList Interface - status: unknown + status: n/a '#selectors': title: 5 Selectors - status: unknown - '#parsing-selectors': - title: 5.1 Parsing Selectors - status: unknown - '#serializing-selectors': - title: 5.2 Serializing Selectors - status: unknown + status: n/a '#css-object-model': title: 6 CSS - status: unknown + status: partial '#css-style-sheets': title: 6.1 CSS Style Sheets - status: unknown + status: partial + gaps: + - The media of a style sheet is ignored, a style sheet for other media still applies, see HTML 4.2.4.1 '#the-stylesheet-interface': title: 6.1.1 The StyleSheet Interface - status: unknown + status: n/a '#the-cssstylesheet-interface': title: 6.1.2 The CSSStyleSheet Interface - status: unknown - '#legacy-css-style-sheet-members': - title: 6.1.2.1 Deprecated CSSStyleSheet members - status: unknown + status: n/a '#css-style-sheet-collections': title: 6.2 CSS Style Sheet Collections - status: unknown + status: partial + gaps: + - A title doesn't form a style sheet set, sheets with another title than the preferred one still apply + - The default-style pragma doesn't change the preferred style sheet set name, see HTML 4.2.5.3 '#the-http-default-style-header': title: 6.2.1 The HTTP Default-Style Header - status: unknown + status: n/a '#the-stylesheetlist-interface': title: 6.2.2 The StyleSheetList Interface - status: unknown + status: n/a '#extensions-to-the-document-or-shadow-root-interface': title: 6.2.3 Extensions to the DocumentOrShadowRoot Interface Mixin - status: unknown + status: n/a '#style-sheet-association': title: 6.3 Style Sheet Association - status: unknown + status: partial '#fetching-css-style-sheets': title: 6.3.1 Fetching CSS style sheets - status: unknown + status: partial + gaps: + - In a quirks mode document a same-origin style sheet of another content type isn't applied '#the-linkstyle-interface': title: 6.3.2 The LinkStyle Interface - status: unknown + status: n/a '#requirements-on-specifications': title: 6.3.3 Requirements on specifications - status: unknown + status: n/a '#requirements-on-user-agents-implementing-the-xml-stylesheet-processing-instruction': title: 6.3.4 Requirements on user agents Implementing the xml-stylesheet processing instruction - status: unknown + status: n/a '#requirements-on-user-agents-implementing-the-http-link-header': title: 6.3.5 Requirements on user agents Implementing the HTTP Link Header - status: unknown + status: n/a '#css-rules': title: 6.4 CSS Rules - status: unknown - '#the-cssrulelist-interface': - title: 6.4.1 The CSSRuleList Interface - status: unknown - '#the-cssrule-interface': - title: 6.4.2 The CSSRule Interface - status: unknown - '#the-cssstylerule-interface': - title: 6.4.3 The CSSStyleRule Interface - status: unknown - '#the-cssimportrule-interface': - title: 6.4.4 The CSSImportRule Interface - status: unknown - '#the-cssgroupingrule-interface': - title: 6.4.5 The CSSGroupingRule Interface - status: unknown - '#the-cssmediarule-interface': - title: 6.4.6 The CSSMediaRule Interface - status: unknown - '#the-csspagerule-interface': - title: 6.4.7 The CSSPageRule Interface - status: unknown - '#the-cssmarginrule-interface': - title: 6.4.8 The CSSMarginRule Interface - status: unknown - '#the-cssnamespacerule-interface': - title: 6.4.9 The CSSNamespaceRule Interface - status: unknown + status: n/a '#css-declarations': title: 6.5 CSS Declarations - status: unknown + status: n/a '#css-declaration-blocks': title: 6.6 CSS Declaration Blocks - status: unknown - '#the-cssstyledeclaration-interface': - title: 6.6.1 The CSSStyleDeclaration Interface - status: unknown + status: n/a '#css-values': title: 6.7 CSS Values - status: unknown - '#parsing-css-values': - title: 6.7.1 Parsing CSS Values - status: unknown - '#serializing-css-values': - title: 6.7.2 Serializing CSS Values - status: unknown - '#serializing-css-values-examples': - title: 6.7.2.1 Examples - status: unknown + status: n/a '#dom-access-to-css-declaration-blocks': title: 7 DOM Access to CSS Declaration Blocks - status: unknown - '#the-elementcssinlinestyle-mixin': - title: 7.1 The ElementCSSInlineStyle Mixin - status: unknown - '#extensions-to-the-window-interface': - title: 7.2 Extensions to the Window Interface - status: unknown + status: n/a '#utility-apis': title: 8 Utility APIs - status: unknown - '#the-css.escape()-method': - title: 8.1 The CSS.escape() Method - status: unknown + status: n/a '#resolved-values': title: 9 Resolved Values - status: unknown + status: n/a '#iana-considerations': title: 10 IANA Considerations - status: unknown - '#default-style': - title: 10.1 Default-Style - status: unknown + status: n/a '#change-history': title: 11 Change History - status: unknown - '#changes-from-17-march-2016': - title: 11.1 Changes From 17 March 2016 - status: unknown - '#changes-from-5-december-2013': - title: 11.2 Changes From 5 December 2013 - status: unknown - '#changes-from-12-july-2011-to-5-december-2013': - title: 11.3 Changes From 12 July 2011 To 5 December 2013 - status: unknown + status: n/a '#security-and-privacy-considerations': title: 12 Security and Privacy Considerations - status: unknown + status: n/a '#acknowledgments': title: 13 Acknowledgments - status: unknown + status: n/a From 4b64cc64cded9f6ca232d8f8d129008cb1f1f21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:33:31 +0100 Subject: [PATCH 126/150] Verify and document CSSOM View compliance --- .../CssModules/CssomViewTests.cs | 93 +++++++++++++++++++ Spec/cssom-view-1.yml | 84 ++++++----------- 2 files changed, 123 insertions(+), 54 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssomViewTests.cs diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssomViewTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssomViewTests.cs new file mode 100644 index 000000000..dfbd8a9c7 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssomViewTests.cs @@ -0,0 +1,93 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssomViewTests +{ + [TestMethod] + [Spec("cssom-view-1", "#terminology")] + [DataRow("<div style='height:300px'></div>", 300)] + [DataRow("<div style='height:100px'></div><div style='position:absolute;top:400px;height:100px'></div>", 500)] + [DataRow("<div style='height:100px'><div style='position:relative;top:300px;height:100px'></div></div>", 400)] + public void ViewportScrollingArea_ReachesTheBottomOfEveryBox(string body, double height) + { + var (_, container) = LayoutHarness.Layout(LayoutHarness.Wrap(body), maxWidth: 500); + + Assert.AreEqual(height, container.ActualSize.Height, 0.5); + } + + [TestMethod] + [Ignore("Gap, see Spec/cssom-view-1.yml")] + [Spec("cssom-view-1", "#terminology")] + public void ViewportScrollingArea_ReachesTheBottomMarginEdge() + { + var (_, container) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='height:100px'><div style='position:absolute;top:0;height:100px;margin-bottom:50px'></div></div>"), maxWidth: 500); + + Assert.AreEqual(150, container.ActualSize.Height, 0.5); + } + + [TestMethod] + [Spec("cssom-view-1", "#scrolling")] + [Spec("cssom-view-1", "#element-scrolling-members")] + public void FragmentNavigation_AlignsTheTargetWithTheTopOfTheViewport() + { + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<a id=x href=#target>a</a><p id=target style='margin-top:300px'>b</p>")); + + Assert.AreEqual(LayoutHarness.FindById(root, "target")!.Location.Y, ScrollTarget(container, root, "x")?.Y); + } + + [TestMethod] + [Ignore("Gap, see Spec/cssom-view-1.yml")] + [Spec("cssom-view-1", "#element-scrolling-members")] + public void FragmentNavigation_ScrollsTheInlineAxisOnlyAsFarAsNeeded() + { + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<a id=x href=#target>a</a><p id=target style='margin:300px 0 0 300px'>b</p>")); + + Assert.AreEqual(0d, ScrollTarget(container, root, "x")?.X); + } + + [TestMethod] + [Ignore("Gap, see Spec/cssom-view-1.yml")] + [Spec("cssom-view-1", "#scrolling")] + public void TopFragment_ScrollsToTheBeginningOfTheDocument() + { + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<div style='height:2000px'></div><a id=x href=#top>top</a>")); + + Assert.AreEqual<RPoint?>(RPoint.Empty, ScrollTarget(container, root, "x")); + } + + private static RPoint? ScrollTarget(HtmlContainerInt container, TheArtOfDev.HtmlRenderer.Core.Dom.CssBox root, string linkId) + { + RPoint? location = null; + container.ScrollChange += (_, e) => location = new RPoint(e.X, e.Y); + container.HandleLinkClicked(new TestControl(container.Adapter), RPoint.Empty, LayoutHarness.FindById(root, linkId)!); + return location; + } + + private sealed class TestControl(RAdapter adapter) : RControl(adapter) + { + public override bool LeftMouseButton => false; + public override bool RightMouseButton => false; + public override RPoint MouseLocation => RPoint.Empty; + public override void SetCursorDefault() { } + public override void SetCursorHand() { } + public override void SetCursorIBeam() { } + public override void DoDragDropCopy(object dragDropData) { } + + public override void MeasureString(string str, RFont font, double maxWidth, out int charFit, out double charFitWidth) + { + charFit = str.Length; + charFitWidth = 0; + } + + public override void Invalidate() { } + } +} diff --git a/Spec/cssom-view-1.yml b/Spec/cssom-view-1.yml index e40b8e105..c1c4c4eab 100644 --- a/Spec/cssom-view-1.yml +++ b/Spec/cssom-view-1.yml @@ -5,103 +5,79 @@ snapshot: https://www.w3.org/TR/2025/WD-cssom-view-1-20250916/ sections: '#background': title: 1 Background - status: unknown + status: n/a '#terminology': title: 2 Terminology - status: unknown + status: partial + gaps: + - The scrolling area of the viewport ends at the bottom border edge of the boxes instead of their margin edge '#css-pixels': title: 2.1 CSS pixels - status: unknown + status: n/a '#zooming': title: 2.2 Zooming - status: unknown + status: n/a '#web-exposed-screen-information': title: 2.3 Web-exposed screen information - status: unknown + status: n/a '#common-infrastructure': title: 3 Common Infrastructure - status: unknown + status: partial '#scrolling': title: 3.1 Scrolling - status: unknown + status: partial + gaps: + - "Following a link to #top doesn't scroll to the beginning of the document" '#webidl-values': title: 3.2 WebIDL values - status: unknown + status: n/a '#extensions-to-the-window-interface': title: 4 Extensions to the Window Interface - status: unknown - '#the-features-argument-to-the-open()-method': - title: 4.1 The features argument to the open() method - status: unknown - '#the-mediaquerylist-interface': - title: 4.2 The MediaQueryList Interface - status: unknown - '#mediaquerylist-event-summary': - title: 4.2.1 Event summary - status: unknown - '#the-screen-interface': - title: 4.3 The Screen Interface - status: unknown + status: n/a '#extensions-to-the-document-interface': title: 5 Extensions to the Document Interface - status: unknown - '#the-caretposition-interface': - title: 5.1 The CaretPosition Interface - status: unknown + status: n/a '#extension-to-the-element-interface': title: 6 Extensions to the Element Interface - status: unknown + status: partial '#element-scrolling-members': title: 6.1 Element Scrolling Members - status: unknown + status: partial + gaps: + - Scrolling a fragment target into view also scrolls the inline axis to its left edge instead of only as far as needed '#extensions-to-the-htmlelement-interface': title: 7 Extensions to the HTMLElement Interface - status: unknown + status: n/a '#extensions-to-the-htmlimageelement-interface': title: 8 Extensions to the HTMLImageElement Interface - status: unknown + status: n/a '#extensions-to-the-range-interface': title: 9 Extensions to the Range Interface - status: unknown + status: n/a '#extensions-to-the-mouseevent-interface': title: 10 Extensions to the MouseEvent Interface - status: unknown + status: n/a '#geometry': title: 11 Geometry - status: unknown - '#the-geometryutils-interface': - title: 11.1 The GeometryUtils Interface - status: unknown + status: n/a '#visualViewport': title: 12 VisualViewport - status: unknown - '#the-visualviewport-interface': - title: 12.1 The VisualViewport Interface - status: unknown + status: n/a '#events': title: 13 Events - status: unknown - '#resizing-viewports': - title: 13.1 Resizing viewports - status: unknown - '#scrolling-events': - title: 13.2 Scrolling - status: unknown - '#event-summary': - title: 13.3 Event summary - status: unknown + status: n/a '#post-layout-snapshot': title: 14 Post-Layout State Snapshotting - status: unknown + status: n/a '#privacy': title: 15 Privacy Considerations - status: unknown + status: n/a '#security': title: 16 Security Considerations - status: unknown + status: n/a '#changes': title: 17 Changes - status: unknown + status: n/a '#acks': title: 18 Acknowledgements - status: unknown + status: n/a From 7cde30a32c892e117790e4c1719bcd4bdd2fc107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:33:31 +0100 Subject: [PATCH 127/150] Verify and document Geometry Interfaces compliance --- Spec/geometry-1.yml | 50 +++++++++------------------------------------ 1 file changed, 10 insertions(+), 40 deletions(-) diff --git a/Spec/geometry-1.yml b/Spec/geometry-1.yml index 3fe56aa30..5dcfbff2a 100644 --- a/Spec/geometry-1.yml +++ b/Spec/geometry-1.yml @@ -5,61 +5,31 @@ snapshot: https://www.w3.org/TR/2025/CRD-geometry-1-20251204/ sections: '#intro': title: 1 Introduction - status: unknown + status: n/a '#DOMPoint': title: 2 The DOMPoint interfaces - status: unknown - '#transforming-a-point-with-a-matrix': - title: 2.1 Transforming a point with a matrix - status: unknown + status: n/a '#DOMRect': title: 3 The DOMRect interfaces - status: unknown + status: n/a '#DOMRectList': title: 4 The DOMRectList interface - status: unknown + status: n/a '#DOMQuad': title: 5 The DOMQuad interface - status: unknown + status: n/a '#DOMMatrix': title: 6 The DOMMatrix interfaces - status: unknown - '#dommatrixinit-dictionary': - title: 6.1 DOMMatrix2DInit and DOMMatrixInit dictionaries - status: unknown - '#dommatrix-parse': - title: 6.2 Parsing a string into an abstract matrix - status: unknown - '#dommatrix-create': - title: 6.3 Creating DOMMatrixReadOnly and DOMMatrix objects - status: unknown - '#dommatrix-attributes': - title: 6.4 DOMMatrix attributes - status: unknown - '#immutable-transformation-methods': - title: 6.5 Immutable transformation methods - status: unknown - '#mutable-transformation-methods': - title: 6.6 Mutable transformation methods - status: unknown + status: n/a '#structured-serialization': title: 7 Structured serialization - status: unknown + status: n/a '#security': title: 8 Security Considerations - status: unknown + status: n/a '#priv-sec': title: 9 Privacy Considerations - status: unknown + status: n/a '#historical': title: 10 Historical - status: unknown - '#historical-cssom-view': - title: 10.1 CSSOM View - status: unknown - '#historical-svg': - title: 10.2 SVG - status: unknown - '#historical-non-standard': - title: 10.3 Non-standard - status: unknown + status: n/a From 2fe00bf7bc8d020033be257bfb8ed7ddd2645f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:24:54 +0100 Subject: [PATCH 128/150] Verify and document HTML introduction compliance --- Spec/html.yml | 68 +-------------------------------------------------- 1 file changed, 1 insertion(+), 67 deletions(-) diff --git a/Spec/html.yml b/Spec/html.yml index 85ebfa41f..1cd8413cb 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -5,73 +5,7 @@ snapshot: 7a1d2b8107ef368c4951f15f091d82fb62e59dde sections: introduction.html#introduction: title: 1 Introduction - status: unknown - introduction.html#abstract: - title: 1.1 Where does this specification fit? - status: unknown - introduction.html#is-this-html5?: - title: 1.2 Is this HTML5? - status: unknown - introduction.html#background: - title: 1.3 Background - status: unknown - introduction.html#audience: - title: 1.4 Audience - status: unknown - introduction.html#scope: - title: 1.5 Scope - status: unknown - introduction.html#history-2: - title: 1.6 History - status: unknown - introduction.html#design-notes: - title: 1.7 Design notes - status: unknown - introduction.html#serialisability-of-script-execution: - title: 1.7.1 Serializability of script execution - status: unknown - introduction.html#extensibility: - title: 1.7.2 Extensibility - status: unknown - introduction.html#html-vs-xhtml: - title: 1.8 HTML vs XML syntax - status: unknown - introduction.html#structure-of-this-specification: - title: 1.9 Structure of this specification - status: unknown - introduction.html#how-to-read-this-specification: - title: 1.9.1 How to read this specification - status: unknown - introduction.html#typographic-conventions: - title: 1.9.2 Typographic conventions - status: unknown - introduction.html#a-quick-introduction-to-html: - title: 1.10 A quick introduction to HTML - status: unknown - introduction.html#writing-secure-applications-with-html: - title: 1.10.1 Writing secure applications with HTML - status: unknown - introduction.html#common-pitfalls-to-avoid-when-using-the-scripting-apis: - title: 1.10.2 Common pitfalls to avoid when using the scripting APIs - status: unknown - introduction.html#how-to-catch-mistakes-when-writing-html:-validators-and-conformance-checkers: - title: '1.10.3 How to catch mistakes when writing HTML: validators and conformance checkers' - status: unknown - introduction.html#conformance-requirements-for-authors: - title: 1.11 Conformance requirements for authors - status: unknown - introduction.html#presentational-markup: - title: 1.11.1 Presentational markup - status: unknown - introduction.html#syntax-errors: - title: 1.11.2 Syntax errors - status: unknown - introduction.html#restrictions-on-content-models-and-on-attribute-values: - title: 1.11.3 Restrictions on content models and on attribute values - status: unknown - introduction.html#suggested-reading: - title: 1.12 Suggested reading - status: unknown + status: n/a infrastructure.html#infrastructure: title: 2 Common infrastructure status: unknown From bf8536573da6b22a0664e39916d0e65a02fcd7a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:26:39 +0100 Subject: [PATCH 129/150] Verify and document HTML common infrastructure compliance --- Source/HtmlRenderer/Adapters/RAdapter.cs | 7 +- Source/HtmlRenderer/Core/Utils/CommonUtils.cs | 6 +- .../CssModules/CssNamespacesTests.cs | 1 + .../Html/DocumentMetadataTests.cs | 6 + .../Html/InfrastructureTests.cs | 139 ++++++++++++++++++ Spec/html.yml | 137 ++++------------- 6 files changed, 179 insertions(+), 117 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/InfrastructureTests.cs diff --git a/Source/HtmlRenderer/Adapters/RAdapter.cs b/Source/HtmlRenderer/Adapters/RAdapter.cs index e34b4157d..dad5b9cf1 100644 --- a/Source/HtmlRenderer/Adapters/RAdapter.cs +++ b/Source/HtmlRenderer/Adapters/RAdapter.cs @@ -192,11 +192,8 @@ protected void OnColorSchemeChanged() /// <returns>the resolved resource, or null if it could not be resolved</returns> public Task<RNetworkResponse> GetResourceStream(RUri uri) { - // BaseUri is normally never null, so every reference resolves to an absolute URI and loaders - // only ever see those - RUri.Scheme throws on a relative URI, and neither DataUriNetworkLoader - // nor HttpClientNetworkLoader checks. Denying local file access is what makes BaseUri nullable, - // so a relative reference can now survive resolution; answer "unresolved" for it here rather - // than handing a loader a URI it cannot inspect. + // Without local file access BaseUri can be null and a relative reference stays relative. + // RUri.Scheme throws on it and the loaders don't check, so it's answered as unresolved here. if (!uri.IsAbsoluteUri) { return Task.FromResult<RNetworkResponse>(null); diff --git a/Source/HtmlRenderer/Core/Utils/CommonUtils.cs b/Source/HtmlRenderer/Core/Utils/CommonUtils.cs index 1e97811bb..8e81be372 100644 --- a/Source/HtmlRenderer/Core/Utils/CommonUtils.cs +++ b/Source/HtmlRenderer/Core/Utils/CommonUtils.cs @@ -203,10 +203,8 @@ public static RUri ResolveAgainstDocumentBase(TheArtOfDev.HtmlRenderer.Core.Html try { - // Deliberately not pre-filtered with Uri.IsWellFormedUriString: it applies stricter - // generic-syntax rules than Uri's own parser and rejects some URIs Uri/RUri parse - // correctly (e.g. certain percent-encoded `data:` URIs). new RUri(base, src) resolves an - // absolute src as-is, so this handles absolute and relative references uniformly. + // HTML §2.4.2. Not pre-filtered with Uri.IsWellFormedUriString, it rejects some URIs Uri + // parses fine, e.g. certain percent-encoded data: URIs. An absolute src is kept as is. return baseUri == null ? new RUri(src, UriKind.RelativeOrAbsolute) : new RUri(baseUri, src); } catch (UriFormatException) diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssNamespacesTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssNamespacesTests.cs index bdb067a99..016de943f 100644 --- a/Source/Test/HtmlRenderer.Test/CssModules/CssNamespacesTests.cs +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssNamespacesTests.cs @@ -89,6 +89,7 @@ public void ElementWithoutANamespace_IsntAnHtmlElement() [Ignore("Gap, see Spec/css-namespaces-3.yml")] [Spec("css-namespaces-3", "#css-qnames")] [Spec("css-namespaces-3", "#prefixes")] + [Spec("html", "infrastructure.html#xml")] public void DeclaredPrefix_MatchesItsNamespace() { Assert.AreEqual(Green, Styled($"@namespace h url({Html}); h|div#x {{ color: green }}").Color); diff --git a/Source/Test/HtmlRenderer.Test/Html/DocumentMetadataTests.cs b/Source/Test/HtmlRenderer.Test/Html/DocumentMetadataTests.cs index 1a19a4849..20ac746c5 100644 --- a/Source/Test/HtmlRenderer.Test/Html/DocumentMetadataTests.cs +++ b/Source/Test/HtmlRenderer.Test/Html/DocumentMetadataTests.cs @@ -39,6 +39,7 @@ public void MetadataInTheHead_IsProcessed() [TestMethod] [Spec("html", "semantics.html#the-base-element")] + [Spec("html", "urls-and-fetching.html#document-base-urls")] public void BaseHref_IsTheBaseForRelativeUrls() { var doc = Load("<base href='https://example.com/img/'>", "<img src=a.png>"); @@ -48,6 +49,7 @@ public void BaseHref_IsTheBaseForRelativeUrls() [TestMethod] [Spec("html", "semantics.html#the-base-element")] + [Spec("html", "urls-and-fetching.html#document-base-urls")] public void WithoutABase_RelativeUrlsResolveAgainstTheDocumentUrl() { var doc = Load("<link rel=stylesheet href=a.css>", "<img src=a.png>"); @@ -59,6 +61,7 @@ public void WithoutABase_RelativeUrlsResolveAgainstTheDocumentUrl() [TestMethod] [Ignore("Gap, see Spec/html.yml")] [Spec("html", "semantics.html#the-base-element")] + [Spec("html", "urls-and-fetching.html#document-base-urls")] public void BaseHref_IsTheBaseForStyleSheetLinks() { var doc = Load("<base href='https://example.com/css/'><link rel=stylesheet href=a.css>", "<p id=x>a</p>"); @@ -69,6 +72,7 @@ public void BaseHref_IsTheBaseForStyleSheetLinks() [TestMethod] [Ignore("Gap, see Spec/html.yml")] [Spec("html", "semantics.html#the-base-element")] + [Spec("html", "urls-and-fetching.html#document-base-urls")] public void RelativeBaseHref_IsResolvedAgainstTheDocumentUrl() { var doc = Load("<base href='img/'>", "<img src=a.png>"); @@ -79,6 +83,7 @@ public void RelativeBaseHref_IsResolvedAgainstTheDocumentUrl() [TestMethod] [Ignore("Gap, see Spec/html.yml")] [Spec("html", "semantics.html#the-base-element")] + [Spec("html", "urls-and-fetching.html#document-base-urls")] public void FirstBaseWithAnHref_IsUsed() { var doc = Load("<base target=_blank><base href='https://example.com/img/'>", "<img src=a.png>"); @@ -142,6 +147,7 @@ public void StyleSheetWithAnotherTitle_ThanThePreferredSet_IsDisabled(string hea [TestMethod] [Spec("html", "semantics.html#processing-the-type-attribute")] [Spec("cssom-1", "#fetching-css-style-sheets")] + [Spec("html", "urls-and-fetching.html#content-type-sniffing")] public void LinkedResourceOfAnotherContentType_IsNotApplied() { var doc = Load( diff --git a/Source/Test/HtmlRenderer.Test/Html/InfrastructureTests.cs b/Source/Test/HtmlRenderer.Test/Html/InfrastructureTests.cs new file mode 100644 index 000000000..48eaa7240 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/InfrastructureTests.cs @@ -0,0 +1,139 @@ +using System.Text; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Network; +using static HtmlRenderer.Test.Html.EmbeddedTestSupport; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class InfrastructureTests +{ + private const string DocumentUrl = "https://example.com/doc/index.html"; + + private static readonly RColor Green = RColor.FromArgb(0, 128, 0); + + [TestMethod] + [Spec("html", "infrastructure.html#extensibility-2")] + public void UnknownElementsAndAttributes_AreStyledByCss() + { + var box = Box("<style>foo[bar=baz] { display: block; color: green }</style><foo id=x bar=baz>a</foo>"); + + Assert.AreEqual("block", box.Display); + Assert.AreEqual(Green, box.ActualColor); + } + + [TestMethod] + [Spec("html", "infrastructure.html#xml")] + public void HtmlElements_MatchTheHtmlNamespace() + { + Assert.AreEqual(Green, Box("<style>@namespace url(http://www.w3.org/1999/xhtml); div#x { color: green }</style><div id=x>a</div>").ActualColor); + } + + [TestMethod] + [Spec("html", "urls-and-fetching.html#resolving-urls")] + [DataRow("a.png", "https://example.com/doc/a.png")] + [DataRow("../img/a.png", "https://example.com/img/a.png")] + [DataRow("/a.png", "https://example.com/a.png")] + [DataRow("//cdn.example.org/a.png", "https://cdn.example.org/a.png")] + [DataRow("?v=1", "https://example.com/doc/index.html?v=1")] + [DataRow("a b.png", "https://example.com/doc/a%20b.png")] + [DataRow("ä.png", "https://example.com/doc/%C3%A4.png")] + [DataRow("http://other.example/a.png", "http://other.example/a.png")] + [DataRow(" a.png ", "https://example.com/doc/a.png")] + [DataRow("img\\a.png", "https://example.com/doc/img/a.png")] + public void Url_IsParsedRelativeToTheDocument(string src, string expected) + { + CollectionAssert.AreEqual(new[] { expected }, Load($"<img src='{src}'>").Requested); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "urls-and-fetching.html#resolving-urls")] + [DataRow("a\n.png")] + [DataRow("a\t.png")] + public void TabsAndNewlines_AreRemoved(string src) + { + CollectionAssert.AreEqual(new[] { "https://example.com/doc/a.png" }, Load($"<img src='{src}'>").Requested); + } + + [TestMethod] + [Spec("html", "urls-and-fetching.html#content-type-sniffing")] + public void Image_IsDecodedWhateverItsContentType() + { + var doc = Load("<img id=x src=a.png>", new() { ["https://example.com/doc/a.png"] = "not a png" }, "text/plain"); + + Assert.IsNotNull(ImageOf(doc.Box("x"))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "urls-and-fetching.html#content-type-sniffing")] + [Spec("html", "links.html#link-type-stylesheet")] + [DataRow("text/cssx")] + [DataRow("application/x-text/css")] + public void StyleSheet_OfAnotherEssenceThanTextCss_IsNotApplied(string contentType) + { + var doc = Load( + "<link rel=stylesheet href=a.css><p id=x>a</p>", + new() { ["https://example.com/doc/a.css"] = "p { color: green }" }, + contentType); + + Assert.AreNotEqual(Green, doc.Box("x").ActualColor); + } + + [TestMethod] + [Spec("html", "urls-and-fetching.html#lazy-loading-attributes")] + public void LazyImage_IsLoadedWithoutScripting() + { + var doc = Load("<img id=x loading=lazy src=a.png style='margin-top: 10000px'>", new() { ["https://example.com/doc/a.png"] = "png" }); + + CollectionAssert.AreEqual(new[] { "https://example.com/doc/a.png" }, doc.Requested); + Assert.IsNotNull(ImageOf(doc.Box("x"))); + } + + private static Document Load(string body, Dictionary<string, string>? resources = null, string contentType = "text/css") + { + var loader = new StubLoader(resources ?? [], contentType); + var container = new HtmlContainerInt(new MockAdapter { NetworkLoader = loader }) { MaxSize = new RSize(1000, 4000), AvoidAsyncImagesLoading = true, AvoidImagesLateLoading = true }; + container.SetHtml($"<!DOCTYPE html><html><head></head><body>{body}</body></html>").GetAwaiter().GetResult(); + using (var graphics = new RecordingGraphics()) + container.PerformLayout(graphics); + + Assert.IsNotNull(container.Root); + return new Document(container.Root!, loader.Requested); + } + + private sealed record Document(CssBox Root, List<string> Requested) + { + public CssBox Box(string id) + { + var box = LayoutHarness.FindById(Root, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box; + } + } + + /// <summary>Serves <c>DocumentUrl</c> as the document's URL and the given resources from memory.</summary> + private sealed class StubLoader(Dictionary<string, string> resources, string contentType) : RNetworkLoader + { + public List<string> Requested { get; } = []; + + public override RUri? BaseUri => new(DocumentUrl); + + public override Task<string> GetPrimaryContents() => Task.FromResult(string.Empty); + + public override Task<RNetworkResponse?> GetResourceStream(RUri uri) + { + Requested.Add(uri.AbsoluteUri); + if (!resources.TryGetValue(uri.AbsoluteUri, out var content)) + return Task.FromResult<RNetworkResponse?>(null); + + var headers = new Dictionary<string, string[]> { ["Content-Type"] = [contentType] }; + return Task.FromResult<RNetworkResponse?>(new RNetworkResponse(new MemoryStream(Encoding.UTF8.GetBytes(content)), headers)); + } + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 1cd8413cb..18cc967b5 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -8,46 +8,21 @@ sections: status: n/a infrastructure.html#infrastructure: title: 2 Common infrastructure - status: unknown + status: partial infrastructure.html#terminology: title: 2.1 Terminology - status: unknown - infrastructure.html#parallelism: - title: 2.1.1 Parallelism - status: unknown - infrastructure.html#resources: - title: 2.1.2 Resources - status: unknown + status: n/a infrastructure.html#xml: title: 2.1.3 XML compatibility - status: unknown - infrastructure.html#dom-trees: - title: 2.1.4 DOM trees - status: unknown - infrastructure.html#scripting-2: - title: 2.1.5 Scripting - status: unknown - infrastructure.html#plugins: - title: 2.1.6 Plugins - status: unknown - infrastructure.html#encoding-terminology: - title: 2.1.7 Character encodings - status: unknown - infrastructure.html#conformance-classes: - title: 2.1.8 Conformance classes - status: unknown - infrastructure.html#dependencies: - title: 2.1.9 Dependencies - status: unknown + status: partial + gaps: + - "Elements have no namespace, a prefix declared as the HTML namespace doesn't match them, see css-namespaces-3 #css-qnames" infrastructure.html#extensibility-2: title: 2.1.10 Extensibility - status: unknown - infrastructure.html#interactions-with-xpath-and-xslt: - title: 2.1.11 Interactions with XPath and XSLT - status: unknown + status: supported infrastructure.html#policy-controlled-features: title: 2.2 Policy-controlled features - status: unknown + status: n/a common-microsyntaxes.html#common-microsyntaxes: title: 2.3 Common microsyntaxes status: partial @@ -123,112 +98,58 @@ sections: status: n/a urls-and-fetching.html#urls: title: 2.4 URLs - status: unknown + status: partial urls-and-fetching.html#terminology-2: title: 2.4.1 Terminology - status: unknown + status: n/a urls-and-fetching.html#resolving-urls: title: 2.4.2 Parsing URLs - status: unknown + status: partial + gaps: + - Tabs and newlines inside a URL aren't removed, they end up in the requested URL urls-and-fetching.html#document-base-urls: title: 2.4.3 Document base URLs - status: unknown + status: partial + gaps: + - The base element is only partly applied, see 4.2.3 urls-and-fetching.html#fetching-resources: title: 2.5 Fetching resources - status: unknown + status: partial urls-and-fetching.html#terminology-3: title: 2.5.1 Terminology - status: unknown + status: n/a urls-and-fetching.html#content-type-sniffing: title: 2.5.2 Determining the type of a resource - status: unknown + status: partial + gaps: + - A style sheet's Content-Type is matched as a substring, text/cssx counts as text/css urls-and-fetching.html#extracting-character-encodings-from-meta-elements: title: 2.5.3 Extracting character encodings from meta elements - status: unknown + status: n/a urls-and-fetching.html#cors-settings-attributes: title: 2.5.4 CORS settings attributes - status: unknown + status: n/a urls-and-fetching.html#referrer-policy-attributes: title: 2.5.5 Referrer policy attributes - status: unknown + status: n/a urls-and-fetching.html#nonce-attributes: title: 2.5.6 Nonce attributes - status: unknown + status: n/a urls-and-fetching.html#lazy-loading-attributes: title: 2.5.7 Lazy loading attributes - status: unknown + status: supported urls-and-fetching.html#blocking-attributes: title: 2.5.8 Blocking attributes - status: unknown + status: n/a urls-and-fetching.html#fetch-priority-attributes: title: 2.5.9 Fetch priority attributes - status: unknown + status: n/a common-dom-interfaces.html#common-dom-interfaces: title: 2.6 Common DOM interfaces - status: unknown - common-dom-interfaces.html#reflecting-content-attributes-in-idl-attributes: - title: 2.6.1 Reflecting content attributes in IDL attributes - status: unknown - common-dom-interfaces.html#using-reflect-via-idl-extended-attributes: - title: 2.6.2 Using reflect via IDL extended attributes - status: unknown - common-dom-interfaces.html#using-reflect-in-specifications: - title: 2.6.3 Using reflect in specifications - status: unknown - common-dom-interfaces.html#collections: - title: 2.6.4 Collections - status: unknown - common-dom-interfaces.html#the-htmlallcollection-interface: - title: 2.6.4.1 The HTMLAllCollection interface - status: unknown - common-dom-interfaces.html#HTMLAllCollection-call: - title: 2.6.4.1.1 [[Call]] ( thisArgument, argumentsList ) - status: unknown - common-dom-interfaces.html#the-htmlformcontrolscollection-interface: - title: 2.6.4.2 The HTMLFormControlsCollection interface - status: unknown - common-dom-interfaces.html#the-htmloptionscollection-interface: - title: 2.6.4.3 The HTMLOptionsCollection interface - status: unknown - common-dom-interfaces.html#the-domstringlist-interface: - title: 2.6.5 The DOMStringList interface - status: unknown + status: n/a structured-data.html#safe-passing-of-structured-data: title: 2.7 Safe passing of structured data - status: unknown - structured-data.html#serializable-objects: - title: 2.7.1 Serializable objects - status: unknown - structured-data.html#transferable-objects: - title: 2.7.2 Transferable objects - status: unknown - structured-data.html#restricting-to-the-same-agent-cluster: - title: 2.7.3 Restricting to the same agent cluster - status: unknown - structured-data.html#structuredserializeinternal: - title: 2.7.4 StructuredSerializeInternal ( value, forStorage [ , memory ] ) - status: unknown - structured-data.html#structuredserialize: - title: 2.7.5 StructuredSerialize ( value ) - status: unknown - structured-data.html#structuredserializeforstorage: - title: 2.7.6 StructuredSerializeForStorage ( value ) - status: unknown - structured-data.html#structureddeserialize: - title: 2.7.7 StructuredDeserialize ( serialized, targetRealm [ , memory ] ) - status: unknown - structured-data.html#structuredserializewithtransfer: - title: 2.7.8 StructuredSerializeWithTransfer ( value, transferList ) - status: unknown - structured-data.html#structureddeserializewithtransfer: - title: 2.7.9 StructuredDeserializeWithTransfer ( serializeWithTransferResult, targetRealm ) - status: unknown - structured-data.html#performing-structured-clones-from-other-specifications: - title: 2.7.10 Performing serialization and transferring from other specifications - status: unknown - structured-data.html#structured-cloning: - title: 2.7.11 Structured cloning API - status: unknown + status: n/a dom.html#dom: title: 3 Semantics, structure, and APIs of HTML documents status: unknown From 38229a7ff08e424a8def8625fb57143220b24550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:26:56 +0100 Subject: [PATCH 130/150] Verify and document HTML documents compliance --- Spec/html.yml | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/Spec/html.yml b/Spec/html.yml index 18cc967b5..eccdac9cb 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -152,31 +152,10 @@ sections: status: n/a dom.html#dom: title: 3 Semantics, structure, and APIs of HTML documents - status: unknown + status: partial dom.html#documents: title: 3.1 Documents - status: unknown - dom.html#the-document-object: - title: 3.1.1 The Document object - status: unknown - dom.html#the-documentorshadowroot-interface: - title: 3.1.2 The DocumentOrShadowRoot interface - status: unknown - dom.html#ancestor-origins: - title: 3.1.3 Ancestor origins - status: unknown - dom.html#resource-metadata-management: - title: 3.1.4 Resource metadata management - status: unknown - dom.html#reporting-document-loading-status: - title: 3.1.5 Reporting document loading status - status: unknown - dom.html#render-blocking-mechanism: - title: 3.1.6 Render-blocking mechanism - status: unknown - dom.html#dom-tree-accessors: - title: 3.1.7 DOM tree accessors - status: unknown + status: n/a dom.html#elements: title: 3.2 Elements status: partial From 15c4ddf2e2e407ce3f2fc3f1f7855ff82b8852f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:29:43 +0100 Subject: [PATCH 131/150] Verify and document HTML IANA considerations compliance --- .../Test/HtmlRenderer.Test/Html/LinksTests.cs | 3 +++ Spec/html.yml | 20 ++++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs b/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs index b70492bb3..5c99505e5 100644 --- a/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs +++ b/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs @@ -62,6 +62,7 @@ public void Click_ResolvesTheHrefAgainstTheBaseUrl() [TestMethod] [Spec("html", "links.html#following-hyperlinks")] + [Spec("html", "iana.html#text/html")] public void FragmentLink_ScrollsToTheElement() { var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -73,6 +74,7 @@ public void FragmentLink_ScrollsToTheElement() [TestMethod] [Ignore("Gap, see Spec/html.yml")] [Spec("html", "links.html#following-hyperlinks")] + [Spec("html", "iana.html#text/html")] public void FragmentLink_MatchesTheIdCaseSensitively() { var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -84,6 +86,7 @@ public void FragmentLink_MatchesTheIdCaseSensitively() [TestMethod] [Ignore("Gap, see Spec/html.yml")] [Spec("html", "links.html#following-hyperlinks")] + [Spec("html", "iana.html#text/html")] public void FragmentLink_FallsBackToANamedAnchor() { var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Spec/html.yml b/Spec/html.yml index eccdac9cb..16c9e8c32 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -3309,28 +3309,30 @@ sections: - listing, plaintext and xmp aren't rendered like pre, see 15.3.3 iana.html#iana: title: 17 IANA considerations - status: unknown + status: partial iana.html#text/html: title: 17.1 text/html - status: unknown + status: partial + gaps: + - A fragment matches ids case-insensitively and doesn't fall back to the name of an a element, see 4.6.5 iana.html#multipart/x-mixed-replace: title: 17.2 multipart/x-mixed-replace - status: unknown + status: n/a iana.html#application/xhtml+xml: title: 17.3 application/xhtml+xml - status: unknown + status: n/a iana.html#text/ping: title: 17.4 text/ping - status: unknown + status: n/a iana.html#application/microdata+json: title: 17.5 application/microdata+json - status: unknown + status: n/a iana.html#application/speculationrules+json: title: 17.6 application/speculationrules+json - status: unknown + status: n/a iana.html#text/event-stream: title: 17.7 text/event-stream - status: unknown + status: n/a iana.html#web+-scheme-prefix: title: 17.8 web+ scheme prefix - status: unknown + status: n/a From 27a8d4e9c818f295b14356bd5e4a7c8df6d6a95b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:26:38 +0100 Subject: [PATCH 132/150] Verify and document HTML microdata compliance --- Spec/html.yml | 74 +-------------------------------------------------- 1 file changed, 1 insertion(+), 73 deletions(-) diff --git a/Spec/html.yml b/Spec/html.yml index 16c9e8c32..db9f5831c 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1227,79 +1227,7 @@ sections: - ":paused and :muted never match a media element" microdata.html#microdata: title: 5 Microdata - status: unknown - microdata.html#introduction-7: - title: 5.1 Introduction - status: unknown - microdata.html#overview: - title: 5.1.1 Overview - status: unknown - microdata.html#the-basic-syntax: - title: 5.1.2 The basic syntax - status: unknown - microdata.html#typed-items: - title: 5.1.3 Typed items - status: unknown - microdata.html#global-identifiers-for-items: - title: 5.1.4 Global identifiers for items - status: unknown - microdata.html#selecting-names-when-defining-vocabularies: - title: 5.1.5 Selecting names when defining vocabularies - status: unknown - microdata.html#encoding-microdata: - title: 5.2 Encoding microdata - status: unknown - microdata.html#the-microdata-model: - title: 5.2.1 The microdata model - status: unknown - microdata.html#items: - title: 5.2.2 Items - status: unknown - microdata.html#names:-the-itemprop-attribute: - title: '5.2.3 Names: the itemprop attribute' - status: unknown - microdata.html#values: - title: 5.2.4 Values - status: unknown - microdata.html#associating-names-with-items: - title: 5.2.5 Associating names with items - status: unknown - microdata.html#microdata-and-other-namespaces: - title: 5.2.6 Microdata and other namespaces - status: unknown - microdata.html#mdvocabs: - title: 5.3 Sample microdata vocabularies - status: unknown - microdata.html#vcard: - title: 5.3.1 vCard - status: unknown - microdata.html#conversion-to-vcard: - title: 5.3.1.1 Conversion to vCard - status: unknown - microdata.html#examples-2: - title: 5.3.1.2 Examples - status: unknown - microdata.html#vevent: - title: 5.3.2 vEvent - status: unknown - microdata.html#conversion-to-icalendar: - title: 5.3.2.1 Conversion to iCalendar - status: unknown - microdata.html#examples-3: - title: 5.3.2.2 Examples - status: unknown - microdata.html#licensing-works: - title: 5.3.3 Licensing works - status: unknown - microdata.html#examples-4: - title: 5.3.3.1 Examples - status: unknown - microdata.html#converting-html-to-other-formats: - title: 5.4 Converting HTML to other formats - status: unknown - microdata.html#json: - title: 5.4.1 JSON - status: unknown + status: n/a interaction.html#editing: title: 6 User interaction status: unknown From e4254b8cd20cdcde7198bc9c15fb2459df6d963c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:28:54 +0100 Subject: [PATCH 133/150] Verify and document HTML user interaction compliance --- .../Core/Handlers/SelectionHandler.cs | 4 +- .../Test/HtmlRenderer.Test/Html/LinksTests.cs | 1 + .../Html/UserInteractionTests.cs | 155 +++++++++++++++ .../TestSupport/MockAdapter.cs | 2 + Spec/html.yml | 180 ++++++++++-------- 5 files changed, 257 insertions(+), 85 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/UserInteractionTests.cs diff --git a/Source/HtmlRenderer/Core/Handlers/SelectionHandler.cs b/Source/HtmlRenderer/Core/Handlers/SelectionHandler.cs index c9b75ef25..74c411bd5 100644 --- a/Source/HtmlRenderer/Core/Handlers/SelectionHandler.cs +++ b/Source/HtmlRenderer/Core/Handlers/SelectionHandler.cs @@ -246,7 +246,7 @@ public void HandleMouseMove(RControl parent, RPoint loc) { if (_mouseDownOnSelectedWord) { - // make sure not to start drag-drop on click but when it actually moves as it fucks mouse-up + // A drag right after the mouse down would swallow the mouse up of a plain click if ((DateTime.Now - _lastMouseDown).TotalMilliseconds > 200) StartDragDrop(parent); } @@ -510,7 +510,7 @@ private static void ClearSelection(CssBox box) } /// <summary> - /// Start drag & drop operation on the currently selected html segment. + /// Drags the selection as HTML and plain text, HTML §6.11.5. /// </summary> /// <param name="control">the control to start the drag & drop on</param> private void StartDragDrop(RControl control) diff --git a/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs b/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs index 5c99505e5..20a5b9449 100644 --- a/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs +++ b/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs @@ -42,6 +42,7 @@ public void IsmapImage_AppendsTheClickCoordinates() [TestMethod] [Spec("html", "links.html#following-hyperlinks")] + [Spec("html", "interaction.html#activation")] public void Click_RaisesLinkClickedWithTheHref() { var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap("<a id=x href=https://example.com/>a</a>")); diff --git a/Source/Test/HtmlRenderer.Test/Html/UserInteractionTests.cs b/Source/Test/HtmlRenderer.Test/Html/UserInteractionTests.cs new file mode 100644 index 000000000..34e0a8093 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/UserInteractionTests.cs @@ -0,0 +1,155 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Utils; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class UserInteractionTests +{ + private static readonly RColor Red = RColor.FromArgb(255, 0, 0); + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "interaction.html#the-hidden-attribute")] + [DataRow("<div id=x hidden>a</div>")] + [DataRow("<div id=x hidden=''>a</div>")] + [DataRow("<div id=x hidden=foo>a</div>")] + public void Hidden_IsNotRendered(string html) + { + Assert.AreEqual("none", Box(html).Display); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "interaction.html#the-hidden-attribute")] + public void HiddenUntilFound_KeepsItsBoxButSkipsItsContent() + { + var box = Box("<div id=x hidden=until-found style='border:1px solid'>a</div>"); + + Assert.AreEqual("block", box.Display); + Assert.AreEqual(2, box.Size.Height, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "interaction.html#the-inert-attribute")] + public void InertLink_IsNotHit() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("<div inert><a id=x href=page.html>link</a></div>")); + + Assert.IsNull(DomUtils.GetLinkBox(root, Center(LayoutHarness.FindById(root, "x")!))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "interaction.html#the-inert-attribute")] + public void InertText_IsNotSelected() + { + var (_, container) = LayoutHarness.Layout(LayoutHarness.Wrap("<p>active</p><p inert>inert</p>")); + + container.HandleKeyDown(new MockControl(container.Adapter), new RKeyEvent(true, true, false)); + + Assert.AreEqual("active", container.SelectedText.Trim()); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "interaction.html#the-autofocus-attribute")] + public void Autofocus_FocusesTheElement() + { + var box = Box("<style>#x:focus { color: red }</style><div id=x tabindex=0 autofocus>a</div>"); + + Assert.AreEqual(Red, box.ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "interaction.html#contenteditable")] + public void Contenteditable_MakesTheElementEditable() + { + var box = Box("<style>#x:read-write { color: red }</style><div id=x contenteditable>a</div>"); + + Assert.AreEqual(Red, box.ActualColor); + } + + [TestMethod] + [Spec("html", "dnd.html#drag-and-drop-processing-model")] + public void DraggingTheSelection_DragsItsHtmlAndText() + { + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap("<p id=x>hello</p>")); + var control = new MockControl(container.Adapter); + container.HandleKeyDown(control, new RKeyEvent(true, true, false)); + + var dragged = Drag(container, control, Center(LayoutHarness.FindById(root, "x")!)); + + Assert.IsNotNull(dragged); + var (html, text) = ((string, string))dragged; + StringAssert.Contains(html, "hello"); + Assert.AreEqual("hello", text.Trim()); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "dnd.html#drag-and-drop-processing-model")] + public void DraggingALink_DragsIt() + { + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap("<a id=x href=page.html>link</a>")); + + Assert.IsNotNull(Drag(container, new MockControl(container.Adapter), Center(LayoutHarness.FindById(root, "x")!))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "popover.html#the-popover-attribute")] + [DataRow("<div id=x popover>a</div>")] + [DataRow("<div id=x popover=manual>a</div>")] + public void Popover_IsHiddenUntilShown(string html) + { + Assert.AreEqual("none", Box(html).Display); + } + + /// <summary>The center of the first word inside the box.</summary> + private static RPoint Center(CssBox box) + { + var word = LayoutHarness.Descendants(box).SelectMany(b => b.Words).First(); + return new RPoint(word.Left + word.Width / 2, word.Top + word.Height / 2); + } + + /// <summary>Presses the left button at the location and moves the mouse, returns what got dragged, if anything.</summary> + private static object? Drag(HtmlContainerInt container, MockControl control, RPoint location) + { + control.LeftMouseButtonDown = true; + container.HandleMouseDown(control, location); + // A drag only starts once the button has been held for 200ms. + Thread.Sleep(250); + container.HandleMouseMove(control, new RPoint(location.X + 5, location.Y)); + return control.Dragged; + } + + private sealed class MockControl(RAdapter adapter) : RControl(adapter) + { + public bool LeftMouseButtonDown { get; set; } + public object? Dragged { get; private set; } + + public override bool LeftMouseButton => LeftMouseButtonDown; + public override bool RightMouseButton => false; + public override RPoint MouseLocation => RPoint.Empty; + public override void SetCursorDefault() { } + public override void SetCursorHand() { } + public override void SetCursorIBeam() { } + public override void DoDragDropCopy(object dragDropData) => Dragged = dragDropData; + + public override void MeasureString(string str, RFont font, double maxWidth, out int charFit, out double charFitWidth) + { + charFit = str.Length; + charFitWidth = 0; + } + + public override void Invalidate() { } + } +} diff --git a/Source/Test/HtmlRenderer.Test/TestSupport/MockAdapter.cs b/Source/Test/HtmlRenderer.Test/TestSupport/MockAdapter.cs index 56c664307..edd0a5e64 100644 --- a/Source/Test/HtmlRenderer.Test/TestSupport/MockAdapter.cs +++ b/Source/Test/HtmlRenderer.Test/TestSupport/MockAdapter.cs @@ -44,6 +44,8 @@ protected override RBrush CreateLinearGradientBrush(RPoint p1, RPoint p2, (RColo protected override RFont CreateFontInt(RFontFamily family, double size, RFontStyle style) => new MockFont(size); protected override RFontFamily LoadFontFaceFontInt(byte[] fontBytes, string filePath) => new MockFontFamily(filePath); + + protected override object GetClipboardDataObjectInt(string html, string plainText) => (html, plainText); } /// <summary>A pen that remembers the color it was created with.</summary> diff --git a/Spec/html.yml b/Spec/html.yml index db9f5831c..bff17754a 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1230,187 +1230,201 @@ sections: status: n/a interaction.html#editing: title: 6 User interaction - status: unknown + status: partial interaction.html#the-hidden-attribute: title: 6.1 The hidden attribute - status: unknown + status: unsupported + gaps: + - The hidden attribute is ignored, see 15.3.1 + - hidden=until-found doesn't skip the element's content interaction.html#page-visibility: title: 6.2 Page visibility - status: unknown - interaction.html#the-visibilitystateentry-interface: - title: 6.2.1 The VisibilityStateEntry interface - status: unknown + status: n/a interaction.html#inert-subtrees: title: 6.3 Inert subtrees - status: unknown + status: unsupported + gaps: + - Nothing is ever inert, see 6.3.2 interaction.html#modal-dialogs-and-inert-subtrees: title: 6.3.1 Modal dialogs and inert subtrees - status: unknown + status: n/a + gaps: + - Only showModal() makes a dialog modal, which needs script interaction.html#the-inert-attribute: title: 6.3.2 The inert attribute - status: unknown + status: unsupported + gaps: + - inert is ignored, links in it can be clicked and its text gets selected interaction.html#tracking-user-activation: title: 6.4 Tracking user activation - status: unknown - interaction.html#user-activation-data-model: - title: 6.4.1 Data model - status: unknown - interaction.html#user-activation-processing-model: - title: 6.4.2 Processing model - status: unknown - interaction.html#user-activation-gated-apis: - title: 6.4.3 APIs gated by user activation - status: unknown - interaction.html#the-useractivation-interface: - title: 6.4.4 The UserActivation interface - status: unknown - interaction.html#user-activation-user-agent-automation: - title: 6.4.5 User agent automation - status: unknown + status: n/a interaction.html#activation: title: 6.5 Activation behavior of elements - status: unknown + status: partial + gaps: + - Only links can be activated, a click on a summary, label or button does nothing + - Only by mouse click, there's no keyboard activation, see 6.6 interaction.html#the-toggleevent-interface: title: 6.5.1 The ToggleEvent interface - status: unknown + status: n/a interaction.html#the-commandevent-interface: title: 6.5.2 The CommandEvent interface - status: unknown + status: n/a interaction.html#focus: title: 6.6 Focus - status: unknown + status: unsupported + gaps: + - Nothing can be focused, :focus and :focus-visible never match interaction.html#introduction-8: title: 6.6.1 Introduction - status: unknown + status: n/a interaction.html#data-model: title: 6.6.2 Data model - status: unknown + status: unsupported interaction.html#the-tabindex-attribute: title: 6.6.3 The tabindex attribute - status: unknown + status: unsupported + gaps: + - tabindex is ignored interaction.html#focus-processing-model: title: 6.6.4 Processing model - status: unknown + status: unsupported interaction.html#sequential-focus-navigation: title: 6.6.5 Sequential focus navigation - status: unknown + status: unsupported + gaps: + - Tab doesn't move through the links interaction.html#focus-management-apis: title: 6.6.6 Focus management APIs - status: unknown + status: n/a interaction.html#the-autofocus-attribute: title: 6.6.7 The autofocus attribute - status: unknown + status: unsupported + gaps: + - autofocus doesn't focus the element interaction.html#assigning-keyboard-shortcuts: title: 6.7 Assigning keyboard shortcuts - status: unknown + status: unsupported interaction.html#introduction-9: title: 6.7.1 Introduction - status: unknown + status: n/a interaction.html#the-accesskey-attribute: title: 6.7.2 The accesskey attribute - status: unknown + status: unsupported + gaps: + - accesskey is ignored interaction.html#keyboard-shortcuts-processing-model: title: 6.7.3 Processing model - status: unknown + status: unsupported interaction.html#editing-2: title: 6.8 Editing - status: unknown + status: unsupported interaction.html#contenteditable: title: '6.8.1 Making document regions editable: The contenteditable content attribute' - status: unknown + status: unsupported + gaps: + - contenteditable is ignored, the content can't be edited and :read-write doesn't match it interaction.html#making-entire-documents-editable:-the-designmode-idl-attribute: title: '6.8.2 Making entire documents editable: the designMode getter and setter' - status: unknown + status: n/a interaction.html#best-practices-for-in-page-editors: title: 6.8.3 Best practices for in-page editors - status: unknown + status: n/a interaction.html#editing-apis: title: 6.8.4 Editing APIs - status: unknown + status: n/a interaction.html#spelling-and-grammar-checking: title: 6.8.5 Spelling and grammar checking - status: unknown + status: n/a + gaps: + - Only applies to editable text, see 6.8.1 interaction.html#writing-suggestions: title: 6.8.6 Writing suggestions - status: unknown + status: n/a + gaps: + - Only applies to editable text, see 6.8.1 interaction.html#autocapitalization: title: 6.8.7 Autocapitalization - status: unknown + status: n/a + gaps: + - Only applies to editable text, see 6.8.1 interaction.html#autocorrection: title: 6.8.8 Autocorrection - status: unknown + status: n/a + gaps: + - Only applies to editable text, see 6.8.1 interaction.html#input-modalities:-the-inputmode-attribute: title: '6.8.9 Input modalities: the inputmode attribute' - status: unknown + status: n/a + gaps: + - Only applies to editable text, see 6.8.1 interaction.html#input-modalities:-the-enterkeyhint-attribute: title: '6.8.10 Input modalities: the enterkeyhint attribute' - status: unknown + status: n/a + gaps: + - Only applies to editable text, see 6.8.1 interaction.html#find-in-page: title: 6.9 Find-in-page - status: unknown + status: unsupported + gaps: + - There's no find-in-page interaction.html#introduction-10: title: 6.9.1 Introduction - status: unknown + status: n/a interaction.html#interaction-with-details-and-hidden=until-found: title: 6.9.2 Interaction with details and hidden=until-found - status: unknown + status: unsupported interaction.html#interaction-with-selection: title: 6.9.3 Interaction with selection - status: unknown + status: unsupported interaction.html#close-requests-and-close-watchers: title: 6.10 Close requests and close watchers - status: unknown - interaction.html#close-requests: - title: 6.10.1 Close requests - status: unknown - interaction.html#close-watcher-infrastructure: - title: 6.10.2 Close watcher infrastructure - status: unknown - interaction.html#the-closewatcher-interface: - title: 6.10.3 The CloseWatcher interface - status: unknown + status: n/a dnd.html#dnd: title: 6.11 Drag and drop - status: unknown + status: partial dnd.html#event-drag: title: 6.11.1 Introduction - status: unknown + status: n/a dnd.html#the-drag-data-store: title: 6.11.2 The drag data store - status: unknown + status: n/a dnd.html#the-datatransfer-interface: title: 6.11.3 The DataTransfer interface - status: unknown - dnd.html#the-datatransferitemlist-interface: - title: 6.11.3.1 The DataTransferItemList interface - status: unknown - dnd.html#the-datatransferitem-interface: - title: 6.11.3.2 The DataTransferItem interface - status: unknown + status: n/a dnd.html#the-dragevent-interface: title: 6.11.4 The DragEvent interface - status: unknown + status: n/a dnd.html#drag-and-drop-processing-model: title: 6.11.5 Processing model - status: unknown + status: partial + gaps: + - Only a selection can be dragged, links and images can't + - Nothing can be dropped onto the document dnd.html#dndevents: title: 6.11.6 Events summary - status: unknown + status: n/a dnd.html#the-draggable-attribute: title: 6.11.7 The draggable attribute - status: unknown + status: unsupported + gaps: + - draggable is ignored dnd.html#security-risks-in-the-drag-and-drop-model: title: 6.11.8 Security risks in the drag-and-drop model - status: unknown + status: n/a popover.html#the-popover-attribute: title: 6.12 The popover attribute - status: unknown + status: unsupported + gaps: + - A popover is displayed before it's shown, see 15.3.3 popover.html#the-popover-target-attributes: title: 6.12.1 The popover target attributes - status: unknown + status: unsupported + gaps: + - popovertarget on a button doesn't toggle the popover popover.html#popover-light-dismiss: title: 6.12.2 Popover light dismiss - status: unknown + status: n/a browsers.html#browsers: title: 7 Loading web pages status: unknown From dbd7cdf7ad0fad3722573a3fc4128a2f600411f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:31:50 +0100 Subject: [PATCH 134/150] Verify and document HTML XML syntax compliance --- .../HtmlRenderer.Test/Html/XmlSyntaxTests.cs | 36 +++++++++++++++++++ Spec/html.yml | 17 ++++++--- 2 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/XmlSyntaxTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Html/XmlSyntaxTests.cs b/Source/Test/HtmlRenderer.Test/Html/XmlSyntaxTests.cs new file mode 100644 index 000000000..cfaaddd2a --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/XmlSyntaxTests.cs @@ -0,0 +1,36 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class XmlSyntaxTests +{ + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "xhtml.html#parsing-xhtml-documents")] + public void XmlDocument_CdataSectionIsText() + { + Assert.AreEqual("a<b", Text(Xml("<p id=\"x\"><![CDATA[a<b]]></p>"))); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "xhtml.html#parsing-xhtml-documents")] + public void XmlDocument_PrefixedElementIsInTheHtmlNamespace() + { + Assert.AreEqual("block", Xml("<h:p xmlns:h=\"http://www.w3.org/1999/xhtml\" id=\"x\">a</h:p>").Display); + } + + /// <summary>Lays out an XHTML document with the body markup and returns the box with id "x".</summary> + private static CssBox Xml(string body) + { + var (root, _) = LayoutHarness.Layout( + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + + "<html xmlns=\"http://www.w3.org/1999/xhtml\"><head><title>t</title></head><body>" + body + "</body></html>"); + return LayoutHarness.FindById(root, "x")!; + } + + private static string Text(CssBox box) => + string.Concat(LayoutHarness.Descendants(box).SelectMany(b => b.Words).Select(w => w.Text)).Trim(); +} diff --git a/Spec/html.yml b/Spec/html.yml index bff17754a..a72107d54 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -2969,19 +2969,26 @@ sections: status: supported xhtml.html#the-xhtml-syntax: title: 14 The XML syntax - status: unknown + status: unsupported + gaps: + - There's no XML parser, every document goes through the HTML parser xhtml.html#writing-xhtml-documents: title: 14.1 Writing documents in the XML syntax - status: unknown + status: n/a xhtml.html#parsing-xhtml-documents: title: 14.2 Parsing XML documents - status: unknown + status: unsupported + gaps: + - A document can't be marked as XML, XHTML is parsed as HTML + - CDATA sections are parsed as comments, their text isn't shown + - Prefixed elements aren't resolved through their namespace declaration + - Well-formedness errors aren't fatal xhtml.html#serialising-xhtml-fragments: title: 14.3 Serializing XML fragments - status: unknown + status: n/a xhtml.html#parsing-xhtml-fragments: title: 14.4 Parsing XML fragments - status: unknown + status: n/a rendering.html#rendering: title: 15 Rendering status: partial From 212a006a7c9edcc1f271b03d4c02c5a2cbed723e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:22:53 +0100 Subject: [PATCH 135/150] Verify and document HTML loading web pages compliance --- .../Test/HtmlRenderer.Test/Html/LinksTests.cs | 3 + .../Html/LoadingWebPagesTests.cs | 86 ++++ Spec/html.yml | 446 +----------------- 3 files changed, 115 insertions(+), 420 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/LoadingWebPagesTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs b/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs index 20a5b9449..1146b1935 100644 --- a/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs +++ b/Source/Test/HtmlRenderer.Test/Html/LinksTests.cs @@ -64,6 +64,7 @@ public void Click_ResolvesTheHrefAgainstTheBaseUrl() [TestMethod] [Spec("html", "links.html#following-hyperlinks")] [Spec("html", "iana.html#text/html")] + [Spec("html", "browsing-the-web.html#scrolling-to-a-fragment")] public void FragmentLink_ScrollsToTheElement() { var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -76,6 +77,7 @@ public void FragmentLink_ScrollsToTheElement() [Ignore("Gap, see Spec/html.yml")] [Spec("html", "links.html#following-hyperlinks")] [Spec("html", "iana.html#text/html")] + [Spec("html", "browsing-the-web.html#scrolling-to-a-fragment")] public void FragmentLink_MatchesTheIdCaseSensitively() { var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -88,6 +90,7 @@ public void FragmentLink_MatchesTheIdCaseSensitively() [Ignore("Gap, see Spec/html.yml")] [Spec("html", "links.html#following-hyperlinks")] [Spec("html", "iana.html#text/html")] + [Spec("html", "browsing-the-web.html#scrolling-to-a-fragment")] public void FragmentLink_FallsBackToANamedAnchor() { var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.Test/Html/LoadingWebPagesTests.cs b/Source/Test/HtmlRenderer.Test/Html/LoadingWebPagesTests.cs new file mode 100644 index 000000000..eb564aa07 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/LoadingWebPagesTests.cs @@ -0,0 +1,86 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class LoadingWebPagesTests +{ + [TestMethod] + [Spec("html", "browsing-the-web.html#scrolling-to-a-fragment")] + public void FragmentLink_ScrollsToTheFirstElementWithTheId() + { + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<a id=x href=#t>a</a><div id=t style='margin-top:100px'>b</div><p id=t style='margin-top:100px'>c</p>")); + + Assert.AreEqual(LayoutHarness.FindById(root, "t")!.Location.Y, ScrollTarget(container, LayoutHarness.FindById(root, "x")!)); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "browsing-the-web.html#scrolling-to-a-fragment")] + public void FragmentLink_IsPercentDecoded() + { + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<a id=x href=#caf%C3%A9>a</a><p id=café style='margin-top:100px'>b</p>")); + + Assert.AreEqual(LayoutHarness.FindById(root, "café")!.Location.Y, ScrollTarget(container, LayoutHarness.FindById(root, "x")!)); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "browsing-the-web.html#scrolling-to-a-fragment")] + public void TopFragment_ScrollsToTheTopOfTheDocument() + { + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( + "<p style='margin-top:100px'><a id=x href=#top>a</a></p>")); + + Assert.AreEqual(0, ScrollTarget(container, LayoutHarness.FindById(root, "x")!)); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "browsing-the-web.html#scrolling-to-a-fragment")] + public void FragmentLink_MakesTheElementTheTargetElement() + { + var (root, container) = LayoutHarness.Layout( + "<html><head><style>:target { color: red }</style></head><body><a id=x href=#t>a</a><p id=t>b</p></body></html>"); + + ScrollTarget(container, LayoutHarness.FindById(root, "x")!); + using var graphics = new RecordingGraphics(); + container.PerformLayout(graphics); + + Assert.AreEqual("rgb(255, 0, 0)", LayoutHarness.FindById(container.Root!, "t")!.Color); + } + + /// <summary>Clicks the fragment link and returns where the container asks the host to scroll to.</summary> + private static double? ScrollTarget(HtmlContainerInt container, CssBox link) + { + double? y = null; + container.ScrollChange += (_, e) => y = e.Y; + container.HandleLinkClicked(new MockControl(container.Adapter), RPoint.Empty, link); + return y; + } + + private sealed class MockControl(RAdapter adapter) : RControl(adapter) + { + public override bool LeftMouseButton => false; + public override bool RightMouseButton => false; + public override RPoint MouseLocation => RPoint.Empty; + public override void SetCursorDefault() { } + public override void SetCursorHand() { } + public override void SetCursorIBeam() { } + public override void DoDragDropCopy(object dragDropData) { } + + public override void MeasureString(string str, RFont font, double maxWidth, out int charFit, out double charFitWidth) + { + charFit = str.Length; + charFitWidth = 0; + } + + public override void Invalidate() { } + } +} diff --git a/Spec/html.yml b/Spec/html.yml index a72107d54..39713fd27 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1427,466 +1427,72 @@ sections: status: n/a browsers.html#browsers: title: 7 Loading web pages - status: unknown + status: partial browsers.html#loading-web-pages-supporting-concepts: title: 7.1 Supporting concepts - status: unknown - browsers.html#origin: - title: 7.1.1 Origins - status: unknown - browsers.html#sites: - title: 7.1.1.1 Sites - status: unknown - browsers.html#relaxing-the-same-origin-restriction: - title: 7.1.1.2 Relaxing the same-origin restriction - status: unknown - browsers.html#the-origin-interface: - title: 7.1.1.3 The Origin interface - status: unknown - browsers.html#origin-keyed-agent-clusters: - title: 7.1.2 Origin-keyed agent clusters - status: unknown - browsers.html#cross-origin-opener-policies: - title: 7.1.3 Cross-origin opener policies - status: unknown - browsers.html#the-coop-headers: - title: 7.1.3.1 The headers - status: unknown - browsers.html#browsing-context-group-switches-due-to-cross-origin-opener-policy: - title: 7.1.3.2 Browsing context group switches due to opener policy - status: unknown - browsers.html#coop-reporting: - title: 7.1.3.3 Reporting - status: unknown - browsers.html#coep: - title: 7.1.4 Cross-origin embedder policies - status: unknown - browsers.html#the-coep-headers: - title: 7.1.4.1 The headers - status: unknown - browsers.html#embedder-policy-checks: - title: 7.1.4.2 Embedder policy checks - status: unknown - browsers.html#sandboxing: - title: 7.1.5 Sandboxing - status: unknown - browsers.html#iframe-element-referrer-policy: - title: 7.1.6 iframe element referrer policy - status: unknown - browsers.html#policy-containers: - title: 7.1.7 Policy containers - status: unknown + status: n/a nav-history-apis.html#nav-traversal-apis: title: 7.2 APIs related to navigation and session history - status: unknown - nav-history-apis.html#cross-origin-objects: - title: 7.2.1 Security infrastructure for Window, WindowProxy, and Location objects - status: unknown - nav-history-apis.html#integration-with-idl: - title: 7.2.1.1 Integration with IDL - status: unknown - nav-history-apis.html#shared-internal-slot:-crossoriginpropertydescriptormap: - title: '7.2.1.2 Shared internal slot: [[CrossOriginPropertyDescriptorMap]]' - status: unknown - nav-history-apis.html#shared-abstract-operations: - title: 7.2.1.3 Shared abstract operations - status: unknown - nav-history-apis.html#crossoriginproperties-(-o-): - title: 7.2.1.3.1 CrossOriginProperties ( O ) - status: unknown - nav-history-apis.html#crossoriginpropertyfallback-(-p-): - title: 7.2.1.3.2 CrossOriginPropertyFallback ( P ) - status: unknown - nav-history-apis.html#isplatformobjectsameorigin-(-o-): - title: 7.2.1.3.3 IsPlatformObjectSameOrigin ( O ) - status: unknown - nav-history-apis.html#crossorigingetownpropertyhelper-(-o,-p-): - title: 7.2.1.3.4 CrossOriginGetOwnPropertyHelper ( O, P ) - status: unknown - nav-history-apis.html#crossoriginget-(-o,-p,-receiver-): - title: 7.2.1.3.5 CrossOriginGet ( O, P, Receiver ) - status: unknown - nav-history-apis.html#crossoriginset-(-o,-p,-v,-receiver-): - title: 7.2.1.3.6 CrossOriginSet ( O, P, V, Receiver ) - status: unknown - nav-history-apis.html#crossoriginownpropertykeys-(-o-): - title: 7.2.1.3.7 CrossOriginOwnPropertyKeys ( O ) - status: unknown - nav-history-apis.html#the-window-object: - title: 7.2.2 The Window object - status: unknown - nav-history-apis.html#apis-for-creating-and-navigating-browsing-contexts-by-name: - title: 7.2.2.1 Opening and closing windows - status: unknown - nav-history-apis.html#accessing-other-browsing-contexts: - title: 7.2.2.2 Indexed access on the Window object - status: unknown - nav-history-apis.html#named-access-on-the-window-object: - title: 7.2.2.3 Named access on the Window object - status: unknown - nav-history-apis.html#navigating-nested-browsing-contexts-in-the-dom: - title: 7.2.2.4 Accessing related windows - status: unknown - nav-history-apis.html#browser-interface-elements: - title: 7.2.2.5 Historical browser interface element APIs - status: unknown - nav-history-apis.html#script-settings-for-window-objects: - title: 7.2.2.6 Script settings for Window objects - status: unknown - nav-history-apis.html#the-windowproxy-exotic-object: - title: 7.2.3 The WindowProxy exotic object - status: unknown - nav-history-apis.html#windowproxy-getprototypeof: - title: 7.2.3.1 [[GetPrototypeOf]] ( ) - status: unknown - nav-history-apis.html#windowproxy-setprototypeof: - title: 7.2.3.2 [[SetPrototypeOf]] ( V ) - status: unknown - nav-history-apis.html#windowproxy-isextensible: - title: 7.2.3.3 [[IsExtensible]] ( ) - status: unknown - nav-history-apis.html#windowproxy-preventextensions: - title: 7.2.3.4 [[PreventExtensions]] ( ) - status: unknown - nav-history-apis.html#windowproxy-getownproperty: - title: 7.2.3.5 [[GetOwnProperty]] ( P ) - status: unknown - nav-history-apis.html#windowproxy-defineownproperty: - title: 7.2.3.6 [[DefineOwnProperty]] ( P, Desc ) - status: unknown - nav-history-apis.html#windowproxy-get: - title: 7.2.3.7 [[Get]] ( P, Receiver ) - status: unknown - nav-history-apis.html#windowproxy-set: - title: 7.2.3.8 [[Set]] ( P, V, Receiver ) - status: unknown - nav-history-apis.html#windowproxy-delete: - title: 7.2.3.9 [[Delete]] ( P ) - status: unknown - nav-history-apis.html#windowproxy-ownpropertykeys: - title: 7.2.3.10 [[OwnPropertyKeys]] ( ) - status: unknown - nav-history-apis.html#the-location-interface: - title: 7.2.4 The Location interface - status: unknown - nav-history-apis.html#location-getprototypeof: - title: 7.2.4.1 [[GetPrototypeOf]] ( ) - status: unknown - nav-history-apis.html#location-setprototypeof: - title: 7.2.4.2 [[SetPrototypeOf]] ( V ) - status: unknown - nav-history-apis.html#location-isextensible: - title: 7.2.4.3 [[IsExtensible]] ( ) - status: unknown - nav-history-apis.html#location-preventextensions: - title: 7.2.4.4 [[PreventExtensions]] ( ) - status: unknown - nav-history-apis.html#location-getownproperty: - title: 7.2.4.5 [[GetOwnProperty]] ( P ) - status: unknown - nav-history-apis.html#location-defineownproperty: - title: 7.2.4.6 [[DefineOwnProperty]] ( P, Desc ) - status: unknown - nav-history-apis.html#location-get: - title: 7.2.4.7 [[Get]] ( P, Receiver ) - status: unknown - nav-history-apis.html#location-set: - title: 7.2.4.8 [[Set]] ( P, V, Receiver ) - status: unknown - nav-history-apis.html#location-delete: - title: 7.2.4.9 [[Delete]] ( P ) - status: unknown - nav-history-apis.html#location-ownpropertykeys: - title: 7.2.4.10 [[OwnPropertyKeys]] ( ) - status: unknown - nav-history-apis.html#the-history-interface: - title: 7.2.5 The History interface - status: unknown - nav-history-apis.html#navigation-api: - title: 7.2.6 The navigation API - status: unknown - nav-history-apis.html#navigation-api-intro: - title: 7.2.6.1 Introduction - status: unknown - nav-history-apis.html#navigation-interface: - title: 7.2.6.2 The Navigation interface - status: unknown - nav-history-apis.html#navigation-api-core: - title: 7.2.6.3 Core infrastructure - status: unknown - nav-history-apis.html#navigation-api-entry-updates: - title: 7.2.6.4 Initializing and updating the entry list - status: unknown - nav-history-apis.html#the-navigationhistoryentry-interface: - title: 7.2.6.5 The NavigationHistoryEntry interface - status: unknown - nav-history-apis.html#the-history-entry-list: - title: 7.2.6.6 The history entry list - status: unknown - nav-history-apis.html#navigation-api-initiating-navigations: - title: 7.2.6.7 Initiating navigations - status: unknown - nav-history-apis.html#ongoing-navigation-tracking: - title: 7.2.6.8 Ongoing navigation tracking - status: unknown - nav-history-apis.html#navigation-activation-interface: - title: 7.2.6.9 The NavigationActivation interface - status: unknown - nav-history-apis.html#the-navigate-event: - title: 7.2.6.10 The navigate event - status: unknown - nav-history-apis.html#the-navigateevent-interface: - title: 7.2.6.10.1 The NavigateEvent interface - status: unknown - nav-history-apis.html#the-navigationprecommitcontroller-interface: - title: 7.2.6.10.2 The NavigationPrecommitController interface - status: unknown - nav-history-apis.html#the-navigationdestination-interface: - title: 7.2.6.10.3 The NavigationDestination interface - status: unknown - nav-history-apis.html#navigate-event-firing: - title: 7.2.6.10.4 Firing the event - status: unknown - nav-history-apis.html#navigate-event-scroll-focus: - title: 7.2.6.10.5 Scroll and focus behavior - status: unknown - nav-history-apis.html#nav-traversal-event-interfaces: - title: 7.2.7 Event interfaces - status: unknown - nav-history-apis.html#the-navigationcurrententrychangeevent-interface: - title: 7.2.7.1 The NavigationCurrentEntryChangeEvent interface - status: unknown - nav-history-apis.html#the-popstateevent-interface: - title: 7.2.7.2 The PopStateEvent interface - status: unknown - nav-history-apis.html#the-hashchangeevent-interface: - title: 7.2.7.3 The HashChangeEvent interface - status: unknown - nav-history-apis.html#the-pageswapevent-interface: - title: 7.2.7.4 The PageSwapEvent interface - status: unknown - nav-history-apis.html#the-pagerevealevent-interface: - title: 7.2.7.5 The PageRevealEvent interface - status: unknown - nav-history-apis.html#the-pagetransitionevent-interface: - title: 7.2.7.6 The PageTransitionEvent interface - status: unknown - nav-history-apis.html#the-beforeunloadevent-interface: - title: 7.2.7.7 The BeforeUnloadEvent interface - status: unknown - nav-history-apis.html#the-notrestoredreasons-interface: - title: 7.2.8 The NotRestoredReasons interface - status: unknown + status: n/a document-sequences.html#infrastructure-for-sequences-of-documents: title: 7.3 Infrastructure for sequences of documents - status: unknown - document-sequences.html#navigables: - title: 7.3.1 Navigables - status: unknown - document-sequences.html#traversable-navigables: - title: 7.3.1.1 Traversable navigables - status: unknown - document-sequences.html#top-level-traversables: - title: 7.3.1.2 Top-level traversables - status: unknown - document-sequences.html#child-navigables: - title: 7.3.1.3 Child navigables - status: unknown - document-sequences.html#jake-diagrams: - title: 7.3.1.4 Jake diagrams - status: unknown - document-sequences.html#related-navigable-collections: - title: 7.3.1.5 Related navigable collections - status: unknown - document-sequences.html#garbage-collection-and-browsing-contexts: - title: 7.3.1.6 Navigable destruction - status: unknown - document-sequences.html#navigable-target-names: - title: 7.3.1.7 Navigable target names - status: unknown - document-sequences.html#windows: - title: 7.3.2 Browsing contexts - status: unknown - document-sequences.html#creating-browsing-contexts: - title: 7.3.2.1 Creating browsing contexts - status: unknown - document-sequences.html#nested-browsing-contexts: - title: 7.3.2.2 Related browsing contexts - status: unknown - document-sequences.html#groupings-of-browsing-contexts: - title: 7.3.2.3 Groupings of browsing contexts - status: unknown - document-sequences.html#fully-active-documents: - title: 7.3.3 Fully active documents - status: unknown + status: n/a browsing-the-web.html#navigation-and-session-history: title: 7.4 Navigation and session history - status: unknown + status: partial browsing-the-web.html#session-history-infrastructure: title: 7.4.1 Session history - status: unknown - browsing-the-web.html#session-history-entries: - title: 7.4.1.1 Session history entries - status: unknown - browsing-the-web.html#document-state: - title: 7.4.1.2 Document state - status: unknown - browsing-the-web.html#centralized-modifications-of-session-history: - title: 7.4.1.3 Centralized modifications of session history - status: unknown - browsing-the-web.html#low-level-operations-on-session-history: - title: 7.4.1.4 Low-level operations on session history - status: unknown + status: n/a browsing-the-web.html#navigating-across-documents: title: 7.4.2 Navigation - status: unknown - browsing-the-web.html#navigation-supporting-concepts: - title: 7.4.2.1 Supporting concepts - status: unknown - browsing-the-web.html#beginning-navigation: - title: 7.4.2.2 Beginning navigation - status: unknown - browsing-the-web.html#ending-navigation: - title: 7.4.2.3 Ending navigation - status: unknown - browsing-the-web.html#the-usual-cross-document-navigation-case: - title: 7.4.2.3.1 The usual cross-document navigation case - status: unknown - browsing-the-web.html#the-javascript:-url-special-case: - title: '7.4.2.3.2 The javascript: URL special case' - status: unknown - browsing-the-web.html#scroll-to-fragid: - title: 7.4.2.3.3 Fragment navigations - status: unknown - browsing-the-web.html#non-fetch-schemes-and-external-software: - title: 7.4.2.3.4 Non-fetch schemes and external software - status: unknown - browsing-the-web.html#preventing-navigation: - title: 7.4.2.4 Preventing navigation - status: unknown - browsing-the-web.html#aborting-navigation: - title: 7.4.2.5 Aborting navigation - status: unknown + status: n/a browsing-the-web.html#reloading-and-traversing: title: 7.4.3 Reloading and traversing - status: unknown + status: n/a browsing-the-web.html#navigate-non-frag-sync: title: 7.4.4 Non-fragment synchronous "navigations" - status: unknown + status: n/a browsing-the-web.html#populating-a-session-history-entry: title: 7.4.5 Populating a session history entry - status: unknown + status: n/a browsing-the-web.html#applying-the-history-step: title: 7.4.6 Applying the history step - status: unknown + status: partial browsing-the-web.html#updating-the-traversable: title: 7.4.6.1 Updating the traversable - status: unknown + status: n/a browsing-the-web.html#updating-the-document: title: 7.4.6.2 Updating the document - status: unknown + status: n/a browsing-the-web.html#revealing-the-document: title: 7.4.6.3 Revealing the document - status: unknown + status: n/a browsing-the-web.html#scrolling-to-a-fragment: title: 7.4.6.4 Scrolling to a fragment - status: unknown + status: partial + gaps: + - A fragment matches ids case-insensitively and doesn't fall back to the name of an a element + - The fragment isn't percent-decoded + - A fragment of top or an empty fragment doesn't scroll to the top, an empty one is opened with Process.Start instead + - The scrolled-to element doesn't become the target element, :target never matches browsing-the-web.html#persisted-user-state-restoration: title: 7.4.6.5 Persisted history entry state - status: unknown + status: n/a document-lifecycle.html#document-lifecycle: title: 7.5 Document lifecycle - status: unknown - document-lifecycle.html#shared-document-creation-infrastructure: - title: 7.5.1 Shared document creation infrastructure - status: unknown - document-lifecycle.html#read-html: - title: 7.5.2 Loading HTML documents - status: unknown - document-lifecycle.html#read-xml: - title: 7.5.3 Loading XML documents - status: unknown - document-lifecycle.html#read-text: - title: 7.5.4 Loading text documents - status: unknown - document-lifecycle.html#read-multipart-x-mixed-replace: - title: 7.5.5 Loading multipart/x-mixed-replace documents - status: unknown - document-lifecycle.html#read-media: - title: 7.5.6 Loading media documents - status: unknown - document-lifecycle.html#read-ua-inline: - title: 7.5.7 Loading a document for inline content that doesn't have a DOM - status: unknown - document-lifecycle.html#loading-documents: - title: 7.5.8 Finishing the loading process - status: unknown - document-lifecycle.html#unloading-documents: - title: 7.5.9 Unloading documents - status: unknown - document-lifecycle.html#destroying-documents: - title: 7.5.10 Destroying documents - status: unknown - document-lifecycle.html#aborting-a-document-load: - title: 7.5.11 Aborting a document load - status: unknown + status: n/a speculative-loading.html#speculative-loading: title: 7.6 Speculative loading - status: unknown - speculative-loading.html#speculation-rules: - title: 7.6.1 Speculation rules - status: unknown - speculative-loading.html#speculation-rules-data-model: - title: 7.6.1.1 Data model - status: unknown - speculative-loading.html#speculation-rules-parsing: - title: 7.6.1.2 Parsing - status: unknown - speculative-loading.html#speculation-rules-processing-model: - title: 7.6.1.3 Processing model - status: unknown - speculative-loading.html#navigational-prefetching: - title: 7.6.2 Navigational prefetching - status: unknown - speculative-loading.html#the-speculation-rules-header: - title: 7.6.3 The `Speculation-Rules` header - status: unknown - speculative-loading.html#the-sec-speculation-tags-header: - title: 7.6.4 The `Sec-Speculation-Tags` header - status: unknown - speculative-loading.html#speculative-loading-security: - title: 7.6.5 Security considerations - status: unknown - speculative-loading.html#speculative-loading-cross-site-requests: - title: 7.6.5.1 Cross-site requests - status: unknown - speculative-loading.html#speculative-loading-injected-content: - title: 7.6.5.2 Injected content - status: unknown - speculative-loading.html#speculative-loading-ip-anonymization: - title: 7.6.5.3 IP anonymization - status: unknown - speculative-loading.html#speculative-loading-privacy: - title: 7.6.6 Privacy considerations - status: unknown - speculative-loading.html#speculative-loading-heuristics: - title: 7.6.6.1 Heuristics and optionality - status: unknown - speculative-loading.html#speculative-loading-state-partitioning: - title: 7.6.6.2 State partitioning - status: unknown - speculative-loading.html#speculative-loading-identity-joining: - title: 7.6.6.3 Identity joining - status: unknown + status: n/a speculative-loading.html#the-x-frame-options-header: title: 7.7 The `X-Frame-Options` header - status: unknown + status: n/a speculative-loading.html#the-refresh-header: title: 7.8 The `Refresh` header - status: unknown + status: n/a speculative-loading.html#nav-traversal-ui: title: 7.9 Browser user interface considerations - status: unknown + status: n/a webappapis.html#webappapis: title: 8 Web application APIs status: unknown From 74f7312d7420697541013bd57c64d7e58d070553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:19:55 +0100 Subject: [PATCH 136/150] Verify and document HTML web application APIs compliance --- .../HtmlRenderer.Test/Html/WebAppApisTests.cs | 44 +++ Spec/html.yml | 317 +----------------- 2 files changed, 48 insertions(+), 313 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/Html/WebAppApisTests.cs diff --git a/Source/Test/HtmlRenderer.Test/Html/WebAppApisTests.cs b/Source/Test/HtmlRenderer.Test/Html/WebAppApisTests.cs new file mode 100644 index 000000000..e8914fd55 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/WebAppApisTests.cs @@ -0,0 +1,44 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Html; + +[TestClass] +public sealed class WebAppApisTests +{ + private static string Text(CssBox box) => + string.Concat(LayoutHarness.Descendants(box).SelectMany(b => b.Words).Select(w => w.Text)); + + [TestMethod] + [Spec("html", "webappapis.html#enabling-and-disabling-scripting")] + public void ScriptingIsDisabled_NoscriptIsRenderedAndScriptIsNot() + { + var box = Box("<div id=x><script>document.write('b')</script><noscript><span>a</span></noscript></div>"); + + Assert.AreEqual("a", Text(box)); + } + + [TestMethod] + [Spec("html", "webappapis.html#event-handlers-on-elements,-document-objects,-and-window-objects")] + public void EventHandlerContentAttributes_AreIgnored() + { + const string html = + "<div id=x onclick=\"this.style.display='none'\" onmouseover=\"alert(1)\">" + + "<a href=\"#\" onclick=\"return false\">a</a><img onerror=\"alert(1)\"><b onload=x>b</b></div>"; + + var box = Box(html); + + Assert.AreEqual("ab", Text(box)); + Assert.IsTrue(box.Size.Height > 0); + } + + [TestMethod] + [Spec("html", "webappapis.html#event-handlers-on-elements,-document-objects,-and-window-objects")] + public void BodyEventHandlerContentAttributes_AreIgnored() + { + var (root, _) = LayoutHarness.Layout("<html><body onload=\"document.body.innerHTML=''\" onunload=x><p id=x>a</p></body></html>"); + + Assert.AreEqual("a", Text(LayoutHarness.FindById(root, "x")!)); + } +} diff --git a/Spec/html.yml b/Spec/html.yml index 39713fd27..76806dcb7 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1495,325 +1495,16 @@ sections: status: n/a webappapis.html#webappapis: title: 8 Web application APIs - status: unknown - webappapis.html#scripting: - title: 8.1 Scripting - status: unknown - webappapis.html#introduction-11: - title: 8.1.1 Introduction - status: unknown - webappapis.html#agents-and-agent-clusters: - title: 8.1.2 Agents and agent clusters - status: unknown - webappapis.html#integration-with-the-javascript-agent-formalism: - title: 8.1.2.1 Integration with the JavaScript agent formalism - status: unknown - webappapis.html#integration-with-the-javascript-agent-cluster-formalism: - title: 8.1.2.2 Integration with the JavaScript agent cluster formalism - status: unknown - webappapis.html#realms-and-their-counterparts: - title: 8.1.3 Realms and their counterparts - status: unknown - webappapis.html#environments: - title: 8.1.3.1 Environments - status: unknown - webappapis.html#environment-settings-objects: - title: 8.1.3.2 Environment settings objects - status: unknown - webappapis.html#realms-settings-objects-global-objects: - title: 8.1.3.3 Realms, settings objects, and global objects - status: unknown - webappapis.html#entry: - title: 8.1.3.3.1 Entry - status: unknown - webappapis.html#incumbent: - title: 8.1.3.3.2 Incumbent - status: unknown - webappapis.html#current: - title: 8.1.3.3.3 Current - status: unknown - webappapis.html#relevant: - title: 8.1.3.3.4 Relevant - status: unknown + status: n/a webappapis.html#enabling-and-disabling-scripting: title: 8.1.3.4 Enabling and disabling scripting - status: unknown - webappapis.html#secure-contexts: - title: 8.1.3.5 Secure contexts - status: unknown - webappapis.html#scripting-processing-model: - title: 8.1.4 Script processing model - status: unknown - webappapis.html#script-structs: - title: 8.1.4.1 Scripts - status: unknown - webappapis.html#fetching-scripts: - title: 8.1.4.2 Fetching scripts - status: unknown - webappapis.html#creating-scripts: - title: 8.1.4.3 Creating scripts - status: unknown - webappapis.html#calling-scripts: - title: 8.1.4.4 Calling scripts - status: unknown - webappapis.html#killing-scripts: - title: 8.1.4.5 Killing scripts - status: unknown - webappapis.html#runtime-script-errors: - title: 8.1.4.6 Runtime script errors - status: unknown - webappapis.html#unhandled-promise-rejections: - title: 8.1.4.7 Unhandled promise rejections - status: unknown - webappapis.html#import-map-parse-results: - title: 8.1.4.8 Import map parse results - status: unknown - webappapis.html#speculation-rules-parse-results: - title: 8.1.4.9 Speculation rules parse results - status: unknown - webappapis.html#module-specifier-resolution: - title: 8.1.5 Module specifier resolution - status: unknown - webappapis.html#the-resolution-algorithm: - title: 8.1.5.1 The resolution algorithm - status: unknown - webappapis.html#import-maps: - title: 8.1.5.2 Import maps - status: unknown - webappapis.html#import-map-processing-model: - title: 8.1.5.3 Import map processing model - status: unknown - webappapis.html#javascript-specification-host-hooks: - title: 8.1.6 JavaScript specification host hooks - status: unknown - webappapis.html#the-hostensurecanaddprivateelement-implementation: - title: 8.1.6.1 HostEnsureCanAddPrivateElement(O) - status: unknown - webappapis.html#hostensurecancompilestrings(realm,-parameterstrings,-bodystring,-codestring,-compilationtype,-parameterargs,-bodyarg): - title: 8.1.6.2 HostEnsureCanCompileStrings(realm, parameterStrings, bodyString, codeString, compilationType, parameterArgs, bodyArg) - status: unknown - webappapis.html#hostgetcodeforeval(argument): - title: 8.1.6.3 HostGetCodeForEval(argument) - status: unknown - webappapis.html#the-hostpromiserejectiontracker-implementation: - title: 8.1.6.4 HostPromiseRejectionTracker(promise, operation) - status: unknown - webappapis.html#hostsystemutcepochnanoseconds: - title: 8.1.6.5 HostSystemUTCEpochNanoseconds(global) - status: unknown - webappapis.html#integration-with-javascript-jobs: - title: 8.1.6.6 Job-related host hooks - status: unknown - webappapis.html#hostcalljobcallback: - title: 8.1.6.6.1 HostCallJobCallback(callback, V, argumentsList) - status: unknown - webappapis.html#hostenqueuefinalizationregistrycleanupjob: - title: 8.1.6.6.2 HostEnqueueFinalizationRegistryCleanupJob(finalizationRegistry) - status: unknown - webappapis.html#hostenqueuegenericjob: - title: 8.1.6.6.3 HostEnqueueGenericJob(job, realm) - status: unknown - webappapis.html#hostenqueuepromisejob: - title: 8.1.6.6.4 HostEnqueuePromiseJob(job, realm) - status: unknown - webappapis.html#hostenqueuetimeoutjob: - title: 8.1.6.6.5 HostEnqueueTimeoutJob(job, realm, milliseconds) - status: unknown - webappapis.html#hostmakejobcallback: - title: 8.1.6.6.6 HostMakeJobCallback(callable) - status: unknown - webappapis.html#integration-with-the-javascript-module-system: - title: 8.1.6.7 Module-related host hooks - status: unknown - webappapis.html#hostgetimportmetaproperties: - title: 8.1.6.7.1 HostGetImportMetaProperties(moduleRecord) - status: unknown - webappapis.html#hostgetsupportedimportattributes: - title: 8.1.6.7.2 HostGetSupportedImportAttributes() - status: unknown - webappapis.html#hostloadimportedmodule: - title: 8.1.6.7.3 HostLoadImportedModule(referrer, moduleRequest, loadState, payload) - status: unknown - webappapis.html#event-loops: - title: 8.1.7 Event loops - status: unknown - webappapis.html#definitions-3: - title: 8.1.7.1 Definitions - status: unknown - webappapis.html#queuing-tasks: - title: 8.1.7.2 Queuing tasks - status: unknown - webappapis.html#event-loop-processing-model: - title: 8.1.7.3 Processing model - status: unknown - webappapis.html#generic-task-sources: - title: 8.1.7.4 Generic task sources - status: unknown - webappapis.html#event-loop-for-spec-authors: - title: 8.1.7.5 Dealing with the event loop from other specifications - status: unknown - webappapis.html#events: - title: 8.1.8 Events - status: unknown - webappapis.html#event-handler-attributes: - title: 8.1.8.1 Event handlers - status: unknown + status: supported webappapis.html#event-handlers-on-elements,-document-objects,-and-window-objects: title: 8.1.8.2 Event handlers on elements, Document objects, and Window objects - status: unknown + status: supported webappapis.html#idl-definitions: title: 8.1.8.2.1 IDL definitions - status: unknown - webappapis.html#event-firing: - title: 8.1.8.3 Event firing - status: unknown - webappapis.html#windoworworkerglobalscope-mixin: - title: 8.2 The WindowOrWorkerGlobalScope mixin - status: unknown - webappapis.html#atob: - title: 8.3 Base64 utility methods - status: unknown - dynamic-markup-insertion.html#dynamic-markup-insertion: - title: 8.4 Dynamic markup insertion - status: unknown - dynamic-markup-insertion.html#opening-the-input-stream: - title: 8.4.1 Opening the input stream - status: unknown - dynamic-markup-insertion.html#closing-the-input-stream: - title: 8.4.2 Closing the input stream - status: unknown - dynamic-markup-insertion.html#document.write(): - title: 8.4.3 document.write() - status: unknown - dynamic-markup-insertion.html#document.writeln(): - title: 8.4.4 document.writeln() - status: unknown - dynamic-markup-insertion.html#dom-parsing-and-serialization: - title: 8.5 DOM parsing and serialization APIs - status: unknown - dynamic-markup-insertion.html#the-domparser-interface: - title: 8.5.1 The DOMParser interface - status: unknown - dynamic-markup-insertion.html#html-parsing-methods: - title: 8.5.2 HTML parsing and insertion methods - status: unknown - dynamic-markup-insertion.html#html-streaming-methods: - title: 8.5.3 HTML streaming methods - status: unknown - dynamic-markup-insertion.html#html-serialization-methods: - title: 8.5.4 HTML serialization methods - status: unknown - dynamic-markup-insertion.html#the-innerhtml-property: - title: 8.5.5 The innerHTML property - status: unknown - dynamic-markup-insertion.html#the-outerhtml-property: - title: 8.5.6 The outerHTML property - status: unknown - dynamic-markup-insertion.html#the-insertadjacenthtml()-method: - title: 8.5.7 The insertAdjacentHTML() method - status: unknown - dynamic-markup-insertion.html#the-createcontextualfragment()-method: - title: 8.5.8 The createContextualFragment() method - status: unknown - dynamic-markup-insertion.html#the-xmlserializer-interface: - title: 8.5.9 The XMLSerializer interface - status: unknown - dynamic-markup-insertion.html#html-sanitization: - title: 8.6 HTML sanitization - status: unknown - dynamic-markup-insertion.html#introduction-12: - title: 8.6.1 Introduction - status: unknown - dynamic-markup-insertion.html#safe-and-unsafe: - title: 8.6.1.1 Safe and unsafe - status: unknown - dynamic-markup-insertion.html#the-sanitizer-interface: - title: 8.6.2 The Sanitizer interface - status: unknown - dynamic-markup-insertion.html#sanitizer-configuration: - title: 8.6.3 Sanitizer configuration - status: unknown - dynamic-markup-insertion.html#configuration-invariants: - title: 8.6.3.1 Configuration invariants - status: unknown - dynamic-markup-insertion.html#sanitization-algorithms: - title: 8.6.4 Sanitization algorithms - status: unknown - dynamic-markup-insertion.html#sanitization-constants: - title: 8.6.5 Sanitization constants - status: unknown - dynamic-markup-insertion.html#sanitizer-security-considerations: - title: 8.6.6 Security considerations - status: unknown - dynamic-markup-insertion.html#sanitizer-security-server-reflected: - title: 8.6.6.1 Server-side reflected and stored XSS - status: unknown - dynamic-markup-insertion.html#sanitizer-security-dom-clobbering: - title: 8.6.6.2 DOM clobbering - status: unknown - dynamic-markup-insertion.html#sanitizer-security-script-gadgets: - title: 8.6.6.3 XSS with script gadgets - status: unknown - dynamic-markup-insertion.html#sanitizer-security-mxss: - title: 8.6.6.4 Mutation XSS - status: unknown - timers-and-user-prompts.html#timers: - title: 8.7 Timers - status: unknown - timers-and-user-prompts.html#microtask-queuing: - title: 8.8 Microtask queuing - status: unknown - timers-and-user-prompts.html#user-prompts: - title: 8.9 User prompts - status: unknown - timers-and-user-prompts.html#simple-dialogs: - title: 8.9.1 Simple dialogs - status: unknown - timers-and-user-prompts.html#printing: - title: 8.9.2 Printing - status: unknown - system-state.html#system-state-and-capabilities: - title: 8.10 System state and capabilities - status: unknown - system-state.html#the-navigator-object: - title: 8.10.1 The Navigator object - status: unknown - system-state.html#client-identification: - title: 8.10.1.1 Client identification - status: unknown - system-state.html#language-preferences: - title: 8.10.1.2 Language preferences - status: unknown - system-state.html#navigator.online: - title: 8.10.1.3 Browser state - status: unknown - system-state.html#custom-handlers: - title: '8.10.1.4 Custom scheme handlers: the registerProtocolHandler() method' - status: unknown - system-state.html#security-and-privacy: - title: 8.10.1.4.1 Security and privacy - status: unknown - system-state.html#user-agent-automation: - title: 8.10.1.4.2 User agent automation - status: unknown - system-state.html#cookies: - title: 8.10.1.5 Cookies - status: unknown - system-state.html#pdf-viewing-support: - title: 8.10.1.6 PDF viewing support - status: unknown - imagebitmap-and-animations.html#images-2: - title: 8.11 Images - status: unknown - imagebitmap-and-animations.html#the-imagedata-interface: - title: 8.11.1 The ImageData interface - status: unknown - imagebitmap-and-animations.html#the-imagebitmap-interface: - title: 8.11.2 The ImageBitmap interface - status: unknown - imagebitmap-and-animations.html#animation-frames: - title: 8.12 Animation frames - status: unknown + status: n/a comms.html#comms: title: 9 Communication status: unknown From 45ef9329ec6a7ad45e470ba3a3788b5f7bb3d76e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:20:11 +0100 Subject: [PATCH 137/150] Verify and document HTML communication compliance --- Spec/html.yml | 89 +-------------------------------------------------- 1 file changed, 1 insertion(+), 88 deletions(-) diff --git a/Spec/html.yml b/Spec/html.yml index 76806dcb7..ba548bd46 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1507,94 +1507,7 @@ sections: status: n/a comms.html#comms: title: 9 Communication - status: unknown - comms.html#the-messageevent-interface: - title: 9.1 The MessageEvent interface - status: unknown - server-sent-events.html#server-sent-events: - title: 9.2 Server-sent events - status: unknown - server-sent-events.html#server-sent-events-intro: - title: 9.2.1 Introduction - status: unknown - server-sent-events.html#the-eventsource-interface: - title: 9.2.2 The EventSource interface - status: unknown - server-sent-events.html#sse-processing-model: - title: 9.2.3 Processing model - status: unknown - server-sent-events.html#the-last-event-id-header: - title: 9.2.4 The `Last-Event-ID` header - status: unknown - server-sent-events.html#parsing-an-event-stream: - title: 9.2.5 Parsing an event stream - status: unknown - server-sent-events.html#event-stream-interpretation: - title: 9.2.6 Interpreting an event stream - status: unknown - server-sent-events.html#authoring-notes: - title: 9.2.7 Authoring notes - status: unknown - server-sent-events.html#eventsource-push: - title: 9.2.8 Connectionless push and other features - status: unknown - server-sent-events.html#garbage-collection: - title: 9.2.9 Garbage collection - status: unknown - server-sent-events.html#implementation-advice: - title: 9.2.10 Implementation advice - status: unknown - web-messaging.html#web-messaging: - title: 9.3 Cross-document messaging - status: unknown - web-messaging.html#introduction-13: - title: 9.3.1 Introduction - status: unknown - web-messaging.html#security-postmsg: - title: 9.3.2 Security - status: unknown - web-messaging.html#authors: - title: 9.3.2.1 Authors - status: unknown - web-messaging.html#user-agents: - title: 9.3.2.2 User agents - status: unknown - web-messaging.html#posting-messages: - title: 9.3.3 Posting messages - status: unknown - web-messaging.html#channel-messaging: - title: 9.4 Channel messaging - status: unknown - web-messaging.html#introduction-14: - title: 9.4.1 Introduction - status: unknown - web-messaging.html#examples-5: - title: 9.4.1.1 Examples - status: unknown - web-messaging.html#ports-as-the-basis-of-an-object-capability-model-on-the-web: - title: 9.4.1.2 Ports as the basis of an object-capability model on the web - status: unknown - web-messaging.html#ports-as-the-basis-of-abstracting-out-service-implementations: - title: 9.4.1.3 Ports as the basis of abstracting out service implementations - status: unknown - web-messaging.html#message-channels: - title: 9.4.2 Message channels - status: unknown - web-messaging.html#the-messageeventtarget-mixin: - title: 9.4.3 The MessageEventTarget mixin - status: unknown - web-messaging.html#message-ports: - title: 9.4.4 Message ports - status: unknown - web-messaging.html#broadcasting-to-many-ports: - title: 9.4.5 Broadcasting to many ports - status: unknown - web-messaging.html#ports-and-garbage-collection: - title: 9.4.6 Ports and garbage collection - status: unknown - web-messaging.html#broadcasting-to-other-browsing-contexts: - title: 9.5 Broadcasting to other browsing contexts - status: unknown + status: n/a workers.html#workers: title: 10 Web workers status: unknown From ff7028fe9e327956a6723c11684af72bb6c86c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:20:18 +0100 Subject: [PATCH 138/150] Verify and document HTML web workers compliance --- Spec/html.yml | 98 +-------------------------------------------------- 1 file changed, 1 insertion(+), 97 deletions(-) diff --git a/Spec/html.yml b/Spec/html.yml index ba548bd46..5a0eb2f94 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1510,103 +1510,7 @@ sections: status: n/a workers.html#workers: title: 10 Web workers - status: unknown - workers.html#introduction-15: - title: 10.1 Introduction - status: unknown - workers.html#scope-2: - title: 10.1.1 Scope - status: unknown - workers.html#examples-6: - title: 10.1.2 Examples - status: unknown - workers.html#a-background-number-crunching-worker: - title: 10.1.2.1 A background number-crunching worker - status: unknown - workers.html#module-worker-example: - title: 10.1.2.2 Using a JavaScript module as a worker - status: unknown - workers.html#shared-workers-introduction: - title: 10.1.2.3 Shared workers introduction - status: unknown - workers.html#shared-state-using-a-shared-worker: - title: 10.1.2.4 Shared state using a shared worker - status: unknown - workers.html#delegation: - title: 10.1.2.5 Delegation - status: unknown - workers.html#providing-libraries: - title: 10.1.2.6 Providing libraries - status: unknown - workers.html#tutorials: - title: 10.1.3 Tutorials - status: unknown - workers.html#creating-a-dedicated-worker: - title: 10.1.3.1 Creating a dedicated worker - status: unknown - workers.html#communicating-with-a-dedicated-worker: - title: 10.1.3.2 Communicating with a dedicated worker - status: unknown - workers.html#shared-workers: - title: 10.1.3.3 Shared workers - status: unknown - workers.html#infrastructure-2: - title: 10.2 Infrastructure - status: unknown - workers.html#the-global-scope: - title: 10.2.1 The global scope - status: unknown - workers.html#the-workerglobalscope-common-interface: - title: 10.2.1.1 The WorkerGlobalScope common interface - status: unknown - workers.html#dedicated-workers-and-the-dedicatedworkerglobalscope-interface: - title: 10.2.1.2 Dedicated workers and the DedicatedWorkerGlobalScope interface - status: unknown - workers.html#shared-workers-and-the-sharedworkerglobalscope-interface: - title: 10.2.1.3 Shared workers and the SharedWorkerGlobalScope interface - status: unknown - workers.html#worker-event-loop: - title: 10.2.2 The event loop - status: unknown - workers.html#the-worker's-lifetime: - title: 10.2.3 The worker's lifetime - status: unknown - workers.html#worker-processing-model: - title: 10.2.4 Processing model - status: unknown - workers.html#runtime-script-errors-2: - title: 10.2.5 Runtime script errors - status: unknown - workers.html#creating-workers: - title: 10.2.6 Creating workers - status: unknown - workers.html#the-abstractworker-mixin: - title: 10.2.6.1 The AbstractWorker mixin - status: unknown - workers.html#script-settings-for-workers: - title: 10.2.6.2 Script settings for workers - status: unknown - workers.html#dedicated-workers-and-the-worker-interface: - title: 10.2.6.3 Dedicated workers and the Worker interface - status: unknown - workers.html#shared-workers-and-the-sharedworker-interface: - title: 10.2.6.4 Shared workers and the SharedWorker interface - status: unknown - workers.html#navigator.hardwareconcurrency: - title: 10.2.7 Concurrent hardware capabilities - status: unknown - workers.html#apis-available-to-workers: - title: 10.3 APIs available to workers - status: unknown - workers.html#importing-scripts-and-libraries: - title: 10.3.1 Importing scripts and libraries - status: unknown - workers.html#the-workernavigator-object: - title: 10.3.2 The WorkerNavigator interface - status: unknown - workers.html#worker-locations: - title: 10.3.3 The WorkerLocation interface - status: unknown + status: n/a worklets.html#worklets: title: 11 Worklets status: unknown From b94b5264cf2e3dd78cff592cf1483a9fcfb08c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:20:26 +0100 Subject: [PATCH 139/150] Verify and document HTML worklets compliance --- Spec/html.yml | 44 +------------------------------------------- 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/Spec/html.yml b/Spec/html.yml index 5a0eb2f94..1f72401b7 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1513,49 +1513,7 @@ sections: status: n/a worklets.html#worklets: title: 11 Worklets - status: unknown - worklets.html#worklets-intro: - title: 11.1 Introduction - status: unknown - worklets.html#worklets-motivations: - title: 11.1.1 Motivations - status: unknown - worklets.html#worklets-idempotent: - title: 11.1.2 Code idempotence - status: unknown - worklets.html#worklets-speculative: - title: 11.1.3 Speculative evaluation - status: unknown - worklets.html#worklets-examples: - title: 11.2 Examples - status: unknown - worklets.html#worklets-examples-loading: - title: 11.2.1 Loading scripts - status: unknown - worklets.html#worklets-example-registering: - title: 11.2.2 Registering a class and invoking its methods - status: unknown - worklets.html#worklets-infrastructure: - title: 11.3 Infrastructure - status: unknown - worklets.html#worklets-global: - title: 11.3.1 The global scope - status: unknown - worklets.html#worklet-agents-and-event-loops: - title: 11.3.1.1 Agents and event loops - status: unknown - worklets.html#worklets-creation-termination: - title: 11.3.1.2 Creation and termination - status: unknown - worklets.html#script-settings-for-worklets: - title: 11.3.1.3 Script settings for worklets - status: unknown - worklets.html#worklets-worklet: - title: 11.3.2 The Worklet class - status: unknown - worklets.html#worklets-lifetime: - title: 11.3.3 The worklet's lifetime - status: unknown + status: n/a webstorage.html#webstorage: title: 12 Web storage status: unknown From a0711b83d4853091156b3ef19bde6b0532bd4d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:20:54 +0100 Subject: [PATCH 140/150] Verify and document HTML web storage compliance --- Spec/html.yml | 41 +---------------------------------------- 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/Spec/html.yml b/Spec/html.yml index 1f72401b7..3c6138e14 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1516,46 +1516,7 @@ sections: status: n/a webstorage.html#webstorage: title: 12 Web storage - status: unknown - webstorage.html#introduction-16: - title: 12.1 Introduction - status: unknown - webstorage.html#storage: - title: 12.2 The API - status: unknown - webstorage.html#the-storage-interface: - title: 12.2.1 The Storage interface - status: unknown - webstorage.html#the-sessionstorage-attribute: - title: 12.2.2 The sessionStorage getter - status: unknown - webstorage.html#the-localstorage-attribute: - title: 12.2.3 The localStorage getter - status: unknown - webstorage.html#the-storageevent-interface: - title: 12.2.4 The StorageEvent interface - status: unknown - webstorage.html#privacy: - title: 12.3 Privacy - status: unknown - webstorage.html#user-tracking: - title: 12.3.1 User tracking - status: unknown - webstorage.html#sensitivity-of-data: - title: 12.3.2 Sensitivity of data - status: unknown - webstorage.html#security-storage: - title: 12.4 Security - status: unknown - webstorage.html#dns-spoofing-attacks: - title: 12.4.1 DNS spoofing attacks - status: unknown - webstorage.html#cross-directory-attacks: - title: 12.4.2 Cross-directory attacks - status: unknown - webstorage.html#implementation-risks: - title: 12.4.3 Implementation risks - status: unknown + status: n/a syntax.html#syntax: title: 13 The HTML syntax status: partial From 29af3d9b56605402a1c5e7eeb94f58d6d434fc3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:26:08 +0100 Subject: [PATCH 141/150] Verify and document CSS Values Level 5 compliance --- .../Core/CssEngine/Calc/CalcCategory.cs | 4 - .../CssEngine/Factories/PropertyFactory.cs | 4 +- .../Core/CssEngine/Rules/PropertyRule.cs | 6 +- .../BackgroundPaintIntegrationTests.cs | 1 + .../Css/SyntaxAndDataTypesTests.cs | 1 + .../CssModules/CssColor5Tests.cs | 1 + .../CssModules/CssValues5Tests.cs | 237 ++++++++++++++++++ .../CssModules/CssValuesTests.cs | 1 + .../CssModules/CssVariablesTests.cs | 1 + Spec/css-values-5.yml | 127 ++++++---- 10 files changed, 318 insertions(+), 65 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssValues5Tests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcCategory.cs b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcCategory.cs index 46339dd5d..7c327ee19 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcCategory.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcCategory.cs @@ -14,11 +14,7 @@ internal enum CalcCategory Percentage = 4, LengthPercentage = Length | Percentage, Angle = 8, - - /// <summary>Only produced by @property syntax validation.</summary> Time = 16, - - /// <summary>Only produced by @property syntax validation.</summary> Resolution = 32 } } diff --git a/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs b/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs index f1ba34662..fd5b48a17 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs @@ -24,9 +24,7 @@ internal sealed class PropertyFactory private readonly Dictionary<string, LonghandCreator> _fonts; - // @property descriptors (syntax / initial-value / inherits). Their values have no fixed CSS grammar - // (initial-value depends on the syntax; syntax is an arbitrary string), so each is stored raw via an - // UnknownProperty (Converters.Any) and validated later against the syntax string. + // @property descriptors, stored raw, nothing reads them yet private readonly Dictionary<string, LonghandCreator> _propertyDescriptors; // @font-palette-values descriptors (font-family / base-palette / override-colors). Stored raw via diff --git a/Source/HtmlRenderer/Core/CssEngine/Rules/PropertyRule.cs b/Source/HtmlRenderer/Core/CssEngine/Rules/PropertyRule.cs index a7675f8ae..893497a79 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Rules/PropertyRule.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Rules/PropertyRule.cs @@ -4,10 +4,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// An <c>@property</c> at-rule (CSS Properties &amp; Values API Level 1 §3): registers a typed custom - /// property with a <c>syntax</c>, <c>initial-value</c>, and <c>inherits</c> descriptor. The descriptor - /// block is stored like <see cref="FontFaceRule"/>; the registered name (a dashed-ident such as - /// <c>--my-color</c>) is captured from the prelude like <see cref="KeyframesRule.Name"/>. + /// An <c>@property</c> rule (CSS Properties and Values API 1 §3). Parsed only, the registration has no + /// effect on the cascade. /// </summary> internal sealed class PropertyRule : DeclarationRule, IPropertyRule { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs index f53a6a957..1712142f0 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs @@ -228,6 +228,7 @@ public void BackgroundRepeat_TilingCoversTheBorderArea() [Spec("css-values-4", "#position-parsing")] [Spec("css-backgrounds-3", "#background-position")] [Spec("css-backgrounds-4", "#background-position")] + [Spec("css-values-5", "#position-parsing")] public void BackgroundPosition_Keywords(string position, double x, double y) { var g = PaintBackground($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-position:{position}"); diff --git a/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs b/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs index 0b1178fd2..eefeaf2d9 100644 --- a/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs @@ -474,6 +474,7 @@ public void Children_InheritTheComputedLineHeightNotThePercentage() [DataRow("url( a.png )")] [DataRow("url('a.png')")] [DataRow("url( \"a.png\" )")] + [Spec("css-values-5", "#urls")] public void Url_AllowsQuotesAndWhitespace(string value) { Assert.AreEqual("url(\"a.png\")", Styled($"#x{{background-image:{value}}}").BackgroundImage); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssColor5Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssColor5Tests.cs index b9129037c..580fd2735 100644 --- a/Source/Test/HtmlRenderer.Test/CssModules/CssColor5Tests.cs +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssColor5Tests.cs @@ -17,6 +17,7 @@ public sealed class CssColor5Tests [DataRow("color-mix(in srgb, #ff0000, rgb(0 0 255))", 128, 0, 128, 255)] [DataRow("color-mix(in srgb, color-mix(in srgb, red, blue), white)", 191, 128, 191, 255)] [DataRow("color-mix(in srgb, 25% red, blue)", 64, 0, 191, 255)] + [Spec("css-values-5", "#color-mix")] public void ColorMix_MixesTwoColors(string value, int r, int g, int b, int a) { AssertColor(RColor.FromArgb(a, r, g, b), Color(value)); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssValues5Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssValues5Tests.cs new file mode 100644 index 000000000..6d25368a5 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssValues5Tests.cs @@ -0,0 +1,237 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssValues5Tests +{ + private const string Green = "rgb(0, 128, 0)"; + private const double Delta = 0.01; + + // ── 3 Value Definition Syntax ──────────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#component-function-commas")] + public void CommaContainingArgument_CanBeWrappedInBraces() + { + Assert.AreEqual("Times, serif", Styled("#x{font-family:var(--missing, {Times, serif})}").FontFamily); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#css-syntax")] + public void RegisteredCustomProperty_ThatDoesNotMatchItsSyntax_UsesItsInitialValue() + { + Assert.AreEqual(7, Styled("@property --w { syntax: '<length>'; inherits: false; initial-value: 7px } #x{--w:red;margin-left:var(--w)}").ActualMarginLeft, Delta); + } + + // ── 4 Extensions to Level 4 Value Types ────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#request-url-modifiers")] + public void RequestUrlModifier_DoesNotChangeTheUrl() + { + CollectionAssert.AreEqual(new[] { "a.png" }, RequestedImages("#x{background-image:url(\"a.png\" crossorigin(anonymous))}")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#position")] + [DataRow("x-end y-end")] + [DataRow("inline-end block-end")] + [DataRow("end end")] + public void LogicalPositionKeywords_AreAccepted(string value) + { + Assert.AreNotEqual("left top", Styled($"#x{{background-position:left top;background-position:{value}}}").BackgroundPosition); + } + + // ── 5 Progress, 6 Mixing ───────────────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#progress-func")] + public void Progress_IsTheFractionBetweenStartAndEnd() + { + Assert.AreEqual(25, Styled("#x{margin-left:calc(100px * progress(25px, 0px, 100px))}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#media-progress-func")] + public void MediaProgress_IsTheFractionOfTheMediaFeature() + { + Assert.AreEqual(50, Styled("#x{margin-left:calc(100px * media-progress(width, 0px, 1600px))}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#calc-mix")] + public void CalcMix_InterpolatesBetweenTheValues() + { + Assert.AreEqual(25, Styled("#x{margin-left:calc-mix(25%, 0px, 100px)}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css-values-5", "#cross-fade")] + public void CrossFade_IsAcceptedAsAnImage() + { + Assert.AreNotEqual("none", Styled("#x{background-image:cross-fade(url(a.png) 50%, url(b.png))}").BackgroundImage); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#mix")] + public void Mix_InterpolatesBetweenTheValues() + { + Assert.AreEqual(25, Styled("#x{margin-left:mix(25%, 0px, 100px)}").ActualMarginLeft, Delta); + } + + // ── 7 Value Substituting Functions ─────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#first-valid")] + public void FirstValid_UsesTheFirstValidArgument() + { + Assert.AreEqual(Green, Styled("#x{color:red;color:first-valid(foo, green)}").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#if-notation")] + public void If_UsesTheFirstTrueBranch() + { + Assert.AreEqual(Green, Styled("#x{color:red;color:if(media(print): red; else: green)}").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#toggle-notation")] + public void Toggle_CyclesAgainstTheInheritedValue() + { + Assert.AreEqual("normal", Styled("#x{font-style:toggle(italic, normal)}", "<div style='font-style:italic'><div id=x>a</div></div>").FontStyle); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#inherit-notation")] + public void Inherit_TakesTheParentsCustomProperty() + { + Assert.AreEqual(5, Styled("#x{--a:9px;margin-left:inherit(--a)}", "<div style='--a:5px'><div id=x>a</div></div>").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css-values-5", "#attr-notation")] + [Spec("css-values-5", "#attr-substitution")] + public void Attr_WithoutASyntax_IsTheLiteralAttributeValue() + { + Assert.AreEqual("a\\b", Before("#x::before{content:attr(data-v)}", "<div id=x data-v='a\\b'>a</div>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#attr-notation")] + public void Attr_UsesTheFallback_WhenTheAttributeIsMissing() + { + Assert.AreEqual("fb", Before("#x::before{content:attr(data-missing, \"fb\")}")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#attr-notation")] + public void Attr_WithASyntax_ParsesTheAttribute() + { + Assert.AreEqual("[q]", Before("#x::before{content:'[' attr(data-v *) ']'}", "<div id=x data-v='\"q\"'>a</div>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#attr-substitution")] + public void Attr_WorksInAnyProperty() + { + Assert.AreEqual(12, Styled("#x{margin-left:3px;margin-left:attr(data-m <length>)}", "<div id=x data-m='12px'>a</div>").ActualMarginLeft, Delta); + } + + [TestMethod] + [Spec("css-values-5", "#attr-security")] + public void Attr_IsNotUsedAsAUrl() + { + var requested = RequestedImages("#x{background-image:image(attr(data-u))}", "<div id=x data-u='evil.png'>a</div>"); + + Assert.IsFalse(requested.Any(r => r.Contains("evil"))); + } + + // ── 8 Random, 9 Tree Counting, 10 calc-size() ──────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#random")] + public void Random_IsBetweenTheLimits() + { + Assert.AreEqual(10, Styled("#x{margin-left:random(10px, 10px)}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#random-item")] + public void RandomItem_PicksOneOfTheItems() + { + Assert.AreEqual(Green, Styled("#x{color:red;color:random-item(--x, green, green)}").Color); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#tree-counting")] + public void SiblingIndex_IsTheElementsPosition() + { + Assert.AreEqual(30, Styled("#x{margin-left:calc(sibling-index() * 10px)}", "<div>a</div><div>b</div><div id=x>c</div>").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#tree-counting")] + public void SiblingCount_IsTheNumberOfSiblings() + { + Assert.AreEqual(30, Styled("#x{margin-left:calc(sibling-count() * 10px)}", "<div>a</div><div>b</div><div id=x>c</div>").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-values-5.yml")] + [Spec("css-values-5", "#calc-size")] + public void CalcSize_CalculatesWithTheIntrinsicSize() + { + Assert.AreEqual(400, Styled("#x{width:50px;width:calc-size(auto, size * 0.5)}").ActualWidth, Delta); + } + + private static List<string> RequestedImages(string css, string body = "<div id=x>a</div>") + { + var requested = new List<string>(); + var container = new HtmlContainerInt(new MockAdapter()) { MaxSize = new RSize(800, 600) }; + container.ImageLoad += (_, e) => requested.Add(e.Src); + container.SetHtml($"<html><head><style>#x{{width:10px;height:10px}} {css}</style></head><body>{body}</body></html>"); + using var graphics = new RecordingGraphics(); + container.PerformLayout(graphics); + container.PerformPaint(graphics); + return requested; + } + + private static CssBox Styled(string css, string body = "<div id=x>a</div>") + { + var (root, _) = LayoutHarness.Layout($"<html><head><style>{css}</style></head><body style='margin:0'>{body}</body></html>", 800, 600); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } + + private static string Before(string css, string body = "<div id=x>a</div>") + { + var before = Styled(css, body).Boxes.FirstOrDefault(b => b.IsBeforePseudoElement); + Assert.IsNotNull(before, "No ::before box"); + return before.Text ?? string.Concat(LayoutHarness.Descendants(before).SelectMany(b => b.Words).Select(w => w.Text)); + } +} diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssValuesTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssValuesTests.cs index 4a7267a28..5ba98b8f1 100644 --- a/Source/Test/HtmlRenderer.Test/CssModules/CssValuesTests.cs +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssValuesTests.cs @@ -27,6 +27,7 @@ public void CssWideKeyword_ResolvesTheValue(string keyword, string color, double [TestMethod] [Spec("css-values-4", "#custom-idents")] + [Spec("css-values-5", "#textual-values")] public void CustomIdent_IsCaseSensitive() { Assert.AreEqual("2", Before("#x{counter-reset:foo 2} #x::before{content:counter(foo)}")); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssVariablesTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssVariablesTests.cs index bac22654b..f90f0550f 100644 --- a/Source/Test/HtmlRenderer.Test/CssModules/CssVariablesTests.cs +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssVariablesTests.cs @@ -104,6 +104,7 @@ public void CustomProperty_IsInheritedWithItsVariablesSubstituted() [TestMethod] [Spec("css-variables-1", "#using-variables")] + [Spec("css-values-5", "#var-notation")] public void Var_UsesTheFallback_WhenThePropertyIsMissing() { var x = Box("<p id=x style='color: var(--missing, green)'>a</p>"); diff --git a/Spec/css-values-5.yml b/Spec/css-values-5.yml index 01d50912d..95e29fb44 100644 --- a/Spec/css-values-5.yml +++ b/Spec/css-values-5.yml @@ -5,145 +5,164 @@ snapshot: https://www.w3.org/TR/2024/WD-css-values-5-20241111/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module Interactions - status: unknown + status: n/a '#textual-values': title: 2 Textual Data Types - status: unknown + status: partial + gaps: + - 'Same as Level 4, see css-values-4 #textual-values' '#value-defs': title: 3 Value Definition Syntax - status: unknown - '#component-functions': - title: 3.1 Functional Notation Definitions - status: unknown + status: n/a '#component-function-commas': title: 3.1.1 Commas in Function Arguments - status: unknown - '#boolean': - title: 3.2 Boolean Expression Multiplier <boolean-expr[]> - status: unknown + status: unsupported + gaps: + - A {} block around a comma-containing argument isn't unwrapped, the declaration is dropped '#css-syntax': title: '3.3 Specifying CSS Syntax in CSS: the <syntax> type' - status: unknown + status: unsupported + gaps: + - The syntax descriptor of @property is stored but never parsed, registered custom properties have no effect '#parse-syntax': title: 3.3.1 Parsing as <syntax> - status: unknown + status: unsupported '#level-4-extensions': title: 4 Extensions to Level 4 Value Types - status: unknown + status: partial '#urls': title: '4.1 Resource Locators: the <url> type' - status: unknown + status: partial + gaps: + - 'Same as Level 4, see css-values-4 #urls' '#request-url-modifiers': title: 4.1.1 Request URL Modifiers - status: unknown + status: unsupported + gaps: + - 'A modifier after a quoted URL is appended to the URL, see css-values-4 #url-modifiers' '#position': title: '4.2 2D Positioning: the <position> type' - status: unknown + status: partial '#position-parsing': title: 4.2.1 Parsing <position> - status: unknown + status: partial + gaps: + - The x-/y-, block-/inline- and start/end keywords aren't supported, the declaration is dropped + - "'background-position' only resolves keywords, see CSS 2.1 14.2.1" '#position-serialization': title: 4.2.2 Serializing <position> - status: unknown + status: n/a '#combine-positions': title: 4.2.3 Combination of <position> - status: unknown + status: n/a '#progress': title: 5 Interpolation Progress Functional Notations - status: unknown + status: unsupported '#progress-func': title: '5.1 Calculated Progress Values: the progress() notation' - status: unknown + status: unsupported '#media-progress-func': title: '5.2 Media Query Progress Values: the media-progress() notation' - status: unknown + status: unsupported '#container-progress-func': title: '5.3 Container Query Progress Values: the container-progress() notation' - status: unknown + status: unsupported '#mixing': title: '6 Mixing and Interpolation Notations: the *-mix() family' - status: unknown + status: partial '#progress-type': title: '6.1 Representing Interpolation Progress: the <progress> type' - status: unknown + status: unsupported '#calc-mix': title: '6.2 Interpolated Numeric and Dimensional Values: the calc-mix() notation' - status: unknown + status: unsupported '#color-mix': title: '6.3 Interpolated Color Values: the color-mix() notation' - status: unknown + status: partial + gaps: + - 'See css-color-5 #color-mix' '#cross-fade': title: '6.4 Interpolated Image Values: the cross-fade() notation' - status: unknown + status: parsed-only + gaps: + - 'cross-fade() is accepted but paints nothing, see css-images-4 #cross-fade-function' '#transform-mix': title: '6.5 Interpolated Transform Values: the transform-mix() notation' - status: unknown + status: unsupported '#mix': title: '6.6 Interpolated Property Values: the mix() notation' - status: unknown + status: unsupported '#value-insert': title: 7 Miscellaneous Value Substituting Functions - status: unknown + status: partial '#whole-value': title: '7.1 Representing An Entire Property Value: the <whole-value> type' - status: unknown + status: unsupported + gaps: + - None of the functions that take a <whole-value> are supported '#first-valid': title: '7.2 Selecting the First Supported Value: the first-valid() notation' - status: unknown + status: unsupported '#if-notation': title: '7.3 Conditional Value Selection: the if() notation' - status: unknown + status: unsupported '#toggle-notation': title: '7.4 Toggling Between Values: the toggle() notation' - status: unknown + status: unsupported '#var-notation': title: '7.5 Custom Property References: the var() notation' - status: unknown + status: partial + gaps: + - 'See css-variables-1 #using-variables' '#inherit-notation': title: '7.6 Inherited Value References: the inherit() notation' - status: unknown + status: unsupported '#attr-notation': title: '7.7 Attribute References: the attr() notation' - status: unknown + status: partial + gaps: + - The fallback is ignored, a missing attribute gives an empty string + - An attr() with a <syntax> makes the declaration invalid '#attr-substitution': title: '7.7.1 Attribute Value Substitution: the attr() notation' - status: unknown + status: partial + gaps: + - attr() only works in content, in any other property the declaration is dropped '#attr-security': title: 7.7.2 Security - status: unknown + status: supported '#randomness': title: 8 Generating Random Values - status: unknown + status: unsupported '#random': title: '8.1 Generating a Random Numeric Value: the random() function' - status: unknown + status: unsupported '#random-infinities': title: 8.1.1 Argument Ranges - status: unknown + status: unsupported '#random-item': title: '8.2 Picking a Random Item From a List: the random-item() function' - status: unknown + status: unsupported '#random-caching': title: '8.3 Generating/Caching Random Values: the <random-caching-options> value' - status: unknown + status: unsupported '#tree-counting': title: '9 Tree Counting Functions: the sibling-count() and sibling-index() notations' - status: unknown + status: unsupported '#calc-size': title: '10 Calculating With Intrinsic Sizes: the calc-size() function' - status: unknown + status: unsupported '#simplifying-calc-size': title: 10.1 Simplifying calc-size() - status: unknown + status: unsupported '#resolving-calc-size': title: 10.2 Resolving calc-size() - status: unknown + status: unsupported '#interp-calc-size': title: 10.3 Interpolating calc-size() - status: unknown + status: n/a '#interpolate-size': title: '10.4 Interpolating sizing keywords: the interpolate-size property' - status: unknown + status: n/a + gaps: + - Only affects transitions and animations, which never run From 0bb3d4b9db13fc1daa23209472b2ef7d7435fb40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:32:08 +0100 Subject: [PATCH 142/150] Verify and document CSS Conditional Rules Level 4 compliance --- .../CssModules/CssConditional4Tests.cs | 40 +++++++++++++++++++ Spec/css-conditional-4.yml | 8 ++-- 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssConditional4Tests.cs diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssConditional4Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssConditional4Tests.cs new file mode 100644 index 000000000..c05fe9ab7 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssConditional4Tests.cs @@ -0,0 +1,40 @@ +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssConditional4Tests +{ + [TestMethod] + [Ignore("Gap, see Spec/css-conditional-4.yml")] + [Spec("css-conditional-4", "#at-supports-ext")] + [DataRow("selector(p)")] + [DataRow("selector(p > a)")] + public void SelectorFunction_IsTrueForASupportedSelector(string condition) + { + var rule = Supports(condition); + + Assert.IsNotNull(rule, "The rule is valid"); + Assert.IsTrue(rule.Condition.Check()); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-conditional-4.yml")] + [Spec("css-conditional-4", "#support-definition-ext")] + [DataRow("selector(p:foo)")] + [DataRow("selector(p::-webkit-foo)")] + public void SelectorFunction_IsFalseForAnUnsupportedSelector(string condition) + { + var rule = Supports(condition); + + Assert.IsNotNull(rule, "The rule is valid"); + Assert.IsFalse(rule.Condition.Check()); + } + + private static SupportsRule Supports(string condition) + { + var sheet = CssConstructionFunctions.ParseStyleSheet("@supports " + condition + " { }"); + return sheet.Rules.Length == 1 ? (SupportsRule)sheet.Rules[0] : null!; + } +} diff --git a/Spec/css-conditional-4.yml b/Spec/css-conditional-4.yml index 123955c5b..04c544159 100644 --- a/Spec/css-conditional-4.yml +++ b/Spec/css-conditional-4.yml @@ -5,10 +5,12 @@ snapshot: https://www.w3.org/TR/2025/CRD-css-conditional-4-20250904/ sections: '#introduction': title: 1 Introduction - status: unknown + status: n/a '#at-supports-ext': title: 2 Extensions to the @supports rule - status: unknown + status: unsupported + gaps: + - selector() makes the @supports rule invalid '#support-definition-ext': title: 2.1 Extensions to the definition of support - status: unknown + status: unsupported From 809318fcf668b4fc0a19f0f6f4ba2c8cf9c0852d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:32:08 +0100 Subject: [PATCH 143/150] Verify and document CSS Conditional Rules Level 5 compliance --- .../CssModules/CssConditional5Tests.cs | 160 ++++++++++++++++++ Spec/css-conditional-5.yml | 89 +++++----- 2 files changed, 211 insertions(+), 38 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssConditional5Tests.cs diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssConditional5Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssConditional5Tests.cs new file mode 100644 index 000000000..f4d3b62e3 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssConditional5Tests.cs @@ -0,0 +1,160 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssConditional5Tests +{ + private static readonly RColor Green = RColor.FromArgb(0, 128, 0); + + // ── 2 @supports extensions ─────────────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/css-conditional-5.yml")] + [Spec("css-conditional-5", "#support-definition-ext-fonts")] + [DataRow("font-format(truetype)", true)] + [DataRow("font-tech(color-COLRv1)", false)] + public void FontFunctions_TestFontSupport(string condition, bool expected) + { + var rule = Supports(condition); + + Assert.IsNotNull(rule, "The rule is valid"); + Assert.AreEqual(expected, rule.Condition.Check()); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-conditional-5.yml")] + [Spec("css-conditional-5", "#support-definition-at-rules")] + [DataRow("at-rule(@media)", true)] + [DataRow("at-rule(@foo)", false)] + public void AtRuleFunction_TestsAtRuleSupport(string condition, bool expected) + { + var rule = Supports(condition); + + Assert.IsNotNull(rule, "The rule is valid"); + Assert.AreEqual(expected, rule.Condition.Check()); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-conditional-5.yml")] + [Spec("css-conditional-5", "#support-definition-supports-condition-name")] + public void UnknownNamedCondition_IsFalse() + { + var rule = Supports("(--unknown)"); + + Assert.IsNotNull(rule, "The rule is valid"); + Assert.IsFalse(rule.Condition.Check()); + } + + // ── 3 @when, 4 @else ───────────────────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/css-conditional-5.yml")] + [Spec("css-conditional-5", "#when-rule")] + public void When_AppliesItsRulesWhenTrue() + { + var x = Box("<style>p { color: red } @when media(screen) { p { color: green } }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-conditional-5.yml")] + [Spec("css-conditional-5", "#else-rule")] + public void Else_AppliesWhenTheEarlierConditionsAreFalse() + { + var x = Box("<style>@when media(print) { p { color: red } } @else { p { color: green } }</style><p id=x>a</p>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + // ── 5 Container Queries ────────────────────────────────────────────── + + [TestMethod] + [Spec("css-conditional-5", "#container-type")] + [DataRow("normal")] + [DataRow("size")] + [DataRow("inline-size")] + public void ContainerType_IsParsed(string value) + { + Assert.AreEqual(value, CssConstructionFunctions.ParseDeclaration("container-type: " + value)!.Value); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-conditional-5.yml")] + [Spec("css-conditional-5", "#container-type")] + public void ContainerTypeSize_AppliesSizeContainment() + { + var x = Box("<div id=x style='container-type:size;width:100px'><div style='height:20px'>a</div></div>"); + + Assert.AreEqual(0, x.Size.Height, 0.01); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-conditional-5.yml")] + [Spec("css-conditional-5", "#container-name")] + public void ContainerName_TakesIdentifiers() + { + Assert.AreEqual("foo bar", CssConstructionFunctions.ParseDeclaration("container-name: foo bar")!.Value); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-conditional-5.yml")] + [Spec("css-conditional-5", "#container-shorthand")] + public void ContainerShorthand_IsParsed() + { + Assert.IsNotNull(CssConstructionFunctions.ParseDeclaration("container: foo / size")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-conditional-5.yml")] + [Spec("css-conditional-5", "#container-rule")] + [Spec("css-conditional-5", "#width")] + public void ContainerRule_AppliesWhenTheContainerMatches() + { + var x = Box("<style>p { color: red } @container (min-width: 1px) { p { color: green } }</style>" + + "<div style='container-type:inline-size'><p id=x>a</p></div>"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-conditional-5.yml")] + [Spec("css-conditional-5", "#container-lengths")] + public void ContainerUnits_ResolveAgainstTheContainer() + { + var x = Box("<div style='container-type:inline-size;width:100px'><div id=x style='width:50cqw'>a</div></div>"); + + Assert.AreEqual(50, x.Size.Width, 0.01); + } + + // ── 8 @supports-condition ──────────────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/css-conditional-5.yml")] + [Spec("css-conditional-5", "#supports-condition-rule")] + public void SupportsConditionRule_IsParsed() + { + var sheet = CssConstructionFunctions.ParseStyleSheet("@supports-condition --foo { (color: red) }"); + + Assert.AreEqual(1, sheet.Rules.Length); + } + + private static SupportsRule Supports(string condition) + { + var sheet = CssConstructionFunctions.ParseStyleSheet("@supports " + condition + " { }"); + return sheet.Rules.Length == 1 ? (SupportsRule)sheet.Rules[0] : null!; + } + + private static CssBox Box(string body, string id = "x") + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(body)); + var box = LayoutHarness.FindById(root, id); + Assert.IsNotNull(box, $"No box with id '{id}'"); + return box; + } +} diff --git a/Spec/css-conditional-5.yml b/Spec/css-conditional-5.yml index 414268cf2..e5ecb0a53 100644 --- a/Spec/css-conditional-5.yml +++ b/Spec/css-conditional-5.yml @@ -5,103 +5,116 @@ snapshot: https://www.w3.org/TR/2025/WD-css-conditional-5-20251030/ sections: '#introduction': title: 1 Introduction - status: unknown + status: n/a '#at-supports-ext': title: 2 Extensions to the @supports rule - status: unknown + status: unsupported + gaps: + - selector(), font-tech(), font-format() and at-rule() make the @supports rule invalid '#support-definition-ext': title: 2.1 Extensions to the definition of support - status: unknown + status: unsupported '#support-definition-ext-fonts': title: 2.1.1 Font techs and formats - status: unknown + status: unsupported '#support-definition-at-rules': title: 2.1.2 At-rules - status: unknown + status: unsupported '#support-definition-supports-condition-name': title: 2.1.3 Named conditions - status: unknown + status: unsupported + gaps: + - An unknown named condition, (--foo), is true instead of false '#when-rule': title: '3 Generalized Conditional Rules: the @when rule' - status: unknown + status: unsupported + gaps: + - '@when is dropped' '#else-rule': title: '4 Chained Conditionals: the @else rule' - status: unknown + status: unsupported + gaps: + - '@else is dropped' '#container-queries': title: 5 Container Queries - status: unknown + status: unsupported '#container-type': title: '5.1 Creating Query Containers: the container-type property' - status: unknown + status: parsed-only + gaps: + - size and inline-size don't apply size containment + - scroll-state isn't supported, the declaration is dropped '#container-name': title: '5.2 Naming Query Containers: the container-name property' - status: unknown + status: unsupported + gaps: + - Only a quoted string is accepted, a <custom-ident> name drops the declaration '#container-shorthand': title: '5.3 Creating Named Containers: the container shorthand' - status: unknown + status: unsupported + gaps: + - The container shorthand isn't a property, it doesn't serialize container-name and container-type '#container-rule': title: '5.4 Container Queries: the @container rule' - status: unknown + status: unsupported + gaps: + - The rules inside @container never apply, whatever the condition '#animated-containers': title: 5.5 Animated Containers - status: unknown + status: n/a '#container-features': title: 6 Container Features - status: unknown + status: unsupported '#size-container': title: 6.1 Size Container Features - status: unknown + status: unsupported '#width': title: '6.1.1 Width: the width feature' - status: unknown + status: unsupported '#height': title: '6.1.2 Height: the height feature' - status: unknown + status: unsupported '#inline-size': title: '6.1.3 Inline-size: the inline-size feature' - status: unknown + status: unsupported '#block-size': title: '6.1.4 Block-size: the block-size feature' - status: unknown + status: unsupported '#aspect-ratio': title: '6.1.5 Aspect-ratio: the aspect-ratio feature' - status: unknown + status: unsupported '#orientation': title: '6.1.6 Orientation: the orientation feature' - status: unknown + status: unsupported '#style-container': title: 6.2 Style Container Features - status: unknown + status: unsupported '#scroll-state-container': title: 6.3 Scroll State Container Features - status: unknown + status: unsupported '#updating-scroll-state': title: 6.3.1 Updating Scroll State - status: unknown + status: n/a '#stuck': title: '6.3.2 Sticky positioning: the stuck feature' - status: unknown + status: unsupported '#snapped': title: '6.3.3 Scroll snapping: the snapped feature' - status: unknown + status: unsupported '#scrollable': title: '6.3.4 Scrollable: the scrollable feature' - status: unknown + status: unsupported '#scrolled': title: '6.3.5 Scrolled: the scrolled feature' - status: unknown + status: unsupported '#container-lengths': title: '7 Container Relative Lengths: the cqw, cqh, cqi, cqb, cqmin, cqmax units' - status: unknown + status: unsupported + gaps: + - The cq* units aren't supported, the declaration is dropped '#supports-condition-rule': title: '8 Defining Custom Support Queries: the @supports-condition rule' - status: unknown + status: unsupported '#apis': title: 9 APIs - status: unknown - '#the-csscontainerrule-interface': - title: 9.1 The CSSContainerRule interface - status: unknown - '#the-csssupportscondition-interface': - title: 9.2 The CSSSupportsConditionRule interface - status: unknown + status: n/a From 28068b5796b972b464677908b8f7aace947fa1a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:36:40 +0100 Subject: [PATCH 144/150] Verify and document Media Queries Level 5 compliance --- .../MediaFeatures/AnyHoverMediaFeature.cs | 3 +- .../MediaFeatures/AnyPointerMediaFeature.cs | 3 +- .../PrefersColorSchemeMediaFeature.cs | 4 +- .../PrefersContrastMediaFeature.cs | 3 +- .../PrefersReducedMotionMediaFeature.cs | 3 +- .../PrefersReducedTransparencyMediaFeature.cs | 3 +- .../MediaFeatures/UpdateMediaFeature.cs | 4 +- Source/HtmlRenderer/Core/MediaQueryContext.cs | 7 +- .../PrintMediaTests.cs | 1 + .../Css/MediaFeaturesTests.cs | 1 - .../HtmlRenderer.Test/Css/MediaListTests.cs | 7 + .../HtmlRenderer.Test/Css/MediaQueryTests.cs | 4 + .../HtmlRenderer.Test/Css/MediaTypeTests.cs | 6 + .../CssModules/CssConditionalTests.cs | 2 + .../CssModules/MediaQueries5Tests.cs | 203 ++++++++++++++++ .../CssModules/MediaQueriesTests.cs | 100 ++++++-- .../TestSupport/MockAdapter.cs | 4 + Spec/mediaqueries-5.yml | 221 +++++++++--------- 18 files changed, 419 insertions(+), 160 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/MediaQueries5Tests.cs diff --git a/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/AnyHoverMediaFeature.cs b/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/AnyHoverMediaFeature.cs index a58ee27a0..4aa0f727a 100644 --- a/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/AnyHoverMediaFeature.cs +++ b/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/AnyHoverMediaFeature.cs @@ -1,8 +1,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The <c>any-hover</c> media feature (Media Queries 4). Matched against the renderer's fixed - /// <c>none</c> stance (a static PDF has no hover-capable input) in <c>MediaQueryMatcher</c>. + /// Media Queries 5 §7.3, matched in <c>MediaQueryMatcher</c>. /// </summary> internal sealed class AnyHoverMediaFeature : MediaFeature { diff --git a/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/AnyPointerMediaFeature.cs b/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/AnyPointerMediaFeature.cs index 576e2c3c5..252ed534b 100644 --- a/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/AnyPointerMediaFeature.cs +++ b/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/AnyPointerMediaFeature.cs @@ -1,8 +1,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The <c>any-pointer</c> media feature (Media Queries 4). Matched against the renderer's fixed - /// <c>none</c> stance (a static PDF has no pointing device) in <c>MediaQueryMatcher</c>. + /// Media Queries 5 §7.3, matched in <c>MediaQueryMatcher</c>. /// </summary> internal sealed class AnyPointerMediaFeature : MediaFeature { diff --git a/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersColorSchemeMediaFeature.cs b/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersColorSchemeMediaFeature.cs index 1f4bbb601..64197a593 100644 --- a/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersColorSchemeMediaFeature.cs +++ b/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersColorSchemeMediaFeature.cs @@ -1,9 +1,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The <c>prefers-color-scheme</c> media feature (Media Queries 5). The value is validated as any - /// identifier here; the actual <c>light</c>/<c>dark</c> match against the configured scheme happens - /// in <c>MediaQueryMatcher</c>. + /// Media Queries 5 §12.5, matched against the adapter's color scheme in <c>MediaQueryMatcher</c>. /// </summary> internal sealed class PrefersColorSchemeMediaFeature : MediaFeature { diff --git a/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersContrastMediaFeature.cs b/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersContrastMediaFeature.cs index 40f3301d2..52e2faff1 100644 --- a/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersContrastMediaFeature.cs +++ b/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersContrastMediaFeature.cs @@ -1,8 +1,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The <c>prefers-contrast</c> media feature (Media Queries 5). Matched against the renderer's fixed - /// <c>no-preference</c> stance in <c>MediaQueryMatcher</c>. + /// Media Queries 5 §12.3, matched in <c>MediaQueryMatcher</c>. /// </summary> internal sealed class PrefersContrastMediaFeature : MediaFeature { diff --git a/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersReducedMotionMediaFeature.cs b/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersReducedMotionMediaFeature.cs index 717844821..a7e69c3fb 100644 --- a/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersReducedMotionMediaFeature.cs +++ b/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersReducedMotionMediaFeature.cs @@ -1,8 +1,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The <c>prefers-reduced-motion</c> media feature (Media Queries 5). Matched against the renderer's - /// fixed <c>reduce</c> stance (a static PDF has no motion) in <c>MediaQueryMatcher</c>. + /// Media Queries 5 §12.1, matched in <c>MediaQueryMatcher</c>. /// </summary> internal sealed class PrefersReducedMotionMediaFeature : MediaFeature { diff --git a/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersReducedTransparencyMediaFeature.cs b/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersReducedTransparencyMediaFeature.cs index ef84cf8ce..124a93849 100644 --- a/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersReducedTransparencyMediaFeature.cs +++ b/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/PrefersReducedTransparencyMediaFeature.cs @@ -1,8 +1,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The <c>prefers-reduced-transparency</c> media feature (Media Queries 5). Matched against the - /// renderer's fixed <c>no-preference</c> stance in <c>MediaQueryMatcher</c>. + /// Media Queries 5 §12.2, matched in <c>MediaQueryMatcher</c>. /// </summary> internal sealed class PrefersReducedTransparencyMediaFeature : MediaFeature { diff --git a/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/UpdateMediaFeature.cs b/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/UpdateMediaFeature.cs index 59eab7e2b..7e8f564b5 100644 --- a/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/UpdateMediaFeature.cs +++ b/Source/HtmlRenderer/Core/CssEngine/MediaFeatures/UpdateMediaFeature.cs @@ -1,9 +1,7 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// <summary> - /// The <c>update</c> media feature (Media Queries 4): how frequently the output device can modify the - /// appearance of content. A printed/exported PDF cannot update once produced, so it is matched against - /// <c>none</c> in <c>MediaQueryMatcher</c>. + /// Media Queries 5 §5.4, matched in <c>MediaQueryMatcher</c>. /// </summary> internal sealed class UpdateMediaFeature : MediaFeature { diff --git a/Source/HtmlRenderer/Core/MediaQueryContext.cs b/Source/HtmlRenderer/Core/MediaQueryContext.cs index 1a27ddf4a..cbdeb1a31 100644 --- a/Source/HtmlRenderer/Core/MediaQueryContext.cs +++ b/Source/HtmlRenderer/Core/MediaQueryContext.cs @@ -15,11 +15,8 @@ namespace TheArtOfDev.HtmlRenderer.Core { /// <summary> - /// The resolved "device" a CSS <c>@media</c> query is evaluated against: the media type plus the - /// viewport geometry and the display characteristics the adapter reports. Built once per render and - /// threaded through <see cref="CssData"/>'s rule matching so <c>@media</c> conditions - /// (<c>min-width</c>, <c>orientation</c>, <c>prefers-color-scheme</c>, ...) actually gate which rules - /// apply. See <see cref="MediaQueryMatcher"/> for the evaluation itself. + /// The device an <c>@media</c> query is evaluated against (Media Queries 5 §3.1), see + /// <see cref="MediaQueryMatcher"/>. /// </summary> internal struct MediaQueryContext { diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/PrintMediaTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/PrintMediaTests.cs index 87f9bab4a..e9da8ecce 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/PrintMediaTests.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/PrintMediaTests.cs @@ -11,6 +11,7 @@ public sealed class PrintMediaTests [TestMethod] [Spec("html", "rendering.html#print-media")] [Spec("mediaqueries-4", "#media-types")] + [Spec("mediaqueries-5", "#media-types")] public async Task Pdf_IsRenderedForPrintMedia() { const string html = "<html><head><style>div { height: 10pt } @media print { div { height: 1000pt } }</style></head>" diff --git a/Source/Test/HtmlRenderer.Test/Css/MediaFeaturesTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaFeaturesTests.cs index 338489f25..a8efa3c0d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaFeaturesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaFeaturesTests.cs @@ -6,7 +6,6 @@ namespace HtmlRenderer.Test.Css; public sealed class MediaFeaturesTests { [TestMethod] - [Spec("mediaqueries-4", "#mq-features")] public void CssMediaFeatureFactory() { var aspectRatio = MediaFeatureFactory.Instance.Create(FeatureNames.AspectRatio); diff --git a/Source/Test/HtmlRenderer.Test/Css/MediaListTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaListTests.cs index e5bc3b23f..35cff69ea 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaListTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaListTests.cs @@ -26,6 +26,7 @@ public void SimpleScreenMediaList() [TestMethod] [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] public void MediaListAtIllegal() { var source = @"@media @screen { @@ -196,6 +197,7 @@ public void NoMediaQueryGivenSkip() [TestMethod] [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] public void NotNoMediaTypeOrExpressionSkip() { var source = @"@media not { @@ -225,6 +227,7 @@ public void OnlyNoMediaTypeOrExpressionSkip() [TestMethod] [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] public void MediaFeatureMissingSkip() { var source = @"@media () { @@ -257,6 +260,7 @@ public void MediaFeatureMissingSkipReadNext() [TestMethod] [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] public void FeatureMaxWidthMediaListMissingConnectedAnd() { var source = @"@media (max-width:30px) (min-width:10px) { @@ -272,6 +276,7 @@ public void FeatureMaxWidthMediaListMissingConnectedAnd() [TestMethod] [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] public void TvScreenMediaListMissingComma() { var source = @"@media tv screen { @@ -287,6 +292,7 @@ public void TvScreenMediaListMissingComma() [TestMethod] [Spec("mediaqueries-4", "#mq-syntax")] + [Spec("mediaqueries-5", "#mq-syntax")] public void AllFeatureMaxWidthMediaListWithAndKeyword() { var source = @"@media all and (max-width:30px) { @@ -404,6 +410,7 @@ public void CssMediaListApiWithAppendDeleteAndTextShouldWork() [TestMethod] [Spec("mediaqueries-4", "#mq-range-context")] + [Spec("mediaqueries-5", "#mq-range-context")] public void CombinedConditionMediaQueriesLevel4() { const string source = @"/* Traditionelle Syntax */ diff --git a/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs index 7d96f841d..80e912ded 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs @@ -52,6 +52,7 @@ public void AtMedia_Screen_NestedRuleAppliesUnderScreenMedia() [TestMethod] [Spec("css21", "media.html#at-media-rule")] [Spec("mediaqueries-4", "#media-types")] + [Spec("mediaqueries-5", "#media-types")] public void AtMedia_All_NestedRuleAppliesUnderAllMedia() { var box = LayoutAndFindTag("@media all { p { color: red; } }", "<p>text</p>", "p", "screen"); @@ -77,6 +78,7 @@ public void AtMedia_Print_MultipleNestedRulesAreParsed() [TestMethod] [Spec("mediaqueries-4", "#mq-not")] + [Spec("mediaqueries-5", "#mq-not")] public void AtMedia_NotPrint_ExcludesPrintButAppliesElsewhere() { const string css = "@media not print { p { color: red; } }"; @@ -90,6 +92,7 @@ public void AtMedia_NotPrint_ExcludesPrintButAppliesElsewhere() [TestMethod] [Spec("mediaqueries-4", "#mq-only")] + [Spec("mediaqueries-5", "#mq-only")] public void AtMedia_OnlyPrint_AppliesOnlyUnderPrint() { const string css = "@media only print { p { color: red; } }"; @@ -106,6 +109,7 @@ public void AtMedia_OnlyPrint_AppliesOnlyUnderPrint() [TestMethod] [Spec("css21", "media.html#at-media-rule")] [Spec("mediaqueries-4", "#mq-list")] + [Spec("mediaqueries-5", "#mq-list")] public void AtMedia_PrintCommaScreen_AppliesUnderBoth() { const string css = "@media print, screen { p { color: red; } }"; diff --git a/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs index d2298eeb4..94705e33b 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs @@ -116,6 +116,7 @@ public void RecognizedMediaType_MatchesItself(string mediaType) [DataRow("screen")] [DataRow("print")] [Spec("mediaqueries-4", "#media-types")] + [Spec("mediaqueries-5", "#media-types")] public void All_MatchesEveryMediaType(string mediaType) { Assert.AreEqual(Green, ColorOf("<style>@media all { p { color: green } }</style>", mediaType)); @@ -126,6 +127,7 @@ public void All_MatchesEveryMediaType(string mediaType) [DataRow("SCREEN")] [DataRow("Screen")] [Spec("mediaqueries-4", "#mq-syntax")] + [Spec("mediaqueries-5", "#mq-syntax")] public void MediaTypeNames_AreCaseInsensitive(string name) { Assert.AreEqual(Green, ColorOf($"<style>@media {name} {{ p {{ color: green }} }}</style>")); @@ -143,6 +145,7 @@ public void MediaType_OtherThanTheRenderedOne_DoesNotMatch() [DataRow("holographic, screen")] [DataRow("screen, holographic")] [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] public void UnknownMediaType_IsTreatedAsNotPresent(string mediaList) { Assert.AreEqual(Green, ColorOf($"<style>@media {mediaList} {{ p {{ color: green }} }}</style>")); @@ -152,6 +155,7 @@ public void UnknownMediaType_IsTreatedAsNotPresent(string mediaList) [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "media.html#media-types")] [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] public void MalformedEntryInAMediaList_DoesNotDisableTheOthers() { Assert.AreEqual(Green, ColorOf("<style>@media screen, 3D { p { color: green } }</style>")); @@ -160,6 +164,7 @@ public void MalformedEntryInAMediaList_DoesNotDisableTheOthers() [TestMethod] [Spec("css21", "media.html#media-types")] [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] public void OnlyUnknownMediaTypes_DoNotMatch() { Assert.AreNotEqual(Green, ColorOf("<style>@media holographic { p { color: green } }</style>")); @@ -168,6 +173,7 @@ public void OnlyUnknownMediaTypes_DoNotMatch() [TestMethod] [Spec("css21", "media.html#media-types")] [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] public void MalformedMediaType_InvalidatesTheStatement() { Assert.AreNotEqual(Green, ColorOf("<style>@media @screen { p { color: green } }</style>")); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs index d57c32abc..efc52b3d3 100644 --- a/Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs @@ -37,6 +37,7 @@ public void NestedConditions_ApplyOnlyWhenAllAreTrue() [TestMethod] [Spec("css-conditional-3", "#processing")] [Spec("mediaqueries-4", "#media")] + [Spec("mediaqueries-5", "#media")] public void ChangedCondition_IsReflected() { var html = LayoutHarness.Wrap("<style>@media (max-width: 600px) { p { color: green } }</style><p id=x>a</p>"); @@ -76,6 +77,7 @@ public void SupportsRule_InsideAMediaRule_Applies() [TestMethod] [Spec("css-conditional-3", "#at-media")] [Spec("mediaqueries-4", "#mq-list")] + [Spec("mediaqueries-5", "#mq-list")] public void MediaRule_WithAnEmptyQueryList_Applies() { var x = Box("<style>@media { p { color: green } }</style><p id=x>a</p>"); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/MediaQueries5Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/MediaQueries5Tests.cs new file mode 100644 index 000000000..480c811aa --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/MediaQueries5Tests.cs @@ -0,0 +1,203 @@ +using HtmlRenderer.Test.CssEngineSupport; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.CssModules; + +/// <summary> +/// The features Media Queries 5 adds, evaluated against an 800x600 screen. +/// </summary> +[TestClass] +public sealed class MediaQueries5Tests +{ + // ── 4 Viewport/Page Characteristics ────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/mediaqueries-5.yml")] + [Spec("mediaqueries-5", "#mf-horizontal-viewport-segments")] + [Spec("mediaqueries-5", "#mf-vertical-viewport-segments")] + [DataRow("(horizontal-viewport-segments: 1)")] + [DataRow("(vertical-viewport-segments: 1)")] + public void ViewportSegments_AreOne(string query) + { + Assert.IsTrue(Matches(query)); + } + + [TestMethod] + [Ignore("Gap, see Spec/mediaqueries-5.yml")] + [Spec("mediaqueries-5", "#display-modes")] + public void DisplayMode_IsBrowser() + { + Assert.IsTrue(Matches("(display-mode: browser)")); + } + + // ── 5 Display Quality ──────────────────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/mediaqueries-5.yml")] + [Spec("mediaqueries-5", "#environment-blending")] + public void EnvironmentBlending_IsOpaque() + { + Assert.IsTrue(Matches("(environment-blending: opaque)")); + } + + // ── 6 Color ────────────────────────────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/mediaqueries-5.yml")] + [Spec("mediaqueries-5", "#dynamic-range")] + [Spec("mediaqueries-5", "#video-dynamic-range")] + [DataRow("(dynamic-range: standard)")] + [DataRow("(video-dynamic-range: standard)")] + public void DynamicRange_IsStandard(string query) + { + Assert.IsTrue(Matches(query)); + } + + [TestMethod] + [Ignore("Gap, see Spec/mediaqueries-5.yml")] + [Spec("mediaqueries-5", "#video-color-gamut")] + public void VideoColorGamut_IsSrgb() + { + Assert.IsTrue(Matches("(video-color-gamut: srgb)")); + } + + [TestMethod] + [Ignore("Gap, see Spec/mediaqueries-5.yml")] + [Spec("mediaqueries-5", "#inverted")] + public void InvertedColors_IsNone() + { + Assert.IsTrue(Matches("(inverted-colors: none)")); + } + + // ── 7 Interaction ──────────────────────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/mediaqueries-5.yml")] + [Spec("mediaqueries-5", "#nav-controls")] + public void NavControls_IsNone() + { + Assert.IsTrue(Matches("(nav-controls: none)")); + } + + // ── 9 Scripting ────────────────────────────────────────────────────── + + [TestMethod] + [Spec("mediaqueries-5", "#scripting")] + [DataRow("(scripting: none)", true)] + [DataRow("(scripting: initial-only)", false)] + [DataRow("(scripting: enabled)", false)] + [DataRow("(scripting)", false)] + public void Scripting_IsNone(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + // ── 10 Custom Media Queries ────────────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/mediaqueries-5.yml")] + [Spec("mediaqueries-5", "#custom-mq")] + public void CustomMediaQuery_IsEvaluatedWhereItIsUsed() + { + var sheet = CssConstructionFunctions.ParseStyleSheet("@custom-media --wide (min-width: 100px); @media (--wide) { }"); + var rule = sheet.Rules.OfType<MediaRule>().SingleOrDefault(); + + Assert.IsNotNull(rule, "The @media rule is valid"); + Assert.IsTrue(MediaQueryMatcher.Matches(new[] { rule.Media }, MediaQueryContext.FromAdapter(new MockAdapter(), 800, 600))); + } + + // ── 12 User Preferences ────────────────────────────────────────────── + + [TestMethod] + [Spec("mediaqueries-5", "#prefers-reduced-motion")] + [DataRow("(prefers-reduced-motion: reduce)", true)] + [DataRow("(prefers-reduced-motion: no-preference)", false)] + [DataRow("(prefers-reduced-motion)", true)] + public void PrefersReducedMotion_IsReduce(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-5", "#prefers-reduced-transparency")] + [DataRow("(prefers-reduced-transparency: no-preference)", true)] + [DataRow("(prefers-reduced-transparency: reduce)", false)] + [DataRow("(prefers-reduced-transparency)", false)] + public void PrefersReducedTransparency_IsNoPreference(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-5", "#prefers-contrast")] + [DataRow("(prefers-contrast: no-preference)", true)] + [DataRow("(prefers-contrast: more)", false)] + [DataRow("(prefers-contrast: less)", false)] + [DataRow("(prefers-contrast: custom)", false)] + [DataRow("(prefers-contrast)", false)] + public void PrefersContrast_IsNoPreference(string query, bool expected) + { + Assert.AreEqual(expected, Matches(query)); + } + + [TestMethod] + [Ignore("Gap, see Spec/mediaqueries-5.yml")] + [Spec("mediaqueries-5", "#forced-colors")] + [DataRow("(forced-colors: none)")] + [DataRow("not all and (forced-colors)")] + public void ForcedColors_IsNone(string query) + { + Assert.IsTrue(Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-5", "#forced-colors")] + public void ForcedColors_Active_DoesNotMatch() + { + Assert.IsFalse(Matches("(forced-colors: active)")); + } + + [TestMethod] + [Spec("mediaqueries-5", "#prefers-color-scheme")] + [DataRow(RColorScheme.Light, "(prefers-color-scheme: light)", true)] + [DataRow(RColorScheme.Light, "(prefers-color-scheme: dark)", false)] + [DataRow(RColorScheme.Dark, "(prefers-color-scheme: dark)", true)] + [DataRow(RColorScheme.Dark, "(prefers-color-scheme: light)", false)] + [DataRow(RColorScheme.Dark, "(prefers-color-scheme)", true)] + public void PrefersColorScheme_FollowsTheAdapter(RColorScheme scheme, string query, bool expected) + { + Assert.AreEqual(expected, Matches(query, scheme)); + } + + [TestMethod] + [Ignore("Gap, see Spec/mediaqueries-5.yml")] + [Spec("mediaqueries-5", "#prefers-color-scheme")] + [Spec("mediaqueries-5", "#prefers-contrast")] + [DataRow("not all and (prefers-color-scheme: foo)")] + [DataRow("not all and (prefers-contrast: foo)")] + public void UnknownPreferenceValue_IsFalse_EvenNegated(string query) + { + Assert.IsFalse(Matches(query)); + } + + [TestMethod] + [Ignore("Gap, see Spec/mediaqueries-5.yml")] + [Spec("mediaqueries-5", "#prefers-reduced-data")] + public void PrefersReducedData_IsNoPreference() + { + Assert.IsTrue(Matches("(prefers-reduced-data: no-preference)")); + } + + private static bool Matches(string query, RColorScheme scheme = RColorScheme.Light) + { + var sheet = CssConstructionFunctions.ParseStyleSheet("@media " + query + " { }"); + if (sheet.Rules.Length != 1 || sheet.Rules[0] is not MediaRule rule) + return false; + + var context = MediaQueryContext.FromAdapter(new MockAdapter { ColorScheme = scheme }, 800, 600); + return MediaQueryMatcher.Matches(new[] { rule.Media }, context); + } +} diff --git a/Source/Test/HtmlRenderer.Test/CssModules/MediaQueriesTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/MediaQueriesTests.cs index 8fdd33f1b..10d140c9a 100644 --- a/Source/Test/HtmlRenderer.Test/CssModules/MediaQueriesTests.cs +++ b/Source/Test/HtmlRenderer.Test/CssModules/MediaQueriesTests.cs @@ -16,6 +16,7 @@ public sealed class MediaQueriesTests [TestMethod] [Spec("mediaqueries-4", "#media")] + [Spec("mediaqueries-5", "#media")] [DataRow("screen and (min-width: 100px)", true)] [DataRow("print and (min-width: 100px)", false)] [DataRow("screen and (min-width: 1000px)", false)] @@ -27,6 +28,7 @@ public void MediaQuery_IsTrueWhenTheTypeAndTheConditionMatch(string query, bool [TestMethod] [Spec("mediaqueries-4", "#mq-list")] + [Spec("mediaqueries-5", "#mq-list")] [DataRow("print, screen", true)] [DataRow("print, (max-width: 100px)", false)] public void MediaQueryList_IsTrueWhenAnyQueryIs(string query, bool expected) @@ -36,6 +38,7 @@ public void MediaQueryList_IsTrueWhenAnyQueryIs(string query, bool expected) [TestMethod] [Spec("mediaqueries-4", "#mq-not")] + [Spec("mediaqueries-5", "#mq-not")] [DataRow("not print", true)] [DataRow("not screen", false)] [DataRow("not screen and (min-width: 100px)", false)] @@ -47,6 +50,7 @@ public void Not_NegatesTheWholeQuery(string query, bool expected) [TestMethod] [Spec("mediaqueries-4", "#mq-only")] + [Spec("mediaqueries-5", "#mq-only")] [DataRow("only screen", true)] [DataRow("only screen and (min-width: 100px)", true)] [DataRow("only print", false)] @@ -57,6 +61,7 @@ public void Only_DoesNotChangeTheResult(string query, bool expected) [TestMethod] [Spec("mediaqueries-4", "#media-types")] + [Spec("mediaqueries-5", "#media-types")] [DataRow("all", "screen", true)] [DataRow("all", "print", true)] [DataRow("screen", "screen", true)] @@ -72,18 +77,9 @@ public void MediaType_MatchesTheDevice(string query, string mediaType, bool expe // ── 2.4 Media features ─────────────────────────────────────────────── - [TestMethod] - [Spec("mediaqueries-4", "#mq-features")] - [DataRow("(max-weight: 3kg)")] - [DataRow("(min-orientation: portrait)")] - [DataRow("(color: 20example)")] - public void UnknownFeatureOrValue_IsFalse(string query) - { - Assert.IsFalse(Matches(query)); - } - [TestMethod] [Spec("mediaqueries-4", "#mq-boolean-context")] + [Spec("mediaqueries-5", "#mq-boolean-context")] [DataRow("(color)", true)] [DataRow("(monochrome)", false)] [DataRow("(grid)", false)] @@ -97,6 +93,7 @@ public void BooleanContext_IsTrueForAnythingButZeroOrNone(string query, bool exp [Ignore("Gap, see Spec/mediaqueries-4.yml")] [TestMethod] [Spec("mediaqueries-4", "#mq-boolean-context")] + [Spec("mediaqueries-5", "#mq-boolean-context")] [DataRow("(width)")] [DataRow("(height)")] [DataRow("(orientation)")] @@ -109,6 +106,7 @@ public void BooleanContext_IsValidForEveryFeature(string query) [TestMethod] [Spec("mediaqueries-4", "#mq-range-context")] + [Spec("mediaqueries-5", "#mq-range-context")] [DataRow("(width >= 800px)", true)] [DataRow("(width > 800px)", false)] [DataRow("(width <= 800px)", true)] @@ -122,18 +120,28 @@ public void RangeContext_ComparesTheFeature(string query, bool expected) [Ignore("Gap, see Spec/mediaqueries-4.yml")] [TestMethod] [Spec("mediaqueries-4", "#mq-range-context")] - [DataRow("(width = 800px)", true)] - [DataRow("(600px <= width)", true)] - [DataRow("(900px < width)", false)] - [DataRow("(400px < width < 1000px)", true)] - [DataRow("(1000px > width > 900px)", false)] - public void RangeContext_AcceptsEqualityValueFirstAndDoubleRanges(string query, bool expected) + [Spec("mediaqueries-5", "#mq-range-context")] + [DataRow("(width = 800px)")] + [DataRow("(600px <= width)")] + [DataRow("(400px < width < 1000px)")] + public void RangeContext_AcceptsEqualityValueFirstAndDoubleRanges(string query) { - Assert.AreEqual(expected, Matches(query)); + Assert.IsTrue(Matches(query)); + } + + [TestMethod] + [Spec("mediaqueries-4", "#mq-range-context")] + [Spec("mediaqueries-5", "#mq-range-context")] + [DataRow("(900px < width)")] + [DataRow("(1000px > width > 900px)")] + public void RangeContext_ValueFirstAndDoubleRanges_OutsideTheRange_DoNotMatch(string query) + { + Assert.IsFalse(Matches(query)); } [TestMethod] [Spec("mediaqueries-4", "#mq-range-context")] + [Spec("mediaqueries-5", "#mq-range-context")] [DataRow("not all and (width <= -100px)", true)] [DataRow("(height > -100px)", true)] [DataRow("(width: -100px)", false)] @@ -144,6 +152,7 @@ public void RangeContext_NegativeValues_AreFalseInTheNegativeRange(string query, [TestMethod] [Spec("mediaqueries-4", "#mq-min-max")] + [Spec("mediaqueries-5", "#mq-min-max")] [DataRow("(min-width: 800px)", true)] [DataRow("(min-width: 801px)", false)] [DataRow("(max-width: 800px)", true)] @@ -151,6 +160,7 @@ public void RangeContext_NegativeValues_AreFalseInTheNegativeRange(string query, [DataRow("(min-width)", false)] [DataRow("(min-grid: 0)", false)] [Spec("mediaqueries-4", "#mq-ranges")] + [Spec("mediaqueries-5", "#mq-ranges")] public void MinMaxPrefixes_AreInclusiveRanges(string query, bool expected) { Assert.AreEqual(expected, Matches(query)); @@ -159,18 +169,27 @@ public void MinMaxPrefixes_AreInclusiveRanges(string query, bool expected) [Ignore("Gap, see Spec/mediaqueries-4.yml")] [TestMethod] [Spec("mediaqueries-4", "#media-conditions")] - [DataRow("(color) or (grid)", true)] - [DataRow("not (color)", false)] - [DataRow("not (grid)", true)] - [DataRow("(not (color)) or (min-width: 100px)", true)] - [DataRow("screen and ((grid) or (color))", true)] - public void MediaConditions_CombineWithAndOrNot(string query, bool expected) + [Spec("mediaqueries-5", "#media-conditions")] + [DataRow("(color) or (grid)")] + [DataRow("not (grid)")] + [DataRow("(not (color)) or (min-width: 100px)")] + [DataRow("screen and ((grid) or (color))")] + public void MediaConditions_CombineWithAndOrNot(string query) { - Assert.AreEqual(expected, Matches(query)); + Assert.IsTrue(Matches(query)); } [TestMethod] [Spec("mediaqueries-4", "#media-conditions")] + [Spec("mediaqueries-5", "#media-conditions")] + public void NotInFrontOfAMatchingFeature_DoesNotMatch() + { + Assert.IsFalse(Matches("not (color)")); + } + + [TestMethod] + [Spec("mediaqueries-4", "#media-conditions")] + [Spec("mediaqueries-5", "#media-conditions")] [DataRow("(color) and (pointer) or (hover)")] public void MixingAndWithOr_WithoutParentheses_IsInvalid(string query) { @@ -181,6 +200,7 @@ public void MixingAndWithOr_WithoutParentheses_IsInvalid(string query) [TestMethod] [Spec("mediaqueries-4", "#mq-syntax")] + [Spec("mediaqueries-5", "#mq-syntax")] [DataRow("SCREEN AND (WIDTH: 800px)", true)] [DataRow("screen and(min-width: 100px)", false)] [DataRow("or and (color)", false)] @@ -191,6 +211,7 @@ public void Syntax_IsCaseInsensitiveAndNeedsWhitespaceBeforeParentheses(string q [TestMethod] [Spec("mediaqueries-4", "#mq-syntax")] + [Spec("mediaqueries-5", "#mq-syntax")] public void MinMaxPrefixes_AreCaseInsensitive() { Assert.IsTrue(Matches("(MIN-WIDTH: 100px)")); @@ -199,6 +220,7 @@ public void MinMaxPrefixes_AreCaseInsensitive() [Ignore("Gap, see Spec/mediaqueries-4.yml")] [TestMethod] [Spec("mediaqueries-4", "#mq-syntax")] + [Spec("mediaqueries-5", "#mq-syntax")] public void Units_AreCaseInsensitive() { Assert.IsTrue(Matches("(width: 800PX)")); @@ -206,6 +228,7 @@ public void Units_AreCaseInsensitive() [TestMethod] [Spec("mediaqueries-4", "#evaluating")] + [Spec("mediaqueries-5", "#evaluating")] [DataRow("not (max-weight: 3kg)")] [DataRow("not all and (max-weight: 3kg)")] public void Unknown_IsFalse_EvenNegated(string query) @@ -215,6 +238,7 @@ public void Unknown_IsFalse_EvenNegated(string query) [TestMethod] [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] [DataRow("unknown", false)] [DataRow("not unknown", true)] public void UnknownMediaType_DoesNotMatch(string query, bool expected) @@ -222,9 +246,21 @@ public void UnknownMediaType_DoesNotMatch(string query, bool expected) Assert.AreEqual(expected, Matches(query)); } + [TestMethod] + [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] + [DataRow("(max-weight: 3kg)")] + [DataRow("(min-orientation: portrait)")] + [DataRow("(color: 20example)")] + public void UnknownFeatureOrValue_IsFalse(string query) + { + Assert.IsFalse(Matches(query)); + } + [Ignore("Gap, see Spec/mediaqueries-4.yml")] [TestMethod] [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] [DataRow("screen and (max-weight: 3kg) and (color), (color)")] [DataRow("&test, screen")] [DataRow("(example, all,), screen")] @@ -237,7 +273,9 @@ public void InvalidQuery_OnlyReplacesThatQueryWithNotAll(string query) [TestMethod] [Spec("mediaqueries-4", "#width")] + [Spec("mediaqueries-5", "#width")] [Spec("mediaqueries-4", "#units")] + [Spec("mediaqueries-5", "#units")] [DataRow("(min-width: 50em)", true)] [DataRow("(min-width: 51em)", false)] [DataRow("(max-width: 21.17cm)", true)] @@ -249,6 +287,7 @@ public void Width_IsTheViewportWidth(string query, bool expected) [TestMethod] [Spec("mediaqueries-4", "#height")] + [Spec("mediaqueries-5", "#height")] [DataRow("(height: 600px)", true)] [DataRow("(min-height: 601px)", false)] public void Height_IsTheViewportHeight(string query, bool expected) @@ -258,6 +297,7 @@ public void Height_IsTheViewportHeight(string query, bool expected) [TestMethod] [Spec("mediaqueries-4", "#aspect-ratio")] + [Spec("mediaqueries-5", "#aspect-ratio")] [DataRow("(aspect-ratio: 4/3)", true)] [DataRow("(aspect-ratio: 8/6)", true)] [DataRow("(min-aspect-ratio: 1/1)", true)] @@ -270,6 +310,7 @@ public void AspectRatio_IsWidthOverHeight(string query, bool expected) [TestMethod] [Spec("mediaqueries-4", "#orientation")] + [Spec("mediaqueries-5", "#orientation")] [DataRow(800d, 600d, "landscape")] [DataRow(600d, 800d, "portrait")] [DataRow(600d, 600d, "portrait")] @@ -282,6 +323,7 @@ public void Orientation_IsPortraitWhenHeightIsAtLeastWidth(double width, double [TestMethod] [Spec("mediaqueries-4", "#resolution")] + [Spec("mediaqueries-5", "#resolution")] [DataRow("(resolution: 96dpi)", true)] [DataRow("(resolution: 1dppx)", true)] [DataRow("(min-resolution: 2dppx)", false)] @@ -294,6 +336,7 @@ public void Resolution_IsOneDevicePixelPerCssPixel(string query, bool expected) [Ignore("Gap, see Spec/mediaqueries-4.yml")] [TestMethod] [Spec("mediaqueries-4", "#resolution")] + [Spec("mediaqueries-5", "#resolution")] public void Resolution_AcceptsInfinite() { Assert.IsTrue(Matches("(resolution < infinite)")); @@ -301,6 +344,7 @@ public void Resolution_AcceptsInfinite() [TestMethod] [Spec("mediaqueries-4", "#scan")] + [Spec("mediaqueries-5", "#scan")] [DataRow("(scan)")] [DataRow("(scan: interlace)")] public void Scan_DoesNotApply(string query) @@ -310,6 +354,7 @@ public void Scan_DoesNotApply(string query) [TestMethod] [Spec("mediaqueries-4", "#grid")] + [Spec("mediaqueries-5", "#grid")] [DataRow("(grid: 0)", true)] [DataRow("(grid: 1)", false)] public void Grid_IsZero(string query, bool expected) @@ -319,6 +364,7 @@ public void Grid_IsZero(string query, bool expected) [TestMethod] [Spec("mediaqueries-4", "#update")] + [Spec("mediaqueries-5", "#update")] [DataRow("(update: none)", true)] [DataRow("(update: fast)", false)] public void Update_IsNone(string query, bool expected) @@ -330,6 +376,7 @@ public void Update_IsNone(string query, bool expected) [TestMethod] [Spec("mediaqueries-4", "#color")] + [Spec("mediaqueries-5", "#color")] [DataRow("(min-color: 1)", true)] [DataRow("(color: 8)", true)] [DataRow("(color > 8)", false)] @@ -340,6 +387,7 @@ public void Color_IsEightBitsPerComponent(string query, bool expected) [TestMethod] [Spec("mediaqueries-4", "#color-index")] + [Spec("mediaqueries-5", "#color-index")] [DataRow("(color-index)", false)] [DataRow("(color-index: 0)", true)] [DataRow("(min-color-index: 1)", false)] @@ -350,6 +398,7 @@ public void ColorIndex_IsZero(string query, bool expected) [TestMethod] [Spec("mediaqueries-4", "#monochrome")] + [Spec("mediaqueries-5", "#monochrome")] [DataRow("(monochrome: 0)", true)] [DataRow("(min-monochrome: 1)", false)] public void Monochrome_IsZero(string query, bool expected) @@ -361,6 +410,7 @@ public void Monochrome_IsZero(string query, bool expected) [TestMethod] [Spec("mediaqueries-4", "#pointer")] + [Spec("mediaqueries-5", "#pointer")] [DataRow("(pointer: none)", true)] [DataRow("(pointer: fine)", false)] public void Pointer_IsNone(string query, bool expected) @@ -370,6 +420,7 @@ public void Pointer_IsNone(string query, bool expected) [TestMethod] [Spec("mediaqueries-4", "#hover")] + [Spec("mediaqueries-5", "#hover")] [DataRow("(hover: none)", true)] [DataRow("(hover)", false)] public void Hover_IsNone(string query, bool expected) @@ -379,6 +430,7 @@ public void Hover_IsNone(string query, bool expected) [TestMethod] [Spec("mediaqueries-4", "#@media/any-input")] + [Spec("mediaqueries-5", "#any-input")] [DataRow("(any-pointer: none)", true)] [DataRow("(any-pointer)", false)] [DataRow("(any-hover: none)", true)] diff --git a/Source/Test/HtmlRenderer.Test/TestSupport/MockAdapter.cs b/Source/Test/HtmlRenderer.Test/TestSupport/MockAdapter.cs index edd0a5e64..ba87f4dbd 100644 --- a/Source/Test/HtmlRenderer.Test/TestSupport/MockAdapter.cs +++ b/Source/Test/HtmlRenderer.Test/TestSupport/MockAdapter.cs @@ -22,6 +22,10 @@ internal sealed class MockAdapter : RAdapter public override string DefaultMediaType => MediaType; + public RColorScheme ColorScheme { get; set; } = RColorScheme.Light; + + public override RColorScheme SystemColorScheme => ColorScheme; + protected override RColor GetColorInt(string colorName) { var color = Color.FromName(colorName); diff --git a/Spec/mediaqueries-5.yml b/Spec/mediaqueries-5.yml index 585c4d56d..912426147 100644 --- a/Spec/mediaqueries-5.yml +++ b/Spec/mediaqueries-5.yml @@ -5,241 +5,234 @@ snapshot: https://www.w3.org/TR/2026/WD-mediaqueries-5-20260219/ sections: '#intro': title: 1 Introduction - status: unknown - '#placement': - title: 1.1 Module interactions - status: unknown - '#values': - title: 1.2 Values - status: unknown + status: n/a '#units': title: 1.3 Units - status: unknown + status: supported '#media': title: 2 Media Queries - status: unknown + status: partial '#mq-list': title: 2.1 Combining Media Queries - status: unknown + status: partial + gaps: + - 'An empty media query list is false instead of true, see mediaqueries-4 #mq-list' '#mq-prefix': title: 2.2 Media Query Modifiers - status: unknown + status: supported '#mq-not': title: '2.2.1 Negating a Media Query: the not keyword' - status: unknown + status: supported '#mq-only': title: '2.2.2 Hiding a Media Query From Legacy user agents: the only keyword' - status: unknown + status: supported '#media-types': title: 2.3 Media Types - status: unknown + status: supported '#mq-features': title: 2.4 Media Features - status: unknown + status: partial '#mq-ranges': title: '2.4.1 Media Feature Types: “range” and “discrete”' - status: unknown + status: supported '#mq-boolean-context': title: 2.4.2 Evaluating Media Features in a Boolean Context - status: unknown + status: partial + gaps: + - '(width) and (orientation) are invalid, see mediaqueries-4 #mq-boolean-context' '#mq-range-context': title: 2.4.3 Evaluating Media Features in a Range Context - status: unknown + status: partial + gaps: + - '=, value-first and double ranges are invalid, see mediaqueries-4 #mq-range-context' '#mq-min-max': title: 2.4.4 Using “min-” and “max-” Prefixes On Range Features - status: unknown + status: supported '#media-conditions': title: 2.5 Combining Media Features - status: unknown + status: partial + gaps: + - 'or, not in front of a feature and nested parentheses are invalid, see mediaqueries-4 #media-conditions' '#mq-syntax': title: 3 Syntax - status: unknown + status: partial + gaps: + - 'Uppercase units aren''t recognized, see mediaqueries-4 #mq-syntax' '#evaluating': title: 3.1 Evaluating Media Queries - status: unknown + status: supported '#error-handling': title: 3.2 Error Handling - status: unknown + status: partial + gaps: + - 'An invalid query turns the whole list into not all, see mediaqueries-4 #error-handling' '#mf-viewport-characteristics': title: 4 Viewport/Page Characteristics Media Features - status: unknown + status: partial '#width': title: '4.1 Width: the width feature' - status: unknown + status: supported '#height': title: '4.2 Height: the height feature' - status: unknown + status: supported '#aspect-ratio': title: '4.3 Aspect-Ratio: the aspect-ratio feature' - status: unknown + status: supported '#orientation': title: '4.4 Orientation: the orientation feature' - status: unknown + status: supported '#mf-overflow-block': title: '4.5 Block-Axis Overflow: the overflow-block feature' - status: unknown + status: unsupported '#mf-overflow-inline': title: '4.6 Inline-Axis Overflow: the overflow-inline feature' - status: unknown + status: unsupported '#mf-horizontal-viewport-segments': title: '4.7 Horizontal Viewport Segments: the horizontal-viewport-segments feature' - status: unknown + status: unsupported + gaps: + - 'Unknown feature, (horizontal-viewport-segments: 1) is false' '#mf-vertical-viewport-segments': title: '4.8 Vertical Viewport Segments: the vertical-viewport-segments feature' - status: unknown + status: unsupported + gaps: + - 'Unknown feature, (vertical-viewport-segments: 1) is false' '#display-modes': title: '4.9 Display Modes: the display-mode media feature' - status: unknown + status: unsupported + gaps: + - 'Unknown feature, (display-mode: browser) is false' '#mf-display-quality': title: 5 Display Quality Media Features - status: unknown + status: partial '#resolution': title: '5.1 Display Resolution: the resolution feature' - status: unknown + status: partial + gaps: + - 'infinite isn''t accepted and PDF output reports 96dpi, see mediaqueries-4 #resolution' '#scan': title: '5.2 Display Type: the scan feature' - status: unknown + status: supported '#grid': title: '5.3 Detecting Console Displays: the grid feature' - status: unknown + status: supported '#update': title: '5.4 Display Update Frequency: the update feature' - status: unknown + status: supported '#environment-blending': title: '5.5 Detecting the display technology: the environment-blending feature' - status: unknown + status: unsupported + gaps: + - 'Unknown feature, (environment-blending: opaque) is false' '#mf-colors': title: 6 Color Media Features - status: unknown + status: partial '#color': title: '6.1 Color Depth: the color feature' - status: unknown + status: supported '#color-index': title: '6.2 Paletted Color Screens: the color-index feature' - status: unknown + status: supported '#monochrome': title: '6.3 Monochrome Screens: the monochrome feature' - status: unknown + status: supported '#color-gamut': title: '6.4 Color Display Quality: the color-gamut feature' - status: unknown + status: unsupported + gaps: + - 'Unknown feature, see mediaqueries-4 #color-gamut' '#dynamic-range': title: '6.5 Dynamic Range: the dynamic-range feature' - status: unknown + status: unsupported + gaps: + - 'Unknown feature, (dynamic-range: standard) is false' '#contrast-brightness-of-display': title: 6.5.1 Determining contrast and brightness of display - status: unknown + status: unsupported '#inverted': title: '6.6 Detecting inverted colors on the display: the inverted-colors feature' - status: unknown + status: unsupported + gaps: + - 'Unknown feature, (inverted-colors: none) is false' '#mf-interaction': title: 7 Interaction Media Features - status: unknown + status: partial '#pointer': title: '7.1 Pointing Device Quality: the pointer feature' - status: unknown + status: supported '#hover': title: '7.2 Hover Capability: the hover feature' - status: unknown + status: supported '#any-input': title: '7.3 All Available Interaction Capabilities: the any-pointer and any-hover features' - status: unknown + status: supported '#nav-controls': title: '7.4 Detecting UA-supplied navigation controls: the nav-controls feature' - status: unknown + status: unsupported + gaps: + - 'Unknown feature, (nav-controls: none) is false' '#video-prefixed-features': title: 8 Video Prefixed Features - status: unknown + status: unsupported '#video-color-gamut': title: '8.1 Video Color Display Quality: the video-color-gamut feature' - status: unknown + status: unsupported + gaps: + - 'Unknown feature, (video-color-gamut: srgb) is false' '#video-dynamic-range': title: '8.2 Video Dynamic Range: the video-dynamic-range feature' - status: unknown + status: unsupported + gaps: + - 'Unknown feature, (video-dynamic-range: standard) is false' '#mf-scripting': title: 9 Scripting Media Features - status: unknown + status: supported '#scripting': title: '9.1 Scripting Support: the scripting feature' - status: unknown + status: supported '#custom-mq': title: 10 Custom Media Queries - status: unknown + status: unsupported + gaps: + - '@custom-media is dropped, a custom media query in @media makes the query invalid' '#script-custom-mq': title: 10.1 Script-based Custom Media Queries - status: unknown + status: n/a '#custom-mq-cssom': title: 11 CSSOM - status: unknown + status: n/a '#mf-user-preferences': title: 12 User Preference Media Features - status: unknown + status: partial '#prefers-reduced-motion': title: '12.1 Detecting the desire for less motion on the page: the prefers-reduced-motion feature' - status: unknown + status: supported '#prefers-reduced-transparency': title: '12.2 Detecting the desire for reduced transparency on the page: the prefers-reduced-transparency feature' - status: unknown + status: supported '#prefers-contrast': title: '12.3 Detecting the desire for increased or decreased color contrast from elements on the page: the prefers-contrast feature' - status: unknown + status: partial + gaps: + - 'An unknown value isn''t rejected, not all and (prefers-contrast: foo) is true' '#forced-colors': title: '12.4 Detecting Forced Colors Mode: the forced-colors feature' - status: unknown + status: unsupported + gaps: + - 'Unknown feature, (forced-colors: none) is false' '#prefers-color-scheme': title: '12.5 Detecting the desire for light or dark color schemes: the prefers-color-scheme feature' - status: unknown + status: partial + gaps: + - 'An unknown value isn''t rejected, not all and (prefers-color-scheme: foo) is true' '#prefers-reduced-data': title: '12.6 Detecting the desire for reduced data usage when loading a page: the prefers-reduced-data feature' - status: unknown + status: unsupported + gaps: + - 'Unknown feature, (prefers-reduced-data: no-preference) is false' '#auto-pref': title: 12.7 Automatic handling of User Preferences - status: unknown + status: n/a '#script-control-user-prefs': title: 13 Script Control of User Preferences - status: unknown - '#navigator-interface': - title: 13.1 Extensions to the Navigator interface - status: unknown - '#preferences-attribute': - title: 13.1.1 preferences attribute - status: unknown - '#preference-manager': - title: 13.1.2 PreferenceManager interface - status: unknown - '#color-scheme-attribute': - title: 13.1.3 colorScheme attribute - status: unknown - '#contrast-attribute': - title: 13.1.4 contrast attribute - status: unknown - '#reduced-motion-attribute': - title: 13.1.5 reducedMotion attribute - status: unknown - '#reduced-transparency-attribute': - title: 13.1.6 reducedTransparency attribute - status: unknown - '#reduced-data-attribute': - title: 13.1.7 reducedData attribute - status: unknown - '#preference-object-interface': - title: 13.1.8 PreferenceObject interface - status: unknown - '#override-attribute': - title: 13.1.8.1 override attribute - status: unknown - '#preference-value-attribute': - title: 13.1.8.2 value attribute - status: unknown - '#valid-values-attribute': - title: 13.1.8.3 validValues attribute - status: unknown - '#onchange-attribute': - title: 13.1.8.4 onchange event handler attribute - status: unknown - '#request-override-method': - title: 13.1.8.5 requestOverride() method - status: unknown - '#clear-override-method': - title: 13.1.8.6 clearOverride() method - status: unknown + status: n/a From 60c7047dfcd7833ade42169ddbc62418a9f9e40b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:37:41 +0100 Subject: [PATCH 145/150] Verify and document CSS Environment Variables compliance --- .../CssModules/CssEnvTests.cs | 61 +++++++++++++++++++ Spec/css-env-1.yml | 22 ++++--- 2 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssEnvTests.cs diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssEnvTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssEnvTests.cs new file mode 100644 index 000000000..7123c00bd --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssEnvTests.cs @@ -0,0 +1,61 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssEnvTests +{ + private const double Delta = 0.01; + + [TestMethod] + [Ignore("Gap, see Spec/css-env-1.yml")] + [Spec("css-env-1", "#safe-area-insets")] + [Spec("css-env-1", "#safe-area-max-insets")] + [DataRow("safe-area-inset-top")] + [DataRow("safe-area-max-inset-left")] + public void SafeAreaInsets_AreZeroOnARectangularDisplay(string name) + { + Assert.AreEqual(0, Styled($"#x{{margin-left:5px;margin-left:env({name})}}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-env-1.yml")] + [Spec("css-env-1", "#viewport-segments")] + public void ViewportSegments_AreUndefinedWithoutASplitViewport() + { + Assert.AreEqual(7, Styled("#x{margin-left:env(viewport-segment-width 0 0, 7px)}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-env-1.yml")] + [Spec("css-env-1", "#text-zoom")] + public void PreferredTextScale_IsOne() + { + Assert.AreEqual(10, Styled("#x{margin-left:calc(10px * env(preferred-text-scale))}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-env-1.yml")] + [Spec("css-env-1", "#env-function")] + public void UnknownVariable_UsesTheFallback() + { + Assert.AreEqual(7, Styled("#x{margin-left:env(foo, 7px)}").ActualMarginLeft, Delta); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-env-1.yml")] + [Spec("css-env-1", "#env-in-shorthands")] + public void Env_InAShorthand_IsSubstituted() + { + Assert.AreEqual(7, Styled("#x{margin:env(foo, 1px 2px 3px 7px)}").ActualMarginLeft, Delta); + } + + private static CssBox Styled(string css, string body = "<div id=x>a</div>") + { + var (root, _) = LayoutHarness.Layout($"<html><head><style>{css}</style></head><body style='margin:0'>{body}</body></html>", 800, 600); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } +} diff --git a/Spec/css-env-1.yml b/Spec/css-env-1.yml index 4f43ebb67..eced80c26 100644 --- a/Spec/css-env-1.yml +++ b/Spec/css-env-1.yml @@ -5,31 +5,33 @@ snapshot: https://www.w3.org/TR/2025/WD-css-env-1-20250923/ sections: '#intro': title: 1 Introduction - status: unknown + status: n/a '#environment': title: 2 Environment Variables - status: unknown + status: unsupported '#safe-area-insets': title: 2.1 Safe area inset variables - status: unknown + status: unsupported '#safe-area-max-insets': title: 2.2 Safe area maximum inset variables - status: unknown + status: unsupported '#viewport-segments': title: 2.3 Viewport segment variables - status: unknown + status: unsupported '#text-zoom': title: 2.4 Preferred Text Zoom - status: unknown + status: unsupported '#env-function': title: '3 Using Environment Variables: the env() notation' - status: unknown + status: unsupported + gaps: + - env() isn't supported, the declaration is dropped '#env-in-shorthands': title: 3.1 Environment Variables in Shorthand Properties - status: unknown + status: unsupported '#priv': title: 4 Privacy Considerations - status: unknown + status: n/a '#sec': title: 5 Security Considerations - status: unknown + status: n/a From cae2f162e5474005d393906dc8e20601494ee3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:42:27 +0100 Subject: [PATCH 146/150] Verify and document CSS Counter Styles compliance --- .../HtmlRenderer/Core/Dom/CssCounterEngine.cs | 4 +- Source/HtmlRenderer/Core/Utils/CommonUtils.cs | 30 ++--- .../Content/ListStyleTests.cs | 5 + .../CssModules/CssCounterStylesTests.cs | 119 ++++++++++++++++++ .../Dom/CssCounterEngineTests.cs | 6 +- Spec/css-counter-styles-3.yml | 97 ++++++++------ 6 files changed, 193 insertions(+), 68 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssCounterStylesTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs b/Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs index 418de5706..a68402dd3 100644 --- a/Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs @@ -150,8 +150,8 @@ private static void ForEachNameValuePair(string value, int defaultValue, Action< } /// <summary> - /// Formats a counter value for <c>content: counter()</c> (CSS 2.1 §12.4.2). Unknown styles and - /// values a style can't represent fall back to decimal (CSS Counter Styles 3 §2). + /// Formats a counter value for <c>content: counter()</c> (CSS 2.1 §12.4.2), an unknown style as + /// decimal (CSS Counter Styles 3 §5). /// </summary> public static string FormatCounterValue(int number, string style) { diff --git a/Source/HtmlRenderer/Core/Utils/CommonUtils.cs b/Source/HtmlRenderer/Core/Utils/CommonUtils.cs index 8e81be372..713a2ebf8 100644 --- a/Source/HtmlRenderer/Core/Utils/CommonUtils.cs +++ b/Source/HtmlRenderer/Core/Utils/CommonUtils.cs @@ -392,11 +392,9 @@ private static string GetValidFileName(string source) } /// <summary> - /// Convert number to alpha numeric system by the requested style (UpperAlpha, LowerRoman, Hebrew, etc.). + /// Formats a number in one of the predefined counter styles (CSS Counter Styles 3 §6), an unknown + /// style as upper-alpha. Empty for 0. /// </summary> - /// <param name="number">the number to convert</param> - /// <param name="style">the css style to convert by</param> - /// <returns>converted string</returns> public static string ConvertToAlphaNumber(int number, string style = CssConstants.UpperAlpha) { if (number == 0) @@ -442,11 +440,8 @@ public static string ConvertToAlphaNumber(int number, string style = CssConstant } /// <summary> - /// Convert the given integer into alphabetic numeric format (D, AU, etc.) + /// lower-alpha and upper-alpha (CSS Counter Styles 3 §6.2). /// </summary> - /// <param name="number">the number to convert</param> - /// <param name="lowercase">is to use lowercase</param> - /// <returns>the roman number string</returns> private static string ConvertToEnglishNumber(int number, bool lowercase) { var sb = string.Empty; @@ -470,10 +465,8 @@ private static string ConvertToEnglishNumber(int number, bool lowercase) } /// <summary> - /// Convert the given integer into alphabetic numeric format (alpha, AU, etc.) + /// lower-greek (CSS Counter Styles 3 §6.2), the final sigma is skipped. /// </summary> - /// <param name="number">the number to convert</param> - /// <returns>the roman number string</returns> private static string ConvertToGreekNumber(int number) { var sb = string.Empty; @@ -498,11 +491,8 @@ private static string ConvertToGreekNumber(int number) } /// <summary> - /// Convert the given integer into roman numeric format (II, VI, IX, etc.) + /// lower-roman and upper-roman (CSS Counter Styles 3 §6.1). /// </summary> - /// <param name="number">the number to convert</param> - /// <param name="lowercase">if to use lowercase letters for roman digits</param> - /// <returns>the roman number string</returns> private static string ConvertToRomanNumbers(int number, bool lowercase) { // CSS Counter Styles 3 §6.1: the roman styles have the range 1 to 3999 @@ -538,11 +528,8 @@ private static string ConvertToHebrewNumber(int number) } /// <summary> - /// Convert the given integer into given alphabet numeric system. + /// Additive numbering from a table of units, tens, hundreds and thousands. /// </summary> - /// <param name="number">the number to convert</param> - /// <param name="alphabet">the alphabet system to use</param> - /// <returns>the number string</returns> private static string ConvertToSpecificNumbers(int number, string[,] alphabet) { int level = 0; @@ -559,11 +546,8 @@ private static string ConvertToSpecificNumbers(int number, string[,] alphabet) } /// <summary> - /// Convert the given integer into given alphabet numeric system. + /// Hiragana and katakana numbering. /// </summary> - /// <param name="number">the number to convert</param> - /// <param name="alphabet">the alphabet system to use</param> - /// <returns>the number string</returns> private static string ConvertToSpecificNumbers2(int number, string[] alphabet) { for (int i = 20; i > 0; i--) diff --git a/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs b/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs index e9d637d94..f7e711f2a 100644 --- a/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs +++ b/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs @@ -31,6 +31,9 @@ public void ListItem_GetsAMarkerBox() [DataRow("armenian", "Գ.")] [DataRow("georgian", "გ.")] [Spec("css-lists-3", "#text-markers")] + [Spec("css-counter-styles-3", "#extending-css2")] + [Spec("css-counter-styles-3", "#simple-numeric")] + [Spec("css-counter-styles-3", "#simple-alphabetic")] public void NumberedListStyleType_NumbersTheItem(string type, string expected) { Assert.AreEqual(expected, Marker($"<ol style='list-style-type:{type}'><li>a</li><li>b</li><li id=x>c</li></ol>")); @@ -39,6 +42,7 @@ public void NumberedListStyleType_NumbersTheItem(string type, string expected) [TestMethod] [Spec("css21", "generate.html#list-style")] [Spec("css-lists-3", "#text-markers")] + [Spec("css-counter-styles-3", "#simple-symbolic")] public void GlyphListStyleTypes_AreDistinct() { var disc = Marker("<ul style='list-style-type:disc'><li id=x>a</li></ul>"); @@ -112,6 +116,7 @@ public void ListStyleImage_ReplacesTheMarker() [TestMethod] [Ignore("Gap, see Spec/css21.yml")] [Spec("css21", "generate.html#list-style")] + [Spec("css-counter-styles-3", "#simple-numeric")] public void Georgian_Twenty_IsKan() { Assert.AreEqual("კ.", Marker("<ol style='list-style-type:georgian' start=20><li id=x>a</li></ol>")); diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssCounterStylesTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssCounterStylesTests.cs new file mode 100644 index 000000000..6c372e264 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssCounterStylesTests.cs @@ -0,0 +1,119 @@ +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssCounterStylesTests +{ + // ── 2 Counter Styles ───────────────────────────────────────────────── + + [TestMethod] + [Spec("css-counter-styles-3", "#counter-styles")] + [DataRow(4000, "lower-roman", "4000")] + [DataRow(4000, "upper-roman", "4000")] + public void ValueOutsideTheRange_UsesTheFallbackStyle(int number, string style, string expected) + { + Assert.AreEqual(expected, CssCounterEngine.FormatCounterValue(number, style)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-counter-styles-3.yml")] + [Spec("css-counter-styles-3", "#counter-styles")] + public void ValueOutsideTheHebrewRange_UsesTheFallbackStyle() + { + Assert.AreEqual("11000", CssCounterEngine.FormatCounterValue(11000, "hebrew")); + } + + [TestMethod] + [Spec("css-counter-styles-3", "#counter-styles")] + public void NegativeValue_InARomanStyle_FallsBackToDecimal() + { + Assert.AreEqual("-3.", Marker("<ol start=-3 style='list-style-type:lower-roman'><li id=x>a</li></ol>")); + } + + // ── 3 @counter-style, 4 symbols() ──────────────────────────────────── + + [TestMethod] + [Ignore("Gap, see Spec/css-counter-styles-3.yml")] + [Spec("css-counter-styles-3", "#the-counter-style-rule")] + [Spec("css-counter-styles-3", "#cyclic-system")] + public void CounterStyleRule_DefinesAStyleForListStyleType() + { + var marker = Marker("<style>@counter-style stars { system: cyclic; symbols: '*'; suffix: ' ' }</style>" + + "<ul style='list-style-type:stars'><li id=x>a</li></ul>"); + + Assert.AreEqual("*", marker.Trim()); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-counter-styles-3.yml")] + [Spec("css-counter-styles-3", "#symbols-function")] + public void SymbolsFunction_DefinesAnAnonymousStyle() + { + Assert.AreEqual("*", Marker("<ul style=\"list-style-type:symbols(cyclic '*')\"><li id=x>a</li></ul>").Trim()); + } + + // ── 6 Simple Predefined Counter Styles ─────────────────────────────── + + [TestMethod] + [Spec("css-counter-styles-3", "#simple-alphabetic")] + [DataRow(27, "lower-alpha", "aa")] + [DataRow(28, "upper-latin", "AB")] + [DataRow(25, "lower-greek", "αα")] + public void AlphabeticStyle_IsBijective(int number, string style, string expected) + { + Assert.AreEqual(expected, CssCounterEngine.FormatCounterValue(number, style)); + } + + [TestMethod] + [Spec("css-counter-styles-3", "#simple-numeric")] + [DataRow(3999, "upper-roman", "MMMCMXCIX")] + [DataRow(15, "hebrew", "טו")] + [DataRow(1, "armenian", "Ա")] + [DataRow(10000, "armenian", "10000")] + public void AdditiveStyle_FormatsWithinItsRange(int number, string style, string expected) + { + Assert.AreEqual(expected, CssCounterEngine.FormatCounterValue(number, style)); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-counter-styles-3.yml")] + [Spec("css-counter-styles-3", "#simple-numeric")] + public void DecimalLeadingZero_CountsTheNegativeSignTowardsThePadding() + { + Assert.AreEqual("-1", CssCounterEngine.FormatCounterValue(-1, "decimal-leading-zero")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-counter-styles-3.yml")] + [Spec("css-counter-styles-3", "#simple-numeric")] + [Spec("css-counter-styles-3", "#simple-alphabetic")] + [Spec("css-counter-styles-3", "#simple-fixed")] + [DataRow("upper-armenian", "Գ.")] + [DataRow("lower-armenian", "գ.")] + [DataRow("arabic-indic", "٣.")] + [DataRow("hiragana", "う、")] + [DataRow("cjk-earthly-branch", "寅、")] + public void OtherPredefinedStyles_NumberTheItem(string type, string expected) + { + Assert.AreEqual(expected, Marker($"<ol style='list-style-type:{type}'><li>a</li><li>b</li><li id=x>c</li></ol>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-counter-styles-3.yml")] + [Spec("css-counter-styles-3", "#simple-symbolic")] + [Spec("css-counter-styles-3", "#complex-cjk")] + [Spec("css-counter-styles-3", "#ethiopic-numeric-counter-style")] + [DataRow("disclosure-open")] + [DataRow("disclosure-closed")] + [DataRow("japanese-informal")] + [DataRow("simp-chinese-formal")] + [DataRow("korean-hangul-formal")] + [DataRow("ethiopic-numeric")] + public void OtherPredefinedStyles_AreAccepted(string type) + { + Assert.AreEqual(type, CssConstructionFunctions.ParseDeclaration("list-style-type: " + type)!.Value); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs b/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs index 7bcd4143d..068d4ba62 100644 --- a/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs +++ b/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs @@ -18,6 +18,8 @@ public sealed class CssCounterEngineTests [DataRow(1, "lower-alpha", "a")] [DataRow(3, "upper-alpha", "C")] [Spec("css-lists-3", "#counter-functions")] + [Spec("css-counter-styles-3", "#simple-numeric")] + [Spec("css-counter-styles-3", "#simple-alphabetic")] public void FormatCounterValue_KnownStyles_FormatAsExpected(int number, string style, string expected) { Assert.AreEqual(expected, CssCounterEngine.FormatCounterValue(number, style)); @@ -27,9 +29,9 @@ public void FormatCounterValue_KnownStyles_FormatAsExpected(int number, string s [DataRow(1)] [DataRow(7)] [DataRow(42)] + [Spec("css-counter-styles-3", "#extending-css2")] public void FormatCounterValue_UnknownStyle_FallsBackToDecimal(int number) { - // CSS Counter Styles 3 §2 Assert.AreEqual(number.ToString(System.Globalization.CultureInfo.InvariantCulture), CssCounterEngine.FormatCounterValue(number, "not-a-real-style")); } @@ -41,9 +43,9 @@ public void FormatCounterValue_UnknownStyle_FallsBackToDecimal(int number) [DataRow(-3, "lower-roman", "-3")] [DataRow(-3, "upper-roman", "-3")] [DataRow(4000, "lower-roman", "4000")] + [Spec("css-counter-styles-3", "#counter-styles")] public void FormatCounterValue_AlphabeticStyleOutOfRange_FallsBackToDecimal(int number, string style, string expected) { - // CSS Counter Styles 3 §2, values out of the style's range use the fallback style Assert.AreEqual(expected, CssCounterEngine.FormatCounterValue(number, style)); } diff --git a/Spec/css-counter-styles-3.yml b/Spec/css-counter-styles-3.yml index 5565262cb..63d14280f 100644 --- a/Spec/css-counter-styles-3.yml +++ b/Spec/css-counter-styles-3.yml @@ -5,112 +5,127 @@ snapshot: https://www.w3.org/TR/2021/CR-css-counter-styles-3-20210727/ sections: '#intro': title: 1 Introduction - status: unknown + status: n/a '#counter-styles': title: 2 Counter Styles - status: unknown + status: partial + gaps: + - A value outside the style's range isn't formatted with the fallback, e.g. hebrew 11000 is א׳ '#the-counter-style-rule': title: '3 Defining Custom Counter Styles: the @counter-style rule' - status: unknown + status: unsupported + gaps: + - '@counter-style is dropped, list-style-type rejects its name' '#counter-style-system': title: '3.1 Counter algorithms: the system descriptor' - status: unknown + status: unsupported '#cyclic-system': title: '3.1.1 Cycling Symbols: the cyclic system' - status: unknown + status: unsupported '#fixed-system': title: '3.1.2 Exhaustible Symbols: the fixed system' - status: unknown + status: unsupported '#symbolic-system': title: '3.1.3 Repeating Symbols: the symbolic system' - status: unknown + status: unsupported '#alphabetic-system': title: '3.1.4 Bijective Numerals: the alphabetic system' - status: unknown + status: unsupported '#numeric-system': title: '3.1.5 Positional Numerals: the numeric system' - status: unknown + status: unsupported '#additive-system': title: '3.1.6 Accumulating Numerals: the additive system' - status: unknown + status: unsupported '#extends-system': title: '3.1.7 Building from Existing Counter Styles: the extends system' - status: unknown + status: unsupported '#counter-style-negative': title: '3.2 Formatting negative values: the negative descriptor' - status: unknown + status: unsupported '#counter-style-prefix': title: '3.3 Symbols before the marker: the prefix descriptor' - status: unknown + status: unsupported '#counter-style-suffix': title: '3.4 Symbols after the marker: the suffix descriptor' - status: unknown + status: unsupported '#counter-style-range': title: '3.5 Limiting the counter scope: the range descriptor' - status: unknown + status: unsupported '#counter-style-pad': title: '3.6 Zero-Padding and Constant-Width Representations: the pad descriptor' - status: unknown + status: unsupported '#counter-style-fallback': title: '3.7 Defining fallback: the fallback descriptor' - status: unknown + status: unsupported '#counter-style-symbols': title: '3.8 Marker characters: the symbols and additive-symbols descriptors' - status: unknown + status: unsupported '#counter-style-speak-as': title: '3.9 Speech Synthesis: the speak-as descriptor' - status: unknown + status: n/a '#symbols-function': title: '4 Defining Anonymous Counter Styles: the symbols() function' - status: unknown + status: unsupported + gaps: + - 'symbols() is rejected, see css-lists-3 #text-markers' '#extending-css2': title: 5 Extending list-style-type, counter(), and counters() - status: unknown + status: partial + gaps: + - Only the predefined styles of 6.1 to 6.3 that CSS 2.1 has, plus hebrew, are accepted + - 'counters() inserts nothing, see css-lists-3 #counter-functions' '#predefined-counters': title: 6 Simple Predefined Counter Styles - status: unknown + status: partial '#simple-numeric': title: '6.1 Numeric: decimal, decimal-leading-zero, arabic-indic, armenian, upper-armenian, lower-armenian, bengali, cambodian, khmer, cjk-decimal, devanagari, georgian, gujarati, gurmukhi, hebrew, kannada, lao, malayalam, mongolian, myanmar, oriya, persian, lower-roman, upper-roman, tamil, telugu, thai, tibetan' - status: unknown + status: partial + gaps: + - Only decimal, decimal-leading-zero, armenian, georgian, hebrew, lower-roman and upper-roman are supported, the others are rejected + - decimal-leading-zero pads negative values, -1 is -01 instead of -1 + - 'Georgian tens are wrong, 20 is პ instead of კ, see CSS 2.1 12.5.1' '#simple-alphabetic': title: '6.2 Alphabetic: lower-alpha, lower-latin, upper-alpha, upper-latin, lower-greek, hiragana, hiragana-iroha, katakana, katakana-iroha' - status: unknown + status: partial + gaps: + - hiragana, hiragana-iroha, katakana and katakana-iroha are rejected + - hiragana-iroha and katakana-iroha count in the dictionary order of hiragana and katakana instead of the iroha order '#simple-symbolic': title: '6.3 Symbolic: disc, circle, square, disclosure-open, disclosure-closed' - status: unknown + status: partial + gaps: + - disclosure-open and disclosure-closed are rejected + - 'disc, circle and square format counter() as decimal, see css-lists-3 #counter-functions' '#simple-fixed': title: '6.4 Fixed: cjk-earthly-branch, cjk-heavenly-stem' - status: unknown + status: unsupported '#complex-predefined-counters': title: 7 Complex Predefined Counter Styles - status: unknown + status: unsupported '#complex-cjk': title: 7.1 Longhand East Asian Counter Styles - status: unknown + status: unsupported '#limited-japanese': title: '7.1.1 Japanese: japanese-informal and japanese-formal' - status: unknown + status: unsupported '#limited-korean': title: '7.1.2 Korean: korean-hangul-formal, korean-hanja-informal, and korean-hanja-formal' - status: unknown + status: unsupported '#limited-chinese': title: '7.1.3 Chinese: simp-chinese-informal, simp-chinese-formal, trad-chinese-informal, and trad-chinese-formal' - status: unknown + status: unsupported '#ethiopic-numeric-counter-style': title: '7.2 Ethiopic Numeric Counter Style: ethiopic-numeric' - status: unknown + status: unsupported '#additional-predefined': title: 8 Additional “Ready-made” Counter Styles - status: unknown + status: n/a '#apis': title: 9 APIs - status: unknown - '#extensions-to-cssrule-interface': - title: 9.1 Extensions to the CSSRule interface - status: unknown - '#the-csscounterstylerule-interface': - title: 9.2 The CSSCounterStyleRule interface - status: unknown + status: n/a '#ua-stylesheet': title: 10 Sample style sheet for HTML - status: unknown + status: unsupported + gaps: + - 'summary has no disclosure marker, see html rendering.html#the-details-and-summary-elements' From 52ca4d41d2d36659fae67b0fa26d9649e95e8b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Tue, 10 Nov 2026 10:57:34 +0100 Subject: [PATCH 147/150] Record gaps the CSS module and HTML reviews found in other specs --- Source/HtmlRenderer/Core/CssData.cs | 5 +- .../CssEngine/Parser/StylesheetComposer.cs | 33 +-------- .../CssModules/CssOverflowTests.cs | 11 +++ .../BackgroundPaintIntegrationTests.cs | 4 +- .../Painting/OverflowClipIntegrationTests.cs | 1 + .../Text/DirectionIntegrationTests.cs | 2 + .../Text/TextAlignStartEndIntegrationTests.cs | 7 +- .../Text/VerticalAlignIntegrationTests.cs | 68 +++++++++---------- .../Css/CssBackgrounds4ParseTests.cs | 17 ++++- .../Css/CssBorders4ParseTests.cs | 1 + .../HtmlRenderer.Test/Css/FlexboxTests.cs | 2 + .../HtmlRenderer.Test/Css/FontFaceTests.cs | 3 + .../Css/TextPropertyTests.cs | 16 +++-- .../CssModules/CssOverflowTests.cs | 27 ++++++++ .../CssModules/CssomViewTests.cs | 1 + Spec/css-backgrounds-3.yml | 6 +- Spec/css-overflow-3.yml | 20 +++++- Spec/css-text-3.yml | 3 + Spec/css-values-4.yml | 7 +- Spec/css-values-5.yml | 6 +- Spec/css21.yml | 50 ++------------ Spec/html.yml | 1 + 22 files changed, 152 insertions(+), 139 deletions(-) create mode 100644 Source/Test/HtmlRenderer.Test/CssModules/CssOverflowTests.cs diff --git a/Source/HtmlRenderer/Core/CssData.cs b/Source/HtmlRenderer/Core/CssData.cs index c67eca76d..7996cb334 100644 --- a/Source/HtmlRenderer/Core/CssData.cs +++ b/Source/HtmlRenderer/Core/CssData.cs @@ -175,8 +175,9 @@ internal bool MediaOutcomeChanged(MediaQueryContext before, MediaQueryContext af /// <summary> /// Every rule in a rule list, in document order, descending into nested style rules, - /// <c>@layer</c>, <c>@media</c>, <c>@container</c> and, when its condition is true, - /// <c>@supports</c>. <c>@media</c> conditions aren't evaluated here. + /// <c>@layer</c>, <c>@media</c> and <c>@container</c> without evaluating their conditions, and + /// into <c>@supports</c> when its condition is true. Used for <c>@font-face</c>, <c>::selection</c> + /// and the HTML export, the cascade walks the rules itself in <see cref="IndexRules"/>. /// </summary> internal static IEnumerable<IRule> FlattenRules(IEnumerable<IRule> rules) { diff --git a/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetComposer.cs b/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetComposer.cs index abc58ec21..714923d6b 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetComposer.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetComposer.cs @@ -652,13 +652,8 @@ public KeyframeSelector CreateKeyframeSelector(ref Token token) private PageSelector CreatePageSelector(ref Token token) { - // Parses the CSS Paged Media selector grammar per comma-separated entry: an optional - // <ident> page name followed by an optional :<ident> pseudo-class, e.g. - // "@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. Chaining multiple pseudo-classes on one entry (e.g. ":first:left") isn't - // supported — out of scope, not used by any known real-world stylesheet. + // CSS Paged Media 3 §4.3. One pseudo-class per selector, a second one or a functional one (GCPM's + // :nth()) leaves the selector unfinished and the caller drops the rule. var entries = new List<PageSelectorEntry>(); while (true) @@ -695,30 +690,6 @@ private PageSelector CreatePageSelector(ref Token token) var selector = new PageSelector(entries); - //var start = token.Position; - - //while (token.IsNot(TokenType.EndOfFile, TokenType.CurlyBracketOpen, TokenType.CurlyBracketClose)) - //{ - // var a = 1; - // token = NextToken(); - //} - - //var result = selector.ToPool(); - - //if (result is StylesheetNode node) - //{ - // var end = token.Position.Shift(-1); - //node.StylesheetText = CreateView(start, end); - //} - - //if (!selectorIsValid && !_parser.Options.AllowInvalidValues) - //{ - // RaiseErrorOccurred(ParseError.InvalidSelector, start); - // result = null; - //} - - //return result; - return selector; } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssOverflowTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssOverflowTests.cs index b4ed6c604..77574e7fa 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssOverflowTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssOverflowTests.cs @@ -122,6 +122,17 @@ public void TextOverflowEllipsis_EndsTheClippedLineWithAnEllipsis() Assert.IsTrue(g.DrawStringCalls.Any(c => c.Text.Contains('…'))); } + [Ignore("Gap, see Spec/css-overflow-3.yml")] + [TestMethod] + [Spec("css-overflow-3", "#scrollbar-layout")] + [Spec("css-overflow-3", "#scrollbar-sizing")] + public void OverflowScroll_TakesTheScrollbarFromTheContentWidth() + { + var (root, _) = LayoutHarness.Layout(Doc("<div style='overflow:scroll;width:100px;height:50px'><div id='c'>x</div></div>")); + + Assert.IsLessThan(100, LayoutHarness.FindById(root, "c")!.Size.Width); + } + private static string Doc(string body) => $"<!DOCTYPE html><html><head></head><body style='margin:0'>{body}</body></html>"; private static RRect ClipOfRedBox(string body) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs index 1712142f0..22a7a4259 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs @@ -225,10 +225,10 @@ public void BackgroundRepeat_TilingCoversTheBorderArea() [DataRow("top center", 30, 0)] [DataRow("0% 0%", 0, 0)] [Spec("css21", "colors.html#background-properties")] - [Spec("css-values-4", "#position-parsing")] + [Spec("css-values-4", "#position")] [Spec("css-backgrounds-3", "#background-position")] [Spec("css-backgrounds-4", "#background-position")] - [Spec("css-values-5", "#position-parsing")] + [Spec("css-values-5", "#position")] public void BackgroundPosition_Keywords(string position, double x, double y) { var g = PaintBackground($"width:100px;height:100px;background-image:{Image};background-repeat:no-repeat;background-position:{position}"); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/OverflowClipIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OverflowClipIntegrationTests.cs index 61365a471..452999c4c 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/OverflowClipIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OverflowClipIntegrationTests.cs @@ -199,6 +199,7 @@ public void OverflowHidden_OnAnInlineElement_DoesNotClip() [DataRow("scroll")] [DataRow("auto")] [Spec("css-overflow-3", "#overflow-control")] + [Spec("css-overflow-3", "#scrolling")] public void ScrollAndAuto_ClipOverflowingContent(string overflow) { var clip = ClipOfRedBox( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/DirectionIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/DirectionIntegrationTests.cs index d5c91f5ad..d20ca4b22 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/DirectionIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/DirectionIntegrationTests.cs @@ -21,6 +21,8 @@ public void Direction_IsInherited() [Spec("css21", "visuren.html#direction")] [Spec("css-writing-modes-4", "#bidi-para-direction")] [Spec("css-writing-modes-4", "#logical-direction-layout")] + [Spec("css-text-3", "#bidi-linebox")] + [Spec("css-text-4", "#bidi-linebox")] public void RightToLeftWords_InAnRtlBlock_RunFromRightToLeft() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs index 8dd4dfb3c..c2f2db35a 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs @@ -13,12 +13,14 @@ public sealed class TextAlignStartEndIntegrationTests private static CssRect FirstWord(CssBox box) => LayoutHarness.Descendants(box).SelectMany(b => b.Words).First(w => !w.IsSpaces); + // The inherited right alignment keeps an unparsed start or end from passing as the initial left. + [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#text-align-property")] [Spec("css-text-4", "#text-align-property")] public void Start_InLtrBlock_PacksTextAgainstTheLeftEdge() { - var html = LayoutHarness.Wrap("<p id='p' style='text-align: start; width: 200px'>hi</p>"); + var html = LayoutHarness.Wrap("<div style='text-align: right'><p id='p' style='text-align: start; width: 200px'>hi</p></div>"); var (root, _) = LayoutHarness.Layout(html); var p = LayoutHarness.FindById(root, "p")!; @@ -59,6 +61,7 @@ public void End_InLtrBlock_PacksTextAgainstTheRightEdge() Assert.AreEqual(p.ClientRight, word.Right, Delta); } + [Ignore("Gap, see Spec/css-text-3.yml")] [TestMethod] [Spec("css-text-3", "#text-align-property")] [Spec("css-text-4", "#text-align-property")] @@ -66,7 +69,7 @@ public void End_InLtrBlock_PacksTextAgainstTheRightEdge() [Spec("css-text-4", "#bidi-linebox")] public void End_InRtlBlock_PacksTextAgainstTheLeftEdge() { - var html = LayoutHarness.Wrap("<p id='p' dir='rtl' style='text-align: end; width: 200px'>hi</p>"); + var html = LayoutHarness.Wrap("<div style='text-align: right'><p id='p' dir='rtl' style='text-align: end; width: 200px'>hi</p></div>"); var (root, _) = LayoutHarness.Layout(html); var p = LayoutHarness.FindById(root, "p")!; diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs index 0752089c0..d6cf7563c 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs @@ -3,12 +3,7 @@ namespace HtmlRenderer.IntegrationTest.Text; -// This fork's CssParser keeps a process-wide, non-thread-safe regex cache -// (RegexParserUtils.GetRegex's static Dictionary) that SetHtml/DefaultCssData populate lazily on first use per -// AppDomain; running HtmlContainerInt.SetHtml from more than one thread at once (as MSTestSettings.cs's -// assembly-wide [Parallelize(Scope = ExecutionScope.MethodLevel)] does by default) can corrupt it and throw -// "A concurrent update was performed on this collection". [DoNotParallelize] avoids tripping that pre-existing -// library race rather than masking it. +// The regex cache in RegexParserUtils isn't thread-safe, parallel SetHtml calls can corrupt it. [DoNotParallelize] [TestClass] public sealed class VerticalAlignIntegrationTests @@ -101,29 +96,34 @@ public void Middle_DiffersFromDefaultBaselineAlignment() Assert.AreNotEqual(baselineY, middleY); } + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "visudet.html#leading")] [TestMethod] - public void TextTop_ReferencesParentFontAscent_NotJustLineTop() + [Spec("css-inline-3", "#transverse-alignment")] + public void TextTop_RaisesTheSmallerBoxAboveItsBaselinePosition() { - // text-top aligns with the top of the *parent's* font (CSS1 §5.6.11), not the line's raw top extent - // the way plain "top" does - changing only the parent's font-size (the target span stays fixed at - // 10px in both builds) must still move the result, proving the parent's font metrics are actually - // consulted rather than this collapsing to plain "top". - var htmlSmallParent = LayoutHarness.Wrap( - "<p id='p' style='font-size:10px'><span style='font-size:60px'>TALL</span> " + - "<span id='v' style='vertical-align:text-top; font-size:10px'>small</span></p>"); - var htmlLargeParent = LayoutHarness.Wrap( - "<p id='p' style='font-size:40px'><span style='font-size:60px'>TALL</span> " + - "<span id='v' style='vertical-align:text-top; font-size:10px'>small</span></p>"); + var textTopY = GetAlignedTop("text-top"); + var baselineY = GetAlignedTop("baseline"); - var (rootSmall, _) = LayoutHarness.Layout(htmlSmallParent); - var (rootLarge, _) = LayoutHarness.Layout(htmlLargeParent); + Assert.IsTrue(textTopY < baselineY, $"text-top={textTopY} baseline={baselineY}"); + } + + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "visudet.html#leading")] + [TestMethod] + [Spec("css-inline-3", "#transverse-alignment")] + public void TextTop_AlignsWithTheTopOfTheParentsFont() + { + var html = LayoutHarness.Wrap( + "<p id='p' style='font-size:40px'>ref <span style='font-size:60px'>TALL</span> " + + "<span id='v' style='vertical-align:text-top; font-size:10px'>small</span></p>"); - var vSmall = LayoutHarness.FindById(rootSmall, "v")!; - var vLarge = LayoutHarness.FindById(rootLarge, "v")!; - var ySmall = LayoutHarness.Descendants(vSmall).SelectMany(b => b.Words).First().Top; - var yLarge = LayoutHarness.Descendants(vLarge).SelectMany(b => b.Words).First().Top; + var (root, _) = LayoutHarness.Layout(html); + var words = LayoutHarness.Descendants(LayoutHarness.FindById(root, "p")!).SelectMany(b => b.Words).ToList(); + var parentTop = words.First(w => w.Text == "ref").Top; + var textTop = words.First(w => w.Text == "small").Top; - Assert.AreNotEqual(ySmall, yLarge); + Assert.AreEqual(parentTop, textTop, Delta); } [TestMethod] @@ -219,21 +219,15 @@ public void Percentage_PositiveValue_RaisesTheBoxAboveBaseline() Assert.IsTrue(raisedY < baselineY, $"raised={raisedY} baseline={baselineY}"); } + [Ignore("Gap, see Spec/css21.yml")] + [Spec("css21", "visudet.html#leading")] [TestMethod] public void Percentage_ResolvesAgainstTheBoxsOwnLineHeight() { - // A percentage is a fraction of the box's own line-height (CSS 2.1 §10.8.1) - doubling the - // line-height (everything else unchanged) must double the raise relative to that line-height's own - // baseline, proving the percentage is actually resolved against it rather than some other fixed - // reference (e.g. font-size). - var baseline20 = GetAlignedTop("baseline", lineHeight: "20px"); - var percent20 = GetAlignedTop("50%", lineHeight: "20px"); - var baseline40 = GetAlignedTop("baseline", lineHeight: "40px"); - var percent40 = GetAlignedTop("50%", lineHeight: "40px"); - - var raise20 = baseline20 - percent20; - var raise40 = baseline40 - percent40; - - Assert.AreEqual(raise20 * 2, raise40, Delta); + var raise20 = GetAlignedTop("baseline", lineHeight: "20px") - GetAlignedTop("50%", lineHeight: "20px"); + var raise40 = GetAlignedTop("baseline", lineHeight: "40px") - GetAlignedTop("50%", lineHeight: "40px"); + + Assert.AreEqual(10, raise20, Delta); + Assert.AreEqual(20, raise40, Delta); } } diff --git a/Source/Test/HtmlRenderer.Test/Css/CssBackgrounds4ParseTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssBackgrounds4ParseTests.cs index 1287cf9ea..6e8e93aee 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CssBackgrounds4ParseTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CssBackgrounds4ParseTests.cs @@ -71,11 +71,23 @@ public void BackgroundPosition_ThreeAndFourValueFormsAreAccepted(string position [Ignore("Gap, see Spec/css-backgrounds-4.yml")] [TestMethod] - [DataRow("bottom 10px right 20px")] + [Spec("css-backgrounds-4", "#background-position")] + [Spec("css-backgrounds-3", "#background-position")] + [Spec("css-values-4", "#position")] + public void BackgroundPosition_FourValueFormWithVerticalEdgeFirstIsAccepted() + { + var property = CssConstructionFunctions.ParseDeclaration("background-position: bottom 10px right 20px"); + + Assert.IsTrue(property.HasValue); + } + + [Ignore("Gap, see Spec/css-backgrounds-4.yml")] + [TestMethod] [DataRow("top 10px left")] [DataRow("bottom right 5px")] [Spec("css-backgrounds-4", "#background-position")] - public void BackgroundPosition_VerticalEdgeFirstIsAccepted(string position) + [Spec("css-backgrounds-3", "#background-position")] + public void BackgroundPosition_ThreeValueFormWithVerticalEdgeFirstIsAccepted(string position) { var property = CssConstructionFunctions.ParseDeclaration($"background-position: {position}"); @@ -87,6 +99,7 @@ public void BackgroundPosition_VerticalEdgeFirstIsAccepted(string position) [DataRow("left center 10px")] [DataRow("center 10px top")] [Spec("css-backgrounds-4", "#background-position")] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPosition_CenterWithAnOffsetIsRejected(string position) { var property = CssConstructionFunctions.ParseDeclaration($"background-position: {position}"); diff --git a/Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs index 9b364286e..a8eae5eca 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs @@ -66,6 +66,7 @@ public void BorderColor_StripesAreAccepted() [Ignore("Gap, see Spec/css-borders-4.yml")] [TestMethod] [Spec("css-borders-4", "#border-shorthands")] + [Spec("css-backgrounds-3", "#border-shorthands")] public void BorderShorthand_ResetsTheBorderImage() { var style = CssConstructionFunctions.ParseDeclarations("border-image-source: url(a.png); border: solid red"); diff --git a/Source/Test/HtmlRenderer.Test/Css/FlexboxTests.cs b/Source/Test/HtmlRenderer.Test/Css/FlexboxTests.cs index a0c2a4584..13a78cd79 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FlexboxTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FlexboxTests.cs @@ -24,6 +24,8 @@ public void FlexWrapLegalValues(string value) [TestMethod] [DynamicData(nameof(OrderTestDataValues))] [Spec("css-flexbox-1", "#order-property")] + [Spec("css-display-3", "#order-property")] + [Spec("css-display-4", "#order-property")] public void OrderLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<OrderProperty>(PropertyNames.Order, value); diff --git a/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs index c9de54966..4ba4e4977 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs @@ -1,6 +1,7 @@ using System.Text; using HtmlRenderer.Test.CssEngineSupport; using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; using TheArtOfDev.HtmlRenderer.Core; using TheArtOfDev.HtmlRenderer.Core.CssEngine; using TheArtOfDev.HtmlRenderer.Core.Network; @@ -118,6 +119,8 @@ public void UnicodeRangeTokenStartAndEnd(string source, string start, string end [DataRow(@"url('f\'g.ttf')", "f'g.ttf")] [DataRow(@"url(f\(g\).ttf)", "f(g).ttf")] [DataRow(@"url(""f\67 .ttf"")", "fg.ttf")] + [Spec("css-fonts-4", "#font-face-src-loading")] + [Spec("css-values-4", "#relative-urls")] public void FontFaceInALinkedStyleSheet_ResolvesAgainstTheStyleSheetUrl(string src, string expected) { CollectionAssert.Contains(FontFaceRequests(src), "https://example.com/doc/css/" + expected); diff --git a/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs index 43a41ea50..b087b6f9d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs @@ -281,7 +281,7 @@ public void TextIndentLegalReorderedKeywordsSerializeInCanonicalOrder() Assert.IsInstanceOfType<TextIndentProperty>(property); var concrete = (TextIndentProperty)property; Assert.IsFalse(concrete.IsInherited); - Assert.AreEqual("each-line hanging 40pt", concrete.Value); + Assert.AreEqual("40pt hanging each-line", concrete.Value); } [TestMethod] @@ -811,15 +811,17 @@ public void TextJustifyNewspaperLegal() } [TestMethod] + [DataRow("none")] + [DataRow("inter-character")] [Spec("css-text-3", "#text-justify-property")] - public void TextJustifyNoneIllegal() + [Spec("css-text-4", "#text-justify-property")] + [Ignore("Gap, see Spec/css-text-3.yml")] + public void TextJustifyNoneAndInterCharacterLegal(string value) { - var property = CssConstructionFunctions.ParseDeclaration("text-justify: none"); - Assert.AreEqual("text-justify", property.Name); - Assert.IsFalse(property.IsInherited); - Assert.IsFalse(property.IsImportant); + var property = CssConstructionFunctions.ParseDeclaration("text-justify: " + value); Assert.IsInstanceOfType<TextJustifyProperty>(property); - Assert.IsFalse(property.HasValue); + Assert.IsTrue(property.HasValue); + Assert.AreEqual(value, ((TextJustifyProperty)property).Value); } [TestMethod] diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssOverflowTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssOverflowTests.cs new file mode 100644 index 000000000..8f8d0bf33 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssOverflowTests.cs @@ -0,0 +1,27 @@ +using HtmlRenderer.Test.CssEngineSupport; + +namespace HtmlRenderer.Test.CssModules; + +[TestClass] +public sealed class CssOverflowTests +{ + [TestMethod] + [Ignore("Gap, see Spec/css-overflow-3.yml")] + [Spec("css-overflow-3", "#smooth-scrolling")] + public void ScrollBehavior_IsAccepted() + { + var style = CssConstructionFunctions.ParseDeclarations("scroll-behavior: smooth"); + + Assert.AreEqual(1, style.Length); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-overflow-3.yml")] + [Spec("css-overflow-3", "#scrollbar-gutter-property")] + public void ScrollbarGutter_IsAccepted() + { + var style = CssConstructionFunctions.ParseDeclarations("scrollbar-gutter: stable both-edges"); + + Assert.AreEqual(1, style.Length); + } +} diff --git a/Source/Test/HtmlRenderer.Test/CssModules/CssomViewTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssomViewTests.cs index dfbd8a9c7..98a09eba6 100644 --- a/Source/Test/HtmlRenderer.Test/CssModules/CssomViewTests.cs +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssomViewTests.cs @@ -45,6 +45,7 @@ public void FragmentNavigation_AlignsTheTargetWithTheTopOfTheViewport() [TestMethod] [Ignore("Gap, see Spec/cssom-view-1.yml")] [Spec("cssom-view-1", "#element-scrolling-members")] + [Spec("html", "browsing-the-web.html#scrolling-to-a-fragment")] public void FragmentNavigation_ScrollsTheInlineAxisOnlyAsFarAsNeeded() { var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Spec/css-backgrounds-3.yml b/Spec/css-backgrounds-3.yml index 8401fc112..5fc960177 100644 --- a/Spec/css-backgrounds-3.yml +++ b/Spec/css-backgrounds-3.yml @@ -42,6 +42,8 @@ sections: - Only keywords are resolved, see CSS 2.1 14.2.1 - Edge offsets like 'right 10px bottom 20px' aren't resolved - "'10px left' and 'top 10px' are accepted, though the first of two values has to be horizontal" + - Three and four value forms that start with 'top' or 'bottom' are rejected + - "'center' followed by an offset, like 'left center 10px', is accepted" '#bg-position-serialization': title: 2.6.1 Serialization of background-position values status: n/a @@ -103,7 +105,9 @@ sections: - "'border-style: hidden' keeps the width and the initial width isn't 'medium', see CSS 2.1 8.5.1" '#border-shorthands': title: 3.4 Border Shorthand Properties - status: supported + status: partial + gaps: + - "'border' doesn't reset 'border-image'" '#corners': title: 4 Rounded Corners status: partial diff --git a/Spec/css-overflow-3.yml b/Spec/css-overflow-3.yml index 5b40d57a4..fabdf817b 100644 --- a/Spec/css-overflow-3.yml +++ b/Spec/css-overflow-3.yml @@ -17,7 +17,9 @@ sections: status: supported '#scrolling': title: 2.3 Scrolling Overflow - status: n/a + status: partial + gaps: + - "Only the viewport of the panel controls scrolls, a box with 'overflow: scroll' or 'auto' isn't a scroll container, see 3.1" '#overflow-properties': title: 3 Scrolling and Clipping Overflow status: partial @@ -51,10 +53,22 @@ sections: - "'overflow' on the root or body clips that box instead of being applied to the viewport, only pagination treats it as propagated" '#smooth-scrolling': title: '3.4 Smooth Scrolling: the scroll-behavior Property' - status: n/a + status: unsupported + gaps: + - "'scroll-behavior' isn't parsed, the viewport of the panel controls always scrolls instantly" '#scrollbar-layout': title: 4 Scrollbars and Layout - status: n/a + status: unsupported + '#scrollbar-sizing': + title: 4.1 Scrollbar Contributions to Sizing + status: unsupported + gaps: + - "Only the viewport of the panel controls has scrollbars, a box with 'overflow: scroll' or 'auto' gets none and keeps its full content width" + '#scrollbar-gutter-property': + title: '4.2 Reserving space for the scrollbar: the scrollbar-gutter property' + status: unsupported + gaps: + - "'scrollbar-gutter' isn't parsed" '#auto-ellipsis': title: 5 Automatic Ellipses status: unsupported diff --git a/Spec/css-text-3.yml b/Spec/css-text-3.yml index b9125767b..e65841b26 100644 --- a/Spec/css-text-3.yml +++ b/Spec/css-text-3.yml @@ -105,6 +105,9 @@ sections: '#text-justify-property': title: '6.4 Justification Method: the text-justify property' status: parsed-only + gaps: + - none and inter-character aren't accepted + - The non-standard values distribute-all-lines and distribute-center-last are accepted '#expanding-text': title: 6.4.1 Expanding and Compressing Text status: partial diff --git a/Spec/css-values-4.yml b/Spec/css-values-4.yml index 5e5575cb8..63c4dc549 100644 --- a/Spec/css-values-4.yml +++ b/Spec/css-values-4.yml @@ -168,11 +168,12 @@ sections: '#position': title: '8.3 2D Positioning: the <position> type' status: partial - '#position-parsing': - title: 8.3.1 Parsing <position> - status: partial gaps: - "'background-position' only resolves keywords, see CSS 2.1 14.2.1" + - "Four value forms that start with 'top' or 'bottom' are rejected, see css-backgrounds-3 #background-position" + '#position-parsing': + title: 8.3.1 Parsing <position> + status: parsed-only '#position-serialization': title: 8.3.2 Serializing <position> status: n/a diff --git a/Spec/css-values-5.yml b/Spec/css-values-5.yml index 95e29fb44..9a2c84370 100644 --- a/Spec/css-values-5.yml +++ b/Spec/css-values-5.yml @@ -43,12 +43,12 @@ sections: '#position': title: '4.2 2D Positioning: the <position> type' status: partial - '#position-parsing': - title: 4.2.1 Parsing <position> - status: partial gaps: - The x-/y-, block-/inline- and start/end keywords aren't supported, the declaration is dropped - "'background-position' only resolves keywords, see CSS 2.1 14.2.1" + '#position-parsing': + title: 4.2.1 Parsing <position> + status: parsed-only '#position-serialization': title: 4.2.2 Serializing <position> status: n/a diff --git a/Spec/css21.yml b/Spec/css21.yml index 49e7b4536..4ad157a71 100644 --- a/Spec/css21.yml +++ b/Spec/css21.yml @@ -87,7 +87,7 @@ sections: status: partial gaps: - A percentage font-size is inherited as the percentage and applied again in each descendant - - A percentage line-height resolves to 0 instead of a percentage of the font size + - "A percentage line-height computes to 0 instead of a percentage of the font size, so the line gets the height of 'normal'" syndata.html#uri: title: 4.3.4 URLs and URIs status: supported @@ -599,7 +599,7 @@ sections: - The used line height is 90% of 'line-height' - A 'line-height' smaller than the font doesn't make the line shorter - No half-leading, the text sits at the top of the line instead of in its middle - - Percentages compute to 'normal' + - "A percentage line-height computes to 0 instead of a percentage of the font size, so the line gets the height of 'normal'" - A number is inherited as the parent's computed length instead of the number - "'vertical-align' top, bottom, middle, text-top, text-bottom, lengths and percentages are treated as 'baseline'" - "'vertical-align: sub' and 'super' both move the text to the top of the line" @@ -1023,51 +1023,9 @@ sections: status: n/a aural.html#q19.0: title: A Aural style sheets - status: unsupported - gaps: - - None of the aural properties are parsed - aural.html#aural-media-group: - title: A.1 The media types 'aural' and 'speech' - status: n/a - aural.html#aural-intro: - title: A.2 Introduction to aural style sheets - status: n/a - aural.html#volume-props: - title: A.3 Volume properties - status: unsupported - aural.html#speaking-props: - title: A.4 Speaking properties - status: unsupported - aural.html#pause-props: - title: A.5 Pause properties - status: unsupported - aural.html#cue-props: - title: A.6 Cue properties - status: unsupported - aural.html#mixing-props: - title: A.7 Mixing properties - status: unsupported - aural.html#spatial-props: - title: A.8 Spatial properties - status: unsupported - aural.html#voice-char-props: - title: A.9 Voice characteristic properties - status: unsupported - aural.html#speech-props: - title: A.10 Speech properties - status: unsupported - aural.html#aural-tables: - title: A.11 Audio rendering of tables - status: unsupported - aural.html#speak-headers: - title: A.11.1 Speaking headers - status: unsupported - aural.html#sample: - title: A.12 Sample style sheet for HTML - status: n/a - aural.html#Emacspeak: - title: A.13 Emacspeak status: n/a + gaps: + - Informative appendix, the library is a visual renderer and doesn't speak, none of the aural properties are parsed sample.html#q22.0: title: D Default style sheet for HTML 4 status: n/a diff --git a/Spec/html.yml b/Spec/html.yml index 3c6138e14..9a1a206a1 100644 --- a/Spec/html.yml +++ b/Spec/html.yml @@ -1475,6 +1475,7 @@ sections: - The fragment isn't percent-decoded - A fragment of top or an empty fragment doesn't scroll to the top, an empty one is opened with Process.Start instead - The scrolled-to element doesn't become the target element, :target never matches + - The inline axis scrolls to the target's left edge instead of only as far as needed, see cssom-view-1 6.1 browsing-the-web.html#persisted-user-state-restoration: title: 7.4.6.5 Persisted history entry state status: n/a From dc488cc71fb475ede884f5d5a7117b18f8ce934e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Wed, 11 Nov 2026 08:17:47 +0100 Subject: [PATCH 148/150] Keep only the spec compliance overview in the job summary --- .github/workflows/build.yml | 10 +++++- .../HtmlRenderer.SpecCompliance/Program.cs | 13 +++++-- .../HtmlRenderer.SpecCompliance/Report.cs | 34 +++++++++++++------ Spec/README.md | 4 +-- 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4c8391985..b3d67b4b7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,7 +84,15 @@ jobs: - name: Spec compliance report if: ${{ !cancelled() && runner.os == 'Windows' && matrix.dotnet.name == '.NET 8' }} - run: dotnet run --project Source/Tools/HtmlRenderer.SpecCompliance --configuration Release --no-build -- report --output $env:GITHUB_STEP_SUMMARY + run: dotnet run --project Source/Tools/HtmlRenderer.SpecCompliance --configuration Release --no-build -- report --output spec-compliance.md --summary $env:GITHUB_STEP_SUMMARY + + - name: Upload spec compliance report + if: ${{ !cancelled() && runner.os == 'Windows' && matrix.dotnet.name == '.NET 8' }} + uses: actions/upload-artifact@v7 + with: + name: Spec compliance report + path: spec-compliance.md + if-no-files-found: ignore - name: Create HtmlRenderer.Core NuGet package run: dotnet pack Source/HtmlRenderer/HtmlRenderer.csproj --configuration Release --include-symbols -p:SymbolPackageFormat=snupkg --no-build --verbosity normal --output ${{ env.NuGetDirectory }} diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/Program.cs b/Source/Tools/HtmlRenderer.SpecCompliance/Program.cs index 48c2de48f..4e412e114 100644 --- a/Source/Tools/HtmlRenderer.SpecCompliance/Program.cs +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Program.cs @@ -3,8 +3,9 @@ const string Usage = """ Usage: - check Validate the manifests and the [Spec] references in the tests - report [--output <file>] Write the compliance report as Markdown, to stdout by default + check Validate the manifests and the [Spec] references in the tests + report [--output <file>] [--summary <file>] Write the compliance report as Markdown, to stdout by default, + and append the overview table to the summary file """; Console.OutputEncoding = new UTF8Encoding(false); @@ -36,12 +37,18 @@ check Validate the manifests and the [Spec] references in t case "report": { - var markdown = Report.Render(Compliance.Load(root)); + var compliance = Compliance.Load(root); + var markdown = Report.Render(compliance); var output = Option("--output"); if (output == null) Console.Write(markdown); else File.WriteAllText(output, markdown); + + // Appends, as $GITHUB_STEP_SUMMARY is shared by all steps of a job + var summary = Option("--summary"); + if (summary != null) + File.AppendAllText(summary, Report.RenderOverview(compliance)); return 0; } diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs b/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs index 2591c186c..7fd2c1e88 100644 --- a/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs @@ -2,24 +2,22 @@ namespace HtmlRenderer.SpecCompliance; -/// <summary>Renders the compliance as Markdown, for the GitHub Actions job summary or a file.</summary> +/// <summary>Renders the compliance as Markdown, the overview table for the GitHub Actions job summary and the full report as a file.</summary> public static class Report { private static readonly string[] Columns = [SectionStatus.Supported, SectionStatus.Partial, SectionStatus.ParsedOnly, SectionStatus.Unsupported, SectionStatus.NotApplicable, SectionStatus.Unknown]; + public static string RenderOverview(Compliance compliance) + { + var sb = new StringBuilder(); + AppendOverview(sb, compliance); + return sb.ToString(); + } + public static string Render(Compliance compliance) { var sb = new StringBuilder(); - sb.AppendLine("## Spec compliance").AppendLine(); - sb.AppendLine("| Spec | Sections | Supported | Partial | Parsed only | Unsupported | N/A | Unknown | Tested |"); - sb.AppendLine("|---|--:|--:|--:|--:|--:|--:|--:|--:|"); - foreach (var spec in compliance.Specs) - { - var sections = spec.Manifest.Sections; - var counts = Columns.Select(c => sections.Values.Count(s => s.Status == c).ToString()); - var tested = sections.Keys.Count(a => spec.TestsOf(a).Count > 0); - sb.AppendLine($"| [{spec.Manifest.Title}]({spec.Manifest.Url}) | {sections.Count} | {string.Join(" | ", counts)} | {tested} |"); - } + AppendOverview(sb, compliance); foreach (var spec in compliance.Specs) { @@ -36,6 +34,20 @@ public static string Render(Compliance compliance) return sb.ToString(); } + private static void AppendOverview(StringBuilder sb, Compliance compliance) + { + sb.AppendLine("## Spec compliance").AppendLine(); + sb.AppendLine("| Spec | Sections | Supported | Partial | Parsed only | Unsupported | N/A | Unknown | Tested |"); + sb.AppendLine("|---|--:|--:|--:|--:|--:|--:|--:|--:|"); + foreach (var spec in compliance.Specs) + { + var sections = spec.Manifest.Sections; + var counts = Columns.Select(c => sections.Values.Count(s => s.Status == c).ToString()); + var tested = sections.Keys.Count(a => spec.TestsOf(a).Count > 0); + sb.AppendLine($"| [{spec.Manifest.Title}]({spec.Manifest.Url}) | {sections.Count} | {string.Join(" | ", counts)} | {tested} |"); + } + } + private static void RenderChapter(StringBuilder sb, SpecResult spec, List<SpecSection> sections) { var entries = sections.Select(s => spec.Manifest.Sections.GetValueOrDefault(s.Anchor) ?? new ManifestSection()).ToList(); diff --git a/Spec/README.md b/Spec/README.md index 7568e1374..484754a88 100644 --- a/Spec/README.md +++ b/Spec/README.md @@ -1,6 +1,6 @@ # Spec compliance -**tl;dr:** `<spec>.yml` says per spec section how well HTML Renderer implements it, `[Spec]` on a test says which section it verifies, and `SpecComplianceTests` keeps the two in sync. The report shows up in the summary of the Windows .NET 8 CI job. +**tl;dr:** `<spec>.yml` says per spec section how well HTML Renderer implements it, `[Spec]` on a test says which section it verifies, and `SpecComplianceTests` keeps the two in sync. The overview table shows up in the summary of the Windows .NET 8 CI job, the full report is its "Spec compliance report" artifact. ## Manifest @@ -41,6 +41,6 @@ Run from the repository root: # Validate manifests and [Spec] references, same as SpecComplianceTests dotnet run --project Source/Tools/HtmlRenderer.SpecCompliance -- check -# Write the report +# Write the report, --summary <file> also appends the overview table to a file, e.g. the job summary in CI dotnet run --project Source/Tools/HtmlRenderer.SpecCompliance -- report --output report.md ``` From 854d220b1562652e0ae2b39afce3185bcc9c763a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Thu, 12 Nov 2026 23:39:22 +0100 Subject: [PATCH 149/150] Link the remaining ignored tests to their gaps and drop port lineage --- .../AtomicInlineLevelBoxCorrectionTests.cs | 24 +- .../BoxModel/BlockPlacementSeamTests.cs | 7 +- .../BoxModel/HrPlacementTests.cs | 32 +- .../InlineBlockHeightRegressionTests.cs | 44 +-- .../Painting/FragmentContentPainterTests.cs | 23 +- .../RowspanCellShiftTest.cs | 11 +- .../StageR7TableMultiPageCellTest.cs | 8 +- .../Tables/BreakValueCascadeTests.cs | 34 +- .../Tables/PageBreakTableIntegrationTests.cs | 57 +--- ...eBreakTableKeepWithNextIntegrationTests.cs | 51 +-- ...RepeatedTableHeaderClipIntegrationTests.cs | 63 +--- .../Tables/RepeatingTableRelayoutTests.cs | 16 +- ...StructuralCloneBreakValueBehaviourTests.cs | 10 +- .../Tables/TableRowspanContinuationTests.cs | 60 +--- .../Tables/TableSpannedBandRepetitionTests.cs | 48 +-- .../Tables/WholeTableRelocationTests.cs | 45 +-- .../TestSupport/PaintHarness.cs | 2 +- ...FixedPositionPaginationIntegrationTests.cs | 39 +-- .../HandleLinksPaginationTests.cs | 21 +- .../TableHeaderPdfRenderingTests.cs | 43 +-- ...eaderRepetitionThroughTheGeneratorTests.cs | 38 +-- .../HtmlRenderer.Test/Css/AtPropertyTests.cs | 1 - .../Css/BorderPropertyTests.cs | 25 +- .../HtmlRenderer.Test/Css/ContainerTests.cs | 9 +- .../Css/CssDataRuleIndexingTests.cs | 9 +- .../Css/DocumentFunctionTests.cs | 5 - .../Css/FillPropertyTests.cs | 5 - .../HtmlRenderer.Test/Css/GapPropertyTests.cs | 11 +- .../Test/HtmlRenderer.Test/Css/LengthTests.cs | 301 +----------------- .../Css/OpacityPropertyTests.cs | 7 - .../Css/PaletteMixGrammarTests.cs | 5 - .../Css/ParserExtensionsCreateRuleTests.cs | 6 +- .../Css/PropertyFactoryTests.cs | 14 +- .../Css/PropertyLayoutTests.cs | 11 - .../HtmlRenderer.Test/Css/RealWorldTests.cs | 15 +- .../Css/RowGapPropertyTests.cs | 13 +- .../Css/Selectors/SelectorsTests.cs | 44 +-- .../Css/StrokePropertyTests.cs | 8 - .../Css/TransformMatrixTests.cs | 6 - .../Css/TypedCssPropertyTests.cs | 1 - Source/Test/HtmlRenderer.Test/Css/UrlTests.cs | 4 - .../Css/VerticalAlignPropertyTests.cs | 16 +- .../CssConstructionFunctions.cs | 6 +- .../CssEngineSupport/ObjectArrayComparer.cs | 2 +- .../CssEngineSupport/TestExtensions.cs | 1 - .../Dom/CssLayoutEngineTablePageBreakTests.cs | 63 +--- .../Parse/CssValueParserIsValidLengthTests.cs | 2 +- .../TestSupport/LayoutHarness.cs | 2 +- .../Utils/CommonUtilsTests.cs | 14 +- .../HtmlRenderer.Test/Utils/HtmlUtilsTests.cs | 6 +- 50 files changed, 154 insertions(+), 1134 deletions(-) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/AtomicInlineLevelBoxCorrectionTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/AtomicInlineLevelBoxCorrectionTests.cs index cabdb7339..42b32ef0a 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/AtomicInlineLevelBoxCorrectionTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/AtomicInlineLevelBoxCorrectionTests.cs @@ -10,26 +10,13 @@ namespace HtmlRenderer.IntegrationTest.BoxModel; /// correction pass misclassify the box's content as containing a "block", incorrectly splitting the child out of /// its real parent entirely. /// </summary> -/// <remarks> -/// The triage note for this file claimed both cases already pass on this fork. Direct testing shows otherwise -/// for the inline-block case: dumping the box tree for the &lt;select&gt;/&lt;option&gt; markup below shows -/// <c>opt1</c> (the FIRST display:none &lt;option&gt;) gets hoisted out from under &lt;select&gt; entirely and -/// becomes a sibling of the anonymous block box CorrectInlineBoxesParent wraps &lt;select&gt; in, while -/// <c>opt2</c> (the second, otherwise-identical &lt;option&gt;) correctly stays nested. That first-child-only -/// asymmetry is exactly the atomic-inline-level gap the class doc describes - it just was not caught by the -/// triage pass. The inline-table case genuinely does pass, so only the inline-block test method is ignored -/// (with the real, faithfully-ported assertions kept, to document the target behavior). -/// </remarks> [DoNotParallelize] [TestClass] public sealed class AtomicInlineLevelBoxCorrectionTests { - [Ignore("Confirmed real bug on this fork, not a porting mistake: dumping the box tree shows opt1 (the " + - "FIRST display:none <option> child of an inline-block <select>) gets hoisted out from under " + - "<select> and becomes a sibling of the anonymous block wrapper CorrectInlineBoxesParent creates, " + - "while opt2 (second, otherwise-identical child) stays correctly nested. DomUtils.ContainsInlinesOnly " + - "does not treat inline-block as opaque the way an atomic-inline-level check should.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "visuren.html#anonymous-block-level")] public void DisplayNoneChildOfInlineBlockBox_StaysNestedUnderItsRealParent() { var html = LayoutHarness.Wrap( @@ -65,13 +52,6 @@ public void DisplayNoneChildOfInlineTableBox_StaysNestedUnderItsRealParent() Assert.IsTrue(IsDescendantOf(hidden, inlineTable), "a display:none sibling inside an inline-table must stay nested under it."); } - // PeachPDF's source file also has a "DisplayNoneChildOfInlineFlexBox_StillStaysNestedUnderItsRealParent" - // regression case, guarding pre-existing display:inline-flex handling. HTML-Renderer (this fork) implements - // no flexbox layout at all - "flex"/"inline-flex" are not recognized display values anywhere in - // TheArtOfDev.HtmlRenderer.Core (confirmed: no "flex" hits in the Core source tree) - so there is no such - // pre-existing handling to guard, and porting that case would document a feature this fork doesn't have. - // Intentionally dropped rather than ported under a false premise. - private static bool IsDescendantOf(TheArtOfDev.HtmlRenderer.Core.Dom.CssBox box, TheArtOfDev.HtmlRenderer.Core.Dom.CssBox ancestor) { var current = box.ParentBox; diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BlockPlacementSeamTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BlockPlacementSeamTests.cs index 852bd8336..4e3587efe 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BlockPlacementSeamTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/BlockPlacementSeamTests.cs @@ -17,12 +17,7 @@ namespace HtmlRenderer.IntegrationTest.BoxModel; /// to mean is that it finds nothing before it, exactly as a box with no parent always did. /// </para> /// </remarks> -// This fork's CssParser keeps a process-wide, non-thread-safe regex cache -// (RegexParserUtils.GetRegex's static Dictionary) that SetHtml/DefaultCssData populate lazily on first use per -// AppDomain; running HtmlContainerInt.SetHtml from more than one thread at once (as MSTestSettings.cs's -// assembly-wide [Parallelize(Scope = ExecutionScope.MethodLevel)] does by default) can corrupt it and throw -// "A concurrent update was performed on this collection". [DoNotParallelize] avoids tripping that pre-existing -// library race rather than masking it. +// The regex cache in RegexParserUtils isn't thread-safe, parallel SetHtml calls can corrupt it. [TestClass] [DoNotParallelize] public sealed class BlockPlacementSeamTests diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HrPlacementTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HrPlacementTests.cs index 7b8d40b83..7d75d1353 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HrPlacementTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HrPlacementTests.cs @@ -3,12 +3,8 @@ namespace HtmlRenderer.IntegrationTest.BoxModel; /// <summary> -/// Port of PeachPDF.Tests' <c>HrPlacementTests</c>. <c>&lt;hr&gt;</c> resolves its own size but is positioned by -/// the frame above it, like every other block-level box - <c>CssBoxHr.PerformLayoutImp</c> -/// (<c>Core/Dom/CssBoxHr.cs</c>) still carries its own hand-written copy of the same top-placement formula -/// <c>CssBox.PerformLayoutImp</c> uses for ordinary blocks (the "seam" <see cref="BlockPlacementSeamTests"/> -/// documents), rather than PeachPDF's now-unified placement code - so this fork inherits every quirk (and bug) -/// that shared seam has, plus a couple of its own. +/// <c>&lt;hr&gt;</c> sizes itself but is placed by the frame above it like any other block, +/// <c>CssBoxHr.PerformLayoutImp</c> has its own copy of the top placement of <c>CssBox.PerformLayoutImp</c>. /// </summary> /// <remarks> /// One quirk specific to <c>CssBoxHr</c>: <c>CssBox.MarginTopCollapse</c> (<c>Core/Dom/CssBox.cs</c> ~1103-1127) @@ -57,15 +53,9 @@ public void AFloatedPredecessor_IsNotTheRulesMarginCollapsePartner() Assert.AreEqual(30, h.Location.Y - a.ActualBottom, Delta); } - [Ignore("CssBoxHr.PerformLayoutImp places the next box's top at " + - "'prevSibling.ActualBottom + prevSibling.ActualBorderBottomWidth' (mirroring the same seam in " + - "CssBox.PerformLayoutImp) - but ActualBottom (Location.Y + ActualHeight, see CssBox.cs ~703) does " + - "NOT itself include the predecessor's own border-bottom thickness; ActualHeight is just the raw " + - "parsed CSS height. So a bottom-bordered predecessor genuinely widens the gap between its own " + - "ActualBottom and the following rule by exactly its border-bottom width, instead of that border " + - "already being 'baked into' ActualBottom the way PeachPDF's placement code assumes. Confirmed by " + - "direct source read of both CssBoxHr.cs and CssBox.cs.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "visuren.html#block-formatting")] public void ABorderedPredecessorsBottomBorder_IsNotCountedTwice() { var (borderless, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -81,23 +71,19 @@ public void ABorderedPredecessorsBottomBorder_IsNotCountedTwice() Assert.AreEqual(plainGap, borderedGap, Delta); } - [Ignore("This fork has no page-fragmentation/pagination support anywhere in Core (confirmed - no " + - "PageBreakBefore/PageBreakAfter property exists, and layout runs against a single unbounded canvas, " + - "not a sequence of fixed-height page bands - see FixedPositionPaginationIntegrationTests' remarks for " + - "the same fact in more detail). There is no fragmentainer for an oversized margin to be truncated " + - "against, so the rule's margin-top is never clipped at a simulated page boundary - it is simply " + - "added in full, same as it would be at any height.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "page.html#allowed-page-breaks")] + [Spec("css-break-3", "#break-margins")] public void AnOversizedTopMarginAdjoiningAnUnforcedBreak_IsTruncated() { - // 200px sheet, 20px margins - page k's band is [20 + 160k, 180 + 160k). - var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + var (root, container) = LayoutHarness.Layout(LayoutHarness.Wrap( "<div id='a' style='height:40px'></div><hr id='h' style='margin-top:300px'>"), maxHeight: 200, margin: 20); var h = LayoutHarness.FindById(root, "h")!; - Assert.AreEqual(180, h.Location.Y, Delta * 3); + Assert.AreEqual(container.PageTopOf(1), h.Location.Y, Delta * 3); } [TestMethod] diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/InlineBlockHeightRegressionTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/InlineBlockHeightRegressionTests.cs index e69ccd846..aedfc494a 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/InlineBlockHeightRegressionTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/InlineBlockHeightRegressionTests.cs @@ -10,29 +10,16 @@ namespace HtmlRenderer.IntegrationTest.BoxModel; /// Verifies block height around a padded <c>inline-block</c> (e.g. a themeable button). /// </summary> /// <remarks> -/// HTML-Renderer fact (confirmed, searched all of Core): <c>display:inline-block</c> has NO dedicated -/// sizing/measurement path - <c>CssBox.IsInline</c> is simply <c>Display==Inline || Display==InlineBlock</c>, -/// so inline-block boxes are routed through the exact same inline-flow word-wrap algorithm -/// (<c>CssLayoutEngine.FlowBox</c>/<c>CreateLineBoxes</c>) as plain <c>inline</c>, just with their own -/// margin/border/padding added around each child. A SIMPLE inline-block (single line of content, padding -/// around inline content with no internal wrapping complexity) plausibly measures/positions correctly by -/// accident, since the general inline-flow algorithm handles padding around any inline box reasonably. -/// Anything requiring inline-block to be measured/sized ONCE as a single atomic unit before being placed -/// (multi-line content wrapping independently at a fixed width, or interaction with ::before/multi-column, -/// neither of which exist in this fork at all) is not properly supported, and is [Ignore]d below. +/// An inline-block goes through the same inline flow as an inline (<c>CssBox.IsInline</c>), it isn't measured +/// as one atomic box first. /// </remarks> [DoNotParallelize] [TestClass] public sealed class InlineBlockHeightRegressionTests { - [Ignore("Confirmed real limitation (matches this class's own remarks): the WRAPPING block's height does " + - "not grow to cover its sole inline-block child's vertical padding at all - measured height came " + - "back as just the word's own line height (~20px), not >=200px. Since inline-block is routed " + - "through the same inline-flow algorithm as plain 'inline' (CssBox.IsInline), and CSS2.1 section " + - "10.8.1 correctly keeps plain-inline padding from growing line-box height, that same non-growth " + - "leaks onto inline-block here too, even though inline-block's OWN padding box is sized correctly " + - "in isolation (see PaddedInlineBlock_WordsSitInsidePaddingBox, which passes).")] + [Ignore("Gap, see Spec/html.yml")] [TestMethod] + [Spec("html", "rendering.html#button-layout")] public void PaddedInlineBlock_SoleContentOfBlock_BlockHeightCoversPadding() { const string html = "<div style='height:300px'>filler</div>" + @@ -53,9 +40,9 @@ public void PaddedInlineBlock_SoleContentOfBlock_BlockHeightCoversPadding() // A tighter companion to the test above: asserts the block's height against the button's own measured // insets and word height, not just a loose lower bound. Delta of 1px is a guess at cross-engine // rounding/line-metric slack - flagging for the follow-up verification pass in case it needs widening. - [Ignore("Same confirmed limitation as PaddedInlineBlock_SoleContentOfBlock_BlockHeightCoversPadding - the " + - "wrapping block's height does not grow to cover the inline-block's vertical insets at all.")] + [Ignore("Gap, see Spec/html.yml")] [TestMethod] + [Spec("html", "rendering.html#button-layout")] public void PaddedInlineBlock_SoleContentOfBlock_BlockHeightMatchesInsetsExactly() { const string html = "<div style='height:300px'>filler</div>" + @@ -139,12 +126,9 @@ public void PaddedInlineBlock_WordsSitInsidePaddingBox() // With only padding-bottom set, the rect must extend below the words by that amount while the top edge // stays at the word band - regression guard against padding-top/padding-bottom being swapped. - [Ignore("Confirmed real limitation: with asymmetric padding (zero padding-top, 30px padding-bottom), the " + - "box's own rect.Bottom does not extend past the words by the padding-bottom amount at all - unlike " + - "the symmetric-padding case (see PaddedInlineBlock_WordsSitInsidePaddingBox, which passes with " + - "equal top/bottom padding). The rect ends up sized to just the word band, with padding-bottom " + - "silently dropped from the box's own height computation.")] + [Ignore("Gap, see Spec/css21.yml")] [TestMethod] + [Spec("css21", "visudet.html#block-root-margin")] public void AsymmetricPaddingBottom_ExpandsRectBottomByPaddingBottom() { const string html = "<button class='btn' style='padding:0 0 30px 0'>Go</button>"; @@ -205,11 +189,9 @@ public void InsetShiftNearPageBoundary_WordsDoNotStraddleThePage() $"Inset-shifted word should not straddle a page boundary (top={word.Top:F1}, bottom={word.Bottom:F1}, pageHeight={pageHeight})"); } - [Ignore("Multi-column layout (the CSS 'columns' shorthand) is not implemented anywhere in this fork's " + - "Core layout engine - a <div style='columns:2'> lays out as an ordinary single-column block, so " + - "there is no column-boundary-aware line relocation for this button's padding-inset shift to " + - "interact with.")] + [Ignore("Gap, see Spec/css-multicol-1.yml")] [TestMethod] + [Spec("css-multicol-1", "#the-multi-column-model")] public void InsetShiftNearColumnBoundary_WholeLineMovesToNextColumn() { const string html = "<div id='mc' style='columns:2;column-gap:0;column-fill:auto;width:200px'>" + @@ -231,11 +213,9 @@ public void InsetShiftNearColumnBoundary_WholeLineMovesToNextColumn() $"expected padding-top (60) to apply once, at column 2's own content top, word top={word.Top}"); } - [Ignore("Multi-column layout (the CSS 'columns' shorthand) is not implemented anywhere in this fork's " + - "Core layout engine, so there is no column-boundary-aware relocation of an inline-block's " + - "internal lines for a padding-inset shift to interact with (orphans/widows control is not " + - "implemented either).")] + [Ignore("Gap, see Spec/css-multicol-1.yml")] [TestMethod] + [Spec("css-multicol-1", "#the-multi-column-model")] public void InsetShiftedInlineBlock_SecondInternalLine_MovesAloneToNextColumn() { const string html = "<div id='mc' style='columns:2;column-gap:0;column-fill:auto;width:200px;orphans:1;widows:1'>" + diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentContentPainterTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentContentPainterTests.cs index 6cad853a5..2116ac6fe 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentContentPainterTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentContentPainterTests.cs @@ -7,28 +7,9 @@ namespace HtmlRenderer.IntegrationTest.Painting; /// <summary> -/// Ported from PeachPDF.Tests/Integration/FragmentContentPainterTests.cs: the per-box-type paint dispatch - -/// <c>FragmentContentPainters.For</c> picks the painter, and the ones the other paint suites don't already -/// drive (<c>&lt;iframe&gt;</c>, <c>&lt;hr&gt;</c>) draw what they should. +/// <c>FragmentContentPainters.For</c> picks the painter per box type, and the ones the other paint suites +/// don't already drive (<c>&lt;iframe&gt;</c>, <c>&lt;hr&gt;</c>) draw what they should. /// </summary> -/// <remarks> -/// <c>FragmentContentPainters.For</c>'s <c>switch</c> (Core/Paint/Content/FragmentContentPainters.cs) lists -/// only 3 cases - <see cref="TheArtOfDev.HtmlRenderer.Core.Dom.CssBoxImage"/>, -/// <see cref="TheArtOfDev.HtmlRenderer.Core.Dom.CssBoxHr"/>, -/// <see cref="TheArtOfDev.HtmlRenderer.Core.Dom.CssBoxFrame"/> - confirmed by direct source read, HTML-Renderer -/// has no distinct box type for <c>&lt;object&gt;</c> or inline <c>&lt;svg&gt;</c> at all (no -/// <c>CssBoxObject</c>/<c>CssBoxSvg</c> anywhere in <c>Core/Dom/</c>), so PeachPDF's theory rows for those two -/// element types are dropped. -/// <para> -/// <c>Iframe_PaintsItsOwnBoxOnly_WithNoEmbeddedContent</c> ports for a different underlying reason than in -/// PeachPDF: this fork's <c>CssBoxFrame</c> is not a stub - it can render a YouTube/Vimeo video thumbnail/ -/// title/play button for a matching <c>src</c> (see <c>CssBoxFrame</c>'s own doc comment). An ordinary -/// (non-video) <c>&lt;iframe&gt;</c> with no matching <c>src</c> still draws nothing beyond its own -/// background/border, though: <c>_isVideo</c> is false, so <c>DrawImage</c>/<c>DrawTitle</c>/<c>DrawPlay</c> -/// all no-op (confirmed by reading <c>CssBoxFrame.DrawFrameContent</c> and its three private helpers in -/// full), which happens to match PeachPDF's own "no embedded content" expectation for this fixture. -/// </para> -/// </remarks> [TestClass] [DoNotParallelize] public sealed class FragmentContentPainterTests diff --git a/Source/Test/HtmlRenderer.IntegrationTest/RowspanCellShiftTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/RowspanCellShiftTest.cs index 7c575d2fd..415e073c0 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/RowspanCellShiftTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/RowspanCellShiftTest.cs @@ -10,15 +10,8 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; /// <summary> -/// Verifies a real, previously-undocumented gap found while auditing this port's fragmentation engine -/// against PeachPDF a second time: <c>CssLayoutEngineTable.LayoutCells</c>'s <c>break-inside:avoid</c> -/// row-shift correction did <c>foreach (CssBox cell in row.Boxes) cell.OffsetTop(delta)</c> - but for a -/// row that is the END of a rowspan, <c>row.Boxes</c> holds only the <c>CssSpacingBox</c> placeholder -/// (Display:none, no children/words/rectangles), not the real spanning cell (<c>ExtendedBox</c>). -/// <c>OffsetTop</c> on the placeholder was a silent no-op, leaving the spanning cell's real bottom edge -/// stale relative to the rest of the row, which moved on to the next page. Confirmed by temporarily -/// reverting the fix and re-running this exact test: it reliably reproduced the spanning cell's bottom -/// edge lagging behind its sibling's at several filler counts. +/// Moving a row that ends a rowspan to the next page also moves the spanning cell, the row itself only holds +/// a <c>CssSpacingBox</c> placeholder for it. /// </summary> [TestClass] [DoNotParallelize] diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableMultiPageCellTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableMultiPageCellTest.cs index fbea9344b..b31d2afc6 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableMultiPageCellTest.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableMultiPageCellTest.cs @@ -9,12 +9,8 @@ namespace TheArtOfDev.HtmlRenderer.IntegrationTest; /// <summary> -/// Verifies the fragmentation-engine-parity plan's R7 investigation finding: a table cell whose own -/// content spans several pages by itself (the content routes through the same, already-fixed -/// <c>CssBox.PerformLayoutImp</c>/<c>CreateLineBoxes</c>/<c>ApplyLineBreaking</c> machinery as any other -/// box) is preserved intact and subsequent rows correctly continue after it - no TableBreakToken/ -/// TableRowCursor machinery needed for this case, matching the R2/R5/R6 finding that this port's -/// architecture rarely needs what it looks like it needs at first glance. +/// A table cell whose own content spans several pages is kept intact, and the following rows continue +/// after it. /// </summary> /// <remarks> /// Does NOT cover repeated-header behavior for this shape - a row whose own content spans multiple diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/BreakValueCascadeTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/BreakValueCascadeTests.cs index 551d73087..9154088b1 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/BreakValueCascadeTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/BreakValueCascadeTests.cs @@ -7,37 +7,13 @@ namespace HtmlRenderer.IntegrationTest.Tables; /// <summary> -/// Ported from PeachPDF.Tests/Integration/BreakValueCascadeTests.cs: the survival of -/// <c>break-before</c>/<c>break-after</c>/<c>break-inside</c> across a STRUCTURAL CLONE of a box - -/// <see cref="TheArtOfDev.HtmlRenderer.Core.Dom.CssBoxProperties.InheritStyle"/>'s <c>everything: true</c> -/// branch. These properties are not ordinarily inherited (an unrelated child must not pick them up from -/// its parent), but a structural duplicate is a fragment of the same element, and css-break-3 §3 attaches -/// these values to the element rather than to one of its boxes - so both directions have to hold. +/// <c>break-before</c>, <c>break-after</c> and <c>break-inside</c> aren't inherited, but a structural clone of a +/// box (<see cref="TheArtOfDev.HtmlRenderer.Core.Dom.CssBoxProperties.InheritStyle"/> with +/// <c>everything: true</c>) is a fragment of the same element and keeps them, css-break-3 §3. /// </summary> /// <remarks> -/// A real, confirmed production bug was found and fixed while porting this file, not merely documented: -/// <c>CssBoxProperties.InheritStyle</c>'s <c>everything: true</c> branch copied every other originating- -/// element property (background, border, position, size, ...) but never <c>_pageBreakInside</c>/ -/// <c>_breakBefore</c>/<c>_breakAfter</c> - confirmed by grep, none of the three appeared anywhere in that -/// method. Both of this method's real <c>everything: true</c> callers are exactly the two structural-clone -/// sites this file is about: <c>TableHeaderRepeat.CloneSubtree</c> (a repeated <c>&lt;thead&gt;</c> row) and -/// <c>DomParser.CorrectBlockSplitBadBox</c> (the block-in-inline split, <c>leftbox</c>/<c>rightBox</c>) - so -/// every clone of either kind silently read <c>auto</c>/<c>auto</c>/<c>auto</c> regardless of what the -/// source element declared, before this fix. Fixed at its source in <c>CssBoxProperties.cs</c> - see that -/// method's own remarks for the full mechanism. These tests assert the fix's storage; what the stored value -/// then does (or, mostly, does not do) to pagination is <see cref="StructuralCloneBreakValueBehaviourTests"/>. -/// <para> -/// Adapted, not renamed, for <see cref="RepeatedTableHeaderProxy_CarriesTheSourceBreakValues"/>: PeachPDF's -/// UA stylesheet gives <c>thead</c> an avoiding <c>break-inside</c> unconditionally (its print-scoped rule -/// applies to its own harness's media type), so its fixture can vary <c>break-inside</c> itself as one of -/// the four cases under test and still get a proxy to inspect. This fork's UA default lives under -/// <c>@media print</c>, which <see cref="LayoutHarness"/>'s <c>WinFormsAdapter</c> (media type -/// <c>"screen"</c>) never matches - so <c>break-inside:avoid</c> is held FIXED across all three cases below -/// (needed just to get a repeated header to inspect at all, per this repo's own established convention), -/// varying <c>break-before</c>/<c>break-after</c> instead of also varying <c>break-inside</c> itself, which -/// TableRepeatedGroupConditionsTests already covers from the opposite direction (does an EXPLICIT -/// <c>break-inside:auto</c> suppress the repeat at all). -/// </para> +/// <c>break-inside: avoid</c> stays on the <c>thead</c> in every case, the UA rule for it is under +/// <c>@media print</c> and <see cref="LayoutHarness"/> uses screen, without it there's no repeated header. /// </remarks> [TestClass] [DoNotParallelize] diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/PageBreakTableIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/PageBreakTableIntegrationTests.cs index c347a445a..310c93074 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/PageBreakTableIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/PageBreakTableIntegrationTests.cs @@ -10,35 +10,14 @@ namespace HtmlRenderer.IntegrationTest.Tables; /// Verifies table page-break behaviour for single-row tables. /// </summary> /// <remarks> -/// This file predates (#262) the fragmentation-engine-parity branch's own table work, and its original -/// remarks described a mechanism (<c>CssBox.BreakPage()</c>, gated on the table's own explicit -/// <c>page-break-inside:avoid</c>) that no longer exists at all - confirmed by grep, no such method remains -/// anywhere in <c>Core/Dom/CssBox.cs</c>. Revised here to match the CURRENT, confirmed mechanics: since -/// css-tables-3 §6.1 row preservation landed (commit <c>362dee9</c>), <c>CssLayoutEngineTable.LayoutCells</c> -/// attempts to keep every row unfragmented by default - unconditionally, not gated on the table's own -/// <c>break-inside</c> at all - unless the row is "freely fragmentable" (its own height is at least half -/// the fragmentainer's height OR width, or a cell only STARTS spanning into a later row there). -/// <para> -/// The important nuance this revision is built around: that default preservation shifts the STRADDLING -/// ROW'S CELLS (<c>cell.OffsetTop(delta)</c>), not the table's own outer box - <c>_tableBox.Location</c> is -/// set once, before <c>CssLayoutEngineTable.PerformLayout</c> even runs, and is never itself touched by the -/// internal row-shift (only <c>ActualBottom</c> grows to cover it). So a straddling single-row table's -/// CONTENT is correctly relocated by default now, but <c>table.Location.Y</c> - what most of this file's -/// original assertions check - stays exactly where it always would have. Moving the table's own outer box -/// still requires the SEPARATE, parent-level <c>BlockFragmentation.RelocateIfNeeded</c>, gated on an -/// EXPLICIT <c>break-inside:avoid</c> declared directly on the &lt;table&gt; (none of this file's fixtures -/// declare one, matching PeachPDF's own fixtures). <see cref="SingleRowTable_CrossingPageBoundary_IsMovedToNextPage"/> -/// is accordingly rewritten to check the cell's own position (what the fix actually does), rather than the -/// table's outer box (what it does not); every other test's ORIGINAL assertion (checking <c>table.Location.Y</c>) -/// is left as-is, with its Ignore reason corrected to cite the real, current gap where one remains. -/// </para> +/// Keeping a row whole moves its cells, not the table's own box, so <c>table.Location.Y</c> only moves when +/// the table itself is relocated. /// </remarks> [DoNotParallelize] [TestClass] public sealed class PageBreakTableIntegrationTests { - // Mirrors PeachPDF's A4-at-1:1-scale page height; treated as px here since LayoutHarness measures in - // px, not pt - the numbers below only need generous relative separation, which they already have. + // A4 height in pt, used as px. private const double PageHeight = 842.0; // A spacer this tall pushes the table close enough to the page end to plausibly cross a page boundary @@ -82,11 +61,7 @@ public void SingleRowTable_FitsOnCurrentPage_IsNotMoved() [TestMethod] public void MultiRowTable_CrossingPageBoundary_PerRowBreakStillWorks() { - // PeachPDF's original ran a full PDF-generation pass and asserted no exception. This fork has no - // PdfGenerator/PDF-generation API at all (it is a WinForms/GDI+ HTML renderer, not a PDF library), - // so this is adapted into a layout-only smoke test: a multi-row table near the page boundary must - // still lay out without throwing, whichever rows css-tables-3 6.1's default preservation ends up - // shifting (see the class remarks). + // Smoke test, laying out a multi-row table near the page boundary must not throw. var html = BuildHtml(SpacerThatCrossesPage, rowCount: 3); Exception? thrown = null; @@ -105,12 +80,7 @@ public void MultiRowTable_CrossingPageBoundary_PerRowBreakStillWorks() [TestMethod] public void SingleRowTable_WithRoundedBoxes_GeneratesPdf() { - // Adapted from a PDF-generation regression smoke test (PdfGenerator.GeneratePdf does not exist in - // this fork) into a layout-only smoke test: a page of single-row tables with border-radius content - // near a page boundary must lay out without throwing. Trimmed to two of the original six sections - // (including the "Combined Styles" one specifically called out as the original regression trigger) - // to keep this focused; the @page at-rule was dropped since this fork's parser is not known to - // support it. + // Smoke test, rounded boxes in tables near the page boundary must lay out without throwing. const string html = """ <!DOCTYPE html><html><head><style> body { font: 8.5px Arial, sans-serif; margin: 0 } @@ -149,12 +119,7 @@ public void SingleRowTable_WithRoundedBoxes_GeneratesPdf() Assert.IsNull(thrown, $"Layout of tables with border-radius content should not throw, but got: {thrown}"); } - [Ignore("Confirmed (not just assumed): a row's css-tables-3 6.1 default preservation has its own carve-" + - "out for a row whose height is at least half the fragmentainer's height OR width - and this " + - "fixture's 400px cell content is well past half PageSize.Width (595/2=297.5), so the row is " + - "'freely fragmentable' and the table (declaring no break-inside:avoid of its own, so " + - "RelocateIfNeeded also declines) is never relocated. Confirmed empirically: the cell straddles " + - "at Y=499..905 across the 842px boundary, untouched.")] + [Ignore("The 400px row is over half the page width, so it breaks across the pages instead of the table moving")] [TestMethod] public void SingleRowTable_TallCellContentMissedByEstimate_IsMovedToNextPageAfterLayout() { @@ -185,15 +150,7 @@ public void SingleRowTable_TallerThanOnePage_IsLeftInPlace() $"Table taller than a page should stay on page 1 (Y < {PageHeight}) but Y={table.Location.Y:F1}"); } - [Ignore("Confirmed gap, for two independent reasons. First, this fixture's 400px cell content is " + - "freely-fragmentable (past half PageSize.Width=595, same as " + - "SingleRowTable_TallCellContentMissedByEstimate_IsMovedToNextPageAfterLayout), so nothing " + - "relocates at all. Second, and more fundamentally, even a fixture that DID engage row " + - "preservation would still not pull the heading: BlockFragmentation.EnforceKeepWithNext (the " + - "only keep-with-next mechanism that exists) reads the table's own EffectiveTop, and the " + - "internal row-shift never touches the table's own Location (see class remarks) - so from " + - "EnforceKeepWithNext's perspective the table never appears to have moved at all, and there is " + - "no gap for it to notice between the heading and the table to begin with.")] + [Ignore("The 400px row breaks across the pages instead of the table moving, so there is no move for the heading to follow")] [TestMethod] public void SingleRowTable_MovedByPostCheck_PullsAvoidChainedHeadingAlong() { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/PageBreakTableKeepWithNextIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/PageBreakTableKeepWithNextIntegrationTests.cs index 4ad82f6e2..942aa2844 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/PageBreakTableKeepWithNextIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/PageBreakTableKeepWithNextIntegrationTests.cs @@ -8,36 +8,12 @@ namespace HtmlRenderer.IntegrationTest.Tables; /// <summary> -/// Ported from PeachPDF.Tests/Integration/PageBreakTableKeepWithNextIntegrationTests.cs: a heading -/// carrying <c>break-after:avoid</c> must not be stranded alone at the bottom of a page while the table -/// immediately following it starts on the next one. +/// A heading with <c>break-after: avoid</c> isn't left alone at the bottom of a page while the table after it +/// starts on the next one. /// </summary> /// <remarks> -/// PeachPDF names two independent gaps its own fixtures are built around ("Gap 1": the general block path -/// relocating a box across a margin-crossing boundary without pulling a preceding keep-with-next run; -/// "Gap 2": a repeating-header table's own pre-check being gated off whenever it repeats a header at all). -/// Confirmed by reading both of this fork's real mechanisms and then running the fixtures below (not just -/// reading source): Gap 1 does NOT reproduce here - <c>BlockFragmentation.EnforceKeepWithNext</c> is called -/// UNCONDITIONALLY from the block child loop, for every child regardless of what relocated it (its own doc -/// comment even names this as the general fix for exactly this class of bug) - so a table whose margin gets -/// truncated across a page boundary (css-break-3 §5.2) already pulls a preceding <c>break-after:avoid</c> -/// heading along, with no table-specific handling needed at all. -/// <para> -/// Gap 2's shape, however, DOES reproduce, for a different and more fundamental reason than PeachPDF's own -/// (a table-specific pre-check being gated off): this fork's row preservation -/// (<c>CssLayoutEngineTable.LayoutCells</c>) shifts a straddling row's CELLS, never the table's own outer -/// <see cref="CssBox.Location"/> (see <c>Tables/PageBreakTableIntegrationTests.cs</c>'s own class remarks -/// for the same fact) - and <c>EnforceKeepWithNext</c> reads the CHILD's (the table's) own -/// <see cref="CssBoxProperties.EffectiveTop"/>, which never moves via an internal row-shift. So when a -/// table's own header fits under a heading but its first BODY row does not, the header (and the heading -/// above it) are left exactly where flow put them while only the straddling row moves on - an orphaned -/// header, not a whole-table-plus-heading move. <see cref="HeaderFitsButNoBodyRowDoes_MovesWholeTableAndHeadingTogether"/> -/// and <see cref="LongRepeatingHeaderTable_StartingNearPageBottom_StartsOnNextPageAndRepeatsHeaders"/> are -/// ported with PeachPDF's full original assertions but <c>[Ignore]</c>d, citing this. PeachPDF's own -/// three-way composition test (<c>GapOneThenGapTwo_...</c>) has no counterpart to port onto - it exists -/// specifically to pin two SEPARATE pre-checks not double-counting each other's own offset, and this fork -/// has only the one (general) mechanism, which does not re-fire the way two independent passes could. -/// </para> +/// Keeping a row whole moves its cells, not the table's own box, so a header that fits on the page stays +/// there with the heading while the first body row moves on. /// </remarks> [TestClass] [DoNotParallelize] @@ -100,14 +76,7 @@ public void Heading_MarginCrossesPageBoundary_PullsHeadingWithTable() $"Heading (bottom={heading.ActualBottom:F1}) must sit above the moved table (top={table.Location.Y:F1})"); } - // Confirmed gap (see class remarks): the <thead> row fits comfortably under the heading on page 1, but - // the tall .rbox body row does not - only the straddling ROW is relocated (css-tables-3 6.1), and - // neither the table's own outer box nor the heading above it follow it, since EnforceKeepWithNext reads - // the table's own EffectiveTop, which the internal row-shift never touches. - [Ignore("Confirmed gap: row preservation shifts only the straddling row's cells, never the table's own " + - "outer Location - EnforceKeepWithNext reads the table's EffectiveTop (unchanged) and finds no gap " + - "to react to, so the heading and the table's own header are left in place while only the body row " + - "moves on. See this file's own class remarks for the full mechanism.")] + [Ignore("Only the body row moves to the next page, moving the header and the heading along with it isn't implemented")] [TestMethod] public void HeaderFitsButNoBodyRowDoes_MovesWholeTableAndHeadingTogether() { @@ -173,15 +142,7 @@ public void HeaderAndFirstBodyRowBothFit_NothingIsMoved() Assert.IsTrue(heading!.Location.Y < PageHeight); } - // Confirmed gap, same mechanism as HeaderFitsButNoBodyRowDoes_MovesWholeTableAndHeadingTogether: a - // table whose entire body clearly does not fit on one page still leaves its header (and the heading - // above it) in flow on the ORIGINAL page - orphaned - rather than starting fresh on the next page, - // because only the first straddling row is what row preservation ever relocates. - [Ignore("Confirmed gap: the header (and the heading above it) stay in flow on the original page while " + - "only the first straddling body row is relocated by row preservation - see class remarks. The " + - "header does still repeat correctly on every page the table's body spans from there, which is a " + - "real, working, SEPARATE mechanism from the one this test is about (not stranding the header's " + - "own first appearance).")] + [Ignore("Only the first body row moves to the next page, moving the header and the heading along with it isn't implemented")] [TestMethod] public void LongRepeatingHeaderTable_StartingNearPageBottom_StartsOnNextPageAndRepeatsHeaders() { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/RepeatedTableHeaderClipIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/RepeatedTableHeaderClipIntegrationTests.cs index 5a0dbdc5e..38e9148b3 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/RepeatedTableHeaderClipIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/RepeatedTableHeaderClipIntegrationTests.cs @@ -5,63 +5,13 @@ namespace HtmlRenderer.IntegrationTest.Tables; /// <summary> -/// Ported from PeachPDF.Tests/Integration/RepeatedTableHeaderClipIntegrationTests.cs: a repeating -/// &lt;thead&gt;'s own <c>overflow:hidden</c> clip must be resolved against the geometry the clip's -/// content actually paints at on THAT page, not against some other page's geometry. +/// The <c>overflow: hidden</c> clip of a repeated &lt;thead&gt; is resolved against where its content paints on +/// that page, not on another one. /// </summary> /// <remarks> -/// PeachPDF's own root defect does not exist here by construction, the same way Batch 3 found for list -/// markers: PeachPDF repeats a header by re-emitting fragments for one shared, live <c>CssProxyBox</c> -/// subtree whose <c>ContainingBlock</c> walk (used to resolve an <c>overflow:hidden</c> ancestor's clip -/// rectangle) reads that ONE box's current position - last set by whichever page positioned it most -/// recently. This port's <c>TableHeaderRepeat.CloneAndPosition</c> (<c>Core/Fragmentation/TableHeaderRepeat.cs</c>) -/// instead deep-clones a real, independent <see cref="TheArtOfDev.HtmlRenderer.Core.Dom.CssBox"/> subtree -/// per repeat, each with its OWN <c>Location</c>/<c>Size</c> baked in at clone time -/// (<c>CloneSubtree</c> copies them, then <c>CloneAndPosition</c> shifts the whole clone by -/// <c>targetTop - sourceRenderedTop</c>) - so <c>RenderUtils.ClipGraphicsByOverflow</c>'s own -/// <c>ContainingBlock</c> walk, run per-clone during <c>FragmentPainter.PaintFragmentContent</c>, always -/// reads THAT clone's own already-correctly-positioned geometry, never another page's. These three tests -/// are accordingly regression pins of the invariant rather than reproductions of PeachPDF's bug - confirmed -/// by actually running them (not just reading source), matching this batch's established practice. -/// <para> -/// <c>break-inside:avoid</c> is declared explicitly on the &lt;thead&gt; below rather than relied on from -/// the UA default stylesheet's <c>@media print { thead, tfoot { break-inside: avoid } }</c> -/// (<c>Core/CssDefaults.cs</c>) - <see cref="PaintHarness"/> lays out over <c>MockAdapter</c>, whose -/// <c>DefaultMediaType</c> (the <see cref="TheArtOfDev.HtmlRenderer.Adapters.RAdapter"/> base default) is -/// not <c>"print"</c>, so that print-scoped rule never matches here, same as the established convention in -/// <c>StageD4RepeatedHeaderTest.cs</c>/<c>KeepWithNextIntegrationTests.cs</c>. Confirmed empirically: -/// without the explicit declaration, <c>CssLayoutEngineTable.LayoutCells</c>'s <c>repeatsHeader</c> gate -/// (<c>BreakValues.AvoidsBreak(_headerBox.BreakInside)</c>) never fires and no page after the first paints -/// the header at all. -/// </para> -/// <para> -/// A real, confirmed production bug was found and fixed while calibrating this fixture, not merely -/// documented: <c>CssLayoutEngineTable.LayoutCells</c> fed the row cursor's raw <c>starty</c>/<c>cury</c> -/// straight into <c>HtmlContainerInt.PageIndexOf</c> for its own slot arithmetic. For a -/// <c>border-collapse:collapse</c> table, <c>GetVerticalSpacing()</c> is <c>-1</c> (a deliberate one-pixel -/// row/border overlap), so <c>starty</c> sits one pixel BELOW <c>CssBox.ClientTop</c> - and whenever a -/// table starts flush at a page's own content top (this fixture's own case: nothing precedes the table), -/// that one pixel was enough for <c>PageIndexOf</c> to floor into the slot BEFORE the one the table -/// actually starts on. Observed directly (200px pages, <c>MarginTop=10</c>, table at -/// <c>ClientTop=10</c>): <c>PageIndexOf(9)</c> returned <c>-1</c> instead of <c>0</c>. That corrupted two -/// things: the row-preservation straddle check (css-tables-3 6.1) saw the header row as spuriously -/// straddling a boundary it never crossed, and the repeated-header loop saw a spurious "transition" into -/// slot 0 at the very first body row - consuming its first repeat on a duplicate painted almost exactly on -/// top of the header the table already has in flow there (confirmed: before the fix, page 0 alone painted -/// "HEADERMARKER" twice, at (0,3) and (0,4)). Fixed at its source by clamping the slot lookup to -/// <c>CssBox.ClientTop</c> (immune to the collapsed-border overlap) in a new <c>PageSlotOf</c> helper - see -/// its own remarks in <c>CssLayoutEngineTable.cs</c> for the full mechanism. -/// </para> -/// <para> -/// The fixture repeats 42 body rows, not a smaller number, deliberately: with the duplicate-clone bug -/// fixed, the header-repeat loop's own known limitation (documented on the loop itself - a page reached -/// only because ITS OWN last row's straddle-correction pushed it there, with no LATER row's own start left -/// to notice the crossing, gets no repeat at all) still applies to whichever page the table's very LAST row -/// happens to land on. 42 rows leaves several trailing rows on the final page after that row, so a later -/// row's own start is what the loop actually observes the transition through - the same mechanism a real, -/// longer document exercises in practice. This is why <see cref="ClippedRepeatedHeader_IsPaintedOnEveryPageItRepeatsOn"/> -/// and its sibling do not also serve as a regression test for that separate, still-open limitation. -/// </para> +/// <c>break-inside: avoid</c> is set on the &lt;thead&gt; because the UA rule for it is under +/// <c>@media print</c> and <c>MockAdapter</c> uses screen. The fixture has 42 rows so several rows land on the +/// last page, a page only reached through the last row's own move gets no repeated header. /// </remarks> [TestClass] [DoNotParallelize] @@ -72,8 +22,7 @@ public sealed class RepeatedTableHeaderClipIntegrationTests /// <summary> /// A table long enough to repeat its header on several pages, whose header cell clips its own - /// content via an inner <c>overflow:hidden</c> div - mirroring PeachPDF's own fixture shape, where - /// the clipped text has to sit in a box of its own below the clipping div (the walk starts at the + /// content via an inner <c>overflow:hidden</c> div, where the clipped text has to sit in a box of its own below the clipping div (the walk starts at the /// painted box's containing block; text held directly on the clipping box itself never asks about it). /// </summary> private static string ClippingHeaderTable() => PaintHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/RepeatingTableRelayoutTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/RepeatingTableRelayoutTests.cs index 844aed538..9bb49b335 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/RepeatingTableRelayoutTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/RepeatingTableRelayoutTests.cs @@ -6,21 +6,9 @@ namespace HtmlRenderer.IntegrationTest.Tables; /// <summary> -/// Ported from PeachPDF.Tests/Integration/RepeatingTableRelayoutTests.cs: a container holding a -/// repeating-header table takes the same relocation any other <c>break-inside:avoid</c> box does when it -/// straddles a page boundary - real relayout at its destination, not a translate. +/// A container holding a repeating-header table is moved like any other <c>break-inside: avoid</c> box that +/// straddles a page boundary, laid out again at its destination, not translated. /// </summary> -/// <remarks> -/// PeachPDF's own excuse for excluding this ("laying the table out a second time did not reproduce the -/// first result - the repeating group was detached and replaced by per-page proxies nothing removed, so a -/// second run threw") does not apply here by construction: <c>CssLayoutEngineTable.LayoutCells</c> resets -/// <c>_tableBox.RepeatedHeaderRows = null</c> at the very start of every call (confirmed by direct source -/// read, line ~652), and <see cref="TableHeaderRepeat"/>'s clones are freshly built, fully detached -/// <see cref="CssBox"/> instances - never mutating or reusing state from a previous pass. A relaid-out -/// table's repeated-header list is simply rebuilt from scratch, matching that field's own doc comment -/// ("rebuilt from scratch on every layout pass"). These two tests are accordingly regression pins of that -/// idempotency, not reproductions of a PeachPDF-specific bug - confirmed by actually running them. -/// </remarks> [TestClass] [DoNotParallelize] public sealed class RepeatingTableRelayoutTests diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/StructuralCloneBreakValueBehaviourTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/StructuralCloneBreakValueBehaviourTests.cs index 3cfc6488b..2d125c990 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/StructuralCloneBreakValueBehaviourTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/StructuralCloneBreakValueBehaviourTests.cs @@ -7,9 +7,7 @@ namespace HtmlRenderer.IntegrationTest.Tables; /// <summary> -/// Ported from PeachPDF.Tests/Integration/StructuralCloneBreakValueBehaviourTests.cs: what a structurally -/// cloned box's <c>break-*</c> values actually DO to pagination, now that <see cref="BreakValueCascadeTests"/> -/// confirms (and this batch's fix to <c>CssBoxProperties.InheritStyle</c> ensures) the clone carries them. +/// What the <c>break-*</c> values a structural clone keeps (<see cref="BreakValueCascadeTests"/>) do to pagination. /// </summary> /// <remarks> /// Characterization, not desired output: carrying the values is a box-model correctness fix, and on its own @@ -24,7 +22,7 @@ namespace HtmlRenderer.IntegrationTest.Tables; /// so its break values are stored and read by nothing.</item> /// <item><b>Block-in-inline split.</b> Only inline boxes are split by <c>DomParser.CorrectBlockSplitBadBox</c>, /// and css-break-3 §3.1/§3.2 apply break properties to block-level boxes (not inlines) - so the values are -/// inert on a split fragment by specification, independent of this fork's own architecture. Separately, +/// inert on a split fragment by specification. Separately, /// <c>CorrectBlockSplitBadBox</c> wraps each fragment behind an anonymous block /// (<c>CssBox.CreateBox(leftBlock/parentBox, badBox.HtmlTag)</c> creates <c>leftbox</c>/<c>rightBox</c> as /// new, separate boxes - the split fragments become their CHILDREN, not their equals in the sibling chain), @@ -94,9 +92,7 @@ public void RepeatedHeaderWithForcedBreakAfter_IsCurrentlyInert() // CorrectBlockSplitBadBox creates still separates each fragment from the sibling that would otherwise // read it, so a box genuinely chained by break-after:avoid to the span never actually gets pulled // across a page boundary the way it would if chained to an ordinary, unsplit break-after:avoid box. - // Adapted from PeachPDF's own direct call into DomUtils.GetPrecedingKeepWithNextRun - this fork's - // equivalent (BlockFragmentation.CollectPrecedingKeepWithNextRun) is private, so this is stated as the - // observable layout outcome instead: the same fixture, once with an ordinary break-after:avoid box + // BlockFragmentation.CollectPrecedingKeepWithNextRun is private, so this checks the layout instead: the same fixture, once with an ordinary break-after:avoid box // immediately preceding 'kept' (which DOES get pulled - PageBreakIntegrationTests already covers this // as a general invariant) and once with the split span in its place (which does not). [TestMethod] diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableRowspanContinuationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableRowspanContinuationTests.cs index 9dc5478f4..89110cc95 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableRowspanContinuationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableRowspanContinuationTests.cs @@ -6,50 +6,9 @@ namespace HtmlRenderer.IntegrationTest.Tables; /// <summary> -/// Ported from PeachPDF.Tests/Integration/TableRowspanContinuationTests.cs: a <c>rowspan</c> cell whose -/// ending row is preserved-and-shifted onto the next page (css-tables-3 §6.1) must have its own bottom -/// edge extend to cover the gap that shift opens up, rather than being silently left stale. +/// A <c>rowspan</c> cell whose ending row is moved to the next page (css-tables-3 §6.1) extends its bottom +/// edge to cover the gap that move opens up. /// </summary> -/// <remarks> -/// A near-total rewrite, not a rename - confirmed by direct source read that PeachPDF's own 17 tests -/// assert against a resumable-pass architecture this fork does not have at all: <c>TableRowCursor</c> -/// (per-cell continuation tracking across bands), <c>CssBox.PageBreakBottoms</c> (per-band table-slice -/// bookkeeping the paint clip reads), <c>FragmentEmitter.ShellIn</c> (content-free continuation shells for -/// a band with no real per-pass content), and <c>SlotStartingAt</c>/<c>SlotEndingAt</c>/<c>BandOfSlot</c>/ -/// <c>FallsPast</c> (none of which exist on this fork's <see cref="HtmlContainerInt"/> - confirmed by -/// grep). This fork's mechanism is fundamentally simpler: <c>CssLayoutEngineTable.LayoutCells</c>'s -/// row-preservation straddle-shift extends a spanning cell's own <see cref="CssBox.ActualBottom"/> by the -/// same delta the row shift applies (the confirmed bugfix <c>RowspanCellShiftTest.cs</c> already pins with -/// a real-WinForms/text-sweep fixture) - after which the box is just an ordinary, taller -/// <see cref="CssBox"/>, and <c>FragmentEmitter</c>'s existing generic per-band walk (already proven for -/// any tall box by <c>Painting/FragmentPaintIntegrationTests.BoxSpanningTwoPages_PaintsItsBackgroundOnBoth</c>) -/// produces one <see cref="TheArtOfDev.HtmlRenderer.Core.Fragments.BoxFragment"/> per band it spans, with -/// no table-specific continuation-shell machinery needed. These tests accordingly focus on what IS real -/// here - the extension itself, deterministically (explicit pixel heights, not PeachPDF's <c>pt</c> -/// fixtures or this fork's own text-sweep calibration) - rather than PeachPDF's fragment-count/box- -/// decoration-break-edge assertions, which have no counterpart to port onto. -/// <para> -/// Dropped outright (no HTML-Renderer counterpart, confirmed by source read): every fragment-count/ -/// <c>SliceGeometry</c>-edge assertion (<c>box-decoration-break</c> is a confirmed stub - -/// <c>FragmentEmitter.TrivialSlice</c> always reports every edge real, per Batch 3's own finding - so -/// "does a continuation fragment repaint its top border" cannot be asked here); every <c>PageBreakBottoms</c> -/// assertion (member does not exist); <c>ASpanningCellReachedTwice_IsAlignedOnce</c> (this fork's -/// <c>ApplyCellVerticalAlignment</c> dispatch - <c>LayoutCells</c>'s per-row alignment loop - visits a -/// spanning cell's <c>ExtendedBox</c> exactly once, only via its <see cref="CssSpacingBox"/> placeholder on -/// the row that ends the span, never via the cell's own opening row (gated by <c>GetRowSpan(cell)==1</c>) - -/// so the "reached twice" defect this test guards against cannot occur here by construction); the -/// header-opened-rowspan-crossing-into-the-body pagination test (<c>SeedCrossBoundaryRowSpans</c>-style -/// cross-boundary rowspan seeding has no counterpart - a header/body split does not exist in this fork's -/// row loop, which walks <c>_allRows</c> as one continuous sequence regardless of header/body/footer -/// origin). -/// </para> -/// <para> -/// A forced <c>break-before:page</c> declared on a row in the middle of a span is Ignored, not ported as -/// passing, per the same confirmed gap documented on <c>TableRowBreakValueTests</c>: <c>CssLayoutEngineTable.cs</c> -/// never reads <c>BreakBefore</c>/<c>BreakAfter</c> anywhere (confirmed by grep - no matches) - the table -/// row loop has no forced-break support of any kind. -/// </para> -/// </remarks> [TestClass] [DoNotParallelize] public sealed class TableRowspanContinuationTests @@ -63,9 +22,8 @@ public sealed class TableRowspanContinuationTests // Four 40px rows, then a row opening a two-row span, then a 120px row that ENDS it and would land // across the band boundary [20, 300) on its own. The spanning cell is deliberately the FIRST cell of - // its opening row (column 0), and the ending row's own real cell comes after it: a confirmed, separate - // gap in this fork's CssLayoutEngineTable.InsertEmptyBoxes (matching PeachPDF's own issue #522, not fixed - // here) means a CssSpacingBox placeholder is only ever inserted into a later row by walking that row's + // its opening row (column 0), and the ending row's own real cell comes after it: a separate + // gap in CssLayoutEngineTable.InsertEmptyBoxes means a CssSpacingBox placeholder is only ever inserted into a later row by walking that row's // OWN EXISTING cells looking for a matching column - so a spanning cell whose column sits at or past the // ending row's own last existing cell gets NO placeholder there at all, and neither the row-shift's // ActualBottom-extension nor anything else that reaches the span through its placeholder ever fires. @@ -202,15 +160,9 @@ public void TheSpanningCellsContent_SurvivesTheExtensionUnmoved() CollectionAssert.Contains(words, "spans"); } - // css-break-3 3.1 requires a forced break be honored exactly where declared - a row in the middle of a - // span carrying break-before:page should fragment the span there rather than at the row-preservation's - // own straddle point. Confirmed gap: CssLayoutEngineTable.cs never reads BreakBefore/BreakAfter (no - // matches anywhere in the file), so the table row loop has no forced-break support at all - a row - // carrying it lays out completely normally, wherever geometry happens to place it. - [Ignore("Confirmed gap: CssLayoutEngineTable.cs's row loop never reads BreakBefore/BreakAfter on a <tr> " + - "anywhere (grepped the whole file - no matches) - forced row breaks are not implemented in the " + - "table engine at all, so break-before:page on a row mid-span has no effect on where anything lands.")] + [Ignore("Gap, see Spec/css-tables-3.yml")] [TestMethod] + [Spec("css-tables-3", "#breaking-rules")] public void AForcedBreakOnARowInsideASpan_FragmentsTheCellAtTheDeclaredRow() { var html = LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableSpannedBandRepetitionTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableSpannedBandRepetitionTests.cs index 7af23146a..3ed604763 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableSpannedBandRepetitionTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/TableSpannedBandRepetitionTests.cs @@ -6,49 +6,12 @@ namespace HtmlRenderer.IntegrationTest.Tables; /// <summary> -/// Ported from PeachPDF.Tests/Integration/TableSpannedBandRepetitionTests.cs: whether a repeated -/// <c>&lt;thead&gt;</c> appears on bands a table spans WITHOUT breaking on them - a single row (or cell) -/// tall enough to overflow straight through one or more page bands, rather than a break falling neatly -/// between two rows. +/// Whether a repeated <c>&lt;thead&gt;</c> appears on pages a table spans without breaking between rows there, +/// because one row or cell runs through them. /// </summary> /// <remarks> -/// A drastic reduction from PeachPDF's 13 (thead half only, per the port plan's tfoot-drop rule - this -/// fork implements no <c>&lt;tfoot&gt;</c> repeat at all), not a rename - confirmed by direct source read -/// that almost all of the rest assert against machinery this fork does not have: -/// <list type="bullet"> -/// <item><c>BoxFragment.OverflowClip</c> is <c>null</c> on every fragment this fork ever builds - confirmed -/// by grep: <c>Core/Fragmentation/FragmentEmitter.cs</c> constructs every <c>BoxFragment</c> with -/// <c>OverflowClip: null</c> literally, with no other assignment anywhere in the file. PeachPDF's own -/// "slice the row's graphical representation, leave room for the header, state the strip's confinement" -/// mechanism (<c>TheStripsMeetExactly_...</c>, <c>TheStripsCoverTheWholeRow_...</c>, -/// <c>EveryBandOfASlicedRun_...</c>, <c>AClipOutsideTheSlicedRow_...</c>) has nothing to port onto: this -/// fork's <see cref="TheArtOfDev.HtmlRenderer.Core.Fragmentation.FragmentEmitter"/> already fragments any -/// box taller than one band into one <see cref="TheArtOfDev.HtmlRenderer.Core.Fragments.BoxFragment"/> per -/// band it overlaps (proven generically by <c>Painting/FragmentPaintIntegrationTests.BoxSpanningTwoPages_PaintsItsBackgroundOnBoth</c>), -/// with no room-reservation step and no separate "confinement" object - so there is no "does the strip -/// begin below the header" question to ask; the header repeat and the tall row's own natural per-band -/// fragments simply overlap in painted space when the header-repeat loop's own known limitation (see -/// below) doesn't apply.</item> -/// <item>A quarter-of-the-page-height cap on repeat eligibility (css-tables-3 6.2's second condition) is -/// not implemented - confirmed by reading <c>CssLayoutEngineTable.LayoutCells</c>'s <c>repeatsHeader</c> -/// gate in full: it checks only <c>BreakValues.AvoidsBreak(_headerBox.BreakInside)</c>, no height -/// comparison of any kind. <c>TableRepeatedGroupConditionsTests</c> documents this gap; it is not -/// re-documented here.</item> -/// <item><c>AFixedBoxInsideASlicedRow_...</c> is subsumed by the already-ported, general -/// <c>Painting/FragmentPaintIntegrationTests.FixedBox_PaintsAtTheSameCoordinatesOnEveryPage</c> - nothing -/// about a fixed box's own repeat-per-page mechanism (<c>FragmentEmitter.CollectFixedRoots</c>) is -/// table-specific.</item> -/// <item><c>ARowspanTallerThanABand_DoesNotSliceTheRowThatEndsIt</c> is <c>TableRowspanContinuationTests</c>' -/// own subject (the rowspan-extension mechanism), not this file's.</item> -/// </list> -/// What remains and DOES port is the file's own real, confirmed subject: the loop's own documented -/// per-row-only slot check (<c>CssLayoutEngineTable.cs</c> ~634-645's own "KNOWN LIMITATION" remark) means -/// a table that breaks BETWEEN rows across several bands repeats its header on every one of them (the -/// common case), but a table whose header-repeat opportunity is reached only through a SINGLE row's own -/// straddle - one cell taller than the rows around it, overflowing through one or more bands with no later -/// row to trigger the next check - only ever gets the FIRST such band's repeat, never a later intermediate -/// one. Pinned here as the accurate, current behavior (a documented gap, not silently reproduced as if it -/// were correct) rather than Ignored, since it is exactly what the loop's own comment already promises. +/// The header repeat is only checked at the start of a row, so a single row running through several pages +/// only gets the repeat on the first of them. These tests pin that. /// </remarks> [TestClass] [DoNotParallelize] @@ -79,8 +42,7 @@ public void ATableThatBreaksBetweenOrdinaryRows_RepeatsItsHeaderOnEveryBandItSpa var table = TableOf(root); Assert.IsNotNull(table.RepeatedHeaderRows); - // One repeat per continuation page (slot 1..pages-1) - the header's own first page is in flow, not - // a repeat, matching this fork's own established, confirmed count convention. + // One repeat per continuation page, the header's own first page is in flow, not a repeat. Assert.AreEqual(pages - 1, table.RepeatedHeaderRows!.Count); } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Tables/WholeTableRelocationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Tables/WholeTableRelocationTests.cs index 0abebd769..0cbd858bc 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Tables/WholeTableRelocationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Tables/WholeTableRelocationTests.cs @@ -7,29 +7,12 @@ namespace HtmlRenderer.IntegrationTest.Tables; /// <summary> -/// Ported from PeachPDF.Tests/Integration/WholeTableRelocationTests.cs: a table that did not fragment -/// internally between any two of its own rows is content §4.3 treats as monolithic, so a table declaring -/// <c>break-inside:avoid</c> that straddles a page boundary is moved whole rather than sliced. +/// A table that didn't break between its own rows is monolithic content (css-break-3 §4.3), so a table with +/// <c>break-inside: avoid</c> that straddles a page boundary is moved whole rather than sliced. /// </summary> /// <remarks> -/// Real adaptation, not a rename: PeachPDF's "whole-table move" is a dedicated post-check -/// (<c>CssBox.PerformLayoutEpilogue</c>) reacting to a pre-layout height ESTIMATE that can miss tall cell -/// content. This port has neither an estimate nor a table-specific post-check - the SAME generic mover -/// every other <c>break-inside:avoid</c> box uses, <c>BlockFragmentation.RelocateIfNeeded</c>, called from -/// the block child loop right after a child (here, the table) finishes its own layout -/// (<c>Core/Dom/CssBox.cs</c>, ~985) - already sees the table's real, fully-laid-out height, so there is no -/// separate "estimate was wrong" case to port; every scenario below goes through the same one check. This -/// also means - unlike this fork's <c>Tables/PageBreakTableIntegrationTests.cs</c>, which documents that a -/// STRADDLING ROW is preserved unfragmented by css-tables-3 6.1 automatically, with no author opt-in needed -/// - a table declaring no <c>break-inside:avoid</c> at all is never moved AS A WHOLE by this mechanism (it -/// requires <c>BreakValues.AvoidsBreak(child.BreakInside)</c> or <see cref="TheArtOfDev.HtmlRenderer.Core.Fragmentation.MonolithicContent.IsMonolithic"/> -/// - see <c>RelocateIfNeeded</c>'s own doc comment), so every fixture below declares it explicitly, unlike -/// PeachPDF's own (which assumes automatic avoidance). -/// <para> -/// <c>PageBreakBottoms</c> (PeachPDF's own per-band table-slice bookkeeping) has no counterpart here - -/// confirmed by grep, no such member exists on this fork's <see cref="CssBox"/> - so -/// <c>ATableThatBrokeBetweenItsOwnRows_IsNotMoved</c> is adapted to check position alone. -/// </para> +/// Without <c>break-inside: avoid</c> a table is never moved as a whole, only a straddling row is, so every +/// fixture declares it. /// </remarks> [TestClass] [DoNotParallelize] @@ -57,8 +40,7 @@ private static (CssBox Table, HtmlContainerInt Container, CssBox Root) Layout(st return (table, container, root); } - // No pre-layout estimate exists to be "wrong" in this port (see the class remarks) - RelocateIfNeeded - // always sees the table's real, already-measured height, so a single-row table with tall cell content + // RelocateIfNeeded always sees the table's real, already-measured height, so a single-row table with tall cell content // is moved whole exactly like any other straddling break-inside:avoid box. [TestMethod] public void ATallSingleRowTable_IsMovedWholeOnceItsHeightIsKnown() @@ -99,9 +81,7 @@ public void ATableTallerThanOnePage_IsLeftWhereFlowPutIt() // A table that fragments between two of its own rows (css-tables-3 6.1's per-row preservation, not // this whole-table mover) straddles a boundary too - but RelocateIfNeeded's own "fits on no single - // page" guard (its total height clearly exceeds one page) declines to move it either way, for a - // different reason than PeachPDF's "the mover has nothing to say about a break it chose": this port has - // no concept of "the table itself chose this break", only whether moving it whole would help. + // page" guard (its total height clearly exceeds one page) declines to move it. [TestMethod] public void ATableThatBrokeBetweenItsOwnRows_IsNotMoved() { @@ -136,10 +116,8 @@ public void AnAvoidChainedHeading_TravelsWithTheMovedTable() "the heading must still precede the table it is chained to"); } - // A repeating <thead> used to be excluded from PeachPDF's own equivalent correction because its engine - // could not run a second time; this port's table engine rebuilds RepeatedHeaderRows from scratch on - // every LayoutCells call (see RepeatingTableRelayoutTests), so relocating the table and relaying it out - // fresh at its destination just works, with no stale/duplicate header state left over. + // The table engine rebuilds RepeatedHeaderRows on every layout, so the moved table gets no stale or + // duplicate header. [TestMethod] public void ATableWithARepeatingHeader_IsMovedAndItsHeaderRepeatsCorrectlyAtTheNewPosition() { @@ -159,12 +137,7 @@ public void ATableWithARepeatingHeader_IsMovedAndItsHeaderRepeatsCorrectlyAtTheN Assert.AreEqual(1, headerCells, "the header must appear exactly once, not duplicated by relocation"); } - // A table with a <tfoot> and no <thead> is moved like any other break-inside:avoid table - - // RelocateIfNeeded has no special-casing for header/footer presence at all (unlike PeachPDF's own - // correction, which PeachPDF's remarks describe as falling between two header/footer-aware pre-checks). - // Adapted rather than dropped outright: this still confirms tfoot's absence from the repeat mechanism - // (this fork implements no footer repeat at all - see the port plan's tfoot-drop rule) does not somehow - // also disable the unrelated whole-table relocation. + // A table with a <tfoot> and no <thead> is moved like any other break-inside:avoid table. [TestMethod] public void ATableWithAFooterAndNoHeader_IsMovedToo() { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/TestSupport/PaintHarness.cs b/Source/Test/HtmlRenderer.IntegrationTest/TestSupport/PaintHarness.cs index 2f8ff963e..e448efbbb 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/TestSupport/PaintHarness.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/TestSupport/PaintHarness.cs @@ -11,7 +11,7 @@ namespace HtmlRenderer.IntegrationTest.TestSupport; /// DrawRectangle, colors, positions, ordering) rather than just resulting geometry. Uses the local /// <see cref="MockAdapter"/>/<see cref="RecordingGraphics"/> pair (deterministic word metrics, every draw call /// logged) instead of a real GDI+ surface, since real WinForms rendering has no way to intercept individual -/// draw calls. Mirrors the shape of PeachPDF.Tests' FragmentPaintHarness/TestRecordingGraphics. +/// draw calls. /// </summary> internal static class PaintHarness { diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/FixedPositionPaginationIntegrationTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/FixedPositionPaginationIntegrationTests.cs index f6e9a1f11..7bd5180b9 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/FixedPositionPaginationIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/FixedPositionPaginationIntegrationTests.cs @@ -6,44 +6,9 @@ namespace HtmlRenderer.PdfSharp.Test; /// <summary> -/// Ported from PeachPDF.Tests' <c>FixedPositionPaginationIntegrationTests</c>, which confirms -/// <c>position:fixed</c> content repeats identically across real, multi-page <see cref="PdfGenerator"/> -/// output by scanning generated PDF content streams for a fixed box's own fill operator appearing on -/// every one of 3 real pages, produced there via <c>page-break-before:always</c>. +/// <c>position: fixed</c> content repaints at the same page position on every generated PDF page, checked +/// through the fill of a fixed box in each page's content stream. Filler content makes the pages. /// </summary> -/// <remarks> -/// <para> -/// This fork has no <c>page-break-before</c>/<c>page-break-after</c> support at all - already confirmed -/// and documented by <c>HtmlRenderer.IntegrationTest.Positioning.FixedPositionPaginationIntegrationTests</c>'s -/// own <c>PageBreakBefore_PushesFollowingContentToTheNextSimulatedPage</c> test, which is <c>[Ignore]</c>d -/// for exactly that reason (no <c>PageBreakBefore</c>/<c>PageBreakAfter</c> property anywhere in -/// <c>CssBoxProperties</c>, and the default stylesheet's own rules for it are inert). Real multiple pages -/// are forced here the same way <c>MultiPageTextVisibilityTest</c>/<c>FixedPositionRepeatsPerPdfPageTest</c> -/// already do it: enough filler paragraph content to genuinely overflow several A4 pages. -/// </para> -/// <para> -/// This is deliberately NOT a duplicate of the two fixed-position tests already in this branch's -/// history: -/// </para> -/// <list type="bullet"> -/// <item><c>HtmlRenderer.IntegrationTest.FixedPositionRepeatsPerPageTest</c> asserts against the -/// fragment tree directly (<c>FragmentainerFragment.Root</c> word positions) and never generates a real -/// PDF at all.</item> -/// <item><c>HtmlRenderer.PdfSharp.Test.FixedPositionRepeatsPerPdfPageTest</c> does go through the real -/// generator, but only ever checks a relative <c>Tj</c>-operator COUNT for fixed TEXT content - it never -/// confirms a fixed box's own painted geometry (a <c>background-color</c> fill, drawn through -/// <c>GraphicsAdapter.DrawRectangle(RBrush,...)</c>, a different code path than <c>DrawString</c>), and -/// never confirms the fixed content repaints at the SAME page-local position on every page rather than -/// merely "some extra content exists somewhere".</item> -/// </list> -/// <para> -/// This test fills that specific, previously-uncovered gap. The content-stream shape of a filled rect - -/// a "&lt;r&gt; &lt;g&gt; &lt;b&gt; rg" color-set operator followed (not necessarily immediately, since -/// PdfSharp's writer elides a redundant "gs"/state push in between) by an "x y w h re" path and an "f" -/// fill operator - was confirmed empirically by dumping a real generated content stream for this exact -/// fixture during porting, not guessed. -/// </para> -/// </remarks> [TestClass] [DoNotParallelize] public sealed class FixedPositionPaginationIntegrationTests diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/HandleLinksPaginationTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/HandleLinksPaginationTests.cs index b2749e3d9..188021cd9 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/HandleLinksPaginationTests.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/HandleLinksPaginationTests.cs @@ -4,27 +4,8 @@ namespace HtmlRenderer.PdfSharp.Test; /// <summary> -/// Ported from PeachPDF.Tests' <c>HandleLinksPaginationTests</c>: end-to-end tests for link-annotation -/// page attribution through the real <see cref="PdfGenerator"/> pipeline, asserting on the generated -/// <c>PdfDocument</c>'s own <c>Pages[i].Annotations</c>. +/// A link annotation lands on the PDF page its link is painted on, also when blank page slots are skipped. /// </summary> -/// <remarks> -/// PeachPDF's own two historical bugs this file documented (an un-shifted <c>MarginTop</c> in the -/// page-index formula, and a raw grid-slot index used directly as a <c>document.Pages</c> index) don't -/// map onto this fork's own <c>PdfGenerator.HandleLinks</c> (<c>Source/HtmlRenderer.PdfSharp/PdfGenerator.cs</c> -/// ~235-285) verbatim - this fork was written already carrying the fix: it builds a <c>slotToPage</c> -/// dictionary from <c>tree.Fragmentainers</c> up front (so a content-empty slot skipped by blank-page -/// skipping is simply absent from the map, never silently misindexed) and matches each link against -/// every fragmentainer's own <c>Geometry</c> band rather than dividing by a fixed page height. These -/// tests are therefore regression PINS for that already-correct behavior, not bug repros - but real ones, -/// exercised through the actual generator rather than assumed. -/// -/// PeachPDF's first fixture used <c>page-break-before:always</c> to land its link deterministically on -/// "page two" - this fork has no such property (confirmed elsewhere in this branch's own history; see -/// <c>HtmlRenderer.PdfSharp.Test.FixedPositionPaginationIntegrationTests</c>'s remarks), so both fixtures -/// here instead force genuine multi-page output the same way the other real-PDF tests in this project do: -/// with enough filler content that the layout itself overflows onto further pages. -/// </remarks> [TestClass] [DoNotParallelize] public sealed class HandleLinksPaginationTests diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/TableHeaderPdfRenderingTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/TableHeaderPdfRenderingTests.cs index 20e50ea3b..714dbb586 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/TableHeaderPdfRenderingTests.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/TableHeaderPdfRenderingTests.cs @@ -7,35 +7,9 @@ namespace HtmlRenderer.PdfSharp.Test; /// <summary> -/// Ported from PeachPDF.Tests' <c>TableHeaderPdfRenderingTests</c>: real end-to-end PDF rendering of a -/// repeating <c>&lt;thead&gt;</c> across multiple real generated pages. +/// A repeating <c>&lt;thead&gt;</c> across generated PDF pages, the header's background fill is checked in +/// each page's content stream since the page count alone doesn't catch a missing or doubled header. /// </summary> -/// <remarks> -/// <para> -/// PeachPDF's own file verified only page count and "the page has some content" (its own doc-comment -/// admits as much: "Extracting and decoding PDF text content is complex... For full text verification, -/// manual inspection... would be needed"). This branch's own history has since specifically shown that -/// page-count-only checks are NOT sufficient to catch a real header-repeat bug (see -/// <c>MultiPageTextVisibilityTest</c>'s own doc-comment, and Batch 4's phantom-double-header-paint fix) - -/// so every test here additionally confirms the header's own background fill repeats on every real -/// generated page via the raw content stream, using the same fill-operator convention confirmed -/// empirically while porting (see <see cref="HeaderFillPattern"/>). -/// </para> -/// <para> -/// PeachPDF's file name says "header/footer", but only 1 of its 5 tests is pure footer-repetition -/// (<c>TableFooter_MultiPageTable_GeneratesWithFooter</c>) and 1 mixes header+footer in a single-page, -/// paint-order-recording test (<c>TableHeaderAndFooter_SinglePageTable_PaintsEachCellTextExactlyOnce</c>, -/// which used PeachPDF's own <c>FragmentPaintHarness</c>/<c>DrawStringRecordingGraphics</c> test-only -/// mock, not a real generated PDF). This fork implements no <c>&lt;tfoot&gt;</c> repeat at all - -/// confirmed across this whole branch by <c>TableHeaderRepeat.cs</c> being thead-only - so the pure-footer -/// test is dropped outright, and the mixed test is rewritten below as a header-only, real-PDF, -/// content-stream-level equivalent (<see cref="TableHeader_SinglePageTable_PaintsHeaderBackgroundExactlyOnce"/>) -/// rather than ported with PeachPDF's own mock-graphics harness, which this project has no equivalent of -/// and which would test paint-call sequencing rather than the real generated PDF this project's own -/// established pattern (<c>MultiPageTextVisibilityTest</c>, <c>FixedPositionRepeatsPerPdfPageTest</c>) -/// insists on. -/// </para> -/// </remarks> [TestClass] [DoNotParallelize] public sealed class TableHeaderPdfRenderingTests @@ -140,18 +114,7 @@ public async Task TableHeader_ComplexHeaderWithColspan_RepeatsAcrossPages() } /// <summary> - /// Rewrite of PeachPDF's <c>TableHeaderAndFooter_SinglePageTable_PaintsEachCellTextExactlyOnce</c> - - /// a regression test for two real bugs PeachPDF found together there: a header/footer row never - /// getting its own <c>Bounds</c> set (so paint-time visibility culling silently dropped it), and its - /// proxy row being both self-registering AND explicitly re-added by its caller, painting every - /// header/footer cell twice at identical coordinates. This fork's <c>TableHeaderRepeat</c> mechanism - /// is architecturally different (an independent, already-positioned <c>CssBox</c> clone per repeat, - /// not a shared-subtree proxy - see <c>RepeatedTableHeaderClipIntegrationTests</c>'s own doc-comment - /// from Batch 4), so this exact bug shape doesn't apply here; ported instead as a real-PDF regression - /// PIN, through the real generator, that a single-page table's header paints its background exactly - /// once - not zero (dropped by culling) and not two (duplicated by a proxy re-add), the same class of - /// defect PeachPDF's test was guarding against, verified the way this project's own established - /// pattern requires (a real content stream, not a mock paint-recording harness). + /// The header paints its background exactly once, not zero times (dropped by culling) or twice. /// </summary> [TestMethod] public async Task TableHeader_SinglePageTable_PaintsHeaderBackgroundExactlyOnce() diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/TableHeaderRepetitionThroughTheGeneratorTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/TableHeaderRepetitionThroughTheGeneratorTests.cs index 141208ea6..1e90e3068 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/TableHeaderRepetitionThroughTheGeneratorTests.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/TableHeaderRepetitionThroughTheGeneratorTests.cs @@ -7,42 +7,12 @@ namespace HtmlRenderer.PdfSharp.Test; /// <summary> -/// Ported from PeachPDF.Tests' <c>TableHeaderRepetitionThroughTheGeneratorTests</c>: a repeating -/// <c>&lt;thead&gt;</c> above a single row that continues mid-cell across several pages, laid out the way -/// the real generator lays a document out - not PeachPDF's isolated <c>LayoutHarness</c>, which parses -/// once and computes its own content band. PeachPDF's own remarks are explicit that this distinction is -/// the file's whole point: two green unit-level tests over the same behavior weren't enough to know the -/// header was wrong, because nothing else in that suite laid a document out through the real generator's -/// own path with a repeating header. +/// A repeating <c>&lt;thead&gt;</c> above a single row that continues across several pages, laid out through +/// the real generator rather than <c>LayoutHarness</c>. /// </summary> /// <remarks> -/// <para> -/// Three of PeachPDF's adaptation notes don't apply here at all: this fork implements no -/// <c>&lt;tfoot&gt;</c> repeat (confirmed across the whole branch by <c>TableHeaderRepeat.cs</c> being -/// thead-only), its <c>@page</c> at-rule is confirmed parse-only with no consumer anywhere (so the -/// original fixture's <c>@page { size: a6; margin: 12mm }</c> is dropped in favor of driving page size -/// through <see cref="PdfGenerateConfig"/> directly, the same as every other real-generator test in this -/// project), and this project has no <c>InternalsVisibleTo</c> access to <c>HtmlContainerInt</c>/ -/// <c>FragmentTree</c> (only <c>HtmlRenderer.Test</c>/<c>HtmlRenderer.IntegrationTest</c> do - confirmed -/// by reading <c>Source/HtmlRenderer/HtmlRenderer.csproj</c>'s <c>InternalsVisibleTo</c> list), so every -/// assertion here goes through the real generated PDF's content stream instead of the fragment tree -/// PeachPDF's own version asserted on. -/// </para> -/// <para> -/// Porting this fixture surfaced a real, already-documented architectural gap rather than a new bug: -/// <c>HtmlRenderer.IntegrationTest.Tables.TableSpannedBandRepetitionTests</c> (Batch 4) already pins that -/// <c>CssLayoutEngineTable</c>'s header-repeat loop only re-checks for a "did we cross into a new band" -/// transition at the START of each subsequent row's own iteration - so a table whose only body row is a -/// single cell tall enough to overflow through several bands on its own, with no later row to trigger that -/// check, never gets the header repeated onto any of those bands at all. That is exactly PeachPDF's -/// fixture shape (one row, one very tall cell, no trailing row) - confirmed empirically here, through the -/// full real <see cref="PdfGenerator"/> pipeline rather than <c>LayoutHarness</c>, by dumping a real -/// generated content stream during porting: the header's own background fill appears once on the row's -/// starting page and not at all on any of the pages the row's content continues onto afterward. This is -/// pinned below as the accurate, current, real-pipeline-confirmed behavior (extending Batch 4's coverage -/// of the same gap past the isolated layout harness) rather than silently reproduced as if it were -/// correct, or forced to pass by reshaping the fixture into something PeachPDF never tested. -/// </para> +/// The header repeat is only checked at the start of a row, so with one tall row the header's fill is only on +/// the row's first page. This pins that behavior. /// </remarks> [TestClass] [DoNotParallelize] diff --git a/Source/Test/HtmlRenderer.Test/Css/AtPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/AtPropertyTests.cs index a06e6bc60..f128c57e5 100644 --- a/Source/Test/HtmlRenderer.Test/Css/AtPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/AtPropertyTests.cs @@ -4,7 +4,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary>Ported from PeachPDF.Tests/CSS/AtPropertyTests.cs.</summary> [TestClass] public sealed class AtPropertyTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/BorderPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/BorderPropertyTests.cs index 2442f1df7..bd1de584f 100644 --- a/Source/Test/HtmlRenderer.Test/Css/BorderPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/BorderPropertyTests.cs @@ -4,25 +4,7 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/BorderProperty.cs. -/// The CSS engine port replaced the old hand-rolled border parsing entirely - both the -/// Dictionary&lt;string,string&gt; longhand expansion via <c>CssParser.AddProperty</c>/ -/// <c>SplitMultiDirectionValues</c> (no legality validation of individual tokens: illegal keywords like -/// "wavy" used to be stored verbatim, and a too-long value list used to silently leave the whole -/// longhand set unset) and the public <c>CssParser.ParseBorder</c> whitespace-only tokenizer (which -/// couldn't recognize a color function containing internal spaces, e.g. "rgb(255, 100, 0)", or a bare -/// unitless "0" width, or "currentColor") - with the same real, typed, validating value-converter -/// pipeline PeachPDF's own CSS engine uses (see Source/HtmlRenderer/Core/CssEngine/StyleProperties/Border/). -/// Exercised here through <see cref="CssParser.ParseInlineStyle"/> and the resulting -/// <c>StyleDeclaration</c>'s longhand properties (colors normalize to "rgb(r, g, b)"/"rgba(r, g, b, a)" -/// text; a longhand a shorthand's grammar didn't cover resolves to the literal string "initial" per CSS -/// Cascading - a shorthand always sets every longhand it manages, explicitly or to its initial value - -/// rather than being left unset/null as the old parser's positional splitting did). -/// Every case the old whitespace-tokenizer/no-validation model couldn't handle now genuinely works -/// against the real engine and is un-ignored below: BorderSpacingPercentIllegal, BorderStyleWavyIllegal, -/// BorderLeftZeroLegal (bare "0" width), BorderBottomRgbLegal (color function with internal spaces), and -/// BorderOutSetCurrentColor ("currentColor" keyword) - all verified by actually running against the real -/// pipeline (see the probe values in this port's chat history), not assumed. +/// A shorthand sets every longhand it manages, a longhand its value doesn't cover reads "initial". /// </summary> [TestClass] public sealed class BorderPropertyTests @@ -271,10 +253,7 @@ public void BorderLeftStyleHiddenLegal() } // ── border-width (longhand + all-sides shorthand) ──────────────────────────────────────────────────── - // NOTE: unlike the old parser (which stored the "thin"/"medium"/"thick" keyword text verbatim), the - // real engine's LineWidthConverter resolves these keywords straight to their pixel value at parse - // time (this fork's scale: thin=1px, medium=3px, thick=5px - matching PeachPDF's own scale, since - // both share the same vendored converter), so the stored longhand value is already "1px"/"3px"/"5px". + // thin, medium and thick are resolved to 1px, 3px and 5px when parsed, so the stored longhand is a length. [TestMethod] public void BorderBottomWidthThinLegal() diff --git a/Source/Test/HtmlRenderer.Test/Css/ContainerTests.cs b/Source/Test/HtmlRenderer.Test/Css/ContainerTests.cs index 5fb1dde5a..58d5e8518 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ContainerTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ContainerTests.cs @@ -4,7 +4,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary>Ported from PeachPDF.Tests/CSS/Container.cs.</summary> [TestClass] public sealed class ContainerTests { @@ -103,9 +102,8 @@ li span { } [TestMethod] - [Ignore("HTML-Renderer's PropertyFactory only registers longhand container-name/container-type; no " + - "'container' shorthand/ContainerShorthandConverter exists to compose the two into a single " + - "serialized value. See Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs:202-203.")] + [Ignore("Gap, see Spec/css-conditional-5.yml")] + [Spec("css-conditional-5", "#container-shorthand")] public void ContainerShorthand_BothLonghandsSet_ComposesShorthandValue() { // Exercises ContainerShorthandConverter.Construct: setting both container-name and @@ -116,9 +114,6 @@ public void ContainerShorthand_BothLonghandsSet_ComposesShorthandValue() } [TestMethod] - [Ignore("HTML-Renderer's PropertyFactory only registers longhand container-name/container-type; no " + - "'container' shorthand/ContainerShorthandConverter exists. See " + - "Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs:202-203.")] public void ContainerShorthand_OnlyOneLonghandSet_ShorthandIsEmpty() { var style = CssConstructionFunctions.ParseDeclarations("container-name: sidebar;"); diff --git a/Source/Test/HtmlRenderer.Test/Css/CssDataRuleIndexingTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssDataRuleIndexingTests.cs index 6f9eb0190..02c4a4608 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CssDataRuleIndexingTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CssDataRuleIndexingTests.cs @@ -6,13 +6,8 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/CssDataRuleIndexingTests.cs. -/// Regression tests for <c>CssData</c>'s tag/class/id rule index (added to avoid linearly scanning every -/// stylesheet rule against every box during cascade). These render real HTML/CSS and assert on the -/// resulting box tree, so they exercise <see cref="TheArtOfDev.HtmlRenderer.Core.CssData"/>'s -/// <c>GetUserAgentStyleRules</c>/<c>GetAuthorStyleRules</c> exactly as the cascade does - the index only -/// narrows candidates, <c>DoesSelectorMatch</c> is still the source of truth, but a bucketing bug would -/// show up here as a rule silently failing to apply (or applying somewhere it shouldn't). +/// The tag, class and id rule index of <c>CssData</c> only narrows the candidates, a bucketing bug shows up as +/// a rule that doesn't apply, or applies where it shouldn't. /// </summary> [TestClass] public sealed class CssDataRuleIndexingTests diff --git a/Source/Test/HtmlRenderer.Test/Css/DocumentFunctionTests.cs b/Source/Test/HtmlRenderer.Test/Css/DocumentFunctionTests.cs index 059e0a96f..7b0b09f60 100644 --- a/Source/Test/HtmlRenderer.Test/Css/DocumentFunctionTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/DocumentFunctionTests.cs @@ -4,11 +4,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/DocumentFunction.cs (<c>CssDocumentFunctionTests</c>). <c>DocumentRule</c>, -/// <c>IDocumentFunction</c>, and <c>DomainFunction</c> all match HTML-Renderer's -/// <see cref="TheArtOfDev.HtmlRenderer.Core.CssEngine.DocumentRule"/> / <c>Functions/DocumentFunction.cs</c>. -/// </summary> [TestClass] public sealed class DocumentFunctionTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/FillPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/FillPropertyTests.cs index f8249efd9..ca7e04ddc 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FillPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FillPropertyTests.cs @@ -3,11 +3,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/FillProperty.cs (source class <c>FillPropertyTests</c>). -/// Covers the SVG paint/fill longhands: <see cref="FillProperty"/> (paint - colors/"none"/"url()"), -/// <see cref="FillOpacityProperty"/>, and <see cref="FillRuleProperty"/>, matching PeachPDF's grammar. -/// </summary> [TestClass] public sealed class FillPropertyTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/GapPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/GapPropertyTests.cs index b4339fc71..b1eafe8bd 100644 --- a/Source/Test/HtmlRenderer.Test/Css/GapPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/GapPropertyTests.cs @@ -5,7 +5,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary>Ported from PeachPDF.Tests/CSS/PropertyTests/GapProperty.cs (source class <c>GapPropertyTests</c>).</summary> [TestClass] public sealed class GapPropertyTests { @@ -14,11 +13,9 @@ public sealed class GapPropertyTests public void GapLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<GapProperty>(PropertyNames.Gap, value); - [Ignore("not yet spec compliant: GapProperty's converter (StyleProperties/GapProperty.cs) is " + - "Converters.LengthOrPercentConverter.OrGlobalValue().Periodic(RowGap, ColumnGap) - there is no " + - "'normal'-accepting branch anywhere in the chain (same gap as RowGapProperty/ColumnGapProperty), " + - "so 'normal' is rejected instead of accepted as the spec's default keyword.")] + [Ignore("Gap, see Spec/css-grid-2.yml")] [TestMethod] + [Spec("css-grid-2", "#gutters")] public void GapAcceptsNormalKeyword() => CssConstructionFunctions.TestForLegalValue<GapProperty>(PropertyNames.Gap, "normal"); @@ -35,9 +32,9 @@ public void GapShorthandValueExpanded(string propertyValue, string expectedRowGa Assert.AreEqual(expectedColumnGap, rule.Style.ColumnGap); } - [Ignore("not yet spec compliant: same missing-'normal' gap as GapAcceptsNormalKeyword - the periodic " + - "expansion never even runs since GapProperty's converter rejects 'normal' outright.")] + [Ignore("Gap, see Spec/css-grid-2.yml")] [TestMethod] + [Spec("css-grid-2", "#gutters")] public void GapShorthandValueExpanded_Normal() => GapShorthandValueExpanded("normal", "normal", "normal"); diff --git a/Source/Test/HtmlRenderer.Test/Css/LengthTests.cs b/Source/Test/HtmlRenderer.Test/Css/LengthTests.cs index abcbea841..b25fcedcd 100644 --- a/Source/Test/HtmlRenderer.Test/Css/LengthTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/LengthTests.cs @@ -4,54 +4,15 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/LengthTests.cs. -/// PeachPDF's <c>Length</c> is a small, rich value type: it has a two-argument (value, unit) -/// constructor, a ~40-member <c>Unit</c> enum (covering em/ex/px/mm/cm/in/pt/pc, rem, %, ch, and the -/// full viewport/small-viewport/large-viewport/dynamic-viewport/container-query unit families), -/// <c>IsAbsolute</c>/<c>IsRelative</c>, <c>UnitString</c>, a static <c>GetUnit</c>/<c>TryParse</c>, -/// <c>ToPixel()</c>/several <c>ToPixels(...)</c> overloads that resolve relative-to-absolute -/// conversions against font/container/viewport/page context, a <c>To(Unit)</c> absolute-unit -/// converter, comparison operators/<c>IComparable</c>, value equality/<c>GetHashCode</c>, -/// <c>IFormattable</c>, and predefined constants (Zero/Half/Full/Thin/Medium/Thick). -/// -/// HTML-Renderer's internal <see cref="CssLength"/> is much smaller: it has only a single -/// string-parsing constructor (<c>new CssLength("10px")</c>), a 10-member <see cref="CssUnit"/> enum -/// (None, Ems, Pixels, Ex, Inches, Centimeters, Milimeters, Points, Picas, Rems - i.e. only em/ex/px/mm/cm/ -/// in/pt/pc/rem, no %/ch/viewport/container-query units at all; percentage is tracked separately via -/// <c>IsPercentage</c>/<c>Number</c> rather than being a <see cref="CssUnit"/> value), <c>Number</c>, -/// <c>HasError</c>, <c>IsPercentage</c>, <c>IsRelative</c> (no <c>IsAbsolute</c>), <c>Length</c> (the -/// original string), <c>ConvertEmToPoints(emSize)</c>/<c>ConvertEmToPixels(pixelFactor)</c> (Ems-only, -/// both throw <see cref="InvalidOperationException"/> for any other unit), and a plain -/// <c>ToString()</c> override - no <c>TryParse</c>, no <c>To(unit)</c>, no <c>ToPixel</c>/ -/// <c>ToPixels</c>, no comparison operators/<c>IComparable</c>, no value equality/<c>GetHashCode</c> -/// override, no <c>IFormattable</c>, no predefined constants. -/// -/// Tests below that exercise only the 8 units/members this fork actually supports are ported as plain -/// passing tests against the real constructor/members (a few reusing <c>ConvertEmToPoints</c>/ -/// <c>ConvertEmToPixels</c> as the closest real analog to PeachPDF's richer pixel-conversion API). -/// Tests that need an unsupported unit (%-as-a-unit, ch, any viewport or container-query variant) -/// or an API member this fork's <see cref="CssLength"/> doesn't have (TryParse, To, ToPixel(s), -/// comparison/equality operators, IFormattable, predefined constants, IsAbsolute, UnitString, GetUnit) -/// are ported under <c>[Ignore]</c>, adapted as closely as possible to compile against the real API -/// surface and documenting the intended/target behavior for when such members might be added. -/// The container-query-unit theory (ToPixels_ContainerRelativeUnit_*, 4 methods), the viewport-unit -/// theories (ToPixels_ViewportUnit_*, 2 methods), ToPixels_Rem_UsesRemFactor, and -/// ToPixels_Ch_ApproximatesHalfEm were dropped entirely: their units (cqw/cqh/cqi/cqb/cqmin/cqmax, vw/ -/// vh/vmin/vmax and the sv*/lv*/dv* variants, ch) have no representation whatsoever in -/// <see cref="CssUnit"/>, so there is no plausible compileable adaptation - constructing a -/// <see cref="CssLength"/> with any of those suffixes just falls into the "unrecognized unit" -/// <c>HasError</c> branch and asserts nothing meaningful. +/// <see cref="CssLength"/> only knows em, ex, px, mm, cm, in, pt, pc and rem, a percentage is tracked through +/// <c>IsPercentage</c> as a fraction. /// </summary> [TestClass] public sealed class LengthTests { - // ── Ported as passing tests (the ~8 unit-supported, API-compatible cases) ────────────────── - [TestMethod] public void Constructor_SetsValueAndType() { - // PeachPDF: new Length(10f, Length.Unit.Px). This fork's CssLength has only a string - // constructor - the closest real equivalent is parsing the same "10px" text. var length = new CssLength("10px"); Assert.AreEqual(10d, length.Number); @@ -71,10 +32,7 @@ public void Constructor_SetsValueAndType() [DataRow("bogus", "None")] public void Constructor_ParsesKnownUnitSuffixes(string suffix, string expectedUnitName) { - // PeachPDF: static Length.GetUnit(suffix). This fork has no such static helper; the - // constructor itself is the (only) unit parser, so read the parsed Unit back off it. - // CssUnit is internal, so DataRow carries the expected unit's name (string) rather than - // the enum value itself - a public [TestMethod] can't declare an internal parameter type. + // CssUnit is internal, a public test method can't take it as a parameter var length = new CssLength("1" + suffix); Assert.AreEqual(expectedUnitName, length.Unit.ToString()); @@ -83,8 +41,6 @@ public void Constructor_ParsesKnownUnitSuffixes(string suffix, string expectedUn [TestMethod] public void Constructor_ValidLength_ParsesSuccessfully() { - // PeachPDF: Length.TryParse("10px", out result) returning true. This fork has no TryParse; - // the constructor always succeeds and HasError plays TryParse's "did this work" role. var length = new CssLength("10px"); Assert.IsFalse(length.HasError); @@ -130,12 +86,8 @@ public void Constructor_UnitlessNonZero_HasError() } [TestMethod] - public void ToPixel_RelativeUnit_Throws() + public void ConvertEmToPoints_NonEmUnit_Throws() { - // PeachPDF: a generic ToPixel() throws for a relative (Em) length. This fork has no - // ToPixel(); the closest real analog is ConvertEmToPoints, which requires an Ems-unit - // length and throws InvalidOperationException for any other unit. Pixels is (quirkily) - // flagged IsRelative in this fork, so use it to exercise the "wrong unit family" guard. var length = new CssLength("1px"); Assert.IsTrue(length.IsRelative); @@ -143,10 +95,8 @@ public void ToPixel_RelativeUnit_Throws() } [TestMethod] - public void ToPixels_Em_UsesEmFactor() + public void ConvertEmToPixels_UsesTheEmFactor() { - // PeachPDF: length.ToPixels(12, 0, 0) for 2em == 24. This fork's real analog is - // ConvertEmToPixels(pixelFactor), which returns a new px-unit CssLength. var length = new CssLength("2em"); var result = length.ConvertEmToPixels(12); @@ -171,11 +121,7 @@ public void ToString_NonZero_IncludesUnit() Assert.AreEqual("10px", length.ToString()); } - // ── Ported under [Ignore] - unsupported unit or missing API member ───────────────────────── - [TestMethod] - [Ignore("not yet spec compliant")] - [DataRow("1px", false)] [DataRow("1pt", false)] [DataRow("1in", false)] [DataRow("1cm", false)] @@ -185,235 +131,18 @@ public void ToString_NonZero_IncludesUnit() [DataRow("1ex", true)] public void IsRelative_MatchesUnitCategory(string lengthText, bool expectedIsRelative) { - // PeachPDF: IsAbsolute_And_IsRelative_MatchUnitCategory, covering all ~40 PeachPDF units. - // This fork's CssLength has no IsAbsolute property (only IsRelative), and currently - // (incorrectly, per CSS Values & Units, where px is an absolute unit) flags Pixels as - // relative. This documents the spec-correct target for the 8 units this fork supports: - // only em/ex should be relative: px/pt/in/cm/mm/pc should not. var length = new CssLength(lengthText); Assert.AreEqual(expectedIsRelative, length.IsRelative); } [TestMethod] - [DataRow("None", "")] - [DataRow("Ems", "em")] - [DataRow("Pixels", "px")] - [DataRow("Ex", "ex")] - [DataRow("Inches", "in")] - [DataRow("Centimeters", "cm")] - [DataRow("Milimeters", "mm")] - [DataRow("Points", "pt")] - [DataRow("Picas", "pc")] - public void UnitString_MatchesUnitName(string unitName, string expected) - { - // PeachPDF: Length.UnitString, covering all ~40 PeachPDF units (including "%" for Percent - // and "" for None). This fork's CssLength has no UnitString member at all; documents the - // intended mapping keyed off the (much smaller) CssUnit enum this fork actually has. - // CssUnit is internal, so DataRow carries the unit's name (string) rather than the enum - // value itself - a public [TestMethod] can't declare an internal parameter type. - Assert.AreEqual(expected, ExpectedUnitString(unitName)); - } - - [TestMethod] - public void ToPixel_ConvertsAbsoluteUnit() - { - // PeachPDF: length.ToPixel() resolves to the engine's internal layout unit, points: - // 1in == 72pt. No ToPixel() exists on this fork's CssLength at all (absolute-to-absolute - // conversion lives outside CssLength, in CssValueParser, and needs a CssBoxProperties - // context) - documents the intended result. - var length = new CssLength("1in"); - - Assert.AreEqual(CssUnit.Inches, length.Unit); - Assert.AreEqual(1d, length.Number); - // Target: length.ToPixel() == 72d (1in == 72pt). - } - - [TestMethod] - [Ignore("not yet spec compliant")] - public void ToPixels_Percent_UsesHundredPercentFactor() - { - // PeachPDF: length.ToPixels(0, 0, 200) for 50% == 100. This fork tracks percentages via - // IsPercentage/Number rather than a CssUnit value, and has no ToPixels(...) at all - - // documents the intended result against a 200-unit basis. - var length = new CssLength("50%"); - - Assert.IsTrue(length.IsPercentage); - Assert.AreEqual(50d, length.Number); - // Target: length.ToPixels(basis: 200) == 100d. - } - - [TestMethod] - public void ToPixels_Pc_TwelvePointsPerPica() - { - // PeachPDF: length.ToPixels(0, 0, 0) for 1pc == 12 (12pt per pica). No ToPixels(...) - // exists on this fork's CssLength - documents the intended result. - var length = new CssLength("1pc"); - - Assert.AreEqual(CssUnit.Picas, length.Unit); - Assert.AreEqual(1d, length.Number); - // Target: length.ToPixels() == 12d (1pc == 12pt). - } - - [TestMethod] - public void To_ConvertsBetweenAbsoluteUnits() - { - // PeachPDF: length.To(Length.Unit.Px) for 1in == 96 (CSS px: 1px == 1/96in). No To(unit) - // exists on this fork's CssLength - documents the intended result. - var length = new CssLength("1in"); - - Assert.AreEqual(CssUnit.Inches, length.Unit); - Assert.AreEqual(1d, length.Number); - // Target: length.To(CssUnit.Pixels) == 96d. - } - - [TestMethod] - public void To_In_ConvertsFromPoints() - { - var length = new CssLength("72pt"); - - Assert.AreEqual(CssUnit.Points, length.Unit); - Assert.AreEqual(72d, length.Number); - // Target: length.To(CssUnit.Inches) == 1d. - } - - [TestMethod] - public void To_Mm_ConvertsFromPoints() - { - var length = new CssLength("72pt"); - - Assert.AreEqual(CssUnit.Points, length.Unit); - Assert.AreEqual(72d, length.Number); - // Target: length.To(CssUnit.Milimeters) == 25.4d (within 3 decimal places). - } - - [TestMethod] - public void To_Pc_ConvertsFromPoints() - { - var length = new CssLength("12pt"); - - Assert.AreEqual(CssUnit.Points, length.Unit); - Assert.AreEqual(12d, length.Number); - // Target: length.To(CssUnit.Picas) == 1d. - } - - [TestMethod] - public void To_Pt_ReturnsSameValue() + [Ignore("CssLength.IsRelative is an internal flag that is also true for px")] + public void IsRelative_Pixels_IsFalse() { - var length = new CssLength("42pt"); - - Assert.AreEqual(CssUnit.Points, length.Unit); - Assert.AreEqual(42d, length.Number); - // Target: length.To(CssUnit.Points) == 42d. - } - - [TestMethod] - public void To_Cm_ConvertsFromPoints() - { - var length = new CssLength("72pt"); - - Assert.AreEqual(CssUnit.Points, length.Unit); - Assert.AreEqual(72d, length.Number); - // Target: length.To(CssUnit.Centimeters) == 2.54d (within 3 decimal places). - } - - [TestMethod] - public void To_RelativeTargetUnit_Throws() - { - // PeachPDF: length.To(Length.Unit.Em) throws for an absolute source length converting to a - // relative target unit. No To(unit) exists on this fork's CssLength - documents the intent. var length = new CssLength("1px"); - Assert.AreEqual(CssUnit.Pixels, length.Unit); - // Target: length.To(CssUnit.Ems) throws InvalidOperationException. - } - - [TestMethod] - public void Equality_ComparesValueAndType() - { - // CssLength overrides neither Equals nor == in this fork (a==b/a.Equals(b) fall back to - // reference equality), so two value-equal instances do not currently compare equal - - // documents the intended value-based equality contract via the two fields it would compare. - var a = new CssLength("10px"); - var b = new CssLength("10px"); - var c = new CssLength("10em"); - - Assert.AreEqual(a.Number, b.Number); - Assert.AreEqual(a.Unit, b.Unit); - Assert.AreNotEqual(a.Unit, c.Unit); - } - - [TestMethod] - public void GetHashCode_SameForEqualLengths() - { - // No GetHashCode override exists in this fork (falls back to reference-based - // object.GetHashCode), so two value-equal instances do not currently hash the same - - // documents the fields such a hash should be derived from. - var a = new CssLength("10px"); - var b = new CssLength("10px"); - - Assert.AreEqual(a.Number, b.Number); - Assert.AreEqual(a.Unit, b.Unit); - } - - [TestMethod] - public void CompareTo_SameUnit_ComparesValue() - { - // No comparison operators/IComparable exist on CssLength in this fork - compare the raw - // Number values directly as the closest available proxy for the intended ordering. - var small = new CssLength("1px"); - var large = new CssLength("2px"); - - Assert.IsTrue(small.Number < large.Number); - Assert.IsTrue(large.Number > small.Number); - Assert.IsTrue(small.Number <= large.Number); - Assert.IsTrue(large.Number >= small.Number); - } - - [TestMethod] - public void CompareTo_DifferentAbsoluteUnits_ComparesInPixels() - { - // No cross-unit comparison exists on CssLength in this fork (no ToPixel/CompareTo) - - // documents the intended result once cross-unit comparison exists: 1in (72pt) > 1cm (~28.3pt). - var oneInch = new CssLength("1in"); - var oneCm = new CssLength("1cm"); - - Assert.AreEqual(CssUnit.Inches, oneInch.Unit); - Assert.AreEqual(CssUnit.Centimeters, oneCm.Unit); - // Target: oneInch.ToPixel() > oneCm.ToPixel(). - } - - [TestMethod] - public void ToString_WithFormatProvider() - { - // CssLength has no ToString(string, IFormatProvider) overload (IFormattable isn't - // implemented) in this fork - falls back to the parameterless ToString(). - var length = new CssLength("10px"); - - Assert.AreEqual("10px", length.ToString()); - } - - [TestMethod] - [Ignore("not yet spec compliant")] - public void PredefinedConstants_HaveExpectedValues() - { - // CssLength has no Zero/Half/Full/Thin/Medium/Thick static constants in this fork - - // documents the values such constants should carry if added. - var zero = new CssLength("0"); - var half = new CssLength("50%"); - var full = new CssLength("100%"); - var thin = new CssLength("1px"); - var medium = new CssLength("3px"); - var thick = new CssLength("5px"); - - Assert.AreEqual(0d, zero.Number); - Assert.IsTrue(half.IsPercentage); - Assert.AreEqual(50d, half.Number); - Assert.IsTrue(full.IsPercentage); - Assert.AreEqual(100d, full.Number); - Assert.AreEqual(1d, thin.Number); - Assert.AreEqual(3d, medium.Number); - Assert.AreEqual(5d, thick.Number); + Assert.IsFalse(length.IsRelative); } [TestMethod] @@ -453,18 +182,4 @@ private static CssBox LayoutRem(string css) Assert.IsNotNull(x); return x!; } - - private static string ExpectedUnitString(string unitName) => unitName switch - { - "None" => "", - "Ems" => "em", - "Pixels" => "px", - "Ex" => "ex", - "Inches" => "in", - "Centimeters" => "cm", - "Milimeters" => "mm", - "Points" => "pt", - "Picas" => "pc", - _ => throw new ArgumentOutOfRangeException(nameof(unitName)) - }; } diff --git a/Source/Test/HtmlRenderer.Test/Css/OpacityPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/OpacityPropertyTests.cs index 810544551..fe9f8e44f 100644 --- a/Source/Test/HtmlRenderer.Test/Css/OpacityPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/OpacityPropertyTests.cs @@ -3,13 +3,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/OpacityPropertyTests.cs. -/// <see cref="OpacityProperty"/> is a plain <see cref="Property"/> whose value converter accepts a bare -/// number or a percentage, matching PeachPDF's own grammar exactly - both cases port unchanged, exercised -/// directly against the real CSS engine's declaration parser via -/// <see cref="CssConstructionFunctions.ParseDeclaration"/>. -/// </summary> [TestClass] public sealed class OpacityPropertyTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/PaletteMixGrammarTests.cs b/Source/Test/HtmlRenderer.Test/Css/PaletteMixGrammarTests.cs index d0f0fdbfe..9ae8d91aa 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PaletteMixGrammarTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PaletteMixGrammarTests.cs @@ -3,11 +3,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/PaletteMixGrammarTests.cs. -/// Tests for the shared <see cref="PaletteMixGrammar"/> - the <c>palette-mix()</c> function grammar -/// (CSS Fonts Module Level 4), confirmed a field-for-field match against PeachPDF's implementation. -/// </summary> [TestClass] public sealed class PaletteMixGrammarTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/ParserExtensionsCreateRuleTests.cs b/Source/Test/HtmlRenderer.Test/Css/ParserExtensionsCreateRuleTests.cs index 6e12a4e58..317f5b080 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ParserExtensionsCreateRuleTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ParserExtensionsCreateRuleTests.cs @@ -3,11 +3,9 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/ParserExtensionsCreateRuleTests.cs. <c>ParserExtensions.CreateRule</c> is a -/// near-verbatim match with HTML-Renderer's <c>Model/ParserExtensions.cs</c>: the same 13 <see cref="RuleType"/> -/// cases construct a concrete rule, and the same 4 types with no concrete subtype (<see cref="RuleType.Unknown"/>, +/// The rule types without a rule class of their own (<see cref="RuleType.Unknown"/>, /// <see cref="RuleType.RegionStyle"/>, <see cref="RuleType.FontFeatureValues"/>, <see cref="RuleType.CounterStyle"/>) -/// fall through to <see langword="null"/>. +/// give <see langword="null"/>. /// </summary> [TestClass] public sealed class ParserExtensionsCreateRuleTests diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyFactoryTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyFactoryTests.cs index ef0cd678e..eaa82e60f 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PropertyFactoryTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PropertyFactoryTests.cs @@ -6,19 +6,7 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/Property.cs (the closing <c>CssPropertyFactoryCalls</c>/ -/// <c>CssUnknownPropertyPreservesCase</c> cases), exercising <c>StyleDeclaration.CreateProperty</c>/ -/// <c>SetProperty</c> (Model/StyleDeclaration.cs) and <c>PropertyFactory.Instance</c> -/// (Factories/PropertyFactory.cs) directly rather than through <c>ParseDeclaration</c>. -/// -/// <c>StyleDeclaration.CreateProperty</c> (StyleDeclaration.cs lines 198-207) first returns any existing -/// declaration of that name, then falls back to <c>PropertyFactory.Instance.Create(name)</c> -/// (PropertyFactory.cs line 578: longhand, then shorthand, then custom-property lookup) - and only -/// synthesizes a same-named <c>UnknownProperty</c> when the parser is not in strict mode -/// (<c>IsStrictMode</c>, StyleDeclaration.cs line 312, is true whenever -/// <c>StylesheetParser.Options.IncludeUnknownDeclarations</c> is false, which is the default -/// <c>new StylesheetParser()</c> constructor's behavior) - so an unrecognized name under the default -/// parser returns null, matching PeachPDF's own assertion. +/// An unknown property name gives null, the default parser doesn't keep unknown declarations. /// </summary> [TestClass] public sealed class PropertyFactoryTests diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyLayoutTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyLayoutTests.cs index 258716683..c79209fe6 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PropertyLayoutTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PropertyLayoutTests.cs @@ -5,17 +5,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/Property.cs (the Clear/Position/Display/Visibility/Overflow/TableLayout -/// cases). Each property here is a plain single-keyword map lookup -/// (Source/HtmlRenderer/Core/CssEngine/StyleProperties/Flow/ClearProperty.cs, -/// PositionProperty.cs, DisplayProperty.cs; StyleProperties/Visibility/VisibilityProperty.cs; -/// StyleProperties/Flow/OverflowProperty.cs; StyleProperties/TableLayoutProperty.cs), backed respectively -/// by <c>Map.ClearModes</c>, <c>Map.PositionModes</c>, <c>Map.DisplayModes</c>, <c>Map.Visibilities</c>, -/// <c>Map.OverflowModes</c> (Model/Map.cs) and the <c>Toggle(Keywords.Fixed, Keywords.Auto)</c> converter -/// for table-layout (Model/Converters.cs line 538) - all verified to contain every keyword this port -/// exercises, so this file is a faithful, unmodified port of the source cases. -/// </summary> [TestClass] public sealed class PropertyLayoutTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/RealWorldTests.cs b/Source/Test/HtmlRenderer.Test/Css/RealWorldTests.cs index a293d3c68..f54610583 100644 --- a/Source/Test/HtmlRenderer.Test/Css/RealWorldTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/RealWorldTests.cs @@ -6,20 +6,7 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/RealWorld.cs. -/// PeachPDF's version parses a stylesheet with its CSS engine's <c>StylesheetParser</c> and reads the -/// parsed rule's properties back as CSSOM-normalized strings. HTML-Renderer's CSS engine port removed the -/// old raw-bucket <c>CssData.GetCssBlock</c> introspection API this file originally used entirely - -/// <c>CssData</c> is now purely a rule index queried during the real box-tree cascade (see -/// <c>CssData.GetStyleRules</c>), not a standalone lookup surface. So instead of parsing a bare -/// stylesheet and reading back its raw property strings, this test now drives the whole real pipeline -/// end to end via <see cref="LayoutHarness"/> - <c>SetHtml</c>, layout, then read the resolved values off -/// the laid-out <see cref="CssBox"/>es (<c>ActualBackgroundColor</c>/<c>ActualColor</c>/ -/// <c>ActualMargin*</c>), which is the closest real equivalent to "does this cascade actually resolve the -/// way the stylesheet says it should". -/// The second PeachPDF test (CreateStylesheet_WithCssProperties_ExpectStandardStringBack) round-trips a -/// rule back through <c>ToCss()</c> - there is no CSS serialization (CSSOM) surface exposed on this fork, -/// so that test still has no equivalent here and remains dropped. +/// The stylesheet is checked through the cascaded values of the laid-out boxes. /// </summary> [TestClass] public sealed class RealWorldTests diff --git a/Source/Test/HtmlRenderer.Test/Css/RowGapPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/RowGapPropertyTests.cs index beae39d86..6d34e1b4a 100644 --- a/Source/Test/HtmlRenderer.Test/Css/RowGapPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/RowGapPropertyTests.cs @@ -3,16 +3,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/RowGapProperty.cs. -/// RowGapLegalValues (length/percent/global keywords) is ported as a plain passing test. -/// RowGapAcceptsNormalKeyword is ported under [Ignore] - HTML-Renderer's -/// <see cref="RowGapProperty"/> (Source/HtmlRenderer/Core/CssEngine/StyleProperties/RowGapProperty.cs:5) -/// uses <c>Converters.LengthOrPercentConverter.OrGlobalValue().OrDefault(0)</c>, while PeachPDF's -/// RowGapProperty.cs:5 uses <c>Converters.LengthOrPercentOrNormalConverter...</c> instead; -/// <c>LengthOrPercentOrNormalConverter</c> does not exist anywhere under Source/HtmlRenderer (confirmed -/// via grep), so "row-gap: normal" is silently rejected here. -/// </summary> [TestClass] public sealed class RowGapPropertyTests { @@ -32,7 +22,8 @@ public void RowGapLegalValues(string value) => CssConstructionFunctions.TestForLegalValue<RowGapProperty>(PropertyNames.RowGap, value); [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-grid-2.yml")] + [Spec("css-grid-2", "#gutters")] public void RowGapAcceptsNormalKeyword() => CssConstructionFunctions.TestForLegalValue<RowGapProperty>(PropertyNames.RowGap, "normal"); } diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorsTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorsTests.cs index 021ade4a8..d34a16e29 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorsTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorsTests.cs @@ -4,8 +4,7 @@ namespace HtmlRenderer.Test.Css.Selectors; /// <summary> -/// Ported from PeachPDF.Tests/CSS/SelectorsTests.cs. Loads the same bootstrap.css fixture (embedded as -/// a resource via HtmlRenderer.Test.csproj) and exercises it against HTML-Renderer's CSS engine port. +/// Runs selectors against the bootstrap.css fixture, embedded as a resource. /// </summary> [TestClass] public sealed class SelectorsTests @@ -56,28 +55,33 @@ public void FindAllStyleRulesWithCompoundSelector() PseudoElementNames.Selection ]; - /// <summary> - /// PORT-UNIT-IGNORED: PeachPDF's expected counts (277 / 6) were calibrated to a PeachPDF-only fix - /// (vendor-prefixed pseudo-elements like ::-moz-placeholder parsing without invalidating their - /// rule) that has no counterpart in HTML-Renderer's CSS engine port - porting the exact expected - /// counts here would just be asserting HTML-Renderer's (currently undetermined, unverified) parser - /// behavior on bootstrap.css's vendor-prefixed selectors, not a real regression guard. - /// </summary> [TestMethod] - [Ignore("not yet spec compliant - expected counts were calibrated to a PeachPDF-only vendor-prefixed " + - "pseudo-element fix with no HTML-Renderer counterpart; see method doc comment.")] - [DataRow(false, false, 277)] - [DataRow(false, true, 6)] - [DataRow(true, false, 277)] - [DataRow(true, true, 6)] - public void FindAllStandardPseudoElementSelectors(bool allowInvalidSelectors, bool nonStandard, int expectedCount) + [DataRow(false)] + [DataRow(true)] + public void FindAllStandardPseudoElementSelectors(bool allowInvalidSelectors) { - var sheet = ParseBootstrap(allowInvalidSelectors); + Assert.AreEqual(277, CountPseudoElementRules(allowInvalidSelectors, nonStandard: false)); + } - var list = sheet.StyleRules - .Where(r => HasStandardPseudoElementSelector(r.Selector, negate: nonStandard)); + [TestMethod] + public void FindAllNonStandardPseudoElementSelectors_WhenToleratingInvalidSelectors() + { + Assert.AreEqual(6, CountPseudoElementRules(allowInvalidSelectors: true, nonStandard: true)); + } + + [TestMethod] + [Ignore("Gap, see Spec/selectors-4.yml")] + [Spec("selectors-4", "#invalid")] + public void FindAllNonStandardPseudoElementSelectors_KeepsOnlyTheWebkitOnes() + { + Assert.AreEqual(3, CountPseudoElementRules(allowInvalidSelectors: false, nonStandard: true)); + } + + private static int CountPseudoElementRules(bool allowInvalidSelectors, bool nonStandard) + { + var sheet = ParseBootstrap(allowInvalidSelectors); - Assert.AreEqual(expectedCount, list.Count()); + return sheet.StyleRules.Count(r => HasStandardPseudoElementSelector(r.Selector, negate: nonStandard)); } [TestMethod] diff --git a/Source/Test/HtmlRenderer.Test/Css/StrokePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/StrokePropertyTests.cs index 7c1cfb8db..4b7f50d41 100644 --- a/Source/Test/HtmlRenderer.Test/Css/StrokePropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/StrokePropertyTests.cs @@ -3,14 +3,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/StrokeProperty.cs (source class <c>StrokePropertyTests</c>). -/// All SVG paint/stroke longhands exist: <see cref="StrokeProperty"/> (paint - colors/"none"/"url()"), -/// <see cref="StrokeDasharrayProperty"/>, <see cref="StrokeDashoffsetProperty"/>, -/// <see cref="StrokeLinecapProperty"/>, <see cref="StrokeLinejoinProperty"/>, -/// <see cref="StrokeMiterlimitProperty"/>, <see cref="StrokeOpacityProperty"/>, and -/// <see cref="StrokeWidthProperty"/>, all matching PeachPDF's grammar. -/// </summary> [TestClass] public sealed class StrokePropertyTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/TransformMatrixTests.cs b/Source/Test/HtmlRenderer.Test/Css/TransformMatrixTests.cs index 8c3aaf818..46da58527 100644 --- a/Source/Test/HtmlRenderer.Test/Css/TransformMatrixTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/TransformMatrixTests.cs @@ -2,12 +2,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/TransformMatrixTests.cs. -/// HTML-Renderer's <see cref="TransformMatrix"/> is a field-for-field match of PeachPDF's: the same -/// Zero/One statics, the same 16-value column-major and 15-value explicit constructors, and the same -/// Equals/GetHashCode implementation over the backing 4x4 array. -/// </summary> [TestClass] public sealed class TransformMatrixTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/TypedCssPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/TypedCssPropertyTests.cs index 973afbf62..283d46980 100644 --- a/Source/Test/HtmlRenderer.Test/Css/TypedCssPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/TypedCssPropertyTests.cs @@ -4,7 +4,6 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/CssPropertyTests.cs. /// Tests for <see cref="CssProperty{T}"/> (the typed box-side property value) and the Layer A → /// <see cref="ITypedPropertyValue{T}"/> carrier that threads a converter's parse to the box without /// re-parsing. diff --git a/Source/Test/HtmlRenderer.Test/Css/UrlTests.cs b/Source/Test/HtmlRenderer.Test/Css/UrlTests.cs index 720c93050..e60cdf07f 100644 --- a/Source/Test/HtmlRenderer.Test/Css/UrlTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/UrlTests.cs @@ -2,10 +2,6 @@ namespace HtmlRenderer.Test.Css; -/// <summary> -/// Ported from PeachPDF.Tests/CSS/UrlTests.cs. HTML-Renderer's <see cref="TheArtOfDev.HtmlRenderer.Core.CssEngine.Url"/> -/// is a near-verbatim structural port of PeachPDF's <c>Model/Url.cs</c> (same state machine, same public surface). -/// </summary> [TestClass] public sealed class UrlTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/VerticalAlignPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/VerticalAlignPropertyTests.cs index da215e637..d93b600ee 100644 --- a/Source/Test/HtmlRenderer.Test/Css/VerticalAlignPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/VerticalAlignPropertyTests.cs @@ -4,16 +4,7 @@ namespace HtmlRenderer.Test.Css; /// <summary> -/// Ported from PeachPDF.Tests/CSS/PropertyTests/VerticalAlignProperty.cs. -/// PeachPDF.CSS tests only that "vertical-align" keyword/length/percentage values are parsed and stored -/// (via a typed VerticalAlignProperty) -- none of the PeachPDF cases assert an actual visual alignment effect. -/// HTML-Renderer's <see cref="CssBoxProperties.VerticalAlign"/> is a plain string set verbatim by the CSS -/// pipeline, with no legality validation. In <see cref="CssLayoutEngine"/>'s ApplyVerticalAlignment, only -/// "sub" and "super" carry a real layout effect; "top", "bottom", "middle", "text-top" and "text-bottom" are -/// empty-body no-ops for inline layout (table-cell vertical alignment is handled separately by -/// ApplyCellVerticalAlignment and isn't exercised here). Since none of the ported cases assert that visual -/// effect, all keyword cases are ported as plain passing tests of value parsing/storage, read back from a real -/// laid-out <see cref="CssBox"/> via <see cref="LayoutHarness"/>. +/// Only checks that vertical-align is parsed and stored, read back from a laid-out box. /// </summary> [TestClass] public sealed class VerticalAlignPropertyTests @@ -69,11 +60,6 @@ public void VerticalAlignNegativeLengthLegal() [TestMethod] public void VerticalAlignInvalidKeywordIllegal() { - // PeachPDF: an invalid vertical-align keyword is rejected, so the property reports HasValue == false - // and the previous (default, "baseline") value is retained. HTML-Renderer's CssBoxProperties.VerticalAlign - // is a plain string setter with no legality validation, so an invalid keyword is currently accepted and - // stored verbatim instead of being rejected. Target/intended behavior: the box keeps its default - // "baseline" alignment when given an invalid keyword. var target = GetTargetBox("wavy"); Assert.AreEqual("baseline", target.VerticalAlign); diff --git a/Source/Test/HtmlRenderer.Test/CssEngineSupport/CssConstructionFunctions.cs b/Source/Test/HtmlRenderer.Test/CssEngineSupport/CssConstructionFunctions.cs index efef97b16..4cd677057 100644 --- a/Source/Test/HtmlRenderer.Test/CssEngineSupport/CssConstructionFunctions.cs +++ b/Source/Test/HtmlRenderer.Test/CssEngineSupport/CssConstructionFunctions.cs @@ -3,11 +3,7 @@ namespace HtmlRenderer.Test.CssEngineSupport; /// <summary> -/// Mirrors PeachPDF.Tests/CSS/ConstructionFunctions.cs's <c>CssConstructionFunctions</c> helper, adapted to -/// HTML-Renderer's internal <see cref="TheArtOfDev.HtmlRenderer.Core.CssEngine"/> namespace (accessible here via -/// the <c>InternalsVisibleTo("HtmlRenderer.Test")</c> grant on HtmlRenderer.csproj). Shared by CSS engine unit -/// tests ported from PeachPDF that parse a snippet in isolation, rather than laying out a full document (for -/// which <see cref="TestSupport.LayoutHarness"/> is the equivalent). +/// Parses a CSS snippet with the internal CSS engine, for tests that don't need a whole document. /// </summary> internal static class CssConstructionFunctions { diff --git a/Source/Test/HtmlRenderer.Test/CssEngineSupport/ObjectArrayComparer.cs b/Source/Test/HtmlRenderer.Test/CssEngineSupport/ObjectArrayComparer.cs index a0ffa2689..a4d28abca 100644 --- a/Source/Test/HtmlRenderer.Test/CssEngineSupport/ObjectArrayComparer.cs +++ b/Source/Test/HtmlRenderer.Test/CssEngineSupport/ObjectArrayComparer.cs @@ -4,7 +4,7 @@ namespace HtmlRenderer.Test.CssEngineSupport; /// <summary> -/// Mirrors PeachPDF.Tests/CSS/ObjectArrayComparer.cs. Used to de-duplicate <c>object[]</c> test-case rows +/// Used to de-duplicate <c>object[]</c> test-case rows /// (e.g. when unioning a keyword list with the shared CSS-wide-keyword list) by comparing their contents /// rather than reference identity. /// </summary> diff --git a/Source/Test/HtmlRenderer.Test/CssEngineSupport/TestExtensions.cs b/Source/Test/HtmlRenderer.Test/CssEngineSupport/TestExtensions.cs index df5109e92..3f870636b 100644 --- a/Source/Test/HtmlRenderer.Test/CssEngineSupport/TestExtensions.cs +++ b/Source/Test/HtmlRenderer.Test/CssEngineSupport/TestExtensions.cs @@ -2,7 +2,6 @@ namespace HtmlRenderer.Test.CssEngineSupport; -/// <summary>Mirrors PeachPDF.Tests/CSS/TestExtensions.cs, adapted to HTML-Renderer's internal CssEngine namespace.</summary> internal static class TestExtensions { public static Stylesheet ToCssStylesheet(this string sourceCode) diff --git a/Source/Test/HtmlRenderer.Test/Dom/CssLayoutEngineTablePageBreakTests.cs b/Source/Test/HtmlRenderer.Test/Dom/CssLayoutEngineTablePageBreakTests.cs index d1016e8ec..a56ddd60f 100644 --- a/Source/Test/HtmlRenderer.Test/Dom/CssLayoutEngineTablePageBreakTests.cs +++ b/Source/Test/HtmlRenderer.Test/Dom/CssLayoutEngineTablePageBreakTests.cs @@ -4,49 +4,10 @@ namespace HtmlRenderer.Test.Dom; -/// <summary> -/// Ported from PeachPDF.Tests/Html/Core/Dom/CssLayoutEngineTablePageBreakTests.cs -/// (CssLayoutEngineTablePageBreakTests). -/// </summary> -/// <remarks> -/// A real rewrite, not a rename - confirmed by direct source read that most of the original file's 19 -/// tests assert against PeachPDF-only internal state or paint output with no counterpart here: -/// <list type="bullet"> -/// <item>7 tests (the <c>PageBreakBottoms_*</c> group plus <c>PageBreakBottoms_WithRepeatingFooter_...</c>) -/// assert against <c>CssBox.PageBreakBottoms</c>, a dictionary this fork's <see cref="CssBox"/> simply does -/// not have (confirmed: no such member exists anywhere in Core/Dom/CssBox.cs) - there is nothing to port -/// them onto.</item> -/// <item>3 tests (<c>TableBorderPaint_*</c>) and 1 more (<c>TableFooter_MultiPageTable_FooterTextIsPaintedOnEveryPage</c>) -/// verify PAINT output (drawn border lines / drawn strings) via a real <c>PdfSharpAdapter</c>-backed -/// recording graphics and a per-page paint harness. This batch's own established convention (see -/// <c>Dom/CssLayoutEngineTableTests.cs</c> and the sibling files in <c>Fragmentation/</c>) is adapter-free -/// layout/geometry assertions only, with no GDI+/paint harness in scope - paint-level border verification -/// belongs in a later, IntegrationTest-based batch, not here.</item> -/// <item>4 tests (<c>RepeatedThead_BoundaryToBody_...</c>, <c>RepeatedThead_OwnInternalGridLine_...</c>, -/// <c>RepeatedThead_RowspanInHeadersLastRow_...</c>, <c>RepeatedThead_BoundaryAgainstABorderedTbody_...</c>) -/// exercise PeachPDF's <c>CollapsedBorderModel</c>/<c>CollapsedBorderSegments</c> - a per-page collapsed- -/// border RESOLUTION model this fork has no counterpart for at all (confirmed: no such types exist -/// anywhere in Core). What DOES map to real, confirmed machinery here - as the port plan itself notes - -/// is the repeated-header MECHANISM underneath those tests: <c>TableHeaderRepeat.CloneAndPosition</c> -/// (Core/Fragmentation/TableHeaderRepeat.cs) and <see cref="CssBox.RepeatedHeaderRows"/>. The -/// <c>RepeatedThead_*</c> tests below are a genuine adaptation - same underlying feature, rewritten as -/// geometry/content assertions against the real clone rows rather than border-segment resolution.</item> -/// <item>The 2 <c>RepeatedTfoot_*</c> tests are dropped per the port plan (only <c>&lt;thead&gt;</c> repeat -/// is implemented, not <c>&lt;tfoot&gt;</c>).</item> -/// </list> -/// What remains and DOES port, as genuine black-box geometry assertions against real <see cref="CssBox"/>/ -/// <see cref="TheArtOfDev.HtmlRenderer.Core.HtmlContainerInt"/> state (<c>Location</c>/<c>ActualBottom</c>, -/// <c>PageTopOf</c>/<c>PageIndexOf</c>), matching the sibling <c>Fragmentation/</c> tests' own style: the -/// three page-break-offset/margin-bleed regression tests, rewritten onto this port's own row-preservation -/// behavior (css-tables-3 §6.1 - rows are shifted whole to the next page rather than split, per the -/// <c>CssLayoutEngineTable.LayoutCells</c> row loop, ~739-782), and the repeated-header geometry tests. -/// </remarks> [TestClass] public sealed class CssLayoutEngineTablePageBreakTests { - // Regression test (adapted): a multi-page table's rows on page 2+ must start flush at that page's own - // content top, not further down (the original PeachPDF bug this guards was a page-break offset - // computation that added marginTop twice). + // Guards against adding the top page margin twice. [TestMethod] public void PageBreakOffset_RowsOnSubsequentPages_StartAtCorrectY() { @@ -70,9 +31,7 @@ public void PageBreakOffset_RowsOnSubsequentPages_StartAtCorrectY() $"row starting page-slot {slot} should be flush at that page's own content top"); } - // Regression test (adapted): a row placed on a given page must not bleed past that page's own content - // bottom into the margin band below it (the original PeachPDF bug this guards was an availableHeight - // computation missing "- marginTop", firing the page break one row too late). + // Guards against leaving the top page margin out of the available height, which breaks one row too late. [TestMethod] public void AvailableHeight_PageBreakFiringPoint_RowDoesNotBleedIntoBottomMargin() { @@ -130,22 +89,8 @@ public void TableLayout_MultiPageTable_RowsDoNotOverlapPageMargins() } } - // The repeated-header MECHANISM this batch's port plan actually points at: a multi-page table's - // <thead> clones itself onto every continuation page (but one - see the remark below), at that - // page's own content top - the real, confirmed machinery behind PeachPDF's (unportable, border- - // resolution-based) RepeatedThead_* tests. - // "break-inside:avoid" is explicit on <thead> here rather than relied on from the UA default - // stylesheet's own thead/tfoot rule, matching this repository's own established convention (see - // StageD4RepeatedHeaderTest's identical note) - that rule lives under "@media print" in - // Core/CssDefaults.cs, and MockAdapter's own DefaultMediaType is "screen", so it would never match here. - // Adapted count, confirmed empirically and matching a real, documented limitation: the LAST page a - // table spans never gets a repeated header. CssLayoutEngineTable.LayoutCells (~654-686) only checks - // for a slot advance once per ROW, at that row's own start - there is no row after the table's last - // one to trigger the check for whatever slot the last row's own tail end lands in, so that final slot - // never gets a repeat inserted. This is a generalization of the file's own "KNOWN LIMITATION" comment - // (~634-645, written about a single row spanning multiple pages by itself) to the ordinary multi-row - // case: <see cref="RepeatedHeaderRows"/> ends up with entries for page-slots 1..(lastSlot-1), not - // 1..lastSlot. + // break-inside: avoid is set on <thead> because the UA rule for it is under @media print and MockAdapter + // uses screen. The last page a table spans gets no repeated header, the check only runs at the start of a row. [TestMethod] public void RepeatedThead_ClonesOntoEveryContinuationPage_AtThePagesOwnContentTop() { diff --git a/Source/Test/HtmlRenderer.Test/Parse/CssValueParserIsValidLengthTests.cs b/Source/Test/HtmlRenderer.Test/Parse/CssValueParserIsValidLengthTests.cs index a62cc620f..eb60b40e8 100644 --- a/Source/Test/HtmlRenderer.Test/Parse/CssValueParserIsValidLengthTests.cs +++ b/Source/Test/HtmlRenderer.Test/Parse/CssValueParserIsValidLengthTests.cs @@ -33,7 +33,7 @@ public void ValidLengthOrPercentage_ReturnsTrue(string value) } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("IsValidLength rejects a bare 0, which layout still handles like 0px")] public void BareZero_ReturnsTrue_NotYetSpecCompliant() { // CSS2.1 §4.3.2 / CSS Values §6.2: the unit identifier is optional after a zero length, so a diff --git a/Source/Test/HtmlRenderer.Test/TestSupport/LayoutHarness.cs b/Source/Test/HtmlRenderer.Test/TestSupport/LayoutHarness.cs index e89f3990c..b97bbb4a3 100644 --- a/Source/Test/HtmlRenderer.Test/TestSupport/LayoutHarness.cs +++ b/Source/Test/HtmlRenderer.Test/TestSupport/LayoutHarness.cs @@ -25,7 +25,7 @@ internal static class LayoutHarness /// <param name="pageHeight"> /// Optional: enables a real page grid (<see cref="HtmlContainerInt.HasRealPageGrid"/>) for fragmentation /// tests. Assigned directly to <see cref="HtmlContainerInt.PageSize"/>'s height - i.e. this is already the - /// per-page CONTENT BAND, not a sheet height margins are subtracted from, matching this fork's own + /// per-page CONTENT BAND, not a sheet height margins are subtracted from, matching the /// <see cref="HtmlContainerInt.PageTopOf"/>/<see cref="HtmlContainerInt.PageIndexOf"/> convention (a /// caller wanting a 300px sheet with 20px margins passes <c>pageHeight: 260</c>). <paramref name="margin"/> /// pixels of top/bottom margin are applied on top - the root box is placed at <c>(margin, margin)</c>, and diff --git a/Source/Test/HtmlRenderer.Test/Utils/CommonUtilsTests.cs b/Source/Test/HtmlRenderer.Test/Utils/CommonUtilsTests.cs index 2a0f633e9..71eebeb05 100644 --- a/Source/Test/HtmlRenderer.Test/Utils/CommonUtilsTests.cs +++ b/Source/Test/HtmlRenderer.Test/Utils/CommonUtilsTests.cs @@ -109,9 +109,6 @@ public void SubStringEquals_OutOfRange_ReturnsFalse() Assert.IsFalse(CommonUtils.SubStringEquals("Hi", 0, 5, "hello")); } - // Style keywords below use TheArtOfDev.HtmlRenderer.Core.Utils.CssConstants -- HTML-Renderer has - // no PeachPDF.CSS.Keywords equivalent, and ConvertToAlphaNumber's own style parameter is just a - // plain CSS keyword string. [TestMethod] [DataRow(0, CssConstants.UpperAlpha, "")] [DataRow(1, CssConstants.UpperAlpha, "A")] @@ -187,7 +184,8 @@ public void ConvertToAlphaNumber_ZeroWithAnyStyle_ReturnsEmpty() } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-counter-styles-3.yml")] + [Spec("css-counter-styles-3", "#simple-alphabetic")] public void ConvertToAlphaNumber_HiraganaIroha_DiffersFromDictionaryOrderHiragana() { // CSS Counter Styles Level 3's hiragana-iroha system reorders the 47 kana by the classical @@ -203,7 +201,8 @@ public void ConvertToAlphaNumber_HiraganaIroha_DiffersFromDictionaryOrderHiragan } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("Gap, see Spec/css-counter-styles-3.yml")] + [Spec("css-counter-styles-3", "#simple-alphabetic")] public void ConvertToAlphaNumber_KatakanaIroha_DiffersFromDictionaryOrderKatakana() { var dictionaryOrder = CommonUtils.ConvertToAlphaNumber(2, CssConstants.Katakana); @@ -220,11 +219,6 @@ public void ConvertToAlphaNumber_KatakanaIroha_DiffersFromDictionaryOrderKatakan [DataRow(CssConstants.KatakanaIroha)] public void ConvertToAlphaNumber_KanaAlphabets_DoNotThrowPastFirstWraparound(string style) { - // The hiragana/katakana tables here are both a fixed 48 characters (including trailing "n"), - // and HiraganaIroha/KatakanaIroha reuse those exact same tables rather than a separate - // 47-character iroha-ordered array -- so, unlike PeachPDF's fixed version (which guards - // against a smaller 47-character iroha table), there's no out-of-range indexing to trigger - // here. This still confirms the algorithm never throws and always produces output. for (var number = 1; number <= 200; number++) { var result = CommonUtils.ConvertToAlphaNumber(number, style); diff --git a/Source/Test/HtmlRenderer.Test/Utils/HtmlUtilsTests.cs b/Source/Test/HtmlRenderer.Test/Utils/HtmlUtilsTests.cs index fba20ba2d..891b30fcf 100644 --- a/Source/Test/HtmlRenderer.Test/Utils/HtmlUtilsTests.cs +++ b/Source/Test/HtmlRenderer.Test/Utils/HtmlUtilsTests.cs @@ -23,7 +23,7 @@ public void DecodeHtml_NumericCharacterReferenceWithTrailingSemicolon_ConsumesSe } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("DecodeHtml throws for a code point above U+10FFFF, the tokenizer has already replaced it when DecodeHtml runs")] public void DecodeHtml_OutOfRangeCodePoint_DecodesToReplacementCharacter() { // WHATWG HTML 13.2.5.80 numeric-character-reference-end-state: an out-of-range code point @@ -34,14 +34,14 @@ public void DecodeHtml_OutOfRangeCodePoint_DecodesToReplacementCharacter() } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("DecodeHtml throws for a surrogate code point, the tokenizer has already replaced it when DecodeHtml runs")] public void DecodeHtml_SurrogateRangeCodePoint_DecodesToReplacementCharacter() { Assert.AreEqual("�", HtmlUtils.DecodeHtml("&#xD800;")); } [TestMethod] - [Ignore("not yet spec compliant")] + [Ignore("DecodeHtml turns &#0; into U+0000, the tokenizer has already replaced it when DecodeHtml runs")] public void DecodeHtml_NullCodePoint_DecodesToReplacementCharacter() { // Unlike the out-of-range/surrogate cases (which decode to an empty string), U+0000 passes From 1128ff04b18f2a4bcc18246e9785190437f8b4f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= <eXpl0it3r@my-gate.net> Date: Fri, 13 Nov 2026 07:59:24 +0100 Subject: [PATCH 150/150] Run the remaining rendering tests sequentially --- .../HtmlRenderingRegressionTests.cs | 3 ++- .../Painting/PageOriginPaintIntegrationTests.cs | 2 ++ Source/Test/HtmlRenderer.PdfSharp.Test/PdfGeneratorTests.cs | 2 ++ .../Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs | 2 ++ .../Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs | 2 ++ .../Test/HtmlRenderer.PdfSharp.Test/StageD4VerificationTest.cs | 2 ++ .../Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs | 2 ++ 7 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/HtmlRenderingRegressionTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/HtmlRenderingRegressionTests.cs index 6c53f201d..72b976f40 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/HtmlRenderingRegressionTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/HtmlRenderingRegressionTests.cs @@ -9,7 +9,9 @@ namespace HtmlRenderer.IntegrationTest; +// Parallel full layout passes race on the adapter singletons. [TestClass] +[DoNotParallelize] public sealed class HtmlRenderingRegressionTests { private const int MaxWidth = 1200; @@ -36,7 +38,6 @@ public async Task Render_ConsecutiveBreaks_LeaveAnEmptyLine() $"Consecutive <br> elements should add an empty line. One break: {oneBreak.Height}px; two breaks: {consecutiveBreaks.Height}px."); } - [DoNotParallelize] [TestMethod] [DynamicData(nameof(GetSamples), DynamicDataDisplayName = nameof(GetSampleDisplayName))] public async Task Render_DemoTestSample_MatchesBaselineImage(string sampleName, string sampleHtml) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageOriginPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageOriginPaintIntegrationTests.cs index c150b0a26..6548f727f 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageOriginPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageOriginPaintIntegrationTests.cs @@ -11,7 +11,9 @@ namespace HtmlRenderer.IntegrationTest.Painting; /// Where fragment content lands on the painted surface: a PDF page keeps its top margin, and the continuous /// (non-paged) surface paints everything even when the container doesn't start at Y=0. /// </summary> +// Parallel full layout passes race on the adapter singletons. [TestClass] +[DoNotParallelize] public sealed class PageOriginPaintIntegrationTests { [TestMethod] diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/PdfGeneratorTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/PdfGeneratorTests.cs index f433cbcd9..cdec7cc91 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/PdfGeneratorTests.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/PdfGeneratorTests.cs @@ -7,7 +7,9 @@ namespace HtmlRenderer.PdfSharp.Test; +// Parallel full layout passes race on the adapter singletons. [TestClass] +[DoNotParallelize] public sealed class PdfGeneratorTests { [TestMethod] diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs index 43dc709d3..23d4b0c9d 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs @@ -3,7 +3,9 @@ namespace HtmlRenderer.PdfSharp.Test; +// Parallel full layout passes race on the adapter singletons. [TestClass] +[DoNotParallelize] public sealed class StageD2VerificationTest { [TestMethod] diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs index 61e6bebbc..5e78cf76e 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs @@ -3,7 +3,9 @@ namespace HtmlRenderer.PdfSharp.Test; +// Parallel full layout passes race on the adapter singletons. [TestClass] +[DoNotParallelize] public sealed class StageD3VerificationTest { [TestMethod] diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD4VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD4VerificationTest.cs index 2a4950485..a5e6ac363 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD4VerificationTest.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD4VerificationTest.cs @@ -3,7 +3,9 @@ namespace HtmlRenderer.PdfSharp.Test; +// Parallel full layout passes race on the adapter singletons. [TestClass] +[DoNotParallelize] public sealed class StageD4VerificationTest { [TestMethod] diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs index abb499dcb..84b0af092 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs @@ -3,7 +3,9 @@ namespace HtmlRenderer.PdfSharp.Test; +// Parallel full layout passes race on the adapter singletons. [TestClass] +[DoNotParallelize] public sealed class StageF1VerificationTest { [TestMethod]