| 1 | // Package layout is the print half of the one rule (spec §0.1): the |
| 2 | // same block tree internal/slides/render turns into HTML, measured and |
| 3 | // placed into a fixed 1920 by 1080 frame. |
| 4 | // |
| 5 | // What comes out is a paint list — a background and a flat, ordered |
| 6 | // sequence of rectangles, ellipses, lines, images and positioned runs |
| 7 | // of text. It is the ONLY input to the SVG serialiser and to the PDF |
| 8 | // writer (spec §4.2, and the slice 3 plan's global constraints say it |
| 9 | // out loud): nothing downstream of here reads a deck. That is what |
| 10 | // keeps a thumbnail and a printed page from being two opinions about |
| 11 | // one slide. |
| 12 | // |
| 13 | // # Where the numbers come from |
| 14 | // |
| 15 | // internal/slides/static/slides.css, evaluated at 1920 by 1080 — |
| 16 | // sizes.go for the scale, regions.go for each template's grid. Both |
| 17 | // are written as the arithmetic they are so a stylesheet change can be |
| 18 | // followed here by reading it, and both were CHECKED against a real |
| 19 | // Chromium rather than reasoned about: internal/slidestest's |
| 20 | // TestTheStylesheetMeasuresWhatTheLayoutEngineAssumes is that check, |
| 21 | // and it found two mistakes the Go tests could not have (a container |
| 22 | // query unit measures the content box, and a picture in a region is a |
| 23 | // flex item that shrinks). |
| 24 | // |
| 25 | // # What this does not claim |
| 26 | // |
| 27 | // Pixel parity with the browser. It is close — every region box and |
| 28 | // type size above agrees with Chromium to within a pixel — and these |
| 29 | // are the places it is not: |
| 30 | // |
| 31 | // - Line breaking, which spec §4.2 names as the expected divergence. |
| 32 | // Both sides measure with the same font programs at the same |
| 33 | // sizes, but this one does not kern (internal/slides/fonts reads |
| 34 | // no GPOS), so a measured string is about a percent narrower in |
| 35 | // the browser and a line can break one word earlier here. |
| 36 | // - A shape's label is drawn here and is not on screen: render puts |
| 37 | // it in the SVG's <title>, where it is announced and not painted, |
| 38 | // and a paint list has no accessibility tree to put it in. |
| 39 | // - image-full's heading scrim is opaque, where the stylesheet mixes |
| 40 | // it 82% towards transparent (layout.go says why), and the four |
| 41 | // greys the stylesheet builds with color-mix() in oklab are mixed |
| 42 | // in plain sRGB (props.go says why). |
| 43 | // |
| 44 | // Text is never clipped. A block whose content outgrows its box draws |
| 45 | // past it and records a Warning, exactly as the stylesheet lets it |
| 46 | // overflow rather than cutting a sentence in half (slides.css's canvas |
| 47 | // rules say why: losing the end of a line to `overflow: hidden` is a |
| 48 | // WCAG 1.4.10 failure nobody ever reports). |
| 49 | // |
| 50 | // The phone layer is never read (§2.5: the print engine does not). |
| 51 | package layout |
| 52 | |
| 53 | import ( |
| 54 | "bytes" |
| 55 | "encoding/json" |
| 56 | "math" |
| 57 | |
| 58 | "amadan.net/oficina/slides/internal/slides/fonts" |
| 59 | ) |
| 60 | |
| 61 | // Fill is a colour, as `#rrggbb`, or "" for none. |
| 62 | // |
| 63 | // A string rather than a struct because every one of these came from |
| 64 | // the theme (render.SlideVars) or from a prop render already |
| 65 | // allowlisted, and every consumer — SVG, PDF — wants it back as text. |
| 66 | // "" is the absence of a colour, which is a real value: a shape with |
| 67 | // no fill is an outline. |
| 68 | type Fill string |
| 69 | |
| 70 | // Paint is one slide, drawn. |
| 71 | // |
| 72 | // W and H are always the 1920 by 1080 frame (§9: other aspect ratios |
| 73 | // are not built). Ops are in paint order — first drawn is furthest |
| 74 | // back — which for a canvas slide is z order and for a flow slide is |
| 75 | // the order the regions and their blocks are read in. |
| 76 | type Paint struct { |
| 77 | W, H float64 |
| 78 | Background Fill |
| 79 | Ops []Op |
| 80 | } |
| 81 | |
| 82 | // Op is one thing to draw. The interface is closed on purpose: the SVG |
| 83 | // serialiser and the PDF writer both switch over these five, and a |
| 84 | // sixth added without their knowing would be silently skipped by one |
| 85 | // of them. Adding one means adding it there too, which is what an |
| 86 | // unexported method makes unavoidable. |
| 87 | type Op interface { |
| 88 | // kind is the name this op is written under in a paint list. |
| 89 | kind() string |
| 90 | } |
| 91 | |
| 92 | // Rect is a rectangle: a code block's panel, a table rule, a |
| 93 | // highlight, a shape. |
| 94 | type Rect struct { |
| 95 | X, Y, W, H float64 |
| 96 | Fill Fill |
| 97 | Stroke Fill |
| 98 | StrokeW float64 |
| 99 | Radius float64 |
| 100 | } |
| 101 | |
| 102 | // Ellipse is an ellipse inscribed in the box X, Y, W, H — the box, not |
| 103 | // a centre and two radii, because every caller here has a box. |
| 104 | type Ellipse struct { |
| 105 | X, Y, W, H float64 |
| 106 | Fill Fill |
| 107 | Stroke Fill |
| 108 | StrokeW float64 |
| 109 | } |
| 110 | |
| 111 | // Line is a straight line. Arrow puts a solid head at the second end, |
| 112 | // which is the `arrow` shape and the only decoration this vocabulary |
| 113 | // has. |
| 114 | type Line struct { |
| 115 | X1, Y1, X2, Y2 float64 |
| 116 | Stroke Fill |
| 117 | W float64 |
| 118 | Arrow bool |
| 119 | } |
| 120 | |
| 121 | // Image is a picture to place in a box. Asset is the deck's own asset |
| 122 | // id (§2.7) and NOT a URL or a ref: what a consumer does with it |
| 123 | // differs — a thumbnail inlines the bytes as a data URI, a PDF embeds |
| 124 | // them, an SVG for the editor points at a route — and each of those |
| 125 | // needs the deck to resolve it. Fit is `cover` or `contain`, Alt is |
| 126 | // what §2.4 requires of every image. |
| 127 | type Image struct { |
| 128 | X, Y, W, H float64 |
| 129 | Asset string |
| 130 | Fit string |
| 131 | Alt string |
| 132 | } |
| 133 | |
| 134 | // Text is one line of text at a baseline. X is where the line starts |
| 135 | // and Y is the baseline — not the top — because that is what both a |
| 136 | // PDF text object and an SVG <text> take, and because runs of |
| 137 | // different sizes on one line share a baseline and share nothing else. |
| 138 | // |
| 139 | // One op per line. The line breaking already happened (text.go); a |
| 140 | // consumer draws the runs left to right, advancing by each run's own |
| 141 | // measured width, and never re-wraps. |
| 142 | type Text struct { |
| 143 | X, Y float64 |
| 144 | Runs []Run |
| 145 | } |
| 146 | |
| 147 | // Run is a stretch of text in one face at one size in one colour. |
| 148 | // Href is the link a run carries (§2.6's `href` mark), for a consumer |
| 149 | // that can make one; the underline a link is drawn with is a separate |
| 150 | // Line op, because §8 does not let colour alone carry state and a PDF |
| 151 | // annotation would not put a mark on the page. |
| 152 | type Run struct { |
| 153 | S string |
| 154 | Face fonts.Face |
| 155 | Size float64 |
| 156 | Fill Fill |
| 157 | Href string |
| 158 | } |
| 159 | |
| 160 | func (Rect) kind() string { return "rect" } |
| 161 | func (Ellipse) kind() string { return "ellipse" } |
| 162 | func (Line) kind() string { return "line" } |
| 163 | func (Image) kind() string { return "image" } |
| 164 | func (Text) kind() string { return "text" } |
| 165 | |
| 166 | // ── Warnings ───────────────────────────────────────────────────────── |
| 167 | |
| 168 | // Warning is something the caller should be able to say out loud about |
| 169 | // an export: this block ran past its box, this deck asked for a |
| 170 | // character these faces cannot draw. §11's degrade-loudly rule — the |
| 171 | // slide is still drawn, and the reason it looks wrong is reported |
| 172 | // rather than left for somebody to discover in a printed handout. |
| 173 | type Warning struct { |
| 174 | Kind string `json:"kind"` |
| 175 | // Slide is the id of the slide the warning is about. Layout stamps |
| 176 | // it on everything it reports, so a caller holding the warnings |
| 177 | // from a whole deck — an export's status page — can say WHICH |
| 178 | // slide is wrong. Without it a warning list is a set of true |
| 179 | // sentences about a deck and the person reading it has to open |
| 180 | // every slide to find the picture that went missing. Empty only on |
| 181 | // a warning made outside any slide. |
| 182 | Slide string `json:"slide,omitempty"` |
| 183 | Block string `json:"block,omitempty"` |
| 184 | Detail string `json:"detail"` |
| 185 | } |
| 186 | |
| 187 | const ( |
| 188 | // WarnOverflow is content drawn past the box it was given. |
| 189 | WarnOverflow = "overflow" |
| 190 | // WarnGlyph is a rune no embedded face has a glyph for. It is |
| 191 | // drawn at .notdef's width, which is what a browser shows a box |
| 192 | // at. |
| 193 | WarnGlyph = "glyph" |
| 194 | // WarnAsset is an image block whose asset this deck never |
| 195 | // registered, or registered with a ref nothing can serve. |
| 196 | WarnAsset = "asset" |
| 197 | // WarnFont is a face this deck asked for that this build does not |
| 198 | // carry (meta.fonts, §2.1) — measured in a substitute, which the |
| 199 | // browser will not be using. |
| 200 | WarnFont = "font" |
| 201 | // WarnUnreached is a block named as a child by a columns block |
| 202 | // that nothing on the slide reaches — a cycle, which the ops refuse |
| 203 | // but a merge of two peers' edits can still produce (codex 7). It |
| 204 | // is drawn after everything else rather than dropped, and this says |
| 205 | // so. |
| 206 | WarnUnreached = "unreached" |
| 207 | ) |
| 208 | |
| 209 | // ── The paint list as JSON ─────────────────────────────────────────── |
| 210 | // |
| 211 | // Only ever written, never read back: it is what the golden files in |
| 212 | // testdata are, and a paint list is produced from a deck rather than |
| 213 | // loaded. So there is no UnmarshalJSON here, and the shapes below are |
| 214 | // chosen for a person reading a diff — an op says its own name first, |
| 215 | // and a face is its PostScript name rather than three fields. |
| 216 | |
| 217 | func (p Paint) MarshalJSON() ([]byte, error) { |
| 218 | type paint struct { |
| 219 | W float64 `json:"w"` |
| 220 | H float64 `json:"h"` |
| 221 | Background Fill `json:"background"` |
| 222 | Ops []Op `json:"ops"` |
| 223 | } |
| 224 | return json.Marshal(paint{p.W, p.H, p.Background, p.Ops}) |
| 225 | } |
| 226 | |
| 227 | // r4 rounds a coordinate to four decimals for the paint list's JSON. |
| 228 | // |
| 229 | // The arithmetic here runs through ratios of a 19.2px unit, so a |
| 230 | // coordinate that is 126.72 comes out of the floats as |
| 231 | // 126.72000000000001 about as often as not. Nothing downstream can see |
| 232 | // a ten-thousandth of a pixel, and a golden file that changes because |
| 233 | // a sum was reassociated is a golden file nobody reads. |
| 234 | func r4(v float64) float64 { return math.Round(v*1e4) / 1e4 } |
| 235 | |
| 236 | func (r Rect) MarshalJSON() ([]byte, error) { |
| 237 | type alias struct { |
| 238 | X float64 `json:"x"` |
| 239 | Y float64 `json:"y"` |
| 240 | W float64 `json:"w"` |
| 241 | H float64 `json:"h"` |
| 242 | Fill Fill `json:"fill,omitempty"` |
| 243 | Stroke Fill `json:"stroke,omitempty"` |
| 244 | StrokeW float64 `json:"strokeWidth,omitempty"` |
| 245 | Radius float64 `json:"radius,omitempty"` |
| 246 | } |
| 247 | return opJSON(r.kind(), alias{r4(r.X), r4(r.Y), r4(r.W), r4(r.H), r.Fill, r.Stroke, r4(r.StrokeW), r4(r.Radius)}) |
| 248 | } |
| 249 | |
| 250 | func (e Ellipse) MarshalJSON() ([]byte, error) { |
| 251 | type alias struct { |
| 252 | X float64 `json:"x"` |
| 253 | Y float64 `json:"y"` |
| 254 | W float64 `json:"w"` |
| 255 | H float64 `json:"h"` |
| 256 | Fill Fill `json:"fill,omitempty"` |
| 257 | Stroke Fill `json:"stroke,omitempty"` |
| 258 | StrokeW float64 `json:"strokeWidth,omitempty"` |
| 259 | } |
| 260 | return opJSON(e.kind(), alias{r4(e.X), r4(e.Y), r4(e.W), r4(e.H), e.Fill, e.Stroke, r4(e.StrokeW)}) |
| 261 | } |
| 262 | |
| 263 | func (l Line) MarshalJSON() ([]byte, error) { |
| 264 | type alias struct { |
| 265 | X1 float64 `json:"x1"` |
| 266 | Y1 float64 `json:"y1"` |
| 267 | X2 float64 `json:"x2"` |
| 268 | Y2 float64 `json:"y2"` |
| 269 | Stroke Fill `json:"stroke,omitempty"` |
| 270 | W float64 `json:"width"` |
| 271 | Arrow bool `json:"arrow,omitempty"` |
| 272 | } |
| 273 | return opJSON(l.kind(), alias{r4(l.X1), r4(l.Y1), r4(l.X2), r4(l.Y2), l.Stroke, r4(l.W), l.Arrow}) |
| 274 | } |
| 275 | |
| 276 | func (i Image) MarshalJSON() ([]byte, error) { |
| 277 | type alias struct { |
| 278 | X float64 `json:"x"` |
| 279 | Y float64 `json:"y"` |
| 280 | W float64 `json:"w"` |
| 281 | H float64 `json:"h"` |
| 282 | Asset string `json:"asset"` |
| 283 | Fit string `json:"fit"` |
| 284 | Alt string `json:"alt"` |
| 285 | } |
| 286 | return opJSON(i.kind(), alias{r4(i.X), r4(i.Y), r4(i.W), r4(i.H), i.Asset, i.Fit, i.Alt}) |
| 287 | } |
| 288 | |
| 289 | func (t Text) MarshalJSON() ([]byte, error) { |
| 290 | type alias struct { |
| 291 | X float64 `json:"x"` |
| 292 | Y float64 `json:"y"` |
| 293 | Runs []Run `json:"runs"` |
| 294 | } |
| 295 | return opJSON(t.kind(), alias{r4(t.X), r4(t.Y), t.Runs}) |
| 296 | } |
| 297 | |
| 298 | // MarshalJSON writes the face by name — "LiberationSans-Bold" — which |
| 299 | // is what a person reading a golden file wants to see, and what the |
| 300 | // PDF registers the face under anyway. |
| 301 | func (r Run) MarshalJSON() ([]byte, error) { |
| 302 | type alias struct { |
| 303 | S string `json:"s"` |
| 304 | Face string `json:"face"` |
| 305 | Size float64 `json:"size"` |
| 306 | Fill Fill `json:"fill,omitempty"` |
| 307 | Href string `json:"href,omitempty"` |
| 308 | } |
| 309 | return json.Marshal(alias{r.S, r.Face.PostScriptName(), r4(r.Size), r.Fill, r.Href}) |
| 310 | } |
| 311 | |
| 312 | // opJSON marshals v and splices the op's name in as the first key, so |
| 313 | // a paint list reads as a list of named things rather than of |
| 314 | // coordinates that have to be guessed at. |
| 315 | func opJSON(kind string, v any) ([]byte, error) { |
| 316 | b, err := json.Marshal(v) |
| 317 | if err != nil { |
| 318 | return nil, err |
| 319 | } |
| 320 | name, err := json.Marshal(kind) |
| 321 | if err != nil { |
| 322 | return nil, err |
| 323 | } |
| 324 | var buf bytes.Buffer |
| 325 | buf.WriteString(`{"op":`) |
| 326 | buf.Write(name) |
| 327 | if len(b) > 2 { |
| 328 | buf.WriteByte(',') |
| 329 | buf.Write(b[1 : len(b)-1]) |
| 330 | } |
| 331 | buf.WriteByte('}') |
| 332 | return buf.Bytes(), nil |
| 333 | } |
| 334 | |