| 1 | //go:build browser |
| 2 | |
| 3 | // The layout engine's own drive. internal/slides/layout measures a |
| 4 | // slide the way slides.css would at 1920 by 1080 — it is a reading of |
| 5 | // that stylesheet, done in Go, and everything downstream of it (the |
| 6 | // SVG thumbnail, the PDF) inherits whatever that reading gets wrong. |
| 7 | // |
| 8 | // Nothing in the Go tests can catch a misreading: they check the engine |
| 9 | // against itself. This file is the other half — a real Chromium laying |
| 10 | // out real slide markup at 1920, with the numbers sizes.go and |
| 11 | // regions.go were derived from asserted against what the browser |
| 12 | // actually does. |
| 13 | // |
| 14 | // It caught two mistakes when it was written (slice 3 task 2), and |
| 15 | // they are the reason it exists rather than a comment saying the |
| 16 | // numbers looked right: |
| 17 | // |
| 18 | // - A container query unit is a percent of the container's CONTENT |
| 19 | // box, so a `cqw` on a slide with the section's 120px padding is |
| 20 | // 16.8px and not 19.2. Every type size on six of the seven |
| 21 | // templates was 12.5% too big. |
| 22 | // - A picture or a shape in a flow region is a flex item that |
| 23 | // SHRINKS to the space that is left; the engine was stacking a |
| 24 | // square 1680px picture and running three slides off the bottom. |
| 25 | package slidestest |
| 26 | |
| 27 | import ( |
| 28 | "encoding/json" |
| 29 | "testing" |
| 30 | |
| 31 | "github.com/chromedp/chromedp" |
| 32 | ) |
| 33 | |
| 34 | // wantGeometry is what slides.css computes at 1920, and every number |
| 35 | // in it has a twin in internal/slides/layout (sizes.go's scale, and |
| 36 | // regions.go's table). When this fails, one of the two has moved: read |
| 37 | // the diff before changing either. |
| 38 | // |
| 39 | // The tolerance is a pixel. Chromium rounds a used font size to a |
| 40 | // 64th, so 121.968 comes back as 121.95, and pinning tighter than that |
| 41 | // would fail on a browser upgrade without anything being wrong. |
| 42 | const tolerance = 1.0 |
| 43 | |
| 44 | type rect struct{ X, Y, W, H float64 } |
| 45 | |
| 46 | type geometry struct { |
| 47 | TwoColumnHeading rect `json:"twoColumnHeading"` |
| 48 | TwoColumnBody rect `json:"twoColumnBody"` |
| 49 | TwoColumnAside rect `json:"twoColumnAside"` |
| 50 | TitleHeading rect `json:"titleHeading"` |
| 51 | TitleBody rect `json:"titleBody"` |
| 52 | ImageFullMedia rect `json:"imageFullMedia"` |
| 53 | BlankRegion rect `json:"blankRegion"` |
| 54 | BlankText rect `json:"blankText"` |
| 55 | BlankImage rect `json:"blankImage"` |
| 56 | PaddedT1 float64 `json:"paddedT1"` |
| 57 | PaddedBodyM float64 `json:"paddedBodyM"` |
| 58 | PaddedBodyL float64 `json:"paddedBodyL"` |
| 59 | FullT1 float64 `json:"fullT1"` |
| 60 | FullT2 float64 `json:"fullT2"` |
| 61 | HeadingLine float64 `json:"headingLine"` |
| 62 | BodyLine float64 `json:"bodyLine"` |
| 63 | WideGap float64 `json:"wideGap"` |
| 64 | NarrowGap float64 `json:"narrowGap"` |
| 65 | } |
| 66 | |
| 67 | func TestTheStylesheetMeasuresWhatTheLayoutEngineAssumes(t *testing.T) { |
| 68 | rig, _ := newRig(t) |
| 69 | |
| 70 | const vars = `style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, sans-serif"` |
| 71 | // One section per template under test, at the frame's own width. |
| 72 | // Built by hand rather than through render.Slide because the point |
| 73 | // is the STYLESHEET: this is the markup render produces, and a |
| 74 | // change in either that broke the pairing should fail here. |
| 75 | const markup = ` |
| 76 | <div style="width:1920px;position:absolute;left:0;top:0"> |
| 77 | <section slides-slide data-layout="flow" data-template="two-column" data-theme="paper" ` + vars + `> |
| 78 | <div slides-frame> |
| 79 | <div slides-region data-region="heading" id="TCH"><h1 slides-block data-block="heading" data-level="1" id="TCH1">Regions, not coordinates</h1></div> |
| 80 | <div slides-region data-region="body" id="TCB"><div slides-block data-block="text" data-size="m" id="TCT"><p>A flow slide names regions.</p></div></div> |
| 81 | <div slides-region data-region="aside" id="TCA"><div slides-block data-block="text" data-size="m"><p>The aside.</p></div></div> |
| 82 | </div> |
| 83 | </section> |
| 84 | <section slides-slide data-layout="flow" data-template="image-full" data-theme="paper" ` + vars + `> |
| 85 | <div slides-frame> |
| 86 | <div slides-region data-region="heading"><h2 slides-block data-block="heading" data-level="2" id="IFH2">A picture would go here</h2></div> |
| 87 | <div slides-region data-region="media" id="IFM"><figure slides-block data-block="image" data-fit="cover"><img src="/static/fonts/nothing.png" alt="x" width="1600" height="900"></figure></div> |
| 88 | </div> |
| 89 | </section> |
| 90 | <section slides-slide data-layout="canvas" data-template="blank" data-theme="paper" ` + vars + `> |
| 91 | <h1 slides-block data-block="heading" data-level="1" data-z="1" id="CVH" style="left:6%;top:8%;width:88%;height:16%;z-index:1">Canvas</h1> |
| 92 | </section> |
| 93 | <section slides-slide data-layout="flow" data-template="title" data-theme="paper" ` + vars + `> |
| 94 | <div slides-frame> |
| 95 | <div slides-region data-region="heading" id="TH"><h1 slides-block data-block="heading" data-level="1">Renderer fixture</h1></div> |
| 96 | <div slides-region data-region="body" id="TB"><div slides-block data-block="text" data-size="l" id="TT"><p>One section per slide, one element per block.</p></div></div> |
| 97 | </div> |
| 98 | </section> |
| 99 | <section slides-slide data-layout="flow" data-template="blank" data-theme="paper" ` + vars + `> |
| 100 | <div slides-frame> |
| 101 | <div slides-region data-region="body" id="BR"> |
| 102 | <div slides-block data-block="text" data-size="m" id="BT"><p>One line.</p></div> |
| 103 | <figure slides-block data-block="image" data-fit="contain" id="BI"><img src="/static/fonts/nothing.png" alt="x" width="64" height="64"></figure> |
| 104 | </div> |
| 105 | </div></section> |
| 106 | </div> |
| 107 | <div style="width:780px;position:absolute;left:0;top:2000px"> |
| 108 | <section slides-slide data-layout="flow" data-template="title-body" data-theme="paper" ` + vars + `> |
| 109 | <div slides-frame> |
| 110 | <div slides-region data-region="heading" id="NGH"><h1 slides-block data-block="heading" data-level="1">Narrow</h1></div> |
| 111 | <div slides-region data-region="body" id="NGB"><div slides-block data-block="text" data-size="m"><p>Same slide, smaller.</p></div></div> |
| 112 | </div> |
| 113 | </section> |
| 114 | </div>` |
| 115 | |
| 116 | var raw string |
| 117 | rig.Run( |
| 118 | // A 1920 viewport, which is the projector and the export. The |
| 119 | // slide is drawn at 1920 in the page too — and the second copy |
| 120 | // at 780, which is what proves the gap no longer follows the |
| 121 | // window (see narrowGap below). |
| 122 | chromedp.EmulateViewport(1920, 1080), |
| 123 | chromedp.Navigate(rig.Origin+"/signin"), |
| 124 | chromedp.Evaluate(`(() => { |
| 125 | const link = document.createElement('link'); |
| 126 | link.rel = 'stylesheet'; |
| 127 | link.href = '/static/slides.css'; |
| 128 | document.head.appendChild(link); |
| 129 | document.body.insertAdjacentHTML('beforeend', `+"`"+markup+"`"+`); |
| 130 | return 'ok'; |
| 131 | })()`, new(string)), |
| 132 | // Long enough for the stylesheet and the faces it asks for. |
| 133 | chromedp.Sleep(2_000_000_000), |
| 134 | chromedp.Evaluate(`(() => { |
| 135 | const q = id => { const e = document.getElementById(id); const r = e.getBoundingClientRect(); |
| 136 | const p = e.closest('[slides-slide]').getBoundingClientRect(); |
| 137 | return {X: r.x-p.x, Y: r.y-p.y, W: r.width, H: r.height}; }; |
| 138 | const px = (id, prop) => parseFloat(getComputedStyle(document.getElementById(id))[prop]); |
| 139 | return JSON.stringify({ |
| 140 | twoColumnHeading: q('TCH'), twoColumnBody: q('TCB'), twoColumnAside: q('TCA'), |
| 141 | titleHeading: q('TH'), titleBody: q('TB'), |
| 142 | imageFullMedia: q('IFM'), |
| 143 | blankRegion: q('BR'), blankText: q('BT'), blankImage: q('BI'), |
| 144 | paddedT1: px('TCH1','fontSize'), paddedBodyM: px('TCT','fontSize'), paddedBodyL: px('TT','fontSize'), |
| 145 | fullT1: px('CVH','fontSize'), fullT2: px('IFH2','fontSize'), |
| 146 | headingLine: px('TCH1','lineHeight'), bodyLine: px('TCT','lineHeight'), |
| 147 | wideGap: q('TCB').Y - (q('TCH').Y + q('TCH').H), |
| 148 | narrowGap: (() => { const h = document.getElementById('NGH').getBoundingClientRect(); |
| 149 | const b = document.getElementById('NGB').getBoundingClientRect(); |
| 150 | return b.y - (h.y + h.height); })(), |
| 151 | }); |
| 152 | })()`, &raw), |
| 153 | ) |
| 154 | |
| 155 | var got geometry |
| 156 | if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| 157 | t.Fatalf("read the browser's geometry: %v\n%s", err, raw) |
| 158 | } |
| 159 | |
| 160 | // The type scale. 16.8 per cqw with the section's padding, 19.2 |
| 161 | // without it (image-full and canvas both set `padding: 0`). |
| 162 | for _, c := range []struct { |
| 163 | name string |
| 164 | got, want float64 |
| 165 | derivation string |
| 166 | }{ |
| 167 | {"--slides-t-1 on a padded template", got.PaddedT1, 110.88, "6.6 × 16.8"}, |
| 168 | {"--slides-body-m on a padded template", got.PaddedBodyM, 47.04, "2.8 × 16.8"}, |
| 169 | {"--slides-body-l on a padded template", got.PaddedBodyL, 57.12, "3.4 × 16.8"}, |
| 170 | {"--slides-t-1 on a canvas slide", got.FullT1, 126.72, "6.6 × 19.2"}, |
| 171 | {"--slides-t-2 on image-full", got.FullT2, 96, "5 × 19.2"}, |
| 172 | {"a heading's line box", got.HeadingLine, 121.968, "110.88 × 1.1"}, |
| 173 | {"a body line box", got.BodyLine, 61.152, "47.04 × 1.3"}, |
| 174 | } { |
| 175 | if diff := c.got - c.want; diff > tolerance || diff < -tolerance { |
| 176 | t.Errorf("%s is %v in the browser; layout/sizes.go says %v (%s)", |
| 177 | c.name, c.got, c.want, c.derivation) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | // The region boxes, which are layout/regions.go's table. |
| 182 | for _, c := range []struct { |
| 183 | name string |
| 184 | got rect |
| 185 | want rect |
| 186 | }{ |
| 187 | {"two-column heading", got.TwoColumnHeading, rect{120, 120, 1680, 121.968}}, |
| 188 | {"two-column body", got.TwoColumnBody, rect{120, 285.648, 805.056, 674.352}}, |
| 189 | {"two-column aside", got.TwoColumnAside, rect{994.944, 285.648, 805.056, 674.352}}, |
| 190 | // title centres its two auto rows as a pair, so where each one |
| 191 | // lands depends on how tall the other is. |
| 192 | {"title heading", got.TitleHeading, rect{573.19, 420.048, 773.61, 121.968}}, |
| 193 | {"title body", got.TitleBody, rect{386.98, 585.696, 1146.02, 74.256}}, |
| 194 | {"image-full media", got.ImageFullMedia, rect{0, 0, 1920, 1080}}, |
| 195 | {"a blank slide's body region", got.BlankRegion, rect{120, 120, 1680, 840}}, |
| 196 | } { |
| 197 | compareRect(t, c.name, c.got, c.want) |
| 198 | } |
| 199 | |
| 200 | // A picture in a flow region is a flex item and shrinks to what is |
| 201 | // left: 840 less the text above it and the gap between them. |
| 202 | compareRect(t, "a picture under one line of text", |
| 203 | got.BlankImage, rect{120, 224.832, 1680, 735.168}) |
| 204 | compareRect(t, "that line of text", got.BlankText, rect{120, 120, 1680, 61.152}) |
| 205 | |
| 206 | // The gap between regions, and the reason slice 3 moved the |
| 207 | // template grid onto [slides-frame]. It used to be written on the |
| 208 | // section, which is the query container, so the browser measured |
| 209 | // it against whatever was outside the slide: 49.92px here and |
| 210 | // 20.27px in a 780px window, for the same slide. On the frame it |
| 211 | // is 2.6% of the section's content box, so it is 43.68 at 1920 and |
| 212 | // 17.745 at 780 — the same slide, scaled, which is what the rest |
| 213 | // of the stylesheet already promised and what lets |
| 214 | // layout/sizes.go state one number for it. |
| 215 | // |
| 216 | // If either of these ever equals the other, the gap has stopped |
| 217 | // scaling with the slide and gone back to following the window. |
| 218 | for _, c := range []struct { |
| 219 | name string |
| 220 | got, want float64 |
| 221 | derivation string |
| 222 | }{ |
| 223 | {"the gap between regions at 1920", got.WideGap, 43.68, "2.6% of 1680"}, |
| 224 | {"the same gap on a 780px slide", got.NarrowGap, 17.745, "2.6% of 682.5"}, |
| 225 | } { |
| 226 | if diff := c.got - c.want; diff > tolerance || diff < -tolerance { |
| 227 | t.Errorf("%s is %v in the browser; slides.css means %v (%s)", |
| 228 | c.name, c.got, c.want, c.derivation) |
| 229 | } |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | func compareRect(t *testing.T, name string, got, want rect) { |
| 234 | t.Helper() |
| 235 | for _, c := range []struct { |
| 236 | axis string |
| 237 | got, want float64 |
| 238 | }{{"x", got.X, want.X}, {"y", got.Y, want.Y}, {"width", got.W, want.W}, {"height", got.H, want.H}} { |
| 239 | if diff := c.got - c.want; diff > tolerance || diff < -tolerance { |
| 240 | t.Errorf("%s: the browser puts its %s at %v; internal/slides/layout says %v", |
| 241 | name, c.axis, c.got, c.want) |
| 242 | } |
| 243 | } |
| 244 | } |
| 245 | |