oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1// fixtures/sizes.mjs — the font-size vocabulary, in one place.
2//
3// fonts.mjs's counterpart, and it follows that module's shape
4// deliberately: a closed list, a default that carries NO mark, and a
5// pair of functions mapping between a chosen value and the mark that
6// says it. Read fonts.mjs's head comment for the reasoning; what
7// follows is only what is different about size.
8//
9// WHY POINTS AND NOT FOUR NAMED STEPS. This control used to offer
10// Small / Normal / Large / Extra large, stored as three marks whose CSS
11// was a multiplier of the body size (0.85em, 1.25em, 1.5em). That is a
12// perfectly good way to build a text editor and a poor way to build a
13// word processor: people arrive knowing what 12pt is, documents specify
14// point sizes, and "Large" does not survive a conversation with someone
15// holding a printout. The ladder below is Google Docs' own.
16//
17// WHY A CLOSED ENUMERATION AND NOT A NUMBER ON A MARK. This is the
18// constraint that shaped the whole feature, and it is not a stylistic
19// preference. core/src/fragment.rs renders the CRDT to HTML that
20// internal/docs/store.go returns as template.HTML and views/show.html
21// writes into the page UNESCAPED. That is safe only because no byte
22// from a document ever reaches attribute position: every mark is
23// attribute-free, and its markup is a compile-time literal chosen by
24// lookup. A mark carrying "14pt" would be a valued mark — and
25// POST /d/{sqid}/updates accepts arbitrary Yjs bytes that never went
26// through ProseMirror's schema, so the value would be genuinely
27// arbitrary. Eleven more attribute-free marks spend no new safety
28// property at all; a single sized mark would spend the one the whole
29// renderer rests on.
30//
31// So the ladder is not "the sizes we happened to pick". It is the set
32// of sizes this design can express, and adding 13pt later is a row in
33// four places rather than a schema change.
34
35// The sizes a person can choose, in the order the control shows them.
36//
37// DEFAULT FIRST, for the reason internal/docs/export.go's own comment
38// records having learned the hard way: a <select> shows its first
39// option until something sets a value, and with no JavaScript nothing
40// ever does. The list used to open with Small over body text that was
41// Normal — wrong on every page load, and wrong permanently without
42// scripts.
43export const SIZES = [12, 8, 9, 10, 11, 14, 18, 24, 36, 48, 72]
44
45// The size an unmarked run is set in — views/show.html's [docs-text]
46// rule and internal/export/pdf.go's BodySizePt say the same number, and
47// internal/docs's TestPageGeometryMatchesTheStylesheet holds all three
48// equal.
49export const DEFAULT_SIZE = 12
50
51// Every size mark, including the default's — except that the default
52// has none, which is the rule. This list is what the toolbar CLEARS
53// when a size is chosen, so it must contain every mark that could be on
54// a run, including the three legacy ones below; missing one would leave
55// a run at two sizes at once.
56export const SIZE_MARKS = [
57 ...SIZES.filter((pt) => pt !== DEFAULT_SIZE).map((pt) => 'size_' + pt),
58 // The four-step scale this replaced. They stay in the vocabulary
59 // PERMANENTLY and are simply no longer offered.
60 //
61 // Not politeness to old documents — correctness for them. Documents
62 // in the wild carry these marks, core/src/fragment.rs still renders
63 // them, and dropping one from the editor's schema would strip it out
64 // of every document carrying it on the next edit (editor-entry.js's
65 // own comment makes the same argument about font_sans). They are
66 // mapped to their nearest ladder step everywhere a size is measured.
67 'size_sm',
68 'size_lg',
69 'size_xl',
70]
71
72// What each legacy mark means in points, so one document does not read
73// at two sizes depending on which renderer looked at it.
74//
75// The old marks were multipliers of a 12pt body: 0.85, 1.25 and 1.5,
76// which is 10.2, 15 and 18pt. Ten and eighteen are on the ladder
77// exactly; fifteen is not, and rounds to fourteen. So a document set in
78// "Large" gets one point smaller than it used to be. That is a real,
79// if small, change to existing documents, and it is the price of the
80// ladder being closed — the alternative is a fifteenth mark that exists
81// only to preserve a number nobody chose deliberately, since "Large"
82// never told anyone it meant 15pt.
83export const LEGACY_SIZES = { size_sm: 10, size_lg: 14, size_xl: 18 }
84
85// sizeMark maps a chosen size to the mark to set: null for the default,
86// which is spelled as the absence of one, and undefined for a value
87// this module does not know.
88//
89// null and undefined are different answers, exactly as in fonts.mjs's
90// fontMark: null means "clear the group", a real edit; undefined means
91// "not one of ours", and a caller treating it as null would clear a
92// person's size because a stale page sent an option this build dropped.
93export function sizeMark(value) {
94 const pt = Number(value)
95 if (!SIZES.includes(pt)) return undefined
96 if (pt === DEFAULT_SIZE) return null
97 return 'size_' + pt
98}
99
100// sizeForMarks is the inverse, and it is what keeps the control honest:
101// given the marks active on the selection, what size is it.
102//
103// SIZE_MARKS order decides a run carrying two size marks, which the
104// schema's `excludes` makes impossible through the editor and
105// POST /d/{sqid}/updates makes possible anyway. The ladder comes before
106// the legacy names, so a run carrying both a new mark and an old one
107// reports the new one — the same "first hit wins, in a fixed order"
108// rule fontForMarks uses, and for the same reason: a stable answer
109// beats a random one.
110export function sizeForMarks(names) {
111 for (const mark of SIZE_MARKS) {
112 if (!names.includes(mark)) continue
113 if (mark in LEGACY_SIZES) return LEGACY_SIZES[mark]
114 return Number(mark.slice('size_'.length))
115 }
116 return DEFAULT_SIZE
117}
118
119// Every offered size has a mark, every mark has a size, the default is
120// one of them and is listed first, and every legacy mark maps onto the
121// ladder.
122//
123// Runs at module load, like fonts.mjs's fontsAreCovered and align.mjs's
124// alignmentsAreCovered, and for the reason those comments give at
125// length: a guard that only runs when a browser mounts the editor is
126// dead code in the gate, so a vocabulary that had drifted would pass CI
127// and throw for every user. fixtures/sizes.test.mjs imports this
128// module, which is what makes this call execute on every gate run.
129function sizesAreCovered() {
130 if (!SIZES.includes(DEFAULT_SIZE)) {
131 throw new Error(`sizes: the default ${DEFAULT_SIZE} is not one of [${SIZES}]`)
132 }
133 if (SIZES[0] !== DEFAULT_SIZE) {
134 throw new Error(
135 `sizes: the control lists ${SIZES[0]} first but the default is ${DEFAULT_SIZE} — ` +
136 'with no JavaScript the control would name a size the text is not set in',
137 )
138 }
139 for (const [mark, pt] of Object.entries(LEGACY_SIZES)) {
140 if (!SIZES.includes(pt)) {
141 throw new Error(`sizes: the legacy mark ${mark} maps to ${pt}pt, which is not on the ladder [${SIZES}]`)
142 }
143 if (!SIZE_MARKS.includes(mark)) {
144 throw new Error(`sizes: the legacy mark ${mark} is not in the group the toolbar clears`)
145 }
146 }
147 // Every offered size other than the default has exactly one mark in
148 // the group, and nothing else does.
149 const offered = SIZES.filter((pt) => pt !== DEFAULT_SIZE).map((pt) => 'size_' + pt)
150 const inGroup = SIZE_MARKS.filter((m) => !(m in LEGACY_SIZES))
151 if (offered.join() !== inGroup.join()) {
152 throw new Error(`sizes: the marks [${inGroup}] do not match the offered sizes [${offered}]`)
153 }
154}
155
156sizesAreCovered()
157