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 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 \"\".")]
+ [TestMethod]
+ public void DoubleEscapedEntitiesInPreElement_RenderCorrectly()
+ {
+ // is white-space:pre via the UA default stylesheet - "<html>" 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("<html> "));
+ var p = LayoutHarness.FindById(root, "p")!;
+
+ Assert.AreEqual("<html>", JoinWordsPre(p));
+ }
+
+ [Ignore("The single-escaped portions (\"&\" -> \"&\") are fine, but the double-escaped \"&\" " +
+ "portion gets decoded twice (see class remarks), ending up as a plain \"&\" instead of the " +
+ "literal \"&\" this asserts.")]
+ [TestMethod]
+ public void MixedEntitiesInParagraph_RenderCorrectly()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("A & B & C
"));
+ var p = LayoutHarness.FindById(root, "p")!;
+
+ Assert.AreEqual("A & B & C", JoinWordsNormal(p));
+ }
+
+ #endregion
+
+ #region CSS Content Strings
+
+ [Ignore("Requires ::before/::after pseudo-elements with a CSS content: property - this fork has no " +
+ "pseudo-element support at all (confirmed: no \"::before\"/\"::after\"/pseudo-element handling " +
+ "anywhere in Core, only :link/:hover pseudo-CLASSES are recognized).")]
+ [TestMethod]
+ public void CssContentWithCssEscape_RendersLiterally()
+ {
+ const string html = "" +
+ "text
";
+ var (root, _) = LayoutHarness.Layout(html);
+ var p = LayoutHarness.FindById(root, "p")!;
+
+ var beforeBox = p.Boxes.FirstOrDefault(b => b.HtmlTag == null && b.Text != null);
+ Assert.IsNotNull(beforeBox);
+ Assert.AreEqual("&", beforeBox!.Text);
+ }
+
+ [Ignore("Requires ::before/::after pseudo-elements with a CSS content: property - this fork has no " +
+ "pseudo-element support at all (confirmed: no \"::before\"/\"::after\"/pseudo-element handling " +
+ "anywhere in Core, only :link/:hover pseudo-CLASSES are recognized).")]
+ [TestMethod]
+ public void CssContentWithCssEscapeInString_RendersLiterally()
+ {
+ const string html = "" +
+ "text
";
+ var (root, _) = LayoutHarness.Layout(html);
+ var p = LayoutHarness.FindById(root, "p")!;
+
+ var afterBox = p.Boxes.FirstOrDefault(b => b.HtmlTag == null && b.Text != null);
+ Assert.IsNotNull(afterBox);
+ Assert.IsTrue(afterBox!.Text!.Contains('<'));
+ Assert.IsTrue(afterBox.Text!.Contains('>'));
+ }
+
+ #endregion
+
+ #region Edge Cases
+
+ [Ignore("Double-decoded (see class remarks): \" \" is already literal \" \" by the time " +
+ "ParseToWords runs, and gets decoded again into a plain space.")]
+ [TestMethod]
+ 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(" test
"));
+ var p = LayoutHarness.FindById(root, "p")!;
+
+ 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.")]
+ [TestMethod]
+ public void MultipleDoubleEscapedEntities_InSentence()
+ {
+ var (root, _) = LayoutHarness.Layout(
+ LayoutHarness.Wrap("Entities: <, >, &,
"));
+ var p = LayoutHarness.FindById(root, "p")!;
+
+ Assert.AreEqual("Entities: <, >, &, ", JoinWordsNormal(p));
+ }
+
+ [Ignore("Confirmed real (if convoluted) two-layer decode, empirically verified: raw \"&nbsp;\" -> " +
+ "HtmlKit's tokenizer decodes the FIRST \"&\" (greedy, single pass) to \"&\", leaving " +
+ "box.Text = \" \" (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 (\" \") this asserts.")]
+ [TestMethod]
+ public void TripleEscapedEntity_RendersWithTwoLevels()
+ {
+ // &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 - " ".
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("&nbsp;
"));
+ var p = LayoutHarness.FindById(root, "p")!;
+
+ Assert.AreEqual(" ", JoinWordsNormal(p));
+ }
+
+ [Ignore("HtmlUtils.DecodeHtml is only ever invoked from CssBox.ParseToWords (text-node word content) - " +
+ "grep confirms no other call site in Core decodes attribute values, so entities inside an " +
+ "attribute (e.g. title='A & B') stay literally undecoded rather than becoming 'A & B'.")]
+ [TestMethod]
+ public void EntityInAttributeValue_DecodedCorrectly()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("text
"));
+ var p = LayoutHarness.FindById(root, "p")!;
+
+ var title = p.HtmlTag?.TryGetAttribute("title", "");
+ Assert.AreEqual("A & B", title);
+ }
+
+ #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.
+ 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));
+
+ private static string JoinWordsPre(CssBox box) => string.Concat(AllWords(box).Select(w => w.Text));
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs
new file mode 100644
index 000000000..b2f74ba8d
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/LinkPseudoClassIntegrationTests.cs
@@ -0,0 +1,100 @@
+using HtmlRenderer.IntegrationTest.TestSupport;
+using TheArtOfDev.HtmlRenderer.Core.Dom;
+
+namespace HtmlRenderer.IntegrationTest.Text;
+
+///
+/// Verifies :link /:hover pseudo-class handling, and locks in the resulting gap that
+/// :visited /: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]
+ public void Link_MatchesAnchorWithHref()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(
+ "link "));
+ var a = FindByTag(root, "a")!;
+
+ Assert.AreEqual("rgb(10, 20, 30)", a.Color);
+ }
+
+ [TestMethod]
+ public void Link_DoesNotMatchAnchorWithoutHref()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(
+ "not a link "));
+ var a = FindByTag(root, "a")!;
+
+ Assert.AreNotEqual("rgb(10, 20, 30)", a.Color);
+ }
+
+ [TestMethod]
+ public void Link_DoesNotMatchNonAnchorElement()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(
+ "not an anchor "));
+ var s = LayoutHarness.FindById(root, "s")!;
+
+ Assert.AreNotEqual("rgb(10, 20, 30)", s.Color);
+ }
+
+ [TestMethod]
+ public void Visited_NeverMatches_ByDesign()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(
+ "link "));
+ var a = FindByTag(root, "a")!;
+
+ Assert.AreNotEqual("rgb(10, 20, 30)", a.Color);
+ }
+
+ [TestMethod]
+ public void Active_NeverMatches_ByDesign()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(
+ "link "));
+ var a = FindByTag(root, "a")!;
+
+ Assert.AreNotEqual("rgb(10, 20, 30)", a.Color);
+ }
+
+ // ─── Helpers ─────────────────────────────────────────────────────────────
+
+ private static CssBox? FindByTag(CssBox box, string tag)
+ {
+ if (box.HtmlTag?.Name.Equals(tag, System.StringComparison.OrdinalIgnoreCase) == true)
+ return box;
+ foreach (var child in box.Boxes)
+ {
+ var found = FindByTag(child, tag);
+ if (found != null) return found;
+ }
+ return null;
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs
new file mode 100644
index 000000000..2cabe0dd8
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/SmallCapsIntegrationTests.cs
@@ -0,0 +1,182 @@
+using System.Linq;
+using HtmlRenderer.IntegrationTest.TestSupport;
+using TheArtOfDev.HtmlRenderer.Core.Dom;
+
+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.
+[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.
+ private static CssBox FindWordsBox(CssBox root, string id)
+ {
+ var element = LayoutHarness.FindById(root, id)!;
+ if (element.Words.Count > 0) return element;
+
+ var wordsChild = element.Boxes.FirstOrDefault(b => b.Words.Count > 0);
+ Assert.IsNotNull(wordsChild, $"no descendant of #{id} owns any words");
+ return wordsChild!;
+ }
+
+ [TestMethod]
+ public void FontVariant_SmallCaps_StandaloneProperty_ParsesAndStores()
+ {
+ var (root, _) = LayoutHarness.Layout(
+ LayoutHarness.Wrap("Hello "));
+ var w = LayoutHarness.FindById(root, "w")!;
+
+ Assert.AreEqual("small-caps", w.FontVariant);
+ }
+
+ [TestMethod]
+ public void FontVariant_SmallCaps_ViaFontShorthand_ParsesAndStores()
+ {
+ var (root, _) = LayoutHarness.Layout(
+ LayoutHarness.Wrap("Hello "));
+ var w = LayoutHarness.FindById(root, "w")!;
+
+ 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.")]
+ [TestMethod]
+ 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.
+ Assert.AreEqual(2, box.Words.Count);
+ Assert.AreEqual("H", box.Words[0].Text);
+ Assert.AreEqual("ELLO", box.Words[1].Text);
+ }
+
+ [TestMethod]
+ public void NoSmallCaps_WordIsNotSplit_Regression()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("Hello "));
+ var box = FindWordsBox(root, "w");
+
+ Assert.AreEqual(1, box.Words.Count);
+ Assert.AreEqual("Hello", box.Words[0].Text);
+ }
+
+ [TestMethod]
+ public void SmallCaps_WordWithNoLowercaseLetters_IsNotSplit()
+ {
+ var (root, _) = LayoutHarness.Layout(
+ LayoutHarness.Wrap("ABC "));
+ var box = FindWordsBox(root, "w");
+
+ Assert.AreEqual(1, box.Words.Count);
+ 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. ────────────────────────────────────────────
+
+ [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");
+
+ Assert.AreEqual(1, box.Words.Count);
+ Assert.AreEqual("ABC", box.Words[0].Text);
+ }
+
+ [TestMethod]
+ public void AllSmallCaps_WordWithNoLetters_IsNotSplit()
+ {
+ var (root, _) = LayoutHarness.Layout(
+ LayoutHarness.Wrap("123 "));
+ var box = FindWordsBox(root, "w");
+
+ Assert.AreEqual(1, box.Words.Count);
+ 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.")]
+ [TestMethod]
+ public void AllSmallCaps_MixedCaseWord_WouldSplitIntoThreeRuns()
+ {
+ var (root, _) = LayoutHarness.Layout(
+ LayoutHarness.Wrap("AbC "));
+ var box = FindWordsBox(root, "w");
+
+ Assert.AreEqual(3, box.Words.Count);
+ Assert.AreEqual("A", box.Words[0].Text);
+ Assert.AreEqual("B", box.Words[1].Text);
+ 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.")]
+ [TestMethod]
+ public void AllSmallCaps_DigitRunBetweenLowercaseRuns_WouldSplitIntoThreeRuns()
+ {
+ var (root, _) = LayoutHarness.Layout(
+ LayoutHarness.Wrap("a1b "));
+ var box = FindWordsBox(root, "w");
+
+ Assert.AreEqual(3, box.Words.Count);
+ Assert.AreEqual("A", box.Words[0].Text);
+ Assert.AreEqual("1", box.Words[1].Text);
+ Assert.AreEqual("B", box.Words[2].Text);
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/StyleElementTextConcatenationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/StyleElementTextConcatenationTests.cs
new file mode 100644
index 000000000..dd247cd1d
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/StyleElementTextConcatenationTests.cs
@@ -0,0 +1,81 @@
+using HtmlRenderer.IntegrationTest.TestSupport;
+
+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.
+[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]
+ public void RuleAfterLessThanInDeclaration_StillApplies()
+ {
+ const string html = """
+
+ a
b
+
+ """;
+
+ var (root, _) = LayoutHarness.Layout(html);
+ var b = LayoutHarness.FindById(root, "b")!;
+
+ Assert.IsNotNull(b);
+ // Without concatenation, "#b { color: green }" would need to land in a mis-parsed fragment starting
+ // at '<' and never apply, leaving the default "black" - see class remarks for what actually happens
+ // now (a crash, not a silent non-application).
+ Assert.AreEqual("green", b.Color);
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs
new file mode 100644
index 000000000..0e0cc6ac6
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/TextAlignStartEndIntegrationTests.cs
@@ -0,0 +1,112 @@
+using System.Linq;
+using HtmlRenderer.IntegrationTest.TestSupport;
+using TheArtOfDev.HtmlRenderer.Core.Dom;
+
+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
+{
+ private const double Delta = 1.0;
+
+ private static CssRect FirstWord(CssBox box) =>
+ LayoutHarness.Descendants(box).SelectMany(b => b.Words).First(w => !w.IsSpaces);
+
+ [TestMethod]
+ public void Start_InLtrBlock_PacksTextAgainstTheLeftEdge()
+ {
+ var html = LayoutHarness.Wrap("hi
");
+
+ var (root, _) = LayoutHarness.Layout(html);
+ var p = LayoutHarness.FindById(root, "p")!;
+ var word = FirstWord(p);
+
+ 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.")]
+ [TestMethod]
+ public void Start_InRtlBlock_PacksTextAgainstTheRightEdge()
+ {
+ var html = LayoutHarness.Wrap("hi
");
+
+ var (root, _) = LayoutHarness.Layout(html);
+ var p = LayoutHarness.FindById(root, "p")!;
+ var word = FirstWord(p);
+
+ 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.")]
+ [TestMethod]
+ public void End_InLtrBlock_PacksTextAgainstTheRightEdge()
+ {
+ var html = LayoutHarness.Wrap("hi
");
+
+ var (root, _) = LayoutHarness.Layout(html);
+ var p = LayoutHarness.FindById(root, "p")!;
+ var word = FirstWord(p);
+
+ Assert.AreEqual(p.ClientRight, word.Right, Delta);
+ }
+
+ [TestMethod]
+ public void End_InRtlBlock_PacksTextAgainstTheLeftEdge()
+ {
+ var html = LayoutHarness.Wrap("hi
");
+
+ var (root, _) = LayoutHarness.Layout(html);
+ var p = LayoutHarness.FindById(root, "p")!;
+ var word = FirstWord(p);
+
+ Assert.AreEqual(p.ClientLeft, word.Left, Delta);
+ }
+
+ [TestMethod]
+ public void DefaultsToStart_UnsetTextAlign_BehavesLikeLeftInLtr()
+ {
+ var html = LayoutHarness.Wrap("hi
");
+
+ var (root, _) = LayoutHarness.Layout(html);
+ var p = LayoutHarness.FindById(root, "p")!;
+ var word = FirstWord(p);
+
+ Assert.AreEqual(p.ClientLeft, word.Left, Delta);
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs
new file mode 100644
index 000000000..7aaebb126
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/VerticalAlignIntegrationTests.cs
@@ -0,0 +1,291 @@
+using System.Linq;
+using HtmlRenderer.IntegrationTest.TestSupport;
+
+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.
+[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
+ // with the *parent's* font box, and if the aligned box were taller than that reference box, "top"
+ // and "bottom" alignment could legitimately cross over.
+ var lineHeightDecl = lineHeight is null ? "" : $"; line-height:{lineHeight}";
+ var html = LayoutHarness.Wrap(
+ "TALL " +
+ $"small
");
+
+ var (root, _) = LayoutHarness.Layout(html);
+ var v = LayoutHarness.FindById(root, "v")!;
+ return LayoutHarness.Descendants(v).SelectMany(b => b.Words).First().Top;
+ }
+
+ [Ignore(InlineNoOpReason)]
+ [TestMethod]
+ public void Top_PositionsHigherThanBottom()
+ {
+ var topY = GetAlignedTop("top");
+ var bottomY = GetAlignedTop("bottom");
+
+ Assert.IsTrue(topY < bottomY, $"expected top-aligned span ({topY}) to sit above bottom-aligned span ({bottomY})");
+ }
+
+ [Ignore(InlineNoOpReason)]
+ [TestMethod]
+ public void Middle_PositionsBetweenTopAndBottom()
+ {
+ var topY = GetAlignedTop("top");
+ var bottomY = GetAlignedTop("bottom");
+ var middleY = GetAlignedTop("middle");
+
+ Assert.IsTrue(middleY > topY && middleY < bottomY);
+ }
+
+ [Ignore(InlineNoOpReason)]
+ [TestMethod]
+ public void TextTop_PositionsAboveTextBottom()
+ {
+ var textTopY = GetAlignedTop("text-top");
+ var textBottomY = GetAlignedTop("text-bottom");
+
+ Assert.IsTrue(textTopY < textBottomY, $"top={textTopY} bottom={textBottomY}");
+ }
+
+ [Ignore(InlineNoOpReason)]
+ [TestMethod]
+ public void Sub_PositionsBelowSuper()
+ {
+ var subY = GetAlignedTop("sub");
+ var superY = GetAlignedTop("super");
+
+ Assert.IsTrue(subY > superY, $"sub={subY} super={superY}");
+ }
+
+ [Ignore(InlineNoOpReason)]
+ [TestMethod]
+ public void Bottom_DiffersFromDefaultBaselineAlignment()
+ {
+ var bottomY = GetAlignedTop("bottom");
+ var baselineY = GetAlignedTop("baseline");
+
+ Assert.AreNotEqual(baselineY, bottomY);
+ }
+
+ [Ignore(InlineNoOpReason)]
+ [TestMethod]
+ public void Middle_DiffersFromDefaultBaselineAlignment()
+ {
+ var middleY = GetAlignedTop("middle");
+ var baselineY = GetAlignedTop("baseline");
+
+ Assert.AreNotEqual(baselineY, middleY);
+ }
+
+ [Ignore(InlineNoOpReason)]
+ [TestMethod]
+ public void TextTop_ReferencesParentFontAscent_NotJustLineTop()
+ {
+ // 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 (rootSmall, _) = LayoutHarness.Layout(htmlSmallParent);
+ var (rootLarge, _) = LayoutHarness.Layout(htmlLargeParent);
+
+ 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;
+
+ Assert.AreNotEqual(ySmall, yLarge);
+ }
+
+ [TestMethod]
+ 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(
+ ""
+ + "Tall "
+ + "Short "
+ + "
");
+ var htmlBottom = LayoutHarness.Wrap(
+ ""
+ + "Tall "
+ + "Short "
+ + "
");
+
+ var (rootTop, _) = LayoutHarness.Layout(htmlTop);
+ var (rootBottom, _) = LayoutHarness.Layout(htmlBottom);
+
+ var topY = LayoutHarness.Descendants(LayoutHarness.FindById(rootTop, "v")!).SelectMany(b => b.Words).First().Top;
+ var bottomY = LayoutHarness.Descendants(LayoutHarness.FindById(rootBottom, "v")!).SelectMany(b => b.Words).First().Top;
+
+ Assert.IsTrue(bottomY > topY,
+ $"vertical-align:bottom ({bottomY}) should push the cell's content lower than vertical-align:top ({topY})");
+ }
+
+ [TestMethod]
+ public void Middle_OnATableCellWithExplicitHeight_CentersShortContent()
+ {
+ var htmlTop = LayoutHarness.Wrap(
+ ""
+ + "Tall "
+ + "Short "
+ + "
");
+ var htmlMiddle = LayoutHarness.Wrap(
+ ""
+ + "Tall "
+ + "Short "
+ + "
");
+ var htmlBottom = LayoutHarness.Wrap(
+ ""
+ + "Tall "
+ + "Short "
+ + "
");
+
+ var (rootTop, _) = LayoutHarness.Layout(htmlTop);
+ var (rootMiddle, _) = LayoutHarness.Layout(htmlMiddle);
+ var (rootBottom, _) = LayoutHarness.Layout(htmlBottom);
+
+ var topY = LayoutHarness.Descendants(LayoutHarness.FindById(rootTop, "v")!).SelectMany(b => b.Words).First().Top;
+ var middleY = LayoutHarness.Descendants(LayoutHarness.FindById(rootMiddle, "v")!).SelectMany(b => b.Words).First().Top;
+ var bottomY = LayoutHarness.Descendants(LayoutHarness.FindById(rootBottom, "v")!).SelectMany(b => b.Words).First().Top;
+
+ 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.")]
+ [TestMethod]
+ public void Length_PositiveValue_RaisesTheBoxAboveBaseline()
+ {
+ // CSS 2.1 §10.8.1: a positive length raises the box by that distance from its own baseline.
+ var baselineY = GetAlignedTop("baseline");
+ var raisedY = GetAlignedTop("5px");
+
+ Assert.IsTrue(raisedY < baselineY, $"raised={raisedY} baseline={baselineY}");
+ }
+
+ [Ignore(InlineNoOpReason + " Numeric/percentage lengths have no case at all in the switch, so they fall " +
+ "to the same no-op default path.")]
+ [TestMethod]
+ public void Length_NegativeValue_LowersTheBoxBelowBaseline()
+ {
+ var baselineY = GetAlignedTop("baseline");
+ var loweredY = GetAlignedTop("-5px");
+
+ 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.")]
+ [TestMethod]
+ public void Percentage_PositiveValue_RaisesTheBoxAboveBaseline()
+ {
+ var baselineY = GetAlignedTop("baseline");
+ var raisedY = GetAlignedTop("50%");
+
+ 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.")]
+ [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);
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs
new file mode 100644
index 000000000..8a2829e4d
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/WhiteSpaceLayoutIntegrationTests.cs
@@ -0,0 +1,148 @@
+using System.Linq;
+using HtmlRenderer.IntegrationTest.TestSupport;
+
+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
+{
+ [TestMethod]
+ public void Pre_PreservesMultipleConsecutiveSpacesAsLiteralWord()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("A B
"));
+ var p = LayoutHarness.FindById(root, "p")!;
+ var words = p.LineBoxes[0].Words;
+
+ Assert.IsTrue(words.Any(w => w.Text == " "));
+ }
+
+ [TestMethod]
+ public void Normal_CollapsesConsecutiveSpaces_NoLiteralSpaceWord()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("A B
"));
+ var p = LayoutHarness.FindById(root, "p")!;
+ var words = p.LineBoxes[0].Words;
+
+ Assert.IsFalse(words.Any(w => w.Text != null && w.Text.Length > 0 && w.Text.All(char.IsWhiteSpace)));
+ }
+
+ [TestMethod]
+ public void Pre_TreatsExplicitNewlineAsForcedLineBreak()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("A\nB
"));
+ var p = LayoutHarness.FindById(root, "p")!;
+
+ Assert.AreEqual(2, p.LineBoxes.Count);
+ }
+
+ [TestMethod]
+ public void Normal_IgnoresEmbeddedNewline_NoForcedBreak()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("A\nB
"));
+ var p = LayoutHarness.FindById(root, "p")!;
+
+ Assert.AreEqual(1, p.LineBoxes.Count);
+ }
+
+ [TestMethod]
+ public void NoWrap_PreventsWrapping_EvenWhenNarrowerThanContent()
+ {
+ var html = LayoutHarness.Wrap("a long run of unwrapped text here
");
+ var (root, _) = LayoutHarness.Layout(html);
+ var p = LayoutHarness.FindById(root, "p")!;
+
+ Assert.AreEqual(1, p.LineBoxes.Count);
+ }
+
+ [TestMethod]
+ public void Normal_WrapsAtNarrowWidth_ForContrastWithNoWrap()
+ {
+ var html = LayoutHarness.Wrap("a long run of unwrapped text here
");
+ var (root, _) = LayoutHarness.Layout(html);
+ var p = LayoutHarness.FindById(root, "p")!;
+
+ 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) ───────────
+
+ [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]
+ public void Nbsp_OnlyContent_ProducesNonZeroHeight_MatchingRealText()
+ {
+ var (nbspRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
"));
+ var (textRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap("A
"));
+ var nbspBox = LayoutHarness.FindById(nbspRoot, "b")!;
+ var textBox = LayoutHarness.FindById(textRoot, "b")!;
+
+ var nbspHeight = nbspBox.ActualBottom - nbspBox.Location.Y;
+ var textHeight = textBox.ActualBottom - textBox.Location.Y;
+
+ Assert.IsTrue(nbspHeight > 0, $"Expected non-zero height for nbsp-only content, got {nbspHeight}");
+ Assert.IsTrue(nbspHeight >= textHeight - 1 && nbspHeight <= textHeight + 1);
+ }
+
+ [TestMethod]
+ public void OrdinaryWhitespaceOnlyContent_StillProducesZeroHeight_NoRegression()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
"));
+ var box = LayoutHarness.FindById(root, "b")!;
+
+ var height = box.ActualBottom - box.Location.Y;
+ 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.")]
+ [TestMethod]
+ 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.
+ var (nbspRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap("10 km
"));
+ var pNbsp = LayoutHarness.FindById(nbspRoot, "p")!;
+
+ var (spaceRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap("10 km
"));
+ var pSpace = LayoutHarness.FindById(spaceRoot, "p")!;
+
+ Assert.AreEqual(1, pNbsp.LineBoxes.Count);
+ Assert.IsTrue(pSpace.LineBoxes.Count > 1,
+ "expected ordinary space to still allow wrapping, for contrast with nbsp");
+ }
+
+ // ─── word-break: break-all forces a mid-word break normal cannot find ──────
+
+ [TestMethod]
+ public void BreakAll_ForcesMidWordBreak_ContrastNormal()
+ {
+ // A single unbroken run with no space anywhere: "normal" has no break opportunity at all
+ // and must lay the whole word out on one (overflowing) line, while "break-all" must wrap it.
+ const string longWord = "abcdefghijklmnopqrstuvwxyz";
+
+ var (normalRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap($"{longWord}
"));
+ var pNormal = LayoutHarness.FindById(normalRoot, "p")!;
+
+ var (breakAllRoot, _) = LayoutHarness.Layout(
+ 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.
+ 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) =>
+ box.LineBoxes.Count(lb => lb.Words.Any(w => !string.IsNullOrEmpty(w.Text)));
+}
diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/PageSizeConverterTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/PageSizeConverterTests.cs
new file mode 100644
index 000000000..59fea4cca
--- /dev/null
+++ b/Source/Test/HtmlRenderer.PdfSharp.Test/PageSizeConverterTests.cs
@@ -0,0 +1,32 @@
+using PdfSharp;
+
+namespace HtmlRenderer.PdfSharp.Test;
+
+///
+/// Tests for from the PDFsharp NuGet package, which
+///
+/// calls directly to resolve the page size in points for each configured .
+///
+[TestClass]
+public sealed class PageSizeConverterTests
+{
+ [TestMethod]
+ [DataRow(PageSize.A4, 595d, 842d)]
+ [DataRow(PageSize.Letter, 612d, 792d)]
+ [DataRow(PageSize.Legal, 612d, 1008d)]
+ [DataRow(PageSize.A0, 2384d, 3370d)]
+ [DataRow(PageSize.Tabloid, 792d, 1224d)]
+ public void ToSize_KnownPageSize_ReturnsExpectedPointDimensions(PageSize pageSize, double expectedWidth, double expectedHeight)
+ {
+ var size = PageSizeConverter.ToSize(pageSize);
+
+ Assert.AreEqual(expectedWidth, size.Width);
+ Assert.AreEqual(expectedHeight, size.Height);
+ }
+
+ [TestMethod]
+ public void ToSize_Undefined_Throws()
+ {
+ Assert.ThrowsExactly(() => PageSizeConverter.ToSize(PageSize.Undefined));
+ }
+}
diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/PdfGeneratorTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/PdfGeneratorTests.cs
index f98e20235..f433cbcd9 100644
--- a/Source/Test/HtmlRenderer.PdfSharp.Test/PdfGeneratorTests.cs
+++ b/Source/Test/HtmlRenderer.PdfSharp.Test/PdfGeneratorTests.cs
@@ -112,6 +112,32 @@ public async Task GeneratePdf_FromHtml_WithMultipleFonts_CreatesPdfDocument()
File.WriteAllBytes(pdfPath, pdf);
}
+ [TestMethod]
+ public async Task GeneratePdf_SimpleHtml_ProducesAtLeastOnePage()
+ {
+ // Act
+ using var document = await PdfGenerator.GeneratePdf("Hello
", PageSize.A4);
+
+ // Assert
+ Assert.IsTrue(document.Pages.Count >= 1);
+ }
+
+ [TestMethod]
+ public async Task GeneratePdf_SimpleHtml_CanBeSaved()
+ {
+ // Arrange
+ using var document = await PdfGenerator.GeneratePdf("Hello
", PageSize.A4);
+
+ // Act
+ using var stream = new MemoryStream();
+ document.Save(stream, false);
+
+ // Assert
+ var pdf = stream.ToArray();
+ Assert.IsGreaterThan(4, pdf.Length);
+ Assert.AreEqual("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
+ }
+
private static void OnImageLoadPdfSharp(object? sender, HtmlImageLoadEventArgs e)
{
if (!string.Equals(e.Src, "ImageIcon", StringComparison.OrdinalIgnoreCase))
diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/PdfSharpAdapterColorTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/PdfSharpAdapterColorTests.cs
new file mode 100644
index 000000000..aa25e3c9a
--- /dev/null
+++ b/Source/Test/HtmlRenderer.PdfSharp.Test/PdfSharpAdapterColorTests.cs
@@ -0,0 +1,40 @@
+using TheArtOfDev.HtmlRenderer.PdfSharp.Adapters;
+
+namespace HtmlRenderer.PdfSharp.Test;
+
+///
+/// Direct unit tests for 's named-color resolution
+/// (GetColorInt ), which maps a CSS/system color name to an RColor by
+/// matching it against PdfSharp's known-color table (XColorResourceManager ).
+/// Guards that name lookup path against regression.
+///
+[TestClass]
+public sealed class PdfSharpAdapterColorTests
+{
+ [TestMethod]
+ [DataRow("Red", (byte)255, (byte)0, (byte)0)]
+ [DataRow("red", (byte)255, (byte)0, (byte)0)] // case-insensitive
+ [DataRow("Lime", (byte)0, (byte)255, (byte)0)]
+ [DataRow("Blue", (byte)0, (byte)0, (byte)255)]
+ public void GetColor_KnownColorName_ResolvesToRgb(string name, byte r, byte g, byte b)
+ {
+ var adapter = PdfSharpAdapter.Instance;
+
+ var color = adapter.GetColor(name);
+
+ Assert.IsFalse(color.IsEmpty);
+ Assert.AreEqual(r, color.R);
+ Assert.AreEqual(g, color.G);
+ Assert.AreEqual(b, color.B);
+ }
+
+ [TestMethod]
+ public void GetColor_UnknownColorName_ReturnsEmpty()
+ {
+ var adapter = PdfSharpAdapter.Instance;
+
+ var color = adapter.GetColor("not-a-real-color-name");
+
+ Assert.IsTrue(color.IsEmpty);
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/Css/BorderPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/BorderPropertyTests.cs
new file mode 100644
index 000000000..2442f1df7
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Css/BorderPropertyTests.cs
@@ -0,0 +1,512 @@
+using HtmlRenderer.Test.TestSupport;
+using TheArtOfDev.HtmlRenderer.Core.Parse;
+
+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.
+///
+[TestClass]
+public sealed class BorderPropertyTests
+{
+ private static string GetProperty(string declaration, string propertyName)
+ {
+ var rule = new CssParser(new MockAdapter()).ParseInlineStyle(declaration);
+ Assert.IsNotNull(rule);
+ return rule.Style[propertyName];
+ }
+
+ private static string GetPriority(string declaration, string propertyName)
+ {
+ var rule = new CssParser(new MockAdapter()).ParseInlineStyle(declaration);
+ Assert.IsNotNull(rule);
+ return rule.Style.GetPropertyPriority(propertyName);
+ }
+
+ // ── border-spacing ───────────────────────────────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void BorderSpacingLengthLegal()
+ {
+ Assert.AreEqual("20px", GetProperty("border-spacing: 20px", "border-spacing"));
+ }
+
+ [TestMethod]
+ public void BorderSpacingZeroLegal()
+ {
+ Assert.AreEqual("0", GetProperty("border-spacing: 0", "border-spacing"));
+ }
+
+ [TestMethod]
+ public void BorderSpacingLengthLengthLegal()
+ {
+ Assert.AreEqual("15px 3em", GetProperty("border-spacing: 15px 3em", "border-spacing"));
+ }
+
+ [TestMethod]
+ public void BorderSpacingLengthZeroLegal()
+ {
+ Assert.AreEqual("15px 0", GetProperty("border-spacing: 15px 0", "border-spacing"));
+ }
+
+ [TestMethod]
+ public void BorderSpacingPercentIllegal()
+ {
+ // A percentage is not a legal border-spacing value (only is allowed) - the real engine
+ // now actually validates this (the old parser stored it verbatim, unfiltered).
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty("border-spacing: 15%", "border-spacing")));
+ }
+
+ // ── longhand border-*-color ──────────────────────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void BorderBottomColorRedLegal()
+ {
+ Assert.AreEqual("rgb(255, 0, 0)", GetProperty("border-bottom-color: red", "border-bottom-color"));
+ }
+
+ [TestMethod]
+ public void BorderTopColorHexLegal()
+ {
+ Assert.AreEqual("rgb(0, 255, 0)", GetProperty("border-top-color: #0F0", "border-top-color"));
+ }
+
+ [TestMethod]
+ public void BorderRightColorRgbaLegal()
+ {
+ Assert.AreEqual("rgba(1, 1, 1, 0)", GetProperty("border-right-color: rgba(1, 1, 1, 0)", "border-right-color"));
+ }
+
+ [TestMethod]
+ public void BorderLeftColorRgbLegal()
+ {
+ const string declaration = "border-left-color: rgb(1, 255, 100) !important";
+ Assert.AreEqual("rgb(1, 255, 100)", GetProperty(declaration, "border-left-color"));
+ Assert.AreEqual("important", GetPriority(declaration, "border-left-color"));
+ }
+
+ // ── border-color (all-sides shorthand) ───────────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void BorderColorTransparentLegal()
+ {
+ const string declaration = "border-color: transparent";
+
+ Assert.AreEqual("rgba(0, 0, 0, 0)", GetProperty(declaration, "border-top-color"));
+ Assert.AreEqual("rgba(0, 0, 0, 0)", GetProperty(declaration, "border-right-color"));
+ Assert.AreEqual("rgba(0, 0, 0, 0)", GetProperty(declaration, "border-bottom-color"));
+ Assert.AreEqual("rgba(0, 0, 0, 0)", GetProperty(declaration, "border-left-color"));
+ }
+
+ [TestMethod]
+ public void BorderColorRedGreenLegal()
+ {
+ const string declaration = "border-color: red green";
+
+ Assert.AreEqual("rgb(255, 0, 0)", GetProperty(declaration, "border-top-color"));
+ Assert.AreEqual("rgb(255, 0, 0)", GetProperty(declaration, "border-bottom-color"));
+ Assert.AreEqual("rgb(0, 128, 0)", GetProperty(declaration, "border-left-color"));
+ Assert.AreEqual("rgb(0, 128, 0)", GetProperty(declaration, "border-right-color"));
+ }
+
+ [TestMethod]
+ public void BorderColorRedRgbLegal()
+ {
+ const string declaration = "border-color: red rgb(0,0,0)";
+
+ Assert.AreEqual("rgb(255, 0, 0)", GetProperty(declaration, "border-top-color"));
+ Assert.AreEqual("rgb(255, 0, 0)", GetProperty(declaration, "border-bottom-color"));
+ Assert.AreEqual("rgb(0, 0, 0)", GetProperty(declaration, "border-left-color"));
+ Assert.AreEqual("rgb(0, 0, 0)", GetProperty(declaration, "border-right-color"));
+ }
+
+ [TestMethod]
+ public void BorderColorRedBlueGreenLegal()
+ {
+ const string declaration = "border-color: red blue green";
+
+ Assert.AreEqual("rgb(255, 0, 0)", GetProperty(declaration, "border-top-color"));
+ Assert.AreEqual("rgb(0, 0, 255)", GetProperty(declaration, "border-left-color"));
+ Assert.AreEqual("rgb(0, 0, 255)", GetProperty(declaration, "border-right-color"));
+ Assert.AreEqual("rgb(0, 128, 0)", GetProperty(declaration, "border-bottom-color"));
+ }
+
+ [TestMethod]
+ public void BorderColorRedBlueGreenBlackLegal()
+ {
+ const string declaration = "border-color: red blue green BLACK";
+
+ Assert.AreEqual("rgb(255, 0, 0)", GetProperty(declaration, "border-top-color"));
+ Assert.AreEqual("rgb(0, 0, 255)", GetProperty(declaration, "border-right-color"));
+ Assert.AreEqual("rgb(0, 128, 0)", GetProperty(declaration, "border-bottom-color"));
+ Assert.AreEqual("rgb(0, 0, 0)", GetProperty(declaration, "border-left-color"));
+ }
+
+ [TestMethod]
+ public void BorderColorRedBlueGreenBlackTransparentIllegal()
+ {
+ // A 5-value list is invalid for a 1/2/3/4-value periodic shorthand, so none of the
+ // border-*-color longhands get set at all.
+ const string declaration = "border-color: red blue green black transparent";
+
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "border-top-color")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "border-right-color")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "border-bottom-color")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "border-left-color")));
+ }
+
+ // ── border-style (longhand + all-sides shorthand) ────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void BorderStyleDottedLegal()
+ {
+ const string declaration = "border-style: dotted";
+
+ Assert.AreEqual("dotted", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("dotted", GetProperty(declaration, "border-right-style"));
+ Assert.AreEqual("dotted", GetProperty(declaration, "border-bottom-style"));
+ Assert.AreEqual("dotted", GetProperty(declaration, "border-left-style"));
+ }
+
+ [TestMethod]
+ public void BorderStyleInsetOutsetUpperLegal()
+ {
+ const string declaration = "border-style: INSET OUTset";
+
+ Assert.AreEqual("inset", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("inset", GetProperty(declaration, "border-bottom-style"));
+ Assert.AreEqual("outset", GetProperty(declaration, "border-left-style"));
+ Assert.AreEqual("outset", GetProperty(declaration, "border-right-style"));
+ }
+
+ [TestMethod]
+ public void BorderStyleDoubleGrooveLegal()
+ {
+ const string declaration = "border-style: double groove";
+
+ Assert.AreEqual("double", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("double", GetProperty(declaration, "border-bottom-style"));
+ Assert.AreEqual("groove", GetProperty(declaration, "border-left-style"));
+ Assert.AreEqual("groove", GetProperty(declaration, "border-right-style"));
+ }
+
+ [TestMethod]
+ public void BorderStyleRidgeSolidDashedLegal()
+ {
+ const string declaration = "border-style: ridge solid dashed";
+
+ Assert.AreEqual("ridge", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("solid", GetProperty(declaration, "border-left-style"));
+ Assert.AreEqual("solid", GetProperty(declaration, "border-right-style"));
+ Assert.AreEqual("dashed", GetProperty(declaration, "border-bottom-style"));
+ }
+
+ [TestMethod]
+ public void BorderStyleHiddenDottedNoneNoneLegal()
+ {
+ const string declaration = "border-style : hidden dotted NONE nONe";
+
+ Assert.AreEqual("hidden", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("dotted", GetProperty(declaration, "border-right-style"));
+ Assert.AreEqual("none", GetProperty(declaration, "border-bottom-style"));
+ Assert.AreEqual("none", GetProperty(declaration, "border-left-style"));
+ }
+
+ [TestMethod]
+ public void BorderStyleWavyIllegal()
+ {
+ // An invalid border-style keyword is rejected by the real engine, so none of the
+ // border-*-style longhands get set (the old parser had no keyword validation at all and let
+ // "wavy" through unfiltered onto all four sides).
+ const string declaration = "border-style: wavy";
+
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "border-top-style")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "border-right-style")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "border-bottom-style")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "border-left-style")));
+ }
+
+ [TestMethod]
+ public void BorderBottomStyleGrooveLegal()
+ {
+ Assert.AreEqual("groove", GetProperty("border-bottom-style: GROOVE", "border-bottom-style"));
+ }
+
+ [TestMethod]
+ public void BorderTopStyleNoneLegal()
+ {
+ Assert.AreEqual("none", GetProperty("border-top-style:none", "border-top-style"));
+ }
+
+ [TestMethod]
+ public void BorderRightStyleDoubleLegal()
+ {
+ Assert.AreEqual("double", GetProperty("border-right-style:double", "border-right-style"));
+ }
+
+ [TestMethod]
+ public void BorderLeftStyleHiddenLegal()
+ {
+ const string declaration = "border-left-style: hidden !important";
+ Assert.AreEqual("hidden", GetProperty(declaration, "border-left-style"));
+ Assert.AreEqual("important", GetPriority(declaration, "border-left-style"));
+ }
+
+ // ── 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".
+
+ [TestMethod]
+ public void BorderBottomWidthThinLegal()
+ {
+ Assert.AreEqual("1px", GetProperty("border-bottom-width: THIN", "border-bottom-width"));
+ }
+
+ [TestMethod]
+ public void BorderTopWidthZeroLegal()
+ {
+ Assert.AreEqual("0", GetProperty("border-top-width: 0", "border-top-width"));
+ }
+
+ [TestMethod]
+ public void BorderRightWidthEmLegal()
+ {
+ Assert.AreEqual("3em", GetProperty("border-right-width: 3em", "border-right-width"));
+ }
+
+ [TestMethod]
+ public void BorderLeftWidthThickLegal()
+ {
+ const string declaration = "border-left-width: thick !important";
+ Assert.AreEqual("5px", GetProperty(declaration, "border-left-width"));
+ Assert.AreEqual("important", GetPriority(declaration, "border-left-width"));
+ }
+
+ [TestMethod]
+ public void BorderWidthMediumLegal()
+ {
+ const string declaration = "border-width: medium";
+
+ Assert.AreEqual("3px", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("3px", GetProperty(declaration, "border-right-width"));
+ Assert.AreEqual("3px", GetProperty(declaration, "border-bottom-width"));
+ Assert.AreEqual("3px", GetProperty(declaration, "border-left-width"));
+ }
+
+ [TestMethod]
+ public void BorderWidthLengthZeroLegal()
+ {
+ const string declaration = "border-width: 3px 0";
+
+ Assert.AreEqual("3px", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("3px", GetProperty(declaration, "border-bottom-width"));
+ Assert.AreEqual("0", GetProperty(declaration, "border-left-width"));
+ Assert.AreEqual("0", GetProperty(declaration, "border-right-width"));
+ }
+
+ [TestMethod]
+ public void BorderWidthThinLengthLegal()
+ {
+ const string declaration = "border-width: THIN 1px";
+
+ Assert.AreEqual("1px", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("1px", GetProperty(declaration, "border-bottom-width"));
+ Assert.AreEqual("1px", GetProperty(declaration, "border-left-width"));
+ Assert.AreEqual("1px", GetProperty(declaration, "border-right-width"));
+ }
+
+ [TestMethod]
+ public void BorderWidthMediumThinThickLegal()
+ {
+ const string declaration = "border-width: medium thin thick";
+
+ Assert.AreEqual("3px", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("1px", GetProperty(declaration, "border-left-width"));
+ Assert.AreEqual("1px", GetProperty(declaration, "border-right-width"));
+ Assert.AreEqual("5px", GetProperty(declaration, "border-bottom-width"));
+ }
+
+ [TestMethod]
+ public void BorderWidthLengthLengthLengthLengthLegal()
+ {
+ const string declaration = "border-width: 1px 2px 3px 4px !important ";
+
+ Assert.AreEqual("1px", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("2px", GetProperty(declaration, "border-right-width"));
+ Assert.AreEqual("3px", GetProperty(declaration, "border-bottom-width"));
+ Assert.AreEqual("4px", GetProperty(declaration, "border-left-width"));
+ }
+
+ [TestMethod]
+ public void BorderWidthLengthInEmZeroLegal()
+ {
+ const string declaration = "border-width: 0.3em 0 ";
+
+ Assert.AreEqual("0.3em", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("0.3em", GetProperty(declaration, "border-bottom-width"));
+ Assert.AreEqual("0", GetProperty(declaration, "border-left-width"));
+ Assert.AreEqual("0", GetProperty(declaration, "border-right-width"));
+ }
+
+ [TestMethod]
+ public void BorderWidthMediumZeroLengthThickLegal()
+ {
+ const string declaration = "border-width: medium 0 1px thick ";
+
+ Assert.AreEqual("3px", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("0", GetProperty(declaration, "border-right-width"));
+ Assert.AreEqual("1px", GetProperty(declaration, "border-bottom-width"));
+ Assert.AreEqual("5px", GetProperty(declaration, "border-left-width"));
+ }
+
+ [TestMethod]
+ public void BorderWidthZerosIllegal()
+ {
+ // A 5-value list is invalid for a 1/2/3/4-value periodic shorthand, so none of the
+ // border-*-width longhands get set at all.
+ const string declaration = "border-width: 0 0 0 0 0";
+
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "border-top-width")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "border-right-width")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "border-bottom-width")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "border-left-width")));
+ }
+
+ // ── border (single-side shorthand: "border", and equally "border-left/top/right/bottom", which share
+ // the exact same value grammar) - a longhand this shorthand's value 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 the way the old positional
+ // whitespace-tokenizer parser left it. ───────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void BorderZeroLegal()
+ {
+ // A bare "0" is a legal border width. The old ParseBorderWidth only recognized a bare number as
+ // a width when at least 3 characters long (a number plus a 2-character unit) or one of the
+ // thin/medium/thick keywords, so a bare unitless "0" wasn't recognized at all - restored here
+ // now that the real engine's LineWidthConverter handles it correctly.
+ const string declaration = "border: 0";
+
+ Assert.AreEqual("0", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("initial", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("initial", GetProperty(declaration, "border-top-color"));
+ }
+
+ [TestMethod]
+ public void BorderLineStyleLegal()
+ {
+ const string declaration = "border: dotted";
+
+ Assert.AreEqual("initial", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("dotted", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("initial", GetProperty(declaration, "border-top-color"));
+ }
+
+ [TestMethod]
+ public void BorderLengthRedLegal()
+ {
+ const string declaration = "border : 2px red ";
+
+ Assert.AreEqual("2px", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("initial", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("rgb(255, 0, 0)", GetProperty(declaration, "border-top-color"));
+ }
+
+ [TestMethod]
+ public void BorderRgbLegal()
+ {
+ // "rgb(255, 100, 0)" is recognized as the border color. The old ParseBorder's whitespace-only
+ // tokenizer (CommonUtils.GetNextSubString has no notion of parentheses) split a color function
+ // containing internal spaces into unrecognizable fragments ("rgb(255,", "100,", "0)") that none
+ // individually resolved to a width/style/color - restored here now that the real engine's
+ // tokenizer correctly treats the whole function() as one token.
+ const string declaration = "border : rgb(255, 100, 0) ";
+
+ Assert.AreEqual("initial", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("initial", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("rgb(255, 100, 0)", GetProperty(declaration, "border-top-color"));
+ }
+
+ [TestMethod]
+ public void BorderGrooveRgbLegal()
+ {
+ const string declaration = "border : GROOVE rgb(255, 100, 0) ";
+
+ Assert.AreEqual("initial", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("groove", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("rgb(255, 100, 0)", GetProperty(declaration, "border-top-color"));
+ }
+
+ [TestMethod]
+ public void BorderInsetGreenLengthLegal()
+ {
+ const string declaration = "border : inset green 3em ";
+
+ Assert.AreEqual("3em", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("inset", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("rgb(0, 128, 0)", GetProperty(declaration, "border-top-color"));
+ }
+
+ [TestMethod]
+ public void BorderRedSolidLengthLegal()
+ {
+ const string declaration = "border : red SOLID 1px ";
+
+ Assert.AreEqual("1px", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("solid", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("rgb(255, 0, 0)", GetProperty(declaration, "border-top-color"));
+ }
+
+ [TestMethod]
+ public void BorderLengthBlackDoubleLegal()
+ {
+ const string declaration = "border : 0.5px black double ";
+
+ Assert.AreEqual("0.5px", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("double", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("rgb(0, 0, 0)", GetProperty(declaration, "border-top-color"));
+ }
+
+ [TestMethod]
+ public void BorderOutSetCurrentColor()
+ {
+ // "currentColor" is now a legal color keyword, recognized as the border color - the old parser
+ // had no special handling for it (looked it up like any other named color, which the adapter
+ // doesn't know, so it was rejected).
+ const string declaration = "border: 1px outset currentColor";
+
+ Assert.AreEqual("1px", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("outset", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("currentColor", GetProperty(declaration, "border-top-color"));
+ }
+
+ [TestMethod]
+ public void BorderOutSetWithNoColor()
+ {
+ const string declaration = "border: 1px outset";
+
+ Assert.AreEqual("1px", GetProperty(declaration, "border-top-width"));
+ Assert.AreEqual("outset", GetProperty(declaration, "border-top-style"));
+ Assert.AreEqual("initial", GetProperty(declaration, "border-top-color"));
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/Css/CoordinatePropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/CoordinatePropertyTests.cs
new file mode 100644
index 000000000..1d8bca295
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Css/CoordinatePropertyTests.cs
@@ -0,0 +1,96 @@
+using HtmlRenderer.Test.TestSupport;
+using TheArtOfDev.HtmlRenderer.Core.Parse;
+
+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
+{
+ private static string GetProperty(string declaration, string propertyName)
+ {
+ var rule = new CssParser(new MockAdapter()).ParseInlineStyle(declaration);
+ Assert.IsNotNull(rule);
+ return rule.Style[propertyName];
+ }
+
+ [TestMethod]
+ public void CssHeightLegalPercentage()
+ {
+ Assert.AreEqual("28%", GetProperty("height: 28%", "height"));
+ }
+
+ [TestMethod]
+ public void CssHeightLegalLengthInEm()
+ {
+ Assert.AreEqual("0.3em", GetProperty("height: 0.3em", "height"));
+ }
+
+ [TestMethod]
+ public void CssHeightLegalLengthInPx()
+ {
+ Assert.AreEqual("144px", GetProperty("height: 144px", "height"));
+ }
+
+ [TestMethod]
+ public void CssHeightLegalAutoUppercase()
+ {
+ Assert.AreEqual("auto", GetProperty("height: AUTO", "height"));
+ }
+
+ [TestMethod]
+ public void CssWidthLegalLengthInCm()
+ {
+ Assert.AreEqual("0.5cm", GetProperty("width: 0.5cm", "width"));
+ }
+
+ [TestMethod]
+ public void CssWidthLegalLengthInMm()
+ {
+ Assert.AreEqual("1.5mm", GetProperty("width: 1.5mm", "width"));
+ }
+
+ [TestMethod]
+ public void CssWidthIllegalLength()
+ {
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty("width: 1.5 meter", "width")));
+ }
+
+ [TestMethod]
+ public void CssWidthPercentLegal()
+ {
+ Assert.AreEqual("20.5%", GetProperty("width: 20.5%", "width"));
+ }
+
+ [TestMethod]
+ public void CssWidthLegalLengthInInches()
+ {
+ Assert.AreEqual("3in", GetProperty("width: 3in", "width"));
+ }
+
+ [TestMethod]
+ public void CssHeightAngleIllegal()
+ {
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty("height: 3deg", "height")));
+ }
+
+ [TestMethod]
+ public void CssHeightResolutionIllegal()
+ {
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty("height: 3dpi", "height")));
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs b/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs
new file mode 100644
index 000000000..1f1d5ad14
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Css/CssSpecificityOrderingTests.cs
@@ -0,0 +1,82 @@
+using HtmlRenderer.Test.TestSupport;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+using TheArtOfDev.HtmlRenderer.Core.Dom;
+
+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]
+ 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]
+ public void EqualSpecificity_SameOrigin_StillResolvesByLastDeclared()
+ {
+ var html = Html("div { color: #ff0000; } div { color: #0000ff; }", "text
");
+ var box = FindBoxByTag(html, "div");
+ Assert.AreEqual(RColor.FromArgb(0, 0, 255), box.ActualColor);
+ }
+
+ [TestMethod]
+ public void MediaRuleDeclaredEarlier_LosesToEqualSpecificityPlainRuleDeclaredLater()
+ {
+ // The @media print block (containing a "div" rule) appears FIRST in the source; a plain
+ // "div" rule appears SECOND. LayoutHarness's default MockAdapter reports "screen" media, so
+ // the @media print rule doesn't apply at all here regardless of source order - this also
+ // exercises that a non-matching-media rule is correctly excluded even at equal specificity.
+ var html = Html(
+ "@media print { div { color: #0000ff; } } div { color: #ff0000; }",
+ "text
");
+ var box = FindBoxByTag(html, "div");
+ Assert.AreEqual(RColor.FromArgb(255, 0, 0), box.ActualColor);
+ }
+
+ [TestMethod]
+ 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.
+ var html = Html(
+ ".a, #b { color: #0000ff; } .a.c { color: #ff0000; }",
+ "text
");
+ var box = FindBoxByTag(html, "div");
+ 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}";
+
+ private static CssBox FindBoxByTag(string html, string tag)
+ {
+ var (root, _) = LayoutHarness.Layout(html);
+ var box = LayoutHarness.Descendants(root).FirstOrDefault(b => b.HtmlTag != null && b.HtmlTag.Name == tag);
+ Assert.IsNotNull(box);
+ return box!;
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/Css/FloatPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/FloatPropertyTests.cs
new file mode 100644
index 000000000..c75adbf89
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Css/FloatPropertyTests.cs
@@ -0,0 +1,33 @@
+using HtmlRenderer.Test.TestSupport;
+
+namespace HtmlRenderer.Test.Css;
+
+///
+/// Ported from PeachPDF.Tests/CSS/PropertyTests/FloatClearProperty.cs.
+/// Only the `float` cases apply: HTML-Renderer has no `clear` CSS property at all (no ClearProperty
+/// type and no CssBoxProperties.Clear field/property - confirmed by grepping "Clear" across
+/// CssBoxProperties.cs and HtmlConstants.cs), so every `clear` case from the source file was dropped.
+/// The "invalid keyword" float case was also dropped: TheArtOfDev.HtmlRenderer.Core.Parse.CssParser
+/// does not validate `float` values against a keyword set, and CssUtils.SetPropertyValue assigns
+/// whatever string was parsed straight to CssBox.Float 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 value a laid-out box ends up with.
+///
+[TestClass]
+public sealed class FloatPropertyTests
+{
+ [TestMethod]
+ [DataRow("left")]
+ [DataRow("right")]
+ [DataRow("none")]
+ public void FloatKeywordLegal_SetsBoxFloat(string keyword)
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap($"content
"));
+
+ var target = LayoutHarness.FindById(root, "target");
+
+ Assert.IsNotNull(target);
+ Assert.AreEqual(keyword, target.Float);
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/Css/LengthTests.cs b/Source/Test/HtmlRenderer.Test/Css/LengthTests.cs
new file mode 100644
index 000000000..25ace7ee4
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Css/LengthTests.cs
@@ -0,0 +1,445 @@
+using TheArtOfDev.HtmlRenderer.Core.Dom;
+
+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 9-member enum
+/// (None, Ems, Pixels, Ex, Inches, Centimeters, Milimeters, Points, Picas - i.e. only em/ex/px/mm/cm/
+/// in/pt/pc, no rem/%/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 (rem, %-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, rem, 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.
+///
+[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);
+ Assert.AreEqual(CssUnit.Pixels, length.Unit);
+ }
+
+ [TestMethod]
+ [DataRow("em", "Ems")]
+ [DataRow("ex", "Ex")]
+ [DataRow("px", "Pixels")]
+ [DataRow("mm", "Milimeters")]
+ [DataRow("cm", "Centimeters")]
+ [DataRow("in", "Inches")]
+ [DataRow("pt", "Points")]
+ [DataRow("pc", "Picas")]
+ [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.
+ var length = new CssLength("1" + suffix);
+
+ Assert.AreEqual(expectedUnitName, length.Unit.ToString());
+ }
+
+ [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);
+ Assert.AreEqual(10d, length.Number);
+ Assert.AreEqual(CssUnit.Pixels, length.Unit);
+ }
+
+ [TestMethod]
+ public void Constructor_ZeroWithoutUnit_ReturnsZeroLength()
+ {
+ var length = new CssLength("0");
+
+ Assert.IsFalse(length.HasError);
+ Assert.AreEqual(0d, length.Number);
+ Assert.AreEqual(CssUnit.None, length.Unit);
+ }
+
+ [TestMethod]
+ public void Constructor_NonZeroValueWithUnrecognizedUnit_HasError()
+ {
+ var length = new CssLength("10bogus");
+
+ Assert.IsTrue(length.HasError);
+ }
+
+ [TestMethod]
+ [DataRow("not-a-length")]
+ [DataRow("abc")]
+ public void Constructor_NonNumericInput_HasError(string input)
+ {
+ var length = new CssLength(input);
+
+ Assert.IsTrue(length.HasError);
+ }
+
+ [TestMethod]
+ public void Constructor_UnitlessNonZero_HasError()
+ {
+ // CSS Values & Units §5.1: only zero may omit its unit.
+ var length = new CssLength("5");
+
+ Assert.IsTrue(length.HasError);
+ }
+
+ [TestMethod]
+ public void ToPixel_RelativeUnit_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);
+ Assert.ThrowsExactly(() => length.ConvertEmToPoints(12));
+ }
+
+ [TestMethod]
+ public void ToPixels_Em_UsesEmFactor()
+ {
+ // 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);
+
+ Assert.AreEqual(CssUnit.Pixels, result.Unit);
+ Assert.AreEqual(24d, result.Number);
+ }
+
+ [TestMethod]
+ public void ToString_Zero_OmitsUnit()
+ {
+ var length = new CssLength("0");
+
+ Assert.AreEqual("0", length.ToString());
+ }
+
+ [TestMethod]
+ public void ToString_NonZero_IncludesUnit()
+ {
+ var length = new CssLength("10px");
+
+ 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)]
+ [DataRow("1mm", false)]
+ [DataRow("1pc", false)]
+ [DataRow("1em", true)]
+ [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]
+ [Ignore("not yet spec compliant")]
+ [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]
+ [Ignore("not yet spec compliant")]
+ 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]
+ [Ignore("not yet spec compliant")]
+ 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]
+ [Ignore("not yet spec compliant")]
+ 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]
+ [Ignore("not yet spec compliant")]
+ 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]
+ [Ignore("not yet spec compliant")]
+ 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]
+ [Ignore("not yet spec compliant")]
+ 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]
+ [Ignore("not yet spec compliant")]
+ public void To_Pt_ReturnsSameValue()
+ {
+ var length = new CssLength("42pt");
+
+ Assert.AreEqual(CssUnit.Points, length.Unit);
+ Assert.AreEqual(42d, length.Number);
+ // Target: length.To(CssUnit.Points) == 42d.
+ }
+
+ [TestMethod]
+ [Ignore("not yet spec compliant")]
+ 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]
+ [Ignore("not yet spec compliant")]
+ 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]
+ [Ignore("not yet spec compliant")]
+ 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]
+ [Ignore("not yet spec compliant")]
+ 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]
+ [Ignore("not yet spec compliant")]
+ 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]
+ [Ignore("not yet spec compliant")]
+ 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]
+ [Ignore("not yet spec compliant")]
+ 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);
+ }
+
+ 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/MarginPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/MarginPropertyTests.cs
new file mode 100644
index 000000000..73d180bb7
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Css/MarginPropertyTests.cs
@@ -0,0 +1,118 @@
+using HtmlRenderer.Test.TestSupport;
+using TheArtOfDev.HtmlRenderer.Core.Parse;
+
+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
+{
+ private static string GetProperty(string declaration, string propertyName)
+ {
+ var rule = new CssParser(new MockAdapter()).ParseInlineStyle(declaration);
+ Assert.IsNotNull(rule);
+ return rule.Style[propertyName];
+ }
+
+ [TestMethod]
+ public void MarginAllZeroLegal()
+ {
+ const string declaration = "margin: 0";
+
+ Assert.AreEqual("0", GetProperty(declaration, "margin-left"));
+ Assert.AreEqual("0", GetProperty(declaration, "margin-top"));
+ Assert.AreEqual("0", GetProperty(declaration, "margin-right"));
+ Assert.AreEqual("0", GetProperty(declaration, "margin-bottom"));
+ }
+
+ [TestMethod]
+ public void MarginAllPercentLegal()
+ {
+ const string declaration = "margin: 25%";
+
+ Assert.AreEqual("25%", GetProperty(declaration, "margin-left"));
+ Assert.AreEqual("25%", GetProperty(declaration, "margin-top"));
+ Assert.AreEqual("25%", GetProperty(declaration, "margin-right"));
+ Assert.AreEqual("25%", GetProperty(declaration, "margin-bottom"));
+ }
+
+ [TestMethod]
+ public void MarginAutoLegal()
+ {
+ const string declaration = "margin: auto";
+
+ Assert.AreEqual("auto", GetProperty(declaration, "margin-left"));
+ Assert.AreEqual("auto", GetProperty(declaration, "margin-top"));
+ Assert.AreEqual("auto", GetProperty(declaration, "margin-right"));
+ Assert.AreEqual("auto", GetProperty(declaration, "margin-bottom"));
+ }
+
+ [TestMethod]
+ public void MarginSidesLengthLegal()
+ {
+ const string declaration = "margin: 10px 3em";
+
+ Assert.AreEqual("3em", GetProperty(declaration, "margin-left"));
+ Assert.AreEqual("10px", GetProperty(declaration, "margin-top"));
+ Assert.AreEqual("3em", GetProperty(declaration, "margin-right"));
+ Assert.AreEqual("10px", GetProperty(declaration, "margin-bottom"));
+ }
+
+ [TestMethod]
+ public void MarginSidesLengthAndAutoLegal()
+ {
+ const string declaration = "margin: 10px auto";
+
+ Assert.AreEqual("auto", GetProperty(declaration, "margin-left"));
+ Assert.AreEqual("10px", GetProperty(declaration, "margin-top"));
+ Assert.AreEqual("auto", GetProperty(declaration, "margin-right"));
+ Assert.AreEqual("10px", GetProperty(declaration, "margin-bottom"));
+ }
+
+ [TestMethod]
+ public void MarginThreeValuesLegal()
+ {
+ const string declaration = "margin: 10px 3em 5px";
+
+ Assert.AreEqual("3em", GetProperty(declaration, "margin-left"));
+ Assert.AreEqual("10px", GetProperty(declaration, "margin-top"));
+ Assert.AreEqual("3em", GetProperty(declaration, "margin-right"));
+ Assert.AreEqual("5px", GetProperty(declaration, "margin-bottom"));
+ }
+
+ [TestMethod]
+ public void MarginAllValuesWithPercentAndAutoLegal()
+ {
+ const string declaration = "margin: 10px 5% auto 2%";
+
+ Assert.AreEqual("2%", GetProperty(declaration, "margin-left"));
+ Assert.AreEqual("10px", GetProperty(declaration, "margin-top"));
+ Assert.AreEqual("5%", GetProperty(declaration, "margin-right"));
+ Assert.AreEqual("auto", GetProperty(declaration, "margin-bottom"));
+ }
+
+ [TestMethod]
+ public void MarginTooManyValuesIllegal()
+ {
+ const string declaration = "margin: 10px 5% 8px 2% 3px auto";
+
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "margin-left")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "margin-top")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "margin-right")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "margin-bottom")));
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs b/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs
new file mode 100644
index 000000000..fe08124c8
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Css/MediaQueryTests.cs
@@ -0,0 +1,123 @@
+using System.Linq;
+using HtmlRenderer.Test.TestSupport;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+using TheArtOfDev.HtmlRenderer.Core.Dom;
+
+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
+{
+ private static readonly RColor Red = RColor.FromArgb(255, 0, 0);
+
+ private static CssBox LayoutAndFindTag(string css, string bodyHtml, string tag, string mediaType)
+ {
+ var adapter = new MockAdapter { MediaType = mediaType };
+ var html = $"{bodyHtml}";
+ var (root, _) = LayoutHarness.Layout(html, adapter: adapter);
+ var box = LayoutHarness.Descendants(root).FirstOrDefault(b => b.HtmlTag != null && b.HtmlTag.Name == tag);
+ Assert.IsNotNull(box);
+ return box!;
+ }
+
+ // ── basic single media type + nested rules ──────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void AtMedia_Print_NestedRuleAppliesUnderPrintMedia()
+ {
+ var box = LayoutAndFindTag("@media print { p { color: red; } }", "text
", "p", "print");
+ Assert.AreEqual(Red, box.ActualColor);
+ }
+
+ [TestMethod]
+ public void AtMedia_Print_DoesNotApplyUnderScreenMedia()
+ {
+ // the rule is scoped to the "print" media only - it must not leak into "screen".
+ var box = LayoutAndFindTag("@media print { p { color: red; } }", "text
", "p", "screen");
+ Assert.AreNotEqual(Red, box.ActualColor);
+ }
+
+ [TestMethod]
+ public void AtMedia_Screen_NestedRuleAppliesUnderScreenMedia()
+ {
+ var box = LayoutAndFindTag("@media screen { p { color: red; } }", "text
", "p", "screen");
+ Assert.AreEqual(Red, box.ActualColor);
+ }
+
+ [TestMethod]
+ public void AtMedia_All_NestedRuleAppliesUnderAllMedia()
+ {
+ var box = LayoutAndFindTag("@media all { p { color: red; } }", "text
", "p", "screen");
+ Assert.AreEqual(Red, box.ActualColor);
+ }
+
+ [TestMethod]
+ public void AtMedia_Print_MultipleNestedRulesAreParsed()
+ {
+ const string css = "@media print { p { color: red; } div { font-size: 12pt; } }";
+ const string body = "text
text
";
+
+ var pBox = LayoutAndFindTag(css, body, "p", "print");
+ Assert.AreEqual(Red, pBox.ActualColor);
+
+ var divBox = LayoutAndFindTag(css, body, "div", "print");
+ Assert.AreEqual("12pt", divBox.FontSize);
+ }
+
+ // ── "not" / "only" modifiers - now real ─────────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void AtMedia_NotPrint_ExcludesPrintButAppliesElsewhere()
+ {
+ const string css = "@media not print { p { color: red; } }";
+
+ var underPrint = LayoutAndFindTag(css, "text
", "p", "print");
+ Assert.AreNotEqual(Red, underPrint.ActualColor);
+
+ var underScreen = LayoutAndFindTag(css, "text
", "p", "screen");
+ Assert.AreEqual(Red, underScreen.ActualColor);
+ }
+
+ [TestMethod]
+ public void AtMedia_OnlyPrint_AppliesOnlyUnderPrint()
+ {
+ const string css = "@media only print { p { color: red; } }";
+
+ var underPrint = LayoutAndFindTag(css, "text
", "p", "print");
+ Assert.AreEqual(Red, underPrint.ActualColor);
+
+ var underScreen = LayoutAndFindTag(css, "text
", "p", "screen");
+ Assert.AreNotEqual(Red, underScreen.ActualColor);
+ }
+
+ // ── comma-separated media list - now real ───────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void AtMedia_PrintCommaScreen_AppliesUnderBoth()
+ {
+ const string css = "@media print, screen { p { color: red; } }";
+
+ var underPrint = LayoutAndFindTag(css, "text
", "p", "print");
+ Assert.AreEqual(Red, underPrint.ActualColor);
+
+ var underScreen = LayoutAndFindTag(css, "text
", "p", "screen");
+ Assert.AreEqual(Red, underScreen.ActualColor);
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/Css/PaddingPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/PaddingPropertyTests.cs
new file mode 100644
index 000000000..d2daf75d8
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Css/PaddingPropertyTests.cs
@@ -0,0 +1,106 @@
+using HtmlRenderer.Test.TestSupport;
+using TheArtOfDev.HtmlRenderer.Core.Parse;
+
+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
+{
+ private static string GetProperty(string declaration, string propertyName)
+ {
+ var rule = new CssParser(new MockAdapter()).ParseInlineStyle(declaration);
+ Assert.IsNotNull(rule);
+ return rule.Style[propertyName];
+ }
+
+ [TestMethod]
+ public void CssPaddingAllZeroLegal()
+ {
+ const string declaration = "padding: 0";
+
+ Assert.AreEqual("0", GetProperty(declaration, "padding-left"));
+ Assert.AreEqual("0", GetProperty(declaration, "padding-top"));
+ Assert.AreEqual("0", GetProperty(declaration, "padding-right"));
+ Assert.AreEqual("0", GetProperty(declaration, "padding-bottom"));
+ }
+
+ [TestMethod]
+ public void CssPaddingAllPercentLegal()
+ {
+ const string declaration = "padding: 25%";
+
+ Assert.AreEqual("25%", GetProperty(declaration, "padding-left"));
+ Assert.AreEqual("25%", GetProperty(declaration, "padding-top"));
+ Assert.AreEqual("25%", GetProperty(declaration, "padding-right"));
+ Assert.AreEqual("25%", GetProperty(declaration, "padding-bottom"));
+ }
+
+ [TestMethod]
+ public void CssPaddingSidesLengthLegal()
+ {
+ const string declaration = "padding: 10px 3em";
+
+ Assert.AreEqual("3em", GetProperty(declaration, "padding-left"));
+ Assert.AreEqual("10px", GetProperty(declaration, "padding-top"));
+ Assert.AreEqual("3em", GetProperty(declaration, "padding-right"));
+ Assert.AreEqual("10px", GetProperty(declaration, "padding-bottom"));
+ }
+
+ [TestMethod]
+ 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")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "padding-top")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "padding-right")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "padding-bottom")));
+ }
+
+ [TestMethod]
+ public void CssPaddingThreeValuesLegal()
+ {
+ const string declaration = "padding: 10px 3em 5px";
+
+ Assert.AreEqual("3em", GetProperty(declaration, "padding-left"));
+ Assert.AreEqual("10px", GetProperty(declaration, "padding-top"));
+ Assert.AreEqual("3em", GetProperty(declaration, "padding-right"));
+ Assert.AreEqual("5px", GetProperty(declaration, "padding-bottom"));
+ }
+
+ [TestMethod]
+ public void CssPaddingAllValuesWithPercentLegal()
+ {
+ const string declaration = "padding: 10px 5% 8px 2%";
+
+ Assert.AreEqual("2%", GetProperty(declaration, "padding-left"));
+ Assert.AreEqual("10px", GetProperty(declaration, "padding-top"));
+ Assert.AreEqual("5%", GetProperty(declaration, "padding-right"));
+ Assert.AreEqual("8px", GetProperty(declaration, "padding-bottom"));
+ }
+
+ [TestMethod]
+ public void CssPaddingTooManyValuesIllegal()
+ {
+ const string declaration = "padding: 10px 5% 8px 2% 3px";
+
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "padding-left")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "padding-top")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "padding-right")));
+ Assert.IsTrue(string.IsNullOrEmpty(GetProperty(declaration, "padding-bottom")));
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/Css/RealWorldTests.cs b/Source/Test/HtmlRenderer.Test/Css/RealWorldTests.cs
new file mode 100644
index 000000000..eaaa04605
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Css/RealWorldTests.cs
@@ -0,0 +1,54 @@
+using System.Linq;
+using HtmlRenderer.Test.TestSupport;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+using TheArtOfDev.HtmlRenderer.Core.Dom;
+
+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.
+///
+[TestClass]
+public sealed class RealWorldTests
+{
+ [TestMethod]
+ public void ParseCss_WithStandardString_ExpectReadableProperties()
+ {
+ const string css = "html{ background-color: #5a5eed; color: #FFFFFF; margin: 5px; } h2{ background-color: red }";
+ var html = $"Text ";
+
+ var (root, _) = LayoutHarness.Layout(html);
+
+ var htmlBox = FindByTag(root, "html");
+ var h2Box = FindByTag(root, "h2");
+
+ Assert.AreEqual(RColor.FromArgb(90, 94, 237), htmlBox.ActualBackgroundColor);
+ Assert.AreEqual(RColor.FromArgb(255, 255, 255), htmlBox.ActualColor);
+ Assert.AreEqual(5d, htmlBox.ActualMarginLeft);
+ Assert.AreEqual(5d, htmlBox.ActualMarginTop);
+ Assert.AreEqual(5d, htmlBox.ActualMarginRight);
+ Assert.AreEqual(5d, htmlBox.ActualMarginBottom);
+
+ Assert.AreEqual(RColor.FromArgb(255, 0, 0), h2Box.ActualBackgroundColor);
+ }
+
+ private static CssBox FindByTag(CssBox root, string tag)
+ {
+ var box = LayoutHarness.Descendants(root).FirstOrDefault(b => b.HtmlTag != null && b.HtmlTag.Name == tag);
+ Assert.IsNotNull(box);
+ return box!;
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/Css/VerticalAlignPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/VerticalAlignPropertyTests.cs
new file mode 100644
index 000000000..e154e6bad
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Css/VerticalAlignPropertyTests.cs
@@ -0,0 +1,82 @@
+using HtmlRenderer.Test.TestSupport;
+using TheArtOfDev.HtmlRenderer.Core.Dom;
+
+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 .
+///
+[TestClass]
+public sealed class VerticalAlignPropertyTests
+{
+ private static CssBox GetTargetBox(string value)
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap($"x "));
+ var target = LayoutHarness.FindById(root, "target");
+ Assert.IsNotNull(target);
+ return target;
+ }
+
+ [TestMethod]
+ [DataRow("baseline")]
+ [DataRow("sub")]
+ [DataRow("super")]
+ [DataRow("top")]
+ [DataRow("text-top")]
+ [DataRow("middle")]
+ [DataRow("bottom")]
+ [DataRow("text-bottom")]
+ public void VerticalAlignKeywordLegal(string keyword)
+ {
+ var target = GetTargetBox(keyword);
+
+ Assert.AreEqual(keyword, target.VerticalAlign);
+ }
+
+ [TestMethod]
+ public void VerticalAlignLengthLegal()
+ {
+ var target = GetTargetBox("3px");
+
+ Assert.AreEqual("3px", target.VerticalAlign);
+ }
+
+ [TestMethod]
+ public void VerticalAlignPercentLegal()
+ {
+ var target = GetTargetBox("25%");
+
+ Assert.AreEqual("25%", target.VerticalAlign);
+ }
+
+ [TestMethod]
+ public void VerticalAlignNegativeLengthLegal()
+ {
+ var target = GetTargetBox("-3px");
+
+ Assert.AreEqual("-3px", target.VerticalAlign);
+ }
+
+ [TestMethod]
+ [Ignore("not yet spec compliant")]
+ 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
new file mode 100644
index 000000000..a8ca3e1fb
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Css/WhiteSpacePropertyTests.cs
@@ -0,0 +1,43 @@
+using HtmlRenderer.Test.TestSupport;
+using TheArtOfDev.HtmlRenderer.Core.Parse;
+
+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
+{
+ private static string GetWhiteSpace(string declaration)
+ {
+ var rule = new CssParser(new MockAdapter()).ParseInlineStyle(declaration);
+ Assert.IsNotNull(rule);
+ return rule.Style["white-space"];
+ }
+
+ [TestMethod]
+ [DataRow("normal")]
+ [DataRow("pre")]
+ [DataRow("nowrap")]
+ [DataRow("pre-wrap")]
+ [DataRow("pre-line")]
+ public void WhiteSpaceKeywordLegal(string keyword)
+ {
+ Assert.AreEqual(keyword, GetWhiteSpace($"white-space: {keyword}"));
+ }
+
+ [TestMethod]
+ public void WhiteSpaceInvalidKeywordIllegal()
+ {
+ Assert.IsTrue(string.IsNullOrEmpty(GetWhiteSpace("white-space: wavy")));
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/Dom/CssLayoutEngineTableTests.cs b/Source/Test/HtmlRenderer.Test/Dom/CssLayoutEngineTableTests.cs
new file mode 100644
index 000000000..e38d324fb
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Dom/CssLayoutEngineTableTests.cs
@@ -0,0 +1,145 @@
+using System.Linq;
+using HtmlRenderer.Test.TestSupport;
+
+namespace HtmlRenderer.Test.Dom;
+
+///
+/// Basic table layout tests against ,
+/// ported from PeachPDF's (much larger) CssLayoutEngineTableTests. HTML-Renderer's table engine is a
+/// simpler fork ancestor -- it has no header/footer repeat-across-pages proxies, no caption
+/// grid-decoration box, and (unlike PeachPDF's CssBox.Display enum) represents "Display" as a
+/// plain string compared against . Only the parts of the original suite that
+/// exercise basic dimension/colspan/rowspan layout -- functionality this engine actually has -- are
+/// ported here.
+///
+[TestClass]
+public sealed class CssLayoutEngineTableTests
+{
+ [TestMethod]
+ public void TableLayout_CalculatesCorrectDimensions()
+ {
+ var html = LayoutHarness.Wrap(
+ "" +
+ "Cell 1 Cell 2 " +
+ "Cell 3 Cell 4 " +
+ "
");
+
+ var (root, _) = LayoutHarness.Layout(html);
+ var table = LayoutHarness.FindById(root, "tbl");
+
+ Assert.IsNotNull(table);
+ Assert.IsTrue(table!.ActualRight > table.Location.X, "Table should have width");
+ Assert.IsTrue(table.ActualBottom > table.Location.Y, "Table should have height");
+ }
+
+ [TestMethod]
+ public void TableLayout_WithColspan_CalculatesCorrectWidth()
+ {
+ var html = LayoutHarness.Wrap(
+ "" +
+ "" +
+ "Wide Cell " +
+ "Normal " +
+ " " +
+ "" +
+ "Cell 1 " +
+ "Cell 2 " +
+ "Cell 3 " +
+ " " +
+ "
");
+
+ var (root, _) = LayoutHarness.Layout(html);
+ var wideCell = LayoutHarness.FindById(root, "wide");
+ var normalCell = LayoutHarness.FindById(root, "normal");
+
+ Assert.IsNotNull(wideCell);
+ Assert.IsNotNull(normalCell);
+
+ var wideWidth = wideCell!.ActualRight - wideCell.Location.X;
+ var normalWidth = normalCell!.ActualRight - normalCell.Location.X;
+
+ Assert.IsTrue(wideWidth > normalWidth, "Colspan cell should be wider than single cell");
+ }
+
+ [TestMethod]
+ public void TableLayout_WithRowspan_CalculatesCorrectHeight()
+ {
+ var html = LayoutHarness.Wrap(
+ "" +
+ "Tall Cell Cell 2 " +
+ "Cell 3 " +
+ "Cell 4 Cell 5 " +
+ "
");
+
+ var (root, _) = LayoutHarness.Layout(html);
+ var tallCell = LayoutHarness.FindById(root, "tall");
+
+ Assert.IsNotNull(tallCell);
+ var tallCellHeight = tallCell!.ActualBottom - tallCell.Location.Y;
+ Assert.IsTrue(tallCellHeight > 0, "Rowspan cell should have height");
+ }
+
+ [TestMethod]
+ public void TableLayout_DistributesWidthEqually_WhenNoWidthsSpecified()
+ {
+ var html = LayoutHarness.Wrap(
+ "" +
+ "" +
+ "Cell 1 " +
+ "Cell 2 " +
+ "Cell 3 " +
+ " " +
+ "
");
+
+ var (root, _) = LayoutHarness.Layout(html, maxWidth: 1200);
+ var cells = new[] { "c1", "c2", "c3" }
+ .Select(id => LayoutHarness.FindById(root, id))
+ .ToList();
+
+ Assert.IsTrue(cells.All(c => c is not null));
+
+ var widths = cells.Select(c => c!.ActualRight - c.Location.X).ToList();
+ var avgWidth = widths.Average();
+
+ foreach (var width in widths)
+ {
+ Assert.IsTrue(System.Math.Abs(width - avgWidth) < 5,
+ $"Cell width {width} should be close to average {avgWidth}");
+ }
+ }
+
+ [TestMethod]
+ public void TableLayout_RespectsSpecifiedColumnWidths()
+ {
+ // Adapted from the source test: HTML-Renderer's CssParser has no pseudo-class selector support
+ // (no ":first-child"), so the explicit width is applied directly on the first cell via an
+ // inline style rather than through a "td:first-child { width: ... }" rule.
+ var html = LayoutHarness.Wrap(
+ "" +
+ "" +
+ "Wide Cell " +
+ "Auto " +
+ "Auto " +
+ "Auto " +
+ " " +
+ "
");
+
+ var (root, _) = LayoutHarness.Layout(html, maxWidth: 1200);
+ var wideCell = LayoutHarness.FindById(root, "wide");
+ var auto1Cell = LayoutHarness.FindById(root, "auto1");
+
+ Assert.IsNotNull(wideCell);
+ Assert.IsNotNull(auto1Cell);
+
+ var wideWidth = wideCell!.ActualRight - wideCell.Location.X;
+ var auto1Width = auto1Cell!.ActualRight - auto1Cell.Location.X;
+
+ Assert.IsTrue(wideWidth >= 180, $"First cell should be approximately 200px wide (accounting for borders), but was {wideWidth}");
+ // The remaining table width (600pt minus the 200pt explicit column) is distributed across the
+ // three auto columns - via their content-based max width plus an equal share of the leftover
+ // space (see CssLayoutEngineTable.DetermineMissingColumnWidths) - rather than split evenly by a
+ // fixed pixel budget, so assert the semantically-intended relationship (narrower than the
+ // explicitly-widened column) instead of an arbitrary absolute threshold.
+ Assert.IsTrue(auto1Width < wideWidth, $"Auto cell ({auto1Width}) should be narrower than the explicitly-widened first cell ({wideWidth})");
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/HtmlRenderer.Test.csproj b/Source/Test/HtmlRenderer.Test/HtmlRenderer.Test.csproj
new file mode 100644
index 000000000..eb047dcca
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/HtmlRenderer.Test.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0
+ latest
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/Test/HtmlRenderer.Test/MSTestSettings.cs b/Source/Test/HtmlRenderer.Test/MSTestSettings.cs
new file mode 100644
index 000000000..4a3407c36
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/MSTestSettings.cs
@@ -0,0 +1,8 @@
+// HtmlRenderer.Core.Parse.RegexParserUtils caches compiled regexes in a plain, unlocked static
+// Dictionary (see RegexParserUtils.GetRegex). That's fine for the library's normal
+// single-threaded-per-container usage, but running this test assembly's many independent
+// CssParser/CssData-driven tests concurrently races on populating that shared static dictionary and
+// intermittently throws (e.g. IndexOutOfRangeException/ArgumentException from Dictionary internals).
+// That's a pre-existing thread-safety gap in shared library state, not something a single test can
+// work around, so this assembly runs its tests sequentially rather than opting into MSTest's
+// parallel execution.
diff --git a/Source/Test/HtmlRenderer.Test/Parse/CssValueParserIsValidLengthTests.cs b/Source/Test/HtmlRenderer.Test/Parse/CssValueParserIsValidLengthTests.cs
new file mode 100644
index 000000000..fc7600e6b
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Parse/CssValueParserIsValidLengthTests.cs
@@ -0,0 +1,66 @@
+using TheArtOfDev.HtmlRenderer.Core.Parse;
+
+namespace HtmlRenderer.Test.Parse;
+
+///
+/// Direct unit tests for .
+///
+/// HTML-Renderer's implementation chops the last 1-2 characters off the string and tries
+/// double.TryParse on what's left, gated by a "string length > 1" cutoff. Two known
+/// disagreements with the CSS2.1 §4.3.2 / CSS Values §6.2 grammar this is meant to validate:
+///
+/// - a bare unitless "0" is rejected outright (length must be > 1), even though the unit
+/// identifier is optional after a zero length;
+/// calc(...) expressions are not understood at all -- the naive "chop trailing
+/// characters" strategy can never parse one as a number.
+///
+/// Those two cases are captured as [Ignore] tests below, asserting the spec-correct
+/// expectation rather than today's actual (incorrect) result.
+///
+[TestClass]
+public sealed class CssValueParserIsValidLengthTests
+{
+ [TestMethod]
+ [DataRow("0px")]
+ [DataRow("0.5em")]
+ [DataRow("10px")]
+ [DataRow("-5px")]
+ [DataRow("50%")]
+ [DataRow("1in")]
+ public void ValidLengthOrPercentage_ReturnsTrue(string value)
+ {
+ Assert.IsTrue(CssValueParser.IsValidLength(value));
+ }
+
+ [TestMethod]
+ [Ignore("not yet spec compliant")]
+ 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
+ // bare "0" is a valid length. HTML-Renderer's IsValidLength gates on "value.Length > 1" and
+ // therefore rejects it outright.
+ Assert.IsTrue(CssValueParser.IsValidLength("0"));
+ }
+
+ [TestMethod]
+ [Ignore("not yet spec compliant")]
+ public void Calc_ReturnsTrue_NotYetSpecCompliant()
+ {
+ // CSS Values and Units §8.1: calc() expressions are valid wherever a is
+ // valid. HTML-Renderer's IsValidLength has no calc() awareness at all -- it just tries to
+ // double.TryParse whatever's left after chopping off a trailing unit, which never succeeds
+ // for a "calc(...)" string.
+ Assert.IsTrue(CssValueParser.IsValidLength("calc(10px + 1em)"));
+ }
+
+ [TestMethod]
+ [DataRow("")]
+ [DataRow("auto")]
+ [DataRow("normal")]
+ [DataRow("px")]
+ [DataRow("abc")]
+ public void InvalidOrNonLengthKeyword_ReturnsFalse(string value)
+ {
+ Assert.IsFalse(CssValueParser.IsValidLength(value));
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/TestSupport/LayoutHarness.cs b/Source/Test/HtmlRenderer.Test/TestSupport/LayoutHarness.cs
new file mode 100644
index 000000000..5dce99796
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/TestSupport/LayoutHarness.cs
@@ -0,0 +1,85 @@
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Dom;
+
+namespace HtmlRenderer.Test.TestSupport;
+
+///
+/// The shared lightweight layout harness: builds an over a ,
+/// runs layout, and hands back the laid-out box tree plus the container. Prefer this over hand-rolling another
+/// per-file box-tree setup.
+///
+internal static class LayoutHarness
+{
+ ///
+ /// Lays out at × pixels.
+ ///
+ ///
+ /// Optional: run against the parsed box tree's root after SetHtml and before layout, for a test that
+ /// has to put something in the tree the parser cannot produce.
+ ///
+ ///
+ /// Optional: a caller-supplied (e.g. with a non-default MediaType for
+ /// @media tests). Defaults to a plain new MockAdapter() .
+ ///
+ internal static (CssBox Root, HtmlContainerInt Container) Layout(
+ string html,
+ double maxWidth = 1000,
+ double maxHeight = 4000,
+ Action? prepare = null,
+ MockAdapter? adapter = null)
+ {
+ var container = new HtmlContainerInt(adapter ?? new MockAdapter())
+ {
+ MaxSize = new RSize(maxWidth, maxHeight),
+ Location = RPoint.Empty
+ };
+
+ container.SetHtml(html);
+
+ if (prepare is not null)
+ {
+ Assert.IsNotNull(container.Root);
+ prepare(container.Root!);
+ }
+
+ using var graphics = new RecordingGraphics();
+ container.PerformLayout(graphics);
+
+ Assert.IsNotNull(container.Root);
+
+ return (container.Root!, container);
+ }
+
+ /// Wraps a body fragment in a minimal document, so a test can state only the markup it cares about.
+ internal static string Wrap(string body) => $"{body}";
+
+ /// Depth-first search for the box carrying id=" " .
+ internal static CssBox? FindById(CssBox box, string id)
+ {
+ if (box.HtmlTag?.TryGetAttribute("id") == id)
+ return box;
+
+ foreach (var childBox in box.Boxes)
+ {
+ var found = FindById(childBox, id);
+ if (found is not null) return found;
+ }
+
+ return null;
+ }
+
+ /// Every box in the tree, in document order.
+ internal static IEnumerable Descendants(CssBox box)
+ {
+ yield return box;
+
+ foreach (var childBox in box.Boxes)
+ {
+ foreach (var descendant in Descendants(childBox))
+ {
+ yield return descendant;
+ }
+ }
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/TestSupport/LayoutHarnessSmokeTests.cs b/Source/Test/HtmlRenderer.Test/TestSupport/LayoutHarnessSmokeTests.cs
new file mode 100644
index 000000000..fab59ced9
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/TestSupport/LayoutHarnessSmokeTests.cs
@@ -0,0 +1,18 @@
+using HtmlRenderer.Test.TestSupport;
+
+namespace HtmlRenderer.Test.TestSupportTests;
+
+[TestClass]
+public sealed class LayoutHarnessSmokeTests
+{
+ [TestMethod]
+ public void Layout_SimpleParagraph_ProducesBoxWithId()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("hello world
"));
+
+ var target = LayoutHarness.FindById(root, "target");
+
+ Assert.IsNotNull(target);
+ Assert.IsTrue(target.ActualRight > target.Location.X);
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/TestSupport/MockAdapter.cs b/Source/Test/HtmlRenderer.Test/TestSupport/MockAdapter.cs
new file mode 100644
index 000000000..56c664307
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/TestSupport/MockAdapter.cs
@@ -0,0 +1,87 @@
+using System.Drawing;
+using TheArtOfDev.HtmlRenderer.Adapters;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+
+namespace HtmlRenderer.Test.TestSupport;
+
+///
+/// A minimal, non-sealed for tests that need to construct an
+/// and lay it out without depending on any concrete
+/// UI/PDF platform. Named-color resolution reuses (pure managed lookup, no GDI+),
+/// so color-dependent assertions still see real values; everything else is a deterministic, non-null stub.
+///
+internal sealed class MockAdapter : RAdapter
+{
+ ///
+ /// The CSS media type this adapter reports for @media evaluation (see
+ /// ). Defaults to "screen",
+ /// like the base ; settable so a test can exercise "@media print"
+ /// (or any other media type) deterministically without depending on a real platform adapter.
+ ///
+ public string MediaType { get; set; } = "screen";
+
+ public override string DefaultMediaType => MediaType;
+
+ protected override RColor GetColorInt(string colorName)
+ {
+ var color = 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) =>
+ 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);
+}
+
+/// A pen that remembers the color it was created with.
+internal sealed class MockPen(RColor color) : RPen
+{
+ public RColor Color { get; } = color;
+ public override double Width { get; set; }
+ public RDashStyle RecordedDashStyle { get; private set; }
+ public override RDashStyle DashStyle { set => RecordedDashStyle = value; }
+}
+
+/// A solid-color brush that remembers the color it was created with.
+internal sealed class MockBrush(RColor color) : RBrush
+{
+ public RColor Color { get; } = color;
+ public override void Dispose() { }
+}
+
+/// A fixed-size image, independent of any real pixel decoding.
+internal sealed class MockImage(double width, double height) : RImage
+{
+ public override double Width => width;
+ public override double Height => height;
+ public override void Dispose() { }
+}
+
+/// A deterministic fixed-metric font, independent of any real font file/rasterizer.
+internal sealed class MockFont(double size) : RFont
+{
+ public override double Size => size;
+ public override double Height => size * 1.2;
+ public override double UnderlineOffset => size * 0.9;
+ public override double LeftPadding => size * 0.2;
+ public override double GetWhitespaceWidth(RGraphics graphics) => size * 0.25;
+}
+
+/// A font family stand-in for @font-face loading, independent of any real font file parsing.
+internal sealed class MockFontFamily(string name) : RFontFamily
+{
+ public override string Name => name;
+}
diff --git a/Source/Test/HtmlRenderer.Test/TestSupport/RecordingGraphics.cs b/Source/Test/HtmlRenderer.Test/TestSupport/RecordingGraphics.cs
new file mode 100644
index 000000000..1ee0cbc3d
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/TestSupport/RecordingGraphics.cs
@@ -0,0 +1,123 @@
+using TheArtOfDev.HtmlRenderer.Adapters;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+
+namespace HtmlRenderer.Test.TestSupport;
+
+/// Records every point added to the path so tests can assert on the resulting geometry.
+internal sealed class MockGraphicsPath : RGraphicsPath
+{
+ public List Points { get; } = [];
+
+ 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));
+ public override void Dispose() { }
+}
+
+///
+/// Minimal implementation that records paint calls so tests can verify layout/paint
+/// behavior without a full rendering stack (WinForms/PdfSharp).
+///
+internal class RecordingGraphics : RGraphics
+{
+ public sealed record DrawStringCall(string Text, RFont Font, RColor Color, RPoint Point, RSize Size, bool Rtl);
+ public sealed record DrawRectCall(RColor Color, double X, double Y, double Width, double Height);
+ public sealed record DrawLineCall(RColor Color, double X1, double Y1, double X2, double Y2);
+ public sealed record DrawPolygonCall(RColor Color, RPoint[] Points);
+ public sealed record DrawImageCall(RImage Image, RRect DestRect);
+ public sealed record PushClipCall(RRect Rect);
+ public sealed record PopClipCall;
+
+ public List Log { get; } = [];
+ public List DrawStringCalls { get; } = [];
+ public List DrawImageCalls { get; } = [];
+
+ public RecordingGraphics() : this(new MockAdapter())
+ {
+ }
+
+ public RecordingGraphics(RAdapter adapter) : base(adapter, new RRect(0, 0, double.MaxValue, double.MaxValue))
+ {
+ }
+
+ public override void PopClip()
+ {
+ if (_clipStack.Count > 1) _clipStack.Pop();
+ Log.Add(new PopClipCall());
+ }
+
+ public override void PushClip(RRect rect)
+ {
+ _clipStack.Push(rect);
+ Log.Add(new PushClipCall(rect));
+ }
+
+ public override void PushClipExclude(RRect rect) { }
+
+ public override object SetAntiAliasSmoothingMode() => new object();
+
+ public override void ReturnPreviousSmoothingMode(object prevMode) { }
+
+ public override RBrush GetTextureBrush(RImage image, RRect dstRect, RPoint translateTransformLocation) => new MockBrush(RColor.Empty);
+
+ public override RGraphicsPath GetGraphicsPath() => new MockGraphicsPath();
+
+ public override RSize MeasureString(string str, RFont font) => new((str?.Length ?? 0) * font.Size * 0.6, font.Height);
+
+ public override void MeasureString(string str, RFont font, double maxWidth, out int charFit, out double charFitWidth)
+ {
+ charFit = str?.Length ?? 0;
+ charFitWidth = charFit * font.Size * 0.6;
+ }
+
+ public override void DrawString(string str, RFont font, RColor color, RPoint point, RSize size, bool rtl)
+ {
+ var call = new DrawStringCall(str, font, color, point, size, rtl);
+ DrawStringCalls.Add(call);
+ Log.Add(call);
+ }
+
+ public override void DrawLine(RPen pen, double x1, double y1, double x2, double y2)
+ {
+ var color = pen is MockPen mp ? mp.Color : RColor.Empty;
+ Log.Add(new DrawLineCall(color, x1, y1, x2, y2));
+ }
+
+ public override void DrawRectangle(RPen pen, double x, double y, double width, double height)
+ {
+ var color = pen is MockPen mp ? mp.Color : RColor.Empty;
+ Log.Add(new DrawRectCall(color, x, y, width, height));
+ }
+
+ public override void DrawRectangle(RBrush brush, double x, double y, double width, double height)
+ {
+ var color = brush is MockBrush mb ? mb.Color : RColor.Empty;
+ Log.Add(new DrawRectCall(color, x, y, width, height));
+ }
+
+ public override void DrawImage(RImage image, RRect destRect, RRect srcRect)
+ {
+ var call = new DrawImageCall(image, destRect);
+ DrawImageCalls.Add(call);
+ Log.Add(call);
+ }
+
+ public override void DrawImage(RImage image, RRect destRect)
+ {
+ var call = new DrawImageCall(image, destRect);
+ DrawImageCalls.Add(call);
+ Log.Add(call);
+ }
+
+ public override void DrawPath(RPen pen, RGraphicsPath path) { }
+
+ public override void DrawPath(RBrush brush, RGraphicsPath path) { }
+
+ public override void DrawPolygon(RBrush brush, RPoint[] points)
+ {
+ var color = brush is MockBrush mb ? mb.Color : RColor.Empty;
+ Log.Add(new DrawPolygonCall(color, (RPoint[])points.Clone()));
+ }
+
+ public override void Dispose() { }
+}
diff --git a/Source/Test/HtmlRenderer.Test/Utils/CommonUtilsTests.cs b/Source/Test/HtmlRenderer.Test/Utils/CommonUtilsTests.cs
new file mode 100644
index 000000000..5a88d25c8
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Utils/CommonUtilsTests.cs
@@ -0,0 +1,216 @@
+using System.Collections.Generic;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+using TheArtOfDev.HtmlRenderer.Core.Utils;
+
+namespace HtmlRenderer.Test.Utils;
+
+[TestClass]
+public sealed class CommonUtilsTests
+{
+ // Note: HTML-Renderer's method is "IsAsianCharecter" (sic) and takes a plain char, not a Rune --
+ // so the astral-emoji row from the source test (which relied on Rune to represent a code point
+ // beyond the BMP) cannot be expressed here and is dropped; a char can never hold it.
+ [TestMethod]
+ [DataRow(0x61, false)] // 'a'
+ [DataRow(0x4E2D, true)] // '中'
+ [DataRow(0x4e00, true)]
+ [DataRow(0xFA2D, true)]
+ [DataRow(0x4dff, false)]
+ public void IsAsianCharecter_ChecksRange(int codepoint, bool expected)
+ {
+ Assert.AreEqual(expected, CommonUtils.IsAsianCharecter((char)codepoint));
+ }
+
+ [TestMethod]
+ [DataRow('5', false, true)]
+ [DataRow('a', false, false)]
+ [DataRow('a', true, true)]
+ [DataRow('F', true, true)]
+ [DataRow('g', true, false)]
+ public void IsDigit_ChecksDecimalOrHex(char ch, bool hex, bool expected)
+ {
+ Assert.AreEqual(expected, CommonUtils.IsDigit(ch, hex));
+ }
+
+ [TestMethod]
+ [DataRow('7', false, 7)]
+ [DataRow('a', false, 0)]
+ [DataRow('a', true, 10)]
+ [DataRow('F', true, 15)]
+ [DataRow('g', true, 0)]
+ public void ToDigit_ConvertsCharToNumericValue(char ch, bool hex, int expected)
+ {
+ Assert.AreEqual(expected, CommonUtils.ToDigit(ch, hex));
+ }
+
+ [TestMethod]
+ public void Max_ReturnsComponentWiseMaximum()
+ {
+ var result = CommonUtils.Max(new RSize(10, 20), new RSize(30, 5));
+
+ Assert.AreEqual(new RSize(30, 20), result);
+ }
+
+ [TestMethod]
+ public void GetFirstValueOrDefault_NonEmptyDictionary_ReturnsFirstValue()
+ {
+ var dic = new Dictionary { ["a"] = 1, ["b"] = 2 };
+
+ var value = CommonUtils.GetFirstValueOrDefault(dic, -1);
+
+ Assert.AreEqual(1, value);
+ }
+
+ [TestMethod]
+ public void GetFirstValueOrDefault_EmptyDictionary_ReturnsDefault()
+ {
+ var dic = new Dictionary();
+
+ var value = CommonUtils.GetFirstValueOrDefault(dic, -1);
+
+ Assert.AreEqual(-1, value);
+ }
+
+ [TestMethod]
+ public void GetFirstValueOrDefault_NullDictionary_ReturnsDefault()
+ {
+ var value = CommonUtils.GetFirstValueOrDefault(null!, -1);
+
+ Assert.AreEqual(-1, value);
+ }
+
+ [TestMethod]
+ [DataRow("hello world", 0, 0, 5)]
+ [DataRow(" hello", 0, 2, 5)]
+ [DataRow("hello", 10, -1, 0)]
+ public void GetNextSubString_FindsWhitespaceDelimitedWord(string str, int start, int expectedIndex, int expectedLength)
+ {
+ var index = CommonUtils.GetNextSubString(str, start, out var length);
+
+ Assert.AreEqual(expectedIndex, index);
+ Assert.AreEqual(expectedLength, length);
+ }
+
+ [TestMethod]
+ public void SubStringEquals_CaseInsensitiveMatch_ReturnsTrue()
+ {
+ Assert.IsTrue(CommonUtils.SubStringEquals("Hello World", 0, 5, "hello"));
+ }
+
+ [TestMethod]
+ public void SubStringEquals_DifferentLength_ReturnsFalse()
+ {
+ Assert.IsFalse(CommonUtils.SubStringEquals("Hello World", 0, 5, "hell"));
+ }
+
+ [TestMethod]
+ 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")]
+ [DataRow(27, CssConstants.UpperAlpha, "AA")]
+ [DataRow(1, CssConstants.LowerAlpha, "a")]
+ [DataRow(1, CssConstants.LowerLatin, "a")]
+ [DataRow(1, CssConstants.UpperLatin, "A")]
+ [DataRow(4, CssConstants.LowerRoman, "iv")]
+ [DataRow(4, CssConstants.UpperRoman, "IV")]
+ public void ConvertToAlphaNumber_KnownStyles(int number, string style, string expected)
+ {
+ Assert.AreEqual(expected, CommonUtils.ConvertToAlphaNumber(number, style));
+ }
+
+ [TestMethod]
+ public void ConvertToAlphaNumber_LowerGreek_ProducesNonEmptyResult()
+ {
+ var result = CommonUtils.ConvertToAlphaNumber(1, CssConstants.LowerGreek);
+
+ Assert.AreNotEqual(string.Empty, result);
+ }
+
+ // Note: HTML-Renderer's CssConstants only has a single generic "armenian" / "georgian" /
+ // "hebrew" keyword each -- there's no separate lower-armenian/upper-armenian distinction (and no
+ // Tetragrammaton-avoidance override or >999 support in ConvertToSpecificNumbers), so the source
+ // ConvertToAlphaNumber_ArmenianAndHebrewBoundaries theory (which specifically exercises those
+ // missing capabilities) is dropped entirely rather than ported.
+ [TestMethod]
+ [DataRow(CssConstants.Armenian)]
+ [DataRow(CssConstants.Georgian)]
+ [DataRow(CssConstants.Hebrew)]
+ public void ConvertToAlphaNumber_SpecificAlphabets_ProduceNonEmptyResult(string style)
+ {
+ var result = CommonUtils.ConvertToAlphaNumber(5, style);
+
+ Assert.AreNotEqual(string.Empty, result);
+ }
+
+ [TestMethod]
+ [DataRow(CssConstants.Hiragana)]
+ [DataRow(CssConstants.HiraganaIroha)]
+ [DataRow(CssConstants.Katakana)]
+ [DataRow(CssConstants.KatakanaIroha)]
+ public void ConvertToAlphaNumber_KanaAlphabets_ProduceNonEmptyResult(string style)
+ {
+ var result = CommonUtils.ConvertToAlphaNumber(5, style);
+
+ Assert.AreNotEqual(string.Empty, result);
+ }
+
+ [TestMethod]
+ public void ConvertToAlphaNumber_ZeroWithAnyStyle_ReturnsEmpty()
+ {
+ Assert.AreEqual(string.Empty, CommonUtils.ConvertToAlphaNumber(0, CssConstants.Hebrew));
+ }
+
+ [TestMethod]
+ [Ignore("not yet spec compliant")]
+ public void ConvertToAlphaNumber_HiraganaIroha_DiffersFromDictionaryOrderHiragana()
+ {
+ // CSS Counter Styles Level 3's hiragana-iroha system reorders the 47 kana by the classical
+ // iroha poem instead of gojuon dictionary order. HTML-Renderer's ConvertToAlphaNumber maps
+ // both "hiragana" and "hiragana-iroha" to the exact same 48-character dictionary-order table
+ // (ConvertToSpecificNumbers2 with _hiraganaDigitsTable either way), so the two styles
+ // currently produce identical output instead of a different ordering.
+ var dictionaryOrder = CommonUtils.ConvertToAlphaNumber(2, CssConstants.Hiragana);
+ var irohaOrder = CommonUtils.ConvertToAlphaNumber(2, CssConstants.HiraganaIroha);
+
+ Assert.AreNotEqual(dictionaryOrder, irohaOrder);
+ Assert.AreEqual("ろ", irohaOrder); // ろ - second character of the iroha ordering
+ }
+
+ [TestMethod]
+ [Ignore("not yet spec compliant")]
+ public void ConvertToAlphaNumber_KatakanaIroha_DiffersFromDictionaryOrderKatakana()
+ {
+ var dictionaryOrder = CommonUtils.ConvertToAlphaNumber(2, CssConstants.Katakana);
+ var irohaOrder = CommonUtils.ConvertToAlphaNumber(2, CssConstants.KatakanaIroha);
+
+ Assert.AreNotEqual(dictionaryOrder, irohaOrder);
+ Assert.AreEqual("ロ", irohaOrder); // ロ - second character of the iroha ordering
+ }
+
+ [TestMethod]
+ [DataRow(CssConstants.Hiragana)]
+ [DataRow(CssConstants.HiraganaIroha)]
+ [DataRow(CssConstants.Katakana)]
+ [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);
+ Assert.AreNotEqual(string.Empty, result);
+ }
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/Utils/DomUtilsTests.cs b/Source/Test/HtmlRenderer.Test/Utils/DomUtilsTests.cs
new file mode 100644
index 000000000..212233183
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Utils/DomUtilsTests.cs
@@ -0,0 +1,267 @@
+using System.Linq;
+using HtmlRenderer.Test.TestSupport;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+using TheArtOfDev.HtmlRenderer.Core.Dom;
+using TheArtOfDev.HtmlRenderer.Core.Utils;
+
+namespace HtmlRenderer.Test.Utils;
+
+[TestClass]
+public sealed class DomUtilsTests
+{
+ [TestMethod]
+ public void GetBoxById_FindsMatchingElement()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("Text
"));
+
+ var found = DomUtils.GetBoxById(root, "inner");
+
+ Assert.IsNotNull(found);
+ Assert.AreEqual("span", found!.HtmlTag!.Name);
+ }
+
+ [TestMethod]
+ public void GetBoxById_UnknownId_ReturnsNull()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
"));
+
+ Assert.IsNull(DomUtils.GetBoxById(root, "missing"));
+ }
+
+ [TestMethod]
+ public void GetBoxById_NullOrEmptyId_ReturnsNull()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
"));
+
+ Assert.IsNull(DomUtils.GetBoxById(root, null));
+ Assert.IsNull(DomUtils.GetBoxById(root, string.Empty));
+ }
+
+ [TestMethod]
+ public void FindParent_ReturnsParentOfAncestorMatchingTagName()
+ {
+ // FindParent walks up from `box` looking for an ancestor tagged `tagName`, then returns
+ // *that ancestor's own parent* (not the matched ancestor itself) -- so searching for
+ // "div" from a nested one level inside a returns the div's parent ().
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
Text
"));
+ var span = DomUtils.GetBoxById(root, "inner")!;
+
+ var parent = DomUtils.FindParent(root, "div", span);
+
+ Assert.AreEqual("body", parent!.HtmlTag!.Name);
+ }
+
+ [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.
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
"));
+
+ var parent = DomUtils.FindParent(root, "div", null);
+
+ Assert.AreSame(root, parent);
+ }
+
+ [TestMethod]
+ public void GetPreviousSibling_ReturnsPrecedingBox()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
"));
+ var b = DomUtils.GetBoxById(root, "b")!;
+
+ var previous = DomUtils.GetPreviousSibling(b);
+
+ Assert.IsNotNull(previous);
+ Assert.AreEqual("a", previous!.HtmlTag!.TryGetAttribute("id"));
+ }
+
+ [TestMethod]
+ public void GetPreviousSibling_FirstChild_ReturnsNull()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
"));
+ var a = DomUtils.GetBoxById(root, "a")!;
+
+ Assert.IsNull(DomUtils.GetPreviousSibling(a));
+ }
+
+ [TestMethod]
+ public void GetFollowingSiblings_ReturnsMatchingLaterSiblings()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
"));
+ var a = DomUtils.GetBoxById(root, "a")!;
+
+ var following = DomUtils.GetFollowingSiblings(a, _ => true, isConsecutive: false).ToList();
+
+ Assert.AreEqual(2, following.Count);
+ }
+
+ [TestMethod]
+ public void ContainsInlinesOnly_AllInlineChildren_ReturnsTrue()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
A B
"));
+ var div = DomUtils.GetBoxById(root, "outer")!;
+
+ Assert.IsTrue(DomUtils.ContainsInlinesOnly(div));
+ }
+
+ [TestMethod]
+ public void ContainsInlinesOnly_HasBlockChild_ReturnsFalse()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
"));
+ var div = DomUtils.GetBoxById(root, "outer")!;
+
+ Assert.IsFalse(DomUtils.ContainsInlinesOnly(div));
+ }
+
+ [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("
"));
+
+ var links = new System.Collections.Generic.List
();
+ DomUtils.GetAllLinkBoxes(root, links);
+
+ Assert.IsTrue(links.Any(b => b.HtmlTag?.Name == "a"));
+ }
+
+ [TestMethod]
+ public void GetCssBox_LocationInsideBounds_ReturnsABox()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("Text
"));
+ var outer = DomUtils.GetBoxById(root, "outer")!;
+ var point = new RPoint(outer.Bounds.X + 1, outer.Bounds.Y + 1);
+
+ var found = DomUtils.GetCssBox(root, point);
+
+ Assert.IsNotNull(found);
+ }
+
+ [TestMethod]
+ public void GetCssBox_InvisibleBox_ReturnsNull()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("x
"));
+ var hidden = DomUtils.GetBoxById(root, "hidden")!;
+ var point = new RPoint(hidden.Bounds.X + 1, hidden.Bounds.Y + 1);
+
+ Assert.IsNull(DomUtils.GetCssBox(hidden, point));
+ }
+
+ [TestMethod]
+ public void GetLinkBox_LocationOnClickableVisibleLink_ReturnsIt()
+ {
+ // Note: no "id" on the anchor itself -- HTML-Renderer's IsClickable excludes an "a" that has one.
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("Click "));
+ var link = LayoutHarness.Descendants(root).First(b => b.HtmlTag?.Name == "a");
+ var word = FindFirstWord(link)!;
+ var point = new RPoint(word.Rectangle.X + word.Rectangle.Width / 2, word.Rectangle.Y + word.Rectangle.Height / 2);
+
+ var found = DomUtils.GetLinkBox(root, point);
+
+ Assert.IsNotNull(found);
+ Assert.AreEqual("a", found!.HtmlTag!.Name);
+ }
+
+ [TestMethod]
+ public void GetLinkBox_LocationAwayFromAnyLink_ReturnsNull()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("Click "));
+
+ Assert.IsNull(DomUtils.GetLinkBox(root, new RPoint(-1000, -1000)));
+ }
+
+ [TestMethod]
+ public void GetCssBoxWord_LocationOnVisibleWord_ReturnsWord()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("Hello
"));
+ var p = DomUtils.GetBoxById(root, "p")!;
+ var word = FindFirstWord(p)!;
+ var point = new RPoint(word.Rectangle.X + word.Rectangle.Width / 2, word.Rectangle.Y + word.Rectangle.Height / 2);
+
+ Assert.IsNotNull(DomUtils.GetCssBoxWord(root, point));
+ }
+
+ [TestMethod]
+ public void GetCssBoxWord_InvisibleBox_ReturnsNull()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("Hello
"));
+ var p = DomUtils.GetBoxById(root, "p")!;
+ var word = FindFirstWord(p)!;
+ var point = new RPoint(word.Rectangle.X + word.Rectangle.Width / 2, word.Rectangle.Y + word.Rectangle.Height / 2);
+
+ Assert.IsNull(DomUtils.GetCssBoxWord(p, point));
+ }
+
+ [TestMethod]
+ public void GetCssLineBox_LocationOnLine_ReturnsLine()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("Hello world
"));
+ var p = DomUtils.GetBoxById(root, "p")!;
+ var word = FindFirstWord(p)!;
+ var point = new RPoint(word.Rectangle.X + word.Rectangle.Width / 2, word.Rectangle.Y + word.Rectangle.Height / 2);
+
+ var line = DomUtils.GetCssLineBox(root, point);
+
+ Assert.AreSame(p.LineBoxes[0], line);
+ }
+
+ [TestMethod]
+ public void GetCssLineBox_LocationAboveAllContent_ReturnsNull()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("Hello world
"));
+
+ var line = DomUtils.GetCssLineBox(root, new RPoint(0, -1000));
+
+ Assert.IsNull(line);
+ }
+
+ [TestMethod]
+ public void GetCssLineBox_NullBox_ReturnsNull()
+ {
+ Assert.IsNull(DomUtils.GetCssLineBox(null, new RPoint(0, 0)));
+ }
+
+ [TestMethod]
+ public void GetCssLineBox_TableCellOutsideBounds_ReturnsNull()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(""));
+ var cell = DomUtils.GetBoxById(root, "cell")!;
+
+ var line = DomUtils.GetCssLineBox(cell, new RPoint(-1000, -1000));
+
+ Assert.IsNull(line);
+ }
+
+ [TestMethod]
+ public void IsProperTableChild_TableRow_ReturnsTrue()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(""));
+ var row = DomUtils.GetBoxById(root, "row")!;
+
+ Assert.IsTrue(DomUtils.IsProperTableChild(row));
+ }
+
+ [TestMethod]
+ public void IsProperTableChild_NonTableBox_ReturnsFalse()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap("
"));
+ var div = DomUtils.GetBoxById(root, "plain")!;
+
+ Assert.IsFalse(DomUtils.IsProperTableChild(div));
+ }
+
+ // --- Helper ---
+
+ private static CssRect? FindFirstWord(CssBox box)
+ {
+ if (box.Words.Count > 0) return box.Words[0];
+ foreach (var child in box.Boxes)
+ {
+ var found = FindFirstWord(child);
+ if (found is not null) return found;
+ }
+ return null;
+ }
+}
diff --git a/Source/Test/HtmlRenderer.Test/Utils/HtmlUtilsTests.cs b/Source/Test/HtmlRenderer.Test/Utils/HtmlUtilsTests.cs
new file mode 100644
index 000000000..fba20ba2d
--- /dev/null
+++ b/Source/Test/HtmlRenderer.Test/Utils/HtmlUtilsTests.cs
@@ -0,0 +1,65 @@
+using TheArtOfDev.HtmlRenderer.Core.Utils;
+
+namespace HtmlRenderer.Test.Utils;
+
+[TestClass]
+public sealed class HtmlUtilsTests
+{
+ [TestMethod]
+ [DataRow("A", "A")]
+ [DataRow("ABC", "ABC")]
+ [DataRow("A", "A")]
+ [DataRow("A", "A")]
+ [DataRow("Hello A World", "Hello A World")]
+ public void DecodeHtml_NumericCharacterReference_DecodesToCharacter(string input, string expected)
+ {
+ Assert.AreEqual(expected, HtmlUtils.DecodeHtml(input));
+ }
+
+ [TestMethod]
+ public void DecodeHtml_NumericCharacterReferenceWithTrailingSemicolon_ConsumesSemicolon()
+ {
+ Assert.AreEqual("A rest", HtmlUtils.DecodeHtml("A rest"));
+ }
+
+ [TestMethod]
+ [Ignore("not yet spec compliant")]
+ public void DecodeHtml_OutOfRangeCodePoint_DecodesToReplacementCharacter()
+ {
+ // WHATWG HTML 13.2.5.80 numeric-character-reference-end-state: an out-of-range code point
+ // resolves to U+FFFD REPLACEMENT CHARACTER, not to nothing. HTML-Renderer's
+ // DecodeHtmlCharByCode leaves `repl` as string.Empty for a code point outside 0..0x10FFFF (or
+ // inside the surrogate range), so the reference is silently dropped instead.
+ Assert.AreEqual("�", HtmlUtils.DecodeHtml(""));
+ }
+
+ [TestMethod]
+ [Ignore("not yet spec compliant")]
+ public void DecodeHtml_SurrogateRangeCodePoint_DecodesToReplacementCharacter()
+ {
+ Assert.AreEqual("�", HtmlUtils.DecodeHtml(""));
+ }
+
+ [TestMethod]
+ [Ignore("not yet spec compliant")]
+ public void DecodeHtml_NullCodePoint_DecodesToReplacementCharacter()
+ {
+ // Unlike the out-of-range/surrogate cases (which decode to an empty string), U+0000 passes
+ // HTML-Renderer's range check and is converted verbatim via Char.ConvertFromUtf32(0), yielding
+ // a literal NUL character instead of the spec's U+FFFD replacement.
+ Assert.AreEqual("�", HtmlUtils.DecodeHtml(""));
+ }
+
+ [TestMethod]
+ public void DecodeHtml_NoEntities_ReturnsUnchanged()
+ {
+ Assert.AreEqual("plain text", HtmlUtils.DecodeHtml("plain text"));
+ }
+
+ [TestMethod]
+ public void DecodeHtml_NullOrEmpty_ReturnsInput()
+ {
+ Assert.IsNull(HtmlUtils.DecodeHtml(null!));
+ Assert.AreEqual(string.Empty, HtmlUtils.DecodeHtml(string.Empty));
+ }
+}