oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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).
51package layout
52
53import (
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.
68type 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.
76type 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.
87type 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.
94type 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.
104type 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.
114type 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.
127type 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.
142type 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.
152type Run struct {
153 S string
154 Face fonts.Face
155 Size float64
156 Fill Fill
157 Href string
158}
159
160func (Rect) kind() string { return "rect" }
161func (Ellipse) kind() string { return "ellipse" }
162func (Line) kind() string { return "line" }
163func (Image) kind() string { return "image" }
164func (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.
173type 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
187const (
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
217func (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.
234func r4(v float64) float64 { return math.Round(v*1e4) / 1e4 }
235
236func (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
250func (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
263func (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
276func (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
289func (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.
301func (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.
315func 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