nameplate.gno
18.77 Kb · 479 lines
1package nsview
2
3import (
4 "strings"
5 "time"
6
7 "gno.land/p/nt/ufmt/v0"
8)
9
10/*
11THE NAMEPLATE — the name, the asterisk and the expiry, composited over
12somebody else's artwork.
13
14WHY IT IS THE CONTRACT'S JOB AND NOT THE ARTIST'S. A skin is a picture
15somebody else drew. If they also controlled the lettering they could
16omit it, cover it, shrink it to nothing, or write different text — and a
17card that does not say which name it is, is not a name card. So the
18artist chooses three things from short lists (face, position, ink) and
19the contract does the rest.
20
21THE ASTERISK IS DRAWN, NEVER TYPED. Every typeface sets `*` small and
22lifted to cap height, because in running text it is a footnote marker.
23Here it is a letter — it is the entire brand — so it is drawn at the
24weight of the type around it, at the x-height of the lowercase letters
25beside it, and centred on their optical band. Typing it produces a small
26mark floating above the words, which is exactly what it is for and
27exactly wrong here.
28
29ONE ASTERISK IN ALL THREE FACES, cut square. Three shapes were tried —
30tapered for serif, square for sans, square and lighter for mono — and the
31lightest square one won outright, so the other two are gone. It is the
32mark, not a glyph borrowed from whatever face happens to be set; making
33it agree with the type was solving a problem nobody had.
34
35A REALM CANNOT MEASURE TEXT, and that used to shape the whole algorithm.
36It matters much less now: the widths below are real per-character
37metrics rather than one average, and textLength makes the renderer honour
38them. See the long note in NameplateSVG.
39
40This file is the single source of truth for the geometry. Anything that
41previews a card — the site, a mockup, a shop listing — must reproduce
42these numbers exactly or it is showing something that will not ship.
43tools/diff-nameplate.sh enforces that against the site's mirror.
44*/
45
46// The card is 400x400 and the name may use all but a margin either side.
47const (
48 plateW = 400
49 plateMargin = 30
50 plateAvail = plateW - 2*plateMargin // 340
51
52 plateMaxSize = 56 // any larger and a two-letter name dominates the art
53
54 // A LAST-RESORT floor, not a readability one, and the difference is
55 // a bug worth recording. This was 18 — "any smaller and it stops
56 // being readable in a thumbnail" — which is a fine aspiration and an
57 // impossible guarantee. maxLabelLen is 63, and sixty-three m's need
58 // about five pixels to fit a 400px card. The clamp won that argument
59 // and the name ran off the edge, inside somebody else's wallet.
60 //
61 // Fitting beats legibility because they are not comparable failures:
62 // a small name is small, a name that leaves the card is broken. And
63 // there is no third option — the card has to show the WHOLE name, so
64 // truncating it is not available. So the solver goes as small as the
65 // width demands and this is only the point below which a glyph would
66 // round away to nothing whatever we did. In practice it never binds:
67 // the longest legal name still solves above it.
68 plateMinSize = 4
69)
70
71/*
72GLYPH WIDTHS, per generic family, in thousandths of the type size.
73
74This replaced a single average-width ratio per family, and the reason is
75worth keeping, because the average was not merely imprecise — it put
76names visibly off centre, by an amount that depended on which letters
77somebody had picked.
78
79A serif "l" is 278 and a serif "m" is 833. One number cannot stand in for
80both. The old ratio was 560 against a true average of 488, so a name made
81of narrow letters — tessellation, say — came out forty per cent too wide,
82and because the asterisk is placed using that figure, the whole name slid
83sideways by half the error. That is exactly what showed up on the proof
84sheet: tessellation pushed right, cosmos slightly left, say*gm slightly
85right. Monospace was never off, because there one number IS the answer
86and 600 was within two thousandths of it.
87
88Only the three generic families are offered. Every renderer that shows
89these puts the SVG in a sandbox with no network, so a web font never
90loads — it silently substitutes, and the card becomes whatever that
91viewer happens to default to. serif, sans-serif and monospace are the
92only families that resolve everywhere. These are the metrics of the bold
93faces they resolve to almost everywhere; textLength covers the rest.
94
95Three digits per character, in the order of plateOrder. Names are
96[a-z0-9-] — lowercase is enforced at registration — so that is the whole
97alphabet this has to cover.
98*/
99const plateOrder = "abcdefghijklmnopqrstuvwxyz0123456789-"
100
101const (
102 plateWSerif = "500556444556444333500556278333556278833" +
103 "556500556556444389333556500722500500444" +
104 "500500500500500500500500500500333"
105
106 plateWSans = "556611556611556333611611278278556278889" +
107 "611611611611389556333611556778556556500" +
108 "556556556556556556556556556556333"
109
110 plateWMono = "602602602602602602602602602602602602602" +
111 "602602602602602602602602602602602602602" +
112 "602602602602602602602602602602602"
113)
114
115func plateWidths(face string) (tbl string, fallback int) {
116 switch face {
117 case "mono":
118 return plateWMono, 602
119 case "serif":
120 return plateWSerif, 488
121 default:
122 return plateWSans, 538
123 }
124}
125
126func plateFamily(face string) string {
127 switch face {
128 case "mono":
129 return "monospace"
130 case "serif":
131 return "serif"
132 default:
133 return "sans-serif"
134 }
135}
136
137// plateAdvance sums a string's width in thousandths of the type size.
138// A character outside the alphabet falls back to the family's average
139// rather than to zero: a name that somehow escapes the pattern should
140// lay out slightly wrong, not collapse into the asterisk.
141func plateAdvance(s, face string) int {
142 tbl, fallback := plateWidths(face)
143 total := 0
144 for i := 0; i < len(s); i++ {
145 idx := strings.IndexByte(plateOrder, s[i])
146 if idx < 0 || (idx*3+2) >= len(tbl) {
147 total += fallback
148 continue
149 }
150 total += int(tbl[idx*3]-'0')*100 + int(tbl[idx*3+1]-'0')*10 + int(tbl[idx*3+2]-'0')
151 }
152 return total
153}
154
155/*
156plateSize solves for the largest type size whose width fits.
157
158The asterisk occupies a column of its own, 0.86 of the type size wide,
159which is why it is in the denominator rather than added afterwards: the
160letters and the asterisk have to shrink together or the proportions
161drift as a name gets longer. That is the whole reason this is one
162calculation instead of two.
163
164`adv` is the summed advance of both halves, in thousandths.
165*/
166func plateSize(adv int, face string) int {
167 if adv < 1 {
168 adv = 1
169 }
170 // size * (adv/1000 + 0.86) <= avail
171 denom := adv + 860
172 size := plateAvail * 1000 / denom
173 if size > plateMaxSize {
174 size = plateMaxSize
175 }
176 if size < plateMinSize {
177 size = plateMinSize
178 }
179 return size
180}
181
182/*
183GEOMETRY, all derived from the type size so every proportion holds at
184every length.
185
186 capH 0.70 * size the height of an uppercase letter
187 xH 0.52 * size the height of a lowercase e — no ascender
188 starR 0.26 * size half the x-height, so the asterisk is exactly
189 as tall as an "e" and not as tall as a "b".
190 Matching the cap height made it the tallest
191 thing on the line; names are lowercase, so the
192 x-height is what the eye reads as "letter
193 height" and anything above it reads as a mark
194 starArm 0.104 * size the asterisk's own stroke. This is the weight a
195 MONOSPACED face sets an asterisk at — lighter
196 than the letters around it, because every glyph
197 there has to fit one fixed width — and it is
198 the one that read best of the three that were
199 tried, so it is now the weight in every face
200 outline 0.05 * size the border, on the letters AND the asterisk,
201 the same on both because a border that differs
202 between them looks like a mistake rather than
203 a choice. Thin enough to be a contrast device
204 and not a second typeface: a heavy outline
205 thickens every stem and the name stops looking
206 like the face it was set in
207*/
208func plateGeom(size int) (capH, xH, starR, starArm, outline int) {
209 capH = size * 70 / 100
210 xH = size * 52 / 100
211 starR = xH / 2
212 starArm = size * 104 / 1000
213 outline = size * 5 / 100
214 if outline < 1 {
215 outline = 1
216 }
217 if starArm < 2 {
218 starArm = 2
219 }
220 return
221}
222
223/*
224THE SIX RAYS, as unit vectors in thousandths.
225
226An asterisk is six rays at sixty degrees. In SVG's coordinate system y
227grows downward, so "up" is negative. cos30 is 866 and cos60 is 500.
228
229Listed in CLOCKWISE SCREEN ORDER starting from straight up, which
230starPoly depends on: it walks them in sequence to trace the outline, and
231any other order produces a self-intersecting polygon.
232*/
233var starRays = [6][2]int{
234 {0, -1000}, {866, -500}, {866, 500},
235 {0, 1000}, {-866, 500}, {-866, -500},
236}
237
238/*
239starPoints returns the asterisk's outline, IN HUNDREDTHS OF A PIXEL.
240
241WHY IT IS AN OUTLINE AND NOT THREE CROSSING LINES. The border has to be
242the same thickness the whole way round, exactly as it is on the letters,
243and drawing the shape twice at two stroke widths cannot give that.
244
245A wider stroke offsets a line sideways, but where two arms MEET the two
246wide strokes overlap and fill the valley between them in, so the border
247is fat at the hub. At the tips it only extends by its own half-width, so
248it is thin there. That is not a rounding error, it is the wrong tool.
249Widening a stroke is not the same operation as offsetting a boundary, and
250they only agree on a shape with no concave corners. An asterisk is
251nothing but concave corners.
252
253The letters do not have this problem because a renderer strokes a glyph's
254OUTLINE, which is a true offset — every point of the border ends up the
255same distance from the edge. So the asterisk gets an outline too, and
256then exactly the same two passes the text gets.
257
258WHY HUNDREDTHS, AND NOT WHOLE PIXELS. Because whole pixels made the six
259arms different widths, which is worse than the problem above and was
260plainly visible. The arm half-width is arm/2, and at the sizes this
261actually renders arm is 5, so the half-width is 2.5 — a number a pixel
262grid cannot hold. Worse, each direction lost a DIFFERENT amount to
263truncation: the vertical arm computed 1000*5/2000 = 2 exactly, while a
264diagonal computed (500*5/2000, 866*5/2000) = (1,2), whose length is
2652.236. So four arms came out twelve per cent fatter than the other two,
266and every arm was a fifth thinner than intended.
267
268SVG coordinates are not integers, so the fix is simply to stop rounding
269to them. Everything below is computed in hundredths of a pixel and
270printed with two decimals, which brings the six half-widths to 2.500 and
2712.496 — a spread of 0.16% instead of 12%.
272
273THE GEOMETRY. Each ray is a rectangle of half-width arm/2 out to radius
274r, cut square at the tip. Where two adjacent rays meet, their inner edges
275cross on the bisector — and for rays sixty degrees apart that crossing
276sits at radius exactly arm, which falls out of the algebra rather than
277being tuned. Eighteen points: two tip corners and one valley per ray,
278walked in the ray order above.
279*/
280func starPoints(cx, cy, r, arm int) [18][2]int {
281 var out [18][2]int
282 for i := 0; i < 6; i++ {
283 u := starRays[i]
284 n := starRays[(i+1)%6]
285 // The perpendicular, pointing toward the NEXT ray.
286 px, py := -u[1], u[0]
287 // u is in thousandths and the result is in hundredths, so
288 // u*r/1000*100 collapses to u*r/10. Likewise p/1000 * arm/2 * 100
289 // is p*arm/20.
290 ox, oy := cx*100+u[0]*r/10, cy*100+u[1]*r/10
291 dx, dy := px*arm/20, py*arm/20
292 out[i*3] = [2]int{ox - dx, oy - dy} // tip, trailing corner
293 out[i*3+1] = [2]int{ox + dx, oy + dy} // tip, leading corner
294 // The valley, on the bisector at radius arm. |u+n| is 1732 for
295 // rays sixty degrees apart, so this normalises it.
296 out[i*3+2] = [2]int{
297 cx*100 + (u[0]+n[0])*arm*100/1732,
298 cy*100 + (u[1]+n[1])*arm*100/1732,
299 }
300 }
301 return out
302}
303
304// hundredths formats a coordinate held in hundredths of a pixel.
305//
306// Two things it must not do, both found the hard way. The sign is peeled
307// off first, because integer division truncates toward zero on BOTH
308// halves and -250 would otherwise print as "-2.-50". And the fraction is
309// assembled a digit at a time rather than with "%02d", because ufmt does
310// not implement the zero-padding flag — it silently printed 68.0 where
311// two digits were asked for, which the mirror diff caught as drift.
312func hundredths(v int) string {
313 sign := ""
314 if v < 0 {
315 sign, v = "-", -v
316 }
317 f := v % 100
318 return ufmt.Sprintf("%s%d.%d%d", sign, v/100, f/10, f%10)
319}
320
321func starPoly(cx, cy, r, arm int) string {
322 pts := starPoints(cx, cy, r, arm)
323 var b strings.Builder
324 for i := 0; i < len(pts); i++ {
325 if i > 0 {
326 b.WriteString(" ")
327 }
328 b.WriteString(hundredths(pts[i][0]))
329 b.WriteString(",")
330 b.WriteString(hundredths(pts[i][1]))
331 }
332 return b.String()
333}
334
335// starSVG draws the asterisk in the two passes the lettering uses:
336// border first at twice the width, ink over it. No face argument — one
337// asterisk in every face, see the note at the top of the file.
338func starSVG(cx, cy, r, arm, outline int, ink, edge string) string {
339 pts := starPoly(cx, cy, r, arm)
340 return ufmt.Sprintf(
341 `<polygon points="%s" fill="%s" stroke="%s" stroke-width="%d" stroke-linejoin="round"/>`+
342 `<polygon points="%s" fill="%s"/>`,
343 pts, edge, edge, outline*2, pts, ink)
344}
345
346/*
347NameplateSVG composites the name, the asterisk and the expiry over
348whatever is already on the card.
349
350 face "mono" | "serif" | "sans"
351 pos "top" | "middle" | "bottom"
352 light true = white letters with a black border
353 false = black letters with a white border
354
355Only two ink pairs exist, and that is not a limitation waiting to be
356lifted. Both read on every background there is; a free colour choice does
357not, and mid-grey lettering on a mid-grey drawing is exactly what
358somebody will pick.
359*/
360// `now` is deliberately absent from the signature. Nothing here counts
361// down, so nothing here needs the clock — see the note on the expiry.
362func NameplateSVG(label, domain, face, pos string, light bool, expires int64) string {
363 lAdv := plateAdvance(label, face)
364 dAdv := plateAdvance(domain, face)
365 size := plateSize(lAdv+dAdv, face)
366 _, xH, starR, starArm, outline := plateGeom(size)
367 fam := plateFamily(face)
368
369 ink, edge := "#ffffff", "#0b0d10"
370 if !light {
371 ink, edge = "#0b0d10", "#ffffff"
372 }
373
374 var baseY, dateY int
375 switch pos {
376 case "top":
377 baseY, dateY = 96, 356
378 case "bottom":
379 baseY, dateY = 322, 96
380 default:
381 baseY, dateY = 212, 356
382 }
383
384 /*
385 THE TWO HALVES ARE ANCHORED TO THE ASTERISK, AND THEIR WIDTHS ARE
386 DICTATED RATHER THAN GUESSED.
387
388 Two faults came out of the same root, and the same pair of
389 mechanisms closes both.
390
391 The GAP before the asterisk: the width was estimated, and the
392 estimate placed both the end of the label and the asterisk, so any
393 error between the two opened up as a hole.
394
395 The whole name sitting OFF CENTRE: the composition is centred on
396 x0, computed from that same estimate. Work it through and the
397 asterisk lands at 200 + (lw-dw)/2 when it needs to land at
398 200 + (trueLw-trueDw)/2. The error is half the difference of the
399 two errors — so a long narrow label against a short domain slid
400 right, and the reverse slid left. Exactly what the proof sheet
401 showed.
402
403 FIRST, the halves are anchored TO the asterisk — label right,
404 domain left — which hands the placement of the glyphs relative to
405 it back to the renderer. That kills the gap outright.
406
407 SECOND, and this is what makes the centring exact: textLength. SVG
408 lets an element declare its own advance width and the renderer
409 fits the text to it. So lw and dw stop being predictions and
410 become instructions: whatever font the viewer actually resolved,
411 the label occupies exactly lw and the domain exactly dw, and the
412 arithmetic that centres them is true by construction rather than
413 by luck.
414
415 lengthAdjust="spacing", never "spacingAndGlyphs" — it moves the
416 letters apart, it does not squeeze their shapes. With the real
417 width table above, the adjustment on the usual fonts is nil, and
418 on a substituted one it is a fraction of a pixel of tracking.
419 Distorting glyphs to save a rounding error would be a bad trade.
420
421 The one case left is a single-character half on a substituted
422 font: there are no inter-glyph gaps, so "spacing" has nothing to
423 adjust. The table is exact for one character by definition, so
424 this only misses when the font ALSO substituted, and then by a few
425 pixels. Left deliberately — the alternative distorts the glyph.
426 */
427 lw := lAdv * size / 1000
428 dw := dAdv * size / 1000
429 starCol := size * 86 / 100
430 total := lw + starCol + dw
431 x0 := (plateW - total) / 2
432
433 starCX := x0 + lw + starCol/2
434 // Centred on the x-height, so the asterisk sits in the same optical
435 // band as the lowercase letters either side of it.
436 starCY := baseY - xH/2
437 labelX := starCX - starCol/2
438 domainX := starCX + starCol/2
439
440 var b strings.Builder
441
442 // One pass per layer. Border first, ink second, so the border sits
443 // behind the letter rather than eating half of it. textLength goes on
444 // every pass, or the two would disagree about where the letters are.
445 const half = `<text x="%d" y="%d" text-anchor="%s" textLength="%d" lengthAdjust="spacing"` +
446 ` fill="none" stroke="%s" stroke-width="%d" stroke-linejoin="round">%s</text>`
447 const fill = `<text x="%d" y="%d" text-anchor="%s" textLength="%d" lengthAdjust="spacing"` +
448 ` fill="%s">%s</text>`
449
450 b.WriteString(ufmt.Sprintf(`<g font-family="%s" font-size="%d" font-weight="bold">`, fam, size))
451 b.WriteString(ufmt.Sprintf(half, labelX, baseY, "end", lw, edge, outline*2, esc(label)))
452 b.WriteString(ufmt.Sprintf(half, domainX, baseY, "start", dw, edge, outline*2, esc(domain)))
453 b.WriteString(ufmt.Sprintf(fill, labelX, baseY, "end", lw, ink, esc(label)))
454 b.WriteString(ufmt.Sprintf(fill, domainX, baseY, "start", dw, ink, esc(domain)))
455 b.WriteString(`</g>`)
456
457 b.WriteString(starSVG(starCX, starCY, starR, starArm, outline, ink, edge))
458
459 /*
460 THE EXPIRY, ON EVERY CARD, AS A DATE AND NOTHING ELSE.
461
462 The ordinary card alternates a date with a countdown and carries a
463 long note about which one a still frame must land on. A skinned
464 card skips the problem: a marketplace thumbnail of "412 days left"
465 is a number that was true once and is a lie afterwards, while a
466 date is true forever. One sentence, always the same one.
467 */
468 dateSize := 15
469 // Same format the ordinary card already uses for an expiry, so a
470 // skinned card and a plain one do not disagree about how a date looks.
471 txt := "EXPIRES ON " + strings.ToUpper(time.Unix(expires, 0).UTC().Format("Jan 2, 2006"))
472 b.WriteString(ufmt.Sprintf(
473 `<g font-family="monospace" font-size="%d" font-weight="bold" text-anchor="middle">`+
474 `<text x="200" y="%d" fill="none" stroke="%s" stroke-width="4" stroke-linejoin="round">%s</text>`+
475 `<text x="200" y="%d" fill="%s">%s</text></g>`,
476 dateSize, dateY, edge, txt, dateY, ink, txt))
477
478 return b.String()
479}