| 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. |
| 43 | export 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. |
| 49 | export 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. |
| 56 | export 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. |
| 83 | export 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. |
| 93 | export 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. |
| 110 | export 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. |
| 129 | function 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 | |
| 156 | sizesAreCovered() |
| 157 | |