diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9335d30d9..b3d67b4b7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -82,6 +82,18 @@ 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 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/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.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.PdfSharp/PdfGenerator.cs b/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs index b47ff9184..8042e993e 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()) { @@ -237,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(); for (var pageIndex = 0; pageIndex < tree.Fragmentainers.Count; pageIndex++) { 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 /// 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 } /// - /// Loads one @font-face face's bytes into the adapter's - /// via - through a temp file, matching this - /// adapter's own pre-existing DemoForm.LoadCustomFonts pattern (that path is untouched, - /// this is a new, separate mechanism specific to @font-face). + /// Adds the font to the and returns its family, found by the + /// family name in the font file. groups the faces of + /// a family and isn't in insertion order. /// /// - /// Deliberately NOT , despite it needing no temp - /// file: empirically (a throwaway repro project registering a dozen distinct families into one - /// ), AddMemoryFont is unreliable on this target - /// framework - 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 AddFontFile succeeds 100% of the - /// time across repeated runs. This is a known-flaky area of GDI+'s AddMemoryFont 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 - /// references it, and the OS's own temp-directory housekeeping reclaims it afterward - the same - /// "small, bounded, process-lifetime" rationale the removed AddMemoryFont/AllocHGlobal - /// approach relied on. - /// - /// The returned is found by sniffing the font's own internal - /// family name via and matching it against - /// - not by comparing Families.Length before - /// and after, nor by taking the array's last entry. Two bugs made that approach unreliable: (1) - /// Families groups every face by family name (the whole point of - /// - it lets pick the right face via - /// 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, Families 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. - /// + /// Goes through a temp file, doesn't reliably + /// list the added families. The file can't be deleted while the collection uses it. /// protected override RFontFamily LoadFontFaceFontInt(byte[] fontBytes, string filePath) { 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/HtmlRenderer/Adapters/RAdapter.cs b/Source/HtmlRenderer/Adapters/RAdapter.cs index 6632f6942..dad5b9cf1 100644 --- a/Source/HtmlRenderer/Adapters/RAdapter.cs +++ b/Source/HtmlRenderer/Adapters/RAdapter.cs @@ -94,7 +94,7 @@ protected RAdapter() } /// - /// The CSS media type this adapter renders for, used to evaluate @media queries - + /// The CSS media type this adapter renders for (CSS 2.1 §7.3), used to evaluate @media rules. /// "screen" for on-screen adapters (the default), "print" for paged output. /// public virtual string DefaultMediaType @@ -192,11 +192,8 @@ protected void OnColorSchemeChanged() /// the resolved resource, or null if it could not be resolved public Task 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(null); @@ -322,18 +319,14 @@ public RImage ImageFromStream(Stream memoryStream) } /// - /// Check if the given font exists in the system by font family name. Consulted by - /// to decide whether a font-family - /// candidate should be kept as-is or the next fallback (ultimately ) - /// tried instead - so this must recognize a family the moment it's usable, including one - /// registered via , not just /system fonts. + /// Check if the given font exists in the system by font family name, including the families + /// registered by . uses + /// it to pick the first available family of a font-family list. /// /// the font name to check /// true - font exists by given family name, false - otherwise /// - /// Virtual so the PdfSharp backend can override it - its @font-face registrations live - /// entirely in its own FontResolver (see 's doc comment for why), - /// invisible to the shared FontsHandler this base implementation checks. + /// Virtual for the PdfSharp backend, which keeps its @font-face faces in its own font resolver. /// public virtual bool IsFontExists(string font) { @@ -362,26 +355,19 @@ public void AddFontFamilyMapping(string fromFamily, string toFamily) } /// - /// Loads one @font-face src: url(...) candidate and registers it as a face of - /// for CSS Fonts Level 4 matching. Resolves the resource through - /// - the same funnel used for images/stylesheets, so this supports - /// file:/data:/http(s): uniformly - loads the platform-specific face via - /// , and registers it with . + /// Loads one url() item of an @font-face src (CSS Fonts 4 §4.3.2) through + /// and registers it as a face of . /// - /// the CSS family name declared by the @font-face rule - /// the resolved, absolute src URI to fetch (already resolved against the document base/a stylesheet's own location by the caller) - /// CSS Fonts Level 4 numeric weight (1-1000) this face matches for - /// whether this face matches an italic/oblique request - /// CSS Fonts Level 3 stretch (1-9) this face matches for - /// the face's unicode-range restriction, or null for "covers whatever is asked of it" - /// true if the face was loaded and registered, false if the resource could not be resolved/loaded (the caller tries the next src candidate) + /// the family name of the @font-face rule + /// the absolute URI of the item + /// numeric weight (1-1000) + /// whether the face is italic or oblique + /// width on the 1-9 scale of + /// the unicode-range, or null for any character + /// false if the resource couldn't be loaded, the caller then tries the next item /// - /// Virtual so the PdfSharp backend can override it to bypass 's shared - /// registry entirely and register directly with its own FontResolver instead - PDFsharp - /// needs raw font bytes at PDF-generation time for embedding (via its own richer - /// IFontResolver-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. /// public virtual async Task AddFontFace(string familyName, RUri uri, int weight, bool isItalic, int stretch, IReadOnlyList ranges) { @@ -426,14 +412,12 @@ public virtual async Task AddFontFace(string familyName, RUri uri, int wei } /// - /// Satisfies an @font-face src: local(...) candidate: rather than fetching a resource - /// at all, this looks for a family already registered under (a - /// system font, or an earlier / registration) - /// and, if found, registers *that same* as a face of - /// too. + /// Registers the family named by a local() item of an @font-face src + /// (CSS Fonts 4 §4.3.3.1) as a face of . The name is matched against + /// the registered families, not against full font names. /// - /// true if a local family by that name was found and registered, false otherwise (the caller tries the next src candidate) - /// Virtual for the same reason as - see its doc comment. + /// false if there's no such family, the caller then tries the next item + /// Virtual for the PdfSharp backend, see . public virtual bool AddFontFaceFromLocalFamily(string familyName, string localFamilyName, int weight, bool isItalic, int stretch, IReadOnlyList ranges) { var localFamily = _fontsHandler.TryGetExistingFamily(localFamilyName); @@ -667,16 +651,12 @@ internal RFont CreateFont(RFontFamily family, double size, RFontStyle style) protected abstract RFont CreateFontInt(RFontFamily family, double size, RFontStyle style); /// - /// Registers (a loaded @font-face src candidate's raw - /// font file bytes) with the platform text engine and returns an opaque - /// 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 to - /// bypass this entirely). + /// Registers the bytes of an @font-face font file with the platform text engine and returns + /// the family of the loaded face. Not called when is overridden. /// - /// the font file's raw bytes (TTF/OTF) - /// the resolved source this was loaded from, for diagnostics/error messages only - /// the registered face's family handle, or null if the bytes could not be loaded as a font (the caller tries the next src candidate) + /// the font file (TTF/OTF) + /// where the bytes came from, for diagnostics + /// the family of the face, or null if the bytes aren't a font protected abstract RFontFamily LoadFontFaceFontInt(byte[] fontBytes, string filePath); /// diff --git a/Source/HtmlRenderer/Core/CssData.cs b/Source/HtmlRenderer/Core/CssData.cs index 5957e3d9f..7996cb334 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 { /// @@ -51,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 @@ -181,15 +174,10 @@ internal bool MediaOutcomeChanged(MediaQueryContext before, MediaQueryContext af } /// - /// Every rule in a rule list, in document order, descending into @layer, @media, - /// @container, and (when its condition evaluates true) @supports blocks - matching - /// PeachPDF's own CssData.FlattenRules 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 only sees the top level) filter with - /// .OfType<IStyleRule>(), 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 is itself yielded before recursing. + /// Every rule in a rule list, in document order, descending into nested style rules, + /// @layer, @media and @container without evaluating their conditions, and + /// into @supports when its condition is true. Used for @font-face, ::selection + /// and the HTML export, the cascade walks the rules itself in . /// internal static IEnumerable FlattenRules(IEnumerable rules) { @@ -301,11 +289,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); @@ -320,11 +305,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) @@ -491,9 +472,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 +480,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 and Selectors 4 + /// §15. 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) { @@ -566,10 +540,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 +557,10 @@ 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. 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); @@ -746,17 +717,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 +741,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 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. /// private static bool DoesSelectorMatch(ChildSelector childSelector, CssBox box) { @@ -807,11 +771,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(); @@ -828,10 +788,9 @@ private static bool DoesSelectorMatch(ChildSelector childSelector, CssBox box) } /// - /// 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. /// private static bool DoesColumnSelectorMatch(ChildSelector childSelector, CssBox box, bool fromEnd) { @@ -876,10 +835,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) { @@ -889,9 +845,8 @@ private static int GetColSpan(CssBox box) } /// - /// 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 is step*n + offset for some n >= 0, + /// Selectors 4 §13.3.1. /// private static bool MatchesAnPlusB(int position, int step, int offset) { @@ -949,9 +904,8 @@ private static bool DoesSelectorMatch(HasSelector hasSelector, CssBox box) } /// - /// 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(> x)"/"+"/"~") aren't supported. + /// The descendant form of :has(), Selectors 4 §4.5. Walks all boxes, including anonymous and + /// text boxes, which "*" matches. /// private static bool HasDescendantMatch(CssBox box, ISelector inner) { diff --git a/Source/HtmlRenderer/Core/CssDefaults.cs b/Source/HtmlRenderer/Core/CssDefaults.cs index 00dc80708..109022872 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 rather than the HTML §15 one. /// - /// - /// http://www.w3.org/TR/CSS21/sample.html - /// public const string DefaultStyleSheet = @" html, address, blockquote, @@ -91,30 +88,25 @@ 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 } *[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 } - /* 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 } } - /* 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; } @@ -125,7 +117,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; @@ -134,14 +126,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) @@ -223,10 +210,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/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 { /// - /// The shared grammar for the aspect-ratio value ([ auto || <ratio> ], where - /// <ratio> = <number [0,∞]> [ / <number [0,∞]> ]?). 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 precedent. + /// The aspect-ratio grammar (CSS Sizing 4 §4.1) and the <ratio> type (CSS Values 4 §5.7). /// internal static class AspectRatioGrammar { /// - /// Validates the token stream as an aspect-ratio value. Returns false for anything that is not a - /// valid value. On success, is the used width/height ratio, or null when there - /// is no usable ratio (a bare auto, or a ratio with a zero term — both of which mean "no - /// preferred aspect ratio" for a non-replaced box). + /// is width / height, or null for a bare auto or a degenerate ratio. /// internal static bool TryParse(IReadOnlyList tokens, out double? ratio) => TryParse(tokens, out ratio, out _); /// - /// Validates the token stream as an aspect-ratio value, additionally reporting whether the - /// auto keyword was present ([ auto || <ratio> ]). lets a - /// replaced element prefer its natural aspect ratio and fall back to the specified <ratio> - /// (CSS Box Sizing 4 §5): auto <ratio> ⇒ hasAuto=true with a usable - /// ; a bare <ratio> ⇒ hasAuto=false (the ratio always - /// overrides); a bare auto ⇒ hasAuto=true with a null . + /// Like , tells + /// auto <ratio>, where a replaced element's natural ratio wins, from a bare <ratio>. /// internal static bool TryParse(IReadOnlyList tokens, out double? ratio, out bool hasAuto) { @@ -54,11 +44,7 @@ internal static bool TryParse(IReadOnlyList tokens, out double? ratio, ou } /// - /// Validates a bare <ratio> value (<number [0,∞]> [ / <number [0,∞]> ]?), - /// with no auto — the CSS Values 4 <ratio> data type, as used by the @property - /// syntax: "<ratio>" matcher. Distinct from , whose aspect-ratio - /// grammar additionally permits auto. On success is the used width/height - /// ratio, or null when a term is zero. + /// A bare <ratio> without auto, for the @property syntax matcher. /// internal static bool TryParseRatio(IReadOnlyList tokens, out double? ratio) { diff --git a/Source/HtmlRenderer/Core/CssEngine/BackgroundPositionGrammar.cs b/Source/HtmlRenderer/Core/CssEngine/BackgroundPositionGrammar.cs index b9b05f2ac..b3e52ec8f 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, CSS Backgrounds 3 §2.6: 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..9d56175fd 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, CSS Backgrounds 3 §2.9: 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/BasicShapeGrammar.cs b/Source/HtmlRenderer/Core/CssEngine/BasicShapeGrammar.cs index b234ccd70..451ee9959 100644 --- a/Source/HtmlRenderer/Core/CssEngine/BasicShapeGrammar.cs +++ b/Source/HtmlRenderer/Core/CssEngine/BasicShapeGrammar.cs @@ -4,14 +4,8 @@ namespace TheArtOfDev.HtmlRenderer.Core.CssEngine { /// - /// Shared, layer-agnostic grammar for the CSS <basic-shape> function values used by - /// clip-path (CSS Masking Level 1 / CSS Shapes Level 1): polygon(), inset(), - /// circle() and ellipse(). Like / - /// , it validates the grammar and captures the value's structure - /// as raw component strings / enums (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 TheArtOfDev.HtmlRenderer.Core, which resolves each component against the - /// element's reference box) share a single parser rather than re-implementing the grammar twice. + /// The <basic-shape> functions polygon(), inset(), circle() and ellipse() + /// (CSS Shapes 1 §3), parsed into unresolved component strings and enums. /// internal static class BasicShapeGrammar { @@ -69,9 +63,7 @@ internal sealed class ParsedBasicShape /// each an authored length-percentage component string.
public IReadOnlyList InsetEdges { get; private set; } - /// Whether an inset(... round <border-radius>) corner radius was present. - /// The radius itself is captured in but not rendered - inset is - /// drawn as a rectangle (see the resolver / docs). + /// Whether an inset(... round <border-radius>) corner radius was present. public bool InsetHasRound { get; private set; } public IReadOnlyList InsetRoundRadius { get; private set; } @@ -120,10 +112,7 @@ internal sealed class ParsedBasicShape } /// - /// Parses a clip-path value's tokens into a , or returns - /// null when the value is not a valid basic shape - including the literal none, - /// which callers treat as "no clip". (Layer A therefore accepts none separately, since a - /// null result here can't distinguish none from an invalid value.) + /// Null for anything that isn't a basic shape, none included. /// internal static ParsedBasicShape TryParse(IReadOnlyList tokens) { @@ -366,8 +355,7 @@ private static bool TryShapeRadius(Token token, out ShapeRadius radius) if (IsLengthPercentage(token)) { - // A is a non-negative (CSS Shapes 1 §3.2); a negative - // radius makes the whole clip-path value invalid. + // , 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)) @@ -387,10 +375,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 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/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 { /// - /// Shared, layer-agnostic grammar for the CSS box-shadow value (CSS Backgrounds & Borders - /// Level 3 §5): a comma-separated list of shadow layers, each - /// [ inset? && <length>{2,4} && <color>? ]. Like - /// / , it validates the grammar and - /// captures each layer's structure as raw component strings (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 TheArtOfDev.HtmlRenderer.Core, which resolves each length - /// against the box's own font/size) share a single parser rather than re-implementing the grammar. + /// Grammar of a box-shadow value, CSS Backgrounds 3 §6.1. Keeps the components as authored + /// strings, em/rem lengths can only be resolved against the box. /// internal static class BoxShadowGrammar { /// - /// A single parsed shadow layer. Lengths and the color are kept as the authored component strings - - /// box-shadow lengths may be font-relative (em/rem), so only Layer B can resolve them. - /// defaults to "0" and to "0" when the author - /// omitted them; is null when omitted, meaning "use the element's own color" - /// (currentColor) at paint time. + /// One shadow layer. and default to "0", a null + /// means currentcolor. /// 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(); /// - /// Parses a box-shadow value's tokens into an ordered list of s - /// (first-declared first), or returns null when the value is not a valid box-shadow. The literal - /// keyword none returns an empty list (no shadows), distinct from a null (invalid) - /// result. + /// The layers in declaration order, an empty list for none, or null if the value is invalid. /// internal static List TryParse(IReadOnlyList tokens) { @@ -120,12 +106,8 @@ private static ShadowLayer ParseLayer(IReadOnlyList group) } /// - /// Validates the color component. The full color grammar (named/rgb()/rgba()/hsl()/currentColor) - /// is checked via the shared , but a hex color is special-cased: the - /// raw CssValueParser.GetCssTokens tokenizer Layer B feeds this grammar produces a bare - /// token for #fff (only the in-declaration-value tokenizer path - /// yields a 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 + /// token instead of a , which doesn't accept. /// private static bool IsValidColor(IReadOnlyList colorTokens) { @@ -142,14 +124,8 @@ private static bool IsHexColor(string data) } /// - /// Merges a # delimiter followed by a number/dimension back into one hex-color token. The - /// CssValueParser.GetCssTokens tokenizer Layer B feeds this grammar is not in "value" mode, - /// so a digit-leading hex color (#08f, #000) is split into a bare - /// # plus a / - /// (e.g. 08f becomes a dimension "08" + unit "f"), whereas a letter-leading hex (#c00) - /// becomes a single 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 (#08f) + /// into a # delimiter and a number or dimension, this merges them back into one hash token. /// private static List NormalizeHexColorTokens(IReadOnlyList tokens) { diff --git a/Source/HtmlRenderer/Core/CssEngine/Calc/CalcCategory.cs b/Source/HtmlRenderer/Core/CssEngine/Calc/CalcCategory.cs index ddf28cfd8..7c327ee19 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 { /// - /// The CSS calc() type-checking category a (sub-)expression resolves to. Flags so that - /// "Length | Percentage" can represent the combined <length-percentage> 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. /// [Flags] internal enum CalcCategory @@ -14,24 +13,8 @@ internal enum CalcCategory Length = 2, Percentage = 4, LengthPercentage = Length | Percentage, - - /// - /// 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. - /// Angle = 8, - - /// - /// <time> (s/ms). Like Angle, it resolves without layout context. Currently produced only by - /// @property syntax: "<time>" validation - no layout property consumes a time - /// calc(), so it never reaches CalcEvaluator during a render. - /// Time = 16, - - /// - /// <resolution> (dpi/dpcm/dppx). Context-free like Angle/Time; same @property-only provenance. - /// 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 { /// - /// 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 width, parent font-size for font-size's em-relative resolution). + /// What em, rem and 100% are in pixels where a calc() is resolved. /// internal readonly struct CalcContext { @@ -25,21 +23,16 @@ public CalcContext(double hundredPercent, double emFactor, double remFactor, boo public bool FontAdjust { get; } /// - /// 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 pt leaf - /// must bypass the normal pt->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 + /// . /// public bool ReturnPoints { get; } } /// - /// 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 (CssValueParser.ParseLength), - /// since only layout has the a percentage/em/rem leaf needs to resolve. - /// Reuses 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. /// 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: "
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/Factories/PropertyFactory.cs b/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs index f767eeda4..fd5b48a17 100644 --- a/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs +++ b/Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs @@ -18,18 +18,13 @@ internal sealed class PropertyFactory private readonly Dictionary _shorthandsBuilder = new Dictionary(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 _logicalShorthands = new HashSet(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _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 _propertyDescriptors; // @font-palette-values descriptors (font-family / base-palette / override-colors). Stored raw via @@ -370,9 +365,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 +386,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(), @@ -411,18 +402,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); @@ -430,12 +415,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); @@ -443,12 +426,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, @@ -461,7 +442,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); @@ -475,7 +455,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, @@ -493,7 +472,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, @@ -509,7 +487,6 @@ private PropertyFactory() PropertyNames.BorderRightStyle, PropertyNames.BorderRightColor); - // Logical two-edge border width/style/color shorthands. AddLogicalShorthand(PropertyNames.BorderBlockWidth, () => new BorderBlockWidthProperty(), PropertyNames.BorderTopWidth, PropertyNames.BorderBottomWidth); @@ -558,8 +535,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/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/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 { - /// - /// A parsed grid <grid-line> (one edge of grid-column/grid-row/grid-area): - /// auto, an integer line number (may be negative — counting from the end edge), span N, or - /// a named line reference (a bare <custom-ident>, or <custom-ident> <integer> - /// for the Nth line with that name). span <custom-ident> is out of v1 scope. - /// + /// A <grid-line>, CSS Grid 2 §8.3. internal sealed class GridLine { public bool IsAuto { get; private set; } public bool IsSpan { get; private set; } - /// The line number (for a line reference), the span count (when ), or - /// the 1-based Nth index for a named line (defaults to 1). + /// The line number, the span count, or which line of the name it is. public int Value { get; private set; } = 1; - /// The custom-ident of a named line reference, or null when this is not a named line. 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 }; } - /// - /// Shared grammar for a single grid <grid-line> value. Used by Layer A (to validate the - /// grid-column-start/-end/grid-row-start/-end longhands) and by Layer B (the - /// grid placement engine). - /// + /// The <grid-line> of the grid-row-*/grid-column-* longhands, CSS Grid 2 §8.3. internal static class GridLineGrammar { internal static GridLine TryParse(IReadOnlyList tokens) @@ -40,24 +29,19 @@ internal static GridLine TryParse(IReadOnlyList 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; - // if (toks.Length == 1 && toks[0] is NumberToken number && number.IsInteger && number.IntegerValue != 0) return GridLine.Line(number.IntegerValue); - // span 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); - // — a named line reference. if (toks.Length == 1 && IsCustomIdent(toks[0])) return GridLine.Named(toks[0].Data); - // / — 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 tokens) return null; } - /// Whether the token is a <custom-ident> 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. 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 { - /// - /// A parsed grid-template-areas value (CSS Grid §7.3): a rectangular grid of named cells. Each - /// named area's 0-based inclusive bounding rectangle is recorded; a . (or a run of only dots) is - /// an unnamed empty cell. - /// + /// A grid-template-areas value, CSS Grid 2 §7.3. internal sealed class GridAreas { public int RowCount { get; set; } public int ColCount { get; set; } - /// The area name at each cell ([row, col]), or null for an empty . cell. + /// The area name per [row, col], null for a null cell token. public string[,] Cells { get; set; } - /// Each named area's 0-based inclusive rectangle (rowStart, colStart, rowEnd, colEnd). + /// 0-based inclusive cell rectangle per area name. public IReadOnlyDictionary Areas { get; set; } } /// - /// Shared grammar for a grid-template-areas value (a series of quoted strings). Used by Layer A - /// (accept/reject) and Layer B (the engine derives implicit name-start/name-end lines). - /// Returns null for anything invalid — including the none keyword, which the property accepts - /// separately. + /// The <string>+ of grid-template-areas, CSS Grid 2 §7.3. Returns null for none + /// too, the property handles that keyword. /// internal static class GridTemplateAreasGrammar { @@ -33,7 +27,6 @@ internal static GridAreas TryParse(IReadOnlyList 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(toks.Length); @@ -42,15 +35,14 @@ internal static GridAreas TryParse(IReadOnlyList 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 — 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 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 tokens) return new GridAreas { RowCount = rowCount, ColCount = colCount, Cells = grid, Areas = bounds }; } - /// A cell of only U+002E FULL STOP characters (., ...) is an empty cell. 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 { - /// The kind of a single grid <track-size>/<track-breadth> - /// (CSS Grid Layout Module Level 1/2 §7.2). + /// CSS Grid 2 §7.2.1. internal enum GridTrackKind { - Length, // a (raw component string in Value, resolved by Layer B ParseLength) - Percent, // a (raw component string in Value) - Flex, // a / 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() — the argument is in Value - Minmax // minmax(Min, Max) + FitContent, // argument in Value + Minmax // Min, Max } - /// Which flavour of layout-time repeat() a template carries (CSS Grid §7.2.3.2). + /// CSS Grid 2 §7.2.3.2. internal enum GridAutoRepeatKind { None, AutoFill, AutoFit } - /// - /// A single grid <track-size>. Fixed/percentage/fit-content values keep their authored - /// component string () for Layer B to resolve against the container; fr keeps - /// its numeric factor (); minmax() keeps its two sub-breadths. - /// + /// A <track-size>, lengths and percentages keep their authored text. internal sealed class GridTrackSize { public GridTrackKind Kind { get; private set; } @@ -44,62 +39,42 @@ internal sealed class GridTrackSize } /// - /// A parsed grid-template-columns/grid-template-rows value: the fixed (fully - /// repeat(N,…)-expanded) tracks, plus at most one layout-time repeat(auto-fill|auto-fit,…) - /// section recorded as an insertion point since its count is resolved during layout. + /// A grid-template-columns/-rows value. repeat(<integer>, …) is expanded into + /// , an auto-fill/auto-fit repeat only depends on the container size and is kept apart. /// internal sealed class GridTemplate { - /// The fixed tracks (with the auto-repeat section, if any, NOT included here — it is - /// spliced in at at layout time). + /// The tracks without the auto-repeat ones, those go in at . public IReadOnlyList Tracks { get; set; } = new List(); public GridAutoRepeatKind AutoRepeat { get; set; } = GridAutoRepeatKind.None; - /// The repeated track template for an auto-fill/auto-fit section. public IReadOnlyList AutoRepeatTracks { get; set; } - /// The index into at which the auto-repeat section is inserted. public int AutoRepeatInsertIndex { get; set; } - /// Named lines declared with [name] 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. + /// Line name → sorted 1-based line numbers, line 1 is before the first track. public IReadOnlyDictionary> LineNames { get; set; } = new Dictionary>(); - /// The subgrid 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 — only - /// any explicit [name] line names declared after the keyword (in ). + /// CSS Grid 2 §9, no , only the line names after the keyword. public bool IsSubgrid { get; set; } public bool IsNone => !IsSubgrid && Tracks.Count == 0 && AutoRepeat == GridAutoRepeatKind.None; } /// - /// Shared grammar for the grid <track-list> (grid-template-columns/ - /// grid-template-rows) and <track-size>+ (grid-auto-columns/ - /// grid-auto-rows). Used by both Layer A (accept/reject at parse) and Layer B (compute used - /// tracks during layout), so the grammar is defined once — the / - /// precedent. Top-level named lines ([name]) are collected into - /// ; named lines inside repeat() are out of scope and - /// rejected. + /// CSS Grid 2 §7.2 track lists of grid-template-columns/-rows and the + /// <track-size>+ of grid-auto-columns/-rows. /// internal static class GridTrackListGrammar { - /// - /// Parses a <track-list> (the grid-template-columns/-rows value). Returns - /// null when the value is not a valid track list — including the literal none, which - /// the property accepts separately. - /// + /// Returns null for none too, the property handles that keyword. internal static GridTemplate TryParse(IReadOnlyList 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 ([name] groups only; repeat() of line names is a v1 - // deferral, mirroring the "named lines inside repeat()" restriction below). No 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 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 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 tokens) }; } - /// - /// Parses a subgrid [ <line-name-list> ]? value (the leading subgrid ident already - /// matched by the caller). The optional line-name list is a run of [name …] 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 subgrid (a track size, an unclosed/ill-formed - /// bracket, a non-ident inside [ ]) is invalid. - /// + /// subgrid <line-name-list>?, the caller matched the keyword. private static GridTemplate TryParseSubgrid(Token[] toks) { var lineNames = new Dictionary>(); @@ -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) }; } - /// - /// Parses a <track-size>+ list (grid-auto-columns/grid-auto-rows) — one or - /// more track sizes, no repeat(). Returns null on any invalid token. - /// + /// <track-size>+, no repeat(). internal static IReadOnlyList TryParseTrackSizeList(IReadOnlyList 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 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(); 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(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(, ), fit-content(), and a - // math function (calc()/min()/max()/clamp()) that resolves to a . 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 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 — 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 — 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(). 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; } - /// - /// Whether is a math function (calc()/min()/max()/ - /// clamp()) that resolves to a <length-percentage>. Validated at parse time - /// (mirroring '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 ParseLength. - /// + /// Whether is a math function of type <length-percentage>. private static bool IsLengthPercentageCalc(FunctionToken fn) { if (!CalcParser.IsCalcFamily(fn.Data)) return false; 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 { /// - /// The any-hover media feature (Media Queries 4). Matched against the renderer's fixed - /// none stance (a static PDF has no hover-capable input) in MediaQueryMatcher. + /// Media Queries 5 §7.3, matched in MediaQueryMatcher. /// 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 { /// - /// The any-pointer media feature (Media Queries 4). Matched against the renderer's fixed - /// none stance (a static PDF has no pointing device) in MediaQueryMatcher. + /// Media Queries 5 §7.3, matched in MediaQueryMatcher. /// 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 { /// - /// The prefers-color-scheme media feature (Media Queries 5). The value is validated as any - /// identifier here; the actual light/dark match against the configured scheme happens - /// in MediaQueryMatcher. + /// Media Queries 5 §12.5, matched against the adapter's color scheme in MediaQueryMatcher. /// 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 { /// - /// The prefers-contrast media feature (Media Queries 5). Matched against the renderer's fixed - /// no-preference stance in MediaQueryMatcher. + /// Media Queries 5 §12.3, matched in MediaQueryMatcher. /// 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 { /// - /// The prefers-reduced-motion media feature (Media Queries 5). Matched against the renderer's - /// fixed reduce stance (a static PDF has no motion) in MediaQueryMatcher. + /// Media Queries 5 §12.1, matched in MediaQueryMatcher. /// 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 { /// - /// The prefers-reduced-transparency media feature (Media Queries 5). Matched against the - /// renderer's fixed no-preference stance in MediaQueryMatcher. + /// Media Queries 5 §12.2, matched in MediaQueryMatcher. /// 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 { /// - /// The update 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 - /// none in MediaQueryMatcher. + /// Media Queries 5 §5.4, matched in MediaQueryMatcher. /// internal sealed class UpdateMediaFeature : MediaFeature { diff --git a/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs b/Source/HtmlRenderer/Core/CssEngine/Model/Converters.cs index cb018d112..893bec5ca 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( @@ -299,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 " (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 ". 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(); @@ -349,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: ? — one value applies to both axes. public static readonly IValueConverter PlaceItemsConverter = AlignItemsConverter.Periodic(PropertyNames.AlignItems, PropertyNames.JustifyItems); public static readonly IValueConverter PlaceContentConverter = @@ -406,8 +393,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)); @@ -474,12 +460,8 @@ public static readonly IValueConverter IntegerConverter.Required(), IntegerConverter.StartsWithDelimiter().Required()); - // A single grid (auto | | span ), validated by GridLineGrammar. public static readonly IValueConverter GridLineConverter = new GridLineValueConverter(); - // grid-column / grid-row / grid-area: slash-separated components with the CSS Grid - // §8.3.1 omitted-value copy rule (a bare 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); @@ -499,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 property (background-image, list-style-image, cursor, - // content, and @property syntax:"") 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 is, but never painted public static readonly IValueConverter ImageSetImageConverter = Construct(() => new FunctionValueConverter(FunctionNames.ImageSet, new ImageSetConverter())); public static readonly IValueConverter CrossFadeImageConverter = @@ -523,8 +501,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( @@ -611,4 +587,4 @@ private static IValueConverter WithArgs(params IValueConverter[] converters) #endregion } -} \ No newline at end of file +} diff --git a/Source/HtmlRenderer/Core/CssEngine/Parser/Lexer.cs b/Source/HtmlRenderer/Core/CssEngine/Parser/Lexer.cs index d920be4fd..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,10 +330,7 @@ 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). + // In a value an all-hex name ("#f00") becomes a color token, anything else ("#hero") stays a hash. var allHex = true; while (true) @@ -550,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) { @@ -941,15 +921,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. + // 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) { - // 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 +1068,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/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() } /// - /// Repositions the lexer so the next token is re-lexed from character index - /// (a raw index, as returned by - /// ). Unlike setting (whose - /// BackNative loop is not a faithful inverse across \r\n normalization), this - /// restores the character stream exactly — line/column tracking is not rewound (it only affects - /// reported positions, never token content). Used by to rewind a - /// CSS-Nesting classification look-ahead so a declaration re-lexes cleanly in value mode. + /// Lexes the next token from the raw index , e.g. one + /// returned by . Unlike setting this is exact + /// across \r\n normalization. Line and column aren't rewound. /// 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..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; @@ -884,12 +869,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 " ['+' | '-'] " production of - // (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 , 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 +890,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 here, defined as "a - // 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 , "3n + -6" is invalid. _valid = _valid && ((NumberToken)token).IsInteger && int.TryParse(token.Data, out _offset); _offset *= _sign; _state = ParseState.BeforeOf; @@ -963,4 +941,4 @@ private enum ParseState : byte } } } -} \ No newline at end of file +} diff --git a/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetComposer.cs b/Source/HtmlRenderer/Core/CssEngine/Parser/StylesheetComposer.cs index d28f5fff8..714923d6b 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 _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) @@ -654,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 - // page name followed by an optional : 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(); while (true) @@ -697,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; } @@ -772,7 +741,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 +807,11 @@ public TextPosition FillDeclarations(StyleDeclaration style) } /// - /// 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 { } - /// block) rather than a declaration, parses it, resolves its selector against the enclosing rule, - /// attaches it, and returns true (with 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 and attaches it to the + /// enclosing rule. Returns false for a declaration, with the lexer rewound so the caller re-reads it. /// 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 +823,10 @@ private bool TryCreateNestedRule(ref Token token) } /// - /// Looks ahead (bracket-aware) to classify the construct starting at as a - /// nested style rule vs a declaration: the first top-level { means a nested rule (the stream - /// is left just after it and its source index returned via ); a - /// top-level ;/}/EOF means a declaration, in which case the lexer is rewound to the - /// construct start and re-read so the unchanged declaration path re-lexes - /// it in value mode (avoiding the # value-mode tokenization hazard a token buffer would hit). - /// Custom properties (--x) are always declarations, even with a { in their value. - /// Function tokens (e.g. url(…)) are opaque — the lexer already consumed their parentheses — - /// so only bare round/square brackets contribute to depth. + /// A top-level { before a ; or } makes the construct a nested rule, the stream is + /// left after the brace and is its source index. For a declaration the + /// lexer is rewound and re-read, so the value is lexed in value mode. Custom + /// properties are always declarations. A function token already holds its parentheses. /// private bool IsNestedRuleAhead(ref Token token, out int braceStart) { @@ -916,11 +874,8 @@ private bool IsNestedRuleAhead(ref Token token, out int braceStart) } /// - /// Builds a from a nested prelude (source span - /// [preludeStart, bracePosition)) resolved against the enclosing rule's selector, consumes - /// its declaration block from the live stream, and attaches it to the parent rule via - /// . The resolved selector is absolute (& → - /// :is(parent)) 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. /// private void CreateNestedStyleRule(int preludeStart, int braceStart) { @@ -931,8 +886,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 +901,15 @@ private void CreateNestedStyleRule(int preludeStart, int braceStart) } /// - /// Resolves a nested selector's prelude text against its parent's selector text per CSS Nesting: - /// each & becomes :is(parent) (giving & the parent's specificity); a - /// prelude with no & is made relative to the parent (:is(parent) <prelude>), - /// which correctly yields both the implicit-descendant (.b → :is(parent) .b) and the - /// leading-combinator (> .b → :is(parent) > .b) forms. + /// CSS Nesting 1 §3.1 and §4: & becomes :is(parent), a prelude without one is + /// prefixed with :is(parent) and a space, which covers both .b and > .b. An + /// & in a string is kept. /// 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) ) rather than mis-taking the replace path. var sb = Pool.NewStringBuilder(); var hasNestingSelector = false; var quote = '\0'; @@ -1403,19 +1351,8 @@ private TextPosition FillRules(GroupingRule group) while (token.IsNot(TokenType.EndOfFile, TokenType.CurlyBracketClose)) { - // "Consume a block's contents" discards a outright, exactly as it does a - // (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 - // 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 - // 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 ParseAsync(Stylesheet sheet, TextSource source) var tokenizer = new Lexer(source); var start = tokenizer.GetCurrentPosition(); var builder = new StylesheetComposer(tokenizer, this); - //var tasks = new List(); var end = builder.CreateRules(sheet); var range = new TextRange(start, end); sheet.StylesheetText = new StylesheetText(range, source); @@ -131,7 +130,6 @@ internal async Task 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/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 { /// - /// The block form of @layer (@layer name { rules } or anonymous @layer { rules }). - /// A grouping rule holding the style rules assigned to a cascade layer; cascade precedence between - /// layers is resolved when the rules are indexed (see CssData), not here. + /// The block form of @layer, CSS Cascade 5 §6.4.4.1. CssData indexes its rules as + /// if they were unlayered. /// 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 { /// - /// The statement form of @layer (@layer a, b, c;). It declares the relative order of - /// one or more named cascade layers without providing any rules; a later block-form - /// attaches rules to a layer already ordered by this statement, per - /// CSS Cascade 5. + /// The statement form of @layer, CSS Cascade 5 §6.4.4.2. Parsed, but the layer order it + /// declares isn't used. /// internal sealed class LayerStatementRule : Rule { 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 { /// - /// An @property at-rule (CSS Properties & Values API Level 1 §3): registers a typed custom - /// property with a syntax, initial-value, and inherits descriptor. The descriptor - /// block is stored like ; the registered name (a dashed-ident such as - /// --my-color) is captured from the prelude like . + /// An @property rule (CSS Properties and Values API 1 §3). Parsed only, the registration has no + /// effect on the cascade. /// internal sealed class PropertyRule : DeclarationRule, IPropertyRule { 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 _nestedRules = new List(); public StyleRule(StylesheetParser parser) : base(RuleType.Style, parser) 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 { /// - /// 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(> 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. /// 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 { /// - /// 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); - /// 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. /// 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 { /// - /// The negation pseudo-class ":not(S)" - matches an element that does NOT match S. + /// :not(), Selectors 4 §4.3. /// 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/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 { /// - /// The aspect-ratio property (CSS Box Sizing Level 4). The value is validated by the shared - /// and the authored text preserved for the layout engine, which computes - /// an auto dimension from the definite one. + /// aspect-ratio, CSS Sizing 4 §4.1. /// internal sealed class AspectRatioProperty : Property { 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? && {2,4} && ? ]#) 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/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/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 { - /// - /// The grid-area shorthand (CSS Grid §8.3.1): <grid-line> [ / <grid-line> ]{0,3}, - /// expanding to grid-row-start / grid-column-start / grid-row-end / - /// grid-column-end. The single named-area form is out of v1 scope. - /// + /// grid-area, CSS Grid 2 §8.4. 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 { - /// The grid-auto-columns property (CSS Grid §7.6): the size of implicitly-created columns. + /// grid-auto-columns, CSS Grid 2 §7.6. 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 { - /// The grid-auto-flow property (CSS Grid §7.7): the auto-placement direction and packing. + /// grid-auto-flow, CSS Grid 2 §7.7. 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 { - /// The grid-auto-rows property (CSS Grid §7.6): the size of implicitly-created rows. + /// grid-auto-rows, CSS Grid 2 §7.6. 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 { - /// - /// The grid-column-end longhand (CSS Grid §8.3), one edge of a - /// grid item's placement. Validated by the shared . - /// + /// grid-column-end, CSS Grid 2 §8.3. 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 { - /// - /// The grid-column shorthand (CSS Grid §8.3.1): <grid-line> [ / <grid-line> ]?, - /// expanding to grid-column-start / grid-column-end. - /// + /// grid-column, CSS Grid 2 §8.4. 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 { - /// - /// The grid-column-start longhand (CSS Grid §8.3), one edge of a - /// grid item's placement. Validated by the shared . - /// + /// grid-column-start, CSS Grid 2 §8.3. 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 { - /// - /// The grid shorthand (CSS Grid §7.8): expands to the three grid-template-* longhands plus - /// grid-auto-flow / grid-auto-rows / grid-auto-columns. - /// + /// grid, CSS Grid 2 §7.8. 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 { - /// - /// The grid-row-end longhand (CSS Grid §8.3), one edge of a - /// grid item's placement. Validated by the shared . - /// + /// grid-row-end, CSS Grid 2 §8.3. 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 { - /// - /// The grid-row shorthand (CSS Grid §8.3.1): <grid-line> [ / <grid-line> ]?, - /// expanding to grid-row-start / grid-row-end. - /// + /// grid-row, CSS Grid 2 §8.4. 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 { - /// - /// The grid-row-start longhand (CSS Grid §8.3), one edge of a - /// grid item's placement. Validated by the shared . - /// + /// grid-row-start, CSS Grid 2 §8.3. 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 { - /// - /// The grid-template-areas property (CSS Grid §7.3). Validated by the shared - /// ; the authored text is preserved for the grid layout engine, - /// which derives implicit name-start/name-end lines from each named area. - /// + /// grid-template-areas, CSS Grid 2 §7.3. 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 { - /// - /// The grid-template-columns property (CSS Grid Layout Module Level 1/2 §7.2). Validated by the - /// shared ; the authored text is preserved for the grid layout engine. - /// + /// grid-template-columns, CSS Grid 2 §7.2. 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 { - /// - /// The grid-template shorthand (CSS Grid §7.4): expands to grid-template-rows / - /// grid-template-columns / grid-template-areas. - /// + /// grid-template, CSS Grid 2 §7.4. 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 { - /// - /// The grid-template-rows property (CSS Grid Layout Module Level 1/2 §7.2). Validated by the - /// shared ; the authored text is preserved for the grid layout engine. - /// + /// grid-template-rows, CSS Grid 2 §7.2. internal sealed class GridTemplateRowsProperty : Property { private static readonly IValueConverter StyleConverter = new GridTemplateValueConverter().OrDefault(); 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 { - /// The justify-items property (CSS Box Alignment): default inline-axis alignment of grid items. + /// CSS Box Alignment 3 §7.1 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 { - /// The justify-self property (CSS Box Alignment): a grid item's own inline-axis alignment. + /// CSS Box Alignment 3 §6.1 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 { - /// The place-content shorthand (CSS Box Alignment): <align-content> <justify-content>?. + /// CSS Box Alignment 3 §5.2 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 { - /// The place-items shorthand (CSS Box Alignment): <align-items> <justify-items>?. + /// CSS Box Alignment 3 §7.3 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 { - /// The place-self shorthand (CSS Box Alignment): <align-self> <justify-self>?. + /// CSS Box Alignment 3 §6.3 internal sealed class PlaceSelfProperty : ShorthandProperty { private static readonly IValueConverter StyleConverter = Converters.PlaceSelfConverter.OrGlobalValue(); 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/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 { /// - /// Represents the CSS string-set property from CSS Generated Content for Paged Media Module. - /// Syntax: string-set: [ <custom-ident> <content-list> ]# | none - /// where <content-list> = [ <string> | <counter()> | <counters()> | <content()> | <attr()> ]+ + /// string-set (CSS Generated Content 3 §2.7.1). /// internal sealed class StringSetProperty : Property { 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 (CSS Transforms 1). As with transform-origin, the two-value - // 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 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/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 | ] - // | [ left | center | right | ] [ top | center | bottom | ] ? - // | [ [ center | left | right ] && [ center | top | bottom ] ] ? - // - // The two-value 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 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 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) z-offset - this branch only matches when a - // third length token is actually present... WithOrder(TwoValuePosition, LengthConverter) - // ...otherwise a plain one- or two-value with no z-offset. .Or(Position) .OrDefault(Point.Center); 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 { /// - /// The clip-path property (CSS Masking Module Level 1). The basic-shape grammar - /// (polygon()/inset()/circle()/ellipse()) 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 none and a <basic-shape> without a <geometry-box>. /// 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/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 and not what was authored. + // Data is the range without "U+". public override string ToValue() { return string.Concat("U+", Data); 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 { /// - /// Validates an aspect-ratio value through the shared — accepting - /// auto, a <ratio>, or auto && <ratio>, and rejecting everything - /// else so an invalid value is dropped at parse time. The authored text is preserved so Layer B - /// () re-parses the same value during layout. + /// Validates an aspect-ratio value (CSS Sizing 4 §4.1) and keeps the authored text. /// internal sealed class AspectRatioValueConverter : IValueConverter { 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/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 { /// - /// Validates a box-shadow value through the shared - accepting - /// none or a comma-separated list of [ inset? && <length>{2,4} && - /// <color>? ] shadow layers, and rejecting everything else so an invalid value (e.g. - /// box-shadow: banana) is dropped at parse time. The authored value text is preserved verbatim - /// so the paint-time resolver (Layer B, CssBox.PaintBoxShadows) re-tokenizes and resolves the - /// same value against the box - a single grammar shared across both layers. + /// Validates a box-shadow value with and keeps the authored text. /// internal sealed class BoxShadowValueConverter : IValueConverter { @@ -31,7 +26,6 @@ public BoxShadowValue(IEnumerable 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/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 { /// - /// Validates a clip-path value: accepts the keyword none or any <basic-shape> - /// the shared recognizes (polygon()/inset()/circle()/ - /// ellipse()), and rejects everything else so an unknown value (e.g. clip-path: banana) - /// is dropped at parse time. The authored value text is preserved verbatim ( - /// returns the original tokens) so the render-time resolver (Layer B) sees exactly what the author wrote. + /// Accepts none or a shape recognizes and keeps the authored tokens as the value. /// internal sealed class ClipPathValueConverter : IValueConverter { @@ -36,7 +32,6 @@ public ClipPathValue(IEnumerable 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/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 { /// - /// Converter for the content() function used in string-set property. - /// Supports: content(text), content(before), content(after), content(first-letter) + /// content() (CSS Generated Content 3 §2.7.3), without the marker argument. /// internal sealed class ContentFunctionConverter : IValueConverter { @@ -18,7 +17,6 @@ public IPropertyValue Convert(IEnumerable 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/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; /// - /// A validated-but-not-rendered image function (image-set()/cross-fade()/element(), - /// CSS Images 4 §2/§3). The engine accepts these as syntactically-valid <image> values (so a - /// property or @property registration using one is valid, per spec) but paints nothing for them — - /// the render path (CssValueParser.ParseImage → CssImagePainter) only handles url()/gradients. - /// This value carries the original tokens for serialization only. + /// An image-set(), cross-fade() or element() value, CSS Images 4 §2.4, §2.6 and §2.7. + /// Only validated and serialized, the painter doesn't handle them. /// internal sealed class ImageFunctionValue : IPropertyValue { @@ -26,12 +23,13 @@ public ImageFunctionValue(IEnumerable tokens) public TokenValue ExtractFor(string name) => Original; } - /// The inner <image> of an image-set()/cross-fade() option — a url() or a - /// gradient. Nested image-set() isn't modeled (not a real authoring pattern); referencing the two - /// base converters directly also avoids a construction cycle with ImageSourceConverter. + /// + /// The <image> inside image-set() and cross-fade(), only a url() or a gradient. + /// Using ImageSourceConverter here would be a construction cycle. + /// 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 value) { - // ToArray (LINQ) — a bare ToList() on IEnumerable 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 (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 option) if (StringConverter.Convert(items[0]) == null && ExtendedImage.InnerImage.Convert(items[0]) == null) return false; - // Any remaining items are a (incl. the `x` dppx alias, modeled by Resolution.GetUnit) - // and/or a type(), 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(); } - /// - /// The converter used to validate a colour stop's position. Length/percentage for linear and - /// radial gradients (the default); overridden by , whose - /// angular stops are positioned by an <angle> or <percentage> instead. - /// + /// Validates a color stop position, a length or percentage unless overridden. protected virtual IValueConverter StopPositionConverter => LengthOrPercentConverter; private IPropertyValue[] ToGradientStops(List> values, int offset) @@ -55,13 +51,8 @@ private IPropertyValue ToGradientStop(List value) if (firstPosition != null) items.RemoveAt(items.Count - 1); } - // = {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 .) + // 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 value) if (color != null) items.RemoveAt(items.Count - 1); } - // The two-position form is only defined for a ; a bare with no - // colour is a , 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 " 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 tokens) => new PlaceholderValue(tokens); @@ -193,8 +181,7 @@ public LinearGradientConverter() protected override IPropertyValue ConvertFirstArgument(IEnumerable value) { - // "[ | to ] || " (CSS Images 4). Validate - // any "in []" 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 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 ]? [ at ]? — 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 is positioned by | , 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). + // | protected override IValueConverter StopPositionConverter { get; } = AngleConverter.Or(PercentConverter).Or(new ZeroOrCalcPositionConverter()); protected override IPropertyValue ConvertFirstArgument(IEnumerable value) { - // "[ from ]? [ at ]? || " — validate any - // "in []" 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 value) private IPropertyValue ConvertPrelude(IEnumerable 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 value) return _prelude.Convert(value); } - // Accepts a bare `0` (the universal zero, which 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 value) @@ -306,8 +282,7 @@ public RadialGradientConverter() protected override IPropertyValue ConvertFirstArgument(IEnumerable value) { - // "[ || ]? [ at ]? || " — validate - // any "in []" 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 value) private IPropertyValue ConvertShape(IEnumerable 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/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 { - /// - /// The grid-area shorthand: <grid-line> [ / <grid-line> ]{0,3} in the order - /// row-start / column-start / row-end / column-end. Implements the CSS Grid §8.3.1 omitted-value copy - /// rule — a bare <custom-ident> propagates to the paired/all edges, so grid-area: main - /// fills the whole main area, while grid-area: 2 leaves the other edges auto. - /// + /// grid-area, CSS Grid 2 §8.4. internal sealed class GridAreaShorthandValueConverter : IValueConverter { public IPropertyValue Convert(IEnumerable value) @@ -16,13 +11,10 @@ public IPropertyValue Convert(IEnumerable 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 { - /// - /// Validates a grid-auto-flow value (CSS Grid §7.7): [ row | column ] || dense — one of - /// row/column optionally combined with dense, in either order. The authored text is - /// preserved for the grid layout engine. - /// + /// grid-auto-flow, CSS Grid 2 §7.7. internal sealed class GridAutoFlowValueConverter : IValueConverter { public IPropertyValue Convert(IEnumerable value) @@ -23,7 +19,7 @@ public IPropertyValue Convert(IEnumerable 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 { - /// - /// Validates a grid-auto-columns/grid-auto-rows value: a <track-size>+ list - /// (no repeat()) via the shared . The authored text is preserved - /// for the grid layout engine. - /// + /// grid-auto-columns/-rows, CSS Grid 2 §7.6. internal sealed class GridAutoTracksValueConverter : IValueConverter { public IPropertyValue Convert(IEnumerable 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 { /// - /// The grid-column / grid-row shorthand: <grid-line> [ / <grid-line> ]? - /// expanding to the axis's -start/-end longhands. Implements the CSS Grid §8.3.1 - /// omitted-value copy rule that the generic WithOrder(...).Option() DSL cannot: when the end is - /// omitted, it copies a bare <custom-ident> start (so grid-column: main spans the - /// whole main area) but leaves a line number / span as auto. + /// grid-row/grid-column, CSS Grid 2 §8.4. Not built from WithOrder(...).Option(), + /// that resets an omitted end to auto instead of copying a bare <custom-ident>. /// internal sealed class GridColumnRowShorthandValueConverter : IValueConverter { @@ -26,7 +23,6 @@ public IPropertyValue Convert(IEnumerable 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 { - /// - /// Validates a single grid <grid-line> (grid-column-start/-end, - /// grid-row-start/-end) through the shared — auto, an - /// integer line, or span N. The authored text is preserved for the grid placement engine. - /// + /// grid-row-start/-end, grid-column-start/-end, CSS Grid 2 §8.3. internal sealed class GridLineValueConverter : IValueConverter { public IPropertyValue Convert(IEnumerable 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 { - /// Shared helpers for the grid placement shorthands (grid-column/grid-row/ - /// grid-area): splitting a value on / into <grid-line> components, validating - /// each, and building the omitted-value copy result (CSS Grid §8.3.1). + /// Helpers for grid-row, grid-column and grid-area, CSS Grid 2 §8.4. internal static class GridPlacementShorthand { - /// A parsed <grid-line> component: its authored tokens and whether it is a bare - /// <custom-ident> (the only form the omitted-value copy rule propagates). + /// A <grid-line>, only a bare <custom-ident> is copied to omitted lines. internal struct Component { public IReadOnlyList Tokens { get; set; } @@ -18,9 +15,7 @@ internal struct Component private static readonly Token AutoToken = new Token(TokenType.Ident, Keywords.Auto, TextPosition.Empty); - /// Splits the value on top-level / delimiters into <grid-line> components, - /// validating each via . Returns null on any invalid component or an - /// out-of-range component count. + /// Splits on /, null if a component is invalid or there are too many. internal static List Split(IEnumerable value, int maxComponents) { var groups = new List> { new List() }; @@ -37,9 +32,7 @@ internal static List Split(IEnumerable 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 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 Split(IEnumerable value, int maxComponent return components.Count >= 1 && components.Count <= maxComponents ? components : null; } - /// The tokens for a longhand: the specified component's tokens, or auto when omitted. internal static IReadOnlyList Or(Component? component) { return component.HasValue ? component.Value.Tokens : new[] { AutoToken }; } - /// Drops leading and trailing whitespace tokens, keeping internal whitespace. private static IReadOnlyList Trim(List group) { var start = 0; @@ -67,8 +58,7 @@ private static IReadOnlyList Trim(List group) } } - /// A shorthand that emits a precomputed token stream for each of - /// its longhands (via ), used by the grid placement shorthands. + /// A shorthand value with precomputed tokens per longhand. internal sealed class MappedShorthandValue : IPropertyValue { private readonly IReadOnlyDictionary> _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 { - /// - /// The grid shorthand (CSS Grid §7.8): either a <grid-template>, or one of the two - /// auto-flow forms - /// <grid-template-rows> / [ auto-flow && dense? ] <grid-auto-columns>? and - /// [ auto-flow && dense? ] <grid-auto-rows>? / <grid-template-columns>. Expands to - /// the three grid-template-* longhands plus grid-auto-flow/-rows/-columns; - /// every longhand it doesn't set is reset to its initial value (an absent slice — §7.8). - /// + /// grid, CSS Grid 2 §7.8. A longhand without a slice is reset to its initial value. internal sealed class GridShorthandValueConverter : IValueConverter { public IPropertyValue Convert(IEnumerable value) { - // NB: the domain-specific IEnumerable.ToList() extension splits on commas; materialize the - // flat token list explicitly instead. + // Not ToList(), the IEnumerable extension of that name splits on commas. var tokens = new List(value); - // Form 1: . 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>(); @@ -30,7 +20,6 @@ public IPropertyValue Convert(IEnumerable 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 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>(); 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 { - /// - /// Validates a grid-template-areas value: the keyword none or a rectangular grid of quoted - /// strings accepted by the shared (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. - /// + /// grid-template-areas, CSS Grid 2 §7.3. internal sealed class GridTemplateAreasValueConverter : IValueConverter { public IPropertyValue Convert(IEnumerable 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 { - /// - /// Shared parsing for the CSS Grid mega-shorthands grid-template (§7.4) and grid (§7.8): splitting the value on the - /// top-level /, running the areas-form row scanner, and validating each partitioned slice - /// against the existing longhand grammars (/). The two converters stay thin over this helper. - /// + /// Parsing shared by grid-template (CSS Grid 2 §7.4) and grid (§7.8). 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); /// - /// Splits the flat token stream on top-level / delimiters, preserving each group's - /// whitespace. No bracket/paren depth counter is needed: functions are single s (their args nested) and [name] groups hold only idents, so a - /// / never appears nested at the flat level (same reasoning as - /// ). + /// No nesting depth needed, a function is a single and [name] + /// groups only hold idents. /// internal static List> SplitTopLevelSlash(IEnumerable value) { @@ -39,11 +29,7 @@ internal static List> SplitTopLevelSlash(IEnumerable value) internal static Token[] Significant(IEnumerable tokens) => tokens.Where(t => t.Type != TokenType.Whitespace).ToArray(); - /// - /// Validates one axis of the <rows> / <columns> form — a <track-list> or - /// the keyword none (both accepted by the grid-template-rows/-columns longhands) — - /// returning the edge-trimmed slice, or if invalid. - /// + /// One side of <rows> / <columns>, trimmed, or null if invalid. internal static IReadOnlyList TryTemplateAxis(IReadOnlyList raw) { var significant = Significant(raw); @@ -55,7 +41,6 @@ internal static IReadOnlyList TryTemplateAxis(IReadOnlyList raw) private static bool ContainsRepeat(IEnumerable significant) => significant.Any(t => t is FunctionToken fn && fn.Data.Isi(FunctionNames.Repeat)); - /// Drops leading/trailing whitespace tokens, keeping internal whitespace. internal static IReadOnlyList Trim(IReadOnlyList tokens) { var start = 0; @@ -68,7 +53,6 @@ internal static IReadOnlyList Trim(IReadOnlyList tokens) return result; } - /// The grid-auto-flow token slice for the grid auto-flow forms. internal static IReadOnlyList BuildAutoFlow(bool column, bool dense) { var flow = new List { new Token(TokenType.Ident, column ? Keywords.Column : Keywords.Row, TextPosition.Empty) }; @@ -80,25 +64,20 @@ internal static IReadOnlyList BuildAutoFlow(bool column, bool dense) return flow; } - /// - /// Parses a grid-template value (§7.4) into the three longhand slices. A - /// out-slice means "omit it" (the longhand resets to its initial value). Returns - /// for an invalid value (the whole declaration is then dropped). - /// + /// A null slice means the longhand is reset to its initial value. internal static bool TryParseTemplate(IEnumerable value, out IReadOnlyList rows, out IReadOnlyList columns, out IReadOnlyList 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 value, if (left.Any(t => t.Type == TokenType.String)) { - // Areas form: [ ? ? ? ]+ [ / ]? 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 value, if (rightRaw != null) { - // The trailing columns are an — a with no repeat(). + // , 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 value, return true; } - // rows / columns form: requires the slash; each side is a 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 value, } /// - /// Scans the areas-form left side, synthesizing the grid-template-rows track list - /// () and collecting the ordered <string> area tokens - /// (). Every emitted token is whitespace-separated so both round-trip - /// serialization (via ToText) and re-parsing are valid. + /// Splits the areas form into the grid-template-rows track list and the area strings, a row + /// without a track size gets auto. /// private static bool TryScanAreasForm(Token[] toks, out List rowsOut, out List areaStrings) { @@ -158,8 +133,6 @@ private static bool TryScanAreasForm(Token[] toks, out List 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 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 rowsOut, out sawString = true; i++; - // Optional single-token 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 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 tokens, Token token) } /// - /// Parses the [ auto-flow && dense? ] <track-size>* side of a grid auto-flow - /// form. Returns when auto-flow is absent or the value is malformed; - /// is null when the optional track-size list is omitted (reset to initial). + /// The [ auto-flow && dense? ] <track-size>* side of grid, a null + /// means it's reset to its initial value. /// internal static bool TryParseAutoFlowSide(IReadOnlyList raw, out bool dense, out IReadOnlyList 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 { /// - /// The grid-template shorthand (CSS Grid §7.4), expanding to grid-template-rows / - /// grid-template-columns / grid-template-areas. An omitted axis is reset to its initial - /// value (none) by when its slice is absent. + /// grid-template, CSS Grid 2 §7.4. resets a longhand without + /// a slice to its initial value. /// 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 { /// - /// Validates a grid-template-columns/grid-template-rows value: accepts the keyword - /// none, the subgrid keyword, or any <track-list> the shared - /// recognizes (lengths, %, fr, auto, - /// min-content/max-content, minmax(), fit-content(), repeat()), - /// rejecting everything else so an invalid template is dropped at parse time. The parse produced here to - /// validate is kept and exposed as a via - /// , so the grid layout engine (Layer B) reads the already-parsed - /// template instead of re-parsing the same string. + /// grid-template-columns/-rows, CSS Grid 2 §7.2. The parsed template is kept as a typed + /// . /// internal sealed class GridTemplateValueConverter : IValueConverter { @@ -38,12 +32,8 @@ public IPropertyValue Construct(Property[] properties) } /// - /// Builds the box-side 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 var( stays unresolved (never parsed - /// into a bogus template); otherwise the track list is parsed by the same - /// the converter uses — keeping all 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 var(). /// internal static CssProperty FromCssText(string value) { @@ -55,9 +45,7 @@ internal static CssProperty FromCssText(string value) return CssProperty.FromValue(value, template); } - /// Tokenizes an authored value string with the CSS-OM (whitespace/EOF - /// stripped) — the CSS-OM equivalent of the Html-layer CssValueParser.GetCssTokens, so this - /// Layer A factory has no upward dependency on Html.Core.Parse. + /// Like CssValueParser.GetCssTokens, without depending on Core.Parse. private static List Tokenize(string value) { using (var lexer = new Lexer(value) { IsInValue = false }) @@ -81,8 +69,7 @@ private sealed class GridTemplateValue : IPropertyValue, ITypedPropertyValue 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.FromValue(Original.Text, template); } 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 { /// - /// 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) + /// string() (CSS Generated Content 3 §2.7.2). /// internal sealed class StringFunctionConverter : IValueConverter { @@ -18,7 +17,6 @@ public IPropertyValue Convert(IEnumerable 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 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 { /// - /// Converter for the string-set property. - /// Syntax: string-set: [ <custom-ident> <content-list> ]# | none - /// where <content-list> = [ <string> | <counter()> | <counters()> | <content()> | <attr()> ]+ + /// string-set (CSS Generated Content 3 §2.7.1). Each name takes the GCPM content list + /// (counters, content(), attr(), string()), not just the strings of the /TR grammar. /// 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 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> + // ValueExtensions.ToList, not LINQ's: one token list per comma-separated pair + var items = value.ToList(); var pairs = new List(); foreach (var item in items) @@ -56,13 +53,11 @@ private StringSetPair ParsePair(List 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(); 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 { /// - /// 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). /// internal sealed class TimeBasedShorthandConverter : IValueConverter { @@ -32,15 +31,12 @@ public IPropertyValue Convert(IEnumerable 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 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(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 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(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 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 value) } } - // Success if all tokens were consumed if (list.Count == 0) { return new OptionsValue(options, value); @@ -124,16 +110,13 @@ public IPropertyValue Convert(IEnumerable value) } private bool TryMatchTime(List list, out IPropertyValue timeValue) { - // Use the TimeConverter to try to match a time value var testList = new List(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/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 } /// - /// CSS Color 5 color-mix(): - /// premultiplied-alpha interpolation in with weight - /// toward , the result alpha then scaled by - /// (below 1 only when the two percentages summed to under 100%). + /// Interpolates with premultiplied alpha in (CSS Color 4 §13), + /// is the weight of . is below 1 when the + /// color-mix() percentages add up to less than 100% (CSS Color 5 §3.3). /// 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 { /// - /// 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 ('s - /// oklch()/lab()/color-mix() 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). /// 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/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() } /// - /// Resolves this length to pixels, given the context needed for relative units - /// (em/rem/ex/%). This is the single source of truth for - /// "unit + context -> pixels" — 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. /// /// Pixels per 1em, i.e. the relevant font size in pixels. /// Pixels per 1rem, i.e. the root element's font size in pixels. @@ -233,10 +228,7 @@ public float ToPixel() /// If the length is in pixels and font related, apply the 72/96 factor. 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/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index 30b435ee7..0dbf1e628 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -84,11 +84,9 @@ internal CssBox ListItemBox } /// - /// For a table box only: detached clones of the table's own <thead> rows, one set per - /// continuation page the table's body spans (css-tables-3 6.2's repeated headers) - not part - /// of (so re-running table layout can never mistake them for real body - /// content), rebuilt from scratch on every layout pass by . - /// 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 . Not part of + /// . Null when nothing repeats. /// internal List RepeatedHeaderRows { get; set; } @@ -258,10 +256,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 { @@ -269,9 +265,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 { @@ -506,9 +500,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 +536,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 @@ -600,16 +589,9 @@ internal void ResumeAt(BreakToken token, double? resumeTopOverride = null) } /// - /// Whether a forced break here could actually be deferred to (and resumed in) a later pass - - /// false anywhere inside a table cell's subtree. 's row loop - /// calls cell.PerformLayout directly, the same way it always has, and does not participate - /// in the 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 and turn it into a real pass - /// boundary. Deferring anyway would leave the deferred content measured but never positioned - /// (its call returns before reaching CreateLineBoxes/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: + /// lays cells out directly and never reads their + /// , so deferred content would never be positioned. /// private bool CanDeferToLaterPass() { @@ -649,7 +631,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() { @@ -691,7 +673,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++; @@ -722,16 +704,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() { @@ -755,21 +730,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() @@ -836,24 +802,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); @@ -863,11 +819,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); } } @@ -880,10 +832,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,18 +850,13 @@ 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)) { - // 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) @@ -924,12 +867,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()) @@ -958,22 +897,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 +917,9 @@ 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). + // 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 + ResolveOffsetOrZero(this, Left, nearestPositionedAncestor.ActualWidth) @@ -1084,14 +1009,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(); @@ -1118,20 +1037,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 +1048,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); @@ -1216,9 +1121,9 @@ protected override sealed CssBoxProperties GetParent() } /// - /// 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. /// - /// private int GetIndexForList() { bool reversed = !string.IsNullOrEmpty(ParentBox.GetAttribute("reversed")); @@ -1242,9 +1147,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)) { @@ -1262,9 +1165,9 @@ private int GetIndexForList() } /// - /// Creates the + /// Creates the marker box (CSS Lists 3 §3) from list-style-type and places it 5px left of the + /// list item's border box. /// - /// private void CreateListItemBox(RGraphics g) { if (Display == CssConstants.ListItem && ListStyleType != CssConstants.None) @@ -1286,9 +1189,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)) @@ -1313,7 +1213,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; } } @@ -1504,13 +1404,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 @@ -1565,18 +1459,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)) { @@ -1611,14 +1497,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 +1506,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 +1525,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 +1554,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 +1568,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 +1581,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 +1605,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 +1623,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 +1642,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 +1656,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 +1686,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 +1706,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 +1748,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 +1757,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); } @@ -1963,17 +1766,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) { @@ -2128,12 +1922,13 @@ internal void PaintWord(RGraphics g, CssRect word, RRect wordRect) } /// - /// 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. /// /// the device to draw into - /// - /// - /// + /// the box's rectangle on this line + /// whether it's the first line, where the start padding and border are skipped + /// whether it's the last line, where the end padding and border are skipped internal void PaintDecoration(RGraphics g, RRect rectangle, bool isFirst, bool isLast) { if (string.IsNullOrEmpty(TextDecoration) || TextDecoration == CssConstants.None) @@ -2152,8 +1947,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; @@ -2280,20 +2074,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) { @@ -2313,12 +2102,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) @@ -2350,4 +2134,4 @@ public override string ToString() #endregion } -} \ No newline at end of file +} 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/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/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index 552e3a325..868e5343b 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; } } @@ -624,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 { @@ -762,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 { @@ -780,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, CSS UI 4 §3.4. The initial value and invert + /// paint as currentcolor. /// 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 @@ -800,10 +784,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(); @@ -815,10 +797,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 +806,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; @@ -1033,10 +1011,8 @@ public double AvailableWidth } /// - /// The padding+border this box's own explicit / value needs - /// added on top of it to reach the border-box measure - zero for box-sizing: border-box - /// (where the declared value already IS the border-box measure), padding+border for the default - /// content-box (where the declared value is the content measure alone). + /// What has to be added to an explicit to get the border-box width, the padding and + /// border for content-box and nothing for border-box (CSS Sizing 3 §3.3). /// public double ActualBoxSizeIncludedWidth { @@ -1131,11 +1107,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; @@ -1145,7 +1118,7 @@ public double ActualHeight } /// - /// Gets the actual height + /// Gets the actual width, 0 for 'width: auto' /// public double ActualWidth { @@ -1153,12 +1126,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); @@ -1578,9 +1547,8 @@ private static string SecondCssValue(string value) } /// - /// 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 after the overlap reduction, CSS Backgrounds 3 §4.5. + /// The horizontal and vertical radii are scaled separately. /// public BorderRadii ComputeRadii(RRect rect) { @@ -1628,7 +1596,6 @@ public double ActualWordSpacing } /// - /// /// Gets the actual color for the text. /// public RColor ActualColor @@ -1669,9 +1636,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 { @@ -1779,10 +1745,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); } } @@ -1884,13 +1847,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() @@ -1988,9 +1946,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 +2037,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/Dom/CssContentEngine.cs b/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs index 09ceba7b5..384b084bc 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, CSS Generated Content 3 §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 +39,8 @@ 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( @@ -58,6 +53,8 @@ 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"); @@ -65,6 +62,8 @@ 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( @@ -76,6 +75,7 @@ public void ApplyContent_WithCounterNoStyle_UsesDecimal() } [TestMethod] + [Spec("css21", "generate.html#counter-styles")] public void ApplyContent_WithCounterDecimalLeadingZero_PadsToTwoDigits() { var html = LayoutHarness.Wrap( @@ -88,6 +88,7 @@ public void ApplyContent_WithCounterDecimalLeadingZero_PadsToTwoDigits() } [TestMethod] + [Spec("css21", "generate.html#counter-styles")] public void ApplyContent_WithCounterAlphabeticStyle_FormatsWithStyle() { var html = LayoutHarness.Wrap( @@ -102,7 +103,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 +114,8 @@ 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( @@ -125,10 +128,10 @@ public void ApplyContent_WithCounterAndStyleAndLiteral_Concatenates() } [TestMethod] + [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#increment-set")] 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 +151,10 @@ public void CounterIncrement_ThreadsSequentiallyAcrossSiblings() } [TestMethod] + [Spec("css21", "generate.html#scope")] + [Spec("css-lists-3", "#inheriting-counters")] 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 +165,10 @@ public void CounterReset_OnNestedAncestor_IsVisibleToDescendant() } [TestMethod] + [Spec("css21", "generate.html#scope")] + [Spec("css-lists-3", "#nested-counters")] 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 +188,8 @@ 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( @@ -201,6 +206,8 @@ 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 1c37d26ba..def283663 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,10 @@ public void OlStart_OutsideTheStyleRange_FallsBackToDecimal(int start, string st } [TestMethod] - public void ListStyleTypeSquare_UsesAFilledSquareGlyph_NotASpadeSuitSymbol() + [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#text-markers")] + 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.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.IntegrationTest/CssModules/CssBoxTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssBoxTests.cs new file mode 100644 index 000000000..cde51d719 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssBoxTests.cs @@ -0,0 +1,68 @@ +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")] + [Spec("css-box-4", "#margin-physical")] + public void MarginLonghands_SetTheirOwnSideOnly() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
    ")); + 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")] + [Spec("css-box-4", "#margin-physical")] + public void Margin_DoesNotApplyToTableCells() + { + 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); + } + + [TestMethod] + [Spec("css-box-3", "#padding-physical")] + [Spec("css-box-4", "#padding-physical")] + public void PaddingLonghands_SetTheirOwnSideOnly() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
    ")); + 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")] + [Spec("css-box-4", "#padding-physical")] + public void Padding_AppliesToTableCells() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
    a
    ")); + 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.IntegrationTest/CssModules/CssDisplayTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssDisplayTests.cs new file mode 100644 index 000000000..ca7938dd3 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssDisplayTests.cs @@ -0,0 +1,299 @@ +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")] + [Spec("css-display-4", "#outer-role")] + public void BlockAndInlineOuterRoles_StackOrShareTheLine() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
    firstsecond" + + "
    third
    fourth
    ")); + + 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-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( + "
    head

    body

    ")); + + 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")] + [Spec("css-display-4", "#the-display-properties")] + public void TwoValueSyntax_ComputesToTheEquivalentShortValue(string value, string expected) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + $"

    x

    ")); + + Assert.AreEqual(expected, LayoutHarness.FindById(root, "e")!.Display); + } + + [TestMethod] + [Spec("css-display-3", "#inner-model")] + [Spec("css-display-4", "#inner-model")] + public void FlowInnerDisplay_LaysTheChildrenOutInNormalFlow() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
    ")); + + 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")] + [Spec("css-display-4", "#inner-model")] + public void FlowRoot_IsBlockLevelAndContainsItsFloats() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
    text
    after
    ")); + + 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-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( + "
    baseanno
    ")); + + Assert.IsLessThan(FindWord(root, "base")!.Top - Delta, FindWord(root, "anno")!.Top); + } + + [TestMethod] + [Spec("css-display-3", "#list-items")] + [Spec("css-display-4", "#list-items")] + public void ListItem_GeneratesAMarker() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
    item
    ")); + + 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")] + [Spec("css-display-4", "#list-items")] + public void InlineListItem_IsInlineAndGeneratesAMarker() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
    first second
    ")); + + 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")] + [Spec("css-display-4", "#layout-specific-display")] + public void TableInternalDisplayTypes_BuildATable() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
    " + + "
    a
    b
    " + + "
    ")); + + 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")] + [Spec("css-display-4", "#box-generation")] + public void DisplayNone_PaintsNothingOfTheSubtree() + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
    gone
    after
    ")); + var g = PaintHarness.PaintPage(container); + + Assert.IsFalse(g.Log.OfType().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")] + [Spec("css-display-4", "#box-generation")] + public void DisplayContents_ReplacesTheElementByItsChildren() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
    first
    second
    third
    ")); + + 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")] + [Spec("css-display-4", "#box-generation")] + public void DisplayContents_PaintsNoBoxOfItsOwn() + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
    child
    ")); + var g = PaintHarness.PaintPage(container); + + Assert.IsFalse(g.Log.OfType().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")] + [Spec("css-display-4", "#legacy-display")] + public void InlineBlockWithBlockChildren_StaysOnTheLine() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
    first
    a
    b
    second
    ")); + + 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-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( + $"
    aaa
    bbb
    ")); + 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")] + [Spec("css-display-4", "#transformations")] + public void FloatedInlineBlock_BlockifiesToBlock() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
    text
    ")); + + Assert.AreEqual("block", LayoutHarness.FindById(root, "f")!.Display); + } + + [TestMethod] + [DataRow("")] + [DataRow("display:inline")] + [Spec("css-display-3", "#root")] + [Spec("css-display-4", "#root")] + public void RootElement_IsBlockLevelAndFillsTheViewport(string style) + { + var (root, _) = LayoutHarness.Layout($"
    ", 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")] + [Spec("css-display-4", "#root")] + public void RootElement_IsBlockified(string display) + { + var (root, _) = LayoutHarness.Layout($"
    ", 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")] + [Spec("css-display-4", "#visibility")] + public void VisibilityCollapse_OutsideTables_HidesLikeHidden() + { + var (_, collapsed) = PaintHarness.Layout(PaintHarness.Wrap( + "
    gone
    after
    ")); + var g = PaintHarness.PaintPage(collapsed); + + Assert.IsFalse(g.Log.OfType().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/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("
    x y
    "); + + 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("
    x y
    z
    "); + + 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("
    אבג
    "); + + 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/CssModules/CssOverflowTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssOverflowTests.cs new file mode 100644 index 000000000..77574e7fa --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssOverflowTests.cs @@ -0,0 +1,169 @@ +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( + "
    ")); + var g = PaintHarness.PaintPage(container); + + var rect = g.Log.OfType().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( + "
    " + + "
    "), 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( + "
    "), 300, 300); + + container.ScrollOffset = new RPoint(0, -150); + var g = PaintHarness.PaintPage(container); + + Assert.AreEqual(50, g.Log.OfType().Single(r => r.Color == Red).Y, 0.5); + } + + [TestMethod] + [Spec("css-overflow-3", "#overflow-control")] + public void OverflowHidden_ClipsToTheBox() + { + var clip = ClipOfRedBox( + "
    "); + + 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( + $"
    "); + + 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( + "
    "); + + 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( + "
    x
    ")); + + 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( + "
    "); + + 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( + "
    aaaa bbbb cccc dddd
    ")); + var g = PaintHarness.PaintPage(container); + + 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("
    x
    ")); + + Assert.IsLessThan(100, LayoutHarness.FindById(root, "c")!.Size.Width); + } + + 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); + } + + 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/CssModules/CssPositionTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssPositionTests.cs new file mode 100644 index 000000000..e5480230f --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssPositionTests.cs @@ -0,0 +1,190 @@ +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 = "
    {0}
    "; + + [TestMethod] + [Spec("css-position-3", "#position-property")] + public void Sticky_WithAutoInsets_StaysInFlow() + { + var root = Layout("
    "); + + 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($"
    " + + "
    "); + 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("
    "); + + 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("
    "); + + 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, "
    " + + "
    ")); + + 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")] + [Spec("css-logical-1", "#position-properties")] + public void InsetBlockAndInsetInline_SetTheirAxis() + { + var root = Layout(string.Format(Container, "
    ")); + + Assert.AreEqual(20, Box(root, "a").Location.X, Delta); + Assert.AreEqual(10, Box(root, "a").Location.Y, Delta); + } + + [TestMethod] + [Spec("css-position-3", "#insets")] + [Spec("css-logical-1", "#position-properties")] + public void FlowRelativeInsets_MapToThePhysicalSidesInLtr() + { + var root = Layout(string.Format(Container, "
    ")); + + 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")] + [Spec("css-logical-1", "#position-properties")] + public void InsetInlineStart_IsTheRightSideInRtl() + { + var root = Layout("
    " + + "
    "); + + 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("
    a
    b
    "); + + 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("
    " + + "
    "); + 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, "
    ")); + + 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, "
    ")); + + 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, "
    ")); + + 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, "
    ")); + + 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( + "
    "), + 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/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( + $"
    " + + "
    "), maxWidth: 400).Root; + + Assert.AreEqual(50, LayoutHarness.FindById(root, "t")!.Rectangles.Values.Single().X, 0.5); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs new file mode 100644 index 000000000..40fc8f599 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssSizingTests.cs @@ -0,0 +1,223 @@ +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("
    "); + 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("
    "); + 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("
    "); + + 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("
    "); + + Assert.AreEqual(120, Box(root, "b").Size.Width, Delta); + } + + [TestMethod] + [Spec("css-sizing-3", "#box-sizing")] + public void BorderBox_AppliesToMaxHeight() + { + var root = Layout("
    "); + + 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")] + [Spec("css-logical-1", "#dimension-properties")] + [Spec("css-sizing-4", "#sizing-properties")] + public void FlowRelativePreferredSize_MapsToThePhysicalSize(string style, string axis) + { + var root = Layout($"
    "); + 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")] + [Spec("css-logical-1", "#dimension-properties")] + [Spec("css-sizing-4", "#sizing-properties")] + public void MinBlockSize_GrowsTheBox() + { + var root = Layout("
    "); + + 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")] + [Spec("css-logical-1", "#dimension-properties")] + [Spec("css-sizing-4", "#sizing-properties")] + public void MaxInlineSize_LimitsTheBox() + { + var root = Layout("
    "); + + Assert.AreEqual(100, Box(root, "b").Size.Width, Delta); + } + + [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("
    "); + + 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("
    "); + + 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("
    "); + + 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($"
    "); + + 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")] + [Spec("css-sizing-4", "#extrinsic")] + public void Stretch_FillsTheContainingBlock() + { + var root = Layout("
    "); + + Assert.AreEqual(280, Box(root, "b").Size.Width, Delta); + } + + [TestMethod] + [Spec("css-sizing-3", "#behave-auto")] + public void PercentageHeight_AgainstAnIndefiniteHeight_BehavesAsAuto() + { + var root = Layout("
    "); + + Assert.AreEqual(30, Box(root, "b").Size.Height, Delta); + } + + [TestMethod] + [Spec("css-sizing-3", "#stretch-fit-sizing")] + [Spec("css-sizing-4", "#extrinsic")] + public void AutoWidthBlock_IsStretchFit() + { + var root = Layout("
    "); + 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-4", "#intrinsic-sizes")] + [Spec("css-sizing-3", "#intrinsic-contribution")] + [Spec("css-sizing-4", "#intrinsic-contribution")] + public void TableColumns_UseTheMinAndMaxContentOfTheirCells() + { + var root = Layout("" + + "
    "); + + 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")] + [Spec("css-sizing-4", "#intrinsic-sizes")] + public void NarrowTable_ShrinksColumnsToTheirMinContent() + { + var root = Layout("
    aaaa bbbb cccc
    "); + 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")] + [Spec("css-sizing-4", "#intrinsic-contribution")] + public void CyclicPercentageWidth_IsAutoForTheContribution_ThenResolves() + { + var root = Layout("
    "); + + 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("" + body + "", maxWidth: 400).Root; + + private static CssBox Box(CssBox root, string id) => LayoutHarness.FindById(root, id)!; +} 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().Any(r => r.Color == color) + || g.Log.OfType().Any(l => l.Color == color) + || g.Log.OfType().Any(p => p.Color == color); + + [TestMethod] + [Spec("css-tables-3", "#fixup-boxes")] + public void AnonymousCell_HasNoPaddingBorderOrBackground() + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( + "
    text
    ")); + + 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( + "
    ")); + + 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( + "
    " + + "
    a
    " + + "
    b
    c
    " + + "
    ")); + + 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( + "" + + "" + + "
    a
    b
    ")); + + 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( + "" + + "" + + "
    ab
    c
    ")); + + 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( + "
    " + + "
    a
    b
    ")); + + 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( + "
    ab
    ")); + + 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( + "
    a
    ")); + + 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( + "
    a
    ")); + + 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( + "
    ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsTrue(g.Log.OfType().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( + "
    ab
    ")); + + 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( + "
    " + + "
    a
    ")); + + 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( + "
    " + + "
    a
    ")); + + 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( + "" + + "" + + "
    xxxxxxxxxxxxxxxxxxxxx
    y
    ")); + + 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( + "
    some text that is a lot wider than two hundred pixels when it is not wrapped
    ")); + + 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( + "
    a
    ")); + + 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( + "" + + "
    abc
    ")); + + 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( + "" + + "
    xxxxxxxxxxxxxxxxxxxxx
    ")); + + 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( + "" + + "
    a
    b
    ")); + + 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( + "" + + "
    a
    b
    ")); + + 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( + "
    x
    ")); + + 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( + "
    " + + "
    " + + "
    ")); + + 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( + "
    " + + "
    ")); + + 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( + "" + + "
    a" + + "
    ")); + + 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( + "" + + "
    a" + + "
    ")); + + 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( + "" + + "
    a" + + "
    ")); + + 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( + "" + + "
    a" + + "
    ")); + + 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( + $"{group}" + + string.Concat(Enumerable.Range(1, 30).Select(i => $"")) + + "
    row{i}
    "); + + [TestMethod] + [Spec("css-tables-3", "#repeated-headers")] + public void Header_IsPaintedAgainOnTheNextPage() + { + var (_, container) = PaintHarness.LayoutPaginated(LongTable("Head"), 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("Foot"), 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( + "" + + "
    ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + var rects = g.Log.OfType().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( + "
    ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "t")!); + + Assert.IsTrue(g.Log.OfType().Any(r => r.Color == Red)); + } + + [TestMethod] + [Spec("css-tables-3", "#drawing-cell-borders")] + public void CellBorder_IsPaintedInSeparatedMode() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
    ")); + + 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( + "
    ahidden
    ")); + + 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/CssModules/CssTransformsTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs new file mode 100644 index 000000000..144b88b7e --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/CssModules/CssTransformsTests.cs @@ -0,0 +1,121 @@ +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("
    "); + + 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("
    "); + + 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("
    " + + "
    "); + + 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("
    " + + "
    "); + + 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); + } + + [Ignore("Gap, see Spec/css-transforms-2.yml")] + [TestMethod] + [Spec("css-transforms-2", "#individual-transforms")] + public void TranslateProperty_MovesThePaintedBox() + { + var rect = RedRect("
    "); + + 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) + { + 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)"); + } + + [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($"
    " + + "
    "); + + 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().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.IntegrationTest/FixedPositionRepeatsPerPageTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/FixedPositionRepeatsPerPageTest.cs index 1b6734598..bba3ce94b 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,9 @@ 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(); @@ -75,12 +66,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/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..dd1654529 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,12 @@ 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("css-break-3", "#break-propagation")] + [Spec("css-break-4", "#break-propagation")] public async Task ForcedBreakBeforeAFirstChild_MovesTheContainer() { var (root, container) = await BuildAsync( @@ -116,8 +84,10 @@ 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")] + [Spec("css-break-3", "#break-propagation")] + [Spec("css-break-4", "#break-propagation")] public async Task ForcedBreakBeforeANestedFirstChild_MovesTheOutermostContainerItBegins() { var (root, container) = await BuildAsync( @@ -132,9 +102,10 @@ 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")] + [Spec("css-break-3", "#forced-breaks")] + [Spec("css-break-4", "#forced-breaks")] public async Task ForcedBreakBeforeALaterChild_LeavesTheContainerWhereItIs() { var (root, container) = await BuildAsync( @@ -152,12 +123,9 @@ 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. [TestMethod] + [Spec("css-break-3", "#break-propagation")] + [Spec("css-break-4", "#break-propagation")] public async Task ForcedBreakBeforeTheFirstBoxInTheFlow_ManufacturesNoBlankPage() { var (root, container) = await BuildAsync( @@ -175,11 +143,10 @@ 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")] + [Spec("css-break-3", "#break-propagation")] + [Spec("css-break-4", "#break-propagation")] public async Task BreakAfterOnALastChild_ForcesTheBreakBeforeTheFollowingSibling() { var (root, container) = await BuildAsync( @@ -194,12 +161,12 @@ 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")] + [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/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 => $"Row {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 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..e22cc8600 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,15 +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}"); } - // 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. [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")); @@ -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,10 @@ 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("Gap, see Spec/css-break-3.yml")] + [Spec("css-break-3", "#break-margins")] public async Task FirstBoxInTheFlow_CarryingAnUntakenForcedBreak_KeepsItsMargin() { var (root, _) = await BuildAsync( @@ -431,15 +369,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 +379,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..d1b08de71 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Fragmentation/PagedMediaIntegrationTests.cs @@ -0,0 +1,477 @@ +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")] + [Spec("css-page-3", "#page-model")] + 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")] + [Spec("css-page-3", "#page-model")] + 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")] + [Spec("css-page-3", "#content-outside-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")] + [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] + 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")] + [Spec("css-break-3", "#page-break-properties")] + [Spec("css-break-4", "#page-break-properties")] + 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")] + [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] + [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")] + [Spec("css-break-3", "#forced-breaks")] + [Spec("css-break-4", "#forced-breaks")] + [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")] + [Spec("css-break-3", "#break-within")] + [Spec("css-break-4", "#break-within")] + 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")] + [Spec("css-break-3", "#break-within")] + [Spec("css-break-4", "#break-within")] + 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")] + [Spec("css-break-3", "#break-decoration")] + [Spec("css-break-4", "#break-decoration")] + 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")] + [Spec("css-break-3", "#widows-orphans")] + [Spec("css-break-4", "#widows-orphans")] + 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")] + [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) + { + 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")] + [Spec("css-break-3", "#widows-orphans")] + [Spec("css-break-4", "#widows-orphans")] + 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")] + [Spec("css-break-3", "#widows-orphans")] + [Spec("css-break-4", "#widows-orphans")] + 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")] + [Spec("css-break-3", "#break-margins")] + [Spec("css-break-4", "#break-margins")] + 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")] + [Spec("css-break-3", "#possible-breaks")] + [Spec("css-break-4", "#possible-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")] + [Spec("css-break-3", "#possible-breaks")] + [Spec("css-break-4", "#possible-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")] + [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) + { + 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")] + [Spec("css-break-3", "#unforced-breaks")] + [Spec("css-break-4", "#unforced-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")] + [Spec("css-break-3", "#unforced-breaks")] + [Spec("css-break-4", "#unforced-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")] + [Spec("css-break-3", "#unforced-breaks")] + [Spec("css-break-4", "#unforced-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")] + [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. + 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")] + [Spec("css-break-3", "#break-margins")] + [Spec("css-break-4", "#break-margins")] + 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")] + [Spec("css-break-3", "#break-margins")] + [Spec("css-break-4", "#break-margins")] + 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")] + [Spec("css-break-3", "#forced-breaks")] + [Spec("css-break-4", "#forced-breaks")] + [Spec("css-page-3", "#page-breaks")] + 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")] + [Spec("css-break-3", "#possible-breaks")] + [Spec("css-break-4", "#possible-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")] + [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 })] + [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")] + [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 })] + 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..0b1d587e2 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,10 @@ 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("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}
    ")); @@ -242,11 +206,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/HtmlRenderingRegressionTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/HtmlRenderingRegressionTests.cs index 05d58bd36..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; @@ -23,6 +25,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(); @@ -35,7 +38,6 @@ public async Task Render_ConsecutiveBreaks_LeaveAnEmptyLine() $"Consecutive
    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/Layout/BoxGenerationIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs new file mode 100644 index 000000000..37b024781 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Layout/BoxGenerationIntegrationTests.cs @@ -0,0 +1,281 @@ +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")] + [Spec("css-display-3", "#legacy-display")] + [Spec("css-display-4", "#legacy-display")] + 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")] + [Spec("css-display-3", "#box-generation")] + [Spec("css-display-4", "#box-generation")] + 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")] + [Spec("css-display-3", "#legacy-display")] + [Spec("css-display-4", "#legacy-display")] + 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")] + [Spec("css-display-3", "#transformations")] + [Spec("css-display-4", "#transformations")] + 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")] + [Spec("css-display-3", "#transformations")] + [Spec("css-display-4", "#transformations")] + [Spec("css-position-3", "#position-property")] + 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")] + [Spec("css-position-3", "#position-property")] + 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/GlobalKeywordCascadeTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Layout/GlobalKeywordCascadeTests.cs index 923939a37..586abadff 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,8 @@ public sealed class GlobalKeywordCascadeTests // ── inherit ──────────────────────────────────────────────────────────── [TestMethod] + [Spec("css21", "cascade.html#value-def-inherit")] + [Spec("css-cascade-5", "#inherit")] public void Inherit_ColorFromParent_ChildGetsParentColor() { var html = LayoutHarness.Wrap( @@ -28,9 +22,10 @@ public void Inherit_ColorFromParent_ChildGetsParentColor() } [TestMethod] + [Spec("css21", "cascade.html#value-def-inherit")] + [Spec("css-cascade-5", "#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 +35,8 @@ 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("
    text
    "); @@ -52,6 +49,7 @@ public void Inherit_ColorWithoutExplicitParent_UsesInitialBlack() // ── initial ─────────────────────────────────────────────────────────── [TestMethod] + [Spec("css-cascade-5", "#initial")] public void Initial_Color_ResetsToBlackIgnoringParent() { var html = LayoutHarness.Wrap( @@ -63,6 +61,7 @@ public void Initial_Color_ResetsToBlackIgnoringParent() } [TestMethod] + [Spec("css-cascade-5", "#initial")] public void Initial_FontSize_ResetsToMedium() { var html = LayoutHarness.Wrap("

    heading

    "); @@ -73,6 +72,7 @@ public void Initial_FontSize_ResetsToMedium() } [TestMethod] + [Spec("css-cascade-5", "#initial")] public void Initial_MarginTop_ResetsToZero() { var html = LayoutHarness.Wrap( @@ -86,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( @@ -97,6 +98,7 @@ public void Unset_InheritedProperty_BehavesLikeInherit() } [TestMethod] + [Spec("css-cascade-5", "#inherit-initial")] public void Unset_NonInheritedProperty_BehavesLikeInitial() { var html = LayoutHarness.Wrap( @@ -108,6 +110,7 @@ public void Unset_NonInheritedProperty_BehavesLikeInitial() } [TestMethod] + [Spec("css-cascade-5", "#inherit-initial")] public void Unset_InheritedPropertyWithNoParentValue_UsesInitial() { var html = LayoutHarness.Wrap("
    text
    "); @@ -117,9 +120,11 @@ public void Unset_InheritedPropertyWithNoParentValue_UsesInitial() Assert.AreEqual("black", el.Color); } - // ── cascade order regressions ──────────────────────────────────────── + // ── cascade order ──────────────────────────────────────────────────── [TestMethod] + [Spec("css21", "cascade.html#inheritance")] + [Spec("css-cascade-5", "#inheriting")] public void Regression_ColorInheritsFromParentWithoutKeyword() { var html = LayoutHarness.Wrap( @@ -131,6 +136,8 @@ public void Regression_ColorInheritsFromParentWithoutKeyword() } [TestMethod] + [Spec("css21", "cascade.html#specificity")] + [Spec("css-cascade-5", "#cascade-sort")] public void Regression_InlineStyleOverridesAuthorRule() { var html = LayoutHarness.Wrap( @@ -142,6 +149,8 @@ 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("

    heading

    "); @@ -152,6 +161,8 @@ public void Regression_AuthorRuleOverridesUaDefault() } [TestMethod] + [Spec("css21", "cascade.html#important-rules")] + [Spec("css-cascade-5", "#importance")] public void Regression_ImportantAuthorRuleBeatsInlineStyle() { var html = LayoutHarness.Wrap( @@ -163,6 +174,8 @@ 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 +187,8 @@ 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("
    text
    "); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Layout/NormalFlowIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Layout/NormalFlowIntegrationTests.cs new file mode 100644 index 000000000..ea968bf32 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Layout/NormalFlowIntegrationTests.cs @@ -0,0 +1,166 @@ +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")] + [Spec("css-inline-3", "#line-boxes")] + 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")] + [Spec("css-inline-3", "#line-boxes")] + 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")] + [Spec("css-inline-3", "#line-boxes")] + 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")] + [Spec("css-inline-3", "#line-boxes")] + 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")] + [Spec("css-inline-3", "#invisible-line-boxes")] + 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/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/BackgroundPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs index 07d5c5784..22a7a4259 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs @@ -1,19 +1,26 @@ 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")] + [Spec("css-backgrounds-3", "#background-color")] + [Spec("css-backgrounds-4", "#background-color")] public void BackgroundColor_PaintsASolidFillRect() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -30,6 +37,8 @@ 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( @@ -42,10 +51,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")!; @@ -56,9 +64,10 @@ public void BackgroundColorDefault_PaintsNothing() } [TestMethod] + [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#painting-order")] 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 +83,278 @@ 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")] + [Spec("css-backgrounds-3", "#root-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")] + [Spec("css-backgrounds-3", "#body-background")] + [Spec("css-backgrounds-4", "#body-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")] + [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"); + + 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")] + [Spec("css-backgrounds-3", "#background-image")] + 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")] + [Spec("css-backgrounds-3", "#background-repeat")] + 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")] + [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}"); + + // 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")] + [Spec("css-backgrounds-3", "#background-repeat")] + 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")] + [Spec("css-values-4", "#position")] + [Spec("css-backgrounds-3", "#background-position")] + [Spec("css-backgrounds-4", "#background-position")] + [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}"); + + var image = g.DrawImageCalls.Single(); + Assert.AreEqual(x, image.DestRect.X, 0.5); + 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)] + [DataRow("100% 100%", 60, 70)] + [DataRow("25% 50%", 15, 35)] + [DataRow("10% 90%", 6, 63)] + [Spec("css21", "colors.html#background-properties")] + [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}"); + + 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")] + [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}"); + + 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")] + [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"); + + 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")] + [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"); + + var image = g.DrawImageCalls.Single(); + Assert.AreEqual(0, image.DestRect.Y, 0.5); + } + + [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"); + + 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")] + [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)"); + + 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/BorderRadiusIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BorderRadiusIntegrationTests.cs index f6905c472..741dc5d9e 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; -/// -/// Ported from PeachPDF.Tests/Integration/BorderRadiusIntegrationTests.cs. -/// Standard CSS border-radius (shorthand and all four longhands) used to be entirely unrecognized -/// by this fork's Core, which instead had a proprietary corner-radius/corner-{nw,ne,se,sw}-radius -/// mechanism (backed by CssBoxProperties.CornerRadius/ActualCornerNw/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 (BorderTopLeftRadius/BorderTopRightRadius/ -/// BorderBottomRightRadius/BorderBottomLeftRadius), each carrying independent X/Y radii -/// (elliptical corners, via a "h v" pair per longhand, or "/" in the shorthand); -/// - the border-radius 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 (ActualBorderTopLeftRadiusX resolves a percentage -/// against Size.Width, ...RadiusY against Size.Height - not hardcoded to 0); and -/// - overlap reduction via ComputeRadii, 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 [Ignore]d because border-radius -/// didn't exist at all - now genuinely passes against the real properties -/// (ActualBorderTopLeftRadiusX/Y etc., IsRounded, ComputeRadii), 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 -/// corner-radius/corner-*-radius syntax and its backing ActualCornerNw/Ne/Se/Sw/ -/// CornerRadius members have been removed from Core entirely, so the "supplementary" tests this -/// file used to carry for them are gone too. -/// [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,8 @@ 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"); @@ -77,6 +55,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 +69,7 @@ public void BorderTopLeftRadius_Longhand_SetsOnlyTopLeft() } [TestMethod] + [Spec("css-backgrounds-3", "#border-radius")] public void BorderRadius_Zero_IsNotRounded() { var box = FindDivBox(""); @@ -99,6 +79,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 +96,8 @@ 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 @@ -128,6 +111,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 +124,8 @@ public void BorderRadius_Percentage_ResolvesRelativeToDimensions() // ── Overlapping radii reduction ───────────────────────────────────────────────────────────────────── [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 @@ -157,6 +143,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 f855215f6..ded88db60 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,31 @@ 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")] + [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( + $"
    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")] + [Spec("css-backgrounds-3", "#border-style")] public void BorderStyleDouble_DrawsTwoEqualWidthStripesWithGap() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -78,10 +62,10 @@ 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")] + [Spec("css-backgrounds-3", "#border-style")] public void BorderStyleGroove_OuterStripeIsDarker_InnerStripeIsBaseColor() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -96,9 +80,10 @@ 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")] + [Spec("css-backgrounds-3", "#border-style")] public void BorderRightStyleDouble_DrawsTwoEqualWidthVerticalStripesWithGap() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -113,9 +98,10 @@ 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")] + [Spec("css-backgrounds-3", "#border-style")] public void BorderLeftStyleGroove_OuterStripeIsDarker_InnerStripeIsBaseColor() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -131,23 +117,14 @@ public void BorderLeftStyleGroove_OuterStripeIsDarker_InnerStripeIsBaseColor() } [TestMethod] + [Spec("css21", "box.html#border-color-properties")] + [Spec("css-backgrounds-3", "#border-color")] 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 +142,16 @@ 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, see Spec/css-color-4.yml")] [TestMethod] - public void BorderColorPerSide_CurrentColor_DoesNotResolveToElementColor() + [Spec("css-color-4", "#currentcolor-color")] + 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 +197,14 @@ 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")] + [Spec("css-backgrounds-3", "#border-style")] public void BorderStyleTwoValueShorthand_OnlyPaintsTheSolidSides() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -253,9 +218,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 +225,14 @@ 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( "
    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 +240,10 @@ public void BorderColorFourValueShorthand_ResolvesTopRightBottomLeftPerSide() } [TestMethod] + [Spec("css21", "box.html#border-width-properties")] + [Spec("css-backgrounds-3", "#border-width")] 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/Source/Test/HtmlRenderer.IntegrationTest/Painting/BoxDecorationBreakIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BoxDecorationBreakIntegrationTests.cs new file mode 100644 index 000000000..141179a8b --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BoxDecorationBreakIntegrationTests.cs @@ -0,0 +1,50 @@ +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() + .Count(l => l.Color == BorderColor && Math.Abs(l.X1 - l.X2) < 0.01); + + private static int HorizontalEdges(RecordingGraphics recording) => + recording.Log.OfType() + .Count(l => l.Color == BorderColor && Math.Abs(l.Y1 - l.Y2) < 0.01); + + [TestMethod] + [Spec("css-break-3", "#break-decoration")] + [Spec("css-break-4", "#break-decoration")] + public void Slice_InlineBrokenAcrossLines_HasNoBorderAtTheBrokenEdges() + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
    first
    second
    third
    ")); + + 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")] + [Spec("css-break-4", "#break-decoration")] + public void Clone_InlineBrokenAcrossLines_HasBordersOnEveryFragment() + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
    first
    second
    third
    ")); + + var recording = PaintHarness.PaintPage(container, 0); + + Assert.AreEqual(6, VerticalEdges(recording)); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/ColorFunctionPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/ColorFunctionPaintIntegrationTests.cs new file mode 100644 index 000000000..6550072ad --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/ColorFunctionPaintIntegrationTests.cs @@ -0,0 +1,41 @@ +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)); + } + + [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( + $"
    x
    ")); + + var graphics = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + + Assert.AreEqual(expected, graphics.DrawStringCalls.Single().Color); + Assert.AreEqual(expected, graphics.Log.OfType().Single().Color); + } +} 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( + "
    ", + 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.IntegrationTest/Painting/CssBackgrounds3Tests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds3Tests.cs new file mode 100644 index 000000000..fc782fc5a --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssBackgrounds3Tests.cs @@ -0,0 +1,451 @@ +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")] + [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"); + + 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().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().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().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().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")] + [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"); + + 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")] + [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}"); + + var fill = g.Log.OfType().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().Any(r => r.Color == Fill)); + Assert.AreEqual(1, g.DrawImageCalls.Count); + } + + [TestMethod] + [Spec("css-backgrounds-3", "#root-background")] + [Spec("css-backgrounds-4", "#root-background")] + public void RootBackground_IsPainted() + { + var (_, container) = PaintHarness.Layout( + "
    ", + 500, 300); + + var g = PaintHarness.PaintPage(container); + + Assert.AreEqual(1, g.Log.OfType().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")] + [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"); + + 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")] + [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"); + + var path = g.Paths.Single(p => p.Brush == Fill); + Assert.AreEqual(4, path.Arcs.Count); + Assert.IsFalse(g.Log.OfType().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().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( + "
    " + + "
    ")); + + var g = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + + Assert.IsFalse(g.Log.OfType().Any(r => r.Color == Fill), + "the child's background is only clipped to the rectangular padding box"); + } + + [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"); + + var top = g.Log.OfType().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().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")] + [Spec("css-borders-4", "#border-radius-tables")] + public void BorderRadius_RoundsASeparatedBordersTable() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
    x
    ")); + 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( + "
    x
    ")); + 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")] + [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"); + + Assert.IsFalse(g.Log.OfType().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")] + [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"); + + 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); + } + + /// Lays out a div with at the origin, loading images synchronously. + 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($"
    ")).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 Points { get; } = []; + public List 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() { } + } + + /// Also records the paths, which drops. + internal sealed class ShapeRecordingGraphics : RecordingGraphics + { + public List 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.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().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($"
    ")).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/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("
    x
    "); + + 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("
    x
    "); + + 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.IntegrationTest/Painting/CssImages3Tests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssImages3Tests.cs new file mode 100644 index 000000000..2a391e0a1 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssImages3Tests.cs @@ -0,0 +1,283 @@ +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")] + [Spec("css-images-4", "#image-values")] + [Spec("css-images-4", "#url-notation")] + [Spec("css-images-4", "#sizing")] + public void UrlImage_IsDrawnAtItsNaturalSize() + { + var (g, _) = Paint($"
    "); + + Assert.AreEqual(new RRect(0, 0, 40, 30), g.DrawImageCalls.Single().DestRect); + } + + [TestMethod] + [Spec("css-images-3", "#default-sizing")] + [Spec("css-images-4", "#sizing")] + public void ImageWithOnlyAWidth_KeepsItsNaturalAspectRatio() + { + var root = TestImages.Layout($""); + + 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("
    "); + + 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")] + [Spec("css-images-4", "#linear-gradients")] + public void LinearGradient_DirectionSetsTheGradientLine(string image, double x1, double y1, double x2, double y2) + { + var (_, gradient) = Paint($"
    "); + + 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("
    "); + + 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("
    "); + + // 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(-30, gradient.P1.Y, 0.5); + Assert.AreEqual(140, gradient.P2.X, 0.5); + Assert.AreEqual(130, gradient.P2.Y, 0.5); + } + + [TestMethod] + [Spec("css-images-3", "#radial-gradient-syntax")] + [Spec("css-images-4", "#radial-gradients")] + public void RadialGradient_IsAccepted() + { + var (root, _) = PaintHarness.Layout(PaintHarness.Wrap("
    ")); + + 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")] + [Spec("css-images-4", "#radial-gradients")] + public void RadialGradient_IsPainted(string image) + { + var (g, _) = Paint($"
    "); + + Assert.IsTrue(g.Log.OfType().Any()); + } + + [TestMethod] + [Spec("css-images-3", "#repeating-gradients")] + [Spec("css-images-4", "#repeating-gradients")] + public void RepeatingLinearGradient_RepeatsTheStopsAlongTheLine() + { + var (_, gradient) = Paint("
    "); + + 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")] + [Spec("css-images-4", "#color-stop-syntax")] + public void ColorStops_TakeLengthsPercentagesAndTwoPositions() + { + var (_, gradient) = Paint("
    "); + + 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("
    "); + + 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")] + [Spec("css-images-4", "#coloring-gradient-line")] + public void ColorHint_ShiftsTheMidpointOfTheTransition() + { + var (_, gradient) = Paint("
    "); + + // 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")] + [Spec("css-images-4", "#coloring-gradient-line")] + public void ColorsAreInterpolatedPremultiplied() + { + var (_, gradient) = Paint("
    "); + + // 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")] + [Spec("css-images-4", "#color-stop-fixup")] + public void ColorStopFixup_DefaultsTheEndsAndSpacesStopsEvenly() + { + var (_, gradient) = Paint("
    "); + + 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")] + [Spec("css-images-4", "#color-stop-fixup")] + public void ColorStopFixup_ClampsAStopBeforeThePreviousOne() + { + var (_, gradient) = Paint("
    "); + + 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")] + [Spec("css-images-4", "#the-object-fit")] + public void ObjectFit_SizesTheImageInTheContentBox(string fit, double x, double y, double width, double height) + { + var (g, _) = Paint($""); + + 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($""); + + var image = g.DrawImageCalls.Single().DestRect; + Assert.AreEqual(new RRect(50, 0, 40, 30), image); + } + + /// Lays out and paints the element with id "b", returns the draw calls and the last gradient brush. + 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()); + } + + /// Like , but remembers the linear gradient brushes it creates. + internal sealed class GradientAdapter : RAdapter + { + internal sealed record Gradient(RPoint P1, RPoint P2, (RColor Color, double Position)[] Stops); + + public List 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.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( + $"", + 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($""); + + 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($"
    "); + + 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("
    " + + "
    "); + + Assert.IsTrue(g.Log.OfType().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($"
    "); + + Assert.IsTrue(g.Log.OfType().Any()); + } + + [TestMethod] + [Spec("css-images-4", "#linear-gradients")] + public void LinearGradient_InterpolationMethodAfterTheDirectionKeepsTheDirection() + { + var (_, gradient) = Paint("
    "); + + 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($"
    "); + + 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("
    "); + + // 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("
    "); + + Assert.IsTrue(g.Log.OfType().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)); + } + + /// Lays out and paints the element with id "b", returns the draw calls and the last gradient brush. + 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.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( + "
    "), 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); + } + + /// 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 { 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.IntegrationTest/Painting/CssTextDecor3PaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssTextDecor3PaintIntegrationTests.cs new file mode 100644 index 000000000..b45ca1e9d --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssTextDecor3PaintIntegrationTests.cs @@ -0,0 +1,108 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.Painting; + +/// CSS Text Decoration Level 3 line styles and colors, text shadows and the painting order. +[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")] + [Spec("css-text-decor-4", "#text-decoration-property")] + public void Shorthand_WithStyleAndColor_SetsTheLine() + { + var lines = Log("text").OfType(); + + Assert.AreEqual(1, lines.Count()); + } + + [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("text").OfType().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")] + [Spec("css-text-decor-4", "#text-decoration-color-property")] + public void DecorationColor_IsTheColorOfTheLine() + { + var line = Log("text").OfType().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")] + [Spec("css-text-decor-4", "#text-decoration-style-property")] + public void Double_DrawsTwoLines() + { + var lines = Log("text").OfType(); + + Assert.AreEqual(2, lines.Count()); + } + + [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("text").OfType(); + + 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")] + [Spec("css-text-decor-4", "#painting-order")] + [DataRow("underline")] + [DataRow("overline")] + public void UnderlineAndOverline_ArePaintedBelowTheText(string line) + { + var log = Log($"text"); + + Assert.IsLessThan(IndexOf(log), IndexOf(log)); + } + + [TestMethod] + [Spec("css-text-decor-3", "#painting-order")] + [Spec("css-text-decor-4", "#painting-order")] + public void LineThrough_IsPaintedOverTheText() + { + var log = Log("text"); + + Assert.IsGreaterThan(IndexOf(log), IndexOf(log)); + } + + [TestMethod] + [Spec("css-text-decor-3", "#overflow")] + [Spec("css-text-decor-4", "#overflow")] + public void Decorations_DontChangeTheLayout() + { + var (plain, _) = PaintHarness.Layout(PaintHarness.Wrap("
    text
    ")); + var (decorated, _) = PaintHarness.Layout(PaintHarness.Wrap("
    text
    ")); + + Assert.AreEqual(PaintHarness.FindById(plain, "d")!.Size.Height, PaintHarness.FindById(decorated, "d")!.Size.Height, 0.01); + } + + private static List Log(string body) + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap(body)); + return PaintHarness.PaintPage(container).Log.Cast().ToList(); + } + + private static int IndexOf(List log) => log.FindIndex(call => call is T); +} 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($"
    a B c
    "); + + 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("
    aaa bbb ccc
    ")] + [DataRow("aaa bbb ccc")] + 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("
    aaa bbb ccc
    ")); + } + + private static void AssertContinuous(List 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 PageLines(string bodyHtml) + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap(bodyHtml)); + return PaintHarness.PaintPage(container).Log.OfType().ToList(); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssUi3Tests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssUi3Tests.cs new file mode 100644 index 000000000..28aafdcc6 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/CssUi3Tests.cs @@ -0,0 +1,91 @@ +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($"
    "); + + 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("
    "); + + Assert.AreEqual(30, box.Size.Width, 0.5); + Assert.AreEqual(30, box.Size.Height, 0.5); + } + + [TestMethod] + [Spec("css-ui-3", "#outline-width")] + [Spec("css-ui-4", "#outline-width")] + public void OutlineWidth_TakesTheBorderWidthKeywords() + { + var g = Paint("
    x
    "); + + var top = g.Log.OfType().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")] + [Spec("css-ui-4", "#outline-width")] + public void OutlineWidth_InitialValueIsMedium() + { + var g = Paint("
    x
    "); + + var top = g.Log.OfType().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")] + [Spec("css-ui-4", "#outline")] + public void Outline_FollowsTheBorderRadius() + { + var g = Paint("
    x
    "); + + Assert.IsFalse(g.Log.OfType().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("
    a very long line of text
    "); + + 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/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( + "
    "), 400, 400); + var g = PaintHarness.PaintPage(container); + + Assert.IsFalse(g.Log.OfType().Any(r => r.Color == RColor.FromArgb(255, 0, 0))); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/ForegroundColorPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/ForegroundColorPaintIntegrationTests.cs new file mode 100644 index 000000000..f9c83b2ca --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/ForegroundColorPaintIntegrationTests.cs @@ -0,0 +1,49 @@ +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")] + [Spec("css-color-4", "#the-color-property")] + 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")] + [Spec("css-color-4", "#the-color-property")] + 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.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; /// -/// Ported from PeachPDF.Tests/Integration/FragmentContentPainterTests.cs: the per-box-type paint dispatch - -/// FragmentContentPainters.For picks the painter, and the ones the other paint suites don't already -/// drive (<iframe>, <hr>) draw what they should. +/// FragmentContentPainters.For picks the painter per box type, and the ones the other paint suites +/// don't already drive (<iframe>, <hr>) draw what they should. /// -/// -/// FragmentContentPainters.For's switch (Core/Paint/Content/FragmentContentPainters.cs) lists -/// only 3 cases - , -/// , -/// - confirmed by direct source read, HTML-Renderer -/// has no distinct box type for <object> or inline <svg> at all (no -/// CssBoxObject/CssBoxSvg anywhere in Core/Dom/), so PeachPDF's theory rows for those two -/// element types are dropped. -/// -/// Iframe_PaintsItsOwnBoxOnly_WithNoEmbeddedContent ports for a different underlying reason than in -/// PeachPDF: this fork's CssBoxFrame is not a stub - it can render a YouTube/Vimeo video thumbnail/ -/// title/play button for a matching src (see CssBoxFrame's own doc comment). An ordinary -/// (non-video) <iframe> with no matching src still draws nothing beyond its own -/// background/border, though: _isVideo is false, so DrawImage/DrawTitle/DrawPlay -/// all no-op (confirmed by reading CssBoxFrame.DrawFrameContent and its three private helpers in -/// full), which happens to match PeachPDF's own "no embedded content" expectation for this fixture. -/// -/// [TestClass] [DoNotParallelize] public sealed class FragmentContentPainterTests diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs index b85a668b5..a182e81b6 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/FragmentPaintIntegrationTests.cs @@ -10,35 +10,17 @@ 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 { [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( @@ -74,8 +56,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)); @@ -99,8 +80,6 @@ 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). Assert.IsTrue(recording.Log.OfType() .Any(r => r.Color == RColorOf(10, 20, 30))); } @@ -133,16 +112,10 @@ 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")] + [Spec("css-position-4", "#painting-order")] public void StackingOrder_IsPreservedWhenPaintingFromFragments() { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -175,10 +148,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 +170,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/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/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) }", + "
    x
    "); + + 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("", "
    x
    "); + var reference = PaintHarness.PaintBox(container, PaintHarness.FindById(root, "b")!); + var highlight = Rects(reference).Single().Color; + var highlightText = reference.DrawStringCalls.Single().Color; + + var g = PaintSelected("", "
    x
    "); + + 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) }", + "
    x
    "); + 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) }", "
    x
    "); + + 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) }", + "

    x

    y

    "); + + 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) }", + "
    x
    "); + + var lines = g.Log.OfType().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) }", + "
    x
    "); + + Assert.IsTrue(g.Log.OfType().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) }", + $"
    ", 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($"{body}"); + 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 Rects(RecordingGraphics g) => + g.Log.OfType().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.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs index 45db4d71b..e61d5240e 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs @@ -4,18 +4,14 @@ 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")] + [Spec("css-ui-3", "#outline")] + [Spec("css-ui-4", "#outline")] public void SolidOutline_PaintsAFourSidedRingAroundTheBorderBox() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -28,20 +24,15 @@ 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("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( @@ -56,6 +47,9 @@ 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( @@ -70,10 +64,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 +83,9 @@ public void Outline_PaintsAfterBorder() [TestMethod] [DataRow("none")] [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( @@ -102,6 +98,9 @@ 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( @@ -113,11 +112,10 @@ 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("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( @@ -132,6 +130,7 @@ public void OutlineOffset_PushesTheRingFurtherFromTheBorderBox() } [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void OutlineColorInvert_FallsBackToCurrentColor() { var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -147,6 +146,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 +171,84 @@ 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")] + [Spec("css-ui-3", "#outline-props")] + [Spec("css-ui-4", "#outline-props")] + 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")] + [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( + $"
    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")] + [Spec("css-ui-3", "#outline-props")] + [Spec("css-ui-4", "#outline")] + 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")] + [Spec("css-ui-3", "#outline-props")] + [Spec("css-ui-4", "#outline")] + 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/OverflowClipIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OverflowClipIntegrationTests.cs index 557d9ed27..452999c4c 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,10 @@ 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")] + [Spec("css-overflow-3", "#overflow-control")] public void OverflowHidden_WithPadding_PaintClipReachesThePaddingBoxEdge() { var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( @@ -158,12 +123,194 @@ 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")] + [Spec("css-overflow-3", "#overflow-control")] + 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")] + [Spec("css-overflow-3", "#overflow-control")] + 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")] + [Spec("css-overflow-3", "#overflow-control")] + 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")] + [Spec("css-overflow-3", "#overflow-control")] + 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")] + [Spec("css-overflow-3", "#overflow-control")] + 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")] + [Spec("css-overflow-3", "#overflow-control")] + [Spec("css-overflow-3", "#scrolling")] + 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")] + [Spec("css-overflow-3", "#overflow-control")] + 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")] + [Spec("css-overflow-3", "#overflow-control")] + 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")] + [Spec("css-overflow-3", "#overflow-control")] + 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")] + [Spec("css-overflow-3", "#overflow-propagation")] + 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/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( + $"{body}", + pageWidth: 400, pageHeight: PageHeight, margin: Margin).Container; + + private static RecordingGraphics.DrawRectCall[] CanvasRects(HtmlContainerInt container, int page) => + PaintHarness.PaintPage(container, page).Log.OfType().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) }", "
    "); + + 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) }", "
    "); + + 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) }", "
    "); + + Assert.IsTrue(CanvasRects(container, 0).Length > 0); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageOriginPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageOriginPaintIntegrationTests.cs index 9a96461e9..6548f727f 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageOriginPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/PageOriginPaintIntegrationTests.cs @@ -11,10 +11,13 @@ 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. /// +// Parallel full layout passes race on the adapter singletons. [TestClass] +[DoNotParallelize] 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/Painting/StackingContextTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs index 1e5b377d8..ef2fb9ec5 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs @@ -1,39 +1,196 @@ using System.Linq; using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; 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. +/// Painting order within a stacking context, checked by the order of the recorded draw calls. /// [DoNotParallelize] [TestClass] public sealed class StackingContextTests { - [Ignore("z-index and stacking-context paint order aren't implemented yet, boxes paint in document order")] + 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")] + [Spec("css-position-4", "#painting-order")] + public void BlockBackgrounds_PaintInTreeOrder() + { + var g = Paint("
    "); + + Assert.IsLessThan(RectIndex(g, Blue), RectIndex(g, Red)); + } + + [TestMethod] + [Spec("css21", "zindex.html#painting-order")] + [Spec("css-position-4", "#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("css-position-4", "#painting-order")] + [Spec("css21", "visuren.html#z-index")] + [Spec("css-position-3", "#stacking")] + public void AbsoluteBox_PaintsAfterLaterInFlowSiblings() + { + var g = Paint("
    " + + "
    " + + "
    "); + + Assert.IsGreaterThan(RectIndex(g, Blue), RectIndex(g, Red)); + } + + [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("
    " + + "
    "); + + Assert.IsGreaterThan(RectIndex(g, Blue), RectIndex(g, Red)); + } + + [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() { - // 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( - "
    " - + "
    "); + var g = Paint("
    " + + "
    "); + + Assert.IsGreaterThan(RectIndex(g, Red), RectIndex(g, Blue)); + } + + [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() + { + var g = Paint("
    " + + "
    "); + + Assert.IsLessThan(RectIndex(g, Blue), RectIndex(g, Red)); + } - var (_, container) = PaintHarness.Layout(html); - var recorder = PaintHarness.PaintPage(container); + [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() + { + var g = Paint("
    " + + "
    "); - 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); + Assert.IsGreaterThan(RectIndex(g, Blue), RectIndex(g, Red)); + } - 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"); + [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("
    " + + "
    "); + + Assert.IsGreaterThan(RectIndex(g, Blue), RectIndex(g, Red)); + } + + [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("
    " + + "
    "); + + Assert.IsGreaterThan(RectIndex(g, Blue), RectIndex(g, Red)); + } + + [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("
    Text
    " + + "
    "); + + Assert.IsGreaterThan(RectIndex(g, Blue), TextIndex(g, "Text")); + } + + [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("
    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")] + [Spec("css-position-4", "#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/Source/Test/HtmlRenderer.IntegrationTest/Painting/SystemColorPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/SystemColorPaintIntegrationTests.cs new file mode 100644 index 000000000..cb94e650e --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/SystemColorPaintIntegrationTests.cs @@ -0,0 +1,76 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.Painting; + +[DoNotParallelize] +[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("ButtonHighlight")] + [DataRow("ButtonShadow")] + [DataRow("CaptionText")] + [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 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 (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); + } + + private static void AssertIsAValidBackgroundColor(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); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs index 87d12b4b0..2fecb352b 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/TextDecorationPaintIntegrationTests.cs @@ -4,29 +4,15 @@ 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")] + [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( @@ -41,9 +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( @@ -59,6 +49,9 @@ 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( @@ -75,6 +68,9 @@ 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"); @@ -86,6 +82,9 @@ 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( @@ -97,8 +96,122 @@ public void TextDecorationNone_DrawsNoLine() Assert.IsFalse(g.Log.OfType().Any()); } + [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("text"); + + Assert.AreEqual(2, lines.Select(l => l.Y1).Distinct().Count()); + } + + [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("text").Count); + } + + [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("
    text
    ").Count); + } + + [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("a
    b
    c
    ").Count); + } + + [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( + "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")] + [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( + "
    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")] + [Spec("css-text-decor-3", "#line-decoration")] + [Spec("css-text-decor-4", "#line-decoration")] + 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")] + [Spec("css-text-decor-3", "#line-decoration")] + [Spec("css-text-decor-4", "#line-decoration")] + 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")] + [Spec("css-text-decor-3", "#line-decoration")] + [Spec("css-text-decor-4", "#line-decoration")] + 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/Painting/VisibilityPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/VisibilityPaintIntegrationTests.cs new file mode 100644 index 000000000..b5cc3f810 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/VisibilityPaintIntegrationTests.cs @@ -0,0 +1,56 @@ +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")] + [Spec("css-display-3", "#visibility")] + [Spec("css-display-4", "#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")] + [Spec("css-display-3", "#visibility")] + [Spec("css-display-4", "#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")] + [Spec("css-display-3", "#visibility")] + [Spec("css-display-4", "#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/Source/Test/HtmlRenderer.IntegrationTest/Positioning/AbsolutePositioningIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/AbsolutePositioningIntegrationTests.cs index 83235affb..ce883bf19 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,22 @@ public sealed class AbsolutePositioningIntegrationTests private const double Delta = 1.0; [TestMethod] + [Spec("css21", "visuren.html#relative-positioning")] + [Spec("css-position-3", "#relpos-insets")] 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")] + [Spec("css-position-3", "#relpos-insets")] 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 +35,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 +58,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 +73,8 @@ 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( @@ -102,11 +84,12 @@ 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")] + [Spec("css-position-3", "#insets")] public void PositionAbsolute_WithMarginAndBorderedContainingBlock_AppliesBothCorrectly() { var html = LayoutHarness.Wrap( @@ -116,12 +99,13 @@ 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")] + [Spec("css-position-3", "#position-property")] public void PositionFixed_WithMargin_AppliesMarginOnTopOfOffset() { var html = LayoutHarness.Wrap( @@ -134,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( "
    " + "
    " @@ -152,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( @@ -171,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( "
    " + "
    " diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/FixedPositionPaginationIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/FixedPositionPaginationIntegrationTests.cs index 4edae4c5a..4a7f96a88 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,30 +25,20 @@ 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); } - [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( @@ -91,8 +50,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..18d9115bd --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/OutOfFlowPositioningIntegrationTests.cs @@ -0,0 +1,164 @@ +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")] + [Spec("css-position-3", "#position-property")] + 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")] + [Spec("css-position-3", "#def-cb")] + 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", "visudet.html#containing-block-details")] + [Spec("css-position-3", "#def-cb")] + 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")] + [Spec("css-position-3", "#insets")] + 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")] + [Spec("css-position-3", "#insets")] + 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")] + [Spec("css-position-3", "#def-cb")] + 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")] + [Spec("css-position-3", "#insets")] + 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..610ef15fc --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/RelativePositioningIntegrationTests.cs @@ -0,0 +1,170 @@ +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")] + [Spec("css-position-3", "#position-property")] + 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")] + [Spec("css-position-3", "#relpos-insets")] + 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")] + [Spec("css-position-3", "#relpos-insets")] + 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")] + [Spec("css-position-3", "#relpos-insets")] + 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")] + [Spec("css-position-3", "#relpos-insets")] + 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")] + [Spec("css-position-3", "#relpos-insets")] + 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")] + [Spec("css-position-3", "#relpos-insets")] + 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")] + [Spec("css-position-3", "#insets")] + 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")] + [Spec("css-position-3", "#insets")] + 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")] + [Spec("css-position-3", "#insets")] + 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")] + [Spec("css-position-3", "#relpos-insets")] + 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/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; /// -/// Verifies a real, previously-undocumented gap found while auditing this port's fragmentation engine -/// against PeachPDF a second time: CssLayoutEngineTable.LayoutCells's break-inside:avoid -/// row-shift correction did foreach (CssBox cell in row.Boxes) cell.OffsetTop(delta) - but for a -/// row that is the END of a rowspan, row.Boxes holds only the CssSpacingBox placeholder -/// (Display:none, no children/words/rectangles), not the real spanning cell (ExtendedBox). -/// OffsetTop 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 CssSpacingBox placeholder for it. /// [TestClass] [DoNotParallelize] 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/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("
    "); 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/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/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; /// -/// 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 -/// CssBox.PerformLayoutImp/CreateLineBoxes/ApplyLineBreaking 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. /// /// /// Does NOT cover repeated-header behavior for this shape - a row whose own content spans multiple 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.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; /// -/// 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 -/// (css-tables-3 §6.1, current -/// Editor's Draft): "When fragmenting a table, 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. Other rows are said freely -/// fragmentable." This is phrased as a required UA default, not something an author opts into - -/// CssLayoutEngineTable.LayoutCells previously only preserved a row when the TABLE had explicit -/// break-inside:avoid, 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). /// [TestClass] [DoNotParallelize] @@ -40,6 +31,7 @@ private static IEnumerable 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("

    filler line

    ", 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( $""" @@ -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/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/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; /// -/// Ported from PeachPDF.Tests/Integration/BreakValueCascadeTests.cs: the survival of -/// break-before/break-after/break-inside across a STRUCTURAL CLONE of a box - -/// 's everything: true -/// 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. +/// break-before, break-after and break-inside aren't inherited, but a structural clone of a +/// box ( with +/// everything: true) is a fragment of the same element and keeps them, css-break-3 §3. /// /// -/// A real, confirmed production bug was found and fixed while porting this file, not merely documented: -/// CssBoxProperties.InheritStyle's everything: true branch copied every other originating- -/// element property (background, border, position, size, ...) but never _pageBreakInside/ -/// _breakBefore/_breakAfter - confirmed by grep, none of the three appeared anywhere in that -/// method. Both of this method's real everything: true callers are exactly the two structural-clone -/// sites this file is about: TableHeaderRepeat.CloneSubtree (a repeated <thead> row) and -/// DomParser.CorrectBlockSplitBadBox (the block-in-inline split, leftbox/rightBox) - so -/// every clone of either kind silently read auto/auto/auto regardless of what the -/// source element declared, before this fix. Fixed at its source in CssBoxProperties.cs - 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 . -/// -/// Adapted, not renamed, for : PeachPDF's -/// UA stylesheet gives thead an avoiding break-inside unconditionally (its print-scoped rule -/// applies to its own harness's media type), so its fixture can vary break-inside itself as one of -/// the four cases under test and still get a proxy to inspect. This fork's UA default lives under -/// @media print, which 's WinFormsAdapter (media type -/// "screen") never matches - so break-inside:avoid 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 break-before/break-after instead of also varying break-inside itself, which -/// TableRepeatedGroupConditionsTests already covers from the opposite direction (does an EXPLICIT -/// break-inside:auto suppress the repeat at all). -/// +/// break-inside: avoid stays on the thead in every case, the UA rule for it is under +/// @media print and uses screen, without it there's no repeated header. /// [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. /// /// -/// This file predates (#262) the fragmentation-engine-parity branch's own table work, and its original -/// remarks described a mechanism (CssBox.BreakPage(), gated on the table's own explicit -/// page-break-inside:avoid) that no longer exists at all - confirmed by grep, no such method remains -/// anywhere in Core/Dom/CssBox.cs. Revised here to match the CURRENT, confirmed mechanics: since -/// css-tables-3 §6.1 row preservation landed (commit 362dee9), CssLayoutEngineTable.LayoutCells -/// attempts to keep every row unfragmented by default - unconditionally, not gated on the table's own -/// break-inside 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). -/// -/// The important nuance this revision is built around: that default preservation shifts the STRADDLING -/// ROW'S CELLS (cell.OffsetTop(delta)), not the table's own outer box - _tableBox.Location is -/// set once, before CssLayoutEngineTable.PerformLayout even runs, and is never itself touched by the -/// internal row-shift (only ActualBottom grows to cover it). So a straddling single-row table's -/// CONTENT is correctly relocated by default now, but table.Location.Y - 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 BlockFragmentation.RelocateIfNeeded, gated on an -/// EXPLICIT break-inside:avoid declared directly on the <table> (none of this file's fixtures -/// declare one, matching PeachPDF's own fixtures). -/// 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 table.Location.Y) -/// is left as-is, with its Ignore reason corrected to cite the real, current gap where one remains. -/// +/// Keeping a row whole moves its cells, not the table's own box, so table.Location.Y only moves when +/// the table itself is relocated. /// [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 = """ {body}"); + + 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/CssText3IntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/CssText3IntegrationTests.cs new file mode 100644 index 000000000..7709aa9d5 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/CssText3IntegrationTests.cs @@ -0,0 +1,143 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.Text; + +/// CSS Text Level 3 white space processing, line breaking and alignment. +[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-4", "#white-space-property")] + public void BreakSpaces_PreservesSpaces() + { + var p = Paragraph("

      A B

      "); + + 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")] + [Spec("css-text-4", "#word-break-property")] + public void KeepAll_DoesntBreakBetweenCjkCharacters() + { + var p = Paragraph($"

      {Cjk}

      "); + + Assert.AreEqual(1, LinesWithText(p)); + } + + [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($"

      {Cjk}

      "); + + Assert.IsGreaterThan(1, LinesWithText(p)); + } + + [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($"

      {LongWord}

      "); + + Assert.IsGreaterThan(1, LinesWithText(p)); + } + + [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")] + public void OverflowWrap_BreaksAWordThatOverflows(string declaration) + { + var p = Paragraph($"

      {LongWord}

      "); + + Assert.IsGreaterThan(1, LinesWithText(p)); + } + + [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("

      abcdefghijkl­mnopqrstuvwx

      "); + + Assert.AreEqual(2, LinesWithText(p)); + } + + [TestMethod] + [Spec("css-text-3", "#line-break-details")] + [Spec("css-text-4", "#line-break-details")] + public void HyphenMinus_IsABreakOpportunity() + { + var p = Paragraph("

      abcdefghijkl-mnopqrstuvwx

      "); + + Assert.AreEqual(2, LinesWithText(p)); + } + + [TestMethod] + [Spec("css-text-3", "#justify-algos")] + [Spec("css-text-4", "#justify-algos")] + public void Justify_ExpandsBetweenCjkCharacters() + { + var p = Paragraph($"

      {Cjk}

      "); + 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")] + [Spec("css-text-4", "#text-align-last-property")] + public void TextAlignLast_AlignsTheLastLine() + { + var p = Paragraph("

      hi

      "); + + Assert.AreEqual(p.ClientRight, FirstWord(p).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")] + public void JustifyAll_JustifiesTheLastLine() + { + var p = Paragraph("

      hi there

      "); + 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/DirectionIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/DirectionIntegrationTests.cs new file mode 100644 index 000000000..d20ca4b22 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/DirectionIntegrationTests.cs @@ -0,0 +1,78 @@ +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")] + [Spec("css-writing-modes-4", "#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")] + [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( + "
      אבג דהו
      ")); + + 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")] + [Spec("css-writing-modes-4", "#bidi-algo")] + 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")] + [Spec("css-writing-modes-4", "#bidi-control-codes")] + 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.IntegrationTest/Text/FontIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/FontIntegrationTests.cs new file mode 100644 index 000000000..08d4cef29 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/FontIntegrationTests.cs @@ -0,0 +1,492 @@ +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")] + [Spec("css-fonts-4", "#font-matching-algorithm")] + public void FirstInstalledFamily_IsUsed() + { + var font = FontOf("x"); + + Assert.AreEqual("Georgia", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#algorithm")] + [Spec("css-fonts-4", "#font-matching-algorithm")] + public void NoInstalledFamily_FallsBackToTheDefaultFont() + { + var font = FontOf("x"); + + Assert.AreEqual("Segoe UI", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#algorithm")] + [Spec("css-fonts-4", "#font-matching-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")] + [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("\U0001F600"); + + Assert.AreEqual("Segoe UI Emoji", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] + public void QuotedFamilyName_IsMatched() + { + var font = FontOf("x"); + + Assert.AreEqual("Times New Roman", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] + public void UnquotedIdentifierSequence_IsJoinedIntoOneName() + { + var font = FontOf("x"); + + Assert.AreEqual("Times New Roman", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-prop")] + public void FontFamily_IsInherited() + { + var font = FontOf("
      x
      "); + + Assert.AreEqual("Georgia", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] + 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")] + [Spec("css-fonts-4", "#font-family-name-syntax")] + public void QuotedGenericName_IsAFamilyNameNotTheKeyword() + { + var font = FontOf("x"); + + Assert.AreNotEqual("Courier New", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#sans-serif-def")] + [Spec("css-fonts-4", "#generic-font-families")] + 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")] + [Spec("css-fonts-4", "#generic-font-families")] + [Spec("css-fonts-4", "#generic-family-name-syntax")] + 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")] + [Spec("css-fonts-4", "#generic-font-families")] + 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")] + [Spec("css-fonts-4", "#generic-font-families")] + 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")] + [Spec("css-fonts-4", "#generic-font-families")] + 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")] + [Spec("css-fonts-4", "#generic-font-families")] + public void GenericFamily_IsUsedAsTheLastAlternative() + { + var font = FontOf("x"); + + Assert.AreEqual("Courier New", font.Name); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-styling")] + [Spec("css-fonts-4", "#font-style-prop")] + [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")] + [Spec("css-fonts-4", "#font-style-prop")] + public void Normal_InsideItalic_SelectsTheUprightFace() + { + var font = FontOf("
      x
      "); + + Assert.IsFalse(font.Italic); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-styling")] + [Spec("css-fonts-4", "#font-style-prop")] + 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")] + [Spec("css-fonts-4", "#font-variant-prop")] + 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")] + [Spec("css-fonts-4", "#missing-weights")] + [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")] + [Spec("css-fonts-4", "#relative-weights")] + 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")] + [Spec("css-fonts-4", "#relative-weights")] + 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")] + [Spec("css-fonts-4", "#relative-weights")] + [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")] + [Spec("css-fonts-4", "#relative-weights")] + 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")] + [Spec("css-fonts-4", "#font-weight-prop")] + public void FontWeight_IsInherited() + { + var font = FontOf("
      x
      "); + + Assert.IsTrue(font.Bold); + } + + [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"]; + 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")] + [Spec("css-fonts-4", "#font-size-prop")] + 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")] + [Spec("css-fonts-4", "#font-size-prop")] + 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")] + [Spec("css-fonts-4", "#font-size-prop")] + 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")] + [Spec("css-fonts-4", "#font-size-prop")] + public void Percentage_RefersToTheParentSize() + { + var font = FontOf("
      x
      "); + + Assert.AreEqual(30 * 72d / 96d, font.Size, Delta); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] + public void Em_RefersToTheParentSize() + { + var font = FontOf("
      x
      "); + + Assert.AreEqual(30 * 72d / 96d, font.Size, Delta); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] + public void Length_IsUsedAsIs() + { + var font = FontOf("x"); + + Assert.AreEqual(12, font.Size, Delta); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] + 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")] + [Spec("css-fonts-4", "#font-size-prop")] + 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")] + [Spec("css-fonts-4", "#font-size-prop")] + public void TinySize_IsKept() + { + var font = FontOf("x"); + + Assert.AreEqual(0.75, font.Size, Delta); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] + 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")] + [Spec("css-fonts-4", "#font-prop")] + 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")] + [Spec("css-fonts-4", "#font-prop")] + public void Shorthand_ResetsOmittedPropertiesToTheirInitialValues() + { + var font = FontOf("
      x
      "); + + Assert.IsFalse(font.Bold); + Assert.IsFalse(font.Italic); + } + + [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] + 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")] + [Spec("css-fonts-4", "#font-prop")] + 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/HtmlEntityDecodingIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs index 890ab5d4d..42b78a1b6 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs @@ -5,47 +5,19 @@ namespace HtmlRenderer.IntegrationTest.Text; /// -/// Verifies HTML character-reference (entity) decoding. +/// Character references as they end up in the rendered words. The text is decoded once by the tokenizer and +/// a second time when it's split into words, so an escaped reference such as &amp;lt; doesn't +/// survive as literal text. /// -/// -/// -/// Correction from an earlier draft of this file's own reasoning, verified empirically by dumping the actual -/// box tree: CssBox.Text is NOT raw, undecoded source text on this fork. HtmlKit's HtmlTokenizer -/// (used by Parse\HtmlParser.cs) already performs standard HTML5 entity decoding while producing Data -/// tokens - e.g. for source "Use &amp;nbsp; here", the anonymous text child's own .Text is -/// already "Use &nbsp; here" (confirmed by direct inspection) by the time HTML-Renderer sees it at -/// all. CssBox.ParseToWords THEN calls HtmlUtils.DecodeHtml a SECOND time, per word, on top of -/// that already-decoded text. The net effect is real double-decoding: a double-escaped entity like -/// &amp;nbsp; - a legitimate technique for displaying literal entity syntax, e.g. in documentation -/// or code samples - does NOT survive as literal text the way a single-pass decoder would leave it; it gets -/// fully resolved by the second pass instead. Confirmed by direct instrumentation reading -/// HtmlUtils.DecodeHtml's own two internal sub-passes (DecodeHtmlCharByCode then -/// DecodeHtmlCharByName, each of which is individually single-pass) combined with the tokenizer's own -/// prior decode. -/// -/// -/// Decoded content only ever shows up in box.Words (each 's Text) - and -/// specifically on the descendant ANONYMOUS TEXT CssBox that HtmlParser creates for text content (a container -/// box like <p> never carries text/words directly - see AllWords below) - so these tests -/// read words, joined back together, instead of box.Text. -/// -/// [DoNotParallelize] [TestClass] public sealed class HtmlEntityDecodingIntegrationTests { #region Double-Escaped Entities (Should Render as Literal Entity References) - // A double-escaped entity like "&nbsp;" is meant to display literal entity syntax (e.g. in - // documentation/code samples) by surviving exactly one decode pass. On this fork it does NOT survive: - // see this class's remarks on the confirmed double-decode (HtmlKit tokenizer decode + a second - // HtmlUtils.DecodeHtml pass in CssBox.ParseToWords). Ported faithfully and ignored, not deleted, so the - // intended behavior stays documented. - - [Ignore("Double-decoded (see class remarks): box.Text already arrives as \"Use   here\" (HtmlKit's " + - "tokenizer already decoded the outer &), then ParseToWords's own DecodeHtml decodes the now-" + - "literal \" \" AGAIN into a plain space - confirmed by dumping the box tree directly.")] + [Ignore("Gap, see Spec/html.yml")] [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] public void DoubleEscapedNbsp_RendersAsLiteralEntityReference() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

      Use &nbsp; here

      ")); @@ -54,9 +26,9 @@ public void DoubleEscapedNbsp_RendersAsLiteralEntityReference() Assert.IsTrue(AllWords(p).Any(w => w.Text == " ")); } - [Ignore("Double-decoded (see class remarks): box.Text already arrives as \"Use & here\", then " + - "ParseToWords's own DecodeHtml decodes that already-literal \"&\" AGAIN into a plain \"&\".")] + [Ignore("Gap, see Spec/html.yml")] [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] public void DoubleEscapedAmp_RendersAsLiteralEntityReference() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

      Use &amp; here

      ")); @@ -65,9 +37,9 @@ public void DoubleEscapedAmp_RendersAsLiteralEntityReference() Assert.IsTrue(AllWords(p).Any(w => w.Text == "&")); } - [Ignore("Double-decoded (see class remarks): box.Text already arrives as \"Use < here\", then " + - "ParseToWords's own DecodeHtml decodes that already-literal \"<\" AGAIN into a plain \"<\".")] + [Ignore("Gap, see Spec/html.yml")] [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] public void DoubleEscapedLt_RendersAsLiteralEntityReference() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

      Use &lt; here

      ")); @@ -76,9 +48,9 @@ public void DoubleEscapedLt_RendersAsLiteralEntityReference() Assert.IsTrue(AllWords(p).Any(w => w.Text == "<")); } - [Ignore("Double-decoded (see class remarks): box.Text already arrives as \"Use A here\" (a now-genuine " + - "numeric entity), then ParseToWords's own DecodeHtml decodes THAT into the literal character \"A\".")] + [Ignore("Gap, see Spec/html.yml")] [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] public void DoubleEscapedNumericEntity_RendersAsLiteralEntityReference() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

      Use &#65; here

      ")); @@ -91,10 +63,11 @@ public void DoubleEscapedNumericEntity_RendersAsLiteralEntityReference() #region Single-Escaped Entities (Should Render as Decoded Characters) - [Ignore("HtmlUtils.DecodeHtml decodes   to a plain U+0020 space rather than U+00A0 (non-breaking " + - "space) - confirmed real engine behavior (_decodeOnly[\"nbsp\"] = ' ' in HtmlUtils.cs), same " + - "root cause tracked for WhiteSpaceLayoutIntegrationTests' nbsp cases.")] + [Ignore("Gap, see Spec/css21.yml")] [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("

      Use here

      ")); @@ -104,6 +77,7 @@ public void SingleEscapedNbsp_RendersAsNonBreakingSpace() } [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] public void SingleEscapedAmp_RendersAsAmpersand() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

      Use & here

      ")); @@ -113,6 +87,7 @@ public void SingleEscapedAmp_RendersAsAmpersand() } [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] public void SingleEscapedLt_RendersAsLessThan() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

      Use < here

      ")); @@ -122,6 +97,7 @@ public void SingleEscapedLt_RendersAsLessThan() } [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] public void SingleEscapedNumericEntity_RendersAsCharacter() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

      Use A here

      ")); @@ -134,36 +110,31 @@ public void SingleEscapedNumericEntity_RendersAsCharacter() #region Code Blocks and Pre Elements - [Ignore("Double-decoded (see class remarks): the already-tokenizer-decoded \"Use   for spaces\" gets " + - "its \" \" decoded again by ParseToWords into a plain space.")] + [Ignore("Gap, see Spec/html.yml")] [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] public void DoubleEscapedEntitiesInCodeBlock_RenderCorrectly() { - // is normal white-space, so re-joining decoded words with single spaces reconstructs the - // original (collapsed) spacing exactly. var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("Use &nbsp; for spaces")); var c = LayoutHarness.FindById(root, "c")!; Assert.AreEqual("Use   for spaces", JoinWordsNormal(c)); } - [Ignore("Double-decoded (see class remarks): the already-tokenizer-decoded \"<html>\" gets decoded " + - "again by ParseToWords into literal \"\".")] + [Ignore("Gap, see Spec/html.yml")] [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] public void DoubleEscapedEntitiesInPreElement_RenderCorrectly() { - //
       is white-space:pre via the UA default stylesheet - "&lt;html&gt;" has no whitespace
      -        // at all, so it is a single word token, decoded in one pass with no join/spacing concerns.
               var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
      &lt;html&gt;
      ")); var p = LayoutHarness.FindById(root, "p")!; Assert.AreEqual("<html>", JoinWordsPre(p)); } - [Ignore("The single-escaped portions (\"&\" -> \"&\") are fine, but the double-escaped \"&amp;\" " + - "portion gets decoded twice (see class remarks), ending up as a plain \"&\" instead of the " + - "literal \"&\" this asserts.")] + [Ignore("Gap, see Spec/html.yml")] [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] public void MixedEntitiesInParagraph_RenderCorrectly() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

      A & B &amp; C

      ")); @@ -197,9 +168,6 @@ public void CssContentWithCssEscapeInString_RendersLiterally() var (root, _) = LayoutHarness.Layout(html); var p = LayoutHarness.FindById(root, "p")!; - // Unlike ::before (inserted at index 0), ::after is appended at the end of p.Boxes - so - // FirstOrDefault(HtmlTag == null) would instead match the real text node "text" (which also has - // no HtmlTag), not the pseudo box. IsAfterPseudoElement identifies it unambiguously. var afterBox = p.Boxes.FirstOrDefault(b => b.IsAfterPseudoElement); Assert.IsNotNull(afterBox); Assert.IsTrue(afterBox!.Text!.Contains('<')); @@ -210,14 +178,11 @@ public void CssContentWithCssEscapeInString_RendersLiterally() #region Edge Cases - [Ignore("Double-decoded (see class remarks): \"&nbsp;\" is already literal \" \" by the time " + - "ParseToWords runs, and gets decoded again into a plain space.")] + [Ignore("Gap, see Spec/html.yml")] [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] public void WhitespacePreservation_WithEntities() { - // white-space:pre preserves the two literal spaces between the entity and "test" as their own word - // token (see WhiteSpaceLayoutIntegrationTests' Pre_PreservesMultipleConsecutiveSpacesAsLiteralWord), - // so concatenating words with NO separator reconstructs the exact original spacing. var (root, _) = LayoutHarness.Layout( LayoutHarness.Wrap("

      &nbsp; test

      ")); var p = LayoutHarness.FindById(root, "p")!; @@ -225,9 +190,9 @@ public void WhitespacePreservation_WithEntities() Assert.AreEqual("  test", JoinWordsPre(p)); } - [Ignore("Every double-escaped entity in this sentence gets decoded twice (see class remarks), so none of " + - "them survive as the literal entity references this asserts.")] + [Ignore("Gap, see Spec/html.yml")] [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] public void MultipleDoubleEscapedEntities_InSentence() { var (root, _) = LayoutHarness.Layout( @@ -237,16 +202,11 @@ public void MultipleDoubleEscapedEntities_InSentence() Assert.AreEqual("Entities: <, >, &,  ", JoinWordsNormal(p)); } - [Ignore("Confirmed real (if convoluted) two-layer decode, empirically verified: raw \"&amp;nbsp;\" -> " + - "HtmlKit's tokenizer decodes the FIRST \"&\" (greedy, single pass) to \"&\", leaving " + - "box.Text = \"&nbsp;\" (i.e. exactly the DoubleEscapedNbsp case's raw INPUT) -> " + - "ParseToWords's own DecodeHtml then decodes THAT down one more level to \" \", not the two " + - "literal levels (\"&nbsp;\") this asserts.")] + [Ignore("Gap, see Spec/html.yml")] [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] public void TripleEscapedEntity_RendersWithTwoLevels() { - // &amp;nbsp; -> the entity scan finds only the leading "&" (index 0..4) and decodes it to - // "&"; the remainder "amp;nbsp;" has no leading '&' left, so it stays literal - "&nbsp;". var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

      &amp;nbsp;

      ")); var p = LayoutHarness.FindById(root, "p")!; @@ -254,6 +214,7 @@ public void TripleEscapedEntity_RendersWithTwoLevels() } [TestMethod] + [Spec("html", "parsing.html#character-reference-state")] public void EntityInAttributeValue_DecodedCorrectly() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

      text

      ")); @@ -265,12 +226,7 @@ public void EntityInAttributeValue_DecodedCorrectly() #endregion - // A block/inline CONTAINER box (e.g.

      , ) never carries its own Words directly - HtmlParser always - // puts text content into a separate anonymous CHILD CssBox (see HtmlParser.AddTextBox: it always creates a - // new child box and sets Text on THAT, never on the current container). So "p.Words" for a

      wrapping - // plain text is always empty; the real per-word decoded content lives on the descendant anonymous text - // box(es). AllWords flattens words from the box and every descendant, in document order, which is what - // these tests actually need to read back the decoded content. + // Text sits in anonymous child boxes, so the words are collected from the whole subtree. private static IEnumerable AllWords(CssBox box) => LayoutHarness.Descendants(box).SelectMany(b => b.Words); private static string JoinWordsNormal(CssBox box) => string.Join(" ", AllWords(box).Select(w => w.Text)); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs index 145b1643f..613199eaa 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,10 @@ public sealed class LineBaselineIntegrationTests private const double Delta = 0.5; [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( @@ -27,6 +30,9 @@ 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( @@ -38,6 +44,8 @@ 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/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/LineHeightIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineHeightIntegrationTests.cs new file mode 100644 index 000000000..92f8c013a --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineHeightIntegrationTests.cs @@ -0,0 +1,184 @@ +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")] + [Spec("css-inline-3", "#line-layout")] + [Spec("css-inline-3", "#inline-height")] + public void TallerInlineText_MakesTheLineTaller() + { + var root = Layout("
      xT
      "); + + 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("
      x
      "); + var plainP = Box(plain, "p"); + var descent = Height(plainP) - (FirstWord(plainP).Top - plainP.Location.Y) - plainP.ActualFont.UnderlineOffset; + var root = TestImages.Layout($"
      xy
      "); + + 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("
      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")] + [Spec("css-inline-3", "#line-layout")] + 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")] + [Spec("css-inline-3", "#inline-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")] + [Spec("css-writing-modes-4", "#replaced-baselines")] + [Spec("css-inline-3", "#baseline-tables")] + 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")] + [Spec("css-inline-3", "#line-height-property")] + 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")] + [Spec("css-inline-3", "#line-height-property")] + 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")] + [Spec("css-inline-3", "#line-height-property")] + 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")] + [Spec("css-inline-3", "#line-height-property")] + 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")] + [Spec("css-inline-3", "#inline-height")] + 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")] + [Spec("css-inline-3", "#inline-height")] + 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")] + [Spec("css-inline-3", "#line-height-property")] + 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")] + [Spec("css-inline-3", "#line-height-property")] + public void NumberLineHeight_IsInheritedAsTheNumber() + { + var root = Layout("
      x
      "); + + Assert.AreEqual(60, Height(Box(root, "p")), Delta); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs index b2f74ba8d..158dbb6b5 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs @@ -4,37 +4,15 @@ 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")] + [Spec("html", "semantics-other.html#pseudo-classes")] public void Link_MatchesAnchorWithHref() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap( @@ -45,6 +23,8 @@ 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( @@ -55,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( @@ -65,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( @@ -75,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.IntegrationTest/Text/SmallCapsIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs index 2cabe0dd8..61e969491 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,8 @@ 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( @@ -70,6 +33,8 @@ 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( @@ -79,18 +44,17 @@ 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")] + [Spec("css-fonts-4", "#font-variant-prop")] 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 +81,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,11 +105,9 @@ 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("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( @@ -164,10 +120,9 @@ 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("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/Test/HtmlRenderer.IntegrationTest/Text/StyleElementTextConcatenationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/StyleElementTextConcatenationTests.cs index dd247cd1d..e27c2ff17 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/StyleElementTextConcatenationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/StyleElementTextConcatenationTests.cs @@ -2,62 +2,14 @@ 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 -// 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. +// RegexParserUtils caches its regexes in a static Dictionary, parallel SetHtml calls can corrupt it [DoNotParallelize] [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")] + [Spec("html", "parsing.html#creating-and-inserting-nodes")] public void RuleAfterLessThanInDeclaration_StillApplies() { const string html = """ @@ -73,9 +25,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); + Assert.AreEqual("rgb(0, 128, 0)", b.Color); } } diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs new file mode 100644 index 000000000..bc5ea41d5 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignIntegrationTests.cs @@ -0,0 +1,120 @@ +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")] + [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] + public void Left_PacksTextAgainstTheLeftEdge() + { + var word = Words("

      hi

      ").Single(); + + Assert.AreEqual(0, word.Left, Delta); + } + + [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("

      hi

      ").Single(); + + Assert.AreEqual(200, word.Right, Delta); + } + + [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("

      hi

      ").Single(); + + Assert.AreEqual(200 - word.Right, word.Left, Delta); + } + + [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("

      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")] + [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] + public void TextAlign_IsInherited() + { + var word = Words("

      hi

      ").Single(); + + Assert.AreEqual(200, word.Right, Delta); + } + + [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("

      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")] + [Spec("css-text-3", "#text-align-property")] + [Spec("css-text-4", "#text-align-property")] + 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")] + [Spec("css-text-3", "#expanding-text")] + [Spec("css-text-4", "#expanding-text")] + 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..c2f2db35a 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 @@ -42,10 +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("

      hi

      "); + var html = LayoutHarness.Wrap("

      hi

      "); var (root, _) = LayoutHarness.Layout(html); var p = LayoutHarness.FindById(root, "p")!; @@ -54,11 +29,12 @@ 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("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("

      hi

      "); @@ -70,11 +46,10 @@ 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("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("

      hi

      "); @@ -86,10 +61,15 @@ 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")] + [Spec("css-text-3", "#bidi-linebox")] + [Spec("css-text-4", "#bidi-linebox")] public void End_InRtlBlock_PacksTextAgainstTheLeftEdge() { - var html = LayoutHarness.Wrap("

      hi

      "); + var html = LayoutHarness.Wrap("

      hi

      "); var (root, _) = LayoutHarness.Layout(html); var p = LayoutHarness.FindById(root, "p")!; @@ -99,6 +79,9 @@ 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("

      hi

      "); 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.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs index e579e9882..4b7b61e5a 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextSpacingAndIndentLayoutIntegrationTests.cs @@ -3,48 +3,17 @@ 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")] + [Spec("css-text-3", "#word-spacing-property")] + [Spec("css-text-4", "#word-spacing-property")] public void WordSpacing_IncreasesGapBetweenAdjacentWords() { var spacedGap = InterWordGap("

      AA BB

      "); @@ -53,10 +22,11 @@ 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")] + [Spec("css-text-3", "#word-spacing-property")] + [Spec("css-text-4", "#word-spacing-property")] public void WordSpacing_OnAncestor_AffectsDescendantGap() { var spacedGap = InterWordGap("

      AA BB

      "); @@ -66,6 +36,9 @@ 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("

      AA BB

      "); @@ -74,9 +47,11 @@ 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")] + [Spec("css-text-3", "#word-spacing-property")] + [Spec("css-text-4", "#word-spacing-property")] public void WordSpacingNegative_ReducesGap() { var negativeGap = InterWordGap("

      AA BB

      "); @@ -85,12 +60,27 @@ public void WordSpacingNegative_ReducesGap() Assert.AreEqual(plainGap - 5, negativeGap, 1); } + [Ignore("Gap, see Spec/css21.yml")] + [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("

      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")] + [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] 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 +95,9 @@ 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("

      Hello world

      "); @@ -115,7 +108,105 @@ public void TextIndentZero_NoIndentOnAnyLine() Assert.AreEqual(0, firstWord.Left, 1); } - // ── Helpers ──────────────────────────────────────────────────────────── + [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( + "

      hi

      "), 1); + } + + [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( + "

      hi

      "), 1); + } + + [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("
      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")] + [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] + public void PercentageTextIndent_ResolvesAgainstTheContainingBlockWidth() + { + Assert.AreEqual(40, FirstWordLeft( + "

      hi

      "), 1); + } + + [Ignore("Gap, see Spec/css21.yml")] + [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("
      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")] + [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( + "

      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")] + [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] + 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..b897faca4 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextTransformIntegrationTests.cs @@ -0,0 +1,24 @@ +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")] + [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")] + 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/VerticalAlignIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs index 471e15996..d6cf7563c 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs @@ -3,73 +3,13 @@ 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 -// 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 { 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,8 +25,10 @@ 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] + [Spec("css-inline-3", "#transverse-alignment")] public void Top_PositionsHigherThanBottom() { var topY = GetAlignedTop("top"); @@ -95,8 +37,10 @@ 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] + [Spec("css-inline-3", "#transverse-alignment")] public void Middle_PositionsBetweenTopAndBottom() { var topY = GetAlignedTop("top"); @@ -106,8 +50,10 @@ public void Middle_PositionsBetweenTopAndBottom() Assert.IsTrue(middleY > topY && middleY < bottomY); } - [Ignore(InlineNoOpReason)] + [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"); @@ -116,8 +62,10 @@ 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] + [Spec("css-inline-3", "#transverse-alignment")] public void Sub_PositionsBelowSuper() { var subY = GetAlignedTop("sub"); @@ -126,7 +74,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 +85,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() { @@ -146,38 +96,40 @@ 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( - "

      TALL " + - "small

      "); - var htmlLargeParent = LayoutHarness.Wrap( - "

      TALL " + - "small

      "); + 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}"); + } - 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; + [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( + "

      ref TALL " + + "small

      "); - Assert.AreNotEqual(ySmall, yLarge); + 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.AreEqual(parentTop, textTop, Delta); } [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( "
  • Col ACol B
    " + "" @@ -200,6 +152,7 @@ public void Bottom_OnATableCell_PushesShortContentLowerThanTopAligned() } [TestMethod] + [Spec("css21", "tables.html#height-layout")] public void Middle_OnATableCellWithExplicitHeight_CentersShortContent() { var htmlTop = LayoutHarness.Wrap( @@ -229,25 +182,23 @@ 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); } - [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] + [Spec("css-inline-3", "#transverse-alignment")] 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 +208,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() { @@ -268,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.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs index 8a2829e4d..63d696f08 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs @@ -1,17 +1,19 @@ 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")] + [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] public void Pre_PreservesMultipleConsecutiveSpacesAsLiteralWord() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("

    A B

    ")); @@ -22,6 +24,9 @@ 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("

    A B

    ")); @@ -32,6 +37,9 @@ 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("

    A\nB

    ")); @@ -41,6 +49,44 @@ 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("

    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")] + [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] + 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")] + [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("

    A\nB

    ")); @@ -50,6 +96,10 @@ 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("

    a long run of unwrapped text here

    "); @@ -60,6 +110,10 @@ 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("

    a long run of unwrapped text here

    "); @@ -69,14 +123,144 @@ 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")] + [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] + 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")] + [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] + 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")] + [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] + 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")] + [Spec("css-text-3", "#white-space-property")] + [Spec("css-text-4", "#white-space-property")] + public void WhiteSpace_IsInherited() + { + var words = Words("

    A B

    "); + + Assert.IsTrue(words.Any(w => w.Text == " ")); + } + + [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("

    AA BB

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

    AA \n BB

    ", "AA", "BB"), Delta); + } + + [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("

    AA BB

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

    AA\tBB

    ", "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.")] [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("

    AA

    ").Single(); + + Assert.AreEqual(0, word.Left, Delta); + } + + [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("

    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")] + [Spec("css-text-3", "#white-space-phase-1")] + [Spec("css-text-4", "#white-space-phase-1")] + 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")] + [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( + "

    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")] + [Spec("css-text-3", "#white-space-phase-1")] + [Spec("css-text-4", "#white-space-phase-1")] + 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")] + [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("
     
    ")); @@ -92,6 +276,9 @@ public void Nbsp_OnlyContent_ProducesNonZeroHeight_MatchingRealText() } [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 OrdinaryWhitespaceOnlyContent_StillProducesZeroHeight_NoRegression() { var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
    ")); @@ -101,15 +288,14 @@ 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")] + [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 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 +307,74 @@ 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")] + [Spec("css-text-3", "#line-break-details")] + [Spec("css-text-4", "#line-break-details")] + 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")] + [Spec("css-text-3", "#line-break-details")] + [Spec("css-text-4", "#line-break-details")] + 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")] + [Spec("css-text-3", "#characters")] + [Spec("css-text-4", "#characters")] + public void CombiningCharacter_StaysWithItsBaseCharacter() + { + var word = Words("

    öx

    ").Single(); + + 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("

    öx

    "); + + 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) + { + var words = Words($"

    A{control}B

    "); + + Assert.AreEqual(1, words.Count); + } + + [TestMethod] + [Spec("css-text-3", "#word-break-property")] + [Spec("css-text-4", "#word-break-property")] 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 +384,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.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; /// -/// Ported from PeachPDF.Tests' FixedPositionPaginationIntegrationTests, which confirms -/// position:fixed content repeats identically across real, multi-page -/// 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 page-break-before:always. +/// position: fixed 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. /// -/// -/// -/// This fork has no page-break-before/page-break-after support at all - already confirmed -/// and documented by HtmlRenderer.IntegrationTest.Positioning.FixedPositionPaginationIntegrationTests's -/// own PageBreakBefore_PushesFollowingContentToTheNextSimulatedPage test, which is [Ignore]d -/// for exactly that reason (no PageBreakBefore/PageBreakAfter property anywhere in -/// CssBoxProperties, and the default stylesheet's own rules for it are inert). Real multiple pages -/// are forced here the same way MultiPageTextVisibilityTest/FixedPositionRepeatsPerPdfPageTest -/// already do it: enough filler paragraph content to genuinely overflow several A4 pages. -/// -/// -/// This is deliberately NOT a duplicate of the two fixed-position tests already in this branch's -/// history: -/// -/// -/// HtmlRenderer.IntegrationTest.FixedPositionRepeatsPerPageTest asserts against the -/// fragment tree directly (FragmentainerFragment.Root word positions) and never generates a real -/// PDF at all. -/// HtmlRenderer.PdfSharp.Test.FixedPositionRepeatsPerPdfPageTest does go through the real -/// generator, but only ever checks a relative Tj-operator COUNT for fixed TEXT content - it never -/// confirms a fixed box's own painted geometry (a background-color fill, drawn through -/// GraphicsAdapter.DrawRectangle(RBrush,...), a different code path than DrawString), and -/// never confirms the fixed content repaints at the SAME page-local position on every page rather than -/// merely "some extra content exists somewhere". -/// -/// -/// This test fills that specific, previously-uncovered gap. The content-stream shape of a filled rect - -/// a "<r> <g> <b> 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. -/// -/// [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; /// -/// Ported from PeachPDF.Tests' HandleLinksPaginationTests: end-to-end tests for link-annotation -/// page attribution through the real pipeline, asserting on the generated -/// PdfDocument's own Pages[i].Annotations. +/// A link annotation lands on the PDF page its link is painted on, also when blank page slots are skipped. /// -/// -/// PeachPDF's own two historical bugs this file documented (an un-shifted MarginTop in the -/// page-index formula, and a raw grid-slot index used directly as a document.Pages index) don't -/// map onto this fork's own PdfGenerator.HandleLinks (Source/HtmlRenderer.PdfSharp/PdfGenerator.cs -/// ~235-285) verbatim - this fork was written already carrying the fix: it builds a slotToPage -/// dictionary from tree.Fragmentainers 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 Geometry 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 page-break-before:always to land its link deterministically on -/// "page two" - this fork has no such property (confirmed elsewhere in this branch's own history; see -/// HtmlRenderer.PdfSharp.Test.FixedPositionPaginationIntegrationTests'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. -/// [TestClass] [DoNotParallelize] public sealed class HandleLinksPaginationTests 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..4241ac674 --- /dev/null +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/PageRuleTests.cs @@ -0,0 +1,67 @@ +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")] + [Spec("css-page-3", "#page-model")] + 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")] + [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) + { + Assert.AreEqual(2, await PageCount($"{Block}")); + } + + [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) + { + Assert.AreEqual(2, await PageCount($"{Block}")); + } + + [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 }"; + + Assert.AreEqual(2, await PageCount($"{Block}")); + } +} 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 Generate(string css, string body = "

    x

    ") => + await PdfGenerator.GeneratePdf( + $"{body}", + 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 }", + "

    x

    wide
    "); + + 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.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/PrintMediaTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/PrintMediaTests.cs new file mode 100644 index 000000000..e9da8ecce --- /dev/null +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/PrintMediaTests.cs @@ -0,0 +1,24 @@ +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")] + [Spec("mediaqueries-4", "#media-types")] + [Spec("mediaqueries-5", "#media-types")] + public async Task Pdf_IsRenderedForPrintMedia() + { + const string 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.PdfSharp.Test/StageD2VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs index 544e5bbd2..23d4b0c9d 100644 --- a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs @@ -3,10 +3,13 @@ namespace HtmlRenderer.PdfSharp.Test; +// Parallel full layout passes race on the adapter singletons. [TestClass] +[DoNotParallelize] 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 +41,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 +65,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 +99,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 +118,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..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] @@ -12,10 +14,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 +29,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 +42,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 +51,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/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 33cacb7f5..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] @@ -12,9 +14,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 +29,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 +43,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 +53,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.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; /// -/// Ported from PeachPDF.Tests' TableHeaderPdfRenderingTests: real end-to-end PDF rendering of a -/// repeating <thead> across multiple real generated pages. +/// A repeating <thead> 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. /// -/// -/// -/// 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 -/// MultiPageTextVisibilityTest'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 ). -/// -/// -/// PeachPDF's file name says "header/footer", but only 1 of its 5 tests is pure footer-repetition -/// (TableFooter_MultiPageTable_GeneratesWithFooter) and 1 mixes header+footer in a single-page, -/// paint-order-recording test (TableHeaderAndFooter_SinglePageTable_PaintsEachCellTextExactlyOnce, -/// which used PeachPDF's own FragmentPaintHarness/DrawStringRecordingGraphics test-only -/// mock, not a real generated PDF). This fork implements no <tfoot> repeat at all - -/// confirmed across this whole branch by TableHeaderRepeat.cs 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 () -/// 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 (MultiPageTextVisibilityTest, FixedPositionRepeatsPerPdfPageTest) -/// insists on. -/// -/// [TestClass] [DoNotParallelize] public sealed class TableHeaderPdfRenderingTests @@ -140,18 +114,7 @@ public async Task TableHeader_ComplexHeaderWithColspan_RepeatsAcrossPages() } /// - /// Rewrite of PeachPDF's TableHeaderAndFooter_SinglePageTable_PaintsEachCellTextExactlyOnce - - /// a regression test for two real bugs PeachPDF found together there: a header/footer row never - /// getting its own Bounds 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 TableHeaderRepeat mechanism - /// is architecturally different (an independent, already-positioned CssBox clone per repeat, - /// not a shared-subtree proxy - see RepeatedTableHeaderClipIntegrationTests'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. /// [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; /// -/// Ported from PeachPDF.Tests' TableHeaderRepetitionThroughTheGeneratorTests: a repeating -/// <thead> 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 LayoutHarness, 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 <thead> above a single row that continues across several pages, laid out through +/// the real generator rather than LayoutHarness. /// /// -/// -/// Three of PeachPDF's adaptation notes don't apply here at all: this fork implements no -/// <tfoot> repeat (confirmed across the whole branch by TableHeaderRepeat.cs being -/// thead-only), its @page at-rule is confirmed parse-only with no consumer anywhere (so the -/// original fixture's @page { size: a6; margin: 12mm } is dropped in favor of driving page size -/// through directly, the same as every other real-generator test in this -/// project), and this project has no InternalsVisibleTo access to HtmlContainerInt/ -/// FragmentTree (only HtmlRenderer.Test/HtmlRenderer.IntegrationTest do - confirmed -/// by reading Source/HtmlRenderer/HtmlRenderer.csproj's InternalsVisibleTo 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. -/// -/// -/// Porting this fixture surfaced a real, already-documented architectural gap rather than a new bug: -/// HtmlRenderer.IntegrationTest.Tables.TableSpannedBandRepetitionTests (Batch 4) already pins that -/// CssLayoutEngineTable'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 pipeline rather than LayoutHarness, 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. -/// +/// 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. /// [TestClass] [DoNotParallelize] 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/Content/GeneratedContentModuleTests.cs b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentModuleTests.cs new file mode 100644 index 000000000..5559cf8fd --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentModuleTests.cs @@ -0,0 +1,78 @@ +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("

    abc

    ")) + .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("

    b

    ").Text); + } + + [TestMethod] + [Spec("css-content-3", "#strings")] + public void WhiteSpaceInStrings_CollapsesAcrossStrings() + { + var before = Before("

    a

    "); + + 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-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 = "

    a

    b

    "; + + Assert.AreEqual("[Loomings]", Before(html).Text); + } + + [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("

    abc

    ").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 new file mode 100644 index 000000000..95ff23e2f --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Content/GeneratedContentTests.cs @@ -0,0 +1,314 @@ +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")] + [Spec("css-pseudo-4", "#generated-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")] + [Spec("css-pseudo-4", "#treelike")] + 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")] + [Spec("css-pseudo-4", "#generated-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")] + [Spec("css-pseudo-4", "#generated-content")] + [Spec("css-content-3", "#content-property")] + 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")] + [Spec("css-content-3", "#strings")] + 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")] + [Spec("css-lists-3", "#counter-functions")] + 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")] + [Spec("css-content-3", "#content-uri")] + 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")] + [Spec("css-content-3", "#quotes-property")] + public void Quotes_SuppliesTheQuoteMarks() + { + Assert.AreEqual("", Before("

    '\">a

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

    a

    ").Text); + } + + [TestMethod] + [Spec("css21", "generate.html#quotes-insert")] + [Spec("css-content-3", "#quote-values")] + 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")] + [Spec("css-content-3", "#quote-values")] + 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")] + [Spec("css-content-3", "#quote-values")] + public void CloseQuoteAtDepthZero_InsertsNothing() + { + Assert.AreEqual("a", Before("

    a

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

    a

    ").Text); + } + + [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("

    a

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

    a

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

    a

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

    a

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

    a

    "; + + Assert.AreEqual("02", Before(html).Text); + } + + [TestMethod] + [Spec("css21", "generate.html#counters")] + [Spec("css21", "generate.html#scope")] + [Spec("css-lists-3", "#increment-set")] + 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")] + [Spec("css-lists-3", "#nested-counters")] + 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")] + [Spec("css-lists-3", "#increment-set")] + [Spec("css-lists-3", "#instantiating-counters")] + 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")] + [Spec("css-lists-3", "#counter-functions")] + 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")] + [Spec("css-lists-3", "#counter-functions")] + public void Counter_WithGlyphOrNoneStyle_UsesTheGlyphOrNothing() + { + Assert.AreEqual("[]", Before("

    a

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

    a

    ").Text); + } + + [TestMethod] + [Spec("css21", "generate.html#undisplayed-counters")] + [Spec("css-lists-3", "#counters-without-boxes")] + 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")] + [Spec("css-lists-3", "#counters-without-boxes")] + 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..f7e711f2a --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Content/ListStyleTests.cs @@ -0,0 +1,124 @@ +using static HtmlRenderer.Test.Html.HtmlTestSupport; + +namespace HtmlRenderer.Test.Content; + +[TestClass] +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("
    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", "გ.")] + [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($"
    1. a
    2. b
    3. c
    ")); + } + + [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("
    • 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")] + [Spec("css-lists-3", "#text-markers")] + public void ListStyleTypeNone_HasNoMarker() + { + Assert.IsNull(Box("
    • a
    ").ListItemBox); + } + + [TestMethod] + [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-property")] + public void ListStyleShorthand_SetsTheType() + { + Assert.AreEqual("square", Box("
    • a
    ").ListStyleType); + Assert.IsNull(Box("
    • a
    ").ListItemBox); + } + + [TestMethod] + [Spec("css21", "generate.html#list-style")] + [Spec("css-lists-3", "#list-style-position-property")] + 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")] + [Spec("css-lists-3", "#list-style-position-property")] + 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")] + [Spec("css-lists-3", "#list-style-position-property")] + 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")] + [Spec("css-lists-3", "#image-markers")] + [Spec("css-images-3", "#image-values")] + 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")] + [Spec("css-counter-styles-3", "#simple-numeric")] + public void Georgian_Twenty_IsKan() + { + Assert.AreEqual("კ.", Marker("
    1. a
    ")); + } +} 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("
    • a
    "); + + 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("
    • a
    ")); + } + + [TestMethod] + [Spec("css-lists-3", "#content-property")] + public void ListItemWithoutMarkerString_HasNoMarkerBox() + { + Assert.IsNull(Box("
    1. a
    ").ListItemBox); + Assert.IsNull(Box("
    a
    ").ListItemBox); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#text-markers")] + public void StringListStyleType_IsTheMarkerString() + { + Assert.AreEqual("*", Marker("
    • a
    ")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#text-markers")] + public void UnknownCounterStyle_FallsBackToDecimal() + { + Assert.AreEqual("1.", Marker("
    • a
    ")); + } + + [TestMethod] + [Spec("css-lists-3", "#list-style-property")] + public void ListStyleShorthand_NoneGoesToTheLonghandNotOtherwiseSet() + { + var noneImage = Box("
    • a
    "); + Assert.AreEqual("none", noneImage.ListStyleType); + Assert.AreEqual("url(\"x.png\")", noneImage.ListStyleImage); + + var noneDisc = Box("
    1. a
    "); + Assert.AreEqual("disc", noneDisc.ListStyleType); + Assert.AreEqual("none", noneDisc.ListStyleImage); + + Assert.AreEqual("none", Box("
    1. a
    ").ListStyleType); + Assert.AreEqual("decimal", Box("
    1. a
    ").ListStyleType); + } + + [TestMethod] + [Spec("css-lists-3", "#list-style-property")] + public void ListStyleShorthand_ResetsTheOmittedLonghands() + { + Assert.AreEqual("disc", Box("
    1. a
    ").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("

    a

    ").Text); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#increment-set")] + public void CounterSet_SetsTheValueAfterTheIncrement() + { + Assert.AreEqual("2", Before("

    a

    ").Text); + } + + [TestMethod] + [Spec("css-lists-3", "#inheriting-counters")] + public void CounterValue_ComesFromThePrecedingElementInTreeOrder() + { + var html = "
      " + + "
    • foo
      bar
    • " + + "
    • baz
    " + + ""; + + 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 = "

    a

    b

    c

    " + + "

    d

    " + + ""; + + 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 = "

    a

    b

    c

    " + + ""; + + 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 = "
    1. a
    2. b
    "; + Assert.AreEqual("4", Before(html).Text); + + html = "
    1. a
    2. b
    "; + 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("
    1. a
    2. b
    ")); + Assert.AreEqual("5.", Marker("
    1. a
    ")); + } + + [TestMethod] + [Ignore("Gap, see Spec/css-lists-3.yml")] + [Spec("css-lists-3", "#list-item-counter")] + public void NestedList_StartsANewListItemCounter() + { + Assert.AreEqual("1", Before("
    1. a
      1. b
    ").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("
    1. a
    ").Text); + Assert.AreEqual("7", Before("
    1. a
    ").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/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; -/// Ported from PeachPDF.Tests/CSS/PropertyTests/AnimationPropertyTests.cs. Pure CSSOM parse tests - no layout involved. [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/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; -/// -/// Ported from PeachPDF.Tests/CSS/AspectRatioGrammarTests.cs. -/// Tests for the shared (the aspect-ratio value grammar -/// [ auto || <ratio> ]) and its Layer-A accept/reject via the full parser. -/// [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/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; -/// Ported from PeachPDF.Tests/CSS/AtPropertyTests.cs. [TestClass] public sealed class AtPropertyTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/BackgroundPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/BackgroundPropertyTests.cs index e09ba0cae..9b7e236b4 100644 --- a/Source/Test/HtmlRenderer.Test/Css/BackgroundPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/BackgroundPropertyTests.cs @@ -3,11 +3,11 @@ namespace HtmlRenderer.Test.Css; -/// Ported from PeachPDF.Tests/CSS/PropertyTests/BackgroundProperty.cs. Pure CSSOM parse tests - no layout involved. [TestClass] public sealed class BackgroundPropertyTests { [TestMethod] + [Spec("css-backgrounds-3", "#background-attachment")] public void BackgroundAttachmentScrollLegal() { var snippet = "background-attachment : scroll"; @@ -36,6 +36,7 @@ public void BackgroundAttachmentInitialLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-attachment")] public void BackgroundAttachmentFixedUppercaseLegal() { var snippet = "background-attachment : Fixed "; @@ -50,6 +51,7 @@ public void BackgroundAttachmentFixedUppercaseLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-attachment")] public void BackgroundAttachmentFixedLocalLegal() { var snippet = "background-attachment : fixed , local "; @@ -64,6 +66,7 @@ public void BackgroundAttachmentFixedLocalLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-attachment")] public void BackgroundAttachmentFixedLocalScrollScrollLegal() { var snippet = "background-attachment : fixed , local,scroll,scroll "; @@ -78,6 +81,7 @@ public void BackgroundAttachmentFixedLocalScrollScrollLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-attachment")] public void BackgroundAttachmentNoneIllegal() { var snippet = "background-attachment : none "; @@ -91,6 +95,7 @@ public void BackgroundAttachmentNoneIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-clip")] public void BackgroundClipPaddingBoxUppercaseLegal() { var snippet = "background-clip : Padding-Box "; @@ -105,6 +110,7 @@ public void BackgroundClipPaddingBoxUppercaseLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-clip")] public void BackgroundClipPaddingBoxBorderBoxLegal() { var snippet = "background-clip : Padding-Box, border-box "; @@ -119,6 +125,7 @@ public void BackgroundClipPaddingBoxBorderBoxLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-clip")] public void BackgroundClipContentBoxLegal() { var snippet = "background-clip : content-box"; @@ -228,8 +235,7 @@ public void BackgroundImageUrlLegal() [DataRow("background-image: element(#hero)")] public void BackgroundImageExtendedFunctionLegal(string snippet) { - // image-set()/cross-fade()/element() are valid values (CSS Images 4) and now parse via the - // shared ImageSourceConverter, even though PeachPDF renders nothing for them (issue #229 gap 3). + // Valid 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(property); @@ -291,6 +297,7 @@ public void BackgroundImageUrlNoneUrlLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-origin")] public void BackgroundOriginContentBoxLegal() { var snippet = "background-origin: CONTENT-BOX"; @@ -305,6 +312,7 @@ public void BackgroundOriginContentBoxLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-origin")] public void BackgroundOriginContentBoxPaddingBoxLegal() { var snippet = "background-origin: CONTENT-BOX, Padding-Box"; @@ -319,6 +327,7 @@ public void BackgroundOriginContentBoxPaddingBoxLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-origin")] public void BackgroundOriginBorderBoxLegal() { var snippet = "background-origin: border-box"; @@ -333,6 +342,7 @@ public void BackgroundOriginBorderBoxLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPositionTopLegal() { var snippet = "background-position: top"; @@ -347,6 +357,7 @@ public void BackgroundPositionTopLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPositionPercentPercentLegal() { var snippet = "background-position: 25% 75%"; @@ -361,6 +372,7 @@ public void BackgroundPositionPercentPercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPositionCenterPercentLegal() { var snippet = "background-position: center 75%"; @@ -375,6 +387,7 @@ public void BackgroundPositionCenterPercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPositionRightLengthBottomLengthLegal() { var snippet = "background-position: right 20px bottom 20px"; @@ -389,6 +402,7 @@ public void BackgroundPositionRightLengthBottomLengthLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPositionLengthLengthCenterMultipleLegal() { var snippet = "background-position: 10px 20px, center"; @@ -403,6 +417,7 @@ public void BackgroundPositionLengthLengthCenterMultipleLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-position")] public void BackgroundPositionZeroMultipleLegal() { var snippet = "background-position: 0 0, 0 0"; @@ -417,6 +432,7 @@ public void BackgroundPositionZeroMultipleLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRepeatXLegal() { var snippet = "background-repeat: repeat-x"; @@ -431,6 +447,7 @@ public void BackgroundRepeatRepeatXLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRepeatYLegal() { var snippet = "background-repeat: repeat-y"; @@ -445,6 +462,7 @@ public void BackgroundRepeatRepeatYLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRepeatLegal() { var snippet = "background-repeat: REPEAT"; @@ -459,6 +477,7 @@ public void BackgroundRepeatRepeatLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRoundLegal() { var snippet = "background-repeat: rounD"; @@ -473,6 +492,7 @@ public void BackgroundRepeatRoundLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRepeatSpaceLegal() { var snippet = "background-repeat: repeat space"; @@ -487,6 +507,7 @@ public void BackgroundRepeatRepeatSpaceLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRepeatXSpaceIllegal() { var snippet = "background-repeat: repeat-x space"; @@ -500,6 +521,7 @@ public void BackgroundRepeatRepeatXSpaceIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatRepeatXRepeatYMultipleLegal() { var snippet = "background-repeat: repeat-X, repeat-Y"; @@ -514,6 +536,7 @@ public void BackgroundRepeatRepeatXRepeatYMultipleLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatSpaceRoundLegal() { var snippet = "background-repeat: space round"; @@ -528,6 +551,7 @@ public void BackgroundRepeatSpaceRoundLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-repeat")] public void BackgroundRepeatNoRepeatRepeatXIllegal() { var snippet = "background-repeat: no-repeat repeat-x"; @@ -541,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"; @@ -555,6 +580,7 @@ public void BackgroundRepeatRepeatRepeatNoRepeatRepeatLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeLengthLegal() { var snippet = "background-size: 2em"; @@ -569,6 +595,7 @@ public void BackgroundSizeLengthLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizePercentLegal() { var snippet = "background-size: 20%"; @@ -583,6 +610,7 @@ public void BackgroundSizePercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeAutoAutoLegal() { var snippet = "background-size: auto auto"; @@ -597,6 +625,7 @@ public void BackgroundSizeAutoAutoLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeAutoLengthLegal() { var snippet = "background-size: auto 50px"; @@ -611,6 +640,7 @@ public void BackgroundSizeAutoLengthLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeLengthLengthLegal() { var snippet = "background-size: 25px 50px"; @@ -625,6 +655,7 @@ public void BackgroundSizeLengthLengthLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizePercentPercentLegal() { var snippet = "background-size: 50% 50%"; @@ -639,6 +670,7 @@ public void BackgroundSizePercentPercentLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeAutoUppercaseLegal() { var snippet = "background-size: AUTO"; @@ -653,6 +685,7 @@ public void BackgroundSizeAutoUppercaseLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeCoverLegal() { var snippet = "background-size: cover"; @@ -667,6 +700,7 @@ public void BackgroundSizeCoverLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeContainCoverMultipleLegal() { var snippet = "background-size: contain,cover"; @@ -681,6 +715,7 @@ public void BackgroundSizeContainCoverMultipleLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#background-size")] public void BackgroundSizeContainLengthAutoPercentLegal() { var snippet = "background-size: contain,100px,auto,20%"; @@ -874,6 +909,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 +930,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 +941,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 +953,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 +984,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"); @@ -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/BasicShapeGrammarTests.cs b/Source/Test/HtmlRenderer.Test/Css/BasicShapeGrammarTests.cs index c30629cc8..7f98ffd47 100644 --- a/Source/Test/HtmlRenderer.Test/Css/BasicShapeGrammarTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/BasicShapeGrammarTests.cs @@ -6,10 +6,8 @@ namespace HtmlRenderer.Test.Css; /// -/// Ported from PeachPDF.Tests/CSS/BasicShapeGrammarTests.cs. -/// Tests for the shared (Layer-agnostic parse of the -/// polygon()/inset()/circle()/ellipse() basic-shape grammar) and the clip-path -/// Layer-A converter () that accepts/rejects and preserves it. +/// The <basic-shape> grammar of and the clip-path values built on it. +/// The and the clip-path values it validates. /// [TestClass] public sealed class BasicShapeGrammarTests @@ -20,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%)"); @@ -103,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); @@ -205,6 +205,7 @@ public void Circle_PositionRightBottom_ResolveToHundredPercent() [DataRow("circle(-5px)")] // negative 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)); @@ -224,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%)"); @@ -248,26 +250,53 @@ 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)); } - // ─── Layer A: converter accept/reject via the parser ──────────────────── + // ─── clip-path ────────────────────────────────────────────────────────── [TestMethod] [DataRow("none")] @@ -275,6 +304,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 +312,6 @@ public void ClipPath_ValidValue_SurvivesParsing(string value) Assert.IsInstanceOfType(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 +319,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 +329,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/Css/BorderImagePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/BorderImagePropertyTests.cs index b130ed185..0c76ae204 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; -/// Ported from PeachPDF.Tests/CSS/PropertyTests/BorderImageProperty.cs. Pure CSSOM parse tests - no layout involved. [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,8 @@ 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)"; @@ -36,6 +38,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 +53,8 @@ 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"; @@ -64,6 +69,7 @@ public void BorderImageOutsetZeroLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-outset")] public void BorderImageOutsetLengthPercentLegal() { var snippet = "border-image-outset: 10px 25%"; @@ -78,6 +84,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 +99,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 +114,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 +128,7 @@ public void BorderImageOutsetZerosIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-width")] public void BorderImageWidthZeroLegal() { var snippet = "border-image-width: 0"; @@ -133,6 +143,8 @@ 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"; @@ -147,6 +159,7 @@ public void BorderImageWidthAutoLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-width")] public void BorderImageWidthMultipleLegal() { var snippet = "border-image-width: 5"; @@ -161,6 +174,7 @@ public void BorderImageWidthMultipleLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-width")] public void BorderImageWidthLengthPercentLegal() { var snippet = "border-image-width: 10px 25%"; @@ -175,6 +189,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 +204,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 +219,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 +233,7 @@ public void BorderImageWidthZerosIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-repeat")] public void BorderImageRepeatStretchUppercaseLegal() { var snippet = "border-image-repeat: StRETCH"; @@ -230,6 +248,7 @@ public void BorderImageRepeatStretchUppercaseLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-repeat")] public void BorderImageRepeatRepeatLegal() { var snippet = "border-image-repeat: repeat"; @@ -244,6 +263,8 @@ 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"; @@ -258,6 +279,7 @@ public void BorderImageRepeatRoundLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-repeat")] public void BorderImageRepeatStretchRoundLegal() { var snippet = "border-image-repeat: stretch round"; @@ -272,6 +294,7 @@ public void BorderImageRepeatStretchRoundLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-repeat")] public void BorderImageRepeatNoRepeatIllegal() { var snippet = "border-image-repeat: no-repeat"; @@ -285,6 +308,7 @@ public void BorderImageRepeatNoRepeatIllegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-slice")] public void BorderImageSlicePixelsLegal() { var snippet = "border-image-slice: 3"; @@ -299,6 +323,7 @@ public void BorderImageSlicePixelsLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image-slice")] public void BorderImageSlicePercentLegal() { var snippet = "border-image-slice: 10%"; @@ -313,7 +338,10 @@ public void BorderImageSlicePercentLegal() } [TestMethod] - public void BorderImageSliceFillLegal() + [Ignore("Gap, see Spec/css-backgrounds-3.yml")] + [Spec("css-backgrounds-3", "#border-image-slice")] + [Spec("css-borders-4", "#border-image-slice")] + public void BorderImageSliceFillIllegal() { var snippet = "border-image-slice: fill"; var property = CssConstructionFunctions.ParseDeclaration(snippet); @@ -322,15 +350,11 @@ public void BorderImageSliceFillLegal() Assert.IsInstanceOfType(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] + [Spec("css-backgrounds-3", "#border-image-slice")] public void BorderImageSlicePercentFillLegal() { var snippet = "border-image-slice: 10% fill"; @@ -345,6 +369,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 +384,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); @@ -368,11 +395,11 @@ public void BorderImageSlicePercentPixelsFillZerosLegal() Assert.IsInstanceOfType(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] + [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,8 @@ 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"; @@ -427,6 +458,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 +473,7 @@ public void BorderImageUrlOffsetRepeatLegal() } [TestMethod] + [Spec("css-backgrounds-3", "#border-image")] public void BorderImageUrlStretchUppercaseLegal() { var snippet = "border-image: url(image.png) STRETCH"; @@ -455,6 +488,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 +503,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 +518,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/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; /// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/BorderProperty.cs. -/// The CSS engine port replaced the old hand-rolled border parsing entirely - both the -/// Dictionary<string,string> longhand expansion via CssParser.AddProperty/ -/// SplitMultiDirectionValues (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 CssParser.ParseBorder 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 and the resulting -/// StyleDeclaration'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". /// [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/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; -/// Ported from PeachPDF.Tests/CSS/PropertyTests/BorderRadiusProperty.cs. Pure CSSOM parse tests, -/// including CSS-text round-trip/simplification tests via . [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..5f74d3caa 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; -/// -/// Ported from PeachPDF.Tests/CSS/BoxShadowGrammarTests.cs. -/// Tests for the shared (the box-shadow value grammar -/// none | [ inset? && <length>{2,4} && <color>? ]#) and its -/// Layer-A accept/reject via the full parser. -/// [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,8 @@ 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"); @@ -61,6 +60,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 +74,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 +90,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 +102,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 +113,7 @@ public void EmValidLengthsAreKeptAsAuthoredStrings() } [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] public void NegativeOffsetsAndSpread_AreValid() { var layers = Parse("-2px -3px 4px -1px"); @@ -121,6 +125,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 +137,7 @@ public void MultipleLayers_ParseInOrder() } [TestMethod] + [Spec("css-backgrounds-3", "#box-shadow")] [DataRow("")] [DataRow("banana")] // no lengths [DataRow("2px")] // only one length @@ -151,6 +157,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/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; -/// Ported from PeachPDF.Tests/CSS/PropertyTests/BoxSizingPropertyTests.cs. Pure CSSOM parse tests. [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/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; /// -/// Ported from PeachPDF.Tests/CSS/CalcEvaluatorTests.cs. -/// Tests for 's angle-leaf evaluation: a calc() whose value is an -/// <angle> (e.g. calc(1turn * 0.35)) 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 calc(1turn * <value>). -/// -/// HTML-Renderer's 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 false below (and returnPoints keeps its default of false) to match PeachPDF's -/// 3-arg semantics. +/// on non-length calc() values: angles evaluate to radians, times to +/// milliseconds and resolutions to dppx. /// [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 } - // ──
    Tall
    a
    "); + + Assert.AreEqual("left", x.TextAlign); + } + + [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("a"); + + Assert.AreEqual(Green, x.ActualColor); + } + + [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")] + [Spec("css-cascade-5", "#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/CasesTests.cs b/Source/Test/HtmlRenderer.Test/Css/CasesTests.cs index 01e027fe9..e06e064af 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 { @@ -18,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"";"); @@ -163,6 +159,7 @@ public void StyleSheetCommaSelectorFunction() } [TestMethod] + [Spec("css21", "syndata.html#comments")] public void StyleSheetCommentIn() { var sheet = ParseSheet(@"a { @@ -187,6 +184,7 @@ public void StyleSheetCommentIn() } [TestMethod] + [Spec("css21", "syndata.html#comments")] public void StyleSheetCommentUrl() { var sheet = ParseSheet(@"/* http://foo.com/bar/baz.html */ @@ -203,6 +201,7 @@ public void StyleSheetCommentUrl() } [TestMethod] + [Spec("css21", "syndata.html#comments")] public void StyleSheetComment() { var sheet = ParseSheet(@"/* 1 */ @@ -279,6 +278,7 @@ public void StyleSheetEmpty() } [TestMethod] + [Spec("css21", "syndata.html#characters")] public void StyleSheetEscapes() { var sheet = ParseSheet(@"/* tests compressed for easy testing */ @@ -866,6 +866,7 @@ public void StyleSheetMessedUp() } [TestMethod] + [Spec("css-namespaces-3", "#syntax")] public void StyleSheetNamespaceLinebreak() { var sheet = ParseSheet(@"@namespace @@ -875,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""; @@ -936,6 +938,7 @@ tobi loki jane { } [TestMethod] + [Spec("css21", "syndata.html#strings")] public void StyleSheetQuoteEscape() { var sheet = ParseSheet(@"p[qwe=""a\"",b""] { color: red } @@ -947,6 +950,7 @@ public void StyleSheetQuoteEscape() } [TestMethod] + [Spec("css21", "syndata.html#declaration")] public void StyleSheetQuoted() { var sheet = ParseSheet(@"body { @@ -1058,6 +1062,7 @@ public void StyleSheetWtf() } [TestMethod] + [Spec("css21", "syndata.html#characters")] public void StyleSheetUnicodeEscapeLiteral() { var sheet = ParseSheet(@"h1 { background-color: \000062 @@ -1066,6 +1071,7 @@ public void StyleSheetUnicodeEscapeLiteral() } [TestMethod] + [Spec("css21", "syndata.html#characters")] public void StyleSheetUnicodeEscapeVarious() { var sheet = ParseSheet( 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; /// -/// Ported from PeachPDF.Tests/CSS/ColorInterpolationMethodGrammarTests.cs. -/// Tests for the shared (the single home of the gradient -/// in <color-interpolation-method> grammar consumed by both the CSS-OM validator and the -/// render-time parser). -/// -/// The grammar validates only the in … 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 "longer hue") and is rejected downstream. +/// only takes the in … part out of a gradient's +/// arguments, the remainder is validated by the gradient, e.g. a hue method after a rectangular space. /// [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 b708d0bfe..5286fa2ac 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"; @@ -27,6 +22,7 @@ public void ColorInvalidHexDigitString() } [TestMethod] + [Spec("css-color-4", "#hex-notation")] public void ColorValidFourLetterString() { var color = "abcd"; @@ -36,6 +32,7 @@ public void ColorValidFourLetterString() } [TestMethod] + [Spec("css21", "syndata.html#color-units")] public void ColorInvalidLengthString() { var color = "abcde"; @@ -60,6 +57,7 @@ public void ColorValidLengthLongString() } [TestMethod] + [Spec("css21", "syndata.html#color-units")] public void ColorWhiteShortString() { var color = "fff"; @@ -68,6 +66,7 @@ public void ColorWhiteShortString() } [TestMethod] + [Spec("css21", "syndata.html#color-units")] public void ColorRedShortString() { var color = "f00"; @@ -76,6 +75,7 @@ public void ColorRedShortString() } [TestMethod] + [Spec("css21", "syndata.html#color-units")] public void ColorFromRedName() { var color = "red"; @@ -85,6 +85,7 @@ public void ColorFromRedName() } [TestMethod] + [Spec("css21", "syndata.html#color-units")] public void ColorFromWhiteName() { var color = "white"; @@ -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/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; -/// Ported from PeachPDF.Tests/CSS/PropertyTests/ColumnGapProperty.cs. Pure CSSOM parse tests - no layout involved. [TestClass] public sealed class ColumnGapPropertyTests { [TestMethod] [DynamicData(nameof(ColumnGapTestDataValues))] + [Spec("css-multicol-1", "#cg")] public void ColumnGapLegalValues(string value) => CssConstructionFunctions.TestForLegalValue(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(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; -/// Ported from PeachPDF.Tests/CSS/PropertyTests/ColumnsProperty.cs. Pure CSSOM parse tests - no layout involved. [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(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(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/ConformanceTests.cs b/Source/Test/HtmlRenderer.Test/Css/ConformanceTests.cs new file mode 100644 index 000000000..9cbc5b669 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/ConformanceTests.cs @@ -0,0 +1,78 @@ +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")] + [Spec("html", "semantics.html#fetching-and-processing-a-resource-from-a-link-element")] + 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")] + [Spec("html", "semantics.html#processing-the-type-attribute")] + public void TextCssResource_IsUsedAsAStyleSheet() + { + Assert.AreEqual(Green, ColorOf($"")); + } + + [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/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; -/// Ported from PeachPDF.Tests/CSS/Container.cs. [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/ContentPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/ContentPropertyTests.cs index 4d9a704bf..a66fff65f 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; /// -/// 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. /// [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,8 @@ 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)}"; @@ -57,6 +62,8 @@ 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)}"; @@ -65,6 +72,8 @@ 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)}"; @@ -73,6 +82,8 @@ 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)}"; @@ -81,6 +92,8 @@ 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)}"; @@ -89,6 +102,8 @@ 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)}"; @@ -97,6 +112,8 @@ 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)}"; @@ -105,6 +122,8 @@ 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)}"; @@ -113,6 +132,8 @@ 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)}"; @@ -121,6 +142,8 @@ 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)}"; @@ -129,6 +152,8 @@ 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)}"; @@ -137,6 +162,8 @@ 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)}"; @@ -145,6 +172,8 @@ 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)}"; @@ -153,6 +182,8 @@ 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)}"; @@ -161,6 +192,8 @@ 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)}"; @@ -169,6 +202,8 @@ 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)}"; @@ -177,6 +212,8 @@ 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/CoordinatePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/CoordinatePropertyTests.cs index 1d8bca295..71e189bc5 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; -/// -/// 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 CssParser.ParseCssBlock/CssData.GetCssBlock 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 -/// , returning an IStyleRule whose Style -/// (a StyleDeclaration) exposes each longhand as a typed, validating property - an invalid value -/// is simply never stored, so GetPropertyValue/the string indexer returns "" rather than throwing -/// or keeping a "has no value" flag. Validity is exercised through that same real pipeline (tokenizer -> -/// grammar -> value converter) that inline "style" attributes and stylesheet rules go through. -/// [TestClass] public sealed class CoordinatePropertyTests { @@ -29,68 +14,123 @@ 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")); } [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")); } [TestMethod] + [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void CssHeightLegalLengthInPx() { Assert.AreEqual("144px", GetProperty("height: 144px", "height")); } [TestMethod] + [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void CssHeightLegalAutoUppercase() { Assert.AreEqual("auto", GetProperty("height: AUTO", "height")); } [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")); } [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")); } [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"))); } [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")); } [TestMethod] + [Spec("css-sizing-3", "#preferred-size-properties")] + [Spec("css-sizing-4", "#sizing-properties")] public void CssWidthLegalLengthInInches() { Assert.AreEqual("3in", GetProperty("width: 3in", "width")); } [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"))); } [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"))); } + + [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) + { + Assert.AreEqual("none", GetProperty($"{property}: none", 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) + { + 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/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/Source/Test/HtmlRenderer.Test/Css/CssBackgrounds4ParseTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssBackgrounds4ParseTests.cs new file mode 100644 index 000000000..6e8e93aee --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/CssBackgrounds4ParseTests.cs @@ -0,0 +1,147 @@ +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] + [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")] + [Spec("css-backgrounds-3", "#background-position")] + public void BackgroundPosition_ThreeValueFormWithVerticalEdgeFirstIsAccepted(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")] + [Spec("css-backgrounds-3", "#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/Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs new file mode 100644 index 000000000..a8eae5eca --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/CssBorders4ParseTests.cs @@ -0,0 +1,156 @@ +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")] + [Spec("css-images-4", "#stripes")] + 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")] + [Spec("css-backgrounds-3", "#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/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; /// -/// Ported from PeachPDF.Tests/CSS/CssDataRuleIndexingTests.cs. -/// Regression tests for CssData'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 's -/// GetUserAgentStyleRules/GetAuthorStyleRules exactly as the cascade does - the index only -/// narrows candidates, DoesSelectorMatch 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 CssData only narrows the candidates, a bucketing bug shows up as +/// a rule that doesn't apply, or applies where it shouldn't. /// [TestClass] public sealed class CssDataRuleIndexingTests 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; + +/// Which CSS Fonts Level 4 values and descriptors the CSS engine accepts. +[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().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/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( + "
    a
    b
    ")); + 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( + "
    a
    b
    ")); + + 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/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/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; /// -/// Ported from PeachPDF.Tests/CSS/CssNestingIntegrationTests.cs. -/// CSS Nesting (CSS Nesting 1): the & -/// nesting selector and nested style rules inside a declaration block. Nested rules are resolved at parse -/// time into ordinary rules with absolute selectors (& → :is(parent)), 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 (& becomes +/// :is(parent)), 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. /// [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; } }", "
    textinner
    "); @@ -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; } }", "
    x
    "); @@ -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; } }", "

    x

    "); @@ -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; } }", "

    x

    "); @@ -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; }", "
    x
    "); 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; } }", "

    x

    y

    "); @@ -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; }", "
    tx
    "); @@ -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; } } }", "
    x
    "); @@ -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; } }", "
    1
    2
    "); @@ -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; } } }", "

    x

    "); @@ -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; } } }", "

    x

    "); 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); } }", "

    x

    "); 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; } }", "

    x

    "); @@ -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; } }", "

    x

    "); @@ -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; } }", "
    x" + "y
    " + "z"); - 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; } }", "
    x
    "); @@ -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; } }", "
    x
    "); Assert.AreEqual("rgb(0, 0, 255)", Box(html, "e").Color); } - // ── Helpers ──────────────────────────────────────────────────────────────── - private static string Html(string css, string body) => $"{body}"; diff --git a/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs index 1f1d5ad14..5b920e739 100644 --- a/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs @@ -4,33 +4,22 @@ 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")] + [Spec("css-cascade-5", "#cascade-sort")] 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")] + [Spec("css-cascade-5", "#cascade-sort")] public void EqualSpecificity_SameOrigin_StillResolvesByLastDeclared() { var html = Html("div { color: #ff0000; } div { color: #0000ff; }", "
    text
    "); @@ -39,26 +28,23 @@ public void EqualSpecificity_SameOrigin_StillResolvesByLastDeclared() } [TestMethod] + [Spec("css21", "cascade.html#cascading-order")] + [Spec("css-conditional-3", "#processing")] 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; }", + "@media screen { div { color: #0000ff; } } div { color: #ff0000; }", "
    text
    "); var box = FindBoxByTag(html, "div"); Assert.AreEqual(RColor.FromArgb(255, 0, 0), box.ActualColor); } [TestMethod] + [Spec("css21", "cascade.html#specificity")] + [Spec("selectors-4", "#specificity-rules")] 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 +52,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/CssText3PropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssText3PropertyTests.cs new file mode 100644 index 000000000..a9adc6da2 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/CssText3PropertyTests.cs @@ -0,0 +1,110 @@ +using System.Linq; +using HtmlRenderer.Test.CssEngineSupport; +using TheArtOfDev.HtmlRenderer.Core.CssEngine; + +namespace HtmlRenderer.Test.Css; + +/// Which CSS Text Level 3 values the CSS engine accepts. +[TestClass] +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")] + [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")] + [Spec("css-text-4", "#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")] + [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")] + 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")] + [Spec("css-text-4", "#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")] + [Spec("css-text-4", "#white-space-property")] + public void WhiteSpace_BreakSpaces_IsParsed() + { + Assert.AreEqual("break-spaces", Value("white-space", "break-spaces")); + } + + [TestMethod] + [Spec("css-text-3", "#hyphenation")] + [Spec("css-text-4", "#hyphens-property")] + [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")] + [Spec("css-text-4", "#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")] + [Spec("css-text-4", "#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().Single().Style.GetPropertyValue(property); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/CssUi3Tests.cs b/Source/Test/HtmlRenderer.Test/Css/CssUi3Tests.cs new file mode 100644 index 000000000..b2deff046 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/CssUi3Tests.cs @@ -0,0 +1,103 @@ +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("

    a

    ")); + + Assert.AreEqual("content-box", LayoutHarness.FindById(root, "x")!.BoxSizing); + } + + [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"); + + Assert.IsTrue(property.HasValue); + } + + [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"); + + Assert.IsFalse(property.HasValue); + } + + [TestMethod] + [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); + + Assert.IsTrue(property.HasValue); + } + + [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("
    ")); + 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/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; /// -/// 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. /// [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/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; -/// -/// Ported from PeachPDF.Tests/CSS/DocumentFunction.cs (CssDocumentFunctionTests). DocumentRule, -/// IDocumentFunction, and DomainFunction all match HTML-Renderer's -/// / Functions/DocumentFunction.cs. -/// [TestClass] public sealed class DocumentFunctionTests { 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; -/// -/// Ported from PeachPDF.Tests/CSS/DocumentTreePseudoClassTests.cs. -/// The three pseudo-classes that depend only on the document tree - :empty (Selectors 4 §9.5), -/// :any-link (§7.1) and :scope (§6.6) - are genuinely implemented in PeachPDF, but -/// HTML-Renderer's CssData.DoesSelectorMatch(PseudoClassSelector, CssBox) (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 ICssDomNode/SvgCssBoxDomNode duality) in this engine to exercise. The two -/// tests that inspected PeachPDF's ICssDomNode facade directly (no HTML-Renderer counterpart) are -/// adapted to check the equivalent state directly instead. -/// [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("

    ", true)] - [DataRow("

    ", true)] - [DataRow("

    \n\t\r\n

    ", true)] - // A comment is not a child for :empty's purposes (and never reaches the box tree at all). - [DataRow("

    ", true)] - [DataRow("

    \n \n

    ", true)] - // Any text that is not white space. - [DataRow("

    x

    ", false)] - [DataRow("

    x

    ", false)] - // U+00A0 is significant content, not collapsible white space. - [DataRow("

     

    ", false)] - // Any element child, even an empty or void one. - [DataRow("

    ", false)] - [DataRow("


    ", 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("

    ")] + [DataRow("

    ")] + [DataRow("

    \n\t\r\n

    ")] + [DataRow("

    ")] + [DataRow("

    \n \n

    ")] + [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("

    x

    ")] + [DataRow("

    x

    ")] + [DataRow("

     

    ")] + [DataRow("

    ")] + [DataRow("


    ")] + [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"); Assert.AreEqual(Blue, box.Color); } [TestMethod] [Ignore(IgnoreReason)] + [Spec("selectors-4", "#the-empty-pseudo")] public void Empty_IsUnaffectedByASynthesizedMarker() { // An
  • '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; }", "

    Title

    "); 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; }", "
    loose text

    block

    ")); @@ -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; }", "
    loose text

    block

    ")); 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 = "
    linkanchor"; 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; }", "

    text

    ")); 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; }", "

    text

    "); @@ -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); }", "

    text

    "), "p"); Assert.AreEqual(Blue, box.Color); } - // ── Helpers (mirrors CssDataRuleIndexingTests.cs conventions) ──────────────────────────────── - - /// - /// Does match ? Asked through the ordinary - /// author-rule lookup, so the rule index and the matcher both run exactly as they do in a cascade. - /// 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/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; -/// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/FillProperty.cs (source class FillPropertyTests). -/// Covers the SVG paint/fill longhands: (paint - colors/"none"/"url()"), -/// , and , matching PeachPDF's grammar. -/// [TestClass] public sealed class FillPropertyTests { 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; -/// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/FlexPropertyTests.cs (source class FlexPropertyTests). -/// Pure CSSOM parse tests exercising the flex/align/justify shorthand and longhand -/// StyleDeclaration accessors. Distinct from , 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. -/// [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..13a78cd79 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FlexboxTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FlexboxTests.cs @@ -6,72 +6,91 @@ namespace HtmlRenderer.Test.Css; -/// Ported from PeachPDF.Tests/CSS/Flexbox.cs. Pure CSSOM parse tests - no layout involved. [TestClass] public sealed class FlexboxTests { [TestMethod] [DynamicData(nameof(FlexDirectionTestDataValues))] + [Spec("css-flexbox-1", "#flex-direction-property")] public void FlexDirectionLegalValues(string value) => CssConstructionFunctions.TestForLegalValue(PropertyNames.FlexDirection, value); [TestMethod] [DynamicData(nameof(FlexWrapTestDataValues))] + [Spec("css-flexbox-1", "#flex-wrap-property")] public void FlexWrapLegalValues(string value) => CssConstructionFunctions.TestForLegalValue(PropertyNames.FlexWrap, 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(PropertyNames.Order, value); [TestMethod] [DynamicData(nameof(FlexBasisTestDataValues))] + [Spec("css-flexbox-1", "#flex-basis-property")] public void FlexBasisLegalValues(string value) => CssConstructionFunctions.TestForLegalValue(PropertyNames.FlexBasis, value); [TestMethod] [DynamicData(nameof(FlexGrowShrinkTestDataValues))] + [Spec("css-flexbox-1", "#flex-grow-property")] public void FlexGrowLegalValues(string value) => CssConstructionFunctions.TestForLegalValue(PropertyNames.FlexGrow, value); [TestMethod] [DynamicData(nameof(FlexGrowShrinkTestDataValues))] + [Spec("css-flexbox-1", "#flex-shrink-property")] public void FlexShrinkLegalValues(string value) => CssConstructionFunctions.TestForLegalValue(PropertyNames.FlexShrink, 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(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(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(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(PropertyNames.JustifyContent, value); [TestMethod] [DynamicData(nameof(FlexFlowTestDataValues))] + [Spec("css-flexbox-1", "#flex-flow-property")] public void FlexFlowLegalValues(string value) => CssConstructionFunctions.TestForLegalValue(PropertyNames.FlexFlow, value); [TestMethod] [DynamicData(nameof(FlexTestDataValues))] + [Spec("css-flexbox-1", "#flex-property")] public void FlexLegalValues(string value) => CssConstructionFunctions.TestForLegalValue(PropertyNames.Flex, 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}"; @@ -82,6 +101,8 @@ 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}"; @@ -92,11 +113,13 @@ 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 = $"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 +149,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 +163,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 +178,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 +197,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 +251,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 +351,8 @@ public void FlexShorthandThreeValuesExpanded(string propertyValue, public static IEnumerable 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 +368,8 @@ public void FlexShorthandThreeValuesExpanded(string propertyValue, public static IEnumerable 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 +389,10 @@ public void FlexShorthandThreeValuesExpanded(string propertyValue, public static IEnumerable 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 FlexGrowShrinkTestDataValues => 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/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs index 42b8e60d5..4ba4e4977 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontFaceTests.cs @@ -1,17 +1,21 @@ 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; namespace HtmlRenderer.Test.Css; -/// Ported from PeachPDF.Tests/CSS/FontFace.cs. [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")] + [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}"; @@ -31,6 +35,9 @@ 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}"; @@ -65,15 +72,15 @@ public void FontFaceOpenSansNoSource() } [TestMethod] + [Spec("css-fonts-4", "#unicode-range-desc")] [DataRow("U+41-5A")] [DataRow("U+0-7F")] [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 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 +90,9 @@ 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")] + [Spec("css-fonts-4", "#unicode-range-desc")] [DataRow("U+4??", "400", "4FF")] [DataRow("U+??????", "000000", "FFFFFF")] [DataRow("U+41", "41", "41")] @@ -111,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/FontFamilyPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/FontFamilyPropertyTests.cs index e37c49e70..9a5e82f8b 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontFamilyPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontFamilyPropertyTests.cs @@ -4,21 +4,12 @@ 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")] + [Spec("css-fonts-4", "#font-family-prop")] public void CssFontFamilyMultipleWithIdentifiersLegal() { var property = ParseDeclaration("font-family: Gill Sans Extrabold, sans-serif "); @@ -45,6 +36,8 @@ 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 "); @@ -58,6 +51,8 @@ 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 "); @@ -71,10 +66,11 @@ 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 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 +81,10 @@ public void CssFontFamilyMultipleNumberIllegal() } [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] 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 +95,10 @@ public void CssFontFamilyMultipleFractionIllegal() } [TestMethod] + [Spec("css21", "fonts.html#font-family-prop")] + [Spec("css-fonts-4", "#font-family-name-syntax")] 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 +109,8 @@ 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 "); @@ -123,6 +123,8 @@ 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 "); @@ -135,6 +137,8 @@ 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 "); @@ -147,6 +151,8 @@ 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 36cee0e28..20f636401 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,8 @@ public sealed class FontKeywordPropertyTests // ── font-variant (base keywords only) ──────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css21", "fonts.html#small-caps")] + [Spec("css-fonts-4", "#font-variant-prop")] public void CssFontVariantNormalUppercaseLegal() { var property = ParseDeclaration("font-variant : NORMAL"); @@ -41,6 +28,8 @@ 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 "); @@ -54,6 +43,8 @@ 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 "); @@ -68,6 +59,8 @@ public void CssFontVariantSmallCapsIllegal() // ── font-style ──────────────────────────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css21", "fonts.html#font-styling")] + [Spec("css-fonts-4", "#font-style-prop")] public void CssFontStyleItalicLegal() { var property = ParseDeclaration("font-style : italic"); @@ -81,6 +74,8 @@ 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 "); @@ -94,10 +89,9 @@ public void CssFontStyleObliqueLegal() } [TestMethod] + [Spec("css-fonts-4", "#font-style-prop")] public void CssFontStyleObliqueWithAngleLegal() { - // CSS Fonts 4's "oblique " form - Converters.FontStyleConverter's second branch - // (Model/Converters.cs), a StartsWithValueConverter over AngleConverter. var property = ParseDeclaration("font-style : oblique 10deg"); Assert.AreEqual("font-style", property.Name); Assert.IsFalse(property.IsImportant); @@ -109,6 +103,7 @@ public void CssFontStyleObliqueWithAngleLegal() } [TestMethod] + [Spec("css-fonts-4", "#font-style-prop")] public void CssFontStyleObliqueWithNegativeAngleLegal() { var property = ParseDeclaration("font-style : oblique -10deg"); @@ -122,6 +117,8 @@ 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"); @@ -137,6 +134,8 @@ public void CssFontStyleNormalImportantLegal() // ── font-size ───────────────────────────────────────────────────────────────────────────────────────── [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"); @@ -150,6 +149,8 @@ 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"); @@ -163,6 +164,8 @@ 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"); @@ -176,6 +179,8 @@ 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 "); @@ -189,9 +194,10 @@ public void CssFontSizeRelativeLargerLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] 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 +208,8 @@ 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% "); @@ -215,6 +223,8 @@ 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 "); @@ -228,6 +238,8 @@ 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 "); @@ -241,10 +253,10 @@ public void CssFontSizeLengthLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-size-props")] + [Spec("css-fonts-4", "#font-size-prop")] 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 +269,8 @@ public void CssFontSizeNumberIllegal() // ── font-weight ─────────────────────────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css21", "fonts.html#font-boldness")] + [Spec("css-fonts-4", "#font-weight-prop")] public void CssFontWeightPercentllegal() { var property = ParseDeclaration("font-weight : 100% "); @@ -269,6 +283,8 @@ 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"); @@ -282,6 +298,8 @@ 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 +313,8 @@ 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,12 +328,8 @@ 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("Gap, see Spec/css-fonts-4.yml")] + [Spec("css-fonts-4", "#font-weight-prop")] public void CssFontWeight550Legal() { var property = ParseDeclaration("font-weight : 550 "); @@ -329,8 +345,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 +355,8 @@ 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 "); @@ -353,6 +369,8 @@ 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 "); @@ -367,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"); @@ -380,6 +399,7 @@ public void CssFontStretchNormalUppercaseImportantLegal() } [TestMethod] + [Spec("css-fonts-4", "#font-stretch-prop")] public void CssFontStretchExtraCondensedLegal() { var property = ParseDeclaration("font-stretch : extra-condensed "); @@ -393,10 +413,9 @@ public void CssFontStretchExtraCondensedLegal() } [TestMethod] + [Spec("css-fonts-4", "#font-stretch-prop")] 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); @@ -409,6 +428,8 @@ public void CssFontStretchSemiExpandedSpaceBetweenIllegal() // ── letter-spacing ──────────────────────────────────────────────────────────────────────────────────── [TestMethod] + [Spec("css-text-3", "#letter-spacing-property")] + [Spec("css-text-4", "#letter-spacing-property")] public void CssLetterSpacingLengthPxLegal() { var property = ParseDeclaration("letter-spacing: 3px "); @@ -422,6 +443,8 @@ 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 "); @@ -435,6 +458,8 @@ 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 "); @@ -448,6 +473,8 @@ 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 "); @@ -463,6 +490,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"); @@ -476,6 +504,7 @@ public void CssFontSizeAdjustNoneLegal() } [TestMethod] + [Spec("css-fonts-4", "#font-size-adjust-prop")] public void CssFontSizeAdjustNumberLegal() { var property = ParseDeclaration("font-size-adjust : 0.5"); @@ -489,10 +518,9 @@ public void CssFontSizeAdjustNumberLegal() } [TestMethod] + [Spec("css-fonts-4", "#font-size-adjust-prop")] 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/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; -/// -/// Ported from PeachPDF.Tests/CSS/FontPaletteParsingTests.cs. -/// Tests for (the font-palette property) and -/// (the @font-palette-values at-rule), confirmed an exact -/// match against PeachPDF's implementation. -/// +/// Parsing of the font-palette property and the @font-palette-values rule. [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 2b0ea4992..ae85dfb53 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FontShorthandPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FontShorthandPropertyTests.cs @@ -4,24 +4,12 @@ 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")] + [Spec("css-fonts-4", "#font-prop")] public void CssFontShorthandWithFractionLegal() { var property = ParseDeclaration("font : 12px/14px sans-serif "); @@ -35,6 +23,8 @@ 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 "); @@ -48,10 +38,11 @@ public void CssFontShorthandPercentLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] 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 +54,8 @@ 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 "); @@ -76,6 +69,8 @@ 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 "); @@ -89,6 +84,8 @@ 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"); @@ -102,6 +99,8 @@ 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 "); @@ -115,23 +114,10 @@ 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")] + [Spec("css-fonts-4", "#font-prop")] 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 +129,8 @@ 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 "); @@ -156,11 +144,10 @@ public void CssFontWeightVariantSizeFamiliesLegal() } [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] 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 +161,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 +172,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 +186,8 @@ public void CssFontInitialAll() } [TestMethod] + [Spec("css21", "fonts.html#font-shorthand")] + [Spec("css-fonts-4", "#font-prop")] public void CssFontInheritAll() { var property = ParseDeclaration("font: inherit "); @@ -237,17 +212,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/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; -/// Ported from PeachPDF.Tests/CSS/PropertyTests/GapProperty.cs (source class GapPropertyTests). [TestClass] public sealed class GapPropertyTests { @@ -14,11 +13,9 @@ public sealed class GapPropertyTests public void GapLegalValues(string value) => CssConstructionFunctions.TestForLegalValue(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(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/GlobalKeywordPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/GlobalKeywordPropertyTests.cs index 2ffa443c3..ba28b697a 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,8 @@ public sealed class GlobalKeywordPropertyTests // -- inherit --------------------------------------------------------- [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 +22,8 @@ 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"); @@ -31,6 +33,8 @@ 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"); @@ -40,6 +44,8 @@ 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"); @@ -51,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"); @@ -60,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"); @@ -69,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"); @@ -80,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"); @@ -89,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"); @@ -98,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"); @@ -109,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"); @@ -118,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"); @@ -127,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"); @@ -138,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"); @@ -147,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"); @@ -156,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"); @@ -167,6 +185,8 @@ public void Display_RevertLayer_HasRevertLayerValue() // -- !important is preserved alongside global keywords ----------------------- [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"); @@ -176,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"); @@ -185,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/GradientTests.cs b/Source/Test/HtmlRenderer.Test/Css/GradientTests.cs index 05e31551c..6e38ef83e 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; -/// Ported from PeachPDF.Tests/CSS/Gradient.cs. [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%)"; @@ -316,11 +341,7 @@ public void BackgroundImageRepeatingRadialGradientFunky() Assert.IsFalse(backgroundImage.IsInitial); } - // = {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%)")] @@ -328,6 +349,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); @@ -337,22 +359,18 @@ 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)] [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); 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)")] @@ -362,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)")] @@ -371,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}"); @@ -380,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 is not a valid angular position - [DataRow("conic-gradient(from red, blue, green)")] // "from" prelude needs an , 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 " 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)")] @@ -420,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}"); @@ -429,21 +445,17 @@ public void BackgroundImageGradientInterpolationMethodAccepted(string gradient) Assert.IsFalse(backgroundImage.IsInitial); } - // A malformed or unsupported "in " 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 then an invalid shape [DataRow("conic-gradient(in bogus, red, blue)")] [DataRow("conic-gradient(in oklab from red, red, blue)")] // in 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/GrammarTests.cs b/Source/Test/HtmlRenderer.Test/Css/GrammarTests.cs new file mode 100644 index 000000000..ccf1874cc --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/GrammarTests.cs @@ -0,0 +1,203 @@ +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")] + [Spec("css-syntax-3", "#consume-declaration")] + 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")] + [Spec("css-syntax-3", "#consume-comment")] + 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/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; -/// -/// Ported from PeachPDF.Tests/CSS/GridLineGrammarTests.cs. -/// Tests for the shared - the grid <grid-line> value -/// (auto | <integer> | span <integer>), confirmed a field-for-field match against -/// PeachPDF's implementation. -/// [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 - [DataRow("none")] // a CSS-wide/reserved keyword is not a custom-ident line name - [DataRow("initial")] - [DataRow("span foo")] // span 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; -/// -/// Ported from PeachPDF.Tests/CSS/GridTemplateAreasGrammarTests.cs. -/// Tests for the shared - the grid-template-areas value -/// (a rectangular grid of named cells), confirmed a field-for-field match against PeachPDF's -/// implementation. -/// [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; -/// -/// Ported from PeachPDF.Tests/CSS/GridTemplateShorthandTests.cs. -/// The grid / grid-template 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. -/// [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 / 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 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 [DataRow("grid-template: \"a\" \"b\" / repeat(2, 1fr);")] // trailing columns are an (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; -/// -/// Ported from PeachPDF.Tests/CSS/GridTrackListGrammarTests.cs. -/// Tests for the shared — the grid <track-list> -/// (grid-template-columns/-rows) and <track-size>+ (grid-auto-columns/ -/// -rows) value grammars. -/// [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 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'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 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/Source/Test/HtmlRenderer.Test/Css/ImportRuleTests.cs b/Source/Test/HtmlRenderer.Test/Css/ImportRuleTests.cs index fe6a0d8ac..654d8caac 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,8 @@ public void CssImportWithSingleQuotedUrl() } [TestMethod] + [Spec("css21", "cascade.html#at-import")] + [Spec("css-cascade-5", "#at-import")] public void CssImportWithDoubleQuotedStringAsUrl() { var source = "@import \"button.css\";"; @@ -55,6 +55,7 @@ public void CssImportWithDoubleQuotedStringAsUrl() } [TestMethod] + [Spec("css21", "cascade.html#at-import")] public void CssImportWithSingleQuotedStringAsUrl() { var source = "@import 'button.css';"; @@ -64,6 +65,7 @@ public void CssImportWithSingleQuotedStringAsUrl() } [TestMethod] + [Spec("css21", "cascade.html#at-import")] public void CssImportWithUrlAndAllMedia() { var media = "all"; @@ -75,6 +77,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/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; -/// Ported from PeachPDF.Tests/CSS/KeyframeRule.cs. [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/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; /// -/// Ported from PeachPDF.Tests/CSS/LengthTests.cs. -/// PeachPDF's Length is a small, rich value type: it has a two-argument (value, unit) -/// constructor, a ~40-member Unit 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), -/// IsAbsolute/IsRelative, UnitString, a static GetUnit/TryParse, -/// ToPixel()/several ToPixels(...) overloads that resolve relative-to-absolute -/// conversions against font/container/viewport/page context, a To(Unit) absolute-unit -/// converter, comparison operators/IComparable, value equality/GetHashCode, -/// IFormattable, and predefined constants (Zero/Half/Full/Thin/Medium/Thick). -/// -/// HTML-Renderer's internal is much smaller: it has only a single -/// string-parsing constructor (new CssLength("10px")), a 10-member 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 -/// IsPercentage/Number rather than being a value), Number, -/// HasError, IsPercentage, IsRelative (no IsAbsolute), Length (the -/// original string), ConvertEmToPoints(emSize)/ConvertEmToPixels(pixelFactor) (Ems-only, -/// both throw for any other unit), and a plain -/// ToString() override - no TryParse, no To(unit), no ToPixel/ -/// ToPixels, no comparison operators/IComparable, no value equality/GetHashCode -/// override, no IFormattable, 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 ConvertEmToPoints/ -/// ConvertEmToPixels 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 doesn't have (TryParse, To, ToPixel(s), -/// comparison/equality operators, IFormattable, predefined constants, IsAbsolute, UnitString, GetUnit) -/// are ported under [Ignore], 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 -/// , so there is no plausible compileable adaptation - constructing a -/// with any of those suffixes just falls into the "unrecognized unit" -/// HasError branch and asserts nothing meaningful. +/// only knows em, ex, px, mm, cm, in, pt, pc and rem, a percentage is tracked through +/// IsPercentage as a fraction. /// [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/ListPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/ListPropertyTests.cs index 44f55dfc9..f153347ef 100644 --- a/Source/Test/HtmlRenderer.Test/Css/ListPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/ListPropertyTests.cs @@ -4,18 +4,12 @@ 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")] + [Spec("css-lists-3", "#list-style-position-property")] public void CssListStylePositionOutsideLegal() { var property = ParseDeclaration("list-style-position: outside "); @@ -29,6 +23,8 @@ 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 "); @@ -41,6 +37,8 @@ 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 "); @@ -53,6 +51,8 @@ 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 "); @@ -66,6 +66,8 @@ 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 "); @@ -79,6 +81,8 @@ 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)"); @@ -92,6 +96,8 @@ 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 +111,8 @@ 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,13 +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 "); @@ -134,6 +142,8 @@ 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 "); @@ -147,14 +157,10 @@ public void CssListStyleTypeDecimalLeadingZeroLegal() } [TestMethod] + [Spec("css-lists-3", "#text-markers")] 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. + // 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); @@ -166,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; @@ -183,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: \"-> \" "); @@ -204,6 +198,7 @@ public void CssListStyleTypeStringLegal() } [TestMethod] + [Spec("css21", "generate.html#list-style")] public void CssListStyleTypeNumberIllegal() { var property = ParseDeclaration("list-style-type: number "); @@ -216,6 +211,8 @@ 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 +226,8 @@ 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"); @@ -242,6 +241,8 @@ 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 "); @@ -255,6 +256,8 @@ 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 "); @@ -268,6 +271,8 @@ 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;"); @@ -281,6 +286,8 @@ 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"); @@ -294,6 +301,8 @@ public void CssCounterResetSingleLegal() } [TestMethod] + [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#counter-reset")] public void CssCounterResetNoneLegal() { var property = ParseDeclaration("counter-reset: none"); @@ -307,6 +316,8 @@ public void CssCounterResetNoneLegal() } [TestMethod] + [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#counter-reset")] public void CssCounterResetNumberIllegal() { var property = ParseDeclaration("counter-reset: 3"); @@ -319,6 +330,8 @@ 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"); @@ -332,6 +345,8 @@ 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 "); @@ -345,6 +360,8 @@ public void CssCounterResetTwoCountersExplicitLegal() } [TestMethod] + [Spec("css21", "generate.html#counters")] + [Spec("css-lists-3", "#increment-set")] public void CssCounterIncrementNoneLegal() { var property = ParseDeclaration("counter-increment: none"); @@ -358,6 +375,8 @@ 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"); @@ -371,6 +390,8 @@ 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/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; /// -/// Ported from PeachPDF.Tests/CSS/LogicalPropertySerializationTests.cs. -/// PeachPDF resolves a logical box-model shorthand (e.g. margin-block) 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. /// [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().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/Css/MarginPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/MarginPropertyTests.cs index 73d180bb7..f090b9ca8 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; -/// -/// 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 CssParser.ParseCssBlock raw-property-dictionary API no longer exists - the CSS engine -/// port replaced it with a real, spec-compliant "margin" ShorthandProperty (the same vendored -/// engine PeachPDF itself uses), exercised here through and its -/// resulting StyleDeclaration'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. -/// [TestClass] public sealed class MarginPropertyTests { @@ -29,6 +14,8 @@ 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"; @@ -40,6 +27,8 @@ public void MarginAllZeroLegal() } [TestMethod] + [Spec("css-box-3", "#margin-shorthand")] + [Spec("css-box-4", "#margin-shorthand")] public void MarginAllPercentLegal() { const string declaration = "margin: 25%"; @@ -51,6 +40,8 @@ public void MarginAllPercentLegal() } [TestMethod] + [Spec("css-box-3", "#margin-shorthand")] + [Spec("css-box-4", "#margin-shorthand")] public void MarginAutoLegal() { const string declaration = "margin: auto"; @@ -62,6 +53,8 @@ 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"; @@ -73,6 +66,8 @@ 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"; @@ -84,6 +79,8 @@ 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"; @@ -95,6 +92,8 @@ 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%"; @@ -106,6 +105,8 @@ 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/MediaFeaturesTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaFeaturesTests.cs index 0f45414ef..a8efa3c0d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaFeaturesTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaFeaturesTests.cs @@ -2,14 +2,6 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/MediaFeatures.cs (CssMediaFeaturesTests). Only -/// CssMediaFeatureFactory has real assertions in the PeachPDF source - the other three facts -/// (CssMediaWidthValidation, CssMediaMaxHeightValidation, CssMediaMinDeviceWidthValidation, -/// CssMediaAspectRatio) have fully commented-out bodies there and are not ported. -/// MediaFeatureFactory.Instance.Create plus IsMaximum/IsMinimum match HTML-Renderer's -/// Factories/MediaFeatureFactory.cs. -/// [TestClass] public sealed class MediaFeaturesTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/MediaListTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaListTests.cs index 6ca541070..35cff69ea 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaListTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaListTests.cs @@ -4,16 +4,11 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/MediaList.cs (CssMediaListTests). @media parsing, the -/// CSSOM, Media Queries Level 4 range syntax, -/// and ToCss() round-trip all have identical shape to HTML-Renderer's Model/MediaList.cs / -/// Rules/MediaRule.cs. -/// [TestClass] public sealed class MediaListTests { [TestMethod] + [Spec("css21", "media.html#at-media-rule")] public void SimpleScreenMediaList() { var source = @"@media screen { @@ -30,6 +25,8 @@ public void SimpleScreenMediaList() } [TestMethod] + [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] public void MediaListAtIllegal() { var source = @"@media @screen { @@ -57,6 +54,7 @@ public void MediaListInterrupted() } [TestMethod] + [Spec("css21", "media.html#at-media-rule")] public void SimpleScreenTvMediaList() { var source = @"@media screen,tv { @@ -73,6 +71,7 @@ public void SimpleScreenTvMediaList() } [TestMethod] + [Spec("css21", "media.html#at-media-rule")] public void SimpleScreenTvSpacesMediaList() { var source = @"@media screen , tv { @@ -197,6 +196,8 @@ public void NoMediaQueryGivenSkip() } [TestMethod] + [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] public void NotNoMediaTypeOrExpressionSkip() { var source = @"@media not { @@ -225,6 +226,8 @@ public void OnlyNoMediaTypeOrExpressionSkip() } [TestMethod] + [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] public void MediaFeatureMissingSkip() { var source = @"@media () { @@ -256,6 +259,8 @@ 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) { @@ -270,6 +275,8 @@ public void FeatureMaxWidthMediaListMissingConnectedAnd() } [TestMethod] + [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] public void TvScreenMediaListMissingComma() { var source = @"@media tv screen { @@ -284,6 +291,8 @@ 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) { @@ -400,6 +409,8 @@ 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 fe08124c8..80e912ded 100644 --- a/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs @@ -5,23 +5,6 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/MediaQueryTests.cs. -/// HTML-Renderer's CSS engine port added real @media evaluation: -/// evaluates a rule's enclosing MediaList chain (parsed by the vendored engine's real grammar, -/// including "not"/"only" modifiers and comma-separated media lists - see Medium.IsInverse and a -/// MediaList's multiple comma-separated Medium entries) against a -/// built from . -/// The old standalone CssData.GetCssBlock(name, media)/ContainsCssBlock(name, media) bucket -/// API this file originally used no longer exists at all - CssData is now purely a rule index -/// queried per-box during the real cascade (CssData.GetStyleRules), so these tests instead drive -/// the whole pipeline via and read the resolved value off the laid-out box. -/// selects which device the layout runs under (mirroring how a real -/// PdfSharpAdapter/WinFormsAdapter reports its own DefaultMediaType). -/// Because "not"/"only" and comma-lists are now real, the three cases previously [Ignore]d as "not -/// yet spec compliant" (the whitespace-only splitter mis-tokenizing them) now genuinely pass against the -/// real grammar and are un-ignored below. -/// [TestClass] public sealed class MediaQueryTests { @@ -40,6 +23,8 @@ private static CssBox LayoutAndFindTag(string css, string bodyHtml, string tag, // ── basic single media type + nested rules ────────────────────────────────────────────────────────── [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; } }", "

    text

    ", "p", "print"); @@ -47,6 +32,8 @@ 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". @@ -55,6 +42,7 @@ public void AtMedia_Print_DoesNotApplyUnderScreenMedia() } [TestMethod] + [Spec("css21", "media.html#at-media-rule")] public void AtMedia_Screen_NestedRuleAppliesUnderScreenMedia() { var box = LayoutAndFindTag("@media screen { p { color: red; } }", "

    text

    ", "p", "screen"); @@ -62,6 +50,9 @@ 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; } }", "

    text

    ", "p", "screen"); @@ -69,6 +60,8 @@ 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; } }"; @@ -81,9 +74,11 @@ public void AtMedia_Print_MultipleNestedRulesAreParsed() Assert.AreEqual("12pt", divBox.FontSize); } - // ── "not" / "only" modifiers - now real ───────────────────────────────────────────────────────────── + // ── "not" / "only" modifiers ───────────────────────────────────────────────────────────────────────── [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; } }"; @@ -96,6 +91,8 @@ 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; } }"; @@ -107,9 +104,12 @@ public void AtMedia_OnlyPrint_AppliesOnlyUnderPrint() Assert.AreNotEqual(Red, underScreen.ActualColor); } - // ── comma-separated media list - now real ─────────────────────────────────────────────────────────── + // ── comma-separated media list ───────────────────────────────────────────────────────────────────── [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 new file mode 100644 index 000000000..94705e33b --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/MediaTypeTests.cs @@ -0,0 +1,181 @@ +using System.Text; +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.Css; + +[TestClass] +public sealed class MediaTypeTests +{ + private static readonly RColor Green = RColor.FromArgb(0, 128, 0); + + private static CssBox Box(string head, string mediaType = "screen") + { + var html = $"{head}

    a

    "; + var (root, _) = LayoutHarness.Layout(html, adapter: new MockAdapter { MediaType = mediaType }); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } + + private static RColor ColorOf(string head, string mediaType = "screen") => Box(head, mediaType).ActualColor; + + private static string DataCss(string css) => + "data:text/css;base64," + Convert.ToBase64String(Encoding.UTF8.GetBytes(css)); + + [TestMethod] + [Spec("css21", "media.html#media-sheets")] + [Spec("cssom-1", "#css-style-sheets")] + public void LinkWithMatchingMedia_Applies() + { + Assert.AreEqual(Green, ColorOf($"")); + } + + [TestMethod] + [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($"")); + } + + [TestMethod] + [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("")); + } + + [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")] + [Spec("css-conditional-3", "#at-media")] + 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")] + [Spec("css-conditional-3", "#processing")] + public void RulesOutsideAtMedia_ApplyToAllMediaTypes() + { + Assert.AreEqual(Green, ColorOf("", "print")); + } + + [TestMethod] + [Spec("css21", "media.html#at-media-rule")] + [Spec("css-conditional-3", "#contents-of")] + 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")] + [Spec("mediaqueries-4", "#media-types")] + [Spec("mediaqueries-5", "#media-types")] + public void All_MatchesEveryMediaType(string mediaType) + { + Assert.AreEqual(Green, ColorOf("", mediaType)); + } + + [TestMethod] + [Spec("css21", "media.html#media-types")] + [DataRow("SCREEN")] + [DataRow("Screen")] + [Spec("mediaqueries-4", "#mq-syntax")] + [Spec("mediaqueries-5", "#mq-syntax")] + 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")] + [Spec("mediaqueries-4", "#error-handling")] + [Spec("mediaqueries-5", "#error-handling")] + public void UnknownMediaType_IsTreatedAsNotPresent(string mediaList) + { + Assert.AreEqual(Green, ColorOf($"")); + } + + [TestMethod] + [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("")); + } + + [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("")); + } + + [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("")); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/ObjectSizingPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/ObjectSizingPropertyTests.cs index f2200d09f..88f0a99d2 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; -/// -/// Ported from PeachPDF.Tests/CSS/ObjectSizing.cs. Exercises object-fit -/// (, Source/HtmlRenderer/Core/CssEngine/StyleProperties/Sizing/ObjectFitProperty.cs) -/// and object-position (, same directory) through the CSS engine's -/// declaration parser, mirroring PeachPDF's ParseDeclaration helper via -/// . -/// [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,8 @@ 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"); @@ -56,6 +53,8 @@ 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"); @@ -70,6 +69,7 @@ public void CssObjectFitCoverLegal() } [TestMethod] + [Spec("css-images-3", "#the-object-fit")] public void CssObjectFitContainLegal() { var property = ParseDeclaration("object-fit : contain"); @@ -84,6 +84,7 @@ public void CssObjectFitContainLegal() } [TestMethod] + [Spec("css-images-3", "#the-object-position")] public void CssObjectPositionCenterLegal() { var property = ParseDeclaration("object-position : center"); @@ -98,6 +99,7 @@ public void CssObjectPositionCenterLegal() } [TestMethod] + [Spec("css-images-3", "#the-object-position")] public void ObjectPositionTopLeftIllegal() { var property = ParseDeclaration("object-position : top-left"); @@ -111,6 +113,7 @@ public void ObjectPositionTopLeftIllegal() } [TestMethod] + [Spec("css-images-3", "#the-object-position")] public void ObjectPositionTopLeftLegal() { var property = ParseDeclaration("object-position : top left"); @@ -125,6 +128,7 @@ public void ObjectPositionTopLeftLegal() } [TestMethod] + [Spec("css-images-3", "#the-object-position")] public void CssObjectPosition5050Legal() { var property = ParseDeclaration("object-position : 50% 50% "); @@ -139,6 +143,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/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; -/// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/OpacityPropertyTests.cs. -/// is a plain 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 -/// . -/// [TestClass] public sealed class OpacityPropertyTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs index d2b158037..6450f11c8 100644 --- a/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/OutlinePropertyTests.cs @@ -3,25 +3,12 @@ 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")] + [Spec("css-ui-3", "#outline-style")] public void CssOutlineStyleDottedLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-style : dotTED"); @@ -35,6 +22,9 @@ 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"); @@ -48,6 +38,7 @@ public void CssOutlineStyleSolidLegal() } [TestMethod] + [Spec("css-ui-3", "#outline-style")] public void CssOutlineStyleNoIllegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-style : no"); @@ -59,7 +50,20 @@ public void CssOutlineStyleNoIllegal() Assert.IsFalse(concrete.HasValue); } + [Ignore("Gap, see Spec/css21.yml")] [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"); + 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 "); @@ -73,6 +77,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) @@ -87,6 +92,7 @@ public void CssOutlineColorHslLegal() } [TestMethod] + [Spec("css-ui-3", "#outline-color")] public void CssOutlineColorHexLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-color : #0000FF "); @@ -100,6 +106,8 @@ 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 "); @@ -113,6 +121,7 @@ public void CssOutlineColorRedLegal() } [TestMethod] + [Spec("css-ui-3", "#outline-color")] public void CssOutlineColorIllegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-color : blau "); @@ -125,6 +134,8 @@ 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"); @@ -138,6 +149,7 @@ public void CssOutlineWidthThinImportantLegal() } [TestMethod] + [Spec("css-ui-3", "#outline-width")] public void CssOutlineWidthNumberIllegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-width : 3"); @@ -150,6 +162,7 @@ public void CssOutlineWidthNumberIllegal() } [TestMethod] + [Spec("css-ui-3", "#outline-width")] public void CssOutlineWidthLengthLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline-width : 0.1em"); @@ -163,6 +176,7 @@ public void CssOutlineWidthLengthLegal() } [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void CssOutlineSingleLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline : thin"); @@ -176,6 +190,7 @@ public void CssOutlineSingleLegal() } [TestMethod] + [Spec("css21", "ui.html#dynamic-outlines")] public void CssOutlineDualLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline : thin invert"); @@ -189,6 +204,8 @@ 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)"); @@ -202,6 +219,7 @@ public void CssOutlineAllDottedLegal() } [TestMethod] + [Spec("css-ui-3", "#outline")] public void CssOutlineDoubleColorIllegal() { var property = CssConstructionFunctions.ParseDeclaration("outline : dotted #123456 rgb(255, 255, 255)"); @@ -214,6 +232,9 @@ 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"); @@ -227,6 +248,7 @@ public void CssOutlineAllSolidLegal() } [TestMethod] + [Spec("css-ui-3", "#outline")] public void CssOutlineAllColorNamedLegal() { var property = CssConstructionFunctions.ParseDeclaration("outline : solid black 1px"); diff --git a/Source/Test/HtmlRenderer.Test/Css/PaddingPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/PaddingPropertyTests.cs index d2daf75d8..82c3a9c7e 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; -/// -/// 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 CssParser.ParseCssBlock raw-property-dictionary API no longer exists - the CSS engine -/// port replaced it with a real, spec-compliant "padding" ShorthandProperty (the same vendored -/// engine PeachPDF itself uses), exercised here through . -/// Unlike the old hand-rolled ParsePaddingProperty (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). -/// [TestClass] public sealed class PaddingPropertyTests { @@ -25,6 +14,8 @@ 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"; @@ -36,6 +27,8 @@ public void CssPaddingAllZeroLegal() } [TestMethod] + [Spec("css-box-3", "#padding-shorthand")] + [Spec("css-box-4", "#padding-shorthand")] public void CssPaddingAllPercentLegal() { const string declaration = "padding: 25%"; @@ -47,6 +40,8 @@ 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"; @@ -58,11 +53,10 @@ public void CssPaddingSidesLengthLegal() } [TestMethod] + [Spec("css-box-3", "#padding-shorthand")] + [Spec("css-box-4", "#padding-shorthand")] public void CssPaddingAutoIllegal() { - // "auto" is not a legal padding value (padding only accepts ) - 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 +66,8 @@ 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"; @@ -83,6 +79,8 @@ 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%"; @@ -94,6 +92,8 @@ 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/Css/PageRuleTests.cs b/Source/Test/HtmlRenderer.Test/Css/PageRuleTests.cs index c2413d216..f9c277878 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PageRuleTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PageRuleTests.cs @@ -5,25 +5,60 @@ 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. /// [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().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().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().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().Count()); + } + // ── @page { size: ... } ──────────────────────────────────────────────── [TestMethod] + [Spec("css-page-3", "#page-size-prop")] public void AtPage_SizeA4_IsStoredOnStyleDeclaration() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page { size: A4; }"); @@ -33,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; }"); @@ -42,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; }"); @@ -51,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; }"); @@ -62,6 +100,8 @@ public void AtPage_SizeExplicitDimensions_IsStoredOnStyleDeclaration() // ── @page pseudo-selectors ───────────────────────────────────────────── [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; }"); @@ -71,6 +111,8 @@ 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; }"); @@ -80,6 +122,8 @@ 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; }"); @@ -89,6 +133,8 @@ 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; }"); @@ -98,11 +144,9 @@ 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] + [Spec("css-page-3", "#page-selectors")] public void AtPage_NamedSelector_SelectorTextIsName() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page chapter { margin-top: 0; }"); @@ -112,10 +156,9 @@ public void AtPage_NamedSelector_SelectorTextIsName() } [TestMethod] + [Spec("css-page-3", "#page-selectors")] 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); @@ -123,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; }"); @@ -132,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; }"); @@ -141,11 +186,9 @@ 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] + [Spec("css-page-3", "#syntax-page-selector")] public void AtPage_CompoundNamePseudoSelector_ParsesAsOneRule() { var sheet = CssConstructionFunctions.ParseStyleSheet("@page chapter1:left { margin-top: 0; }"); @@ -155,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; }"); @@ -166,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\"; } }"); @@ -176,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); } }"); @@ -186,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\"; } }"); @@ -197,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(@" @@ -213,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; }"); @@ -222,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; }"); @@ -233,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/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; -/// -/// Ported from PeachPDF.Tests/CSS/PaletteMixGrammarTests.cs. -/// Tests for the shared - the palette-mix() function grammar -/// (CSS Fonts Module Level 4), confirmed a field-for-field match against PeachPDF's implementation. -/// [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; /// -/// Ported from PeachPDF.Tests/CSS/ParserExtensionsCreateRuleTests.cs. ParserExtensions.CreateRule is a -/// near-verbatim match with HTML-Renderer's Model/ParserExtensions.cs: the same 13 -/// cases construct a concrete rule, and the same 4 types with no concrete subtype (, +/// The rule types without a rule class of their own (, /// , , ) -/// fall through to . +/// give . /// [TestClass] public sealed class ParserExtensionsCreateRuleTests diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyBoxTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyBoxTests.cs index 7f2ef00e7..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; /// -/// 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 -/// none | [ inset? && <length>{2,4} && <color>? ]# grammar but then -/// stores the token stream's own reconstructed text verbatim as Value/Original -/// (BoxShadowValueConverter.cs: CssText => Original.Text) 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 Converters.ShapeConverter -/// (Model/Converters.cs line 122), a rect(<length>{4}) 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 url() image sources (each -/// optionally followed by an x/y hotspot pair) that must end in a plain keyword from Map.Cursors -/// (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 -/// Toggle(Keywords.Clone, Keywords.Slice) (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. /// [TestClass] public sealed class PropertyBoxTests @@ -445,6 +420,8 @@ 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 "; @@ -458,6 +435,8 @@ 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 "; @@ -473,6 +452,8 @@ 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 "; @@ -502,7 +483,7 @@ 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")] @@ -524,6 +505,8 @@ public void CssBoxDecorationBreakGlobalKeywordLegal(string keyword) [DataRow("none")] [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 c2bc3dc94..2fb880536 100644 --- a/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/PropertyBreakTests.cs @@ -6,31 +6,15 @@ 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 { [TestMethod] + [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void CssBreakAfterLegalAvoid() { var snippet = "break-after:avoid"; @@ -46,6 +30,9 @@ 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"; @@ -61,6 +48,8 @@ public void CssPageBreakAfterLegalAvoid() } [TestMethod] + [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void CssBreakAfterLegalPageCapital() { var snippet = "break-after:Page"; @@ -76,6 +65,9 @@ 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"; @@ -89,6 +81,8 @@ 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"; @@ -104,6 +98,8 @@ public void CssBreakAfterLegalAvoidColumn() } [TestMethod] + [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void CssBreakBeforeLegalAuto() { var snippet = "break-before:AUTO"; @@ -119,6 +115,9 @@ 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"; @@ -134,6 +133,9 @@ 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"; @@ -149,6 +151,8 @@ public void CssPageBreakBeforeLegalLeft() } [TestMethod] + [Spec("css-break-3", "#break-between")] + [Spec("css-break-4", "#break-between")] public void CssBreakBeforeIllegalValue() { var snippet = "break-before:whatever"; @@ -162,6 +166,8 @@ public void CssBreakBeforeIllegalValue() } [TestMethod] + [Spec("css-break-3", "#break-within")] + [Spec("css-break-4", "#break-within")] public void CssBreakInsideIllegalPage() { var snippet = "break-inside:page"; @@ -175,6 +181,8 @@ 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"; @@ -190,6 +198,9 @@ 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"; @@ -205,6 +216,9 @@ 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"; @@ -219,13 +233,12 @@ 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")] + [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 = @@ -241,10 +254,8 @@ 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. [TestMethod] + [Ignore("Gap, see Spec/css-break-3.yml")] [DataRow("break-before", "recto")] [DataRow("break-before", "verso")] [DataRow("break-before", "avoid-region")] @@ -253,45 +264,44 @@ 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")] + [Spec("css-break-4", "#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")] + [Spec("css-break-4", "#break-between")] 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")] [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); } - // 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. [TestMethod] [DataRow("break-before")] [DataRow("break-after")] - public void BreakBeforeAfter_AcceptsAlwaysUnlikePeachPdf(string name) + [Spec("css-break-4", "#break-between")] + public void BreakBeforeAfter_AcceptsAlways(string name) { var property = ParseDeclaration($"{name}:always"); Assert.IsTrue(property.HasValue); @@ -304,6 +314,8 @@ public void BreakBeforeAfter_AcceptsAlwaysUnlikePeachPdf(string name) [DataRow("avoid-page")] [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}"); @@ -318,6 +330,8 @@ public void BreakInside_AcceptsSpecValue(string value) [DataRow("left")] [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}"); @@ -335,6 +349,9 @@ 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")] + [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}"); @@ -351,6 +368,9 @@ 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")] + [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}"); @@ -360,6 +380,9 @@ public void PageBreakBeforeAfter_RejectsModernOnlyValue(string name, string valu [TestMethod] [DataRow("auto")] [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}"); @@ -372,6 +395,9 @@ public void PageBreakInside_AcceptsLegacyValue(string value) [DataRow("avoid-column")] [DataRow("avoid-region")] [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/PropertyContentTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyContentTests.cs index 54c21fbea..9ba4df33a 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,8 @@ public void CssColorNameIllegal() } [TestMethod] + [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#content-property")] public void CssContentNormalLegal() { var snippet = "content : normal "; @@ -128,6 +114,8 @@ public void CssContentNormalLegal() } [TestMethod] + [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#content-property")] public void CssContentNoneLegalUppercaseN() { var snippet = "content : noNe "; @@ -143,6 +131,8 @@ public void CssContentNoneLegalUppercaseN() } [TestMethod] + [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#content-values")] public void CssContentStringLegal() { var snippet = "content : 'hi' "; @@ -158,6 +148,8 @@ 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 "; @@ -173,6 +165,8 @@ public void CssContentNoOpenQuoteNoCloseQuoteLegal() } [TestMethod] + [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#content-values")] public void CssContentUrlLegal() { var snippet = "content : url(test.html) "; @@ -188,6 +182,8 @@ public void CssContentUrlLegal() } [TestMethod] + [Spec("css21", "generate.html#content")] + [Spec("css-content-3", "#content-values")] public void CssContentStringsLegal() { var snippet = "content : 'how' 'are' 'you' "; @@ -203,6 +199,8 @@ public void CssContentStringsLegal() } [TestMethod] + [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void CssQuoteStringIllegal() { var snippet = "quotes : '\"' "; @@ -216,6 +214,8 @@ public void CssQuoteStringIllegal() } [TestMethod] + [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void CssQuoteStringsLegal() { var snippet = "quotes : '\"' '\"' "; @@ -231,6 +231,8 @@ public void CssQuoteStringsLegal() } [TestMethod] + [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void CssQuoteStringsIllegal() { var snippet = "quotes : \"'\""; @@ -244,10 +246,11 @@ 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 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 +264,8 @@ public void CssQuoteStringsMultipleLegal() } [TestMethod] + [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void CssQuoteStringsMultipleIllegal() { var snippet = "quotes : '\"' '\"' '`' "; @@ -274,6 +279,8 @@ public void CssQuoteStringsMultipleIllegal() } [TestMethod] + [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void CssQuoteNoneLegal() { var snippet = "quotes : none"; @@ -289,6 +296,8 @@ public void CssQuoteNoneLegal() } [TestMethod] + [Spec("css21", "generate.html#quotes-specify")] + [Spec("css-content-3", "#quotes-property")] public void CssQuoteNoneStringIllegal() { var snippet = "quotes : 'none'"; @@ -302,6 +311,8 @@ 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/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; /// -/// Ported from PeachPDF.Tests/CSS/Property.cs (the closing CssPropertyFactoryCalls/ -/// CssUnknownPropertyPreservesCase cases), exercising StyleDeclaration.CreateProperty/ -/// SetProperty (Model/StyleDeclaration.cs) and PropertyFactory.Instance -/// (Factories/PropertyFactory.cs) directly rather than through ParseDeclaration. -/// -/// StyleDeclaration.CreateProperty (StyleDeclaration.cs lines 198-207) first returns any existing -/// declaration of that name, then falls back to PropertyFactory.Instance.Create(name) -/// (PropertyFactory.cs line 578: longhand, then shorthand, then custom-property lookup) - and only -/// synthesizes a same-named UnknownProperty when the parser is not in strict mode -/// (IsStrictMode, StyleDeclaration.cs line 312, is true whenever -/// StylesheetParser.Options.IncludeUnknownDeclarations is false, which is the default -/// new StylesheetParser() 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. /// [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; -/// -/// 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 Map.ClearModes, Map.PositionModes, Map.DisplayModes, Map.Visibilities, -/// Map.OverflowModes (Model/Map.cs) and the Toggle(Keywords.Fixed, Keywords.Auto) 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. -/// [TestClass] public sealed class PropertyLayoutTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/PropertyPaginationTests.cs b/Source/Test/HtmlRenderer.Test/Css/PropertyPaginationTests.cs index 5b3e83536..945980206 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,9 @@ 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 "; @@ -58,6 +42,9 @@ 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 "; @@ -71,6 +58,9 @@ 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 "; @@ -99,6 +89,9 @@ 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"; @@ -114,6 +107,9 @@ 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/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; /// -/// Ported from PeachPDF.Tests/CSS/RealWorld.cs. -/// PeachPDF's version parses a stylesheet with its CSS engine's StylesheetParser and reads the -/// parsed rule's properties back as CSSOM-normalized strings. HTML-Renderer's CSS engine port removed the -/// old raw-bucket CssData.GetCssBlock introspection API this file originally used entirely - -/// CssData is now purely a rule index queried during the real box-tree cascade (see -/// CssData.GetStyleRules), 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 - SetHtml, layout, then read the resolved values off -/// the laid-out es (ActualBackgroundColor/ActualColor/ -/// ActualMargin*), 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 ToCss() - 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. /// [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; -/// -/// 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 -/// (Source/HtmlRenderer/Core/CssEngine/StyleProperties/RowGapProperty.cs:5) -/// uses Converters.LengthOrPercentConverter.OrGlobalValue().OrDefault(0), while PeachPDF's -/// RowGapProperty.cs:5 uses Converters.LengthOrPercentOrNormalConverter... instead; -/// LengthOrPercentOrNormalConverter does not exist anywhere under Source/HtmlRenderer (confirmed -/// via grep), so "row-gap: normal" is silently rejected here. -/// [TestClass] public sealed class RowGapPropertyTests { @@ -32,7 +22,8 @@ public void RowGapLegalValues(string value) => CssConstructionFunctions.TestForLegalValue(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(PropertyNames.RowGap, "normal"); } diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/AttrSelectorTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/AttrSelectorTests.cs index 7da8d1d11..518b98907 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/AttrSelectorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/AttrSelectorTests.cs @@ -3,7 +3,6 @@ namespace HtmlRenderer.Test.Css.Selectors; -/// Ported from PeachPDF.Tests/CSS/AttrSelectorTests.cs. [TestClass] public sealed class AttrSelectorTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/ClassSelectorTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/ClassSelectorTests.cs index 1c824feee..dbe454f71 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/ClassSelectorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/ClassSelectorTests.cs @@ -3,7 +3,6 @@ namespace HtmlRenderer.Test.Css.Selectors; -/// Ported from PeachPDF.Tests/CSS/ClassSelectorTests.cs. [TestClass] public sealed class ClassSelectorTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/ConditionGroupingRuleIntegrationTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/ConditionGroupingRuleIntegrationTests.cs index 4a0180bea..c1a8ace9e 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; -/// -/// Ported from PeachPDF.Tests/CSS/ConditionGroupingRuleIntegrationTests.cs. -/// Supports_SvgOnlyProperty_AppliesToAnInlineSvgElement is not ported, HTML-Renderer has no SVG -/// rendering pipeline. The cascade doesn't apply @container's inner rules yet. -/// [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( @@ -96,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/Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs new file mode 100644 index 000000000..a74b2047d --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/Css21SelectorTests.cs @@ -0,0 +1,540 @@ +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")] + [Spec("selectors-4", "#type-selectors")] + [Spec("selectors-4", "#case-sensitive")] + public void TypeSelector_MatchesElementNamesCaseInsensitively() + { + Assert.IsTrue(Matches("DIV", "
    a
    ")); + Assert.IsTrue(Matches("div", "
    a
    ")); + } + + [TestMethod] + [Spec("css21", "selector.html#type-selectors")] + [Spec("selectors-4", "#type-selectors")] + public void TypeSelector_MatchesOnlyThatElementType() + { + Assert.IsTrue(Matches("p", "

    a

    ")); + Assert.IsFalse(Matches("p", "
    a
    ")); + } + + [TestMethod] + [Spec("css21", "selector.html#selector-syntax")] + [Spec("selectors-4", "#structure")] + 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")] + [Spec("selectors-4", "#child-combinators")] + [Spec("selectors-4", "#adjacent-sibling-combinators")] + 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")] + [Spec("selectors-4", "#descendant-combinators")] + public void CombinatorChain_TriesFartherAncestors() + { + Assert.IsTrue(Matches(".a > div span", "
    a
    ")); + } + + [TestMethod] + [Spec("css21", "selector.html#grouping")] + [Spec("selectors-4", "#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")] + [Spec("selectors-4", "#the-universal-selector")] + public void UniversalSelector_MatchesAnyElement() + { + Assert.IsTrue(Matches("*", "

    a

    ")); + Assert.IsTrue(Matches("*", "a")); + } + + [TestMethod] + [Spec("css21", "selector.html#universal-selector")] + [Spec("selectors-4", "#the-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")] + [Spec("selectors-4", "#descendant-combinators")] + 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")] + [Spec("selectors-4", "#descendant-combinators")] + 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")] + [Spec("selectors-4", "#descendant-combinators")] + public void DescendantSelector_CombinesWithAttributeSelectors() + { + Assert.IsTrue(Matches("div p *[href]", "")); + Assert.IsFalse(Matches("div p *[href]", "")); + } + + [TestMethod] + [Spec("css21", "selector.html#child-selectors")] + [Spec("selectors-4", "#child-combinators")] + 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")] + [Spec("selectors-4", "#child-combinators")] + 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")] + [Spec("selectors-4", "#adjacent-sibling-combinators")] + public void AdjacentSelector_IgnoresTextAndComments() + { + Assert.IsTrue(Matches("h1 + h2", "

    a

    text

    b

    ")); + } + + [TestMethod] + [Spec("css21", "selector.html#adjacent-selectors")] + [Spec("selectors-4", "#adjacent-sibling-combinators")] + 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")] + [Spec("selectors-4", "#attribute-representation")] + 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")] + [Spec("selectors-4", "#attribute-representation")] + 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")] + [Spec("selectors-4", "#attribute-representation")] + 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")] + [Spec("selectors-4", "#attribute-representation")] + 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")] + [Spec("selectors-4", "#attribute-representation")] + 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")] + [Spec("selectors-4", "#attribute-representation")] + 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")] + [Spec("selectors-4", "#case-sensitive")] + public void AttributeNames_AreCaseInsensitiveInHtml() + { + Assert.IsTrue(Matches("[TITLE]", "

    a

    ")); + } + + [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]", "

    a

    ")); + } + + [TestMethod] + [Spec("css21", "selector.html#class-html")] + [Spec("selectors-4", "#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")] + [Spec("selectors-4", "#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")] + [Spec("selectors-4", "#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")] + [Spec("selectors-4", "#class-html")] + public void ClassSelector_IsCaseSensitiveInHtml() + { + Assert.IsFalse(Matches(".Foo", "

    a

    ")); + } + + [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", "

    a

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

    a

    ")); + } + + [TestMethod] + [Spec("css21", "selector.html#id-selectors")] + [Spec("selectors-4", "#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")] + [Spec("selectors-4", "#specificity-rules")] + 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")] + [Spec("selectors-4", "#id-selectors")] + public void IdSelector_IsCaseSensitiveInHtml() + { + Assert.IsFalse(Matches("#Foo", "

    a

    ", "foo")); + } + + [TestMethod] + [Spec("css21", "selector.html#pseudo-elements")] + [Spec("selectors-4", "#pseudo-classes")] + 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")] + [Spec("selectors-4", "#pseudo-classes")] + 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")] + [Spec("selectors-4", "#pseudo-classes")] + [Spec("selectors-4", "#case-sensitive")] + public void PseudoClassNames_AreCaseInsensitive() + { + Assert.IsTrue(Matches("p:FIRST-CHILD", "

    a

    ")); + } + + [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' }", "

    a

    "); + + Assert.AreEqual("N", GeneratedText(p, before: true)); + } + + [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", "

    before

    first

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

    before

    Note

    first

    ")); + } + + [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", "

    abc default

    ")); + } + + [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", "

    a b

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

    a b

    ")); + } + + [TestMethod] + [Spec("css21", "selector.html#link-pseudo-classes")] + [Spec("selectors-4", "#link")] + 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")] + [Spec("selectors-4", "#link")] + 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")] + [Spec("selectors-4", "#the-hover-pseudo")] + 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")] + [Spec("selectors-4", "#the-hover-pseudo")] + 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#matching-attrs")] + [Spec("selectors-4", "#attribute-representation")] + 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")] + [Spec("selectors-4", "#the-lang-pseudo")] + 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")] + [Spec("css-pseudo-4", "#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")] + [Spec("css-pseudo-4", "#first-letter-pseudo")] + 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")] + [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: '!' }", "

    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/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; -/// -/// 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 , -/// , -/// ), so those are ported here via the -/// / pattern (see -/// ). -/// -/// The four leading-combinator ":has()" matching tests (":has(> S)"/":has(+ S)"/":has(~ S)" and the -/// mixed-combinator comma-list case) are ported below as d: HTML-Renderer's -/// HasSelector only supports the default (descendant) relative-selector form - there is no -/// Selectors/RelativeSelector.cs. The root cause is -/// 's private -/// Insert(ISelector) (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 ">(S)"), _temp is still -/// null, so the "no previous selector" branch runs _combinators.Clear() and simply assigns -/// _temp = selector - silently discarding the leading combinator instead of attaching it. See -/// also HasSelector's own doc comment, which documents this as a known, intentional v1 gap. -/// [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; }", "

    1

    "); 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; }", "
    text
    "); @@ -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; }", "
    x
    " + @@ -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; }", "

    text

    "); @@ -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; }", "

    text

    "); @@ -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) => $"{body}"; diff --git a/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorMatchingTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorMatchingTests.cs index be7546730..f47c27900 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorMatchingTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/SelectorMatchingTests.cs @@ -4,18 +4,13 @@ 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 { // ── Attribute: starts-with [attr^=value] ───────────────────────────────── [TestMethod] + [Spec("selectors-4", "#attribute-substrings")] public void AttrBegins_Matches_WhenValueStartsWith() { var html = Html("[class^='btn'] { background-color: #ff0000; }", "text"); @@ -24,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; }", "text"); @@ -34,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; }", "text"); @@ -42,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; }", "text"); @@ -52,6 +50,8 @@ public void AttrEnds_DoesNotMatch_WhenValueDoesNotEndWith() // ── Attribute: hyphen-prefix [attr|=value] ─────────────────────────────── [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; }", "text"); @@ -60,6 +60,8 @@ 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; }", "text"); @@ -68,6 +70,8 @@ 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; }", "text"); @@ -78,6 +82,8 @@ public void AttrHyphen_DoesNotMatch_SubstringOnly() // ── Combinator: adjacent sibling (div + p) ─────────────────────────────── [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; }", "
    d

    first

    "); @@ -86,6 +92,8 @@ 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; }", "
    d

    first

    second

    "); @@ -96,6 +104,8 @@ 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; }", "

    before

    d
    "); @@ -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; }", "
    d

    first

    second

    "); @@ -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; }", "

    before

    d
    "); @@ -123,9 +135,11 @@ 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")] + [Spec("selectors-4", "#child-combinators")] public void ChildCombinator_StillMatches_DirectChild() { var html = Html("div > p { background-color: #ff0000; }", "

    direct

    nested

    "); @@ -136,6 +150,8 @@ 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; }", "

    deep

    outside

    "); @@ -145,7 +161,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/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; /// -/// 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. /// [TestClass] public sealed class SelectorsTests @@ -56,28 +55,33 @@ public void FindAllStyleRulesWithCompoundSelector() PseudoElementNames.Selection ]; - /// - /// 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. - /// [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/Selectors/StructuralPseudoClassSelectorTests.cs b/Source/Test/HtmlRenderer.Test/Css/Selectors/StructuralPseudoClassSelectorTests.cs index e1ebe377a..c227799ec 100644 --- a/Source/Test/HtmlRenderer.Test/Css/Selectors/StructuralPseudoClassSelectorTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/Selectors/StructuralPseudoClassSelectorTests.cs @@ -4,22 +4,14 @@ 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")] + [Spec("selectors-4", "#the-first-child-pseudo")] public void FirstChild_Matches_FirstElement() { var html = Html(":first-child { background-color: #ff0000; }", "

    first

    second

    "); @@ -32,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; }", "

    first

    second

    "); @@ -44,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; }", "

    alone

    "); @@ -52,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; }", "

    first

    second

    "); @@ -63,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; }", "

    content

    "); @@ -71,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; }", "

    content

    "); @@ -81,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; }", "

    content

    "); @@ -89,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; }", "

    content

    "); @@ -103,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; }", "

    1

    2

    3

    "); @@ -114,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; }", "

    1

    2

    3

    4

    "); @@ -125,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; }", "

    1

    2

    3

    4

    "); @@ -136,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; }", "

    1

    2

    3

    4

    "); var boxes = FindAllBoxesByTag(html, "p"); Assert.AreNotEqual(RColor.Transparent, boxes[0].ActualBackgroundColor); @@ -149,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; }", "

    1

    2

    3

    "); var boxes = FindAllBoxesByTag(html, "p"); Assert.AreNotEqual(RColor.Transparent, boxes[0].ActualBackgroundColor); @@ -161,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. @@ -173,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; }", "

    1

    2

    3

    "); @@ -183,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; }", "

    1

    2

    3

    "); @@ -193,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. @@ -205,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; }", "

    1

    2

    3

    "); @@ -218,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; }", "
    s

    1

    2

    "); @@ -228,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; }", "

    1

    2

    s
    "); @@ -238,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; }", "
    s

    1

    "); @@ -246,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; }", "

    1

    2

    "); @@ -254,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; }", "
    s

    1

    s2

    2

    3

    "); @@ -265,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; }", "

    1

    s

    2

    "); @@ -277,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; }", "

    1

    2

    3

    "); @@ -287,6 +299,8 @@ 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; }", "

    1

    2

    "); @@ -297,6 +311,8 @@ 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; }", "

    1

    2

    "); @@ -338,6 +354,7 @@ public void NthLastColumn_1_MatchesLastColumnOnly() // ── CSS4 "of " 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. @@ -353,6 +370,7 @@ public void NthChildOfSelector_1_MatchesOnlyTheFirstMatchingSibling() } [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void NthChildOfSelector_2nPlus1_AppliesFormulaWithinTheFilteredSubset() { var html = Html( @@ -368,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( @@ -381,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 @@ -395,6 +415,7 @@ public void NthChildOfSelector_ElementNotMatchingS_NeverMatchesRegardlessOfPosit } [TestMethod] + [Spec("selectors-4", "#the-nth-child-pseudo")] public void NthChildOfSelector_CommaSeparatedList_MatchesEitherBranch() { var html = Html( @@ -408,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; }", "
    A
    "); @@ -422,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) => $"{body}"; 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/StringFunctionConverterTests.cs b/Source/Test/HtmlRenderer.Test/Css/StringFunctionConverterTests.cs index 5c1cd2bd4..33602bc5d 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; /// -/// 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(<custom-ident> [, [ first | start | last | first-except ] ]?). -/// The CSS engine port's StringFunctionConverter is wired into both ContentProperty and -/// StringSetValueConverter, matching PeachPDF's chain exactly. The token-level tests use -/// , 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. /// [TestClass] public sealed class StringFunctionConverterTests { [TestMethod] + [Spec("css-syntax-3", "#consume-function")] public void StringFunction_WithNameOnly_ParsesCorrectly() { const string input = "string(chapter)"; @@ -40,6 +32,7 @@ public void StringFunction_WithNameOnly_ParsesCorrectly() } [TestMethod] + [Spec("css-syntax-3", "#consume-function")] public void StringFunction_WithFirstKeyword_ParsesCorrectly() { const string input = "string(chapter, first)"; @@ -62,6 +55,7 @@ public void StringFunction_WithFirstKeyword_ParsesCorrectly() } [TestMethod] + [Spec("css-syntax-3", "#consume-function")] public void StringFunction_WithLastKeyword_ParsesCorrectly() { const string input = "string(chapter, last)"; @@ -78,6 +72,7 @@ public void StringFunction_WithLastKeyword_ParsesCorrectly() } [TestMethod] + [Spec("css-syntax-3", "#consume-function")] public void StringFunction_WithStartKeyword_ParsesCorrectly() { const string input = "string(chapter, start)"; @@ -94,6 +89,7 @@ public void StringFunction_WithStartKeyword_ParsesCorrectly() } [TestMethod] + [Spec("css-syntax-3", "#consume-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-syntax-3", "#consume-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-syntax-3", "#consume-function")] public void StringFunction_WithUnderscoreName_ParsesCorrectly() { const string input = "string(chapter_title)"; @@ -140,6 +138,8 @@ 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 +152,8 @@ 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)"; @@ -164,6 +166,8 @@ 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)"; @@ -176,6 +180,8 @@ 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)"; @@ -188,6 +194,7 @@ public void StringFunction_MultipleInContent_ParsesCorrectly() } [TestMethod] + [Spec("css-syntax-3", "#consume-function")] public void StringFunction_WithWhitespaceAroundComma_ParsesCorrectly() { const string input = "string(chapter , last)"; @@ -204,6 +211,7 @@ public void StringFunction_WithWhitespaceAroundComma_ParsesCorrectly() } [TestMethod] + [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 f5417ae04..fefd9ca3f 100644 --- a/Source/Test/HtmlRenderer.Test/Css/StringSetPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/StringSetPropertyTests.cs @@ -4,16 +4,14 @@ namespace HtmlRenderer.Test.Css; /// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/StringSetPropertyTests.cs. -/// and its StringSetValueConverter 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 content() sub-converter collapses -/// "content(text)" to "content()" the same way PeachPDF's does. +/// Parsing of string-set. content(text) serializes as content(). /// [TestClass] 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"); @@ -27,6 +25,8 @@ 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)"); @@ -40,6 +40,8 @@ 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 +55,8 @@ 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)"); @@ -66,6 +70,8 @@ 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)"); @@ -79,6 +85,8 @@ 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)"); @@ -92,19 +100,23 @@ 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(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)"); @@ -118,6 +130,8 @@ 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"); @@ -130,6 +144,8 @@ 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)"); @@ -140,4 +156,18 @@ public void StringSetInvalidNumberIllegal() Assert.IsFalse(concrete.IsInherited); Assert.IsFalse(concrete.HasValue); } + + // A CSS-wide keyword isn't a . + [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(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 c087ca162..1cf71a6df 100644 --- a/Source/Test/HtmlRenderer.Test/Css/StringSetWithStringFunctionTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/StringSetWithStringFunctionTests.cs @@ -4,11 +4,7 @@ namespace HtmlRenderer.Test.Css; /// -/// 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. 's content-list-item converter composes a -/// StringFunctionConverter 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. Neither css-content-3 nor css-gcpm-3 allows it there, it's an extension. /// [TestClass] public sealed class StringSetWithStringFunctionTests 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; -/// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/StrokeProperty.cs (source class StrokePropertyTests). -/// All SVG paint/stroke longhands exist: (paint - colors/"none"/"url()"), -/// , , -/// , , -/// , , and -/// , all matching PeachPDF's grammar. -/// [TestClass] public sealed class StrokePropertyTests { 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; -/// -/// Ported from PeachPDF.Tests/CSS/Supports.cs. -/// HTML-Renderer's still uses the old grammar-only oracle -/// (Property.TrySetValue) rather than PeachPDF's newer CssPropertyRegistry-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 -/// [Ignore] below with the same citation. -/// [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(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/Css/SyntaxAndDataTypesTests.cs b/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs new file mode 100644 index 000000000..eefeaf2d9 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Css/SyntaxAndDataTypesTests.cs @@ -0,0 +1,756 @@ +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")] + [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) }"); + + 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")] + [Spec("css-values-4", "#dimensions")] + 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}")] + [Spec("css-syntax-3", "#consume-escaped-code-point")] + 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")] + [Spec("css-syntax-3", "#consume-escaped-code-point")] + public void OnlyOneWhitespaceAfterAHexEscape_IsConsumed() + { + Assert.AreEqual("&B & B", Before(@"#x::before{content:'\26 B \26 B'}")); + } + + [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'}")); + } + + [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 } "); + + 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")] + [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}"); + + 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")] + [Spec("css-syntax-3", "#consume-simple-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")] + [Spec("css-syntax-3", "#consume-simple-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")] + [Spec("css-syntax-3", "#style-rules")] + 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")] + [Spec("css-syntax-3", "#consume-comment")] + public void Comments_DontNest() + { + Assert.AreEqual(Green, Styled("#x { color: red; /* /* */ color: green; /* */ }").Color); + } + + [TestMethod] + [Spec("css21", "syndata.html#comments")] + [Spec("css-syntax-3", "#consume-stylesheet-contents")] + 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; }")] + [Spec("css-syntax-3", "#parser-algorithms")] + public void ParsingError_IsIgnoredAndParsingResumes(string css) + { + Assert.AreEqual(Green, Styled(css).Color); + } + + [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")); + } + + [TestMethod] + [Spec("css21", "syndata.html#numbers")] + [Spec("css-values-4", "#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")] + [Spec("css-values-4", "#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")] + [Spec("css-values-4", "#absolute-lengths")] + [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")] + [Spec("css-values-4", "#lengths")] + public void UnitAfterZero_IsOptional() + { + Assert.AreEqual(0, Styled("#x{margin-left:5px;margin-left:0}").ActualMarginLeft, Delta); + } + + [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); + } + + [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); + } + + [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}", "
    a
    "); + + Assert.AreEqual(30, box.ActualMarginLeft, Delta); + } + + [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); + } + + [TestMethod] + [Spec("css21", "syndata.html#length-units")] + [Spec("css-values-4", "#font-relative-lengths")] + 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")] + [Spec("css-values-4", "#numeric-ranges")] + [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")] + [Spec("css-values-4", "#percentages")] + 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")] + [Spec("css-values-4", "#percentages")] + 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")] + [Spec("css-values-4", "#urls")] + [DataRow("url(a.png)")] + [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); + } + + [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); + } + + [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")] + [Spec("css-values-4", "#relative-urls")] + [Spec("css-values-4", "#url-processing")] + [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")] + [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\") }")); + + 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")] + [Spec("css-color-4", "#rgb-functions")] + [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")] + [Spec("css-color-4", "#rgb-functions")] + [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")] + [Spec("css-color-4", "#hex-notation")] + [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")] + [Spec("css-color-4", "#color-syntax-legacy")] + [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")] + [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\"")] + [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")] + [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 }"; + + 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")] + [Spec("css-syntax-3", "#input-byte-stream")] + 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")] + [Spec("css-syntax-3", "#input-byte-stream")] + 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")] + [Spec("css-syntax-3", "#input-byte-stream")] + 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")] + [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); + } + + [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/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs index eb687ef77..b087b6f9d 100644 --- a/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/TextPropertyTests.cs @@ -4,29 +4,13 @@ namespace HtmlRenderer.Test.Css; -/// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/TextProperty.cs (source class TextPropertyTests). -/// All word-spacing (incl. "normal" - ), 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 -/// (Source/HtmlRenderer/Core/CssEngine/StyleProperties/Text/ -/// TextIndentProperty.cs:5-6) uses a bare Converters.LengthOrPercentConverter.OrDefault(Length.Zero), -/// whereas PeachPDF has a dedicated TextIndentValueConverter supporting the -/// "<length-percentage> && hanging? && 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). -/// +/// Parsing of the CSS Text and CSS Text Decoration properties. [TestClass] 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"); @@ -40,6 +24,8 @@ 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 "); @@ -53,6 +39,8 @@ 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 "); @@ -66,6 +54,8 @@ 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 "); @@ -79,6 +69,7 @@ public void WordSpacingNormalLegal() } [TestMethod] + [Spec("css-text-decor-4", "#text-shadow-property")] public void TextShadowLegalInsetAtLast() { var property = CssConstructionFunctions.ParseDeclaration("text-shadow: 0 0 2px black inset"); @@ -92,6 +83,8 @@ 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"); @@ -105,6 +98,8 @@ 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, @@ -124,6 +119,8 @@ 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)"); @@ -137,6 +134,8 @@ 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)"); @@ -150,6 +149,8 @@ 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"); @@ -163,6 +164,8 @@ 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"); @@ -176,6 +179,8 @@ 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"); @@ -189,6 +194,8 @@ 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%"); @@ -202,6 +209,8 @@ 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"); @@ -212,7 +221,9 @@ public void TextIndentIllegalNone() } [TestMethod] - [Ignore("not yet spec compliant")] + [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"); @@ -226,7 +237,9 @@ public void TextIndentLegalHanging() } [TestMethod] - [Ignore("not yet spec compliant")] + [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,7 +253,9 @@ public void TextIndentLegalEachLine() } [TestMethod] - [Ignore("not yet spec compliant")] + [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"); @@ -254,8 +269,10 @@ public void TextIndentLegalHangingEachLine() } [TestMethod] - [Ignore("not yet spec compliant")] - public void TextIndentLegalReorderedKeywordsPreservesAuthoredOrder() + [Ignore("Gap, see Spec/css-text-3.yml")] + [Spec("css-text-3", "#text-indent-property")] + [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); @@ -264,10 +281,12 @@ public void TextIndentLegalReorderedKeywordsPreservesAuthoredOrder() Assert.IsInstanceOfType(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] + [Spec("css-text-3", "#text-indent-property")] + [Spec("css-text-4", "#text-indent-property")] public void TextIndentIllegalHangingAloneMissingLength() { var property = CssConstructionFunctions.ParseDeclaration("text-indent:hanging"); @@ -278,6 +297,8 @@ 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"); @@ -288,6 +309,8 @@ 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"); @@ -299,6 +322,8 @@ 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"); @@ -312,6 +337,8 @@ 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"); @@ -325,6 +352,8 @@ 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"); @@ -338,6 +367,8 @@ 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"); @@ -351,6 +382,8 @@ 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"); @@ -362,6 +395,8 @@ 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"); @@ -375,6 +410,8 @@ 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 "); @@ -388,6 +425,8 @@ 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 "); @@ -401,6 +440,8 @@ 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"); @@ -412,6 +453,8 @@ 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;}"; @@ -421,6 +464,8 @@ 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"); @@ -434,6 +479,8 @@ 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"); @@ -447,6 +494,8 @@ 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"); @@ -460,6 +509,8 @@ 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"); @@ -471,6 +522,8 @@ 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"); @@ -484,6 +537,8 @@ 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"); @@ -497,6 +552,8 @@ 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"); @@ -510,6 +567,8 @@ 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"); @@ -523,6 +582,8 @@ 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"); @@ -536,6 +597,8 @@ 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"); @@ -549,6 +612,8 @@ 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"); @@ -562,6 +627,8 @@ 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"); @@ -623,6 +690,8 @@ 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"); @@ -636,6 +705,8 @@ 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"); @@ -649,29 +720,27 @@ public void TextJustifyDistributeLegal() } [TestMethod] - public void TextJustifyDistributeAllLinesLegal() + [Ignore("Gap, see Spec/css-text-3.yml")] + [Spec("css-text-3", "#text-justify-property")] + [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(property); - var concrete = (TextJustifyProperty)property; - Assert.IsTrue(property.HasValue); - Assert.AreEqual("distribute-all-lines", concrete.Value); + Assert.IsFalse(property.HasValue); } [TestMethod] - public void TextJustifyDistributeCenterLastLegal() + [Ignore("Gap, see Spec/css-text-3.yml")] + [Spec("css-text-3", "#text-justify-property")] + [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(property); - var concrete = (TextJustifyProperty)property; - Assert.IsTrue(property.HasValue); - Assert.AreEqual("distribute-center-last", concrete.Value); + Assert.IsFalse(property.HasValue); } [TestMethod] @@ -701,6 +770,8 @@ 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"); @@ -740,17 +811,22 @@ public void TextJustifyNewspaperLegal() } [TestMethod] - public void TextJustifyNoneIllegal() + [DataRow("none")] + [DataRow("inter-character")] + [Spec("css-text-3", "#text-justify-property")] + [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(property); - Assert.IsFalse(property.HasValue); + Assert.IsTrue(property.HasValue); + Assert.AreEqual(value, ((TextJustifyProperty)property).Value); } [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"); @@ -764,6 +840,8 @@ 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"); @@ -777,6 +855,8 @@ 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"); @@ -790,6 +870,8 @@ 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"); @@ -803,6 +885,8 @@ 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"); @@ -814,6 +898,8 @@ 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/TokenizationTests.cs b/Source/Test/HtmlRenderer.Test/Css/TokenizationTests.cs index e9a444573..f7aff44bd 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,8 @@ public void CssParserAtRule() } [TestMethod] + [Spec("css21", "grammar.html#scanner")] + [Spec("css-syntax-3", "#consume-url-token")] public void CssParserUrlUnquoted() { var url = "http://someurl"; @@ -39,6 +38,7 @@ public void CssParserUrlUnquoted() } [TestMethod] + [Spec("css21", "grammar.html#scanner")] public void CssParserUrlDoubleQuoted() { var url = "http://someurl"; @@ -49,6 +49,7 @@ public void CssParserUrlDoubleQuoted() } [TestMethod] + [Spec("css21", "grammar.html#scanner")] public void CssParserUrlSingleQuoted() { var url = "http://someurl"; @@ -58,10 +59,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("css-syntax-3", "#consume-remnants-of-bad-url")] public void CssParserUrlBad_UnexpectedQuoteInUnquotedUrl_RecoversAtClosingParen() { var teststring = "url(bad\"value) next"; @@ -69,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); @@ -77,57 +77,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 +131,8 @@ public void LexerInvalidEscapedCodePointInIdent(string input) } [TestMethod] + [Spec("css21", "grammar.html#scanner")] + [Spec("css-syntax-3", "#input-preprocessing")] public void LexerOnlyCarriageReturn() { var teststring = "\r"; @@ -188,6 +142,8 @@ public void LexerOnlyCarriageReturn() } [TestMethod] + [Spec("css21", "grammar.html#scanner")] + [Spec("css-syntax-3", "#input-preprocessing")] public void LexerCarriageReturnLineFeed() { var teststring = "\r\n"; @@ -197,6 +153,8 @@ 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/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; -/// -/// Ported from PeachPDF.Tests/CSS/TransformMatrixTests.cs. -/// HTML-Renderer's 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. -/// [TestClass] public sealed class TransformMatrixTests { diff --git a/Source/Test/HtmlRenderer.Test/Css/TransformPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/TransformPropertyTests.cs index 19ac8e3ee..4c0efb204 100644 --- a/Source/Test/HtmlRenderer.Test/Css/TransformPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/TransformPropertyTests.cs @@ -4,20 +4,13 @@ namespace HtmlRenderer.Test.Css; /// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/TransformProperty.cs (source class -/// CssTransformPropertyTests). -/// , , -/// , , and -/// all exist; TransformOriginProperty/ -/// PerspectiveOriginProperty reproduce the exact ordered-vs-keyword-reorderable -/// <position> 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. /// [TestClass] public sealed class TransformPropertyTests { [TestMethod] + [Spec("css-transforms-2", "#perspective-property")] public void CssPerspectiveNoneUppercaseLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective: NONE "); @@ -31,6 +24,7 @@ public void CssPerspectiveNoneUppercaseLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-property")] public void CssPerspectiveLengthPixelLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective: 20px "); @@ -44,6 +38,7 @@ public void CssPerspectiveLengthPixelLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-property")] public void CssPerspectiveLengthEmLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective: 3.5em "); @@ -57,6 +52,7 @@ public void CssPerspectiveLengthEmLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-property")] public void CssPerspectiveZeroLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective: 0 "); @@ -70,6 +66,7 @@ public void CssPerspectiveZeroLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-property")] public void CssPerspectivePercentIllegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective: 10% "); @@ -82,6 +79,7 @@ public void CssPerspectivePercentIllegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginZeroLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective-origin: 0 "); @@ -95,6 +93,7 @@ public void CssPerspectiveOriginZeroLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginLengthLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective-origin: 20px "); @@ -108,6 +107,7 @@ public void CssPerspectiveOriginLengthLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginLeftLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective-origin: left "); @@ -121,6 +121,7 @@ public void CssPerspectiveOriginLeftLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginPercentLegal() { var property = CssConstructionFunctions.ParseDeclaration("perspective-origin: 15% "); @@ -134,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% "); @@ -147,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 "); @@ -160,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 "); @@ -173,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 "); @@ -186,11 +191,10 @@ public void CssPerspectiveOriginTopCenterLegal() } [TestMethod] + [Spec("css-transforms-2", "#perspective-origin-property")] public void CssPerspectiveOriginVerticalKeywordThenLengthIllegal() { - // "top 2px" is invalid (CSS Transforms 1 ): 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(property); @@ -199,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 "); @@ -209,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 "); @@ -223,6 +229,7 @@ public void CssTransformStylePreserve3DLegal() } [TestMethod] + [Spec("css-transforms-2", "#transform-style-property")] public void CssTransformStyleNoneIllegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-style: none "); @@ -235,6 +242,7 @@ public void CssTransformStyleNoneIllegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginXOffsetLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: 2px "); @@ -248,6 +256,7 @@ public void CssTransformOriginXOffsetLegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-origin-property")] public void CssTransformOriginXOffsetKeywordLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform-origin: bottom "); @@ -261,6 +270,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 +284,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 +297,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 +309,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 +321,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 +335,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 +349,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 +363,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 +377,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 +389,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 +403,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 +417,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 +431,7 @@ public void CssTransformOriginYKeywordXKeywordZLegal() } [TestMethod] + [Spec("css-transforms-1", "#transform-property")] public void CssTransformNoneLegal() { var property = CssConstructionFunctions.ParseDeclaration("transform: none "); @@ -425,6 +445,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 +459,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 +473,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 +487,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 +501,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 +515,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 +529,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 +543,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 +557,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 +571,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 +585,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)"); @@ -568,6 +599,7 @@ public void CssTransformMultipleLegal() } [TestMethod] + [Spec("css-transforms-2", "#three-d-transform-functions")] public void CssTransformMatrix3dLegal() { var property = CssConstructionFunctions.ParseDeclaration( @@ -581,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)"); @@ -593,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)"); @@ -605,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)"); @@ -617,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)"); @@ -629,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)"); @@ -641,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)"); @@ -653,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)"); @@ -665,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)"); @@ -677,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/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; -/// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/TransitionProperty.cs (source class -/// CssTransitionPropertyTests). -/// (via a restricted known-property-name list matching the -/// "-specific"/"sliding-vertically"/"test_05"-illegal cases), , -/// , , and the -/// shorthand all exist and match PeachPDF's grammar. -/// [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(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(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(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/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; /// -/// Ported from PeachPDF.Tests/CSS/CssPropertyTests.cs. /// Tests for (the typed box-side property value) and the Layer A → /// 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; -/// -/// Ported from PeachPDF.Tests/CSS/UrlTests.cs. HTML-Renderer's -/// is a near-verbatim structural port of PeachPDF's Model/Url.cs (same state machine, same public surface). -/// [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; /// -/// 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 is a plain string set verbatim by the CSS -/// pipeline, with no legality validation. In '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 via . +/// Only checks that vertical-align is parsed and stored, read back from a laid-out box. /// [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/Css/WhiteSpacePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/WhiteSpacePropertyTests.cs index a8ca3e1fb..f599427b4 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,9 @@ 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")] @@ -36,6 +28,9 @@ 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/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; /// -/// 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 InsetBlockProperty/InsetInlineProperty 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 -/// Source/HtmlRenderer/Core/CssEngine/Factories/PropertyFactory.cs:447-450 registers these -/// longhands as bare aliases reusing TopProperty/BottomProperty/LeftProperty/ -/// RightProperty directly (() => new TopProperty()), whose constructor hardcodes -/// Name = PropertyNames.Top regardless of which alias name it was created under. Since -/// StyleDeclaration.GetProperty (Model/StyleDeclaration.cs:209-212) matches purely by -/// Declarations.FirstOrDefault(m => m.Name.Isi(name)), looking the value back up by its -/// literal declared name ("inset-block-start") never finds it, and GetPropertyValue falls through -/// to string.Empty. PeachPDF avoids this by giving each logical inset longhand its own dedicated -/// property class (e.g. InsetBlockStartProperty, 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. /// [TestClass] public sealed class WptCssPositionInsetLogicalParsingTests @@ -50,7 +21,8 @@ private static string DeclaredValue(string property, string value) // ─── Longhands: 'auto | ' ────────────────────────────── [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 | ' - a calc() is only valid when it type-checks as a - // plain (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; -/// -/// Ported from PeachPDF.Tests/CSS/PropertyTests/WptCssPositionParsingTests.cs. -/// Grammar coverage for position, the four physical inset longhands (top/right/ -/// bottom/left), and z-index, 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 (accepted) or is dropped -/// entirely, leaving an empty string (rejected). -/// position (), top/right/bottom/left -/// (Coordinate/*.cs), and non-calc() z-index () all exist with matching -/// accept/reject behavior. -/// [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/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; /// -/// Mirrors PeachPDF.Tests/CSS/ConstructionFunctions.cs's CssConstructionFunctions helper, adapted to -/// HTML-Renderer's internal namespace (accessible here via -/// the InternalsVisibleTo("HtmlRenderer.Test") 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 is the equivalent). +/// Parses a CSS snippet with the internal CSS engine, for tests that don't need a whole document. /// 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; /// -/// Mirrors PeachPDF.Tests/CSS/ObjectArrayComparer.cs. Used to de-duplicate object[] test-case rows +/// Used to de-duplicate object[] 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. /// 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; -/// Mirrors PeachPDF.Tests/CSS/TestExtensions.cs, adapted to HTML-Renderer's internal CssEngine namespace. internal static class TestExtensions { public static Stylesheet ToCssStylesheet(this string sourceCode) 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; + +/// Which Compositing and Blending values the CSS engine accepts. +[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().Single().Style.GetPropertyValue(property)); + } +} 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($"
    text
    ")); + + 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( + "
    ")); + + 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( + "
    ")); + + 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( + "
    " + + "
    ")); + + 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/Source/Test/HtmlRenderer.Test/CssModules/CssAnimationsTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssAnimationsTests.cs new file mode 100644 index 000000000..ff92d8f47 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssAnimationsTests.cs @@ -0,0 +1,292 @@ +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; + +/// +/// The static frame is the document at time zero of its timeline, the first frame a browser would paint. +/// +[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("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("

    a

    "); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-animations-1", "#animations")] + public void AnimationWithoutKeyframes_ChangesNothing() + { + var x = Box("

    a

    "); + + 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().Select(r => r.KeyText).ToArray()); + } + + [TestMethod] + [Spec("css-animations-1", "#keyframes")] + public void KeyframesRule_DoesNotAffectOtherRules() + { + Assert.AreEqual(Green, Box("

    a

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

    a

    ").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().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().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")] + [Spec("web-animations-1", "#calculating-the-overall-progress")] + public void ZeroDuration_WithForwardsFill_ShowsTheLastKeyframe() + { + var x = Box("

    a

    "); + + 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("

    a

    "); + + Assert.AreEqual(Green, x.ActualColor); + } + + [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("

    a

    "); + + Assert.AreEqual(Green, x.ActualColor); + } + + [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("

    a

    "); + + 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")] + [Spec("web-animations-1", "#fill-modes")] + public void BackwardsFill_AppliesTheFirstKeyframeDuringTheDelay() + { + var x = Box("

    a

    "); + + 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("

    a

    "); + + 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/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("
    "); + + 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/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("

    a

    "); + + 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("
    a
    "); + + 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("
    a
    "); + + 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("

    a

    "); + + 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("

    a

    "); + + Assert.AreEqual(Green, x.ActualColor); + } + + // ── 7.3 Explicit defaulting ────────────────────────────────────────── + + [TestMethod] + [Spec("css-cascade-5", "#initial")] + public void Initial_IgnoresTheDefaultStyleSheet() + { + var x = Box("

    a

    "); + + Assert.AreEqual("normal", x.FontWeight); + } + + [TestMethod] + [Spec("css-cascade-5", "#inherit-initial")] + public void Unset_ErasesEarlierDeclarations() + { + var x = Box("

    a

    "); + + Assert.AreEqual(Green, x.ActualColor); + Assert.AreEqual("0", x.MarginLeft); + } + + [TestMethod] + [Spec("css-cascade-5", "#default")] + public void Revert_InAnAuthorRule_RollsBackToTheDefaultStyleSheet() + { + var x = Box("

    a

    "); + + Assert.AreEqual("bolder", x.FontWeight); + } + + [TestMethod] + [Spec("css-cascade-5", "#default")] + public void Revert_WithoutADefaultStyleSheetValue_InheritsOrTakesTheInitialValue() + { + var x = Box("

    a

    "); + + 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("

    a

    "); + + 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("

    a

    "); + + Assert.AreEqual("bolder", x.FontWeight); + } + + [TestMethod] + [Spec("css-cascade-5", "#revert-layer")] + public void RevertLayer_WithoutALowerLayer_RollsBackToTheDefaultStyleSheet() + { + var x = Box("

    a

    "); + + 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/Source/Test/HtmlRenderer.Test/CssModules/CssColor5Tests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssColor5Tests.cs new file mode 100644 index 000000000..580fd2735 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssColor5Tests.cs @@ -0,0 +1,167 @@ +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)] + [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)); + } + + [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 = "
    a
    ") + { + var (root, _) = LayoutHarness.Layout($"{body}"); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } +} 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/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}}}", "
    a
    "); + + 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 = "
    a
    ") + { + var (root, _) = LayoutHarness.Layout($"{body}"); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } +} 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/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("

    a

    "); + + 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("

    a

    "); + + 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("
    a
    "); + + 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("" + + "

    a

    "); + + 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("
    a
    "); + + 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/Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs new file mode 100644 index 000000000..efc52b3d3 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssConditionalTests.cs @@ -0,0 +1,165 @@ +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("

    a

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

    a

    ").ActualColor); + } + + [TestMethod] + [Spec("css-conditional-3", "#processing")] + [Spec("css-conditional-3", "#use")] + public void NestedConditions_ApplyOnlyWhenAllAreTrue() + { + var x = Box( + "

    a

    "); + + Assert.AreEqual(Green, x.ActualColor); + Assert.AreEqual("italic", x.FontStyle); + } + + [TestMethod] + [Spec("css-conditional-3", "#processing")] + [Spec("mediaqueries-4", "#media")] + [Spec("mediaqueries-5", "#media")] + public void ChangedCondition_IsReflected() + { + var html = LayoutHarness.Wrap("

    a

    "); + 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("

    a

    "); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-conditional-3", "#use")] + public void SupportsRule_InsideAMediaRule_Applies() + { + var x = Box("

    a

    "); + + Assert.AreEqual(Green, x.ActualColor); + } + + // ── 5 @media ───────────────────────────────────────────────────────── + + [Ignore("Gap, see Spec/css-conditional-3.yml")] + [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("

    a

    "); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-conditional-3", "#at-media")] + public void MediaRule_AppliesWhenAnyQueryOfTheListMatches() + { + var x = Box("

    a

    "); + + Assert.AreEqual(Green, x.ActualColor); + } + + // ── 6 @supports ────────────────────────────────────────────────────── + + [TestMethod] + [Spec("css-conditional-3", "#at-supports")] + public void SupportsRule_WithATrueCondition_Applies() + { + var x = Box("

    a

    "); + + Assert.AreEqual(Green, x.ActualColor); + } + + [TestMethod] + [Spec("css-conditional-3", "#at-supports")] + public void SupportsRule_WithAFalseCondition_DoesNotApply() + { + var x = Box("

    a

    "); + + 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/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("
    "); + + 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("
    "); + + 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("
    "); + + 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/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("
    1. a
    ")); + } + + // ── 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("" + + "
    • a
    "); + + 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("
    • a
    ").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($"
    1. a
    2. b
    3. c
    ")); + } + + [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/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/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( + "

    a

    ")); + + 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( + "

    a

    ")); + + 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/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 = "
    a
    ") + { + var (root, _) = LayoutHarness.Layout($"{body}", 800, 600); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } +} 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( + "
    " + + "
    ")); + + 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( + "
    " + + "
    ")); + + 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/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().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().Single().GetPropertyValue(descriptor); + } +} 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("

    Header

    "); + + 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("

    TextNote

    "); + 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().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().Count()); + } + + private static (CssBox Root, HtmlContainerInt Container) Paginate(string body) => + LayoutHarness.Layout($"{body}", 400, pageHeight: PageHeight, margin: 0); + + private static CssBox Find(CssBox root, string id) => LayoutHarness.FindById(root, id)!; +} 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("
    ab
    "); + + 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("
    a
    "); + var trimmed = Layout("
    a
    "); + + 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($"{body}", 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($"{body}"); + 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().Single().Style.GetPropertyValue(property); + } +} 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("
    "); + + 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().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($"{body}", 800, 600).Root; + + private static CssBox Styled(string css, string body = "
    a
    ") + { + var (root, _) = LayoutHarness.Layout($"{body}", 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().Single().Style.GetPropertyValue(property); + } +} 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; + +/// Which CSS Masking values the CSS engine accepts. +[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().Single().Style; + } +} 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("
    " + + "
    "); + + 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("
    "); + + 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/Source/Test/HtmlRenderer.Test/CssModules/CssNamespacesTests.cs b/Source/Test/HtmlRenderer.Test/CssModules/CssNamespacesTests.cs new file mode 100644 index 000000000..016de943f --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/CssModules/CssNamespacesTests.cs @@ -0,0 +1,113 @@ +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")] + [Spec("html", "infrastructure.html#xml")] + 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($"
    a
    "); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } +} 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 = "

    a

    b

    "; + + 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('~') + ? "

    a

    " + : "
    a

    b

    "; + + 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 } }", "
    a
    ").Color); + Assert.AreNotEqual(Green, Styled(".a { .p & { color: green } }", "
    a
    ").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 }", "
    a
    ").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 } } }", "

    a

    ").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 } }", "
    a
    ")] + [DataRow(".a { div& { color: green } }", "
    a
    ")] + [DataRow(".a { && { color: green } }", "
    a
    ")] + [DataRow(".a { & > & { color: green } }", "
    a
    ")] + [DataRow(".a { :not(&) > #x { color: green } }", "

    a

    ")] + [DataRow(".a { & :is(.b, &.c) { color: green } }", "

    a

    ")] + [DataRow(".a, .b { & #x { color: green } }", "

    a

    ")] + 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 } }", "
    a
    ").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 }", "a").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 = "
    a
    ", string id = "x") + { + var (root, _) = LayoutHarness.Layout($"{body}"); + var box = LayoutHarness.FindById(root, id); + Assert.IsNotNull(box); + return box; + } +} 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/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 = "
    " + + "
    "; + + [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 = "

    a

    "; + 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/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/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

    b

    ")); + + 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( + "" + + "a

    b

    "); + + 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/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/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/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($"
    "); + + 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("
    aaaa bbbb cccc dddd eeee ffff
    "); + + 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("
    "); + + 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/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", "
    a
    "); + + 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 = "
    a
    ") + { + body = body.Replace("
    {body}"); + var box = LayoutHarness.FindById(root, "x"); + Assert.IsNotNull(box); + return box; + } +} 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("bcdefgh

    "))); + } + + [TestMethod] + [Spec("html", "parsing.html#bogus-comment-state")] + [Spec("html", "parsing.html#processing-instruction-open-state")] + [Spec("html", "parsing.html#processing-instruction-target-state")] + [Spec("html", "parsing.html#after-processing-instruction-target-state")] + [Spec("html", "parsing.html#processing-instruction-data-state")] + [Spec("html", "parsing.html#processing-instruction-questionable-state")] + public void BogusCommentsAndProcessingInstructions_AreNotRendered() + { + Assert.AreEqual("abcdef", Text(Box( + "

    abcdef

    "))); + } + + [TestMethod] + [Spec("html", "parsing.html#doctype-state")] + [Spec("html", "parsing.html#before-doctype-name-state")] + [Spec("html", "parsing.html#doctype-name-state")] + [Spec("html", "parsing.html#after-doctype-name-state")] + [Spec("html", "parsing.html#after-doctype-public-keyword-state")] + [Spec("html", "parsing.html#before-doctype-public-identifier-state")] + [Spec("html", "parsing.html#doctype-public-identifier-(double-quoted)-state")] + [Spec("html", "parsing.html#doctype-public-identifier-(single-quoted)-state")] + [Spec("html", "parsing.html#after-doctype-public-identifier-state")] + [Spec("html", "parsing.html#between-doctype-public-and-system-identifiers-state")] + [Spec("html", "parsing.html#after-doctype-system-keyword-state")] + [Spec("html", "parsing.html#before-doctype-system-identifier-state")] + [Spec("html", "parsing.html#doctype-system-identifier-(double-quoted)-state")] + [Spec("html", "parsing.html#doctype-system-identifier-(single-quoted)-state")] + [Spec("html", "parsing.html#after-doctype-system-identifier-state")] + [Spec("html", "parsing.html#bogus-doctype-state")] + [DataRow("")] + [DataRow("")] + [DataRow("")] + [DataRow("")] + [DataRow("")] + [DataRow("")] + [DataRow("")] + public void Doctype_IsNotRendered(string doctype) + { + var (root, _) = LayoutHarness.Layout(doctype + LayoutHarness.Wrap("a")); + Assert.AreEqual("a", Text(root)); + } + + [TestMethod] + [Spec("html", "parsing.html#rcdata-state")] + [Spec("html", "parsing.html#rcdata-less-than-sign-state")] + [Spec("html", "parsing.html#rcdata-end-tag-open-state")] + [Spec("html", "parsing.html#rcdata-end-tag-name-state")] + public void Rcdata_KeepsTagsAndDecodesReferences() + { + Assert.AreEqual("a < ", Text(Box(""))); + } + + [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 new file mode 100644 index 000000000..bb38d6b57 --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Html/TreeConstructionTests.cs @@ -0,0 +1,577 @@ +using HtmlRenderer.Test.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; +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("<p>a<div id=x>b</div>")); + Assert.AreEqual("body", ParentTag("<p>a<ul id=x><li>b</li></ul>")); + Assert.AreEqual("body", ParentTag("<p>a<p id=x>b")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void BlockStartTag_ClosesAnOpenParagraph_WithAnInlineElementStillOpen() + { + Assert.AreEqual("body", ParentTag("<p><b>a<div id=x>b</div>")); + Assert.AreEqual("body", ParentTag("<p><b>a<p id=x>b")); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void Pre_DropsTheNewlineRightAfterTheStartTag() + { + Assert.AreEqual("a", Box("<pre id=x>\na</pre>").Boxes[0].Text); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void Textarea_ContentIsText() + { + Assert.AreEqual(0, Box("<textarea id=x><b>a</b></textarea>").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("<ul><li>a<li id=x>b</ul>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void DescriptionTerm_AndDetails_CloseEachOther() + { + Assert.AreEqual("dl", ParentTag("<dl><dt>a<dd id=x>b</dl>")); + Assert.AreEqual("dl", ParentTag("<dl><dd>a<dt id=x>b</dl>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void Option_ClosesThePreviousOption() + { + Assert.AreEqual("select", ParentTag("<select><option>a<option id=x>b</select>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void UnmatchedEndTag_IsIgnored() + { + Assert.AreEqual("span", ParentTag("<div><span>a</i><b id=x>b</b></span></div>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-inbody")] + public void UnmatchedParagraphEndTag_InsertsAnEmptyParagraph() + { + Assert.IsTrue(Box("<div id=x></p></div>").Boxes.Any(b => b.HtmlTag?.Name == "p")); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-intable")] + public void Caption_IsAChildOfTheTable() + { + Assert.AreEqual("table", ParentTag("<table><caption id=x>a</caption><tr><td>b</table>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-intable")] + public void TableRow_OutsideASection_GetsAnImpliedTbody() + { + Assert.AreEqual("tbody", ParentTag("<table><tr id=x><td>a</table>")); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-intbody")] + [Spec("html", "parsing.html#parsing-main-intr")] + public void TableRow_ClosesThePreviousRow() + { + Assert.AreEqual("tbody", ParentTag("<table><tbody><tr><td>a<tr id=x><td>b</table>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-intbody")] + public void TableSection_ClosesThePreviousSection() + { + Assert.AreEqual("table", ParentTag("<table><thead><tr><td>a<tbody id=x><tr><td>b</table>")); + } + + [TestMethod] + [Spec("html", "parsing.html#parsing-main-intd")] + public void DataCell_ClosesThePreviousCell() + { + Assert.AreEqual("tr", ParentTag("<table><tr><td>a<td id=x>b</table>")); + } + + [TestMethod] + [Ignore("Gap, see Spec/html.yml")] + [Spec("html", "parsing.html#parsing-main-intd")] + 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/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/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/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/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 @@ <ItemGroup> <ProjectReference Include="..\..\HtmlRenderer\HtmlRenderer.csproj" /> + <ProjectReference Include="..\..\Tools\HtmlRenderer.SpecCompliance\HtmlRenderer.SpecCompliance.csproj" /> </ItemGroup> <ItemGroup> 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/TestSupport/MockAdapter.cs b/Source/Test/HtmlRenderer.Test/TestSupport/MockAdapter.cs index 56c664307..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); @@ -44,6 +48,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/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/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("<div></div>")); 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("<div><a href='#'>Link</a><span>Not a link</span></div>")); var links = new System.Collections.Generic.List<CssBox>(); @@ -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("<a href='#'>Click</a>")); var link = LayoutHarness.Descendants(root).First(b => b.HtmlTag?.Name == "a"); var word = FindFirstWord(link)!; 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 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; + +/// <summary> +/// Links a test to the spec section it verifies, e.g. <c>[Spec("css21", "box.html#collapsing-margins")]</c>. +/// The anchor is a section key in <c>Spec/&lt;spec&gt;.yml</c>, the test also counts for its parent sections. +/// </summary> +[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..671896f78 --- /dev/null +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Compliance.cs @@ -0,0 +1,156 @@ +using System.Text.RegularExpressions; + +namespace HtmlRenderer.SpecCompliance; + +/// <summary>One spec with its manifest and the tests credited to each section.</summary> +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; } + + /// <summary>The manifest's sections in spec order.</summary> + public List<SpecSection> Sections { get; } + + /// <summary>Tests per section anchor. A test counts for the section it references and all its ancestors.</summary> + public Dictionary<string, List<TestReference>> Tests { get; } = new(StringComparer.Ordinal); + + public IReadOnlyList<TestReference> 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); + } + } +} + +/// <summary>Everything under <c>Spec/</c> plus the <c>[Spec]</c> references in <c>Source/Test</c>.</summary> +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<SpecResult> Specs { get; init; } + + public required List<TestReference> 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"); + + /// <summary>Walks up from <paramref name="start"/> to the folder holding <c>Source/HtmlRenderer.sln</c>.</summary> + 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) + { + // CSS 2.1, the CSS modules, then HTML + var specs = Directory.EnumerateFiles(SpecDirectory(root), "*.yml") + .OrderBy(path => Path.GetFileNameWithoutExtension(path) switch { "css21" => 0, "html" => 2, _ => 1 }) + .ThenBy(path => path, 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 }; + } + + /// <summary>Every section has a title starting with its section number, a known status, and isn't an n/a entry its parent already covers.</summary> + public List<string> CheckManifests() + { + var problems = new List<string>(); + 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; + } + + /// <summary>Every <c>[Spec]</c> in the tests points to a section of a known spec.</summary> + public List<string> CheckReferences() + { + var problems = new List<string>(); + 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; + } + + /// <summary>A section claimed as (partially) supported has tests, and a partial one lists its gaps.</summary> + public List<string> CheckEvidence() + { + var problems = new List<string>(); + 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 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net8.0</TargetFramework> + <LangVersion>latest</LangVersion> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <IsPackable>false</IsPackable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="YamlDotNet" Version="16.3.0" /> + </ItemGroup> + +</Project> diff --git a/Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs b/Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs new file mode 100644 index 000000000..9eb69148e --- /dev/null +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Manifest.cs @@ -0,0 +1,66 @@ +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<string>? Gaps { get; set; } +} + +/// <summary> +/// The implementation status of one spec, <c>Spec/&lt;spec&gt;.yml</c>. Sections are keyed by their anchor +/// and kept in spec order. +/// </summary> +public sealed class Manifest +{ + public string Spec { get; set; } = ""; + + public string Title { get; set; } = ""; + + /// <summary>Published URL, anchors are appended to it for links in the report.</summary> + public string Url { get; set; } = ""; + + /// <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); + + 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<Manifest>(File.ReadAllText(path)); + manifest.Sections ??= new Dictionary<string, ManifestSection>(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..4e412e114 --- /dev/null +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Program.cs @@ -0,0 +1,58 @@ +using System.Text; +using HtmlRenderer.SpecCompliance; + +const string Usage = """ + Usage: + 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); + +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 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; + } + + 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..7fd2c1e88 --- /dev/null +++ b/Source/Tools/HtmlRenderer.SpecCompliance/Report.cs @@ -0,0 +1,81 @@ +using System.Text; + +namespace HtmlRenderer.SpecCompliance; + +/// <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(); + AppendOverview(sb, compliance); + + foreach (var spec in compliance.Specs) + { + sb.AppendLine().AppendLine($"### {spec.Manifest.Title}").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); + } + + 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(); + 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($"<details><summary>{Escape(sections[0].DisplayTitle)}: {string.Join(", ", summary)}</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("<br>", 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("</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, 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.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/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; + +/// <summary>A numbered section of a spec, e.g. "8.3.1 Collapsing margins" at <c>box.html#collapsing-margins</c>.</summary> +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); + + /// <summary>Splits a manifest title like "8.3.1 Collapsing margins" into number and title.</summary> + 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; + +/// <summary> +/// A <c>[Spec("css21", "box.html#collapsing-margins")]</c> on a test method. An ignored test documents a gap +/// and doesn't count as evidence. +/// </summary> +public sealed record TestReference(string Spec, string Anchor, string Test, string File, int Line, bool Ignored); + +/// <summary> +/// Finds the <c>[Spec]</c> 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. +/// </summary> +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<TestReference> Scan(string testDirectory) + { + var references = new List<TestReference>(); + 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; + } + + /// <summary>The attribute lines above a method, up to the previous blank line or brace.</summary> + 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..484754a88 --- /dev/null +++ b/Spec/README.md @@ -0,0 +1,46 @@ +# 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 overview table shows up in the summary of the Windows .NET 8 CI job, the full report is its "Spec compliance report" artifact. + +## 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. + +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: + +- `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`: accepted, e.g. a CSS property or an HTML attribute, but layout and painting ignore it +- `unsupported`: not implemented +- `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 +[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")]`. 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. + +## 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, --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 +``` diff --git a/Spec/compositing-1.yml b/Spec/compositing-1.yml new file mode 100644 index 000000000..c41df8d7e --- /dev/null +++ b/Spec/compositing-1.yml @@ -0,0 +1,205 @@ +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: n/a + '#reading-this-document': + title: 2 Reading This Document + status: n/a + '#csscompositingandblending': + title: 3 Specifying Blending in CSS + status: unsupported + '#compositingandblendingorder': + title: 3.1 Order of graphical operations + 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: unsupported + gaps: + - Stacking contexts aren't isolated groups, see 8 + '#csscompositingrules_SVG': + title: 3.3 Behavior specific to SVG + status: unsupported + gaps: + - No SVG support + '#csskeywords': + title: 3.4 CSS Properties + 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: unsupported + '#isolation': + title: 3.4.2 The isolation property + status: unsupported + '#background-blend-mode': + title: 3.4.3 The background-blend-mode property + status: unsupported + '#canvascompositingandblending': + title: 4 Specifying Compositing and Blending in Canvas 2D + status: n/a + '#whatiscompositing': + title: 5 Introduction to compositing + status: supported + '#simplealphacompositing': + title: 5.1 Simple alpha compositing + status: supported + '#simplealphacompositingexamples': + title: 5.1.1 Examples of simple alpha compositing + status: n/a + '#generalformula': + title: 6 General Formula for Compositing and Blending + status: partial + gaps: + - Only the normal blend mode, see 10 + '#backdropCalc': + title: 7 Backdrop calculation + status: unsupported + gaps: + - No blending, see 10 + '#backdropexamples': + title: 7.1 Examples of backdrop calculation + status: n/a + '#groups': + title: 8 Compositing Groups + status: unsupported + gaps: + - No compositing groups, everything paints directly onto the canvas + '#groupinvariance': + title: 8.1 Group invariance + status: unsupported + '#isolatedgroups': + title: 8.2 Isolated Groups + status: unsupported + '#pagebackdrop': + title: 8.3 The Root Element Group + status: unsupported + '#rootgroup': + title: 8.4 The Root Group + status: unsupported + '#advancedcompositing': + title: 9 Advanced compositing features + status: partial + '#porterduffcompositingoperators': + title: 9.1 The Porter Duff Compositing Operators + 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: n/a + '#porterduffcompositingoperators_src': + title: 9.1.2 Copy + status: n/a + '#porterduffcompositingoperators_dst': + title: 9.1.3 Destination + status: n/a + '#porterduffcompositingoperators_srcover': + title: 9.1.4 Source Over + status: supported + '#porterduffcompositingoperators_dstover': + title: 9.1.5 Destination Over + status: n/a + '#porterduffcompositingoperators_srcin': + title: 9.1.6 Source In + status: unsupported + gaps: + - "Only used by mask-composite, see css-masking-1 #the-mask-composite" + '#porterduffcompositingoperators_dstin': + title: 9.1.7 Destination In + status: n/a + '#porterduffcompositingoperators_srcout': + title: 9.1.8 Source Out + status: unsupported + gaps: + - "Only used by mask-composite, see css-masking-1 #the-mask-composite" + '#porterduffcompositingoperators_dstout': + title: 9.1.9 Destination Out + status: n/a + '#porterduffcompositingoperators_srcatop': + title: 9.1.10 Source Atop + status: n/a + '#porterduffcompositingoperators_dstatop': + title: 9.1.11 Destination Atop + status: n/a + '#porterduffcompositingoperators_xor': + title: 9.1.12 XOR + status: unsupported + gaps: + - "Only used by mask-composite, see css-masking-1 #the-mask-composite" + '#porterduffcompositingoperators_plus': + title: 9.1.13 Lighter + status: n/a + '#groupcompositing': + title: 9.2 Group compositing behavior with Porter Duff modes + status: unsupported + '#blending': + title: 10 Blending + 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: partial + gaps: + - Only normal + '#blendingnormal': + title: 10.1.1 normal blend mode + status: supported + '#blendingmultiply': + title: 10.1.2 multiply blend mode + status: unsupported + '#blendingscreen': + title: 10.1.3 screen blend mode + status: unsupported + '#blendingoverlay': + title: 10.1.4 overlay blend mode + status: unsupported + '#blendingdarken': + title: 10.1.5 darken blend mode + status: unsupported + '#blendinglighten': + title: 10.1.6 lighten blend mode + status: unsupported + '#blendingcolordodge': + title: 10.1.7 color-dodge blend mode + status: unsupported + '#blendingcolorburn': + title: 10.1.8 color-burn blend mode + status: unsupported + '#blendinghardlight': + title: 10.1.9 hard-light blend mode + status: unsupported + '#blendingsoftlight': + title: 10.1.10 soft-light blend mode + status: unsupported + '#blendingdifference': + title: 10.1.11 difference blend mode + status: unsupported + '#blendingexclusion': + title: 10.1.12 exclusion blend mode + status: unsupported + '#blendingnonseparable': + title: 10.2 Non-separable blend modes + status: unsupported + '#blendinghue': + title: 10.2.1 hue blend mode + status: unsupported + '#blendingsaturation': + title: 10.2.2 saturation blend mode + status: unsupported + '#blendingcolor': + title: 10.2.3 color blend mode + status: unsupported + '#blendingluminosity': + title: 10.2.4 luminosity blend mode + status: unsupported + '#isolationblending': + title: 10.3 Effect of group isolation on blending + status: unsupported diff --git a/Spec/css-align-3.yml b/Spec/css-align-3.yml new file mode 100644 index 000000000..2007b38a1 --- /dev/null +++ b/Spec/css-align-3.yml @@ -0,0 +1,185 @@ +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: n/a + '#overview': + title: 2 Overview of Alignment Properties + status: n/a + '#terms': + title: 3 Alignment Terminology + status: n/a + '#alignment-values': + title: 4 Alignment Keywords + 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: 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: parsed-only + '#distribution-values': + title: '4.3 Distributed Alignment: the stretch, space-between, space-around, and space-evenly keywords' + status: parsed-only + '#overflow-values': + title: '4.4 Overflow Alignment: the safe and unsafe keywords and scroll safety limits' + 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: unsupported + '#auto-safety-scroll': + title: 4.4.1.1 Content Distribution for Scroll Containers + status: unsupported + '#auto-safety-position': + title: 4.4.1.2 Self-Alignment for Absolutely Positioned Boxes + status: unsupported + '#auto-safety-default': + title: 4.4.1.3 All Other Alignment + status: unsupported + '#content-distribution': + title: '5 Content Distribution: Aligning a Box’s Contents Within Itself' + status: parsed-only + '#align-justify-content': + title: 5.1 The justify-content and align-content Properties + status: parsed-only + '#distribution-block': + title: 5.1.1 Block Containers (Including Table Cells) + status: parsed-only + gaps: + - "'align-content' is ignored on block containers and table cells" + '#distribution-multicol': + title: 5.1.2 Multicol Containers + status: unsupported + gaps: + - No multicol layout, see css-multicol-1 + '#distribution-flex': + title: 5.1.3 Flex Containers + status: unsupported + gaps: + - No flex layout, see css-flexbox-1 + '#distribution-grid': + title: 5.1.4 Grid Containers + status: unsupported + gaps: + - No grid layout, see css-grid-2 + '#place-content': + title: '5.2 Content-Distribution Shorthand: the place-content property' + 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: unsupported + '#baseline-align-content': + title: 5.4 Baseline Content-Alignment + status: unsupported + '#self-alignment': + title: '6 Self-Alignment: Aligning the Box Within Its Parent' + status: parsed-only + '#justify-self-property': + title: '6.1 Inline-Axis (or Main-Axis) Self-Alignment: the justify-self property' + status: parsed-only + '#justify-block': + title: 6.1.1 Block-Level Boxes + status: parsed-only + gaps: + - "'justify-self' is ignored on block-level boxes" + '#justify-abspos': + title: 6.1.2 Absolutely-Positioned Boxes + status: parsed-only + gaps: + - "'justify-self' is ignored on absolutely positioned boxes" + '#justify-cell': + title: 6.1.3 Table Cells + status: n/a + '#justify-flex': + title: 6.1.4 Flex Items + status: n/a + '#justify-grid': + title: 6.1.5 Grid Items + 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: parsed-only + '#align-block': + title: 6.2.1 Block-Level Boxes + status: n/a + '#align-abspos': + title: 6.2.2 Absolutely-Positioned Boxes + status: parsed-only + gaps: + - "'align-self' is ignored on absolutely positioned boxes" + '#align-cell': + title: 6.2.3 Table Cells + status: n/a + '#align-flex': + title: 6.2.4 Flex Items + status: unsupported + gaps: + - No flex layout, see css-flexbox-1 + '#align-grid': + title: 6.2.5 Grid Items + status: unsupported + gaps: + - No grid layout, see css-grid-2 + '#place-self-property': + title: '6.3 Self-Alignment Shorthand: the place-self property' + 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: unsupported + '#abspos-sizing': + title: 6.5 Effects on Sizing of Absolutely Positioned Boxes with Static-Position Insets + status: unsupported + '#default-alignment': + title: 7 Default Alignment + status: parsed-only + '#justify-items-property': + title: '7.1 Inline-Axis (or Main-Axis) Default Alignment: the justify-items property' + 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: parsed-only + '#place-items-property': + title: '7.3 Default Alignment Shorthand: the place-items property' + 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: n/a + '#baseline-rules': + title: 9 Baseline Alignment Details + status: unsupported + '#baseline-export': + title: 9.1 Determining the Baselines of a Box + status: unsupported + '#baseline-terms': + title: 9.2 Baseline Alignment Grouping + status: unsupported + '#align-by-baseline': + title: 9.3 Aligning Boxes by Baseline + status: unsupported + '#changes': + title: 10 Changes + status: n/a + '#privacy': + title: 11 Privacy Considerations + status: n/a + '#security': + title: 12 Security Considerations + status: n/a diff --git a/Spec/css-animations-1.yml b/Spec/css-animations-1.yml new file mode 100644 index 000000000..18db6f53b --- /dev/null +++ b/Spec/css-animations-1.yml @@ -0,0 +1,86 @@ +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: n/a + '#animations': + title: 2 Animations + 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: 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: parsed-only + '#animation-name': + title: 3.2 The animation-name property + 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: parsed-only + gaps: + - Negative times are accepted + '#animation-timing-function': + title: 3.4 The animation-timing-function property + 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: parsed-only + '#animation-direction': + title: 3.6 The animation-direction property + status: parsed-only + '#animation-play-state': + title: 3.7 The animation-play-state property + status: parsed-only + '#animation-delay': + title: 3.8 The animation-delay property + status: parsed-only + '#animation-fill-mode': + title: 3.9 The animation-fill-mode property + status: parsed-only + '#animation': + title: 3.10 The animation shorthand property + 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: n/a + '#interface-dom': + title: 5 DOM Interfaces + status: n/a + '#priv': + title: 6 Privacy Considerations + status: n/a + '#sec': + title: 7 Security Considerations + status: n/a + '#changes': + title: 8 Changes + status: n/a + '#acknowledgements': + title: 9 Acknowledgements + status: n/a + '#other-open-issues': + title: 10 Other open issues + status: n/a + '#wg-resolutions-pending': + title: 11 Working Group Resolutions that are pending editing + status: n/a diff --git a/Spec/css-backgrounds-3.yml b/Spec/css-backgrounds-3.yml new file mode 100644 index 000000000..5fc960177 --- /dev/null +++ b/Spec/css-backgrounds-3.yml @@ -0,0 +1,208 @@ +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: n/a + '#backgrounds': + title: 2 Backgrounds + status: partial + '#layering': + title: 2.1 Layering Multiple Background Images + 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: 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: 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: 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: partial + gaps: + - "'fixed' is ignored, see CSS 2.1 14.2.1" + '#background-position': + title: '2.6 Positioning Images: the background-position property' + 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" + - 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 + '#background-clip': + title: '2.7 Painting Area: the background-clip property' + 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: 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: parsed-only + gaps: + - "'background-size' is ignored, images are drawn at their natural size" + '#background': + title: '2.10 Backgrounds Shorthand: the background property' + 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: partial + '#root-background': + title: 2.11.1 The Canvas Background and the Root Element + 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: 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: unsupported + gaps: + - "::first-line declarations never apply, see CSS 2.1 5.12.1" + '#borders': + title: 3 Borders + status: partial + '#border-color': + title: '3.1 Line Colors: the border-color properties' + 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: 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: 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: partial + gaps: + - "'border' doesn't reset 'border-image'" + '#corners': + title: 4 Rounded Corners + status: partial + '#border-radius': + title: '4.1 Curve Radii: the border-radius properties' + status: partial + gaps: + - Negative radii are accepted instead of being rejected + '#corner-shaping': + title: 4.2 Corner Shaping + 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: 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: 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: 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: partial + gaps: + - "'border-radius' also rounds a table with collapsing borders" + '#border-images': + title: 5 Border Images + 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: parsed-only + '#border-image-slice': + title: '5.2 Image Slicing: the border-image-slice property' + 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: parsed-only + '#border-image-outset': + title: '5.4 Edge Overhang: the border-image-outset property' + status: parsed-only + '#border-image-repeat': + title: '5.5 Image Tiling: the border-image-repeat property' + status: parsed-only + '#border-image-process': + title: 5.6 Drawing the Border Image + status: unsupported + '#border-image': + title: '5.7 Border Image Shorthand: the border-image property' + status: parsed-only + '#border-image-tables': + title: 5.8 Effect on Tables + status: unsupported + '#misc': + title: 6 Miscellaneous Effects + status: parsed-only + '#box-shadow': + title: '6.1 Drop Shadows: the box-shadow property' + status: parsed-only + gaps: + - "'box-shadow' is parsed but never painted" + '#shadow-shape': + title: 6.1.1 Shadow Shape, Spread, and Knockout + status: unsupported + '#shadow-blur': + title: 6.1.2 Blurring Shadow Edges + status: unsupported + '#shadow-layers': + title: 6.1.3 Layering, Layout, and Other Details + status: unsupported + '#levels': + title: 7 Levels + status: n/a + '#changes': + title: 8 Changes + status: n/a + '#acknowledgments': + title: 9 Acknowledgments + status: n/a + '#privacy': + title: 10 Privacy Considerations + status: n/a + '#security': + title: 11 Security Considerations + status: n/a diff --git a/Spec/css-backgrounds-4.yml b/Spec/css-backgrounds-4.yml new file mode 100644 index 000000000..9ebabae17 --- /dev/null +++ b/Spec/css-backgrounds-4.yml @@ -0,0 +1,111 @@ +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: n/a + '#backgrounds': + title: 2 Defining Backgrounds + status: partial + '#background-color': + title: '2.1 Base Color: the background-color property' + 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: 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: unsupported + gaps: + - The longhands aren't known properties and are dropped + '#background-repeat': + title: '2.4 Tiling Images Shorthand: the background-repeat property' + 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: 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: 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: 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: n/a + '#background-clip': + title: '2.8 Painting Area: the background-clip property' + 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: 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: 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: n/a + '#background-layers': + title: '2.11 Background Image Layers: the background-tbd shorthand property' + status: unsupported + gaps: + - Not implemented, the section is marked not ready for implementation + '#background': + title: '2.12 Backgrounds Shorthand: the background property' + 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: unsupported + gaps: + - 'Only the first layer is painted, see css-backgrounds-3 #layering' + '#special-backgrounds': + title: 4 Backgrounds of Special Elements + status: partial + '#root-background': + title: 4.1 The Canvas Background and the Root Element + 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: 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: unsupported + gaps: + - "::first-line declarations never apply, see css-backgrounds-3 #first-line-background" + '#changes': + title: 5 Changes + status: n/a + '#acknowledgments': + title: 6 Acknowledgments + status: n/a diff --git a/Spec/css-borders-4.yml b/Spec/css-borders-4.yml new file mode 100644 index 000000000..053a5622a --- /dev/null +++ b/Spec/css-borders-4.yml @@ -0,0 +1,200 @@ +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: n/a + '#borders': + title: 2 Borders + status: partial + '#border-color': + title: '2.1 Line Colors: the border-color properties' + 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: partial + gaps: + - Negative radii are accepted instead of being rejected + '#corner-shaping': + title: '3.7 Corner Shaping: the corner-*-shape properties' + 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: unsupported + '#corner-shaping-side-shorthands': + title: '3.8.1 Shaping The Corners Of One Side: The corner-*-shape shorthands:' + status: unsupported + '#corner-shaping-shorthand': + title: '3.8.2 Shaping All Corners At Once: The corner-shape shorthand' + status: unsupported + '#corner-shorthands': + title: '3.9 Corner Sizing & Shaping Shorthands: the corner and corner-* shorthand properties' + 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: 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: unsupported + '#corner-shorthand': + title: '3.9.3 Sizing & Shaping All Corners At Once: The corner shorthand' + status: unsupported + '#corner-shape-rendering': + title: 3.9.4 Rendering corner-shape + status: unsupported + '#corner-shape-constrain-radii': + title: 3.9.5 Constraining opposite radii + status: unsupported + '#corner-shape-interpolation': + title: 3.9.6 Interpolating corner shapes + status: n/a + '#border-images': + title: 4 Border Images + 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: parsed-only + '#border-image-slice': + title: '4.2 Image Slicing: the border-image-slice property' + 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: parsed-only + '#border-image-outset': + title: '4.4 Edge Overhang: the border-image-outset property' + status: parsed-only + '#border-image-repeat': + title: '4.5 Image Tiling: the border-image-repeat property' + status: parsed-only + '#border-image-process': + title: 4.6 Drawing the Border Image + status: unsupported + '#border-image': + title: '4.7 Border Image Shorthand: the border-image property' + status: parsed-only + '#border-image-tables': + title: 4.8 Effect on Tables + status: unsupported + '#partial-borders': + title: 5 Partial borders + 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: unsupported + '#border-clip': + title: 5.2 The border-clip properties + status: unsupported + '#drop-shadows': + title: 6 Drop Shadows + status: parsed-only + '#box-shadow-color': + title: '6.1 Coloring shadows: the box-shadow-color property' + 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: unsupported + '#box-shadow-blur': + title: '6.3 Blurring shadows: the box-shadow-blur property' + status: unsupported + '#box-shadow-spread': + title: '6.4 Spreading shadows: the box-shadow-spread property' + status: unsupported + '#box-shadow-position': + title: '6.5 Spreading shadows: the box-shadow-position property' + status: unsupported + '#box-shadow': + title: '6.6 Drop Shadows Shorthand: the box-shadow property' + 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: unsupported + '#shadow-blur': + title: 6.6.2 Blurring Shadow Edges + status: unsupported + '#shadow-layers': + title: 6.7 Layering, Layout, and Other Details + status: unsupported + '#border-shape': + title: 7 Border Shaping + status: unsupported + '#border-shape-func': + title: 7.1 The border-shape property + status: unsupported + gaps: + - "'border-shape' isn't a known property" diff --git a/Spec/css-box-3.yml b/Spec/css-box-3.yml new file mode 100644 index 000000000..d9e7bb7fb --- /dev/null +++ b/Spec/css-box-3.yml @@ -0,0 +1,56 @@ +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: n/a + '#box-model': + title: 2 The CSS Box Model + status: partial + '#box-edges': + title: 2.1 Boxes and Edges + status: supported + '#fragmentation': + title: 2.2 Fragmentation + status: n/a + '#keywords': + title: 2.3 Box-edge Keywords + status: parsed-only + gaps: + - margin-box, fill-box, stroke-box and view-box are rejected + '#margins': + title: 3 Margins + status: partial + '#margin-physical': + title: '3.1 Page-relative (Physical) Margin Properties: the margin-top, margin-right, margin-bottom, and margin-left properties' + 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: supported + '#paddings': + title: 4 Padding + status: partial + '#padding-physical': + title: '4.1 Page-relative (Physical) Padding Properties: the padding-top, padding-right, padding-bottom, and padding-left properties' + 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: supported + '#borders': + title: 5 Borders + status: supported + '#changes': + title: 6 Changes + status: n/a + '#priv': + title: 7 Privacy Considerations + status: n/a + '#sec': + title: 8 Security Considerations + status: n/a diff --git a/Spec/css-box-4.yml b/Spec/css-box-4.yml new file mode 100644 index 000000000..86779e141 --- /dev/null +++ b/Spec/css-box-4.yml @@ -0,0 +1,80 @@ +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: n/a + '#box-model': + title: 2 The CSS Box Model + status: partial + '#box-edges': + title: 2.1 Boxes and Edges + status: supported + '#fragmentation': + title: 2.2 Fragmentation + status: n/a + '#keywords': + title: 2.3 Box-edge Keywords + 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: partial + '#margin-physical': + title: '3.1 Page-relative (Physical) Margin Properties: the margin-top, margin-right, margin-bottom, and margin-left properties' + 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: supported + '#margin-trim': + title: '3.3 Margins at Container Edges: the margin-trim property' + status: unsupported + gaps: + - "'margin-trim' is dropped by the parser" + '#margin-trim-block': + title: 3.3.1 Trimming Block Container Content + status: unsupported + '#margin-trim-flex': + title: 3.3.2 Trimming Flex Container Content + status: unsupported + gaps: + - Flex layout isn't supported, see css-display-3 + '#margin-trim-grid': + title: 3.3.3 Trimming Grid Container Content + status: unsupported + gaps: + - Grid layout isn't supported, see css-display-3 + '#paddings': + title: 4 Padding + status: partial + '#padding-physical': + title: '4.1 Page-relative (Physical) Padding Properties: the padding-top, padding-right, padding-bottom, and padding-left properties' + 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: supported + '#borders': + title: 5 Borders + status: supported + '#changes': + title: 6 Recent Changes + status: n/a + '#changes-since-3': + title: 7 Changes Since CSS Level 3 + status: n/a + '#changes-since-2': + title: 8 Changes Since CSS Level 2 + status: n/a + '#priv': + title: 9 Privacy Considerations + status: n/a + '#sec': + title: 10 Security Considerations + status: n/a diff --git a/Spec/css-break-3.yml b/Spec/css-break-3.yml new file mode 100644 index 000000000..af3d44bad --- /dev/null +++ b/Spec/css-break-3.yml @@ -0,0 +1,111 @@ +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: n/a + '#fragmentation-model': + title: 2 Fragmentation Model and Terminology + status: partial + '#parallel-flows': + title: 2.1 Parallel Fragmentation Flows + 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: unsupported + gaps: + - Pages are the only fragmentation context, there's no multi-column or region fragmentation + '#breaking-controls': + title: 3 Controlling Breaks + status: partial + '#break-between': + title: '3.1 Breaks Between Boxes: the break-before and break-after properties' + 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: 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: 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: 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: supported + '#breaking-rules': + title: 4 Rules for Breaking + status: partial + '#possible-breaks': + title: 4.1 Possible Break Points + 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: n/a + '#forced-breaks': + title: 4.3 Forced Breaks + 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: 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: supported + '#breaking-boxes': + title: 5 Box Model for Breaking + status: partial + '#varying-size-boxes': + title: 5.1 Breaking into Varying-size Fragmentainers + 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: 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: supported + '#break-decoration': + title: '5.4 Fragmented Borders and Backgrounds: the box-decoration-break property' + 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: 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: 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 diff --git a/Spec/css-break-4.yml b/Spec/css-break-4.yml new file mode 100644 index 000000000..822d1f8c3 --- /dev/null +++ b/Spec/css-break-4.yml @@ -0,0 +1,105 @@ +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: n/a + '#fragmentation-model': + title: 2 Fragmentation Model and Terminology + status: partial + '#parallel-flows': + title: 2.1 Parallel Fragmentation Flows + 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: unsupported + gaps: + - Pages are the only fragmentation context, there's no multi-column or region fragmentation + '#breaking-controls': + title: 3 Controlling Breaks + status: partial + '#break-between': + title: '3.1 Breaks Between Boxes: the break-before and break-after properties' + 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: 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: 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: 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: supported + '#breaking-rules': + title: 4 Rules for Breaking + status: partial + '#possible-breaks': + title: 4.1 Possible Break Points + 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: n/a + '#forced-breaks': + title: 4.3 Forced Breaks + status: partial + gaps: + - Some forced breaks make no new page, see css-break-3 4.3 + '#unforced-breaks': + title: 4.4 Unforced Breaks + 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: supported + '#breaking-boxes': + title: 5 Box Model for Breaking + status: partial + '#varying-size-boxes': + title: 5.1 Breaking into Varying-size Fragmentainers + 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: 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: supported + '#break-decoration': + title: '5.4 Fragmented Borders and Backgrounds: the box-decoration-break property' + 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: 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: partial + gaps: + - Relatively and absolutely positioned boxes aren't fragmented as specified, see css-break-3 5.5 diff --git a/Spec/css-cascade-5.yml b/Spec/css-cascade-5.yml new file mode 100644 index 000000000..e5c8ff04f --- /dev/null +++ b/Spec/css-cascade-5.yml @@ -0,0 +1,171 @@ +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: n/a + '#at-import': + title: '2 Importing Style Sheets: the @import rule' + 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: parsed-only + gaps: + - The import conditions are never evaluated, see CSS 2.1 6.3 + '#import-processing': + title: 2.2 Processing Stylesheet Imports + status: unsupported + '#content-type': + title: 2.3 Content-Type of CSS Style Sheets + status: unsupported + '#shorthand': + title: 3 Shorthand Properties + status: supported + '#aliasing': + title: 3.1 Property Aliasing + status: n/a + '#all-shorthand': + title: '3.2 Resetting All Properties: the all property' + status: unsupported + '#value-stages': + title: 4 Value Processing + status: partial + '#declared': + title: 4.1 Declared Values + status: supported + '#value-aliasing': + title: 4.1.1 Value Aliasing + status: n/a + '#cascaded': + title: 4.2 Cascaded Values + status: supported + '#specified': + title: 4.3 Specified Values + status: supported + '#computed': + title: 4.4 Computed Values + 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: supported + '#applies-to': + title: 4.5.1 Applicable Properties + status: supported + '#actual': + title: 4.6 Actual Values + status: n/a + '#stages-examples': + title: 4.7 Examples + status: n/a + '#fragments': + title: 4.8 Per-Fragment Value Processing + status: unsupported + '#filtering': + title: 5 Filtering + 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: partial + '#cascade-sort': + title: 6.1 Cascade Sorting Order + status: partial + gaps: + - Cascade layers don't take part in the sort, see 6.4 + '#cascading-origins': + title: 6.2 Cascading Origins + status: supported + '#importance': + title: '6.3 Important Declarations: the !important annotation' + status: supported + '#layering': + title: 6.4 Cascade Layers + 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: parsed-only + gaps: + - '@import with layer or layer() is never loaded, see 2' + '#layer-names': + title: 6.4.2 Layer Naming and Nesting + 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: parsed-only + '#layer-ordering': + title: 6.4.3 Layer Ordering + status: unsupported + '#at-layer': + title: '6.4.4 Declaring Layers Inline: the @layer rule' + status: partial + '#layer-block': + title: '6.4.4.1 Assigning Styles Inline: the @layer block at-rule' + 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: parsed-only + gaps: + - The statement doesn't establish a layer order + '#preshint': + title: 6.5 Precedence of Non-CSS Presentational Hints + 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: partial + '#initial-values': + title: 7.1 Initial Values + status: supported + '#inheriting': + title: 7.2 Inheritance + 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: partial + '#initial': + title: '7.3.1 Resetting a Property: the initial keyword' + status: supported + '#inherit': + title: '7.3.2 Explicit Inheritance: the inherit keyword' + 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: supported + '#default': + title: '7.3.4 Rolling Back Cascade Origins: the revert keyword' + 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: 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: n/a + '#changes': + title: 9 Changes + status: n/a diff --git a/Spec/css-color-4.yml b/Spec/css-color-4.yml new file mode 100644 index 000000000..a1c700a2c --- /dev/null +++ b/Spec/css-color-4.yml @@ -0,0 +1,330 @@ +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: n/a + '#terminology': + title: 2 Color Terminology + status: n/a + '#applying-color': + title: 3 Applying Color in CSS + status: partial + '#accessibility': + title: 3.1 Accessibility and Conveying Information By Color + status: n/a + '#the-color-property': + title: '3.2 Foreground Color: the color property' + status: supported + '#transparency': + title: '3.3 Transparency: the opacity property' + status: parsed-only + gaps: + - "'opacity' is parsed but never applied when painting" + '#tagged-images': + title: 3.4 Color Space of Tagged Images + 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: supported + '#color-type': + title: '4 Representing Colors: the <color> type' + status: partial + '#color-syntax': + title: 4.1 The <color> syntax + status: partial + '#color-syntax-modern': + title: 4.1.1 Modern (Space-separated) Color Function Syntax + 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: 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: 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: 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: 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: 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: n/a + '#numeric-srgb': + title: 5 sRGB Colors + status: partial + '#rgb-functions': + title: '5.1 The RGB functions: rgb() and rgba()' + 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: 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: partial + '#named-colors': + title: 6.1 Named Colors + status: supported + '#css-system-colors': + title: 6.2 System Colors + 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: supported + '#currentcolor-color': + title: 6.4 The currentcolor keyword + 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: 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: supported + '#rgb-to-hsl': + title: 7.2 Converting sRGB Colors to HSL + status: supported + '#hsl-examples': + title: 7.3 Examples of HSL Colors + status: n/a + '#the-hwb-notation': + title: '8 HWB Colors: hwb() function' + 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: supported + '#rgb-to-hwb': + title: 8.2 Converting sRGB Colors to HWB + status: supported + '#hwb-examples': + title: 8.3 Examples of HWB Colors + status: n/a + '#lab-colors': + title: '9 Device-independent Colors: CIE Lab and LCH, Oklab and OkLCh' + status: partial + '#cie-lab': + title: 9.1 CIE Lab and LCH + status: supported + '#ok-lab': + title: 9.2 Oklab and OkLCh + status: supported + '#specifying-lab-lch': + title: '9.3 Specifying Lab and LCH: the lab() and lch() functional notations' + 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: supported + '#lab-to-lch': + title: 9.5 Converting Lab or Oklab colors to LCH or OkLCh colors + status: supported + '#lch-to-lab': + title: 9.6 Converting LCH or OkLCh colors to Lab or Oklab colors + status: supported + '#predefined': + title: 10 Predefined Color Spaces + status: partial + '#color-function': + title: '10.1 Specifying Predefined Colors: the color() function' + 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: 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: 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: 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: 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: 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: 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: 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: 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: supported + '#oklab-lab-to-predefined': + title: 10.11 Converting Lab or Oklab to Predefined RGB Color Spaces + status: supported + '#predefined-to-predefined': + title: 10.12 Converting Between Predefined RGB Color Spaces + status: supported + '#alpha': + title: 10.13 Simple Alpha Compositing + status: n/a + '#color-conversion': + title: 11 Converting Colors + status: partial + '#color-convert-intro': + title: 11.1 Introduction to Converting Colors + status: n/a + '#color-convert-algorithm': + title: 11.2 Algorithm for Converting Colors + 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: n/a + '#interpolation': + title: 13 Color Interpolation + status: partial + '#interpolate-gamut': + title: 13.1 Interpolation and color gamuts + status: n/a + '#component-wise-linear-interpolation': + title: 13.2 Component-wise Linear Interpolation + status: supported + '#interpolation-space': + title: 13.3 Color Space for Interpolation + 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: 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: supported + '#hue-interpolation': + title: 13.6 Hue Interpolation + 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: supported + '#hue-longer': + title: 13.6.2 longer + status: supported + '#hue-increasing': + title: 13.6.3 increasing + status: supported + '#hue-decreasing': + title: 13.6.4 decreasing + status: supported + '#gamut-mapping': + title: 14 Gamut Mapping + status: unsupported + '#gamut-mapping-intro': + title: 14.1 An Introduction to Gamut Mapping + status: n/a + '#css-gamut-mapping': + title: 14.2 CSS Gamut Mapping to an RGB Destination + 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: unsupported + '#pseudo-binsearch': + title: 14.2.2 Sample Pseudocode for the Binary Search Gamut Mapping with Local MINDE + status: n/a + '#GMA-EdgeSeeker': + title: 14.2.3 The EdgeSeeker Gamut Mapping + status: n/a + '#pseudo-edgeseeker': + title: 14.2.4 Sample Pseudocode for the EdgeSeeker Gamut Mapping + status: n/a + '#GMA-Raytrace': + title: 14.2.5 The Ray Trace Gamut Mapping + status: n/a + '#pseudo-raytrace': + title: 14.2.6 Sample Pseudocode for the Ray Trace Gamut Mapping + status: n/a + '#resolving-color-values': + title: 15 Resolving <color> Values + status: n/a + '#serializing-color-values': + title: 16 Serializing <color> Values + status: n/a + '#serializing-opacity-values': + title: 17 Serializing <opacity-value> + status: n/a + '#sample': + title: 18 Default Style Rules + status: n/a + '#color-conversion-code': + title: 19 Sample code for Color Conversions + status: n/a + '#color-difference-code': + title: 20 Color Difference Formulae + status: n/a + '#security': + title: 21 Security Considerations + status: n/a + '#privacy': + title: 22 Privacy Considerations + status: n/a + '#a11y-sec': + title: 23 Accessibility Considerations + status: n/a diff --git a/Spec/css-color-5.yml b/Spec/css-color-5.yml new file mode 100644 index 000000000..c6c2e6887 --- /dev/null +++ b/Spec/css-color-5.yml @@ -0,0 +1,153 @@ +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: n/a + '#color-syntax': + title: 2 The <color> syntax + status: partial + gaps: + - alpha() isn't supported + '#color-mix': + title: '3 Mixing Colors: the color-mix() Function' + status: partial + gaps: + - currentcolor in color-mix() isn't resolved, the color is transparent + '#color-mix-space': + title: 3.1 Colorspace for mixing + 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: partial + gaps: + - Percentages below 0% or above 100% aren't rejected + '#color-mix-result': + title: 3.3 Calculating the Result of color-mix + 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: n/a + '#color-mix-with-alpha': + title: 3.5 Effect of Non-Unity Alpha on color-mix + status: supported + '#relative-colors': + title: 4 Relative Colors + 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: unsupported + '#relative-syntax': + title: 4.2 Relative Color Syntax + status: unsupported + '#relative-RGB': + title: 4.3 Relative sRGB Colors + status: unsupported + '#relative-HSL': + title: 4.4 Relative HSL Colors + status: unsupported + '#relative-HWB': + title: 4.5 Relative HWB Colors + status: unsupported + '#relative-Lab': + title: 4.6 Relative Lab Colors + status: unsupported + '#relative-Oklab': + title: 4.7 Relative Oklab Colors + status: unsupported + '#relative-LCH': + title: 4.8 Relative LCH Colors + status: unsupported + '#relative-OkLCh': + title: 4.9 Relative OkLCh Colors + status: unsupported + '#relative-alpha': + title: 4.10 Relative Alpha Colors + status: unsupported + '#color-function': + title: '5 Specifying Predefined and Custom Color Spaces: the color() Function' + status: unsupported + gaps: + - color() isn't supported, see CSS Color 4 10.1 + '#relative-color-function': + title: 5.1 Relative Color-Function Colors + status: unsupported + '#custom-color': + title: 5.2 Custom Color Spaces + status: unsupported + '#at-profile': + title: '5.3 Specifying a Color Profile: the @color-profile at-rule' + status: unsupported + gaps: + - "@color-profile is ignored" + '#cal-cmyk': + title: '5.4 CSS and Print: Using Calibrated CMYK and Other Printed Color Spaces' + status: unsupported + '#cmyk-to-lab': + title: 5.5 Converting CMYK colors to Lab + status: unsupported + '#lab-to-cmyk': + title: 5.6 Converting Lab colors to CMYK + status: unsupported + '#device-cmyk': + title: '6 Uncalibrated CMYK Colors: the device-cmyk() Function' + 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: unsupported + '#light-dark': + title: '7 Reacting to the used color-scheme: the light-dark() Function' + 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: unsupported + gaps: + - contrast-color() isn't supported, the declaration is dropped + '#interpolation': + title: 9 Color Interpolation + status: partial + '#interpolation-space': + title: 9.1 Color Space for Interpolation + 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: n/a + '#serial': + title: 11 Serialization + status: n/a + '#apis': + title: 12 APIs + status: n/a + '#sample': + title: 13 Default Style Rules + status: n/a + '#color-conversion-code': + title: 14 Sample code for Color Conversions + status: n/a + '#security': + title: 15 Security Considerations + status: n/a + '#privacy': + title: 16 Privacy Considerations + status: n/a + '#a11y-sec': + title: 17 Accessibility Considerations + status: n/a + '#changes': + title: 18 Changes + status: n/a diff --git a/Spec/css-color-adjust-1.yml b/Spec/css-color-adjust-1.yml new file mode 100644 index 000000000..26e0e9d4c --- /dev/null +++ b/Spec/css-color-adjust-1.yml @@ -0,0 +1,65 @@ +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: n/a + '#preferred': + title: 2 Preferred Color Schemes + status: unsupported + '#color-scheme-prop': + title: '2.1 Opting Into a Preferred Color Scheme: the color-scheme property' + 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: 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: n/a + '#forced': + title: 3 Forced Color Palettes + 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: unsupported + '#forced-color-adjust-prop': + title: '3.2 Opting Out of a Forced Color Palette: the forced-color-adjust property' + status: unsupported + gaps: + - "'forced-color-adjust' isn't a known property" + '#perf': + title: 4 Performance-based Color Adjustments + status: unsupported + '#print-color-adjust': + title: '4.1 Ink Economy: the print-color-adjust property' + 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: unsupported + gaps: + - "'color-adjust' isn't a known property" + '#color-adjust-emulation': + title: 5 Emulation + status: n/a + '#privacy': + title: 6 Privacy Considerations + status: n/a + '#security': + title: 7 Security Considerations + status: n/a + '#acknowledgements': + title: 8 Acknowledgements + status: n/a + '#changes': + title: 9 Changes + status: n/a diff --git a/Spec/css-conditional-3.yml b/Spec/css-conditional-3.yml new file mode 100644 index 000000000..6d6af4401 --- /dev/null +++ b/Spec/css-conditional-3.yml @@ -0,0 +1,38 @@ +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: n/a + '#processing': + title: 2 Processing of conditional group rules + status: supported + '#contents-of': + title: 3 Contents of conditional group rules + status: supported + '#use': + title: 4 Placement of conditional group rules + status: supported + '#at-media': + title: '5 Media-specific style sheets: the @media rule' + 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: 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: 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: n/a + '#changes': + title: 8 Changes + status: n/a diff --git a/Spec/css-conditional-4.yml b/Spec/css-conditional-4.yml new file mode 100644 index 000000000..04c544159 --- /dev/null +++ b/Spec/css-conditional-4.yml @@ -0,0 +1,16 @@ +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: n/a + '#at-supports-ext': + title: 2 Extensions to the @supports rule + status: unsupported + gaps: + - selector() makes the @supports rule invalid + '#support-definition-ext': + title: 2.1 Extensions to the definition of support + status: unsupported diff --git a/Spec/css-conditional-5.yml b/Spec/css-conditional-5.yml new file mode 100644 index 000000000..e5ecb0a53 --- /dev/null +++ b/Spec/css-conditional-5.yml @@ -0,0 +1,120 @@ +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: n/a + '#at-supports-ext': + title: 2 Extensions to the @supports rule + 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: unsupported + '#support-definition-ext-fonts': + title: 2.1.1 Font techs and formats + status: unsupported + '#support-definition-at-rules': + title: 2.1.2 At-rules + status: unsupported + '#support-definition-supports-condition-name': + title: 2.1.3 Named conditions + status: unsupported + gaps: + - An unknown named condition, (--foo), is true instead of false + '#when-rule': + title: '3 Generalized Conditional Rules: the @when rule' + status: unsupported + gaps: + - '@when is dropped' + '#else-rule': + title: '4 Chained Conditionals: the @else rule' + status: unsupported + gaps: + - '@else is dropped' + '#container-queries': + title: 5 Container Queries + status: unsupported + '#container-type': + title: '5.1 Creating Query Containers: the container-type property' + 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: 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: 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: unsupported + gaps: + - The rules inside @container never apply, whatever the condition + '#animated-containers': + title: 5.5 Animated Containers + status: n/a + '#container-features': + title: 6 Container Features + status: unsupported + '#size-container': + title: 6.1 Size Container Features + status: unsupported + '#width': + title: '6.1.1 Width: the width feature' + status: unsupported + '#height': + title: '6.1.2 Height: the height feature' + status: unsupported + '#inline-size': + title: '6.1.3 Inline-size: the inline-size feature' + status: unsupported + '#block-size': + title: '6.1.4 Block-size: the block-size feature' + status: unsupported + '#aspect-ratio': + title: '6.1.5 Aspect-ratio: the aspect-ratio feature' + status: unsupported + '#orientation': + title: '6.1.6 Orientation: the orientation feature' + status: unsupported + '#style-container': + title: 6.2 Style Container Features + status: unsupported + '#scroll-state-container': + title: 6.3 Scroll State Container Features + status: unsupported + '#updating-scroll-state': + title: 6.3.1 Updating Scroll State + status: n/a + '#stuck': + title: '6.3.2 Sticky positioning: the stuck feature' + status: unsupported + '#snapped': + title: '6.3.3 Scroll snapping: the snapped feature' + status: unsupported + '#scrollable': + title: '6.3.4 Scrollable: the scrollable feature' + status: unsupported + '#scrolled': + title: '6.3.5 Scrolled: the scrolled feature' + status: unsupported + '#container-lengths': + title: '7 Container Relative Lengths: the cqw, cqh, cqi, cqb, cqmin, cqmax units' + 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: unsupported + '#apis': + title: 9 APIs + status: n/a diff --git a/Spec/css-contain-2.yml b/Spec/css-contain-2.yml new file mode 100644 index 000000000..d7d4e4f34 --- /dev/null +++ b/Spec/css-contain-2.yml @@ -0,0 +1,69 @@ +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: n/a + '#contain-property': + title: '2 Strong Containment: the contain property' + status: unsupported + gaps: + - "'contain' is dropped by the parser" + '#containment-types': + title: 3 Types of Containment + status: unsupported + '#containment-size': + title: 3.1 Size Containment + status: unsupported + '#containment-size-opt': + title: 3.1.1 Possible Size-Containment Optimizations + status: n/a + '#containment-layout': + title: 3.2 Layout Containment + status: unsupported + '#containment-layout-opt': + title: 3.2.1 Possible Layout-Containment Optimizations + status: n/a + '#containment-style': + title: 3.3 Style Containment + status: unsupported + '#containment-style-opt': + title: 3.3.1 Possible Style-Containment Optimizations + status: n/a + '#containment-paint': + title: 3.4 Paint Containment + status: unsupported + '#containment-paint-opt': + title: 3.4.1 Possible Paint-Containment Optimizations + status: n/a + '#content-visibility': + title: '4 Suppressing An Element’s Contents Entirely: the content-visibility property' + status: unsupported + gaps: + - "'content-visibility' is dropped by the parser" + '#using-cv-hidden': + title: '4.1 Using content-visibility: hidden' + status: n/a + '#using-cv-auto': + title: '4.2 Using content-visibility: auto' + status: n/a + '#content-visibility-auto-state-changed': + title: '4.3 Detecting content-visibility: auto state changes: the contentvisibilityautostatechanged event' + status: n/a + '#cv-notes': + title: 4.4 Restrictions and Clarifications + status: unsupported + '#cv-a11y': + title: 4.5 Accessibility Implications + status: n/a + '#cv-examples': + title: 4.6 Examples + status: n/a + '#privacy': + title: 5 Privacy Considerations + status: n/a + '#security': + title: 6 Security Considerations + status: n/a diff --git a/Spec/css-content-3.yml b/Spec/css-content-3.yml new file mode 100644 index 000000000..277fd1f35 --- /dev/null +++ b/Spec/css-content-3.yml @@ -0,0 +1,108 @@ +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: 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: n/a + '#alt': + title: 1.2 Alternative Text for Accessibility + 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: partial + '#strings': + title: '2.1 Basic Strings: <string> and <attr()> values' + status: supported + '#content-uri': + title: '2.2 2D Images: <image> values' + 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: unsupported + '#quotes': + title: 2.4 Quotation Marks + status: partial + '#quotes-property': + title: '2.4.1 Quotation Mark System: the quotes property' + 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: partial + gaps: + - The quote depth isn't tracked, see CSS 2.1 12.3.2 + '#leaders': + title: 2.5 Leaders + status: unsupported + '#leader-function': + title: 2.5.1 The leader() function + status: unsupported + '#leader-rules': + title: 2.5.2 Rendering leaders + status: unsupported + '#leader-alignment': + title: 2.5.3 Procedure for rendering leaders + status: unsupported + '#cross-references': + title: 2.6 Cross References + status: unsupported + '#target-counter': + title: 2.6.1 The target-counter() function + status: unsupported + '#target-counters': + title: 2.6.2 The target-counters() function + status: unsupported + '#target-text': + title: 2.6.3 The target-text() function + status: unsupported + '#named-strings': + title: 2.7 Named Strings + status: parsed-only + '#string-set': + title: '2.7.1 Naming Strings: the string-set property' + 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: parsed-only + gaps: + - string() inserts nothing + '#content-function': + title: 2.7.3 The content() function + status: parsed-only + gaps: + - content() inserts nothing + '#counters': + title: 2.8 Automatic Counters and Numbering + status: n/a + '#bookmark-generation': + title: 3 Bookmarks + status: unsupported + '#bookmark-level': + title: '3.1 Setting a Bookmark: the bookmark-level property' + status: unsupported + '#bookmark-label': + title: '3.2 Labelling a Bookmark: the bookmark-label property' + status: unsupported + '#bookmark-state': + title: '3.3 Initial Bookmark Toggle State: the bookmark-state property' + status: unsupported + '#changes': + title: 4 Changes + status: n/a diff --git a/Spec/css-counter-styles-3.yml b/Spec/css-counter-styles-3.yml new file mode 100644 index 000000000..63d14280f --- /dev/null +++ b/Spec/css-counter-styles-3.yml @@ -0,0 +1,131 @@ +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: n/a + '#counter-styles': + title: 2 Counter Styles + 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: unsupported + gaps: + - '@counter-style is dropped, list-style-type rejects its name' + '#counter-style-system': + title: '3.1 Counter algorithms: the system descriptor' + status: unsupported + '#cyclic-system': + title: '3.1.1 Cycling Symbols: the cyclic system' + status: unsupported + '#fixed-system': + title: '3.1.2 Exhaustible Symbols: the fixed system' + status: unsupported + '#symbolic-system': + title: '3.1.3 Repeating Symbols: the symbolic system' + status: unsupported + '#alphabetic-system': + title: '3.1.4 Bijective Numerals: the alphabetic system' + status: unsupported + '#numeric-system': + title: '3.1.5 Positional Numerals: the numeric system' + status: unsupported + '#additive-system': + title: '3.1.6 Accumulating Numerals: the additive system' + status: unsupported + '#extends-system': + title: '3.1.7 Building from Existing Counter Styles: the extends system' + status: unsupported + '#counter-style-negative': + title: '3.2 Formatting negative values: the negative descriptor' + status: unsupported + '#counter-style-prefix': + title: '3.3 Symbols before the marker: the prefix descriptor' + status: unsupported + '#counter-style-suffix': + title: '3.4 Symbols after the marker: the suffix descriptor' + status: unsupported + '#counter-style-range': + title: '3.5 Limiting the counter scope: the range descriptor' + status: unsupported + '#counter-style-pad': + title: '3.6 Zero-Padding and Constant-Width Representations: the pad descriptor' + status: unsupported + '#counter-style-fallback': + title: '3.7 Defining fallback: the fallback descriptor' + status: unsupported + '#counter-style-symbols': + title: '3.8 Marker characters: the symbols and additive-symbols descriptors' + status: unsupported + '#counter-style-speak-as': + title: '3.9 Speech Synthesis: the speak-as descriptor' + status: n/a + '#symbols-function': + title: '4 Defining Anonymous Counter Styles: the symbols() function' + status: unsupported + gaps: + - 'symbols() is rejected, see css-lists-3 #text-markers' + '#extending-css2': + title: 5 Extending list-style-type, counter(), and counters() + 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: 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: 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: 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: 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: unsupported + '#complex-predefined-counters': + title: 7 Complex Predefined Counter Styles + status: unsupported + '#complex-cjk': + title: 7.1 Longhand East Asian Counter Styles + status: unsupported + '#limited-japanese': + title: '7.1.1 Japanese: japanese-informal and japanese-formal' + status: unsupported + '#limited-korean': + title: '7.1.2 Korean: korean-hangul-formal, korean-hanja-informal, and korean-hanja-formal' + status: unsupported + '#limited-chinese': + title: '7.1.3 Chinese: simp-chinese-informal, simp-chinese-formal, trad-chinese-informal, and trad-chinese-formal' + status: unsupported + '#ethiopic-numeric-counter-style': + title: '7.2 Ethiopic Numeric Counter Style: ethiopic-numeric' + status: unsupported + '#additional-predefined': + title: 8 Additional “Ready-made” Counter Styles + status: n/a + '#apis': + title: 9 APIs + status: n/a + '#ua-stylesheet': + title: 10 Sample style sheet for HTML + status: unsupported + gaps: + - 'summary has no disclosure marker, see html rendering.html#the-details-and-summary-elements' diff --git a/Spec/css-display-3.yml b/Spec/css-display-3.yml new file mode 100644 index 000000000..dc0bd77f0 --- /dev/null +++ b/Spec/css-display-3.yml @@ -0,0 +1,71 @@ +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: n/a + '#the-display-properties': + title: '2 Box Layout Modes: the display property' + 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: 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: 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: 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: partial + gaps: + - The ruby-* keywords are rejected + '#box-generation': + title: '2.5 Box Generation: the none and contents keywords' + status: partial + gaps: + - "'contents' is rejected, the element keeps its own box" + '#legacy-display': + title: 2.6 Precomposed Inline-level Display Values + 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: unsupported + gaps: + - Floated and absolutely positioned boxes keep their inline-level display type + '#root': + title: 2.8 The Root Element’s Principal Box + 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: parsed-only + '#order-accessibility': + title: 3.1 Reordering and Accessibility + status: n/a + '#visibility': + title: '4 Invisibility: the visibility property' + 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: unsupported diff --git a/Spec/css-display-4.yml b/Spec/css-display-4.yml new file mode 100644 index 000000000..47fa50be7 --- /dev/null +++ b/Spec/css-display-4.yml @@ -0,0 +1,76 @@ +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: n/a + '#the-display-properties': + title: '2 Box Layout Modes: the display property' + 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: 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: 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: 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: partial + gaps: + - The ruby-* keywords are rejected + '#box-generation': + title: '2.5 Box Generation: the none and contents keywords' + status: partial + gaps: + - "'contents' is rejected, the element keeps its own box" + '#legacy-display': + title: 2.6 Precomposed Inline-level Display Values + 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: unsupported + gaps: + - Floated and absolutely positioned boxes keep their inline-level display type + '#root': + title: 2.8 The Root Element’s Principal Box + 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: n/a + '#order-property': + title: '3 Display Order: the order property' + status: parsed-only + '#order-accessibility': + title: 3.1 Reordering and Accessibility + status: n/a + '#reading-flow': + title: '4 Reading Order: the reading-flow property' + 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: 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: unsupported diff --git a/Spec/css-easing-1.yml b/Spec/css-easing-1.yml new file mode 100644 index 000000000..8d9f2dfc4 --- /dev/null +++ b/Spec/css-easing-1.yml @@ -0,0 +1,47 @@ +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: n/a + '#easing-functions': + title: 2 Easing functions + status: parsed-only + '#the-linear-easing-function': + title: '2.1 The linear easing function: linear' + 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: 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: 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: 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: unsupported + gaps: + - "Easing functions are never evaluated, animations aren't applied, see css-animations-1 2" + '#serialization': + title: 2.4 Serialization + status: n/a + '#priv-sec': + title: 3 Privacy and Security Considerations + status: n/a + '#changes': + title: 4 Changes + status: n/a + '#acknowledgements': + title: 5 Acknowledgements + status: n/a diff --git a/Spec/css-env-1.yml b/Spec/css-env-1.yml new file mode 100644 index 000000000..eced80c26 --- /dev/null +++ b/Spec/css-env-1.yml @@ -0,0 +1,37 @@ +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: n/a + '#environment': + title: 2 Environment Variables + status: unsupported + '#safe-area-insets': + title: 2.1 Safe area inset variables + status: unsupported + '#safe-area-max-insets': + title: 2.2 Safe area maximum inset variables + status: unsupported + '#viewport-segments': + title: 2.3 Viewport segment variables + status: unsupported + '#text-zoom': + title: 2.4 Preferred Text Zoom + status: unsupported + '#env-function': + title: '3 Using Environment Variables: the env() notation' + status: unsupported + gaps: + - env() isn't supported, the declaration is dropped + '#env-in-shorthands': + title: 3.1 Environment Variables in Shorthand Properties + status: unsupported + '#priv': + title: 4 Privacy Considerations + status: n/a + '#sec': + title: 5 Security Considerations + status: n/a diff --git a/Spec/css-flexbox-1.yml b/Spec/css-flexbox-1.yml new file mode 100644 index 000000000..ddac61cfb --- /dev/null +++ b/Spec/css-flexbox-1.yml @@ -0,0 +1,163 @@ +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: n/a + '#box-model': + title: 2 Flex Layout Box Model and Terminology + status: n/a + '#flex-containers': + title: '3 Flex Containers: the flex and inline-flex display values' + 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: 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: unsupported + '#item-margins': + title: 4.2 Flex Item Margins and Paddings + status: unsupported + '#painting': + title: 4.3 Flex Item Z-Ordering + status: unsupported + '#visibility-collapse': + title: 4.4 Collapsed Items + status: unsupported + '#min-size-auto': + title: 4.5 Automatic Minimum Size of Flex Items + status: unsupported + '#flow-order': + title: 5 Ordering and Orientation + status: parsed-only + '#flex-direction-property': + title: '5.1 Flex Flow Direction: the flex-direction property' + status: parsed-only + '#flex-wrap-property': + title: '5.2 Flex Line Wrapping: the flex-wrap property' + status: parsed-only + '#flex-flow-property': + title: '5.3 Flex Direction and Wrap: the flex-flow shorthand' + status: parsed-only + '#order-property': + title: '5.4 Reordering and Accessibility: the order property' + status: parsed-only + '#flex-lines': + title: 6 Flex Lines + status: unsupported + '#flexibility': + title: 7 Flexibility + status: unsupported + '#flex-property': + title: 7.1 The flex Shorthand + 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: n/a + '#flex-components': + title: 7.2 Components of Flexibility + status: parsed-only + '#flex-grow-property': + title: 7.2.1 The flex-grow property + status: parsed-only + gaps: + - Negative values are accepted + '#flex-shrink-property': + title: 7.2.2 The flex-shrink property + status: parsed-only + gaps: + - Negative values are accepted + '#flex-basis-property': + title: 7.2.3 The flex-basis property + status: parsed-only + gaps: + - Negative lengths are accepted + '#alignment': + title: 8 Alignment + status: parsed-only + '#auto-margins': + title: 8.1 Aligning with auto margins + status: unsupported + '#justify-content-property': + title: '8.2 Axis Alignment: the justify-content property' + 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: 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: parsed-only + gaps: + - 'Grammar gaps of the alignment keywords, see css-align-3 #alignment-values' + '#flex-baselines': + title: 8.5 Flex Container Baselines + status: unsupported + '#layout-algorithm': + title: 9 Flex Layout Algorithm + status: unsupported + '#box-manip': + title: 9.1 Initial Setup + status: unsupported + '#line-sizing': + title: 9.2 Line Length Determination + status: unsupported + '#main-sizing': + title: 9.3 Main Size Determination + status: unsupported + '#cross-sizing': + title: 9.4 Cross Size Determination + status: unsupported + '#main-alignment': + title: 9.5 Main-Axis Alignment + status: unsupported + '#cross-alignment': + title: 9.6 Cross-Axis Alignment + status: unsupported + '#resolve-flexible-lengths': + title: 9.7 Resolving Flexible Lengths + status: unsupported + '#definite-sizes': + title: 9.8 Definite and Indefinite Sizes + status: unsupported + '#intrinsic-sizes': + title: 9.9 Intrinsic Sizes + status: unsupported + '#intrinsic-main-sizes': + title: 9.9.1 Flex Container Intrinsic Main Sizes + status: unsupported + '#intrinsic-main-sizes-ideal': + title: 9.9.1.1 Ideal Algorithm + status: unsupported + '#intrinsic-main-sizes-compat': + title: 9.9.1.2 Web-compatible Intrinsic Sizing Algorithm + status: unsupported + '#intrinsic-main-sizes-multiline': + title: 9.9.1.3 Multi-line Min-content Algorithm + status: unsupported + '#intrinsic-cross-sizes': + title: 9.9.2 Flex Container Intrinsic Cross Sizes + status: unsupported + '#intrinsic-item-contributions': + title: 9.9.3 Flex Item Intrinsic Size Contributions + status: unsupported + '#pagination': + title: 10 Fragmenting Flex Layout + status: unsupported + '#pagination-algo': + title: 10.1 Sample Flex Fragmentation Algorithm + status: n/a diff --git a/Spec/css-font-loading-3.yml b/Spec/css-font-loading-3.yml new file mode 100644 index 000000000..c80ae6903 --- /dev/null +++ b/Spec/css-font-loading-3.yml @@ -0,0 +1,22 @@ +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: 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: n/a + '#FontFaceSet-interface': + title: 3 The FontFaceSet Interface + status: n/a + '#font-face-source': + title: 4 The FontFaceSource Mixin + status: n/a + '#font-load-event-examples': + title: 5 API Examples + status: n/a diff --git a/Spec/css-fonts-4.yml b/Spec/css-fonts-4.yml new file mode 100644 index 000000000..3b29e6fd2 --- /dev/null +++ b/Spec/css-fonts-4.yml @@ -0,0 +1,341 @@ +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: n/a + '#basic-font-props': + title: 2 Basic Font Properties + status: partial + '#font-family-prop': + title: '2.1 Font family: the font-family property' + 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: 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: partial + gaps: + - generic() is rejected, the whole declaration is dropped + '#system': + title: 2.1.3 Syntax of <system-font-family-name> + 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: n/a + '#generic-font-families': + title: 2.1.5 Generic font families + 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: 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: 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: supported + '#font-width-prop': + title: '2.3 Font width: the font-width property' + 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: supported + '#font-style-prop': + title: '2.4 Font style: the font-style property' + 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: 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: 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: parsed-only + '#font-prop': + title: '2.7 Shorthand font property: the font property' + 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: unsupported + '#font-synthesis-weight': + title: '2.8.1 Controlling synthesized bold: The font-synthesis-weight property' + status: unsupported + '#font-synthesis-style': + title: '2.8.2 Controlling synthesized oblique: The font-synthesis-style property' + status: unsupported + '#font-synthesis-small-caps': + title: '2.8.3 Controlling synthesized small caps: The font-synthesis-small-caps property' + status: unsupported + '#font-synthesis-position': + title: '2.8.4 Controlling synthesized super- and subscripts: The font-synthesis-position property' + status: unsupported + '#font-synthesis': + title: '2.8.5 Controlling synthetic faces: the font-synthesis shorthand' + status: unsupported + '#font-rendering-controls': + title: 3 Font Rendering Controls + status: n/a + '#font-resources': + title: 4 Font Resources + status: partial + '#font-face-rule': + title: 4.1 The @font-face rule + status: supported + '#font-family-desc': + title: '4.2 Font family: the font-family descriptor' + status: supported + '#src-desc': + title: '4.3 Font reference: the src descriptor' + status: partial + '#font-face-src-parsing': + title: 4.3.1 Parsing the src descriptor + status: supported + '#font-face-src-loading': + title: 4.3.2 Loading an individual item in the src descriptor + status: supported + '#font-face-src-formats': + title: 4.3.3 Selecting items in the src + 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: 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: 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: supported + '#unicode-range-desc': + title: '4.5 Character range: the unicode-range descriptor' + 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: n/a + '#font-rend-desc': + title: '4.6 Font features and variations: the font-feature-settings and font-variation-settings descriptors' + status: unsupported + '#font-named-instance': + title: '4.7 Using named instances from variable fonts: the font-named-instance descriptor' + status: unsupported + '#font-requests': + title: 4.8 Font request guidelines + status: n/a + '#font-face-loading': + title: 4.8.1 Font loading guidelines + 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: n/a + '#font-display-font-feature-values': + title: 4.9.1 Controlling Font Display Per Font-Family via @font-feature-values + status: unsupported + '#font-language-override-desc': + title: '4.10 Default font language overriding: the font-language-override descriptor' + status: unsupported + '#font-metrics-override-desc': + title: '4.11 Default font metrics overriding: the ascent-override, descent-override and line-gap-override descriptors' + status: unsupported + '#font-matching-algorithm': + title: 5 Font Matching Algorithm + status: partial + gaps: + - The font is chosen once per element, not per character + '#localized-name-matching': + title: 5.1 Localized name matching + 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: partial + gaps: + - font-style is matched before font-width instead of after it + '#cluster-matching': + title: 5.3 Cluster matching + 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: n/a + '#font-rend-props': + title: 6 Font Feature Properties + status: unsupported + '#glyph-selection-positioning': + title: 6.1 Glyph selection and positioning + status: n/a + '#language-specific-support': + title: 6.2 Language-specific display + status: unsupported + '#font-kerning-prop': + title: '6.3 Kerning: the font-kerning property' + status: unsupported + '#horizontal-kerning': + title: 6.3.1 Horizontal kerning + status: unsupported + '#vertical-kerning': + title: 6.3.2 Vertical kerning + status: unsupported + '#font-variant-ligatures-prop': + title: '6.4 Ligatures: the font-variant-ligatures property' + status: unsupported + '#font-variant-position-prop': + title: '6.5 Subscript and superscript forms: the font-variant-position property' + status: unsupported + '#font-variant-caps-prop': + title: '6.6 Capitalization: the font-variant-caps property' + 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: unsupported + '#font-variant-alternates-prop': + title: '6.8 Alternates and swashes: the font-variant-alternates property' + status: unsupported + '#font-feature-values': + title: '6.9 Defining font specific alternates: the @font-feature-values rule' + status: unsupported + '#font-feature-values-syntax': + title: 6.9.1 Basic syntax + status: unsupported + '#multi-value-features': + title: 6.9.2 Multi-valued feature value definitions + status: unsupported + '#font-variant-east-asian-prop': + title: '6.10 East Asian text rendering: the font-variant-east-asian property' + status: unsupported + '#font-variant-prop': + title: '6.11 Overall shorthand for font rendering: the font-variant property' + 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: unsupported + '#font-language-override-prop': + title: '6.13 Font language override: the font-language-override property' + status: unsupported + '#font-feature-variation-resolution': + title: 7 Font Feature and Variation Resolution + status: unsupported + '#default-features': + title: 7.1 Default features + status: unsupported + '#feature-variation-precedence': + title: 7.2 Feature and variation precedence + status: unsupported + '#feature-precedence-examples': + title: 7.3 Feature precedence examples + status: n/a + '#font-variation-props': + title: 8 Font Variation Properties + status: unsupported + '#font-optical-sizing-def': + title: '8.1 Optical sizing control: the font-optical-sizing property' + status: unsupported + '#font-variation-settings-def': + title: '8.2 Low-level font variation settings control: the font-variation-settings property' + status: unsupported + '#color-font-support': + title: 9 Color Font Support + status: unsupported + '#font-palette-prop': + title: '9.1 Controlling Color Font Palettes: The font-palette property' + status: parsed-only + '#font-palette-values': + title: '9.2 User-defined font color palettes: The @font-palette-values rule' + status: parsed-only + '#font-family-2-desc': + title: '9.2.1 Font family: the font-family descriptor' + status: parsed-only + '#base-palette-desc': + title: '9.2.2 Specifying the base palette: the base-palette descriptor' + status: parsed-only + '#override-color': + title: '9.2.3 Overriding a colors from a palette: The override-colors descriptor' + status: parsed-only + '#font-variant-emoji-prop': + title: '9.3 Selecting the text presentation style: The font-variant-emoji property' + status: unsupported + '#font-taxonomy': + title: 10 Font Taxonomy + status: n/a + '#font-technologies-formats': + title: 11 Font Technologies and Formats + status: unsupported + '#font-tech-definitions': + title: 11.1 Font tech + status: unsupported + gaps: + - tech() is ignored, see 4.3.3 + '#font-format-definitions': + title: 11.2 Font formats + status: unsupported + gaps: + - format() is ignored, see 4.3.3 + '#object-model': + title: 12 Object Model + status: n/a + '#serializing': + title: 13 Serializing + status: n/a + '#security': + title: 14 Security Considerations + status: n/a + '#privacy': + title: 15 Privacy Considerations + status: n/a + '#a11y': + title: 16 Accessibility Considerations + status: n/a + '#acknowledgments': + title: 17 Acknowledgments + status: n/a + '#changes': + title: 18 Changes + status: n/a diff --git a/Spec/css-fonts-5.yml b/Spec/css-fonts-5.yml new file mode 100644 index 000000000..aedf24d93 --- /dev/null +++ b/Spec/css-fonts-5.yml @@ -0,0 +1,114 @@ +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: n/a + '#text-scale-meta': + title: 2 Text-Scale meta element + 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: parsed-only + '#font-family-prop': + title: '3.1 Font family: the font-family property' + 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: n/a + gaps: + - Only a placeholder family + '#font-weight-prop': + title: '3.2 Font weight: the font-weight property' + 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: 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: 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: partial + '#font-face-rule': + title: 4.1 The @font-face rule + 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: partial + '#font-face-src-parsing': + title: 4.2.1 Parsing the src descriptor + 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: unsupported + '#size-adjust-desc': + title: '4.4 Glyph Size Multiplier: the size-adjust descriptor' + 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: 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: unsupported + '#font-rend-props': + title: 5 Font Feature Properties + status: n/a + '#font-language-override-prop': + title: '5.1 Font language override: the font-language-override property' + 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: n/a + gaps: + - Only an open issue, see css-fonts-4 + '#font-variation-props': + title: 7 Font Variation Properties + status: n/a + '#font-optical-sizing-def': + title: '7.1 Optical sizing control: the font-optical-sizing property' + 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: unsupported + '#font-tech-definitions': + title: 8.1 Font tech + status: unsupported + gaps: + - "tech() is ignored, see css-fonts-4 #font-face-src-formats" + '#object-model': + title: 9 Object Model + status: n/a + '#security': + title: 10 Security Considerations + status: n/a + '#privacy': + title: 11 Privacy Considerations + status: n/a + '#acknowledgments': + title: 12 Acknowledgments + status: n/a + '#changes': + title: 13 Changes + status: n/a diff --git a/Spec/css-gcpm-3.yml b/Spec/css-gcpm-3.yml new file mode 100644 index 000000000..a6cfd3d92 --- /dev/null +++ b/Spec/css-gcpm-3.yml @@ -0,0 +1,112 @@ +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: 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: parsed-only + '#setting-named-strings-the-string-set-pro': + title: 1.1.1 The string-set property + 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: parsed-only + gaps: + - content() inserts nothing, see css-content-3 2.7.3 + '#using-named-strings': + title: 1.1.2 The string() function + status: parsed-only + gaps: + - string() inserts nothing, see css-content-3 2.7.2 + '#running-elements': + title: 1.2 Running elements + status: unsupported + '#running-syntax': + title: 1.2.1 The running() value + status: unsupported + gaps: + - "'position: running()' is rejected, the element stays in the flow" + '#element-syntax': + title: 1.2.2 The element() value + status: unsupported + gaps: + - "element() is rejected by 'content'" + '#footnotes': + title: 2 Footnotes + status: unsupported + '#footnote-terms': + title: 2.1 Terminology + status: n/a + '#creating-footnotes': + title: 2.2 Creating footnotes + status: unsupported + gaps: + - "'float: footnote' is rejected, the element stays in the flow" + '#footnote-types': + title: 2.3 Types of footnotes + status: unsupported + '#footnote-area': + title: 2.4 The footnote area + 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: unsupported + '#footnote-area-size': + title: 2.4.2 Size of the footnote area + status: unsupported + '#footnote-counters': + title: 2.5 The Footnote Counter + status: unsupported + '#footnote-counter-values': + title: 2.5.1 Values of the footnote counter + status: unsupported + '#resetting-footnote-counter': + title: 2.5.2 Resetting the footnote counter + status: unsupported + '#footnote-call': + title: 2.6 The footnote-call pseudo-element + status: unsupported + gaps: + - "::footnote-call invalidates the selector" + '#footnote-marker': + title: 2.7 The footnote-marker pseudo-element + status: unsupported + gaps: + - "::footnote-marker invalidates the selector" + '#footnote-policy': + title: 2.8 Rendering footnotes and footnote policy + status: unsupported + '#future': + title: 2.9 Future directions + status: n/a + '#the-first-page-pseudo-element': + title: 3 Selecting Pages + status: unsupported + '#document-page-selectors': + title: 3.1 Page Selectors + status: unsupported + gaps: + - "An @page rule with :nth() is dropped" + '#document-sequence-selectors': + title: 3.2 Page groups + status: unsupported + '#leaders': + title: 4 Leaders (moved) + status: n/a + '#cross-references': + title: 5 Cross-references (moved) + status: n/a + '#bookmarks': + title: 6 Bookmarks (moved) + status: n/a diff --git a/Spec/css-grid-2.yml b/Spec/css-grid-2.yml new file mode 100644 index 000000000..be5ccdeac --- /dev/null +++ b/Spec/css-grid-2.yml @@ -0,0 +1,258 @@ +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: n/a + '#overview': + title: 2 Overview + status: n/a + '#grid-concepts': + title: 3 Grid Layout Concepts and Terminology + status: n/a + '#order-accessibility': + title: 4 Reordering and Accessibility + status: n/a + '#grid-model': + title: 5 Grid Containers + 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: 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: unsupported + '#overflow': + title: 5.3 Scrollable Grid Overflow + status: unsupported + '#overlarge-grids': + title: 5.4 Limiting Large Grids + status: unsupported + '#grid-items': + title: 6 Grid Items + 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: unsupported + '#grid-item-sizing': + title: 6.2 Grid Item Sizing + status: unsupported + '#order-property': + title: '6.3 Reordered Grid Items: the order property' + status: parsed-only + gaps: + - "'order' is parsed and ignored" + '#item-margins': + title: 6.4 Grid Item Margins and Paddings + status: unsupported + '#z-order': + title: '6.5 Z-axis Ordering: the z-index property' + status: unsupported + '#min-size-auto': + title: 6.6 Automatic Minimum Size of Grid Items + status: unsupported + '#grid-definition': + title: 7 Defining the Grid + 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: unsupported + '#track-sizing': + title: '7.2 Explicit Track Sizing: the grid-template-rows and grid-template-columns properties' + status: parsed-only + '#track-sizes': + title: 7.2.1 Track Sizes + 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: 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: parsed-only + '#repeat-syntax': + title: 7.2.3.1 Syntax of repeat() + 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: 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: n/a + '#fr-unit': + title: '7.2.4 Flexible Lengths: the fr unit' + status: parsed-only + '#computed-tracks': + title: 7.2.5 Computed Value of a Track Listing + 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: n/a + '#grid-template-areas-property': + title: '7.3 Named Areas: the grid-template-areas property' + 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: n/a + '#implicit-named-lines': + title: 7.3.2 Implicitly-Assigned Line Names + status: unsupported + '#implicit-named-areas': + title: 7.3.3 Implicitly-Named Areas + status: unsupported + '#explicit-grid-shorthand': + title: '7.4 Explicit Grid Shorthand: the grid-template property' + status: parsed-only + '#implicit-grids': + title: 7.5 The Implicit Grid + status: unsupported + '#auto-tracks': + title: '7.6 Implicit Track Sizing: the grid-auto-rows and grid-auto-columns properties' + status: parsed-only + '#grid-auto-flow-property': + title: '7.7 Automatic Placement: the grid-auto-flow property' + status: parsed-only + '#grid-shorthand': + title: '7.8 Grid Definition Shorthand: the grid property' + status: parsed-only + '#placement': + title: 8 Placing Grid Items + status: parsed-only + '#common-uses': + title: 8.1 Common Patterns for Grid Placement + status: n/a + '#placement-a11y': + title: 8.2 Grid Item Placement vs. Source Order + 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: 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: unsupported + '#placement-shorthands': + title: '8.4 Placement Shorthands: the grid-column, grid-row, and grid-area properties' + status: parsed-only + '#auto-placement-algo': + title: 8.5 Grid Item Placement Algorithm + status: unsupported + '#subgrids': + title: 9 Subgrids + 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: unsupported + '#abspos-items': + title: 10.1 With a Grid Container as Containing Block + status: unsupported + '#static-position': + title: 10.2 With a Grid Container as Parent + status: unsupported + '#alignment': + title: 11 Alignment and Spacing + 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: parsed-only + gaps: + - "'normal' is rejected by 'row-gap' and 'gap'" + '#auto-margins': + title: 11.2 Aligning with auto margins + status: unsupported + '#row-align': + title: '11.3 Inline-axis Alignment: the justify-self and justify-items properties' + status: parsed-only + '#column-align': + title: '11.4 Block-axis Alignment: the align-self and align-items properties' + status: parsed-only + '#grid-align': + title: '11.5 Aligning the Grid: the justify-content and align-content properties' + status: parsed-only + '#grid-baselines': + title: 11.6 Grid Container Baselines + status: unsupported + '#layout-algorithm': + title: 12 Grid Layout Algorithm + status: unsupported + gaps: + - "No grid sizing algorithm, see 5.1" + '#algo-grid-sizing': + title: 12.1 Grid Sizing Algorithm + status: unsupported + '#algo-terms': + title: 12.2 Track Sizing Terminology + status: n/a + '#algo-track-sizing': + title: 12.3 Track Sizing Algorithm + status: unsupported + '#algo-init': + title: 12.4 Initialize Track Sizes + status: unsupported + '#algo-content': + title: 12.5 Resolve Intrinsic Track Sizes + status: unsupported + '#extra-space': + title: 12.5.1 Distributing Extra Space Across Spanned Tracks + status: unsupported + '#algo-grow-tracks': + title: 12.6 Maximize Tracks + status: unsupported + '#algo-flex-tracks': + title: 12.7 Expand Flexible Tracks + status: unsupported + '#algo-find-fr-size': + title: 12.7.1 Find the Size of an fr + status: unsupported + '#algo-stretch': + title: 12.8 Stretch auto Tracks + status: unsupported + '#pagination': + title: 13 Fragmenting Grid Layout + status: unsupported + gaps: + - "No grid layout to fragment" + '#fragmentation-alg': + title: 13.1 Sample Fragmentation Algorithm + status: n/a + '#privacy': + title: 14 Privacy Considerations + status: n/a + '#security': + title: 15 Security Considerations + status: n/a + '#changes': + title: 16 Changes + status: n/a + '#acknowledgements': + title: 17 Acknowledgements + status: n/a diff --git a/Spec/css-images-3.yml b/Spec/css-images-3.yml new file mode 100644 index 000000000..7beaf8f78 --- /dev/null +++ b/Spec/css-images-3.yml @@ -0,0 +1,130 @@ +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: n/a + '#image-values': + title: '2 Image Values: the <image> type' + 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: supported + '#ambiguous-urls': + title: 2.1.1 Ambiguous Reference-or-Image URLs + status: n/a + '#url-metadata': + title: 2.1.2 Image Metadata + status: n/a + '#gradients': + title: 3 Gradients + status: partial + '#linear-gradients': + title: '3.1 Linear Gradients: the linear-gradient() notation' + status: partial + '#linear-gradient-syntax': + title: 3.1.1 linear-gradient() syntax + 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: n/a + '#radial-gradients': + title: '3.2 Radial Gradients: the radial-gradient() notation' + 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: parsed-only + '#radial-color-stops': + title: 3.2.2 Placing Color Stops + status: unsupported + '#degenerate-radials': + title: 3.2.3 Degenerate Radial Gradients + status: unsupported + '#radial-gradient-examples': + title: 3.2.4 Radial Gradient Examples + status: n/a + '#repeating-gradients': + title: '3.3 Repeating Gradients: the repeating-linear-gradient() and repeating-radial-gradient() notations' + status: partial + gaps: + - repeating-radial-gradient() isn't painted, see 3.2 + '#gradient-colors': + title: 3.4 Defining Gradient Color + status: partial + '#color-stop-syntax': + title: 3.4.1 Color Stop Lists + 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: partial + gaps: + - Colors are interpolated without premultiplied alpha, a fade to transparent darkens + '#color-stop-fixup': + title: 3.4.3 Color Stop “Fixup” + 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: partial + '#sizing-terms': + title: 4.1 Object-Sizing Terminology + status: n/a + '#object-negotiation': + title: 4.2 CSS⇋Object Negotiation + status: n/a + '#concrete-size-resolution': + title: 4.3 Concrete Object Size Resolution + status: partial + '#default-sizing': + title: 4.3.1 Default Sizing Algorithm + 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: 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: n/a + '#the-object-fit': + title: '4.5 Sizing Objects: the object-fit property' + 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: parsed-only + gaps: + - "'object-position' is ignored" + '#image-processing': + title: 5 Image Processing + status: unsupported + '#the-image-orientation': + title: '5.1 Orienting an Image on the Page: the image-orientation property' + 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: unsupported + gaps: + - "'image-rendering' isn't parsed, the platform decides how images are scaled" + '#interpolation': + title: 6 Interpolation + status: n/a + '#serialization': + title: 7 Serialization + status: n/a diff --git a/Spec/css-images-4.yml b/Spec/css-images-4.yml new file mode 100644 index 000000000..5d4422a2b --- /dev/null +++ b/Spec/css-images-4.yml @@ -0,0 +1,184 @@ +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: n/a + '#image-values': + title: '2 2D Image Values: the <image> type' + 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: partial + gaps: + - SVG images aren't decoded, they're treated as failed images + '#url-notation': + title: '2.2 Image References: the url() notation' + status: supported + '#fetching-images': + title: 2.3 Fetching External Images + status: n/a + '#image-set-notation': + title: '2.4 Resolution/Type Negotiation: the image-set() notation' + 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: unsupported + gaps: + - image() isn't parsed, a declaration using it is dropped + '#image-fallbacks': + title: 2.5.1 Image Fallbacks + status: unsupported + '#image-fragments': + title: 2.5.2 Image Fragments + status: unsupported + '#color-images': + title: 2.5.3 Solid-color Images + status: unsupported + '#bidi-images': + title: 2.5.4 Bidi-sensitive Images + status: unsupported + '#cross-fade-function': + title: '2.6 Combining images: the cross-fade() notation' + 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: unsupported + '#cross-fade-painting': + title: 2.6.2 cross-fade() Painting + status: unsupported + gaps: + - cross-fade() is never painted + '#cross-fade-complex': + title: 2.6.3 Simplifying Complex cross-fade() + status: unsupported + '#element-notation': + title: '2.7 Using Elements as Images: the element() notation' + status: parsed-only + gaps: + - element() is never painted + '#paint-sources': + title: 2.7.1 Paint Sources + status: unsupported + '#elementsources': + title: '2.7.2 Using Out-Of-Document Sources: the ElementSources interface' + status: n/a + '#element-cycles': + title: 2.7.3 Cycle Detection + status: unsupported + '#gradients': + title: 3 Gradients + status: partial + '#linear-gradients': + title: '3.1 Linear Gradients: the linear-gradient() notation' + 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: n/a + '#radial-gradients': + title: '3.2 Radial Gradients: the radial-gradient() notation' + 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: parsed-only + '#radial-size': + title: 3.2.2 Expanding <radial-size> + 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: parsed-only + gaps: + - Conic gradients are parsed but never painted + '#conic-gradient-syntax': + title: 3.3.1 conic-gradient() Syntax + status: parsed-only + gaps: + - "'from 0' is rejected, the zero needs a unit" + '#conic-color-stops': + title: 3.3.2 Placing Color Stops + status: parsed-only + '#conic-gradient-examples': + title: 3.3.3 Conic Gradient Examples + status: n/a + '#repeating-gradients': + title: '3.4 Repeating Gradients: the repeating-linear-gradient(), repeating-radial-gradient(), and repeating-conic-gradient() notations' + status: partial + gaps: + - repeating-radial-gradient() and repeating-conic-gradient() aren't painted + '#gradient-colors': + title: 3.5 Defining Gradient Color + status: partial + '#color-stop-syntax': + title: 3.5.1 Color Stop Lists + 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: 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: 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: unsupported + gaps: + - "stripes() isn't parsed, see css-borders-4 #border-color" + '#sizing': + title: 5 Sizing Images and Objects in CSS + 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: 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: unsupported + '#the-image-resolution': + title: '6.1 Overriding Image Resolutions: the image-resolution property' + status: unsupported + gaps: + - "'image-resolution' isn't a known property" + '#interpolation': + title: 7 Interpolation + status: n/a + '#serialization': + title: 8 Serialization + status: n/a + '#privacy': + title: 9 Privacy Considerations + status: n/a + '#security': + title: 10 Security Considerations + status: n/a + '#changes': + title: 11 Changes + status: n/a diff --git a/Spec/css-inline-3.yml b/Spec/css-inline-3.yml new file mode 100644 index 000000000..45f7c9770 --- /dev/null +++ b/Spec/css-inline-3.yml @@ -0,0 +1,217 @@ +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: n/a + '#model': + title: 2 Inline Layout Model + status: partial + '#line-boxes': + title: 2.1 Layout of Line Boxes + 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: 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: supported + '#paint-order': + title: 2.4 Painting Order + status: supported + '#css-metrics': + title: 3 Baselines and Alignment Metrics + status: partial + '#baseline-intro': + title: 3.1 Introduction to Baselines + status: n/a + '#baseline-types': + title: 3.2 Baselines and Metrics + 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: supported + '#font-line-gap': + title: 3.2.2 Line Gap Metrics + 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: 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: partial + '#dominant-baseline-property': + title: '4.1 Dominant Baselines: the dominant-baseline property' + status: unsupported + gaps: + - dominant-baseline isn't accepted + '#transverse-alignment': + title: '4.2 Transverse Box Alignment: the vertical-align property' + 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: unsupported + gaps: + - baseline-source isn't accepted + '#alignment-baseline-property': + title: '4.2.2 Alignment Baseline Type: the alignment-baseline longhand' + status: unsupported + gaps: + - alignment-baseline isn't accepted + '#alignment-baseline-svg-legacy': + title: 4.2.2.1 Legacy Values for SVG + status: unsupported + '#baseline-shift-property': + title: '4.2.3 Post-Alignment Shift: the baseline-shift longhand' + status: unsupported + gaps: + - baseline-shift isn't accepted + '#baseline-shift-svg-legacy': + title: 4.2.3.1 Legacy Values for SVG + status: unsupported + '#line-height': + title: 5 Logical Heights and Inter-line Spacing + status: partial + '#line-height-property': + title: '5.1 Line Spacing: the line-height property' + 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: unsupported + gaps: + - line-fit-edge isn't accepted + '#inline-height': + title: 5.3 Calculating the Logical Height Contributions (“Layout Bounds”) of Inline Boxes + 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: 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: unsupported + '#text-box-trim': + title: '6.2 Trimming Over/Under Text: the text-box-trim property' + status: unsupported + '#text-box-edge': + title: '6.3 Text Trimming Metrics: the text-box-edge property' + status: unsupported + '#line-fill': + title: '6.4 Inline Box Drawing Height: the inline-sizing property' + status: unsupported + gaps: + - inline-sizing isn't accepted + '#initial-letter-styling': + title: 7 Initial Letters + 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: n/a + '#selecting-drop-initials': + title: 7.2 Selecting Initial Letters + status: unsupported + '#sizing-drop-initials': + title: '7.3 Creating Initial Letters: the initial-letter property' + status: unsupported + '#first-most-inline-level': + title: 7.3.1 Applicability + status: unsupported + '#aligning-initial-letter': + title: '7.4 Alignment of Initial Letters: the initial-letter-align property' + status: unsupported + '#initial-letter-align-defaults': + title: 7.4.1 UA Default Stylesheet for initial-letter-align + status: unsupported + '#initial-letter-layout': + title: 7.5 Initial Letter Layout + status: unsupported + '#initial-letter-properties': + title: 7.5.1 Properties Applying to Initial Letters + status: unsupported + '#initial-letter-box': + title: 7.5.2 Margins, Borders, and Padding + status: unsupported + '#sizing-initial-letter': + title: 7.5.3 Font Sizing of Initial Letters + status: unsupported + '#initial-letter-shaping': + title: 7.5.4 Shaping and Glyph Selection + status: unsupported + '#initial-letter-box-size': + title: 7.5.5 Sizing the Initial Letter Box + status: unsupported + '#initial-letter-content-align': + title: 7.5.6 Alignment Within an Initial Letter Box + status: unsupported + '#initial-letter-position': + title: 7.6 Initial Letter Positioning and Spacing + status: unsupported + '#initial-letter-block-position': + title: 7.6.1 Block-axis Positioning + status: unsupported + '#initial-letter-inline-position': + title: 7.6.2 Inline Kerning + status: unsupported + '#initial-letter-wrapping': + title: '7.7 Initial Letter Wrapping: the initial-letter-wrap property' + status: unsupported + '#initial-letter-line-layout': + title: 7.8 Line Layout + status: unsupported + '#initial-letter-inline-flow': + title: '7.8.1 Inline Flow Layout: Alignment, Justification, and White Space' + status: unsupported + '#initial-letter-indentation': + title: '7.8.2 Edge Effects: Indentation and Hanging Punctuation' + status: unsupported + '#initial-letter-ancestors': + title: 7.8.3 Ancestor Inlines + status: unsupported + '#initial-letter-multi-line': + title: 7.8.4 Multi-line Initial Letters + status: unsupported + '#initial-letter-paragraphs': + title: 7.9 Clearing Initial Letters + status: unsupported + '#raised-sunken-caps': + title: 7.9.1 Raised and sunken caps + status: unsupported + '#short-para-initial-letter': + title: 7.9.2 Short paragraphs with initial letters + status: unsupported + '#initial-letter-floats': + title: 7.9.3 Interaction with floats + status: unsupported + '#initial-letter-breaking': + title: 7.9.4 Interaction with Fragmentation (Pagination) + status: unsupported diff --git a/Spec/css-lists-3.yml b/Spec/css-lists-3.yml new file mode 100644 index 000000000..7ba4d92cc --- /dev/null +++ b/Spec/css-lists-3.yml @@ -0,0 +1,96 @@ +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: n/a + '#declaring-a-list-item': + title: 2 Declaring a List Item + status: supported + '#markers': + title: 3 Markers + status: partial + '#marker-pseudo': + title: 3.1 The ::marker Pseudo-Element + 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: unsupported + '#content-property': + title: 3.2 Generating Marker Contents + 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: 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: 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: 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: supported + '#marker-side': + title: 3.7 The marker-side property + status: unsupported + '#auto-numbering': + title: 4 Automatic Numbering With Counters + 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: supported + '#increment-set': + title: '4.2 Manipulating Counter Values: the counter-increment and counter-set properties' + status: partial + gaps: + - "'counter-set' isn't supported, the declaration is dropped" + '#nested-counters': + title: 4.3 Nested Counters and Scope + status: supported + '#creating-counters': + title: 4.4 Creating and Inheriting Counters + status: supported + '#inheriting-counters': + title: 4.4.1 Inheriting Counters + status: supported + '#instantiating-counters': + title: 4.4.2 Instantiating Counters + status: supported + '#counters-without-boxes': + title: 4.5 Counters in elements that do not generate boxes + 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: 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: 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 diff --git a/Spec/css-logical-1.yml b/Spec/css-logical-1.yml new file mode 100644 index 000000000..70a2c744e --- /dev/null +++ b/Spec/css-logical-1.yml @@ -0,0 +1,95 @@ +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: n/a + '#directional-keywords': + title: '2 Flow-Relative Values: block-start, block-end, inline-start, inline-end' + status: unsupported + '#caption-side': + title: 2.1 Logical Values for the caption-side Property + 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: 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: unsupported + gaps: + - 'start and end aren''t accepted, see css-text-3 #text-align-property' + '#page': + title: 3 Flow-Relative Page Classifications + 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: 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: 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: 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: 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: partial + gaps: + - padding-inline-start is the left padding also in rtl + '#border-properties': + title: 4.5 Flow-Relative Borders + 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: 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: 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: 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: 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: 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: unsupported + gaps: + - The logical keyword isn't accepted + '#acknowledgements': + title: 5 Acknowledgements + status: n/a + '#changes': + title: 6 Changes + status: n/a diff --git a/Spec/css-masking-1.yml b/Spec/css-masking-1.yml new file mode 100644 index 000000000..1be828427 --- /dev/null +++ b/Spec/css-masking-1.yml @@ -0,0 +1,127 @@ +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: n/a + '#placement': + title: 2 Module interactions + status: n/a + '#values': + title: 3 Values + status: n/a + '#terminology': + title: 4 Terminology + status: n/a + '#clipping-paths': + title: 5 Clipping Paths + 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: 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: unsupported + gaps: + - No SVG support + '#ClipPathElement': + title: 6.1 The clipPath element + status: unsupported + '#the-clip-rule': + title: '6.2 Winding Rules: the clip-rule property' + status: unsupported + gaps: + - clip-rule isn't parsed + '#positioned-masks': + title: 7 Positioned Masks + status: unsupported + gaps: + - None of the mask properties is parsed + '#the-mask-image': + title: '7.1 Mask Image Source: the mask-image property' + status: unsupported + '#the-mask-mode': + title: '7.2 Mask Image Interpretation: the mask-mode property' + status: unsupported + '#the-mask-repeat': + title: '7.3 Tiling Mask Images: the mask-repeat property' + status: unsupported + '#the-mask-position': + title: '7.4 Positioning Mask Images: the mask-position property' + status: unsupported + '#the-mask-clip': + title: '7.5 Masking Area: the mask-clip property' + status: unsupported + '#the-mask-origin': + title: '7.6 Positioning Area: the mask-origin property' + status: unsupported + '#the-mask-size': + title: '7.7 Sizing Mask Images: the mask-size property' + status: unsupported + '#the-mask-composite': + title: '7.8 Compositing mask layers: the mask-composite property' + status: unsupported + '#the-mask': + title: '7.9 Mask Shorthand: the mask property' + status: unsupported + '#the-mask-image-rendering-model': + title: 7.10 The Mask Image Rendering Model + status: unsupported + '#MaskValues': + title: 7.10.1 Mask processing + status: unsupported + '#layering': + title: 7.10.2 Layering Multiple Mask Images + status: unsupported + '#mask-borders': + title: 8 Border-Box Mask + 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: unsupported + '#the-mask-border-mode': + title: '8.2 Mask Border Image Interpretation: the mask-border-mode property' + status: unsupported + '#the-mask-border-slice': + title: '8.3 Mask Border Image Slicing: the mask-border-slice property' + status: unsupported + '#the-mask-border-width': + title: '8.4 Masking Areas: the mask-border-width property' + status: unsupported + '#the-mask-border-outset': + title: '8.5 Edge Overhang: the mask-border-outset property' + status: unsupported + '#the-mask-border-repeat': + title: '8.6 Mask Border Image Tiling: the mask-border-repeat property' + status: unsupported + '#the-mask-border': + title: '8.7 Mask Border Image Shorthand: the mask-border property' + status: unsupported + '#masking-with-the-mask-border-image': + title: 8.8 Masking with the mask border image + status: unsupported + '#svg-masks': + title: 9 SVG Mask Sources + status: unsupported + gaps: + - No SVG support + '#MaskElement': + title: 9.1 The mask element + status: unsupported + '#the-mask-type': + title: '9.2 Mask Source Interpretation: the mask-type property' + status: unsupported + gaps: + - mask-type isn't parsed + '#priv-sec': + title: 10 Privacy and Security Considerations + status: n/a diff --git a/Spec/css-multicol-1.yml b/Spec/css-multicol-1.yml new file mode 100644 index 000000000..ef64c7e34 --- /dev/null +++ b/Spec/css-multicol-1.yml @@ -0,0 +1,82 @@ +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: n/a + '#the-multi-column-model': + title: 2 The Multi-Column Model + 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: parsed-only + '#cw': + title: '3.1 The Inline Size of Columns: the column-width property' + status: parsed-only + '#cc': + title: '3.2 The Number of Columns: the column-count property' + status: parsed-only + gaps: + - "column-count: 0 is accepted" + '#columns': + title: '3.3 The column-width and column-count Shorthand: The columns Property' + status: parsed-only + '#pseudo-algorithm': + title: 3.4 The Pseudo-algorithm + status: unsupported + '#stacking-context': + title: 3.5 Stacking Context + status: unsupported + '#column-gaps-and-rules': + title: 4 Column Gaps and Rules + status: parsed-only + '#cg': + title: '4.1 Gutters Between Columns: the column-gap property' + status: parsed-only + gaps: + - "'normal' is rejected" + '#crc': + title: '4.2 The Color of Column Rules: the column-rule-color property' + status: parsed-only + '#crs': + title: '4.3 The Style Of Column Rules: the column-rule-style property' + status: parsed-only + '#crw': + title: '4.4 The Width Of Column Rules: the column-rule-width property' + status: parsed-only + '#cr': + title: '4.5 Column Rule Shorthand: the column-rule property' + status: parsed-only + '#column-breaks': + title: 5 Column Breaks + status: parsed-only + '#break-before-break-after-break-inside': + title: '5.1 Controlling Fragmentation: the break-before, break-after, break-inside properties' + 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: parsed-only + '#column-span': + title: '6.1 Spanning An Element Across Columns: the column-span property' + status: parsed-only + '#filling-columns': + title: 7 Filling Columns + status: parsed-only + '#cf': + title: '7.1 Column Balancing: the column-fill property' + status: parsed-only + '#overflow': + title: 8 Overflow + status: unsupported + '#overflow-inside-multicol-elements': + title: 8.1 Overflow Inside Multicol Containers + status: unsupported + '#pagination-and-overflow-outside-multicol': + title: 8.2 Pagination and Overflow Outside Multicol Containers + status: unsupported diff --git a/Spec/css-namespaces-3.yml b/Spec/css-namespaces-3.yml new file mode 100644 index 000000000..f00dd37ce --- /dev/null +++ b/Spec/css-namespaces-3.yml @@ -0,0 +1,33 @@ +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: n/a + '#conformance': + title: 2 Conformance + status: n/a + '#declaration': + title: '3 Declaring namespaces: the @namespace rule' + status: partial + '#syntax': + title: 3.1 Syntax + status: partial + gaps: + - A namespace name given as a string is dropped, only url() is kept + '#scope': + title: 3.2 Scope + status: unsupported + '#prefixes': + title: 3.3 Declaring Prefixes + status: unsupported + '#css-qnames': + title: 4 CSS Qualified Names + 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 diff --git a/Spec/css-nesting-1.yml b/Spec/css-nesting-1.yml new file mode 100644 index 000000000..ff2cc2372 --- /dev/null +++ b/Spec/css-nesting-1.yml @@ -0,0 +1,49 @@ +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: n/a + '#explainer': + title: 2 Explainer + status: n/a + '#nesting': + title: 3 Nesting Style Rules + status: partial + '#syntax': + title: 3.1 Syntax + status: supported + '#syntax-examples': + title: 3.2 Examples + status: n/a + '#conditionals': + title: 3.3 Nesting Other At-Rules + status: unsupported + '#nesting-at-scope': + title: 3.3.1 Nested @scope Rules + status: unsupported + '#mixing': + title: 3.4 Mixing Nesting Rules and Declarations + 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: 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: 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: n/a + '#changes': + title: 7 Changes + status: n/a diff --git a/Spec/css-overflow-3.yml b/Spec/css-overflow-3.yml new file mode 100644 index 000000000..fabdf817b --- /dev/null +++ b/Spec/css-overflow-3.yml @@ -0,0 +1,77 @@ +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: n/a + '#overflow-concepts': + title: 2 Overflow Concepts and Terminology + status: supported + '#ink': + title: 2.1 Ink Overflow + status: supported + '#scrollable': + title: 2.2 Scrollable Overflow + status: supported + '#scrolling': + title: 2.3 Scrolling Overflow + 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 + '#overflow-control': + title: '3.1 Managing Overflow: the overflow-x, overflow-y, and overflow properties' + 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: n/a + '#corner-clipping': + title: 3.1.2 Interaction of border-radius and overflow + status: unsupported + '#static-media': + title: 3.1.3 Overflow in Print and Other Static Media + status: n/a + '#overflow-clip-margin': + title: '3.2 Expanding Clipping Bounds: the overflow-clip-margin property' + status: unsupported + '#overflow-propagation': + title: 3.3 Overflow Viewport Propagation + 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: unsupported + gaps: + - "'scroll-behavior' isn't parsed, the viewport of the panel controls always scrolls instantly" + '#scrollbar-layout': + title: 4 Scrollbars and Layout + 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 + '#text-overflow': + title: '5.1 Overflow Ellipsis: the text-overflow property' + status: unsupported diff --git a/Spec/css-page-3.yml b/Spec/css-page-3.yml new file mode 100644 index 000000000..58500397e --- /dev/null +++ b/Spec/css-page-3.yml @@ -0,0 +1,150 @@ +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: n/a + '#page-terms': + title: 2 Page Terminology + status: n/a + '#page-model': + title: 3 The Page Model + 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: 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: supported + '#progression': + title: 3.3 Page Progression + 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: parsed-only + '#at-page-rule': + title: 4.1 The @page Rule + 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: parsed-only + '#spread-pseudos': + title: '4.2.1 Spread pseudo-classes: :left, :right' + 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: 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: parsed-only + '#syntax-page-selector': + title: 4.3 @page rule grammar + 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: parsed-only + gaps: + - "@page declarations aren't cascaded, see CSS 2.1 13.4" + '#margin-boxes': + title: 5 Page-Margin Boxes + status: parsed-only + '#margin-at-rules': + title: 5.1 At-rules for page-margin boxes + status: parsed-only + '#populating-margin-boxes': + title: 5.2 Populating page-margin boxes + status: parsed-only + '#margin-dimension': + title: 5.3 Computing Page-margin Box Dimensions + status: unsupported + '#margin-box-terms': + title: 5.3.1 Page-Margin Box Layout Terminology + status: n/a + '#variable-sizing': + title: 5.3.2 Page-Margin Box Variable Dimension Computation Rules + status: unsupported + '#variable-auto-margins': + title: 5.3.2.1 Margins + status: unsupported + '#variable-auto-sizing': + title: 5.3.2.2 Resolving auto widths + status: unsupported + '#variable-minmax': + title: 5.3.2.3 Handling min-width and max-width + status: unsupported + '#variable-position': + title: 5.3.2.4 Positioning + status: unsupported + '#variable-mapping': + title: 5.3.2.5 Boxes on other sides + status: unsupported + '#fixed-sizing': + title: 5.3.3 Page-Margin Box Fixed Dimension Computation Rules + status: unsupported + '#margin-box-ex': + title: 5.4 Page-margin box examples + status: n/a + '#page-properties': + title: 6 Page Properties + status: parsed-only + '#page-based-counters': + title: 6.1 Page-based counters + status: unsupported + '#margin-text-alignment': + title: 6.2 Page-margin boxes and default values + status: unsupported + '#page-size': + title: 7 Page Size + status: parsed-only + '#page-size-prop': + title: '7.1 Page size: the size property' + 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: n/a + '#page-orientation-prop': + title: '7.1.2 Rotating The Printed Page: the page-orientation property' + status: unsupported + '#marks': + title: '7.2 Crop and Registration Marks: the marks property' + status: unsupported + '#bleed': + title: '7.3 Bleed Area: the bleed property' + status: unsupported + '#renderingpages': + title: 7.4 Rendering page boxes that do not fit a page sheet + status: n/a + '#positioning-page-box': + title: 7.5 Positioning the page box on the sheet + status: n/a + '#page-breaks': + title: 8 Page Breaks + status: supported + '#using-named-pages': + title: '8.1 Using named pages: page' + 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: n/a diff --git a/Spec/css-position-3.yml b/Spec/css-position-3.yml new file mode 100644 index 000000000..23855f678 --- /dev/null +++ b/Spec/css-position-3.yml @@ -0,0 +1,120 @@ +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: n/a + '#position-property': + title: '2 Choosing A Positioning Scheme: position property' + 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: 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: unsupported + '#stacking': + title: 2.2 Painting Order and Stacking Contexts + 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: 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: 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: supported + '#relpos-insets': + title: 3.3 Relative Positioning + 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: 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: n/a + '#abspos-insets': + title: 3.5 Absolute (and Fixed) Positioning + status: partial + '#resolving-insets': + title: 3.5.1 Resolvings Insets for the “Inset-Modified Containing Block” + 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: 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: 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: 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: 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: 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: partial + gaps: + - "'justify-self' and 'align-self' are ignored" + '#abspos-old': + title: 6 Old Absolute Positioning Layout Model + status: partial + '#abs-non-replaced-width': + title: 6.1 The Width of Absolutely-Positioned, Non-Replaced Elements + 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: supported + '#abs-non-replaced-height': + title: 6.3 The Height Of Absolutely Positioned, Non-Replaced Elements + 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: supported + '#comparison': + title: 7 Informative Comparison of Normal Flow, Floats, and Positioning + status: n/a + '#ack': + title: 8 Acknowledgments + status: n/a diff --git a/Spec/css-position-4.yml b/Spec/css-position-4.yml new file mode 100644 index 000000000..2d0bb1385 --- /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: n/a + '#scrollable-cb': + title: 2 Scrollable Containing Block + 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: 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: 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: 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: n/a diff --git a/Spec/css-pseudo-4.yml b/Spec/css-pseudo-4.yml new file mode 100644 index 000000000..60e073395 --- /dev/null +++ b/Spec/css-pseudo-4.yml @@ -0,0 +1,137 @@ +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: n/a + '#typographic-pseudos': + title: 2 Typographic Pseudo-elements + status: parsed-only + '#first-line-pseudo': + title: '2.1 First-Line Text: the ::first-line pseudo-element' + 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: unsupported + '#first-line-styling': + title: 2.1.2 Styling the ::first-line Pseudo-element + status: unsupported + '#first-line-inheritance': + title: 2.1.3 Inheritance and the ::first-line Pseudo-element + status: unsupported + '#first-letter-pseudo': + title: '2.2 First-Letter Text: ::first-letter pseudo-element and its ::prefix and ::suffix children' + 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: unsupported + '#first-letter-application': + title: 2.2.2 Finding the First-Letter Text + status: unsupported + '#first-letter-tree': + title: 2.2.3 Inheritance and Box Tree Structure of the First-Letter Pseudo-elements + status: unsupported + '#first-letter-styling': + title: 2.2.4 Styling the First-Letter Pseudo-elements + status: unsupported + '#highlight-pseudos': + title: 3 Highlight Pseudo-elements + 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: partial + gaps: + - "::search-text, ::target-text, ::spelling-error, ::grammar-error and ::highlight() invalidate the selector" + '#highlight-styling': + title: 3.2 Styling Highlights + 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: 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: unsupported + gaps: + - "The default background is painted even when ::selection only sets color" + '#highlight-bounds': + title: 3.4 Area of a Highlight + status: supported + '#highlight-cascade': + title: 3.5 Cascading and Per-Element Highlight Styles + 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: partial + '#highlight-backgrounds': + title: 3.6.1 Backgrounds + status: supported + '#highlight-shadows': + title: 3.6.2 Shadows + status: unsupported + gaps: + - text-shadow on ::selection is ignored + '#highlight-text': + title: 3.6.3 Text and Text Decorations + status: partial + gaps: + - Text decorations keep their own color instead of the ::selection color + '#highlight-replaced': + title: 3.6.4 Replaced Elements + status: supported + '#highlight-security': + title: 3.7 Security Considerations for Highlighting + status: n/a + '#treelike': + title: 4 Tree-Abiding Pseudo-elements + status: partial + '#generated-content': + title: '4.1 Generated Content Pseudo-elements: ::before and ::after' + 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: 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: unsupported + gaps: + - "::placeholder invalidates the selector" + '#element-like': + title: 5 Element-backed Pseudo-Elements + status: unsupported + '#file-selector-button-pseudo': + title: '5.1 File Selector Button: the ::file-selector-button pseudo-element' + 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: unsupported + gaps: + - "::details-content invalidates the selector" + '#interactions': + title: 6 Overlapping Pseudo-element Interactions + status: n/a + '#cssom': + title: 7 Additions to the CSS Object Model + status: n/a + '#css2-compat': + title: 8 Compatibility Syntax + status: supported diff --git a/Spec/css-scroll-anchoring-1.yml b/Spec/css-scroll-anchoring-1.yml new file mode 100644 index 000000000..3a62d65e6 --- /dev/null +++ b/Spec/css-scroll-anchoring-1.yml @@ -0,0 +1,33 @@ +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: n/a + '#description': + title: 2 Description + 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: unsupported + '#scroll-adjustment': + title: 2.2 Scroll Adjustment + status: unsupported + '#suppression-windows': + title: 2.2.1 Suppression Window + status: unsupported + '#suppression-triggers': + title: 2.2.2 Suppression Triggers + status: unsupported + '#exclusion-api': + title: 3 Exclusion API + status: unsupported + gaps: + - "'overflow-anchor' isn't parsed" + '#priv-sec': + title: 4 Privacy and Security Considerations + status: n/a diff --git a/Spec/css-scroll-snap-1.yml b/Spec/css-scroll-snap-1.yml new file mode 100644 index 000000000..e53816fbb --- /dev/null +++ b/Spec/css-scroll-snap-1.yml @@ -0,0 +1,81 @@ +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: n/a + '#examples': + title: 2 Motivating Examples + status: n/a + '#overview': + title: 3 Scroll Snap Model + status: unsupported + '#properties-on-the-scroll-container': + title: '4 Capturing Scroll Snap Areas: Properties on the scroll container' + status: unsupported + '#scroll-snap-type': + title: '4.1 Scroll Snapping Rules: the scroll-snap-type property' + 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: unsupported + '#snap-strictness': + title: '4.1.2 Scroll Snap Strictness: the none, proximity, and mandatory values' + status: unsupported + '#re-snap': + title: 4.1.3 Re-snapping After Layout Changes + status: unsupported + '#scroll-padding': + title: '4.2 Scroll Snapport: the scroll-padding property' + 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: unsupported + '#scroll-margin': + title: '5.1 Scroll Snapping Area: the scroll-margin property' + 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: unsupported + gaps: + - "'scroll-snap-align' isn't parsed" + '#snap-scope': + title: 5.2.1 Scoping Valid Snap Positions to Visible Boxes + status: unsupported + '#snap-overflow': + title: 5.2.2 Snapping Boxes that Overflow the Scrollport + status: unsupported + '#unreachable': + title: 5.2.3 Unreachable Snap Positions + status: unsupported + '#scroll-snap-stop': + title: '5.3 Scroll Snap Limits: the scroll-snap-stop property' + status: unsupported + gaps: + - "'scroll-snap-stop' isn't parsed" + '#snap-concepts': + title: 6 Snapping Mechanics + status: unsupported + '#scroll-types': + title: 6.1 Types of Scrolling Methods + status: n/a + '#choosing': + title: 6.2 Choosing Snap Positions + status: unsupported + '#priv-sec': + title: 7 Privacy and Security Considerations + status: n/a + '#acknowledgements': + title: 8 Acknowledgements + status: n/a + '#changes': + title: 9 Changes + status: n/a diff --git a/Spec/css-scrollbars-1.yml b/Spec/css-scrollbars-1.yml new file mode 100644 index 000000000..36b688c2f --- /dev/null +++ b/Spec/css-scrollbars-1.yml @@ -0,0 +1,18 @@ +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: n/a + '#scrollbar-color': + title: '2 Scrollbar Colors: the scrollbar-color property' + 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: unsupported + gaps: + - "'scrollbar-width' isn't parsed, the scrollbars of the panel controls keep the platform width and can't be hidden" diff --git a/Spec/css-shapes-1.yml b/Spec/css-shapes-1.yml new file mode 100644 index 000000000..c04ee555c --- /dev/null +++ b/Spec/css-shapes-1.yml @@ -0,0 +1,74 @@ +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: n/a + '#relation-to-box-model-and-float-behavior': + title: 2 Relation to the box model and float behavior + status: unsupported + gaps: + - Floats always use their rectangular margin box as the float area + '#basic-shape-functions': + title: 3 Basic Shapes + 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: 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: unsupported + gaps: + - shape() is rejected + '#shape-examples': + title: 3.1.1.1 Using shape() to create responsive, parametric speech bubble + status: n/a + '#interpolating-shape': + title: 3.1.1.2 Interpolating the shape() Function + status: n/a + '#basic-shape-computed-values': + title: 3.2 Computed Values of Basic Shapes + status: unsupported + '#basic-shape-serialization': + title: 3.3 Serialization of Basic Shapes + status: n/a + '#basic-shape-interpolation': + title: 3.4 Interpolation of Basic Shapes + status: n/a + '#shapes-from-image': + title: 4 Shapes from Image + status: unsupported + '#shapes-from-box-values': + title: 5 Shapes from Box Values + status: unsupported + '#declaring-shapes': + title: 6 Declaring Shapes + status: unsupported + '#shape-outside-property': + title: '6.1 Float Area Shape: the shape-outside property' + 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: unsupported + gaps: + - "'shape-image-threshold' isn't a known property" + '#shape-margin-property': + title: '6.3 Expanding a Shape: the shape-margin property' + status: unsupported + gaps: + - "'shape-margin' isn't a known property" + '#privacy': + title: 7 Privacy Considerations + status: n/a + '#security': + title: 8 Security Considerations + status: n/a diff --git a/Spec/css-sizing-3.yml b/Spec/css-sizing-3.yml new file mode 100644 index 000000000..afc52669c --- /dev/null +++ b/Spec/css-sizing-3.yml @@ -0,0 +1,87 @@ +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: n/a + '#terms': + title: 2 Terminology + status: n/a + '#specifying-sizes': + title: 3 Specifying Box Sizes + status: partial + '#sizing-properties': + title: 3.1 Sizing Properties + status: partial + '#preferred-size-properties': + title: '3.1.1 Preferred Size Properties: the width, height, inline-size, and block-size properties' + 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: 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: 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: 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: supported + '#the-contain-floats-value': + title: 3.2.2 Containing or Excluding Floats + status: n/a + '#box-sizing': + title: '3.3 Box Edges for Sizing: the box-sizing property' + 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: unsupported + '#extrinsic': + title: 4 Extrinsic Size Determination + status: partial + '#percentage-sizing': + title: 4.1 Percentage Sizing + 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: partial + gaps: + - "'stretch' is rejected, only an auto width block is stretch-fit sized" + '#intrinsic': + title: 5 Intrinsic Size Determination + status: partial + '#intrinsic-sizes': + title: 5.1 Intrinsic Sizes + 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: supported + '#cyclic-percentage-contribution': + title: 5.2.1 Intrinsic Contributions of Percentage-Sized Boxes + status: supported + '#min-content-zero': + title: 5.2.2 Compressible Replaced Elements + status: unsupported diff --git a/Spec/css-sizing-4.yml b/Spec/css-sizing-4.yml new file mode 100644 index 000000000..425defab3 --- /dev/null +++ b/Spec/css-sizing-4.yml @@ -0,0 +1,101 @@ +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: n/a + '#terms': + title: 2 Terminology + status: n/a + '#specifying-sizes': + title: 3 Specifying Box Sizes + status: partial + '#sizing-properties': + title: 3.1 Sizing Properties + 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: unsupported + gaps: + - contain, fit-content() and calc-size() are rejected + '#ratios': + title: 4 Aspect Ratios + status: parsed-only + '#aspect-ratio': + title: '4.1 Preferred Aspect Ratios: the aspect-ratio property' + 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: unsupported + '#aspect-ratio-margin-collapse': + title: 4.2.1 Margin-collapsing + status: unsupported + '#aspect-ratio-minimum': + title: 4.3 Automatic Content-based Minimum Sizes + status: unsupported + '#aspect-ratio-size-transfers': + title: 4.4 Min/Max Size Transfers + status: unsupported + '#intrinsic': + title: 5 Intrinsic Size Determination + status: partial + '#intrinsic-sizes': + title: 5.1 Intrinsic Sizes + 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: unsupported + gaps: + - The contain-intrinsic-* properties are dropped by the parser + '#last-remembered': + title: 5.2.1 Last Remembered Size + status: unsupported + '#cis-scrollbars': + title: '5.2.2 Interaction With overflow: auto' + 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: 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: unsupported + '#window-requestresize': + title: 5.3.2 Extensions to the Window Interface + status: n/a + '#intrinsic-contribution': + title: 5.4 Intrinsic Size Contributions + status: supported + '#intrinsic-contribution-override': + title: '5.5 Zeroing Min-Content Size Contributions: the min-intrinsic-sizing property' + 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: unsupported + gaps: + - Multi-column layout isn't supported, see css-multicol-1 + '#extrinsic': + title: 6 Extrinsic Size Determination + status: partial + '#contain-fit-sizing': + title: '6.1 Contain-fit Sizing: stretching while maintaining an aspect ratio' + status: unsupported + '#percentage-sizing': + title: 6.2 Percentage Sizing + status: partial + gaps: + - Percentage heights compute to auto even against a definite height, see css-sizing-3 4.1 diff --git a/Spec/css-speech-1.yml b/Spec/css-speech-1.yml new file mode 100644 index 000000000..fa19ebd2f --- /dev/null +++ b/Spec/css-speech-1.yml @@ -0,0 +1,54 @@ +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: 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: n/a + '#ssml-rel': + title: 3 Relationship with SSML + status: n/a + '#example': + title: 4 Example + status: n/a + '#aural-model': + title: 5 The aural formatting model + 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: n/a + '#speaking-props': + title: 7 Speaking properties + status: n/a + '#pause-props': + title: 8 Pause properties + status: n/a + '#rest-props': + title: 9 Rest properties + status: n/a + '#cue-props': + title: 10 Cue properties + status: n/a + '#voice-char-props': + title: 11 Voice characteristic properties + status: n/a + '#duration-props': + title: 12 Voice duration property + status: n/a + '#lists': + title: 13 List items and counters styles + status: n/a + '#content': + title: 14 Inserted and replaced content + status: n/a + '#pronunciation': + title: 15 Pronunciation, phonemes + status: n/a diff --git a/Spec/css-style-attr.yml b/Spec/css-style-attr.yml new file mode 100644 index 000000000..c9b8c019a --- /dev/null +++ b/Spec/css-style-attr.yml @@ -0,0 +1,25 @@ +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: n/a + '#conformance': + title: 2 Conformance + status: n/a + '#syntax': + title: 3 Syntax and Parsing + status: partial + gaps: + - A closing brace ends the style attribute, the declarations after it are dropped + '#interpret': + title: 4 Cascading and Interpretation + status: supported + '#ack': + title: 5 Acknowledgments + status: n/a + '#references': + title: 6 References + status: n/a diff --git a/Spec/css-syntax-3.yml b/Spec/css-syntax-3.yml new file mode 100644 index 000000000..1bada8a6d --- /dev/null +++ b/Spec/css-syntax-3.yml @@ -0,0 +1,238 @@ +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: n/a + '#syntax-description': + title: 2 Description of CSS’s Syntax + status: n/a + '#tokenizing-and-parsing': + title: 3 Tokenizing and Parsing CSS + status: partial + '#parsing-overview': + title: 3.1 Overview of the Parsing Model + status: n/a + '#input-byte-stream': + title: 3.2 The input byte stream + 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: partial + gaps: + - U+0000 NULL isn't replaced with U+FFFD + '#tokenization': + title: 4 Tokenization + status: partial + '#token-diagrams': + title: 4.1 Token Railroad Diagrams + status: n/a + '#tokenizer-definitions': + title: 4.2 Definitions + 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: partial + '#consume-token': + title: 4.3.1 Consume a token + 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: supported + '#consume-numeric-token': + title: 4.3.3 Consume a numeric token + 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: 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: 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: 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: supported + '#starts-with-a-valid-escape': + title: 4.3.8 Check if two code points are a valid escape + 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: supported + '#starts-with-a-number': + title: 4.3.10 Check if three code points would start a number + status: supported + '#starts-a-unicode-range': + title: 4.3.11 Check if three code points would start a unicode-range + status: supported + '#consume-name': + title: 4.3.12 Consume an ident sequence + status: supported + '#consume-number': + title: 4.3.13 Consume a number + 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: supported + '#consume-remnants-of-bad-url': + title: 4.3.15 Consume the remnants of a bad url + 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: partial + '#parser-diagrams': + title: 5.1 Parser Railroad Diagrams + status: n/a + '#css-tree': + title: 5.2 CSS Parsing Results + status: n/a + '#parser-definitions': + title: 5.3 Token Streams + status: n/a + '#parser-entry-points': + title: 5.4 Parser Entry Points + status: supported + '#parse-grammar': + title: 5.4.1 Parse something according to a CSS grammar + status: n/a + '#parse-comma-list': + title: 5.4.2 Parse a comma-separated list according to a CSS grammar + status: n/a + '#parse-stylesheet': + title: 5.4.3 Parse a stylesheet + status: supported + '#parse-stylesheet-contents': + title: 5.4.4 Parse a stylesheet’s contents + status: n/a + '#parse-block-contents': + title: 5.4.5 Parse a block’s contents + status: supported + '#parse-rule': + title: 5.4.6 Parse a rule + status: n/a + '#parse-declaration': + title: 5.4.7 Parse a declaration + status: supported + '#parse-component-value': + title: 5.4.8 Parse a component value + status: n/a + '#parse-list-of-component-values': + title: 5.4.9 Parse a list of component values + status: n/a + '#parse-comma-separated-list-of-component-values': + title: 5.4.10 Parse a comma-separated list of component values + status: n/a + '#parser-algorithms': + title: 5.5 Parser Algorithms + status: partial + '#consume-stylesheet-contents': + title: 5.5.1 Consume a stylesheet’s contents + status: supported + '#consume-at-rule': + title: 5.5.2 Consume an at-rule + 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: 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: supported + '#consume-block-contents': + title: 5.5.5 Consume a block’s contents + 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: 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: supported + '#consume-component-value': + title: 5.5.8 Consume a component value + status: supported + '#consume-simple-block': + title: 5.5.9 Consume a simple block + 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: supported + '#consume-unicode-range-value': + title: 5.5.11 Consume a unicode-range value + status: supported + '#anb-microsyntax': + title: 6 The An+B microsyntax + status: partial + '#anb-syntax': + title: 6.1 Informal Syntax Description + status: n/a + '#the-anb-type': + title: 6.2 The <a-n-plus-b> type + 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: n/a + '#css-stylesheets': + title: 8 CSS stylesheets + status: partial + '#style-rules': + title: 8.1 Style rules + 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: supported + '#charset-rule': + title: 8.3 The @charset Rule + status: supported + '#serialization': + title: 9 Serialization + status: n/a + '#privacy': + title: 10 Privacy Considerations + status: n/a + '#security': + title: 11 Security Considerations + status: n/a + '#changes': + title: 12 Changes + status: n/a diff --git a/Spec/css-tables-3.yml b/Spec/css-tables-3.yml new file mode 100644 index 000000000..b7eadafb5 --- /dev/null +++ b/Spec/css-tables-3.yml @@ -0,0 +1,303 @@ +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: n/a + '#content-model': + title: 2 Content Model + status: partial + '#table-structure': + title: 2.1 Table Structure + 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: n/a + '#fixup': + title: 2.2 Fixup + status: partial + '#fixup-algorithm': + title: 2.2.1 Fixup Algorithm + 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: supported + '#fixup-examples': + title: 2.2.3 Examples + status: n/a + '#layout': + title: 3 Layout + status: partial + '#layout-principles': + title: 3.1 Core layout principles + 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: 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: partial + '#dimensioning-the-row-column-grid--step1': + title: 3.3.1 HTML Algorithm + 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: 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: supported + '#layout-modes': + title: 3.5 Table layout modes + status: partial + '#table-layout-property': + title: 3.5.1 The Table-Layout property + 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: partial + gaps: + - Collapsed borders aren't merged, see 3.7 + '#border-spacing-property': + title: 3.5.2.1 The Border-Spacing property + 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: parsed-only + gaps: + - Captions aren't rendered, see CSS 2.1 17.4 + '#style-overrides': + title: 3.6 Style overrides + status: partial + '#global-style-overrides': + title: 3.6.1 Overrides applying in all modes + status: supported + '#collapsed-style-overrides': + title: 3.6.2 Overrides applying in collapsed-borders mode + 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: unsupported + '#conflict-resolution-for-collapsed-borders': + title: 3.7.1 Conflict Resolution for Collapsed Borders + status: unsupported + '#border-conflict-resolution-algorithm': + title: 3.7.1.1 Conflict Resolution Algorithm for Collapsed Borders + 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: unsupported + '#border-specificity': + title: 3.7.1.3 Specificity of a border style + status: unsupported + '#content-measure': + title: 3.8 Computing table measures + status: partial + '#computing-undistributable-space': + title: 3.8.1 Computing Undistributable Space + 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: 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: 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: partial + '#computing-the-table-width': + title: 3.9.1 Computing the table width + 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: n/a + '#width-distribution-algorithm': + title: 3.9.3 Distribution algorithm + status: partial + '#width-distribution-in-fixed-mode': + title: 3.9.3.1 Changes to width distribution in fixed mode + 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: 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: partial + '#computing-the-table-height': + title: 3.10.1 Computing the table height + 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: 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: 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: n/a + '#height-distribution-algorithm': + title: 3.10.5 Distribution algorithm + 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: 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: 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: partial + '#abspos-boxes-in-table-root': + title: 4.1 With a table-root as containing block + 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: 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: 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: partial + '#paint-order': + title: 5.1 Paint order of cells + status: supported + '#empty-cell-rendering': + title: 5.2 Empty cell rendering (separated-borders mode) + 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: partial + '#drawing-table-backgrounds-and-borders': + title: 5.3.1 Drawing table backgrounds and borders + status: supported + '#drawing-collapsed-borders-1': + title: 5.3.1.1 Changes in collapsed-borders mode + 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: 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: supported + '#drawing-collapsed-borders-2': + title: 5.3.3.1 Changes in collapsed-borders mode + 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: 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: partial + '#visibility-collapse-cell-rendering': + title: '5.4.1 Rendering a visibility: collapse table cell' + 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: unsupported + gaps: + - "Rows and columns with 'visibility: collapse' keep their space, see CSS 2.1 17.5.5" + '#fragmentation': + title: 6 Fragmentation + status: partial + '#breaking-rules': + title: 6.1 Breaking across fragmentainers + 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: partial + gaps: + - Footers aren't repeated + - A header taller than a quarter of the page still repeats + '#security': + title: 7 Security Considerations + status: n/a + '#privacy': + title: 8 Privacy Considerations + status: n/a + '#bug-list': + title: 9 List of bugs being tracked + status: n/a + '#appendices': + title: 10 Appendices + status: n/a + '#TODO': + title: 11 (link here for missing sections) + status: n/a + '#changes': + title: 12 Changes + status: n/a diff --git a/Spec/css-text-3.yml b/Spec/css-text-3.yml new file mode 100644 index 000000000..e65841b26 --- /dev/null +++ b/Spec/css-text-3.yml @@ -0,0 +1,167 @@ +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: 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 + '#text-transform-property': + title: '2.1 Case Transforms: the text-transform property' + 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: unsupported + '#text-transform-order': + title: 2.1.2 Order of Operations + status: unsupported + '#white-space-property': + title: '3 White Space and Wrapping: the white-space property' + status: partial + gaps: + - break-spaces isn't accepted + '#white-space-processing': + title: 4 White Space Processing & Control Characters + 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: partial + '#white-space-phase-1': + title: '4.1.1 Phase I: Collapsing and Transformation' + 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: 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: supported + '#tab-size-property': + title: '4.2 Tab Character Size: the tab-size property' + status: unsupported + '#line-breaking': + title: 5 Line Breaking and Word Boundaries + status: partial + '#word-break-property': + title: '5.1 Breaking Rules for Letters: the word-break property' + 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: unsupported + '#hyphenation': + title: '5.3 Hyphenation: the hyphens property' + 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: 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: 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: partial + '#text-align-property': + title: '6.1 Text Alignment: the text-align shorthand' + 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: unsupported + '#text-align-last-property': + title: '6.3 Last Line Alignment: the text-align-last property' + 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: 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 + 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: n/a + '#justify-limits': + title: 6.4.3 Unexpandable Text + status: n/a + '#justify-cursive': + title: 6.4.4 Cursive Scripts + status: n/a + '#justify-algos': + title: 6.4.5 Minimum Requirements for auto Justification + status: supported + '#spacing': + title: 7 Spacing + status: parsed-only + '#word-spacing-property': + title: '7.1 Word Spacing: the word-spacing property' + 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: parsed-only + gaps: + - letter-spacing is ignored, see CSS 2.1 16.4 + '#cursive-tracking': + title: 7.2.1 Cursive Scripts + status: n/a + '#boundary-shaping': + title: 7.3 Shaping Across Element Boundaries + status: unsupported + '#edge-effects': + title: 8 Edge Effects + status: partial + '#text-indent-property': + title: '8.1 First Line Indentation: the text-indent property' + 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: unsupported + '#hanging-punctuation-property': + title: '8.2.1 Hanging Punctuation: the hanging-punctuation property' + status: unsupported + '#bidi-linebox': + title: 8.3 Bidirectionality and Line Boxes + 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 diff --git a/Spec/css-text-4.yml b/Spec/css-text-4.yml new file mode 100644 index 000000000..80ec4c45d --- /dev/null +++ b/Spec/css-text-4.yml @@ -0,0 +1,269 @@ +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: n/a + '#characters': + title: 1.4 Characters and Letters + 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: parsed-only + '#text-transform-property': + title: '2.1 Case Transforms: the text-transform property' + 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: unsupported + '#word-space-transform': + title: '2.2 Expanding Between Words: the word-space-transform property' + status: unsupported + '#text-transform-order': + title: 2.3 Order of Operations + status: unsupported + '#white-space-property': + title: '3 White Space and Wrapping: the white-space property' + 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: 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: 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: unsupported + '#white-space-rules': + title: 4.3 The White Space Processing Rules + status: partial + '#white-space-phase-1': + title: '4.3.1 Phase I: Collapsing and Transformation' + 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: 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: supported + '#tab-size-property': + title: '4.4 Tab Character Size: the tab-size property' + status: unsupported + '#text-wrapping': + title: 5 Text Wrapping + 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: unsupported + '#wrap-inside': + title: '5.2 Controlling Breaks Within Boxes: the wrap-inside property' + status: unsupported + '#example-avoid': + title: "5.2.1 Example of using 'wrap-inside: avoid' in presenting a footer" + status: n/a + '#wrap-before': + title: '5.3 Controlling Breaks Between Boxes: the wrap-before/wrap-after properties' + status: unsupported + '#text-wrap-style': + title: '5.4 Selecting How to Wrap: the text-wrap-style property' + status: unsupported + '#text-wrap-shorthand': + title: '5.5 Joint Wrapping Control: the text-wrap shorthand property' + status: unsupported + '#line-breaking': + title: 6 Line Breaking and Word Boundaries + status: partial + '#word-break-property': + title: '6.1 Breaking Rules for Letters: the word-break property' + 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: unsupported + '#lexical-breaking': + title: 6.1.1.1 Lexical Word Breaking + status: unsupported + '#orthographic-breaking': + title: 6.1.1.2 Orthographic Breaking + status: unsupported + '#fallback-breaking': + title: 6.1.1.3 Fallback Breaking + status: unsupported + '#phrase-pref': + title: 6.1.2 Expressing User Preferences for Phrase-based Line Breaking + 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: unsupported + '#hyphenation': + title: '6.3 Hyphenation: Morphological Breaking Within Words' + status: parsed-only + '#hyphens-property': + title: '6.3.1 Hyphenation Control: the hyphens property' + status: parsed-only + gaps: + - Soft hyphens (U+00AD) aren't break opportunities + '#hyphenate-character': + title: '6.3.2 Hyphens: the hyphenate-character property' + status: unsupported + '#hyphenate-size-limits': + title: '6.3.3 Hyphenation Size Limit: the hyphenate-limit-zone property' + status: unsupported + '#hyphenate-char-limits': + title: '6.3.4 Hyphenation Character Limits: the hyphenate-limit-chars property' + status: unsupported + '#hyphenate-line-limits': + title: '6.3.5 Hyphenation Line Limits: the hyphenate-limit-lines and hyphenate-limit-last properties' + status: unsupported + '#overflow-wrap-property': + title: '6.4 Overflow Wrapping: the overflow-wrap (word-wrap) property' + 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: 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: partial + '#text-align-property': + title: '7.1 Text Alignment: the text-align shorthand' + 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: unsupported + '#text-align-all-property': + title: '7.3 Default Text Alignment: the text-align-all property' + status: unsupported + '#text-align-last-property': + title: '7.4 Last Line Alignment: the text-align-last property' + 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: 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: 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: n/a + '#justify-limits': + title: 7.5.3 Unexpandable Text + status: n/a + '#justify-cursive': + title: 7.5.4 Cursive Scripts + status: n/a + '#justify-algos': + title: 7.5.5 Minimum Requirements for auto Justification + status: supported + '#text-group-align-property': + title: '7.6 Aligning a block of text within its container: the text-group-align property' + status: unsupported + '#spacing': + title: 8 Spacing + status: parsed-only + '#word-spacing-property': + title: '8.1 Word Spacing: the word-spacing property' + 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: 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: n/a + '#line-padding-property': + title: '8.3 Line Start/End Padding: the line-padding property' + status: unsupported + '#text-autospace-property': + title: '8.4 Automatic Contextual Spacing: the text-autospace property' + status: unsupported + '#inter-script-spacing': + title: 8.4.1 Inter-script Spacing + status: unsupported + '#autospace-bidi': + title: 8.4.2 Interaction with Bidirectional Text + status: unsupported + '#text-spacing-trim-property': + title: '8.5 CJK Punctuation Spacing: the text-spacing-trim property' + status: unsupported + '#fullwidth-collapsing': + title: 8.5.1 Fullwidth Punctuation Collapsing + status: unsupported + '#text-spacing-classes': + title: 8.5.2 Text Spacing Trim Character Classes + status: unsupported + '#japanese-start-edges': + title: 8.5.3 Japanese Paragraph-start Conventions in CSS + status: n/a + '#text-spacing-property': + title: '8.6 Character Class Spacing Shorthand: the text-spacing property' + status: unsupported + '#boundary-shaping': + title: 8.7 Shaping Across Element Boundaries + status: unsupported + '#edge-effects': + title: 9 Edge Effects + status: partial + '#text-indent-property': + title: '9.1 First Line Indentation: the text-indent property' + 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: unsupported + '#hanging-punctuation-property': + title: '9.2.1 Hanging Punctuation: the hanging-punctuation property' + status: unsupported + '#bidi-linebox': + title: 9.3 Bidirectionality and Line Boxes + 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 diff --git a/Spec/css-text-decor-3.yml b/Spec/css-text-decor-3.yml new file mode 100644 index 000000000..d07714a75 --- /dev/null +++ b/Spec/css-text-decor-3.yml @@ -0,0 +1,67 @@ +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: n/a + '#line-decoration': + title: '2 Line Decoration: Underline, Overline, and Strike-Through' + 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: supported + '#text-decoration-style-property': + title: '2.2 Text Decoration Style: the text-decoration-style property' + 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: 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: 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: unsupported + '#emphasis-marks': + title: 3 Emphasis Marks + status: unsupported + '#text-emphasis-style-property': + title: '3.1 Emphasis Mark Style: the text-emphasis-style property' + status: unsupported + '#text-emphasis-color-property': + title: '3.2 Emphasis Mark Color: the text-emphasis-color property' + status: unsupported + '#text-emphasis-property': + title: '3.3 Emphasis Mark Shorthand: the text-emphasis property' + status: unsupported + '#text-emphasis-position-property': + title: '3.4 Emphasis Mark Position: the text-emphasis-position property' + status: unsupported + '#text-shadow-property': + title: '4 Text Shadows: the text-shadow property' + status: parsed-only + gaps: + - No shadow is painted + '#painting': + title: 5 Painting Text Decorations + status: partial + '#painting-order': + title: 5.1 Painting Order of Text Decorations + status: partial + gaps: + - Underlines and overlines are painted over the text instead of below it + '#overflow': + title: 5.2 Overflow of Text Decorations + status: supported diff --git a/Spec/css-text-decor-4.yml b/Spec/css-text-decor-4.yml new file mode 100644 index 000000000..e0b67bf83 --- /dev/null +++ b/Spec/css-text-decor-4.yml @@ -0,0 +1,131 @@ +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: n/a + '#line-decoration': + title: '2 Line Decoration: Underline, Overline, and Strike-Through' + 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: 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: parsed-only + gaps: + - Every line is drawn solid + '#text-decoration-color-property': + title: '2.3 Text Decoration Color: the text-decoration-color property' + 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: unsupported + '#text-decoration-thickness': + title: 2.4.1 Automatic Thickness of Text Decoration Lines + 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: 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: 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: unsupported + '#underline-offset': + title: '2.8 Text Underline Offset: the text-underline-offset property' + status: unsupported + '#line-offset-zero': + title: 2.8.1 Underline Offset Origin (Zero Position) + status: unsupported + '#line-auto-offset': + title: 2.8.2 Using Font Metrics for Automatic Positioning + status: partial + gaps: + - from-font isn't accepted, see 2.7 + '#text-line-constancy': + title: 2.9 Text Decoration Line Uniformity + status: supported + '#text-decoration-skipping': + title: '2.10 Text Decoration Line Continuity: the text-decoration-skip shorthand and its sub-properties' + 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: 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: unsupported + '#text-decoration-skip-inset-property': + title: '2.10.3 Inset Edges: the text-decoration-skip-inset property' + status: unsupported + '#text-decoration-skip-spaces-property': + title: '2.10.4 Skipping Spaces: the text-decoration-skip-spaces property' + 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: unsupported + '#ink-skip-shape': + title: 2.10.6 Shaping Interruptions + status: unsupported + '#emphasis-marks': + title: 3 Additional Controls for Emphasis Marks + status: unsupported + '#text-emphasis-style-property': + title: '3.1 Emphasis Mark Style: the text-emphasis-style property' + status: unsupported + '#text-emphasis-color-property': + title: '3.2 Emphasis Mark Color: the text-emphasis-color property' + status: unsupported + '#text-emphasis-property': + title: '3.3 Emphasis Mark Shorthand: the text-emphasis property' + status: unsupported + '#text-emphasis-position-property': + title: '3.4 Emphasis Mark Position: the text-emphasis-position property' + status: unsupported + '#text-emphasis-skip': + title: '3.5 Emphasis Mark Skip: the text-emphasis-skip property' + status: unsupported + '#text-shadow-property': + title: '4 Text Shadows: the text-shadow property' + status: parsed-only + gaps: + - No shadow is painted + '#painting': + title: 5 Painting Text Decorations + status: partial + '#painting-order': + title: 5.1 Painting Order of Text Decorations + status: partial + gaps: + - Underlines and overlines are painted over the text instead of below it + '#overflow': + title: 5.2 Overflow of Text Decorations + status: supported + '#priv-sec': + title: 6 Privacy and Security Considerations + status: n/a diff --git a/Spec/css-transforms-1.yml b/Spec/css-transforms-1.yml new file mode 100644 index 000000000..2c4783454 --- /dev/null +++ b/Spec/css-transforms-1.yml @@ -0,0 +1,97 @@ +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: n/a + '#terminology': + title: 2 Terminology + status: n/a + '#transform-rendering': + title: 3 The Transform Rendering Model + 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: 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: n/a + '#serialization-of-the-computed-value': + title: 4.2 Serialization of the computed value of <transform-list> + status: n/a + '#transform-origin-property': + title: 5 The transform-origin Property + 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: unsupported + gaps: + - "'transform-box' isn't parsed" + '#svg-transform': + title: 7 The SVG transform Attribute + status: unsupported + gaps: + - No SVG support + '#transform-attribute-specificity': + title: 7.1 SVG presentation attributes + status: unsupported + '#svg-syntax': + title: 7.2 Syntax of the SVG transform attribute + status: unsupported + '#svg-transform-functions': + title: 7.3 SVG transform functions + status: unsupported + '#svg-user-coordinate-space': + title: 7.4 User coordinate space + status: unsupported + '#transform-attribute-dom': + title: 7.5 SVG DOM interface for the transform attribute + status: n/a + '#svg-animation': + title: 8 SVG Animation + status: n/a + '#transform-functions': + title: 9 The Transform Functions + status: parsed-only + '#two-d-transform-functions': + title: 9.1 2D Transform Functions + 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: n/a + '#transform-function-lists': + title: 10 The Transform Function Lists + 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: n/a + '#interpolation-of-transform-functions': + title: 12 Interpolation of primitives and derived transform functions + status: n/a + '#matrix-interpolation': + title: 13 Interpolation of Matrices + status: n/a + '#mathematical-description': + title: 14 Mathematical Description of Transform Functions + status: unsupported + gaps: + - Transform matrices are never applied, see 3 + '#priv-sec': + title: 15 Privacy and Security Considerations + status: n/a diff --git a/Spec/css-transforms-2.yml b/Spec/css-transforms-2.yml new file mode 100644 index 000000000..fd7f02889 --- /dev/null +++ b/Spec/css-transforms-2.yml @@ -0,0 +1,135 @@ +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: n/a + '#terminology': + title: 2 Terminology + status: n/a + '#two-dimensional-subset': + title: 3 Two Dimensional Subset + 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: unsupported + gaps: + - "Transforms aren't applied, see css-transforms-1 #transform-rendering" + '#3d-transform-rendering': + title: 4.1 3D Transform Rendering + status: unsupported + gaps: + - No 3D rendering + '#perspective': + title: 4.1.1 Perspective + status: unsupported + '#3d-rendering-contexts': + title: 4.1.2 3D Rendering Contexts + status: unsupported + '#transformed-element-hierarchies': + title: 4.1.3 Transformed element hierarchies + status: unsupported + '#accumulated-3d-transformation-matrix-computation': + title: 4.1.4 Accumulated 3D Transformation Matrix Computation + status: unsupported + '#backface-visibility': + title: 4.1.5 Backface Visibility + status: unsupported + '#processing-of-perspective-transformed-boxes': + title: 4.2 Processing of Perspective-Transformed Boxes + status: unsupported + '#individual-transforms': + title: '5 Individual Transform Properties: the translate, scale, and rotate properties' + status: unsupported + gaps: + - translate, rotate and scale aren't parsed + '#individual-transform-serialization': + title: 5.1 Serialization + status: n/a + '#ctm': + title: 6 Current Transformation Matrix + status: unsupported + gaps: + - "Transforms aren't applied, see css-transforms-1 #transform-rendering" + '#transform-style-property': + title: 7 The transform-style Property + 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: unsupported + '#perspective-property': + title: 8 The perspective Property + 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: 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: 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: unsupported + gaps: + - No SVG support + '#transform-functions': + title: 12 The Transform Functions + status: parsed-only + '#two-d-transform-functions': + title: 12.1 2D Transform Functions + 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: 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: n/a + '#matrix-interpolation': + title: 13 Interpolation of Matrices + status: n/a + '#interpolation-of-transform-functions': + title: 14 Interpolation of primitives and derived transform functions + status: n/a + '#combining-transform-lists': + title: 15 Addition and accumulation of transform lists + status: n/a + '#mathematical-description': + title: 16 Mathematical Description of Transform Functions + status: unsupported + gaps: + - Transform matrices are never applied, see 4 + '#svg-transform': + title: 17 The SVG transform Attribute + status: unsupported + gaps: + - No SVG support + '#svg-animation': + title: 18 SVG Animation + status: n/a + '#more-issues': + title: 19 More Issues + status: n/a + '#priv-sec': + title: 20 Security and Privacy Considerations + status: n/a diff --git a/Spec/css-transitions-1.yml b/Spec/css-transitions-1.yml new file mode 100644 index 000000000..5c2bebdff --- /dev/null +++ b/Spec/css-transitions-1.yml @@ -0,0 +1,64 @@ +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: n/a + '#transitions': + title: 2 Transitions + status: parsed-only + '#transition-property-property': + title: 2.1 The transition-property Property + 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: parsed-only + gaps: + - Negative times are accepted + '#transition-timing-function-property': + title: 2.3 The transition-timing-function Property + 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: parsed-only + '#transition-shorthand-property': + title: 2.5 The transition Shorthand Property + status: parsed-only + '#starting': + title: 3 Starting of transitions + 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: n/a + '#complete': + title: 5 Completion of transitions + status: n/a + '#transition-events': + title: 6 Transition Events + status: n/a + '#interface-dom': + title: 7 DOM Interfaces + status: n/a + '#security': + title: 8 Security Considerations + status: n/a + '#privacy': + title: 9 Privacy Considerations + status: n/a + '#accessibility': + title: 10 Accessibility Considerations + status: n/a + '#changes': + title: 11 Changes + status: n/a + '#acknowledgments': + title: 12 Acknowledgments + status: n/a diff --git a/Spec/css-ui-3.yml b/Spec/css-ui-3.yml new file mode 100644 index 000000000..da0241d01 --- /dev/null +++ b/Spec/css-ui-3.yml @@ -0,0 +1,85 @@ +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: n/a + '#interaction': + title: 2 Module Interactions + status: n/a + '#box-model': + title: 3 Box Model addition + status: partial + '#box-sizing': + title: '3.1 Changing the Box Model: the box-sizing property' + 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: 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: supported + '#outline-width': + title: '4.2 Outline Thickness: the outline-width property' + status: partial + gaps: + - The initial width is 2px instead of 'medium' + '#outline-style': + title: '4.3 Outline Patterns: the outline-style property' + 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: 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: unsupported + gaps: + - "'outline-offset' isn't parsed" + '#resizing-and-overflow': + title: 5 Resizing & Overflow + status: unsupported + '#resize': + title: '5.1 Resizing Boxes: the resize property' + status: n/a + '#text-overflow': + title: '5.2 Overflow Ellipsis: the text-overflow property' + status: unsupported + gaps: + - "'text-overflow' isn't parsed, overflowing text is clipped" + '#pointing-keyboard': + title: 6 Pointing Devices and Keyboards + status: parsed-only + '#pointer-interaction': + title: 6.1 Pointer interaction + status: parsed-only + '#cursor': + title: '6.1.1 Styling the Cursor: the cursor property' + 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: parsed-only + '#canvas_cursor': + title: 6.1.1.2 Cursor of the canvas + status: parsed-only + '#insertion-caret': + title: 6.2 Insertion caret + status: n/a + '#keyboard': + title: 6.3 Keyboard control + status: n/a diff --git a/Spec/css-ui-4.yml b/Spec/css-ui-4.yml new file mode 100644 index 000000000..82d02ffa0 --- /dev/null +++ b/Spec/css-ui-4.yml @@ -0,0 +1,127 @@ +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: n/a + '#interaction': + title: 2 Module Interactions + status: n/a + '#outline-props': + title: 3 Outline properties + status: partial + '#outline': + title: '3.1 Outlines Shorthand: the outline property' + 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: partial + gaps: + - The initial width is 2px instead of 'medium' + '#outline-style': + title: '3.3 Outline Patterns: the outline-style property' + 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: 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: unsupported + gaps: + - "'outline-offset' isn't parsed" + '#resizing': + title: 4 Resizing + status: n/a + '#pointing-keyboard': + title: 5 Pointing Devices and Keyboards + status: parsed-only + '#pointer-interaction': + title: 5.1 Pointer interaction + status: parsed-only + '#cursor': + title: '5.1.1 Styling the Cursor: the cursor property' + 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: parsed-only + '#canvas_cursor': + title: 5.1.1.2 Cursor of the canvas + status: parsed-only + '#the-insertion-caret': + title: 5.2 Insertion caret + status: n/a + '#keyboard': + title: 5.3 Keyboard control + status: n/a + '#user-interaction': + title: 6 User Interaction + status: unsupported + '#content-selection': + title: 6.1 Controlling content selection + 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: unsupported + gaps: + - "'pointer-events' isn't parsed, links stay clickable" + '#inertness': + title: '6.3 Preventing All Interaction: the interactivity property' + 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: n/a + '#styling-widgets': + title: 7 Styling Widgets + status: unsupported + '#widget-accent': + title: '7.1 Widget Accent Colors: the accent-color property' + 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: 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: 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: 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: n/a + '#control-specific-rules': + title: 7.3 Control-Specific Rules + status: unsupported + '#input-rules': + title: 7.3.1 Single-Line Text Input Controls + status: unsupported + gaps: + - A text input is an empty inline-block without its value, see HTML 15.5.6 diff --git a/Spec/css-values-4.yml b/Spec/css-values-4.yml new file mode 100644 index 000000000..63c4dc549 --- /dev/null +++ b/Spec/css-values-4.yml @@ -0,0 +1,270 @@ +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: n/a + '#value-defs': + title: 2 Value Definition Syntax + status: n/a + '#combining-values': + title: '3 Combining Values: Interpolation, Addition, and Accumulation' + status: n/a + '#textual-values': + title: 4 Textual Data Types + status: partial + '#keywords': + title: 4.1 Pre-defined Keywords + status: supported + '#common-keywords': + title: '4.1.1 CSS-wide keywords: initial, inherit and unset' + status: supported + '#custom-idents': + title: '4.2 Unprefixed Author-defined Identifiers: the <custom-ident> type' + 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: supported + '#strings': + title: '4.4 Quoted Strings: the <string> type' + 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: partial + gaps: + - src() isn't supported, the declaration is dropped + '#relative-urls': + title: 4.5.1 Relative URLs + status: supported + gaps: + '#local-urls': + title: 4.5.1.1 Fragment URLs + status: n/a + '#url-empty': + title: 4.5.2 Empty URLs + status: supported + '#url-modifiers': + title: 4.5.3 URL Modifiers + 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: supported + gaps: + '#numeric-types': + title: 5 Numeric Data Types + status: partial + '#numeric-ranges': + title: 5.1 Range Restrictions and Range Definition Notation + 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: supported + '#combine-integers': + title: 5.2.1 Computation and Combination of <integer> + status: n/a + '#numbers': + title: '5.3 Real Numbers: the <number> type' + 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: n/a + '#dimensions': + title: '5.4 Numbers with Units: dimension values' + 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: supported + '#combine-dimensions': + title: 5.4.2 Combination of Dimensions + status: n/a + '#percentages': + title: '5.5 Percentages: the <percentage> type' + 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: n/a + '#mixed-percentages': + title: 5.6 Mixing Percentages and Dimensions + status: supported + '#combine-mixed': + title: 5.6.1 Computation and Combination of Percentage and Dimension Mixes + status: n/a + '#ratios': + title: '5.7 Ratios: the <ratio> type' + 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: n/a + '#lengths': + title: '6 Distance Units: the <length> type' + status: partial + '#relative-lengths': + title: 6.1 Relative Lengths + 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: 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: parsed-only + '#viewport-variants': + title: 6.1.2.1 The Large, Small, and Dynamic Viewport Sizes + 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: 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: partial + gaps: + - Q isn't supported, the declaration is dropped + '#other-units': + title: 7 Other Quantities + status: partial + '#angles': + title: '7.1 Angle Units: the <angle> type and deg, grad, rad, turn units' + status: supported + '#time': + title: '7.2 Duration Units: the <time> type and s, ms units' + 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: n/a + '#resolution': + title: '7.4 Resolution Units: the <resolution> type and dpi, dpcm, dppx units' + status: supported + '#defined-elsewhere': + title: 8 Data Types Defined Elsewhere + status: n/a + '#position': + title: '8.3 2D Positioning: the <position> type' + 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 + '#combine-positions': + title: 8.3.3 Combination of <position> + status: n/a + '#functional-notations': + title: 9 Functional Notations + status: supported + '#math': + title: 10 Mathematical Expressions + status: partial + '#calc-func': + title: '10.1 Basic Arithmetic: calc()' + status: supported + '#comp-func': + title: '10.2 Comparison Functions: min(), max(), and clamp()' + 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: unsupported + '#round-infinities': + title: 10.3.1 Argument Ranges + status: unsupported + '#trig-funcs': + title: '10.4 Trigonometric Functions: sin(), cos(), tan(), asin(), acos(), atan(), and atan2()' + status: unsupported + '#trig-infinities': + title: 10.4.1 Argument Ranges + status: unsupported + '#exponent-funcs': + title: '10.5 Exponential Functions: pow(), sqrt(), hypot(), log(), exp()' + status: unsupported + '#exponent-infinities': + title: 10.5.1 Argument Ranges + status: unsupported + '#sign-funcs': + title: '10.6 Sign-Related Functions: abs(), sign()' + status: unsupported + '#calc-keywords': + title: 10.7 Numeric Keywords + status: unsupported + '#calc-constants': + title: '10.7.1 Numeric Constants: e, pi' + status: unsupported + '#calc-error-constants': + title: '10.7.2 Degenerate Numeric Constants: infinity, -infinity, NaN' + status: unsupported + '#calc-variables': + title: 10.7.3 Numeric Variables + status: n/a + '#calc-syntax': + title: 10.8 Syntax + 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: 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: unsupported + gaps: + - Division by zero makes the declaration invalid instead of producing infinity + '#calc-internal': + title: 10.10 Internal Representation + status: supported + '#calc-simplification': + title: 10.10.1 Simplification + 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: 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: partial + gaps: + - The result isn't clamped, width and padding take a negative calc() + '#calc-serialize': + title: 10.13 Serialization + status: n/a + '#combine-math': + title: 10.14 Combination of Math Functions + status: n/a diff --git a/Spec/css-values-5.yml b/Spec/css-values-5.yml new file mode 100644 index 000000000..9a2c84370 --- /dev/null +++ b/Spec/css-values-5.yml @@ -0,0 +1,168 @@ +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: n/a + '#textual-values': + title: 2 Textual Data Types + status: partial + gaps: + - 'Same as Level 4, see css-values-4 #textual-values' + '#value-defs': + title: 3 Value Definition Syntax + status: n/a + '#component-function-commas': + title: 3.1.1 Commas in Function Arguments + 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: 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: unsupported + '#level-4-extensions': + title: 4 Extensions to Level 4 Value Types + status: partial + '#urls': + title: '4.1 Resource Locators: the <url> type' + status: partial + gaps: + - 'Same as Level 4, see css-values-4 #urls' + '#request-url-modifiers': + title: 4.1.1 Request URL Modifiers + 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: 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 + '#combine-positions': + title: 4.2.3 Combination of <position> + status: n/a + '#progress': + title: 5 Interpolation Progress Functional Notations + status: unsupported + '#progress-func': + title: '5.1 Calculated Progress Values: the progress() notation' + status: unsupported + '#media-progress-func': + title: '5.2 Media Query Progress Values: the media-progress() notation' + status: unsupported + '#container-progress-func': + title: '5.3 Container Query Progress Values: the container-progress() notation' + status: unsupported + '#mixing': + title: '6 Mixing and Interpolation Notations: the *-mix() family' + status: partial + '#progress-type': + title: '6.1 Representing Interpolation Progress: the <progress> type' + status: unsupported + '#calc-mix': + title: '6.2 Interpolated Numeric and Dimensional Values: the calc-mix() notation' + status: unsupported + '#color-mix': + title: '6.3 Interpolated Color Values: the color-mix() notation' + status: partial + gaps: + - 'See css-color-5 #color-mix' + '#cross-fade': + title: '6.4 Interpolated Image Values: the cross-fade() notation' + 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: unsupported + '#mix': + title: '6.6 Interpolated Property Values: the mix() notation' + status: unsupported + '#value-insert': + title: 7 Miscellaneous Value Substituting Functions + status: partial + '#whole-value': + title: '7.1 Representing An Entire Property Value: the <whole-value> type' + 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: unsupported + '#if-notation': + title: '7.3 Conditional Value Selection: the if() notation' + status: unsupported + '#toggle-notation': + title: '7.4 Toggling Between Values: the toggle() notation' + status: unsupported + '#var-notation': + title: '7.5 Custom Property References: the var() notation' + status: partial + gaps: + - 'See css-variables-1 #using-variables' + '#inherit-notation': + title: '7.6 Inherited Value References: the inherit() notation' + status: unsupported + '#attr-notation': + title: '7.7 Attribute References: the attr() notation' + 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: partial + gaps: + - attr() only works in content, in any other property the declaration is dropped + '#attr-security': + title: 7.7.2 Security + status: supported + '#randomness': + title: 8 Generating Random Values + status: unsupported + '#random': + title: '8.1 Generating a Random Numeric Value: the random() function' + status: unsupported + '#random-infinities': + title: 8.1.1 Argument Ranges + status: unsupported + '#random-item': + title: '8.2 Picking a Random Item From a List: the random-item() function' + status: unsupported + '#random-caching': + title: '8.3 Generating/Caching Random Values: the <random-caching-options> value' + status: unsupported + '#tree-counting': + title: '9 Tree Counting Functions: the sibling-count() and sibling-index() notations' + status: unsupported + '#calc-size': + title: '10 Calculating With Intrinsic Sizes: the calc-size() function' + status: unsupported + '#simplifying-calc-size': + title: 10.1 Simplifying calc-size() + status: unsupported + '#resolving-calc-size': + title: 10.2 Resolving calc-size() + status: unsupported + '#interp-calc-size': + title: 10.3 Interpolating calc-size() + status: n/a + '#interpolate-size': + title: '10.4 Interpolating sizing keywords: the interpolate-size property' + status: n/a + gaps: + - Only affects transitions and animations, which never run diff --git a/Spec/css-variables-1.yml b/Spec/css-variables-1.yml new file mode 100644 index 000000000..6aa0fe815 --- /dev/null +++ b/Spec/css-variables-1.yml @@ -0,0 +1,55 @@ +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: n/a + '#defining-variables': + title: '2 Defining Custom Properties: the --* family of properties' + status: partial + '#syntax': + title: 2.1 Custom Property Value Syntax + status: supported + '#guaranteed-invalid': + title: 2.2 Guaranteed-Invalid Values + 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: supported + '#using-variables': + title: '3 Using Cascading Variables: the var() notation' + 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: 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: supported + '#long-variables': + title: 3.3 Safely Handling Overly-Long Variables + status: unsupported + '#apis': + title: 4 APIs + status: n/a + '#changes': + title: 5 Changes + status: n/a + '#acks': + title: 6 Acknowledgments + status: n/a + '#privacy': + title: 7 Privacy Considerations + status: n/a + '#security': + title: 8 Security Considerations + status: n/a diff --git a/Spec/css-view-transitions-1.yml b/Spec/css-view-transitions-1.yml new file mode 100644 index 000000000..d363b4f6c --- /dev/null +++ b/Spec/css-view-transitions-1.yml @@ -0,0 +1,68 @@ +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: n/a + '#css-properties': + title: 2 CSS properties + status: unsupported + '#view-transition-name-prop': + title: '2.1 Tagging Individually Transitioning Subtrees: the view-transition-name property' + status: unsupported + gaps: + - "'view-transition-name' isn't parsed" + '#named-and-transitioning': + title: 2.1.1 Rendering Consolidation + 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: unsupported + '#pseudo-root': + title: 3.1 Pseudo-element Trees + 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: 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: unsupported + '#view-transition-pseudo': + title: '3.2.2 View Transition Tree Root: the ::view-transition pseudo-element' + status: unsupported + '#::view-transition-group': + title: '3.2.3 View Transition Named Subtree Root: the ::view-transition-group() pseudo-element' + status: unsupported + '#::view-transition-image-pair': + title: '3.2.4 View Transition Image Pair Isolation: the ::view-transition-image-pair() pseudo-element' + status: unsupported + '#::view-transition-old': + title: '3.2.5 View Transition Old State Image: the ::view-transition-old() pseudo-element' + status: unsupported + '#::view-transition-new': + title: '3.2.6 View Transition New State Image: the ::view-transition-new() pseudo-element' + status: unsupported + '#view-transition-rendering': + title: 4 View Transition Layout + status: n/a + gaps: + - Only applies during a view transition, which needs script + '#ua-styles': + title: 5 User Agent Stylesheet + 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: n/a + '#algorithms': + title: 7 Algorithms + status: n/a diff --git a/Spec/css-will-change-1.yml b/Spec/css-will-change-1.yml new file mode 100644 index 000000000..40d476394 --- /dev/null +++ b/Spec/css-will-change-1.yml @@ -0,0 +1,26 @@ +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: n/a + '#will-change': + title: '2 Hinting at Future Behavior: the will-change property' + 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: n/a + '#privacy': + title: 4 Privacy Considerations + status: n/a + '#acks': + title: 5 Acknowledgements + status: n/a + '#changes': + title: 6 Changes + status: n/a diff --git a/Spec/css-writing-modes-4.yml b/Spec/css-writing-modes-4.yml new file mode 100644 index 000000000..898cbace5 --- /dev/null +++ b/Spec/css-writing-modes-4.yml @@ -0,0 +1,197 @@ +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: n/a + '#text-direction': + title: 2 Inline Direction and Bidirectionality + status: partial + '#direction': + title: '2.1 Specifying Directionality: the direction property' + status: partial + gaps: + - No Unicode bidirectional algorithm, see 2.4 + '#unicode-bidi': + title: '2.2 Embeddings and Overrides: the unicode-bidi property' + 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: n/a + '#bidi-algo': + title: 2.4 Applying the Bidirectional Reordering Algorithm + 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: 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: 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: 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: unsupported + '#bidi-box-model': + title: 2.4.5 Reordering-induced Box Fragmentation + status: unsupported + '#bidi-fragmentation': + title: 2.4.5.1 Conditions of Reordering-induced Box Fragmentation + status: unsupported + '#bidi-fragment-boxes': + title: 2.4.5.2 Box Model of Reordering-induced Box Fragments + 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: unsupported + '#vertical-intro': + title: 3.1 Introduction to Vertical Writing + status: n/a + '#block-flow': + title: '3.2 Block Flow Direction: the writing-mode property' + 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: unsupported + '#svg-writing-mode-css': + title: 3.2.1.1 Supporting SVG1.1 writing-mode values in CSS syntax + status: unsupported + '#svg-writing-mode-markup': + title: 3.2.1.2 Supporting SVG1.1 writing-mode values in presentational attributes + status: unsupported + '#inline-alignment': + title: 4 Inline-level Alignment + status: partial + '#intro-baselines': + title: 4.1 Introduction to Baselines + status: n/a + '#text-baselines': + title: 4.2 Text Baselines + status: partial + gaps: + - Only horizontal text, so only the alphabetic baseline is used + '#replaced-baselines': + title: 4.3 Atomic Inline Baselines + 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: 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: unsupported + '#text-orientation': + title: '5.1 Orienting Text: the text-orientation property' + status: unsupported + gaps: + - text-orientation isn't accepted + '#vertical-font-features': + title: 5.1.1 Vertical Typesetting and Font Features + status: unsupported + '#vertical-orientations': + title: 5.1.2 Mixed Vertical Orientations + status: unsupported + '#glyph-orientation': + title: '5.1.3 Obsolete: the SVG1.1 glyph-orientation-vertical property' + status: unsupported + '#abstract-box': + title: 6 Abstract Box Terminology + status: n/a + '#logical-to-physical': + title: 6.4 Abstract-to-Physical Mappings + 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: partial + '#vertical-layout': + title: 7.1 Principles of Layout in Vertical Writing Modes + status: unsupported + '#dimension-mapping': + title: 7.2 Dimensional Mapping + status: unsupported + '#orthogonal-flows': + title: 7.3 Orthogonal Flows + status: unsupported + '#orthogonal-auto': + title: 7.3.1 Available Space in Orthogonal Flows + status: unsupported + '#auto-multicol': + title: 7.3.2 Auto-sizing Block Containers in Orthogonal Flows + status: unsupported + '#orthogonal-layout': + title: 7.3.3 Auto-sizing Other Orthogonal Flow Roots + status: unsupported + '#orthogonal-pagination': + title: 7.3.4 Fragmenting Orthogonal Flows + status: unsupported + '#logical-direction-layout': + title: 7.4 Flow-Relative Mappings + 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: supported + '#physical-only': + title: 7.6 Purely Physical Mappings + status: supported + '#principal-flow': + title: 8 The Principal Writing Mode + status: unsupported + '#icb': + title: 8.1 Propagation to the Initial Containing Block + 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: unsupported + gaps: + - Pages aren't classified as left or right pages, see css-page-3 + '#text-combine': + title: 9 Glyph Composition + status: unsupported + '#text-combine-upright': + title: '9.1 Horizontal-in-Vertical Composition: the text-combine-upright property' + status: unsupported + gaps: + - text-combine-upright isn't accepted + '#text-combine-runs': + title: 9.1.1 Text Run Rules + status: unsupported + '#text-combine-layout': + title: 9.1.2 Layout Rules + status: unsupported + '#text-combine-compression': + title: 9.1.3 Compression Rules + status: unsupported + '#text-combine-fullwidth': + title: 9.1.3.1 Full-width Characters + status: unsupported + '#priv-sec': + title: 10 Privacy and Security Considerations + status: n/a diff --git a/Spec/css21.yml b/Spec/css21.yml new file mode 100644 index 000000000..4ad157a71 --- /dev/null +++ b/Spec/css21.yml @@ -0,0 +1,1068 @@ +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: n/a + conform.html#q3.0: + title: 3 Conformance + status: partial + conform.html#defs: + title: 3.1 Definitions + status: supported + conform.html#conformance: + title: 3.2 UA Conformance + 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: n/a + conform.html#text-css: + title: 3.4 The text/css content type + status: supported + syndata.html#q4.0: + title: 4 Syntax and basic data types + status: partial + syndata.html#syntax: + title: 4.1 Syntax + status: partial + syndata.html#tokenization: + title: 4.1.1 Tokenization + status: supported + syndata.html#keywords: + title: 4.1.2 Keywords + status: supported + syndata.html#vendor-keywords: + title: 4.1.2.1 Vendor-specific extensions + status: supported + syndata.html#vendor-keyword-history: + title: 4.1.2.2 Informative Historical Notes + status: n/a + syndata.html#characters: + title: 4.1.3 Characters and case + 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: supported + syndata.html#at-rules: + title: 4.1.5 At-rules + status: supported + syndata.html#block: + title: 4.1.6 Blocks + 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: supported + syndata.html#declaration: + title: 4.1.8 Declarations and properties + status: supported + syndata.html#comments: + title: 4.1.9 Comments + status: supported + syndata.html#parsing-errors: + title: 4.2 Rules for handling parsing errors + status: supported + syndata.html#values: + title: 4.3 Values + status: partial + syndata.html#numbers: + title: 4.3.1 Integers and real numbers + status: supported + syndata.html#length-units: + title: 4.3.2 Lengths + 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: partial + gaps: + - A percentage font-size is inherited as the percentage and applied again in each descendant + - "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 + syndata.html#counter: + title: 4.3.5 Counters + 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: 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: 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: supported + syndata.html#charset: + title: 4.4 CSS style sheet representation + 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: supported + selector.html#q5.0: + title: 5 Selectors + status: partial + selector.html#pattern-matching: + title: 5.1 Pattern matching + status: supported + selector.html#selector-syntax: + title: 5.2 Selector syntax + 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: supported + selector.html#universal-selector: + title: 5.3 Universal selector + status: supported + selector.html#type-selectors: + title: 5.4 Type selectors + status: supported + selector.html#descendant-selectors: + title: 5.5 Descendant selectors + status: supported + selector.html#child-selectors: + title: 5.6 Child selectors + status: supported + selector.html#adjacent-selectors: + title: 5.7 Adjacent sibling selectors + status: supported + selector.html#attribute-selectors: + title: 5.8 Attribute selectors + status: partial + selector.html#matching-attrs: + title: 5.8.1 Matching attributes and attribute values + 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: n/a + selector.html#class-html: + title: 5.8.3 Class selectors + 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: 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: 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: partial + selector.html#first-child: + title: 5.11.1 :first-child pseudo-class + status: supported + selector.html#link-pseudo-classes: + title: 5.11.2 The link pseudo-classes + status: supported + selector.html#dynamic-pseudo-classes: + 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 + selector.html#lang: + title: 5.11.4 The language pseudo-class + status: unsupported + gaps: + - :lang() never matches + selector.html#pseudo-element-selectors: + title: 5.12 Pseudo-elements + status: partial + selector.html#first-line-pseudo: + title: 5.12.1 The :first-line pseudo-element + 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: 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: supported + cascade.html#q6.0: + title: 6 Assigning property values, Cascading, and Inheritance + status: partial + cascade.html#value-stages: + title: 6.1 Specified, computed, and actual values + status: partial + cascade.html#specified-value: + title: 6.1.1 Specified values + status: supported + cascade.html#computed-value: + title: 6.1.2 Computed values + 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: supported + cascade.html#actual-value: + title: 6.1.4 Actual values + status: n/a + cascade.html#inheritance: + title: 6.2 Inheritance + 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: 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: 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: partial + cascade.html#cascading-order: + title: 6.4.1 Cascading order + status: partial + gaps: + - The media attribute of <link> and <style> is ignored, the style sheet always applies + cascade.html#important-rules: + title: 6.4.2 !important rules + status: supported + cascade.html#specificity: + title: 6.4.3 Calculating a selector's specificity + status: supported + cascade.html#preshint: + title: 6.4.4 Precedence of non-CSS presentational hints + status: partial + gaps: + - Presentational attributes are applied after the author style sheets and override them + media.html#q7.0: + title: 7 Media types + status: partial + media.html#media-intro: + title: 7.1 Introduction to media types + status: n/a + media.html#media-sheets: + title: 7.2 Specifying media-dependent style sheets + 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: supported + media.html#media-types: + title: 7.3 Recognized media types + 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: n/a + box.html#box-model: + title: 8 Box model + status: partial + box.html#box-dimensions: + title: 8.1 Box dimensions + status: supported + box.html#mpb-examples: + title: 8.2 Example of margins, padding, and borders + status: n/a + box.html#margin-properties: + title: 8.3 Margin properties + 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: supported + box.html#padding-properties: + title: 8.4 Padding properties + status: partial + gaps: + - Negative padding is applied instead of being rejected + box.html#border-properties: + title: 8.5 Border properties + status: partial + box.html#border-width-properties: + title: 8.5.1 Border width + 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: 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: partial + gaps: + - double, groove and ridge are painted as solid + box.html#border-shorthand-properties: + title: 8.5.4 Border shorthand properties + status: supported + box.html#bidi-box-model: + title: 8.6 The box model for inline elements in bidirectional context + 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: partial + visuren.html#visual-model-intro: + title: 9.1 Introduction to the visual formatting model + status: supported + visuren.html#viewport: + title: 9.1.1 The viewport + status: supported + visuren.html#containing-block: + title: 9.1.2 Containing blocks + status: supported + visuren.html#box-gen: + title: 9.2 Controlling box generation + status: partial + visuren.html#block-boxes: + title: 9.2.1 Block-level elements and block boxes + status: supported + visuren.html#anonymous-block-level: + title: 9.2.1.1 Anonymous block boxes + 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: 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: supported + visuren.html#run-in: + title: 9.2.3 Run-in boxes + status: n/a + visuren.html#display-prop: + title: 9.2.4 The 'display' property + status: supported + visuren.html#positioning-scheme: + title: 9.3 Positioning schemes + status: partial + visuren.html#choose-position: + title: 9.3.1 Choosing a positioning scheme + status: supported + visuren.html#position-props: + title: 9.3.2 Box offsets + 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: partial + visuren.html#block-formatting: + title: 9.4.1 Block formatting contexts + 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: 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: 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: 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: 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: 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: 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: 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: 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: n/a + visuren.html#layers: + title: 9.9 Layered presentation + status: partial + visuren.html#z-index: + title: 9.9.1 Specifying the stack level + 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: 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: partial + visudet.html#containing-block-details: + title: 10.1 Definition of "containing block" + 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: partial + gaps: + - Negative widths are applied instead of being rejected + visudet.html#Computing_widths_and_margins: + title: 10.3 Calculating widths and margins + status: partial + visudet.html#inline-width: + title: 10.3.1 Inline, non-replaced elements + 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: 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: 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: 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: 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: supported + visudet.html#abs-non-replaced-width: + title: 10.3.7 Absolutely positioned, non-replaced elements + 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 + - 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 + visudet.html#inlineblock-width: + title: 10.3.9 'Inline-block', non-replaced elements in normal flow + 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: supported + visudet.html#min-max-widths: + title: 10.4 Minimum and maximum widths + 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: 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: partial + visudet.html#inline-non-replaced: + title: 10.6.1 Inline, non-replaced elements + 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: 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: supported + visudet.html#abs-non-replaced-height: + title: 10.6.4 Absolutely positioned, non-replaced elements + 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 + visudet.html#block-root-margin: + title: 10.6.6 Complicated cases + 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: 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: 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: 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: 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 + - "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" + visufx.html#q11.0: + title: 11 Visual effects + status: partial + visufx.html#overflow-clipping: + title: 11.1 Overflow and clipping + status: partial + visufx.html#overflow: + title: 11.1.1 Overflow + 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: parsed-only + gaps: + - "'clip' is ignored, absolutely positioned boxes are painted unclipped" + visufx.html#visibility: + title: 11.2 Visibility + 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: partial + generate.html#before-after-content: + title: 12.1 The :before and :after pseudo-elements + 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: 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: partial + generate.html#quotes-specify: + title: 12.3.1 Specifying quotes with the 'quotes' property + 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: 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: supported + generate.html#scope: + title: 12.4.1 Nested counters and scope + status: supported + generate.html#counter-styles: + title: 12.4.2 Counter styles + 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: none'" + 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: partial + generate.html#list-style: + title: 12.5.1 Lists + 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: partial + page.html#page-intro: + title: 13.1 Introduction to paged media + status: n/a + page.html#page-box: + title: 13.2 Page boxes + status: partial + page.html#page-margins: + title: 13.2.1 Page margins + 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: 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: supported + page.html#page-breaks: + title: 13.3 Page breaks + status: partial + page.html#page-break-props: + title: 13.3.1 Page break properties + 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: 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: 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: 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: supported + page.html#page-cascade: + title: 13.4 Cascading in the page context + status: parsed-only + gaps: + - "@page declarations aren't cascaded, nothing reads them" + colors.html#q14.0: + title: 14 Colors and Backgrounds + status: partial + colors.html#colors: + title: 14.1 Foreground color + status: supported + colors.html#background: + title: 14.2 The background + 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: 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: partial + fonts.html#fonts-intro: + title: 15.1 Introduction + status: n/a + fonts.html#algorithm: + title: 15.2 Font matching algorithm + 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: 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: partial + fonts.html#serif-def: + title: 15.3.1.1 serif + 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: supported + fonts.html#cursive-def: + title: 15.3.1.3 cursive + 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: 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: supported + fonts.html#font-styling: + title: 15.4 Font styling + status: supported + fonts.html#small-caps: + title: 15.5 Small-caps + status: parsed-only + gaps: + - "'small-caps' is ignored, lowercase letters are rendered as lowercase" + fonts.html#font-boldness: + title: 15.6 Font boldness + 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: 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: 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: partial + text.html#indentation-prop: + title: 16.1 Indentation + 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: 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: partial + text.html#lining-striking-props: + title: 16.3.1 Underlining, overlining, striking, and blinking + 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: 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: parsed-only + gaps: + - "'text-transform' is ignored" + text.html#white-space-prop: + title: 16.6 White space + 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: 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 &nbsp;) 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: n/a + text.html#ctrlchars: + title: 16.6.3 Control and combining characters' details + 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: partial + tables.html#tables-intro: + title: 17.1 Introduction to tables + status: n/a + tables.html#table-display: + title: 17.2 The CSS table model + 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: 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: partial + gaps: + - Column and column group backgrounds aren't painted + tables.html#model: + title: 17.4 Tables in the visual formatting model + 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: 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: partial + tables.html#table-layers: + title: 17.5.1 Table layers and transparency + status: partial + gaps: + - Row and row group backgrounds aren't painted + tables.html#width-layout: + title: 17.5.2 Table width algorithms + status: partial + gaps: + - "'auto' margins don't center a table" + tables.html#fixed-table-layout: + title: 17.5.2.1 Fixed table layout + 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: 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: 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: supported + tables.html#dynamic-effects: + title: 17.5.5 Dynamic row and column effects + status: unsupported + gaps: + - "Rows, row groups, columns and column groups with 'visibility: collapse' keep their space" + tables.html#borders: + title: 17.6 Borders + status: partial + tables.html#separated-borders: + title: 17.6.1 The separated borders model + 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: 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: 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: 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: unsupported + gaps: + - "'hidden' doesn't suppress the other borders at the same edge" + ui.html#q18.0: + title: 18 User interface + status: partial + ui.html#cursor-props: + title: 18.1 Cursors + 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: supported + ui.html#system-fonts: + title: 18.3 User preferences for fonts + status: n/a + ui.html#dynamic-outlines: + title: 18.4 Dynamic outlines + 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: unsupported + gaps: + - The focus isn't tracked or represented + ui.html#magnification: + title: 18.5 Magnification + status: n/a + aural.html#q19.0: + title: A Aural style sheets + 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 + zindex.html#q23.0: + title: E Elaborate description of Stacking Contexts + status: partial + zindex.html#stacking-defs: + title: E.1 Definitions + status: n/a + zindex.html#painting-order: + title: E.2 Painting order + 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: n/a + grammar.html#q25.0: + title: G Grammar of CSS 2.1 + status: partial + grammar.html#grammar: + title: G.1 Grammar + status: supported + grammar.html#scanner: + title: G.2 Lexical scanner + 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: supported + grammar.html#q25.4: + title: G.4 Implementation note + status: n/a diff --git a/Spec/cssom-1.yml b/Spec/cssom-1.yml new file mode 100644 index 000000000..b51650d85 --- /dev/null +++ b/Spec/cssom-1.yml @@ -0,0 +1,102 @@ +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: n/a + '#terminology': + title: 2 Terminology + status: n/a + '#cssomstring-type': + title: 3 CSSOMString + status: n/a + '#media-queries': + title: 4 Media Queries + status: n/a + '#selectors': + title: 5 Selectors + status: n/a + '#css-object-model': + title: 6 CSS + status: partial + '#css-style-sheets': + title: 6.1 CSS Style Sheets + 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: n/a + '#the-cssstylesheet-interface': + title: 6.1.2 The CSSStyleSheet Interface + status: n/a + '#css-style-sheet-collections': + title: 6.2 CSS Style Sheet Collections + 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: n/a + '#the-stylesheetlist-interface': + title: 6.2.2 The StyleSheetList Interface + status: n/a + '#extensions-to-the-document-or-shadow-root-interface': + title: 6.2.3 Extensions to the DocumentOrShadowRoot Interface Mixin + status: n/a + '#style-sheet-association': + title: 6.3 Style Sheet Association + status: partial + '#fetching-css-style-sheets': + title: 6.3.1 Fetching CSS style sheets + 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: n/a + '#requirements-on-specifications': + title: 6.3.3 Requirements on specifications + 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: 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: n/a + '#css-rules': + title: 6.4 CSS Rules + status: n/a + '#css-declarations': + title: 6.5 CSS Declarations + status: n/a + '#css-declaration-blocks': + title: 6.6 CSS Declaration Blocks + status: n/a + '#css-values': + title: 6.7 CSS Values + status: n/a + '#dom-access-to-css-declaration-blocks': + title: 7 DOM Access to CSS Declaration Blocks + status: n/a + '#utility-apis': + title: 8 Utility APIs + status: n/a + '#resolved-values': + title: 9 Resolved Values + status: n/a + '#iana-considerations': + title: 10 IANA Considerations + status: n/a + '#change-history': + title: 11 Change History + status: n/a + '#security-and-privacy-considerations': + title: 12 Security and Privacy Considerations + status: n/a + '#acknowledgments': + title: 13 Acknowledgments + status: n/a diff --git a/Spec/cssom-view-1.yml b/Spec/cssom-view-1.yml new file mode 100644 index 000000000..c1c4c4eab --- /dev/null +++ b/Spec/cssom-view-1.yml @@ -0,0 +1,83 @@ +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: n/a + '#terminology': + title: 2 Terminology + 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: n/a + '#zooming': + title: 2.2 Zooming + status: n/a + '#web-exposed-screen-information': + title: 2.3 Web-exposed screen information + status: n/a + '#common-infrastructure': + title: 3 Common Infrastructure + status: partial + '#scrolling': + title: 3.1 Scrolling + 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: n/a + '#extensions-to-the-window-interface': + title: 4 Extensions to the Window Interface + status: n/a + '#extensions-to-the-document-interface': + title: 5 Extensions to the Document Interface + status: n/a + '#extension-to-the-element-interface': + title: 6 Extensions to the Element Interface + status: partial + '#element-scrolling-members': + title: 6.1 Element Scrolling Members + 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: n/a + '#extensions-to-the-htmlimageelement-interface': + title: 8 Extensions to the HTMLImageElement Interface + status: n/a + '#extensions-to-the-range-interface': + title: 9 Extensions to the Range Interface + status: n/a + '#extensions-to-the-mouseevent-interface': + title: 10 Extensions to the MouseEvent Interface + status: n/a + '#geometry': + title: 11 Geometry + status: n/a + '#visualViewport': + title: 12 VisualViewport + status: n/a + '#events': + title: 13 Events + status: n/a + '#post-layout-snapshot': + title: 14 Post-Layout State Snapshotting + status: n/a + '#privacy': + title: 15 Privacy Considerations + status: n/a + '#security': + title: 16 Security Considerations + status: n/a + '#changes': + title: 17 Changes + status: n/a + '#acks': + title: 18 Acknowledgements + status: n/a diff --git a/Spec/filter-effects-1.yml b/Spec/filter-effects-1.yml new file mode 100644 index 000000000..119c41360 --- /dev/null +++ b/Spec/filter-effects-1.yml @@ -0,0 +1,220 @@ +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: n/a + '#placement': + title: 2 Module interactions + status: n/a + '#values': + title: 3 Values + status: n/a + '#definitions': + title: 4 Terminology + status: n/a + '#FilterProperty': + title: '5 Graphic filters: the filter property' + status: unsupported + gaps: + - filter isn't parsed + '#filter-functions': + title: 6 Filter Functions + status: unsupported + '#supported-filter-functions': + title: 6.1 Supported Filter Functions + status: unsupported + '#computed-values-of-filter-functions': + title: 6.2 Computed Values of Filter Functions + status: unsupported + '#serialization-of-filter-functions': + title: 6.3 Serialization of Filter Functions + status: n/a + '#interpolation-of-filter-functions': + title: 6.4 Interpolation of Filter Functions + status: n/a + '#FilterElement': + title: '7 SVG Filter Sources: the filter element' + status: unsupported + gaps: + - No SVG support + '#FilterEffectsRegion': + title: 8 Filter Region + status: unsupported + '#FilterPrimitivesOverview': + title: 9 Filter primitives + status: unsupported + gaps: + - No SVG support + '#FilterPrimitivesOverviewIntro': + title: 9.1 Overview + status: unsupported + '#CommonAttributes': + title: 9.2 Common filter primitive attributes + status: unsupported + '#FilterPrimitiveTree': + title: 9.3 Filter primitive tree + status: unsupported + '#FilterPrimitiveSubRegion': + title: 9.4 Filter primitive subregion + status: unsupported + '#feBlendElement': + title: 9.5 Filter primitive feBlend + status: unsupported + '#feColorMatrixElement': + title: 9.6 Filter primitive feColorMatrix + status: unsupported + '#feComponentTransferElement': + title: 9.7 Filter primitive feComponentTransfer + status: unsupported + '#feFuncRElement': + title: 9.7.1 Transfer function feFuncR + status: unsupported + '#feFuncGElement': + title: 9.7.2 Transfer function feFuncG + status: unsupported + '#feFuncBElement': + title: 9.7.3 Transfer function feFuncB + status: unsupported + '#feFuncAElement': + title: 9.7.4 Transfer function feFuncA + status: unsupported + '#feCompositeElement': + title: 9.8 Filter primitive feComposite + status: unsupported + '#feConvolveMatrixElement': + title: 9.9 Filter primitive feConvolveMatrix + status: unsupported + '#feDiffuseLightingElement': + title: 9.10 Filter primitive feDiffuseLighting + status: unsupported + '#feDisplacementMapElement': + title: 9.11 Filter primitive feDisplacementMap + status: unsupported + '#feDropShadowElement': + title: 9.12 Filter primitive feDropShadow + status: unsupported + '#feFloodElement': + title: 9.13 Filter primitive feFlood + status: unsupported + '#FloodColorProperty': + title: 9.13.1 The flood-color property + status: unsupported + gaps: + - flood-color isn't parsed + '#FloodOpacityProperty': + title: 9.13.2 The flood-opacity property + status: unsupported + gaps: + - flood-opacity isn't parsed + '#feGaussianBlurElement': + title: 9.14 Filter primitive feGaussianBlur + status: unsupported + '#feImageElement': + title: 9.15 Filter primitive feImage + status: unsupported + '#feMergeElement': + title: 9.16 Filter primitive feMerge + status: unsupported + '#feMergeNodeElement': + title: 9.16.1 Merge node feMergeNode + status: unsupported + '#feMorphologyElement': + title: 9.17 Filter primitive feMorphology + status: unsupported + '#feOffsetElement': + title: 9.18 Filter primitive feOffset + status: unsupported + '#feSpecularLightingElement': + title: 9.19 Filter primitive feSpecularLighting + status: unsupported + '#feTileElement': + title: 9.20 Filter primitive feTile + status: unsupported + '#feTurbulenceElement': + title: 9.21 Filter primitive feTurbulence + status: unsupported + '#ColorInterpolationFiltersProperty': + title: 10 The color-interpolation-filters property + status: unsupported + gaps: + - color-interpolation-filters isn't parsed + '#LightSourceDefinitions': + title: 11 Light source elements and properties + status: unsupported + gaps: + - No SVG support + '#LightSourceIntro': + title: 11.1 Introduction + status: unsupported + '#feDistantLightElement': + title: 11.2 Light source feDistantLight + status: unsupported + '#fePointLightElement': + title: 11.3 Light source fePointLight + status: unsupported + '#feSpotLightElement': + title: 11.4 Light source feSpotLight + status: unsupported + '#LightingColorProperty': + title: 11.5 The lighting-color property + status: unsupported + gaps: + - lighting-color isn't parsed + '#FilterCSSImageValue': + title: 12 Filter CSS <image> values + status: unsupported + gaps: + - filter() isn't accepted as an <image> + '#interpolating-filter-image': + title: 12.1 Interpolating filter() + status: n/a + '#ShorthandEquivalents': + title: 13 Shorthands defined in terms of the filter element + status: unsupported + gaps: + - No filter functions, see 6 + '#FilterPrimitiveRepresentation': + title: 13.1 Filter primitive representation + status: unsupported + '#grayscaleEquivalent': + title: 13.1.1 grayscale + status: unsupported + '#sepiaEquivalent': + title: 13.1.2 sepia + status: unsupported + '#saturateEquivalent': + title: 13.1.3 saturate + status: unsupported + '#huerotateEquivalent': + title: 13.1.4 hue-rotate + status: unsupported + '#invertEquivalent': + title: 13.1.5 invert + status: unsupported + '#opacityEquivalent': + title: 13.1.6 opacity + status: unsupported + '#brightnessEquivalent': + title: 13.1.7 brightness + status: unsupported + '#contrastEquivalent': + title: 13.1.8 contrast + status: unsupported + '#blurEquivalent': + title: 13.1.9 blur + status: unsupported + '#dropshadowEquivalent': + title: 13.1.10 drop-shadow + status: unsupported + '#filter-region-for-shorthands': + title: 13.2 Filter region for shorthands + status: unsupported + '#animation-of-filters': + title: 14 Animation of Filters + status: n/a + '#priv-sec': + title: 15 Privacy and Security Considerations + status: n/a diff --git a/Spec/geometry-1.yml b/Spec/geometry-1.yml new file mode 100644 index 000000000..5dcfbff2a --- /dev/null +++ b/Spec/geometry-1.yml @@ -0,0 +1,35 @@ +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: n/a + '#DOMPoint': + title: 2 The DOMPoint interfaces + status: n/a + '#DOMRect': + title: 3 The DOMRect interfaces + status: n/a + '#DOMRectList': + title: 4 The DOMRectList interface + status: n/a + '#DOMQuad': + title: 5 The DOMQuad interface + status: n/a + '#DOMMatrix': + title: 6 The DOMMatrix interfaces + status: n/a + '#structured-serialization': + title: 7 Structured serialization + status: n/a + '#security': + title: 8 Security Considerations + status: n/a + '#priv-sec': + title: 9 Privacy Considerations + status: n/a + '#historical': + title: 10 Historical + status: n/a diff --git a/Spec/html.yml b/Spec/html.yml new file mode 100644 index 000000000..9a1a206a1 --- /dev/null +++ b/Spec/html.yml @@ -0,0 +1,2321 @@ +spec: html +title: HTML Living Standard +url: https://html.spec.whatwg.org/multipage/ +snapshot: 7a1d2b8107ef368c4951f15f091d82fb62e59dde +sections: + introduction.html#introduction: + title: 1 Introduction + status: n/a + infrastructure.html#infrastructure: + title: 2 Common infrastructure + status: partial + infrastructure.html#terminology: + title: 2.1 Terminology + status: n/a + infrastructure.html#xml: + title: 2.1.3 XML compatibility + 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: supported + infrastructure.html#policy-controlled-features: + title: 2.2 Policy-controlled features + status: n/a + common-microsyntaxes.html#common-microsyntaxes: + title: 2.3 Common microsyntaxes + status: partial + common-microsyntaxes.html#common-parser-idioms: + title: 2.3.1 Common parser idioms + status: n/a + common-microsyntaxes.html#boolean-attributes: + title: 2.3.2 Boolean attributes + status: supported + common-microsyntaxes.html#keywords-and-enumerated-attributes: + title: 2.3.3 Keywords and enumerated attributes + 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: partial + common-microsyntaxes.html#signed-integers: + title: 2.3.4.1 Signed integers + 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: 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: unsupported + common-microsyntaxes.html#percentages-and-dimensions: + title: 2.3.4.4 Percentages and lengths + 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: supported + common-microsyntaxes.html#lists-of-floating-point-numbers: + title: 2.3.4.6 Lists of floating-point numbers + status: unsupported + common-microsyntaxes.html#lists-of-dimensions: + title: 2.3.4.7 Lists of dimensions + status: unsupported + common-microsyntaxes.html#dates-and-times: + title: 2.3.5 Dates and times + status: n/a + common-microsyntaxes.html#colours: + 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" + common-microsyntaxes.html#space-separated-tokens: + title: 2.3.7 Space-separated tokens + 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: n/a + common-microsyntaxes.html#syntax-references: + title: 2.3.9 References + status: unsupported + common-microsyntaxes.html#mq: + title: 2.3.10 Media queries + 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: n/a + urls-and-fetching.html#urls: + title: 2.4 URLs + status: partial + urls-and-fetching.html#terminology-2: + title: 2.4.1 Terminology + status: n/a + urls-and-fetching.html#resolving-urls: + title: 2.4.2 Parsing URLs + 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: 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: partial + urls-and-fetching.html#terminology-3: + title: 2.5.1 Terminology + status: n/a + urls-and-fetching.html#content-type-sniffing: + title: 2.5.2 Determining the type of a resource + 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: n/a + urls-and-fetching.html#cors-settings-attributes: + title: 2.5.4 CORS settings attributes + status: n/a + urls-and-fetching.html#referrer-policy-attributes: + title: 2.5.5 Referrer policy attributes + status: n/a + urls-and-fetching.html#nonce-attributes: + title: 2.5.6 Nonce attributes + status: n/a + urls-and-fetching.html#lazy-loading-attributes: + title: 2.5.7 Lazy loading attributes + status: supported + urls-and-fetching.html#blocking-attributes: + title: 2.5.8 Blocking attributes + status: n/a + urls-and-fetching.html#fetch-priority-attributes: + title: 2.5.9 Fetch priority attributes + status: n/a + common-dom-interfaces.html#common-dom-interfaces: + title: 2.6 Common DOM interfaces + status: n/a + structured-data.html#safe-passing-of-structured-data: + title: 2.7 Safe passing of structured data + status: n/a + dom.html#dom: + title: 3 Semantics, structure, and APIs of HTML documents + status: partial + dom.html#documents: + title: 3.1 Documents + status: n/a + dom.html#elements: + title: 3.2 Elements + status: partial + dom.html#semantics-2: + title: 3.2.1 Semantics + status: n/a + dom.html#elements-in-the-dom: + title: 3.2.2 Elements in the DOM + status: n/a + dom.html#html-element-constructors: + title: 3.2.3 HTML element constructors + status: n/a + dom.html#element-definitions: + title: 3.2.4 Element definitions + status: n/a + dom.html#content-models: + title: 3.2.5 Content models + status: n/a + dom.html#global-attributes: + title: 3.2.6 Global attributes + status: partial + dom.html#the-title-attribute: + title: 3.2.6.1 The title attribute + 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: 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: n/a + dom.html#the-dir-attribute: + title: 3.2.6.4 The dir attribute + 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: 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: supported + dom.html#the-innertext-idl-attribute: + title: 3.2.7 The innerText and outerText properties + status: n/a + dom.html#requirements-relating-to-the-bidirectional-algorithm: + title: 3.2.8 Requirements relating to the bidirectional algorithm + 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: n/a + dom.html#user-agent-conformance-criteria: + title: 3.2.8.2 User agent conformance criteria + 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: n/a + semantics.html#semantics: + title: 4 The elements of HTML + status: partial + semantics.html#the-root-element: + title: 4.1 The document element + status: supported + semantics.html#the-html-element: + title: 4.1.1 The html element + status: supported + semantics.html#document-metadata: + title: 4.2 Document metadata + status: partial + semantics.html#the-head-element: + title: 4.2.1 The head element + status: supported + semantics.html#the-title-element: + title: 4.2.2 The title element + 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: 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: 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: 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: 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: partial + gaps: + - Critical subresources aren't loaded, @import never loads + semantics.html#processing-link-headers: + title: 4.2.4.4 Processing `Link` headers + status: n/a + semantics.html#early-hints: + title: 4.2.4.5 Early hints + 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: n/a + semantics.html#the-meta-element: + title: 4.2.5 The meta element + status: unsupported + semantics.html#standard-metadata-names: + title: 4.2.5.1 Standard metadata names + status: unsupported + gaps: + - color-scheme is ignored + semantics.html#other-metadata-names: + title: 4.2.5.2 Other metadata names + status: n/a + semantics.html#pragma-directives: + title: 4.2.5.3 Pragma directives + 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: n/a + semantics.html#the-style-element: + title: 4.2.6 The style element + 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: n/a + sections.html#sections: + title: 4.3 Sections + status: partial + sections.html#the-body-element: + title: 4.3.1 The body element + status: supported + sections.html#the-article-element: + title: 4.3.2 The article element + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 + sections.html#the-section-element: + title: 4.3.3 The section element + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 + sections.html#the-nav-element: + title: 4.3.4 The nav element + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 + sections.html#the-aside-element: + title: 4.3.5 The aside element + 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: supported + sections.html#the-hgroup-element: + title: 4.3.7 The hgroup element + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 + sections.html#the-header-element: + title: 4.3.8 The header element + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 + sections.html#the-footer-element: + title: 4.3.9 The footer element + status: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 + sections.html#the-address-element: + title: 4.3.10 The address element + status: supported + sections.html#headings-and-outlines-2: + title: 4.3.11 Headings and outlines + status: partial + sections.html#heading-levels-&-offsets: + title: 4.3.11.1 Heading levels & offsets + 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: n/a + sections.html#exposing-outlines-to-users: + title: 4.3.11.3 Exposing outlines to users + status: n/a + sections.html#usage-summary-2: + title: 4.3.12 Usage summary + status: n/a + grouping-content.html#grouping-content: + title: 4.4 Grouping content + status: partial + grouping-content.html#the-p-element: + title: 4.4.1 The p element + status: supported + grouping-content.html#the-hr-element: + title: 4.4.2 The hr element + status: supported + grouping-content.html#the-pre-element: + title: 4.4.3 The pre element + status: supported + grouping-content.html#the-blockquote-element: + title: 4.4.4 The blockquote element + status: supported + grouping-content.html#the-ol-element: + title: 4.4.5 The ol element + status: partial + gaps: + - reversed without a value, i.e. <ol reversed>, counts up + grouping-content.html#the-ul-element: + title: 4.4.6 The ul element + status: supported + grouping-content.html#the-menu-element: + title: 4.4.7 The menu element + status: supported + grouping-content.html#the-li-element: + title: 4.4.8 The li element + status: supported + grouping-content.html#the-dl-element: + title: 4.4.9 The dl element + status: supported + grouping-content.html#the-dt-element: + title: 4.4.10 The dt element + status: supported + grouping-content.html#the-dd-element: + title: 4.4.11 The dd element + status: supported + grouping-content.html#the-figure-element: + title: 4.4.12 The figure element + 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: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 + grouping-content.html#the-main-element: + title: 4.4.14 The main element + 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: unsupported + gaps: + - Unknown element, rendered inline, see 15.3.3 + grouping-content.html#the-div-element: + title: 4.4.16 The div element + status: supported + text-level-semantics.html#text-level-semantics: + title: 4.5 Text-level semantics + status: partial + text-level-semantics.html#the-a-element: + title: 4.5.1 The a element + status: supported + text-level-semantics.html#the-em-element: + title: 4.5.2 The em element + status: supported + text-level-semantics.html#the-strong-element: + title: 4.5.3 The strong element + status: supported + text-level-semantics.html#the-small-element: + title: 4.5.4 The small element + status: supported + text-level-semantics.html#the-s-element: + title: 4.5.5 The s element + status: supported + text-level-semantics.html#the-cite-element: + title: 4.5.6 The cite element + status: supported + text-level-semantics.html#the-q-element: + title: 4.5.7 The q element + 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: 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: 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: 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: 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: unsupported + gaps: + - Displayed, see 15.3.1 + text-level-semantics.html#the-data-element: + title: 4.5.13 The data element + status: supported + text-level-semantics.html#the-time-element: + title: 4.5.14 The time element + status: supported + text-level-semantics.html#the-code-element: + title: 4.5.15 The code element + status: supported + text-level-semantics.html#the-var-element: + title: 4.5.16 The var element + status: supported + text-level-semantics.html#the-samp-element: + title: 4.5.17 The samp element + status: supported + text-level-semantics.html#the-kbd-element: + title: 4.5.18 The kbd element + status: supported + text-level-semantics.html#the-sub-and-sup-elements: + title: 4.5.19 The sub and sup elements + status: supported + text-level-semantics.html#the-i-element: + title: 4.5.20 The i element + status: supported + text-level-semantics.html#the-b-element: + title: 4.5.21 The b element + status: supported + text-level-semantics.html#the-u-element: + title: 4.5.22 The u element + status: supported + text-level-semantics.html#the-mark-element: + title: 4.5.23 The mark element + 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: 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: 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: supported + text-level-semantics.html#the-br-element: + title: 4.5.27 The br element + status: supported + text-level-semantics.html#the-wbr-element: + title: 4.5.28 The wbr element + status: supported + text-level-semantics.html#usage-summary: + title: 4.5.29 Usage summary + status: n/a + links.html#links: + title: 4.6 Links + status: partial + links.html#introduction-2: + title: 4.6.1 Introduction + status: n/a + links.html#links-created-by-a-and-area-elements: + title: 4.6.2 Links created by a and area elements + 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: n/a + links.html#api-for-a-and-area-elements: + title: 4.6.4 API for a and area elements + status: n/a + links.html#following-hyperlinks: + title: 4.6.5 Following hyperlinks + 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: unsupported + gaps: + - The download attribute is ignored, the link is followed instead + links.html#hyperlink-auditing: + title: 4.6.7 Hyperlink auditing + 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: unsupported + links.html#linkTypes: + title: 4.6.8 Link types + status: partial + links.html#rel-alternate: + title: 4.6.8.1 Link type "alternate" + status: supported + links.html#link-type-author: + title: 4.6.8.2 Link type "author" + status: n/a + links.html#link-type-bookmark: + title: 4.6.8.3 Link type "bookmark" + status: n/a + links.html#link-type-canonical: + title: 4.6.8.4 Link type "canonical" + status: n/a + links.html#link-type-dns-prefetch: + title: 4.6.8.5 Link type "dns-prefetch" + status: n/a + links.html#link-type-expect: + title: 4.6.8.6 Link type "expect" + status: n/a + links.html#link-type-external: + title: 4.6.8.7 Link type "external" + status: n/a + links.html#link-type-help: + title: 4.6.8.8 Link type "help" + status: n/a + links.html#rel-icon: + title: 4.6.8.9 Link type "icon" + status: n/a + links.html#link-type-license: + title: 4.6.8.10 Link type "license" + status: n/a + links.html#link-type-manifest: + title: 4.6.8.11 Link type "manifest" + status: n/a + links.html#link-type-modulepreload: + title: 4.6.8.12 Link type "modulepreload" + status: n/a + links.html#link-type-nofollow: + title: 4.6.8.13 Link type "nofollow" + status: n/a + links.html#link-type-noopener: + title: 4.6.8.14 Link type "noopener" + status: n/a + links.html#link-type-noreferrer: + title: 4.6.8.15 Link type "noreferrer" + status: n/a + links.html#link-type-opener: + title: 4.6.8.16 Link type "opener" + status: n/a + links.html#link-type-pingback: + title: 4.6.8.17 Link type "pingback" + status: n/a + links.html#link-type-preconnect: + title: 4.6.8.18 Link type "preconnect" + status: n/a + links.html#link-type-prefetch: + title: 4.6.8.19 Link type "prefetch" + status: n/a + links.html#link-type-preload: + title: 4.6.8.20 Link type "preload" + status: n/a + links.html#link-type-privacy-policy: + title: 4.6.8.21 Link type "privacy-policy" + status: n/a + links.html#link-type-search: + title: 4.6.8.22 Link type "search" + status: n/a + links.html#link-type-stylesheet: + title: 4.6.8.23 Link type "stylesheet" + 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: n/a + links.html#link-type-terms-of-service: + title: 4.6.8.25 Link Type "terms-of-service" + status: n/a + links.html#sequential-link-types: + title: 4.6.8.26 Sequential link types + status: n/a + links.html#other-link-types: + title: 4.6.8.27 Other link types + status: n/a + edits.html#edits: + title: 4.7 Edits + status: supported + edits.html#the-ins-element: + title: 4.7.1 The ins element + status: supported + edits.html#the-del-element: + title: 4.7.2 The del element + status: supported + edits.html#attributes-common-to-ins-and-del-elements: + title: 4.7.3 Attributes common to ins and del elements + status: n/a + edits.html#edits-and-paragraphs: + title: 4.7.4 Edits and paragraphs + status: n/a + edits.html#edits-and-lists: + title: 4.7.5 Edits and lists + status: n/a + edits.html#edits-and-tables: + title: 4.7.6 Edits and tables + status: n/a + embedded-content.html#embedded-content: + title: 4.8 Embedded content + status: partial + embedded-content.html#the-picture-element: + title: 4.8.1 The picture element + 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: 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: 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: partial + images.html#introduction-3: + title: 4.8.4.1 Introduction + 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: n/a + images.html#images-processing-model: + title: 4.8.4.3 Processing model + status: partial + images.html#when-to-obtain-images: + title: 4.8.4.3.1 When to obtain images + status: supported + images.html#reacting-to-dom-mutations: + title: 4.8.4.3.2 Reacting to DOM mutations + status: n/a + images.html#the-list-of-available-images: + title: 4.8.4.3.3 The list of available images + status: n/a + images.html#decoding-images: + title: 4.8.4.3.4 Decoding images + status: n/a + images.html#updating-the-image-data: + title: 4.8.4.3.5 Updating the image data + status: supported + images.html#preparing-an-image-for-presentation: + title: 4.8.4.3.6 Preparing an image for presentation + 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: 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: 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: 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: unsupported + gaps: + - srcset isn't parsed + images.html#parsing-a-sizes-attribute: + title: 4.8.4.3.11 Parsing a sizes attribute + status: unsupported + gaps: + - sizes isn't parsed + images.html#normalizing-the-source-densities: + title: 4.8.4.3.12 Normalizing the source densities + 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: 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: n/a + iframe-embed-object.html#the-iframe-element: + title: 4.8.5 The iframe element + 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: unsupported + gaps: + - Not rendered, unknown inline element + iframe-embed-object.html#the-object-element: + title: 4.8.7 The object element + 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: 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: unsupported + gaps: + - Not rendered, its fallback content is shown instead + media.html#the-track-element: + title: 4.8.10 The track element + status: n/a + media.html#media-elements: + title: 4.8.11 Media elements + status: n/a + image-maps.html#the-map-element: + title: 4.8.12 The map element + status: supported + image-maps.html#the-area-element: + title: 4.8.13 The area element + status: unsupported + gaps: + - Not a hyperlink, see 4.8.14.2 + image-maps.html#image-maps: + title: 4.8.14 Image maps + status: unsupported + image-maps.html#authoring: + title: 4.8.14.1 Authoring + status: n/a + image-maps.html#image-map-processing-model: + title: 4.8.14.2 Processing model + 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: 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: 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: supported + tables.html#tables: + title: 4.9 Tabular data + status: partial + tables.html#the-table-element: + title: 4.9.1 The table element + status: supported + tables.html#table-descriptions-techniques: + title: 4.9.1.1 Techniques for describing tables + status: n/a + tables.html#table-layout-techniques: + title: 4.9.1.2 Techniques for table design + status: n/a + tables.html#the-caption-element: + title: 4.9.2 The caption element + status: unsupported + gaps: + - Not rendered, see CSS 2.1 17.4 + tables.html#the-colgroup-element: + title: 4.9.3 The colgroup element + status: supported + tables.html#the-col-element: + title: 4.9.4 The col element + status: supported + tables.html#the-tbody-element: + title: 4.9.5 The tbody element + status: supported + tables.html#the-thead-element: + title: 4.9.6 The thead element + status: supported + tables.html#the-tfoot-element: + title: 4.9.7 The tfoot element + status: supported + tables.html#the-tr-element: + title: 4.9.8 The tr element + status: supported + tables.html#the-td-element: + title: 4.9.9 The td element + status: supported + tables.html#the-th-element: + title: 4.9.10 The th element + status: supported + tables.html#attributes-common-to-td-and-th-elements: + title: 4.9.11 Attributes common to td and th elements + 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: partial + tables.html#forming-a-table: + title: 4.9.12.1 Forming a table + 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: n/a + tables.html#table-examples: + title: 4.9.13 Examples + status: n/a + forms.html#forms: + title: 4.10 Forms + status: partial + forms.html#introduction-4: + title: 4.10.1 Introduction + status: n/a + forms.html#categories: + title: 4.10.2 Categories + status: n/a + forms.html#the-form-element: + title: 4.10.3 The form element + status: supported + forms.html#the-label-element: + title: 4.10.4 The label element + status: supported + input.html#the-input-element: + title: 4.10.5 The input element + status: unsupported + input.html#states-of-the-type-attribute: + title: 4.10.5.1 States of the type attribute + 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: 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: unsupported + input.html#telephone-state-(type=tel): + title: 4.10.5.1.3 Telephone state (type=tel) + status: unsupported + input.html#url-state-(type=url): + title: 4.10.5.1.4 URL state (type=url) + status: unsupported + input.html#email-state-(type=email): + title: 4.10.5.1.5 Email state (type=email) + status: unsupported + input.html#password-state-(type=password): + title: 4.10.5.1.6 Password state (type=password) + status: unsupported + input.html#date-state-(type=date): + title: 4.10.5.1.7 Date state (type=date) + status: unsupported + input.html#month-state-(type=month): + title: 4.10.5.1.8 Month state (type=month) + status: unsupported + input.html#week-state-(type=week): + title: 4.10.5.1.9 Week state (type=week) + status: unsupported + input.html#time-state-(type=time): + title: 4.10.5.1.10 Time state (type=time) + 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: unsupported + input.html#number-state-(type=number): + title: 4.10.5.1.12 Number state (type=number) + status: unsupported + input.html#range-state-(type=range): + title: 4.10.5.1.13 Range state (type=range) + status: unsupported + input.html#color-state-(type=color): + title: 4.10.5.1.14 Color state (type=color) + status: unsupported + input.html#checkbox-state-(type=checkbox): + title: 4.10.5.1.15 Checkbox state (type=checkbox) + status: unsupported + input.html#radio-button-state-(type=radio): + title: 4.10.5.1.16 Radio Button state (type=radio) + status: unsupported + input.html#file-upload-state-(type=file): + title: 4.10.5.1.17 File Upload state (type=file) + status: unsupported + input.html#submit-button-state-(type=submit): + title: 4.10.5.1.18 Submit Button state (type=submit) + status: unsupported + input.html#image-button-state-(type=image): + title: 4.10.5.1.19 Image Button state (type=image) + status: unsupported + input.html#reset-button-state-(type=reset): + title: 4.10.5.1.20 Reset Button state (type=reset) + status: unsupported + input.html#button-state-(type=button): + title: 4.10.5.1.21 Button state (type=button) + status: unsupported + input.html#input-impl-notes: + title: 4.10.5.2 Implementation notes regarding localization of form controls + status: n/a + input.html#common-input-element-attributes: + title: 4.10.5.3 Common input element attributes + status: n/a + input.html#the-size-attribute: + title: 4.10.5.3.2 The size attribute + status: unsupported + input.html#the-placeholder-attribute: + title: 4.10.5.3.10 The placeholder attribute + status: unsupported + input.html#common-input-element-apis: + title: 4.10.5.4 Common input element APIs + status: n/a + input.html#common-input-element-events: + title: 4.10.5.5 Common event behaviors + status: n/a + form-elements.html#the-button-element: + title: 4.10.6 The button element + 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: 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: unsupported + gaps: + - Its options are displayed, see 15.3.1 + form-elements.html#the-optgroup-element: + title: 4.10.9 The optgroup element + status: unsupported + form-elements.html#the-option-element: + title: 4.10.10 The option element + status: unsupported + form-elements.html#the-textarea-element: + title: 4.10.11 The textarea element + 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: supported + form-elements.html#the-progress-element: + title: 4.10.13 The progress element + 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: 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: 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: 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: unsupported + form-control-infrastructure.html#form-control-infrastructure: + title: 4.10.18 Form control infrastructure + status: n/a + form-control-infrastructure.html#attributes-common-to-form-controls: + title: 4.10.19 Attributes common to form controls + 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: unsupported + form-control-infrastructure.html#textFieldSelection: + title: 4.10.20 APIs for the text control selections + status: n/a + form-control-infrastructure.html#constraints: + title: 4.10.21 Constraints + status: n/a + form-control-infrastructure.html#form-submission-2: + title: 4.10.22 Form submission + status: n/a + form-control-infrastructure.html#resetting-a-form: + title: 4.10.23 Resetting a form + status: n/a + interactive-elements.html#interactive-elements: + title: 4.11 Interactive elements + status: partial + interactive-elements.html#the-details-element: + title: 4.11.1 The details element + 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: partial + gaps: + - No disclosure triangle, see 15.5.5 + interactive-elements.html#commands: + title: 4.11.3 Commands + status: n/a + interactive-elements.html#the-dialog-element: + title: 4.11.4 The dialog element + 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: n/a + scripting.html#scripting-3: + title: 4.12 Scripting + status: partial + scripting.html#the-script-element: + title: 4.12.1 The script element + status: supported + scripting.html#script-processing-model: + title: 4.12.1.1 Processing model + status: n/a + scripting.html#scriptingLanguages: + title: 4.12.1.2 Scripting languages + status: n/a + scripting.html#restrictions-for-contents-of-script-elements: + title: 4.12.1.3 Restrictions for contents of script elements + status: n/a + scripting.html#inline-documentation-for-external-scripts: + title: 4.12.1.4 Inline documentation for external scripts + status: n/a + scripting.html#scriptTagXSLT: + title: 4.12.1.5 Interaction of script elements and XSLT + status: n/a + scripting.html#the-noscript-element: + title: 4.12.2 The noscript element + status: supported + scripting.html#the-template-element: + title: 4.12.3 The template element + 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: n/a + scripting.html#the-slot-element: + title: 4.12.4 The slot element + status: supported + canvas.html#the-canvas-element: + title: 4.12.5 The canvas element + 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: n/a + canvas.html#the-imagebitmap-rendering-context: + title: 4.12.5.2 The ImageBitmap rendering context + status: n/a + canvas.html#the-offscreencanvas-interface: + title: 4.12.5.3 The OffscreenCanvas interface + status: n/a + canvas.html#colour-spaces-and-colour-correction: + title: 4.12.5.4 Color spaces and color space conversion + status: n/a + canvas.html#serialising-bitmaps-to-a-file: + title: 4.12.5.5 Serializing bitmaps to a file + status: n/a + canvas.html#security-with-canvas-elements: + title: 4.12.5.6 Security with canvas elements + 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: n/a + custom-elements.html#custom-elements: + title: 4.13 Custom elements + status: supported + custom-elements.html#custom-elements-intro: + title: 4.13.1 Introduction + status: n/a + custom-elements.html#custom-element-conformance: + title: 4.13.2 Requirements for custom element constructors and reactions + status: n/a + custom-elements.html#custom-elements-core-concepts: + title: 4.13.3 Core concepts + status: supported + custom-elements.html#custom-elements-api: + title: 4.13.4 The CustomElementRegistry interface + status: n/a + custom-elements.html#upgrades: + title: 4.13.5 Upgrades + status: n/a + custom-elements.html#custom-element-reactions: + title: 4.13.6 Custom element reactions + status: n/a + custom-elements.html#element-internals: + title: 4.13.7 Element internals + status: n/a + semantics-other.html#common-idioms: + title: 4.14 Common idioms without dedicated elements + status: n/a + semantics-other.html#disabled-elements: + title: 4.15 Disabled elements + status: unsupported + semantics-other.html#selectors: + title: 4.16 Matching HTML elements using selectors and CSS + status: partial + semantics-other.html#case-sensitivity-of-the-css-'attr()'-function: + title: 4.16.1 Case-sensitivity of the CSS 'attr()' function + 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: 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: 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" + microdata.html#microdata: + title: 5 Microdata + status: n/a + interaction.html#editing: + title: 6 User interaction + status: partial + interaction.html#the-hidden-attribute: + title: 6.1 The hidden attribute + 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: n/a + interaction.html#inert-subtrees: + title: 6.3 Inert subtrees + 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: 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: 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: n/a + interaction.html#activation: + title: 6.5 Activation behavior of elements + 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: n/a + interaction.html#the-commandevent-interface: + title: 6.5.2 The CommandEvent interface + status: n/a + interaction.html#focus: + title: 6.6 Focus + status: unsupported + gaps: + - Nothing can be focused, :focus and :focus-visible never match + interaction.html#introduction-8: + title: 6.6.1 Introduction + status: n/a + interaction.html#data-model: + title: 6.6.2 Data model + status: unsupported + interaction.html#the-tabindex-attribute: + title: 6.6.3 The tabindex attribute + status: unsupported + gaps: + - tabindex is ignored + interaction.html#focus-processing-model: + title: 6.6.4 Processing model + status: unsupported + interaction.html#sequential-focus-navigation: + title: 6.6.5 Sequential focus navigation + status: unsupported + gaps: + - Tab doesn't move through the links + interaction.html#focus-management-apis: + title: 6.6.6 Focus management APIs + status: n/a + interaction.html#the-autofocus-attribute: + title: 6.6.7 The autofocus attribute + status: unsupported + gaps: + - autofocus doesn't focus the element + interaction.html#assigning-keyboard-shortcuts: + title: 6.7 Assigning keyboard shortcuts + status: unsupported + interaction.html#introduction-9: + title: 6.7.1 Introduction + status: n/a + interaction.html#the-accesskey-attribute: + title: 6.7.2 The accesskey attribute + status: unsupported + gaps: + - accesskey is ignored + interaction.html#keyboard-shortcuts-processing-model: + title: 6.7.3 Processing model + status: unsupported + interaction.html#editing-2: + title: 6.8 Editing + status: unsupported + interaction.html#contenteditable: + title: '6.8.1 Making document regions editable: The contenteditable content attribute' + 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: n/a + interaction.html#best-practices-for-in-page-editors: + title: 6.8.3 Best practices for in-page editors + status: n/a + interaction.html#editing-apis: + title: 6.8.4 Editing APIs + status: n/a + interaction.html#spelling-and-grammar-checking: + title: 6.8.5 Spelling and grammar checking + status: n/a + gaps: + - Only applies to editable text, see 6.8.1 + interaction.html#writing-suggestions: + title: 6.8.6 Writing suggestions + status: n/a + gaps: + - Only applies to editable text, see 6.8.1 + interaction.html#autocapitalization: + title: 6.8.7 Autocapitalization + status: n/a + gaps: + - Only applies to editable text, see 6.8.1 + interaction.html#autocorrection: + title: 6.8.8 Autocorrection + 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: 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: n/a + gaps: + - Only applies to editable text, see 6.8.1 + interaction.html#find-in-page: + title: 6.9 Find-in-page + status: unsupported + gaps: + - There's no find-in-page + interaction.html#introduction-10: + title: 6.9.1 Introduction + status: n/a + interaction.html#interaction-with-details-and-hidden=until-found: + title: 6.9.2 Interaction with details and hidden=until-found + status: unsupported + interaction.html#interaction-with-selection: + title: 6.9.3 Interaction with selection + status: unsupported + interaction.html#close-requests-and-close-watchers: + title: 6.10 Close requests and close watchers + status: n/a + dnd.html#dnd: + title: 6.11 Drag and drop + status: partial + dnd.html#event-drag: + title: 6.11.1 Introduction + status: n/a + dnd.html#the-drag-data-store: + title: 6.11.2 The drag data store + status: n/a + dnd.html#the-datatransfer-interface: + title: 6.11.3 The DataTransfer interface + status: n/a + dnd.html#the-dragevent-interface: + title: 6.11.4 The DragEvent interface + status: n/a + dnd.html#drag-and-drop-processing-model: + title: 6.11.5 Processing model + 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: n/a + dnd.html#the-draggable-attribute: + title: 6.11.7 The draggable attribute + 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: n/a + popover.html#the-popover-attribute: + title: 6.12 The popover attribute + 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: unsupported + gaps: + - popovertarget on a button doesn't toggle the popover + popover.html#popover-light-dismiss: + title: 6.12.2 Popover light dismiss + status: n/a + browsers.html#browsers: + title: 7 Loading web pages + status: partial + browsers.html#loading-web-pages-supporting-concepts: + title: 7.1 Supporting concepts + status: n/a + nav-history-apis.html#nav-traversal-apis: + title: 7.2 APIs related to navigation and session history + status: n/a + document-sequences.html#infrastructure-for-sequences-of-documents: + title: 7.3 Infrastructure for sequences of documents + status: n/a + browsing-the-web.html#navigation-and-session-history: + title: 7.4 Navigation and session history + status: partial + browsing-the-web.html#session-history-infrastructure: + title: 7.4.1 Session history + status: n/a + browsing-the-web.html#navigating-across-documents: + title: 7.4.2 Navigation + status: n/a + browsing-the-web.html#reloading-and-traversing: + title: 7.4.3 Reloading and traversing + status: n/a + browsing-the-web.html#navigate-non-frag-sync: + title: 7.4.4 Non-fragment synchronous "navigations" + status: n/a + browsing-the-web.html#populating-a-session-history-entry: + title: 7.4.5 Populating a session history entry + status: n/a + browsing-the-web.html#applying-the-history-step: + title: 7.4.6 Applying the history step + status: partial + browsing-the-web.html#updating-the-traversable: + title: 7.4.6.1 Updating the traversable + status: n/a + browsing-the-web.html#updating-the-document: + title: 7.4.6.2 Updating the document + status: n/a + browsing-the-web.html#revealing-the-document: + title: 7.4.6.3 Revealing the document + status: n/a + browsing-the-web.html#scrolling-to-a-fragment: + title: 7.4.6.4 Scrolling to a fragment + 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 + - 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 + document-lifecycle.html#document-lifecycle: + title: 7.5 Document lifecycle + status: n/a + speculative-loading.html#speculative-loading: + title: 7.6 Speculative loading + status: n/a + speculative-loading.html#the-x-frame-options-header: + title: 7.7 The `X-Frame-Options` header + status: n/a + speculative-loading.html#the-refresh-header: + title: 7.8 The `Refresh` header + status: n/a + speculative-loading.html#nav-traversal-ui: + title: 7.9 Browser user interface considerations + status: n/a + webappapis.html#webappapis: + title: 8 Web application APIs + status: n/a + webappapis.html#enabling-and-disabling-scripting: + title: 8.1.3.4 Enabling and disabling scripting + 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: supported + webappapis.html#idl-definitions: + title: 8.1.8.2.1 IDL definitions + status: n/a + comms.html#comms: + title: 9 Communication + status: n/a + workers.html#workers: + title: 10 Web workers + status: n/a + worklets.html#worklets: + title: 11 Worklets + status: n/a + webstorage.html#webstorage: + title: 12 Web storage + status: n/a + syntax.html#syntax: + title: 13 The HTML syntax + status: partial + syntax.html#writing: + title: 13.1 Writing HTML documents + 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: n/a + parsing.html#parse-errors: + title: 13.2.2 Parse errors + status: n/a + parsing.html#the-input-byte-stream: + title: 13.2.3 The input byte stream + status: partial + parsing.html#parsing-with-a-known-character-encoding: + title: 13.2.3.1 Parsing with a known character encoding + status: n/a + parsing.html#determining-the-character-encoding: + title: 13.2.3.2 Determining the character encoding + status: n/a + parsing.html#character-encodings: + title: 13.2.3.3 Character encodings + status: n/a + parsing.html#changing-the-encoding-while-parsing: + title: 13.2.3.4 Changing the encoding while parsing + status: n/a + parsing.html#preprocessing-the-input-stream: + title: 13.2.3.5 Preprocessing the input stream + 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: partial + parsing.html#the-insertion-mode: + title: 13.2.4.1 The insertion mode + status: unsupported + parsing.html#the-stack-of-open-elements: + title: 13.2.4.2 The stack of open elements + 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: unsupported + parsing.html#the-element-pointers: + title: 13.2.4.4 The element pointers + status: unsupported + parsing.html#other-parsing-state-flags: + title: 13.2.4.5 Other parsing state flags + 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: partial + parsing.html#data-state: + title: 13.2.5.1 Data state + status: supported + parsing.html#rcdata-state: + title: 13.2.5.2 RCDATA state + status: supported + parsing.html#rawtext-state: + title: 13.2.5.3 RAWTEXT state + 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: supported + parsing.html#plaintext-state: + title: 13.2.5.5 PLAINTEXT state + status: supported + parsing.html#tag-open-state: + title: 13.2.5.6 Tag open state + status: supported + parsing.html#end-tag-open-state: + title: 13.2.5.7 End tag open state + status: supported + parsing.html#tag-name-state: + title: 13.2.5.8 Tag name state + status: supported + parsing.html#rcdata-less-than-sign-state: + title: 13.2.5.9 RCDATA less-than sign state + status: supported + parsing.html#rcdata-end-tag-open-state: + title: 13.2.5.10 RCDATA end tag open state + status: supported + parsing.html#rcdata-end-tag-name-state: + title: 13.2.5.11 RCDATA end tag name state + status: supported + parsing.html#rawtext-less-than-sign-state: + title: 13.2.5.12 RAWTEXT less-than sign state + status: supported + parsing.html#rawtext-end-tag-open-state: + title: 13.2.5.13 RAWTEXT end tag open state + status: supported + parsing.html#rawtext-end-tag-name-state: + title: 13.2.5.14 RAWTEXT end tag name state + status: supported + parsing.html#script-data-less-than-sign-state: + title: 13.2.5.15 Script data less-than sign state + status: supported + parsing.html#script-data-end-tag-open-state: + title: 13.2.5.16 Script data end tag open state + status: supported + parsing.html#script-data-end-tag-name-state: + title: 13.2.5.17 Script data end tag name state + status: supported + parsing.html#script-data-escape-start-state: + title: 13.2.5.18 Script data escape start state + status: supported + parsing.html#script-data-escape-start-dash-state: + title: 13.2.5.19 Script data escape start dash state + status: supported + parsing.html#script-data-escaped-state: + title: 13.2.5.20 Script data escaped state + status: supported + parsing.html#script-data-escaped-dash-state: + title: 13.2.5.21 Script data escaped dash state + status: supported + parsing.html#script-data-escaped-dash-dash-state: + title: 13.2.5.22 Script data escaped dash dash state + status: supported + parsing.html#script-data-escaped-less-than-sign-state: + title: 13.2.5.23 Script data escaped less-than sign state + status: supported + parsing.html#script-data-escaped-end-tag-open-state: + title: 13.2.5.24 Script data escaped end tag open state + status: supported + parsing.html#script-data-escaped-end-tag-name-state: + title: 13.2.5.25 Script data escaped end tag name state + status: supported + parsing.html#script-data-double-escape-start-state: + title: 13.2.5.26 Script data double escape start state + status: supported + parsing.html#script-data-double-escaped-state: + title: 13.2.5.27 Script data double escaped state + status: supported + parsing.html#script-data-double-escaped-dash-state: + title: 13.2.5.28 Script data double escaped dash state + status: supported + parsing.html#script-data-double-escaped-dash-dash-state: + title: 13.2.5.29 Script data double escaped dash dash state + 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: supported + parsing.html#script-data-double-escape-end-state: + title: 13.2.5.31 Script data double escape end state + status: supported + parsing.html#before-attribute-name-state: + title: 13.2.5.32 Before attribute name state + status: supported + parsing.html#attribute-name-state: + title: 13.2.5.33 Attribute name state + 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: supported + parsing.html#before-attribute-value-state: + title: 13.2.5.35 Before attribute value state + status: supported + parsing.html#attribute-value-(double-quoted)-state: + title: 13.2.5.36 Attribute value (double-quoted) state + status: supported + parsing.html#attribute-value-(single-quoted)-state: + title: 13.2.5.37 Attribute value (single-quoted) state + status: supported + parsing.html#attribute-value-(unquoted)-state: + title: 13.2.5.38 Attribute value (unquoted) state + status: supported + parsing.html#after-attribute-value-(quoted)-state: + title: 13.2.5.39 After attribute value (quoted) state + status: supported + parsing.html#self-closing-start-tag-state: + title: 13.2.5.40 Self-closing start tag state + status: supported + parsing.html#bogus-comment-state: + title: 13.2.5.41 Bogus comment state + status: supported + parsing.html#markup-declaration-open-state: + title: 13.2.5.42 Markup declaration open state + status: supported + parsing.html#comment-start-state: + title: 13.2.5.43 Comment start state + status: supported + parsing.html#comment-start-dash-state: + title: 13.2.5.44 Comment start dash state + status: supported + parsing.html#comment-state: + title: 13.2.5.45 Comment state + status: supported + parsing.html#comment-less-than-sign-state: + title: 13.2.5.46 Comment less-than sign state + status: supported + parsing.html#comment-less-than-sign-bang-state: + title: 13.2.5.47 Comment less-than sign bang state + status: supported + parsing.html#comment-less-than-sign-bang-dash-state: + title: 13.2.5.48 Comment less-than sign bang dash state + 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: supported + parsing.html#comment-end-dash-state: + title: 13.2.5.50 Comment end dash state + status: supported + parsing.html#comment-end-state: + title: 13.2.5.51 Comment end state + status: supported + parsing.html#comment-end-bang-state: + title: 13.2.5.52 Comment end bang state + status: supported + parsing.html#doctype-state: + title: 13.2.5.53 DOCTYPE state + status: supported + parsing.html#before-doctype-name-state: + title: 13.2.5.54 Before DOCTYPE name state + status: supported + parsing.html#doctype-name-state: + title: 13.2.5.55 DOCTYPE name state + status: supported + parsing.html#after-doctype-name-state: + title: 13.2.5.56 After DOCTYPE name state + status: supported + parsing.html#after-doctype-public-keyword-state: + title: 13.2.5.57 After DOCTYPE public keyword state + status: supported + parsing.html#before-doctype-public-identifier-state: + title: 13.2.5.58 Before DOCTYPE public identifier state + status: supported + parsing.html#doctype-public-identifier-(double-quoted)-state: + title: 13.2.5.59 DOCTYPE public identifier (double-quoted) state + status: supported + parsing.html#doctype-public-identifier-(single-quoted)-state: + title: 13.2.5.60 DOCTYPE public identifier (single-quoted) state + status: supported + parsing.html#after-doctype-public-identifier-state: + title: 13.2.5.61 After DOCTYPE public identifier state + status: supported + parsing.html#between-doctype-public-and-system-identifiers-state: + title: 13.2.5.62 Between DOCTYPE public and system identifiers state + status: supported + parsing.html#after-doctype-system-keyword-state: + title: 13.2.5.63 After DOCTYPE system keyword state + status: supported + parsing.html#before-doctype-system-identifier-state: + title: 13.2.5.64 Before DOCTYPE system identifier state + status: supported + parsing.html#doctype-system-identifier-(double-quoted)-state: + title: 13.2.5.65 DOCTYPE system identifier (double-quoted) state + status: supported + parsing.html#doctype-system-identifier-(single-quoted)-state: + title: 13.2.5.66 DOCTYPE system identifier (single-quoted) state + status: supported + parsing.html#after-doctype-system-identifier-state: + title: 13.2.5.67 After DOCTYPE system identifier state + status: supported + parsing.html#bogus-doctype-state: + title: 13.2.5.68 Bogus DOCTYPE state + status: supported + parsing.html#cdata-section-state: + title: 13.2.5.69 CDATA section state + status: parsed-only + parsing.html#cdata-section-bracket-state: + title: 13.2.5.70 CDATA section bracket state + status: parsed-only + parsing.html#cdata-section-end-state: + title: 13.2.5.71 CDATA section end state + status: parsed-only + parsing.html#processing-instruction-open-state: + title: 13.2.5.72 Processing instruction open state + status: supported + parsing.html#processing-instruction-target-state: + title: 13.2.5.73 Processing instruction target state + status: supported + parsing.html#after-processing-instruction-target-state: + title: 13.2.5.74 After processing instruction target state + status: supported + parsing.html#processing-instruction-data-state: + title: 13.2.5.75 Processing instruction data state + status: supported + parsing.html#processing-instruction-questionable-state: + title: 13.2.5.76 Processing instruction questionable state + status: supported + parsing.html#character-reference-state: + title: 13.2.5.77 Character reference state + 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: supported + parsing.html#ambiguous-ampersand-state: + title: 13.2.5.79 Ambiguous ampersand state + status: supported + parsing.html#numeric-character-reference-state: + title: 13.2.5.80 Numeric character reference state + status: supported + parsing.html#hexadecimal-character-reference-start-state: + title: 13.2.5.81 Hexadecimal character reference start state + status: supported + parsing.html#hexadecimal-character-reference-state: + title: 13.2.5.82 Hexadecimal character reference state + 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: 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: 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: 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: supported + parsing.html#closing-elements-that-have-implied-end-tags: + title: 13.2.6.3 Closing elements that have implied end tags + 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: 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: 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: 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: 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: 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: 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: 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: unsupported + parsing.html#parsing-main-incaption: + title: 13.2.6.4.11 The "in caption" insertion mode + 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: 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 + 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: 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: unsupported + parsing.html#parsing-main-afterbody: + title: 13.2.6.4.17 The "after body" insertion mode + 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: unsupported + parsing.html#parsing-main-afterframeset: + title: 13.2.6.4.19 The "after frameset" insertion mode + status: unsupported + parsing.html#the-after-after-body-insertion-mode: + title: 13.2.6.4.20 The "after after body" insertion mode + 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: unsupported + parsing.html#parsing-main-inforeign: + title: 13.2.6.5 The rules for parsing tokens in foreign content + status: unsupported + parsing.html#the-end: + title: 13.2.7 The end + status: n/a + parsing.html#speculative-html-parsing: + title: 13.2.8 Speculative HTML parsing + status: n/a + parsing.html#coercing-an-html-dom-into-an-infoset: + title: 13.2.9 Coercing an HTML DOM into an infoset + 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: n/a + parsing.html#serialising-html-fragments: + title: 13.3 Serializing HTML fragments + status: n/a + parsing.html#parsing-html-fragments: + title: 13.4 Parsing HTML fragments + status: n/a + 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: 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: n/a + xhtml.html#parsing-xhtml-documents: + title: 14.2 Parsing XML documents + 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: n/a + xhtml.html#parsing-xhtml-fragments: + title: 14.4 Parsing XML fragments + status: n/a + rendering.html#rendering: + title: 15 Rendering + status: partial + rendering.html#introduction-17: + title: 15.1 Introduction + 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: 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 + rendering.html#hidden-elements: + title: 15.3.1 Hidden elements + status: partial + gaps: + - The hidden attribute is ignored + - 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: 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: + - 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: 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: 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: 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 + - 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 + 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: 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: 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: 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: partial + rendering.html#embedded-content-rendering-rules: + title: 15.4.1 Embedded content + 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: 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: 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: unsupported + rendering.html#widgets: + title: 15.5 Widgets + status: partial + rendering.html#native-appearance-2: + title: 15.5.1 Native appearance + status: unsupported + gaps: + - Widgets aren't drawn, only their content is laid out + rendering.html#writing-mode: + title: 15.5.2 Writing mode + status: n/a + rendering.html#button-layout: + title: 15.5.3 Button layout + 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: 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: 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: 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: unsupported + rendering.html#the-input-element-as-a-range-control: + title: 15.5.8 The input element as a range control + status: unsupported + rendering.html#the-input-element-as-a-colour-well: + title: 15.5.9 The input element as a color well + 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: unsupported + rendering.html#the-input-element-as-a-file-upload-control: + title: 15.5.11 The input element as a file upload control + status: unsupported + rendering.html#the-input-element-as-a-button: + title: 15.5.12 The input element as a button + status: unsupported + gaps: + - An empty inline-block without the value + rendering.html#the-marquee-element-2: + title: 15.5.13 The marquee element + 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: unsupported + rendering.html#the-progress-element-2: + title: 15.5.15 The progress element + status: unsupported + rendering.html#the-select-element-2: + title: 15.5.16 The select element + 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: 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: unsupported + gaps: + - frameset and frame are empty blocks, frames aren't loaded + rendering.html#interactive-media: + title: 15.7 Interactive media + status: unsupported + rendering.html#links,-forms,-and-navigation: + title: 15.7.1 Links, forms, and navigation + status: n/a + rendering.html#the-title-attribute-2: + title: 15.7.2 The title attribute + status: unsupported + rendering.html#editing-hosts: + title: 15.7.3 Editing hosts + status: n/a + rendering.html#text-rendered-in-native-user-interfaces: + title: 15.7.4 Text rendered in native user interfaces + status: n/a + rendering.html#print-media: + title: 15.8 Print media + status: supported + rendering.html#unstyled-xml-documents: + title: 15.9 Unstyled XML documents + status: n/a + obsolete.html#obsolete: + title: 16 Obsolete features + status: partial + obsolete.html#obsolete-but-conforming-features: + title: 16.1 Obsolete but conforming features + status: n/a + obsolete.html#non-conforming-features: + title: 16.2 Non-conforming features + status: n/a + obsolete.html#requirements-for-implementations: + title: 16.3 Requirements for implementations + status: partial + obsolete.html#the-marquee-element: + title: 16.3.1 The marquee element + 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: 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: partial + gaps: + - listing, plaintext and xmp aren't rendered like pre, see 15.3.3 + iana.html#iana: + title: 17 IANA considerations + status: partial + iana.html#text/html: + title: 17.1 text/html + 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: n/a + iana.html#application/xhtml+xml: + title: 17.3 application/xhtml+xml + status: n/a + iana.html#text/ping: + title: 17.4 text/ping + status: n/a + iana.html#application/microdata+json: + title: 17.5 application/microdata+json + status: n/a + iana.html#application/speculationrules+json: + title: 17.6 application/speculationrules+json + status: n/a + iana.html#text/event-stream: + title: 17.7 text/event-stream + status: n/a + iana.html#web+-scheme-prefix: + title: 17.8 web+ scheme prefix + status: n/a diff --git a/Spec/mediaqueries-4.yml b/Spec/mediaqueries-4.yml new file mode 100644 index 000000000..fa4935476 --- /dev/null +++ b/Spec/mediaqueries-4.yml @@ -0,0 +1,135 @@ +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: n/a + '#units': + title: 1.3 Units + status: supported + '#media': + title: 2 Media Queries + status: partial + '#mq-list': + title: 2.1 Combining Media Queries + status: partial + gaps: + - An empty media query list is false instead of true + '#mq-prefix': + title: 2.2 Media Query Modifiers + status: supported + '#mq-not': + title: '2.2.1 Negating a Media Query: the not keyword' + status: supported + '#mq-only': + title: '2.2.2 Hiding a Media Query From Legacy user agents: the only keyword' + status: supported + '#media-types': + title: 2.3 Media Types + status: supported + '#mq-features': + title: 2.4 Media Features + status: partial + '#mq-ranges': + title: '2.4.1 Media Feature Types: “range” and “discrete”' + status: supported + '#mq-boolean-context': + title: 2.4.2 Evaluating Media Features in a Boolean Context + 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: 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: supported + '#media-conditions': + title: 2.5 Combining Media Features + 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: partial + gaps: + - Uppercase units, e.g. 800PX, aren't recognized + '#evaluating': + title: 3.1 Evaluating Media Queries + status: supported + '#error-handling': + title: 3.2 Error Handling + 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: supported + '#width': + title: '4.1 Width: the width feature' + status: supported + '#height': + title: '4.2 Height: the height feature' + status: supported + '#aspect-ratio': + title: '4.3 Aspect-Ratio: the aspect-ratio feature' + status: supported + '#orientation': + title: '4.4 Orientation: the orientation feature' + status: supported + '#mf-display-quality': + title: 5 Display Quality Media Features + status: partial + '#resolution': + title: '5.1 Display Resolution: the resolution feature' + 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: supported + '#grid': + title: '5.3 Detecting Console Displays: the grid feature' + status: supported + '#update': + title: '5.4 Display Update Frequency: the update feature' + status: supported + '#mf-overflow-block': + title: '5.5 Block-Axis Overflow: the overflow-block feature' + status: unsupported + '#mf-overflow-inline': + title: '5.6 Inline-Axis Overflow: the overflow-inline feature' + status: unsupported + '#mf-colors': + title: 6 Color Media Features + status: partial + gaps: + - color-gamut is an unknown feature, see 6.4 + '#color': + title: '6.1 Color Depth: the color feature' + status: supported + '#color-index': + title: '6.2 Paletted Color Screens: the color-index feature' + status: supported + '#monochrome': + title: '6.3 Monochrome Screens: the monochrome feature' + status: supported + '#color-gamut': + title: '6.4 Color Display Quality: the color-gamut feature' + status: unsupported + '#mf-interaction': + title: 7 Interaction Media Features + status: supported + '#pointer': + title: '7.1 Pointing Device Quality: the pointer feature' + status: supported + '#hover': + title: '7.2 Hover Capability: the hover feature' + status: supported + '#@media/any-input': + title: '7.3 All Available Interaction Capabilities: the any-pointer and any-hover features' + status: supported diff --git a/Spec/mediaqueries-5.yml b/Spec/mediaqueries-5.yml new file mode 100644 index 000000000..912426147 --- /dev/null +++ b/Spec/mediaqueries-5.yml @@ -0,0 +1,238 @@ +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: n/a + '#units': + title: 1.3 Units + status: supported + '#media': + title: 2 Media Queries + status: partial + '#mq-list': + title: 2.1 Combining Media Queries + 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: supported + '#mq-not': + title: '2.2.1 Negating a Media Query: the not keyword' + status: supported + '#mq-only': + title: '2.2.2 Hiding a Media Query From Legacy user agents: the only keyword' + status: supported + '#media-types': + title: 2.3 Media Types + status: supported + '#mq-features': + title: 2.4 Media Features + status: partial + '#mq-ranges': + title: '2.4.1 Media Feature Types: “range” and “discrete”' + status: supported + '#mq-boolean-context': + title: 2.4.2 Evaluating Media Features in a Boolean Context + 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: 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: supported + '#media-conditions': + title: 2.5 Combining Media Features + 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: partial + gaps: + - 'Uppercase units aren''t recognized, see mediaqueries-4 #mq-syntax' + '#evaluating': + title: 3.1 Evaluating Media Queries + status: supported + '#error-handling': + title: 3.2 Error Handling + 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: partial + '#width': + title: '4.1 Width: the width feature' + status: supported + '#height': + title: '4.2 Height: the height feature' + status: supported + '#aspect-ratio': + title: '4.3 Aspect-Ratio: the aspect-ratio feature' + status: supported + '#orientation': + title: '4.4 Orientation: the orientation feature' + status: supported + '#mf-overflow-block': + title: '4.5 Block-Axis Overflow: the overflow-block feature' + status: unsupported + '#mf-overflow-inline': + title: '4.6 Inline-Axis Overflow: the overflow-inline feature' + status: unsupported + '#mf-horizontal-viewport-segments': + title: '4.7 Horizontal Viewport Segments: the horizontal-viewport-segments feature' + 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: unsupported + gaps: + - 'Unknown feature, (vertical-viewport-segments: 1) is false' + '#display-modes': + title: '4.9 Display Modes: the display-mode media feature' + status: unsupported + gaps: + - 'Unknown feature, (display-mode: browser) is false' + '#mf-display-quality': + title: 5 Display Quality Media Features + status: partial + '#resolution': + title: '5.1 Display Resolution: the resolution feature' + 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: supported + '#grid': + title: '5.3 Detecting Console Displays: the grid feature' + status: supported + '#update': + title: '5.4 Display Update Frequency: the update feature' + status: supported + '#environment-blending': + title: '5.5 Detecting the display technology: the environment-blending feature' + status: unsupported + gaps: + - 'Unknown feature, (environment-blending: opaque) is false' + '#mf-colors': + title: 6 Color Media Features + status: partial + '#color': + title: '6.1 Color Depth: the color feature' + status: supported + '#color-index': + title: '6.2 Paletted Color Screens: the color-index feature' + status: supported + '#monochrome': + title: '6.3 Monochrome Screens: the monochrome feature' + status: supported + '#color-gamut': + title: '6.4 Color Display Quality: the color-gamut feature' + status: unsupported + gaps: + - 'Unknown feature, see mediaqueries-4 #color-gamut' + '#dynamic-range': + title: '6.5 Dynamic Range: the dynamic-range feature' + 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: unsupported + '#inverted': + title: '6.6 Detecting inverted colors on the display: the inverted-colors feature' + status: unsupported + gaps: + - 'Unknown feature, (inverted-colors: none) is false' + '#mf-interaction': + title: 7 Interaction Media Features + status: partial + '#pointer': + title: '7.1 Pointing Device Quality: the pointer feature' + status: supported + '#hover': + title: '7.2 Hover Capability: the hover feature' + status: supported + '#any-input': + title: '7.3 All Available Interaction Capabilities: the any-pointer and any-hover features' + status: supported + '#nav-controls': + title: '7.4 Detecting UA-supplied navigation controls: the nav-controls feature' + status: unsupported + gaps: + - 'Unknown feature, (nav-controls: none) is false' + '#video-prefixed-features': + title: 8 Video Prefixed Features + status: unsupported + '#video-color-gamut': + title: '8.1 Video Color Display Quality: the video-color-gamut feature' + 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: unsupported + gaps: + - 'Unknown feature, (video-dynamic-range: standard) is false' + '#mf-scripting': + title: 9 Scripting Media Features + status: supported + '#scripting': + title: '9.1 Scripting Support: the scripting feature' + status: supported + '#custom-mq': + title: 10 Custom Media Queries + 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: n/a + '#custom-mq-cssom': + title: 11 CSSOM + status: n/a + '#mf-user-preferences': + title: 12 User Preference Media Features + status: partial + '#prefers-reduced-motion': + title: '12.1 Detecting the desire for less motion on the page: the prefers-reduced-motion feature' + status: supported + '#prefers-reduced-transparency': + title: '12.2 Detecting the desire for reduced transparency on the page: the prefers-reduced-transparency feature' + 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: 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: 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: 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: unsupported + gaps: + - 'Unknown feature, (prefers-reduced-data: no-preference) is false' + '#auto-pref': + title: 12.7 Automatic handling of User Preferences + status: n/a + '#script-control-user-prefs': + title: 13 Script Control of User Preferences + status: n/a diff --git a/Spec/motion-1.yml b/Spec/motion-1.yml new file mode 100644 index 000000000..ad6f5c7fd --- /dev/null +++ b/Spec/motion-1.yml @@ -0,0 +1,57 @@ +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: n/a + '#motion-paths-overview': + title: 2 Motion Paths + status: unsupported + gaps: + - No offset property is parsed + '#offset-path-property': + title: '2.1 Defining A Path: the offset-path property' + 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: unsupported + '#example-shape': + title: 2.1.2 Examples Of <basic-shape> Positioning + status: n/a + '#example-coord': + title: 2.1.3 Examples of <coord-box> Positioning + status: n/a + '#offset-distance-property': + title: '2.2 Position On The Path: the offset-distance property' + status: unsupported + '#path-distance': + title: 2.2.1 Calculating the computed distance along a path + status: unsupported + '#offset-position-property': + title: '2.3 Starting Point Of The Path: the offset-position property' + status: unsupported + '#offset-anchor-property': + title: '2.4 The Element’s Anchor Point: the offset-anchor property' + status: unsupported + '#offset-rotate-property': + title: '2.5 Rotating To Match The Path: the offset-rotate property' + status: unsupported + '#offset-shorthand': + title: 2.6 The offset Shorthand + status: unsupported + '#transform': + title: 2.7 Calculating The Offset Transform + status: unsupported + '#paths': + title: 3 Equivalent Paths For <basic-shape> + status: unsupported + '#privacy': + title: 4 Privacy Considerations + status: n/a + '#security': + title: 5 Security Considerations + status: n/a diff --git a/Spec/resize-observer-1.yml b/Spec/resize-observer-1.yml new file mode 100644 index 000000000..699df5d2b --- /dev/null +++ b/Spec/resize-observer-1.yml @@ -0,0 +1,14 @@ +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: n/a + '#api': + title: 2 Resize Observer API + status: n/a + '#processing-model': + title: 3 Processing Model + status: n/a diff --git a/Spec/selectors-4.yml b/Spec/selectors-4.yml new file mode 100644 index 000000000..97ac902b2 --- /dev/null +++ b/Spec/selectors-4.yml @@ -0,0 +1,433 @@ +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: n/a + '#overview': + title: 2 Selectors Overview + status: n/a + '#syntax': + title: 3 Selector Syntax and Structure + status: partial + '#structure': + title: 3.1 Structure and Terminology + status: supported + '#data-model': + title: 3.2 Data Model + status: n/a + '#scoping': + title: 3.3 Scoped Selectors + status: n/a + '#relative': + title: 3.4 Relative Selectors + status: partial + gaps: + - "A leading combinator is dropped, ':has(> p)' matches like ':has(p)'" + '#pseudo-classes': + title: 3.5 Pseudo-classes + status: partial + gaps: + - Pseudo-class names are case-sensitive, see CSS 2.1 5.10 + '#pseudo-elements': + title: 3.6 Pseudo-elements + status: partial + '#pseudo-element-syntax': + title: 3.6.1 Syntax + 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: 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: 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: 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: 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: 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: n/a + '#invalid': + title: 3.9 Invalid Selectors and Error Handling + 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: supported + '#logical-combination': + title: 4 Logical Combinations + status: partial + '#grouping': + title: 4.1 Selector Lists + status: supported + '#matches': + title: '4.2 The Matches-Any Pseudo-class: :is()' + 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: partial + gaps: + - ":not() inside :not() invalidates the selector" + '#zero-matches': + title: '4.4 The Specificity-adjustment Pseudo-class: :where()' + 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: 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: partial + '#type-selectors': + title: 5.1 Type (tag name) selector + status: supported + '#the-universal-selector': + title: 5.2 Universal selector + status: supported + '#type-nmsp': + title: 5.3 Namespaces in Elemental Selectors + 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: unsupported + gaps: + - ":defined invalidates the selector, see HTML 4.16.3" + '#attribute-selectors': + title: 6 Attribute selectors + status: partial + '#attribute-representation': + title: 6.1 Attribute presence and value selectors + 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: 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: unsupported + gaps: + - "The i and s flags, e.g. [title=foo i], invalidate the selector" + '#attrnmsp': + title: 6.4 Attribute selectors and namespaces + 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: n/a + '#class-html': + title: 6.6 Class selectors + 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: partial + gaps: + - IDs are matched case-insensitively, see CSS 2.1 5.9 + '#linguistic-pseudos': + title: 7 Linguistic Pseudo-classes + status: unsupported + '#the-dir-pseudo': + title: '7.1 The Directionality Pseudo-class: :dir()' + status: unsupported + gaps: + - ":dir() never matches, see HTML 4.16.3" + '#the-lang-pseudo': + title: '7.2 The Language Pseudo-class: :lang()' + 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: partial + '#the-any-link-pseudo': + title: '8.1 The Hyperlink Pseudo-class: :any-link' + status: unsupported + gaps: + - ":any-link never matches" + '#link': + title: '8.2 The Link History Pseudo-classes: :link and :visited' + status: supported + '#the-target-pseudo': + title: '8.3 The Target Pseudo-class: :target' + status: supported + '#the-scope-pseudo': + title: '8.4 The Reference Element Pseudo-class: :scope' + status: unsupported + gaps: + - ":scope never matches, it should match the root element" + '#useraction-pseudos': + title: 9 User Action Pseudo-classes + status: unsupported + '#the-hover-pseudo': + title: '9.1 The Pointer Hover Pseudo-class: :hover' + 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: 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: 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: unsupported + gaps: + - ":focus-visible never matches" + '#the-focus-within-pseudo': + title: '9.5 The Focus Container Pseudo-class: :focus-within' + status: unsupported + gaps: + - ":focus-within never matches" + '#resource-pseudos': + title: 10 Resource State Pseudo-classes + status: unsupported + '#video-state': + title: '10.1 Media Playback State: the :playing, :paused, and :seeking pseudo-classes' + 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: unsupported + gaps: + - ":buffering and :stalled invalidate the selector" + '#sound-state': + title: '10.3 Sound State: the :muted and :volume-locked pseudo-classes' + 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: unsupported + '#open-state': + title: '11.1 Collapse State: the :open pseudo-class' + 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: unsupported + gaps: + - ":popover-open invalidates the selector" + '#modal-state': + title: '11.3 Modal (Exclusive Interaction) State: the :modal pseudo-class' + status: unsupported + gaps: + - ":modal invalidates the selector" + '#fullscreen-state': + title: '11.4 Fullscreen Presentation State: the :fullscreen pseudo-class' + status: unsupported + gaps: + - ":fullscreen invalidates the selector" + '#pip-state': + title: '11.5 Picture-in-Picture Presentation State: the :picture-in-picture pseudo-class' + status: unsupported + gaps: + - ":picture-in-picture invalidates the selector" + '#input-pseudos': + title: 12 The Input Pseudo-classes + status: unsupported + '#input-states': + title: 12.1 Input Control States + status: unsupported + '#enableddisabled': + title: 12.1.1 The :enabled and :disabled Pseudo-classes + 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: 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: unsupported + gaps: + - ":placeholder-shown never matches, see HTML 4.16.3" + '#autofill': + title: '12.1.4 The Automatic Input Pseudo-class: :autofill' + status: unsupported + gaps: + - ":autofill and :-webkit-autofill invalidate the selector" + '#the-default-pseudo': + title: '12.1.5 The Default-option Pseudo-class: :default' + status: unsupported + gaps: + - ":default never matches, see HTML 4.16.3" + '#input-value-states': + title: 12.2 Input Value States + status: unsupported + '#checked': + title: '12.2.1 The Selected-option Pseudo-classes: :checked, :unchecked, and :indeterminate' + status: unsupported + gaps: + - ":checked, :unchecked and :indeterminate never match, see HTML 4.16.3" + '#ui-validity': + title: 12.3 Input Value-checking + status: unsupported + '#validity-pseudos': + title: '12.3.1 The Validity Pseudo-classes: :valid and :invalid' + 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: 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: 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: unsupported + gaps: + - ":user-valid and :user-invalid invalidate the selector" + '#structural-pseudos': + title: 13 Tree-Structural pseudo-classes + status: partial + '#the-root-pseudo': + title: 13.1 :root pseudo-class + status: supported + '#the-empty-pseudo': + title: 13.2 :empty pseudo-class + status: unsupported + gaps: + - ":empty never matches" + '#child-index': + title: 13.3 Child-indexed Pseudo-classes + 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: 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: supported + '#the-first-child-pseudo': + title: 13.3.3 :first-child pseudo-class + status: supported + '#the-last-child-pseudo': + title: 13.3.4 :last-child pseudo-class + status: supported + '#the-only-child-pseudo': + title: 13.3.5 :only-child pseudo-class + status: supported + '#typed-child-index': + title: 13.4 Typed Child-indexed Pseudo-classes + 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: supported + '#the-nth-last-of-type-pseudo': + title: 13.4.2 :nth-last-of-type() pseudo-class + status: supported + '#the-first-of-type-pseudo': + title: 13.4.3 :first-of-type pseudo-class + status: supported + '#the-last-of-type-pseudo': + title: 13.4.4 :last-of-type pseudo-class + status: supported + '#the-only-of-type-pseudo': + title: 13.4.5 :only-of-type pseudo-class + status: supported + '#combinators': + title: 14 Combinators + status: partial + '#descendant-combinators': + title: 14.1 Descendant combinator ( ) + status: partial + gaps: + - "Combinator chains don't backtrack, see CSS 2.1 5.2" + '#child-combinators': + title: 14.2 Child combinator (>) + status: supported + '#adjacent-sibling-combinators': + title: 14.3 Next-sibling combinator (+) + status: supported + '#general-sibling-combinators': + title: 14.4 Subsequent-sibling combinator (~) + status: supported + '#specificity-rules': + title: 15 Calculating a selector’s specificity + 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: partial + gaps: + - "Attribute modifiers are rejected, see 6.3" + '#forgiving-selector': + title: 16.1 <forgiving-selector-list> and <forgiving-relative-selector-list> + 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: n/a + '#changes': + title: 18 Changes + status: n/a + '#acknowledgements': + title: 19 Acknowledgements + status: n/a diff --git a/Spec/web-animations-1.yml b/Spec/web-animations-1.yml new file mode 100644 index 000000000..20a47631a --- /dev/null +++ b/Spec/web-animations-1.yml @@ -0,0 +1,183 @@ +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: n/a + '#spec-conventions': + title: 2 Specification conventions + status: n/a + '#web-animations-model-overview': + title: 3 Web Animations model overview + status: n/a + '#timing-model': + title: 4 Timing model + 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: n/a + '#time-value-section': + title: 4.2 Time values + status: n/a + '#timelines': + title: 4.3 Timelines + status: n/a + '#animations': + title: 4.4 Animations + status: n/a + '#animation-effects': + title: 4.5 Animation effects + status: unsupported + '#animation-effects-and-animations': + title: 4.5.1 Relationship between animation effects and animations + status: unsupported + '#types-of-animation-effects': + title: 4.5.2 Types of animation effects + status: unsupported + '#the-active-interval': + title: 4.5.3 The active interval + status: unsupported + '#local-time-section': + title: 4.5.4 Local time + status: unsupported + '#animation-effect-phases-and-states': + title: 4.5.5 Animation effect phases and states + status: unsupported + '#relevant-animations-section': + title: 4.5.6 Relevant animations + status: n/a + '#fill-behavior': + title: 4.6 Fill behavior + status: unsupported + '#fill-modes': + title: 4.6.1 Fill modes + status: unsupported + '#repeating': + title: 4.7 Repeating + status: unsupported + '#iteration-intervals': + title: 4.7.1 Iteration intervals + status: unsupported + '#controlling-iteration': + title: 4.7.2 Controlling iteration + status: unsupported + '#iteration-time-space': + title: 4.7.3 Iteration time space + status: unsupported + '#interval-timing': + title: 4.7.4 Interval timing + status: unsupported + '#core-animation-effect-calculations': + title: 4.8 Core animation effect calculations + status: unsupported + '#animation-effect-calculations-overview': + title: 4.8.1 Overview + status: n/a + '#calculating-the-active-duration': + title: 4.8.2 Calculating the active duration + status: unsupported + '#transforming-the-local-time': + title: 4.8.3 Transforming the local time + status: unsupported + '#calculating-the-active-time': + title: 4.8.3.1 Calculating the active time + status: unsupported + '#calculating-the-overall-progress': + title: 4.8.3.2 Calculating the overall progress + status: unsupported + '#calculating-the-simple-iteration-progress': + title: 4.8.3.3 Calculating the simple iteration progress + status: unsupported + '#calculating-the-current-iteration': + title: 4.8.4 Calculating the current iteration + status: unsupported + '#direction-control': + title: 4.9 Direction control + status: unsupported + '#calculating-the-directed-progress': + title: 4.9.1 Calculating the directed progress + status: unsupported + '#time-transformations': + title: 4.10 Time transformations + status: unsupported + '#calculating-the-transformed-progress': + title: 4.10.1 Calculating the transformed progress + status: unsupported + '#the-iteration-progress': + title: 4.11 The iteration progress + status: unsupported + '#animation-model': + title: 5 Animation model + 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: n/a + '#animating-properties': + title: 5.2 Animating properties + status: unsupported + '#custom-properties': + title: 5.2.1 Custom Properties + status: unsupported + '#keyframe-effects': + title: 5.3 Keyframe effects + status: unsupported + '#keyframes-section': + title: 5.3.1 Keyframes + status: unsupported + '#computing-property-values': + title: 5.3.2 Computing property values + status: unsupported + '#calculating-computed-keyframes': + title: 5.3.3 Calculating computed keyframes + status: unsupported + '#the-effect-value-of-a-keyframe-animation-effect': + title: 5.3.4 The effect value of a keyframe effect + status: unsupported + '#combining-effects': + title: 5.4 Combining effects + status: unsupported + '#animation-classes': + title: 5.4.1 Animation classes + status: unsupported + '#the-effect-stack': + title: 5.4.2 The effect stack + status: unsupported + '#calculating-the-result-of-an-effect-stack': + title: 5.4.3 Calculating the result of an effect stack + status: unsupported + '#effect-composition': + title: 5.4.4 Effect composition + status: unsupported + '#applying-the-composited-result': + title: 5.4.5 Applying the composited result + status: unsupported + '#replacing-animations': + title: 5.5 Replacing animations + status: n/a + '#side-effects-section': + title: 5.6 Side effects of animation + status: n/a + '#programming-interface': + title: 6 Programming interface + status: n/a + '#integration-with-media-fragments': + title: 7 Integration with Media Fragments + status: n/a + '#interaction-with-page-display': + title: 8 Interaction with page display + status: n/a + '#implementation-requirements': + title: 9 Implementation requirements + status: n/a + '#acknowledgements': + title: 10 Acknowledgements + status: n/a + '#changes-since-last-publication': + title: 11 Changes since last publication + status: n/a