|
| 1 | +using System.Collections.Generic; |
| 2 | +using System.Drawing; |
| 3 | +using System.Linq; |
| 4 | +using System.Reflection; |
| 5 | +using Microsoft.VisualStudio.TestTools.UnitTesting; |
| 6 | +using TheArtOfDev.HtmlRenderer.Core; |
| 7 | +using TheArtOfDev.HtmlRenderer.Core.Dom; |
| 8 | +using TheArtOfDev.HtmlRenderer.WinForms; |
| 9 | + |
| 10 | +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; |
| 11 | + |
| 12 | +/// <summary> |
| 13 | +/// Verifies a real, spec-confirmed default-behavior gap found while auditing this port's fragmentation |
| 14 | +/// engine against PeachPDF a third time, then checking the actual W3C text directly |
| 15 | +/// (<see href="https://drafts.csswg.org/css-tables-3/#breaking-rules">css-tables-3 §6.1</see>, current |
| 16 | +/// Editor's Draft): "When fragmenting a table, user agents <b>must</b> attempt to preserve the table rows |
| 17 | +/// unfragmented if the cells spanning the row do not span any subsequent row, and their height is at |
| 18 | +/// least twice smaller than both the fragmentainer height and width. Other rows are said <i>freely |
| 19 | +/// fragmentable</i>." This is phrased as a required UA default, not something an author opts into - |
| 20 | +/// <c>CssLayoutEngineTable.LayoutCells</c> previously only preserved a row when the TABLE had explicit |
| 21 | +/// <c>break-inside:avoid</c>, meaning an ordinary multi-page table with no special markup at all rendered |
| 22 | +/// rows split across page boundaries by default, which the spec does not permit as the default. |
| 23 | +/// </summary> |
| 24 | +[TestClass] |
| 25 | +[DoNotParallelize] |
| 26 | +public sealed class TableRowDefaultAtomicityTest |
| 27 | +{ |
| 28 | + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) |
| 29 | + { |
| 30 | + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; |
| 31 | + return (HtmlContainerInt)prop.GetValue(wrapper)!; |
| 32 | + } |
| 33 | + |
| 34 | + private static IEnumerable<CssBox> Walk(CssBox box) |
| 35 | + { |
| 36 | + yield return box; |
| 37 | + foreach (var b in box.Boxes) |
| 38 | + foreach (var d in Walk(b)) |
| 39 | + yield return d; |
| 40 | + } |
| 41 | + |
| 42 | + [TestMethod] |
| 43 | + public async Task OrdinaryRowWithNoBreakInsideAvoid_IsStillPreservedUnfragmented_ByDefault() |
| 44 | + { |
| 45 | + var checkedAnyStraddleCandidate = false; |
| 46 | + |
| 47 | + for (var fillerCount = 1; fillerCount < 60; fillerCount++) |
| 48 | + { |
| 49 | + using var wrapper = new HtmlContainer(); |
| 50 | + var filler = string.Concat(Enumerable.Repeat("<p style='margin:0;'>filler line</p>", fillerCount)); |
| 51 | + // Deliberately no break-inside:avoid anywhere - this is the plain, no-special-markup case |
| 52 | + // css-tables-3 §6.1 says every conformant UA must handle this way by default. |
| 53 | + await wrapper.SetHtml( |
| 54 | + $""" |
| 55 | + <html><body> |
| 56 | + {filler} |
| 57 | + <table style="border-collapse:collapse;"> |
| 58 | + <tr><td>RowOneCell</td></tr> |
| 59 | + <tr><td>TargetRowCellText with several words giving it real, non-trivial height</td></tr> |
| 60 | + </table> |
| 61 | + </body></html> |
| 62 | + """); |
| 63 | + |
| 64 | + var container = GetInternal(wrapper); |
| 65 | + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(300, 300); |
| 66 | + container.MarginTop = 0; |
| 67 | + wrapper.MaxSize = new SizeF(300, 0); |
| 68 | + |
| 69 | + using var bitmap = new Bitmap(300, 20000); |
| 70 | + using var g = Graphics.FromImage(bitmap); |
| 71 | + wrapper.PerformLayout(g); |
| 72 | + |
| 73 | + var tds = Walk(container.Root).Where(b => b.HtmlTag?.Name == "td").ToList(); |
| 74 | + if (tds.Count < 2) |
| 75 | + continue; |
| 76 | + var targetCell = tds[1]; |
| 77 | + |
| 78 | + var topSlot = container.PageIndexOf(targetCell.Location.Y); |
| 79 | + var bottomSlot = container.PageIndexOf(System.Math.Max(targetCell.Location.Y, targetCell.ActualBottom - 0.01)); |
| 80 | + |
| 81 | + checkedAnyStraddleCandidate = true; |
| 82 | + Assert.AreEqual(topSlot, bottomSlot, |
| 83 | + $"at fillerCount={fillerCount}, the second row straddles page slots {topSlot}->{bottomSlot} with no break-inside:avoid anywhere - css-tables-3 6.1 requires it stay whole by default"); |
| 84 | + } |
| 85 | + |
| 86 | + Assert.IsTrue(checkedAnyStraddleCandidate, "no filler count in range produced a target cell - test is not meaningful as written"); |
| 87 | + } |
| 88 | + |
| 89 | + [TestMethod] |
| 90 | + public async Task RowSpanningIntoASubsequentRow_RemainsFreelyFragmentable() |
| 91 | + { |
| 92 | + // css-tables-3 6.1's own carve-out: a row a rowspan cell only STARTS in (spanning further rows) |
| 93 | + // is explicitly excluded from the "preserve unfragmented" default - confirming the new default |
| 94 | + // atomicity doesn't overreach into content the spec says must stay freely fragmentable. |
| 95 | + var foundAStraddle = false; |
| 96 | + |
| 97 | + for (var fillerCount = 1; fillerCount < 30; fillerCount++) |
| 98 | + { |
| 99 | + using var wrapper = new HtmlContainer(); |
| 100 | + var filler = string.Concat(Enumerable.Repeat("<p style='margin:0;'>filler line</p>", fillerCount)); |
| 101 | + await wrapper.SetHtml( |
| 102 | + $""" |
| 103 | + <html><body> |
| 104 | + {filler} |
| 105 | + <table style="border-collapse:collapse;"> |
| 106 | + <tr><td rowspan="2">SpanCell</td><td>Row1Cell2 with enough words to make this row meaningfully tall for the straddle test to matter</td></tr> |
| 107 | + <tr><td>Row2Cell</td></tr> |
| 108 | + </table> |
| 109 | + </body></html> |
| 110 | + """); |
| 111 | + |
| 112 | + var container = GetInternal(wrapper); |
| 113 | + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(300, 300); |
| 114 | + container.MarginTop = 0; |
| 115 | + wrapper.MaxSize = new SizeF(300, 0); |
| 116 | + |
| 117 | + using var bitmap = new Bitmap(300, 20000); |
| 118 | + using var g = Graphics.FromImage(bitmap); |
| 119 | + wrapper.PerformLayout(g); |
| 120 | + |
| 121 | + var row1Cell2 = Walk(container.Root) |
| 122 | + .FirstOrDefault(b => b.Words.Any(w => w.Text.Contains("Row1Cell2"))) |
| 123 | + ?.ParentBox; |
| 124 | + if (row1Cell2 == null) |
| 125 | + continue; |
| 126 | + |
| 127 | + var topSlot = container.PageIndexOf(row1Cell2.Location.Y); |
| 128 | + var bottomSlot = container.PageIndexOf(System.Math.Max(row1Cell2.Location.Y, row1Cell2.ActualBottom - 0.01)); |
| 129 | + if (topSlot != bottomSlot) |
| 130 | + foundAStraddle = true; |
| 131 | + } |
| 132 | + |
| 133 | + Assert.IsTrue(foundAStraddle, "expected at least one filler count where the rowspan-starting row straddles a page boundary - if none do, this test isn't exercising the carve-out"); |
| 134 | + } |
| 135 | +} |
0 commit comments