@sametalmdr/word-editor 0.2.0-alpha → 0.3.0-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -70
- package/LICENSE +21 -21
- package/README.md +286 -247
- package/dist/canvas/box-clipboard.js +238 -238
- package/dist/canvas/box-selection.js +80 -80
- package/dist/canvas/canvas-editor.js +502 -502
- package/dist/canvas/controller.js +717 -717
- package/dist/canvas/geometry.js +131 -131
- package/dist/canvas/gesture.d.ts +0 -1
- package/dist/canvas/gesture.js +289 -289
- package/dist/canvas/handles.js +212 -212
- package/dist/canvas/menu.js +82 -82
- package/dist/canvas/mode-controller.js +81 -81
- package/dist/canvas/nudge.js +108 -108
- package/dist/canvas/overlay.js +149 -149
- package/dist/canvas/snap.js +132 -132
- package/dist/canvas/viewport.js +112 -112
- package/dist/canvas/z-order.js +171 -171
- package/dist/commands/alignment.js +150 -150
- package/dist/commands/box.js +272 -272
- package/dist/commands/clipboard.js +93 -93
- package/dist/commands/graphemes.js +112 -112
- package/dist/commands/marks.js +153 -153
- package/dist/commands/paragraph-props.js +176 -176
- package/dist/commands/size.js +368 -368
- package/dist/commands/text.js +291 -291
- package/dist/docx.d.ts +1 -0
- package/dist/docx.js +15 -13
- package/dist/editor/editing-host.js +434 -434
- package/dist/element.js +41 -41
- package/dist/fonts/OFL-Carlito.txt +94 -0
- package/dist/fonts/calibri-metrics.d.ts +27 -0
- package/dist/fonts/calibri-metrics.js +88 -0
- package/dist/history/history.js +433 -433
- package/dist/index.js +63 -63
- package/dist/input/beforeinput.js +224 -224
- package/dist/input/clipboard.js +107 -107
- package/dist/input/composition.js +341 -341
- package/dist/input/keymap.js +54 -54
- package/dist/io/ooxml/custgeom.js +72 -72
- package/dist/io/ooxml/drawingml.js +232 -232
- package/dist/io/ooxml/edits.js +67 -67
- package/dist/io/ooxml/export-splice.js +880 -880
- package/dist/io/ooxml/export.js +365 -365
- package/dist/io/ooxml/fill.d.ts +93 -8
- package/dist/io/ooxml/fill.js +618 -562
- package/dist/io/ooxml/fit.d.ts +252 -0
- package/dist/io/ooxml/fit.js +598 -0
- package/dist/io/ooxml/import-gate.js +200 -200
- package/dist/io/ooxml/import.js +691 -691
- package/dist/io/ooxml/inflate.js +126 -126
- package/dist/io/ooxml/media.js +87 -87
- package/dist/io/ooxml/ns.d.ts +2 -0
- package/dist/io/ooxml/ns.js +26 -21
- package/dist/io/ooxml/numbering.js +290 -290
- package/dist/io/ooxml/opc-write.js +171 -171
- package/dist/io/ooxml/provenance.js +93 -93
- package/dist/io/ooxml/recipe.js +708 -708
- package/dist/io/ooxml/relationships.js +59 -59
- package/dist/io/ooxml/run-fonts.d.ts +89 -0
- package/dist/io/ooxml/run-fonts.js +378 -0
- package/dist/io/ooxml/slots.d.ts +4 -0
- package/dist/io/ooxml/slots.js +582 -581
- package/dist/io/ooxml/styles-resolve.js +257 -257
- package/dist/io/ooxml/units.js +43 -43
- package/dist/io/ooxml/value.js +120 -120
- package/dist/io/ooxml/xml-scan.js +616 -616
- package/dist/io/ooxml/zip-container.js +228 -228
- package/dist/io/ooxml/zip-reject.js +106 -106
- package/dist/measure/placement.js +83 -83
- package/dist/measure/report.js +75 -75
- package/dist/model/ids.js +70 -70
- package/dist/model/normalize.js +146 -146
- package/dist/model/position.js +283 -283
- package/dist/model/transaction.js +209 -209
- package/dist/platform/capabilities.js +25 -25
- package/dist/react/index.js +21 -21
- package/dist/selection/controller.js +315 -315
- package/dist/selection/dom-map.js +334 -334
- package/dist/state/editor-state.js +109 -109
- package/dist/styles.css +66 -66
- package/package.json +94 -93
package/dist/commands/size.js
CHANGED
|
@@ -1,368 +1,368 @@
|
|
|
1
|
-
import { DOC_DEFAULT_STYLE_REF, locateParagraph, paragraphLength, withMarkValue } from "../model/node.js";
|
|
2
|
-
import { createPosition, isCollapsed, paragraphIdOf, selectionRange } from "../model/position.js";
|
|
3
|
-
import { effectiveRunSize, sizeRanges } from "../model/runs.js";
|
|
4
|
-
import { setSizeStep } from "../model/step.js";
|
|
5
|
-
import { createTransaction } from "../model/transaction.js";
|
|
6
|
-
import { marksAt } from "./marks.js";
|
|
7
|
-
|
|
8
|
-
// ADR-0028: font-size is editable via delta ("current size ± one step"), not
|
|
9
|
-
// toggled like bold/italic/underline — see SetSizeStep's own doc comment
|
|
10
|
-
// (model/step.js) for why that made a new step type unavoidable rather than
|
|
11
|
-
// a boolean-shaped mark. This module is toggleMark's counterpart.
|
|
12
|
-
|
|
13
|
-
/** One UI step, half-points (1pt) — a doubled OOXML unit, doubled here to
|
|
14
|
-
* stay an integer. Linear, not Word's own non-linear size table
|
|
15
|
-
* (8,9,10,10.5,...) — a small, reversible parameter, not an architectural
|
|
16
|
-
* choice (ADR-0028's own "Kapsam dışı"). */
|
|
17
|
-
const STEP_HALF_POINTS = 2;
|
|
18
|
-
|
|
19
|
-
/** 1pt — Word's own reported floor (word.tips.net, a secondary source; the
|
|
20
|
-
* OOXML schema itself, ST_HpsMeasure, sets no minimum beyond "unsigned"). */
|
|
21
|
-
const MIN_SZ = 2;
|
|
22
|
-
|
|
23
|
-
/** 1638pt — Word's own reported UI ceiling, same secondary source. NOT
|
|
24
|
-
* verified against a primary ECMA-376/Microsoft spec page (ADR-0028's own
|
|
25
|
-
* DOĞRULANMALI); kept as a safety backstop, not a claimed exact limit.
|
|
26
|
-
*
|
|
27
|
-
* 🔴 ADR-0031 raised the stakes without raising the confidence. While
|
|
28
|
-
* `setSizeDelta` was the only caller this was unreachable in practice — you
|
|
29
|
-
* would have to press A+ from 11pt to 1638pt one point at a time. A free-text
|
|
30
|
-
* box reaches it in one keystroke, so a WRONG ceiling now silently clips a
|
|
31
|
-
* real user's real value, and `noteUnverifiedAssumption` does not save them:
|
|
32
|
-
* `app.html` deliberately renders no report at all. Closing this needs a
|
|
33
|
-
* primary source or a real-Word experiment at 1638/1639pt. */
|
|
34
|
-
const MAX_SZ = 3276;
|
|
35
|
-
|
|
36
|
-
/** `Transaction.noteUnverifiedAssumption`'s message when a resize actually
|
|
37
|
-
* hits `MAX_SZ` — the command-layer counterpart of `ImportReport.
|
|
38
|
-
* unverifiedAssumptions` (`io/ooxml/import.js`), dynamic rather than static:
|
|
39
|
-
* recorded only when the ceiling is genuinely exercised, not on every call. */
|
|
40
|
-
const MAX_SZ_ASSUMPTION =
|
|
41
|
-
"font size clamped to the 3276-half-point (1638pt) ceiling — sourced only from a " +
|
|
42
|
-
"secondary reference (word.tips.net), not a primary ECMA-376/Microsoft spec page " +
|
|
43
|
-
"(ADR-0028's own DOĞRULANMALI; ST_HpsMeasure itself sets no upper bound).";
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Clamps to `[MIN_SZ, MAX_SZ]`, noting on `tr` the first time this call
|
|
47
|
-
* actually hits the CEILING (not the floor: 1pt is schema-consistent —
|
|
48
|
-
* `ST_HpsMeasure` is merely "unsigned" — and matches Word's own reported
|
|
49
|
-
* floor too, so it carries no such uncertainty).
|
|
50
|
-
* @param {import("../model/transaction.js").TransactionBuilder} tr
|
|
51
|
-
* @param {number} value
|
|
52
|
-
* @returns {number}
|
|
53
|
-
*/
|
|
54
|
-
function clampSize(tr, value) {
|
|
55
|
-
const clamped = Math.min(MAX_SZ, Math.max(MIN_SZ, Math.round(value)));
|
|
56
|
-
if (clamped === MAX_SZ && value > MAX_SZ) {
|
|
57
|
-
tr.noteUnverifiedAssumption(MAX_SZ_ASSUMPTION);
|
|
58
|
-
}
|
|
59
|
-
return clamped;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* @param {import("../model/node.js").StyleTable} styles
|
|
64
|
-
* @returns {number}
|
|
65
|
-
*/
|
|
66
|
-
function docDefaultFallback(styles) {
|
|
67
|
-
const raw = styles[DOC_DEFAULT_STYLE_REF]?.sz;
|
|
68
|
-
if (typeof raw === "string") {
|
|
69
|
-
const n = Number.parseInt(raw, 10);
|
|
70
|
-
if (Number.isFinite(n)) {
|
|
71
|
-
return n;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
// Only reached for a Doc that never went through import.js at all (no
|
|
75
|
-
// docDefaults sentinel was ever written) — createEmptyDoc's very first box.
|
|
76
|
-
// 22 half-points = 11pt, the corpus's own most common "at baseline" size
|
|
77
|
-
// (5,447 runs, docs/paragraph-properties-measurement.md's 2026-08-30 ek).
|
|
78
|
-
return 22;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Shared by `effectiveSize` (reads live document state) and
|
|
83
|
-
* `setSizeDelta`'s collapsed branch (must read `current`, which already
|
|
84
|
-
* folds in a pending `storedMarks.sz` — calling `effectiveSize(doc, pos)`
|
|
85
|
-
* there would re-derive straight from `doc` and silently ignore any size
|
|
86
|
-
* not yet committed to the document, breaking repeated presses).
|
|
87
|
-
* @param {import("../model/node.js").StyleTable} styles
|
|
88
|
-
* @param {import("../model/node.js").Marks} marks
|
|
89
|
-
* @returns {number}
|
|
90
|
-
*/
|
|
91
|
-
function resolveSize(styles, marks) {
|
|
92
|
-
const resolved = effectiveRunSize(styles, { type: "text", text: "", marks });
|
|
93
|
-
return resolved ?? docDefaultFallback(styles);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* The effective font size (half-points) at a position — `marksAt`'s
|
|
98
|
-
* left-biased mark resolution, then `Marks.sz` if set there, then
|
|
99
|
-
* `Doc.styles[rPrRef].sz`, then the document's own docDefaults sentinel.
|
|
100
|
-
* @param {import("../model/node.js").Doc} doc
|
|
101
|
-
* @param {import("../model/position.js").Position} pos
|
|
102
|
-
* @returns {number}
|
|
103
|
-
*/
|
|
104
|
-
export function effectiveSize(doc, pos) {
|
|
105
|
-
return resolveSize(doc.styles, marksAt(doc, pos));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* @typedef {object} SizeSegment
|
|
110
|
-
* @property {import("../model/position.js").Position} from
|
|
111
|
-
* @property {import("../model/position.js").Position} to
|
|
112
|
-
* @property {number} current effective half-points, UNCLAMPED
|
|
113
|
-
*/
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* The one run-cluster walk every size command in this module shares — a RANGE
|
|
117
|
-
* selection resolved into `sizeRanges` segments, each already addressed by
|
|
118
|
-
* `Position` and already carrying its effective size.
|
|
119
|
-
*
|
|
120
|
-
* Three return shapes, and the two empty-looking ones are deliberately NOT
|
|
121
|
-
* merged: collapsing them here would make it impossible for a caller to tell
|
|
122
|
-
* "the selection was refused" from "the selection resolved but covers no
|
|
123
|
-
* characters", and they mean different things to a setter than to a readout.
|
|
124
|
-
* - `null` — refused: the range would not resolve, or it crosses containers
|
|
125
|
-
* (ADR-0007, the same rule marks follow).
|
|
126
|
-
* - `[]` — resolved, but covers no characters at all (an empty paragraph).
|
|
127
|
-
* - otherwise — one entry per run-cluster, in document order.
|
|
128
|
-
*
|
|
129
|
-
* **It does not clamp.** `clampSize` needs a live transaction to note the
|
|
130
|
-
* ceiling on, and a readout must report the document's real size even when
|
|
131
|
-
* that size is outside the range a command would write — clamping here would
|
|
132
|
-
* make `sizeAtSelection` lie about a genuine 4000-half-point run.
|
|
133
|
-
*
|
|
134
|
-
* @param {import("../model/node.js").Doc} doc
|
|
135
|
-
* @param {import("../model/position.js").Selection} selection
|
|
136
|
-
* @returns {SizeSegment[] | null}
|
|
137
|
-
*/
|
|
138
|
-
function selectionSizeSegments(doc, selection) {
|
|
139
|
-
const range = selectionRange(doc, selection);
|
|
140
|
-
if (range === null) {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
const fromAt = locateParagraph(doc, paragraphIdOf(range.from));
|
|
144
|
-
const toAt = locateParagraph(doc, paragraphIdOf(range.to));
|
|
145
|
-
if (fromAt === null || toAt === null || fromAt.containerIndex !== toAt.containerIndex) {
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const container = fromAt.container;
|
|
150
|
-
const fallback = docDefaultFallback(doc.styles);
|
|
151
|
-
/** @type {SizeSegment[]} */
|
|
152
|
-
const segments = [];
|
|
153
|
-
|
|
154
|
-
for (let i = fromAt.paraIndex; i <= toAt.paraIndex; i += 1) {
|
|
155
|
-
const para = container.content[i];
|
|
156
|
-
const start = i === fromAt.paraIndex ? range.from.offset : 0;
|
|
157
|
-
const end = i === toAt.paraIndex ? range.to.offset : paragraphLength(para);
|
|
158
|
-
if (end <= start) {
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
for (const seg of sizeRanges(doc.styles, para.content, start, end)) {
|
|
162
|
-
segments.push({
|
|
163
|
-
from: createPosition(container.id, para.id, seg.from),
|
|
164
|
-
to: createPosition(container.id, para.id, seg.to),
|
|
165
|
-
// `sizeRanges` reports `undefined` for "no override, inherited"; every
|
|
166
|
-
// caller here wants the EFFECTIVE size, so the fallback is applied once.
|
|
167
|
-
current: seg.value ?? fallback,
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return segments;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* The size shared by EVERY run the selection touches, or null when they
|
|
176
|
-
* disagree — `alignmentAtSelection`/`indentAtSelection`'s run-level twin, and
|
|
177
|
-
* what a size READOUT should show ("a value means every selected run is
|
|
178
|
-
* already this; blank means mixed").
|
|
179
|
-
*
|
|
180
|
-
* Two things it is deliberately NOT:
|
|
181
|
-
* - not `effectiveSize(doc, selection.head)`: that answers "what size at this
|
|
182
|
-
* one point", which for a range spanning 9pt and 11pt text would report 9pt
|
|
183
|
-
* as though the whole selection were 9pt.
|
|
184
|
-
* - not a setter. Absolute assignment is `setSizeAbsolute` (ADR-0031), a
|
|
185
|
-
* separate command with its own clamp and inherit story.
|
|
186
|
-
*
|
|
187
|
-
* A COLLAPSED caret folds in a pending `storedMarks.sz`, exactly as
|
|
188
|
-
* `setSizeDelta`'s own collapsed branch does — otherwise pressing A+ with no
|
|
189
|
-
* selection would change what the next typed character will be while the
|
|
190
|
-
* readout kept showing the old size.
|
|
191
|
-
*
|
|
192
|
-
* @param {import("../state/editor-state.js").EditorState} state
|
|
193
|
-
* @returns {number | null} half-points, or null when mixed / unresolvable
|
|
194
|
-
*/
|
|
195
|
-
export function sizeAtSelection(state) {
|
|
196
|
-
const { doc, selection } = state;
|
|
197
|
-
|
|
198
|
-
if (isCollapsed(selection)) {
|
|
199
|
-
return resolveSize(doc.styles, state.storedMarks ?? marksAt(doc, selection.head));
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// The very walk the setters act on, so the readout can never disagree with
|
|
203
|
-
// what a resize would actually change.
|
|
204
|
-
const segments = selectionSizeSegments(doc, selection);
|
|
205
|
-
if (segments === null) {
|
|
206
|
-
return null;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** @type {number | null} */
|
|
210
|
-
let seen = null;
|
|
211
|
-
for (const seg of segments) {
|
|
212
|
-
if (seen === null) {
|
|
213
|
-
seen = seg.current;
|
|
214
|
-
} else if (seen !== seg.current) {
|
|
215
|
-
return null; // mixed — the readout goes blank
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
// Still null when the range covered no characters at all (an empty
|
|
219
|
-
// paragraph): blank, never an invented number.
|
|
220
|
-
return seen;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Increases or decreases the font size by one step (`STEP_HALF_POINTS`,
|
|
225
|
-
* signed — pass a negative `steps` to decrease).
|
|
226
|
-
*
|
|
227
|
-
* **Collapsed selection:** mirrors `toggleMark`'s storedMarks path — no
|
|
228
|
-
* document change, the pending size rides on `storedMarks` (via
|
|
229
|
-
* `withMarkValue`, not `withMark`) so the next typed character comes out at
|
|
230
|
-
* the new size.
|
|
231
|
-
*
|
|
232
|
-
* **Range selection:** unlike a boolean toggle's single target value, each
|
|
233
|
-
* existing run-cluster in the selection starts from its OWN current size
|
|
234
|
-
* (`sizeRanges`, via `selectionSizeSegments`) — so a mixed-size selection
|
|
235
|
-
* increases each stretch from wherever it already was, not to one uniform
|
|
236
|
-
* value. Contrast `setSizeAbsolute`, which does the opposite on purpose.
|
|
237
|
-
*
|
|
238
|
-
* @param {import("../state/editor-state.js").EditorState} state
|
|
239
|
-
* @param {number} steps positive = larger, negative = smaller
|
|
240
|
-
* @returns {import("../model/transaction.js").Transaction | null}
|
|
241
|
-
*/
|
|
242
|
-
export function setSizeDelta(state, steps) {
|
|
243
|
-
const { doc, selection } = state;
|
|
244
|
-
const delta = steps * STEP_HALF_POINTS;
|
|
245
|
-
|
|
246
|
-
if (isCollapsed(selection)) {
|
|
247
|
-
const current = state.storedMarks ?? marksAt(doc, selection.head);
|
|
248
|
-
const currentSize = resolveSize(doc.styles, current);
|
|
249
|
-
const tr = createTransaction(doc, selection, "format");
|
|
250
|
-
tr.setStoredMarks(withMarkValue(current, "sz", clampSize(tr, currentSize + delta)));
|
|
251
|
-
return tr.build();
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const segments = selectionSizeSegments(doc, selection);
|
|
255
|
-
if (segments === null || segments.length === 0) {
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Created before the clamp walk (not after, like toggleMark's range
|
|
260
|
-
// branch) — clampSize needs a live `tr` to note the ceiling on, the
|
|
261
|
-
// moment it is actually hit.
|
|
262
|
-
const tr = createTransaction(doc, selection, "format");
|
|
263
|
-
|
|
264
|
-
tr.step(
|
|
265
|
-
setSizeStep(
|
|
266
|
-
segments.map((seg) => ({
|
|
267
|
-
from: seg.from,
|
|
268
|
-
to: seg.to,
|
|
269
|
-
value: clampSize(tr, seg.current + delta),
|
|
270
|
-
})),
|
|
271
|
-
),
|
|
272
|
-
);
|
|
273
|
-
if (!tr.changesDoc) {
|
|
274
|
-
return null;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Sizes never move characters, so the selection is preserved as-is.
|
|
278
|
-
tr.setSelection(selection);
|
|
279
|
-
tr.setStoredMarks(null);
|
|
280
|
-
return tr.build();
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Assigns ONE absolute size to everything the selection touches — the
|
|
285
|
-
* combobox's write path (ADR-0031), `setSizeDelta`'s counterpart.
|
|
286
|
-
*
|
|
287
|
-
* Deliberately NOT implemented as a delta: it never reads the current size,
|
|
288
|
-
* so a mixed-size selection comes out UNIFORM rather than each stretch
|
|
289
|
-
* shifting from wherever it already was. That difference is the whole point
|
|
290
|
-
* of the command — it is what "pick 14pt" means in Word, and what "press A+"
|
|
291
|
-
* does not mean.
|
|
292
|
-
*
|
|
293
|
-
* **Assigning the target to a run that merely INHERITS it still writes an
|
|
294
|
-
* explicit override** and still produces a real transaction. Word does the
|
|
295
|
-
* same (choosing a point size writes `w:sz`), but it is worth stating: the
|
|
296
|
-
* box can read 11 and picking 11 can still change the document.
|
|
297
|
-
*
|
|
298
|
-
* @param {import("../state/editor-state.js").EditorState} state
|
|
299
|
-
* @param {number} targetSz HALF-POINTS, the model's own unit
|
|
300
|
-
* @returns {import("../model/transaction.js").Transaction | null}
|
|
301
|
-
*/
|
|
302
|
-
export function setSizeAbsolute(state, targetSz) {
|
|
303
|
-
const { doc, selection } = state;
|
|
304
|
-
|
|
305
|
-
// clampSize is Math.round/min/max, and every one of those passes NaN
|
|
306
|
-
// straight through — unreachable while `setSizeDelta` was the only caller
|
|
307
|
-
// (its steps are always ±1), reachable the moment a text field feeds this
|
|
308
|
-
// one. A NaN here would land in `marks.sz` and from there in `w:sz`.
|
|
309
|
-
if (!Number.isFinite(targetSz)) {
|
|
310
|
-
return null;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if (isCollapsed(selection)) {
|
|
314
|
-
// Mirrors setSizeDelta's collapsed branch, minus the one line that
|
|
315
|
-
// matters: no `resolveSize`, because an absolute target does not care
|
|
316
|
-
// what the current size is. The two branches are NOT factored together —
|
|
317
|
-
// they differ in exactly that line, and SZ4's mutation targets it.
|
|
318
|
-
const current = state.storedMarks ?? marksAt(doc, selection.head);
|
|
319
|
-
const tr = createTransaction(doc, selection, "format");
|
|
320
|
-
tr.setStoredMarks(withMarkValue(current, "sz", clampSize(tr, targetSz)));
|
|
321
|
-
return tr.build();
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const segments = selectionSizeSegments(doc, selection);
|
|
325
|
-
if (segments === null || segments.length === 0) {
|
|
326
|
-
return null;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const tr = createTransaction(doc, selection, "format");
|
|
330
|
-
// ONE clamp for one target — unlike setSizeDelta, which must clamp each
|
|
331
|
-
// cluster separately because each starts from its own size.
|
|
332
|
-
const value = clampSize(tr, targetSz);
|
|
333
|
-
|
|
334
|
-
tr.step(setSizeStep(segments.map((seg) => ({ from: seg.from, to: seg.to, value }))));
|
|
335
|
-
if (!tr.changesDoc) {
|
|
336
|
-
return null;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
tr.setSelection(selection);
|
|
340
|
-
tr.setStoredMarks(null);
|
|
341
|
-
return tr.build();
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Parses what a user typed into the point-size box into the model's own
|
|
346
|
-
* half-point unit. Lives here rather than in `examples/` because it is the
|
|
347
|
-
* only real logic at that UI boundary, and nothing under `examples/` is
|
|
348
|
-
* loaded by any Node test (measured — `SESSION-LOG.md` Ek 57).
|
|
349
|
-
*
|
|
350
|
-
* Accepts a comma as the decimal separator: the UI is Turkish, and the
|
|
351
|
-
* numpad's decimal key produces a comma under a Turkish keyboard layout.
|
|
352
|
-
*
|
|
353
|
-
* **It does not clamp.** Range enforcement belongs to the command, which has
|
|
354
|
-
* a transaction to disclose a ceiling hit on; keeping the two apart is what
|
|
355
|
-
* lets `"5000"` parse cleanly and then be clamped visibly.
|
|
356
|
-
*
|
|
357
|
-
* @param {string} text
|
|
358
|
-
* @returns {number | null} half-points, or null when there is no number here
|
|
359
|
-
*/
|
|
360
|
-
export function parseSizePoints(text) {
|
|
361
|
-
const points = Number.parseFloat(text.trim().replace(",", "."));
|
|
362
|
-
if (!Number.isFinite(points)) {
|
|
363
|
-
return null;
|
|
364
|
-
}
|
|
365
|
-
// Half-points are integers by definition (ST_HpsMeasure is unsigned
|
|
366
|
-
// decimal), so 11.3pt resolves to the nearest half-point, 11.5.
|
|
367
|
-
return Math.round(points * 2);
|
|
368
|
-
}
|
|
1
|
+
import { DOC_DEFAULT_STYLE_REF, locateParagraph, paragraphLength, withMarkValue } from "../model/node.js";
|
|
2
|
+
import { createPosition, isCollapsed, paragraphIdOf, selectionRange } from "../model/position.js";
|
|
3
|
+
import { effectiveRunSize, sizeRanges } from "../model/runs.js";
|
|
4
|
+
import { setSizeStep } from "../model/step.js";
|
|
5
|
+
import { createTransaction } from "../model/transaction.js";
|
|
6
|
+
import { marksAt } from "./marks.js";
|
|
7
|
+
|
|
8
|
+
// ADR-0028: font-size is editable via delta ("current size ± one step"), not
|
|
9
|
+
// toggled like bold/italic/underline — see SetSizeStep's own doc comment
|
|
10
|
+
// (model/step.js) for why that made a new step type unavoidable rather than
|
|
11
|
+
// a boolean-shaped mark. This module is toggleMark's counterpart.
|
|
12
|
+
|
|
13
|
+
/** One UI step, half-points (1pt) — a doubled OOXML unit, doubled here to
|
|
14
|
+
* stay an integer. Linear, not Word's own non-linear size table
|
|
15
|
+
* (8,9,10,10.5,...) — a small, reversible parameter, not an architectural
|
|
16
|
+
* choice (ADR-0028's own "Kapsam dışı"). */
|
|
17
|
+
const STEP_HALF_POINTS = 2;
|
|
18
|
+
|
|
19
|
+
/** 1pt — Word's own reported floor (word.tips.net, a secondary source; the
|
|
20
|
+
* OOXML schema itself, ST_HpsMeasure, sets no minimum beyond "unsigned"). */
|
|
21
|
+
const MIN_SZ = 2;
|
|
22
|
+
|
|
23
|
+
/** 1638pt — Word's own reported UI ceiling, same secondary source. NOT
|
|
24
|
+
* verified against a primary ECMA-376/Microsoft spec page (ADR-0028's own
|
|
25
|
+
* DOĞRULANMALI); kept as a safety backstop, not a claimed exact limit.
|
|
26
|
+
*
|
|
27
|
+
* 🔴 ADR-0031 raised the stakes without raising the confidence. While
|
|
28
|
+
* `setSizeDelta` was the only caller this was unreachable in practice — you
|
|
29
|
+
* would have to press A+ from 11pt to 1638pt one point at a time. A free-text
|
|
30
|
+
* box reaches it in one keystroke, so a WRONG ceiling now silently clips a
|
|
31
|
+
* real user's real value, and `noteUnverifiedAssumption` does not save them:
|
|
32
|
+
* `app.html` deliberately renders no report at all. Closing this needs a
|
|
33
|
+
* primary source or a real-Word experiment at 1638/1639pt. */
|
|
34
|
+
const MAX_SZ = 3276;
|
|
35
|
+
|
|
36
|
+
/** `Transaction.noteUnverifiedAssumption`'s message when a resize actually
|
|
37
|
+
* hits `MAX_SZ` — the command-layer counterpart of `ImportReport.
|
|
38
|
+
* unverifiedAssumptions` (`io/ooxml/import.js`), dynamic rather than static:
|
|
39
|
+
* recorded only when the ceiling is genuinely exercised, not on every call. */
|
|
40
|
+
const MAX_SZ_ASSUMPTION =
|
|
41
|
+
"font size clamped to the 3276-half-point (1638pt) ceiling — sourced only from a " +
|
|
42
|
+
"secondary reference (word.tips.net), not a primary ECMA-376/Microsoft spec page " +
|
|
43
|
+
"(ADR-0028's own DOĞRULANMALI; ST_HpsMeasure itself sets no upper bound).";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Clamps to `[MIN_SZ, MAX_SZ]`, noting on `tr` the first time this call
|
|
47
|
+
* actually hits the CEILING (not the floor: 1pt is schema-consistent —
|
|
48
|
+
* `ST_HpsMeasure` is merely "unsigned" — and matches Word's own reported
|
|
49
|
+
* floor too, so it carries no such uncertainty).
|
|
50
|
+
* @param {import("../model/transaction.js").TransactionBuilder} tr
|
|
51
|
+
* @param {number} value
|
|
52
|
+
* @returns {number}
|
|
53
|
+
*/
|
|
54
|
+
function clampSize(tr, value) {
|
|
55
|
+
const clamped = Math.min(MAX_SZ, Math.max(MIN_SZ, Math.round(value)));
|
|
56
|
+
if (clamped === MAX_SZ && value > MAX_SZ) {
|
|
57
|
+
tr.noteUnverifiedAssumption(MAX_SZ_ASSUMPTION);
|
|
58
|
+
}
|
|
59
|
+
return clamped;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {import("../model/node.js").StyleTable} styles
|
|
64
|
+
* @returns {number}
|
|
65
|
+
*/
|
|
66
|
+
function docDefaultFallback(styles) {
|
|
67
|
+
const raw = styles[DOC_DEFAULT_STYLE_REF]?.sz;
|
|
68
|
+
if (typeof raw === "string") {
|
|
69
|
+
const n = Number.parseInt(raw, 10);
|
|
70
|
+
if (Number.isFinite(n)) {
|
|
71
|
+
return n;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Only reached for a Doc that never went through import.js at all (no
|
|
75
|
+
// docDefaults sentinel was ever written) — createEmptyDoc's very first box.
|
|
76
|
+
// 22 half-points = 11pt, the corpus's own most common "at baseline" size
|
|
77
|
+
// (5,447 runs, docs/paragraph-properties-measurement.md's 2026-08-30 ek).
|
|
78
|
+
return 22;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Shared by `effectiveSize` (reads live document state) and
|
|
83
|
+
* `setSizeDelta`'s collapsed branch (must read `current`, which already
|
|
84
|
+
* folds in a pending `storedMarks.sz` — calling `effectiveSize(doc, pos)`
|
|
85
|
+
* there would re-derive straight from `doc` and silently ignore any size
|
|
86
|
+
* not yet committed to the document, breaking repeated presses).
|
|
87
|
+
* @param {import("../model/node.js").StyleTable} styles
|
|
88
|
+
* @param {import("../model/node.js").Marks} marks
|
|
89
|
+
* @returns {number}
|
|
90
|
+
*/
|
|
91
|
+
function resolveSize(styles, marks) {
|
|
92
|
+
const resolved = effectiveRunSize(styles, { type: "text", text: "", marks });
|
|
93
|
+
return resolved ?? docDefaultFallback(styles);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The effective font size (half-points) at a position — `marksAt`'s
|
|
98
|
+
* left-biased mark resolution, then `Marks.sz` if set there, then
|
|
99
|
+
* `Doc.styles[rPrRef].sz`, then the document's own docDefaults sentinel.
|
|
100
|
+
* @param {import("../model/node.js").Doc} doc
|
|
101
|
+
* @param {import("../model/position.js").Position} pos
|
|
102
|
+
* @returns {number}
|
|
103
|
+
*/
|
|
104
|
+
export function effectiveSize(doc, pos) {
|
|
105
|
+
return resolveSize(doc.styles, marksAt(doc, pos));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @typedef {object} SizeSegment
|
|
110
|
+
* @property {import("../model/position.js").Position} from
|
|
111
|
+
* @property {import("../model/position.js").Position} to
|
|
112
|
+
* @property {number} current effective half-points, UNCLAMPED
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The one run-cluster walk every size command in this module shares — a RANGE
|
|
117
|
+
* selection resolved into `sizeRanges` segments, each already addressed by
|
|
118
|
+
* `Position` and already carrying its effective size.
|
|
119
|
+
*
|
|
120
|
+
* Three return shapes, and the two empty-looking ones are deliberately NOT
|
|
121
|
+
* merged: collapsing them here would make it impossible for a caller to tell
|
|
122
|
+
* "the selection was refused" from "the selection resolved but covers no
|
|
123
|
+
* characters", and they mean different things to a setter than to a readout.
|
|
124
|
+
* - `null` — refused: the range would not resolve, or it crosses containers
|
|
125
|
+
* (ADR-0007, the same rule marks follow).
|
|
126
|
+
* - `[]` — resolved, but covers no characters at all (an empty paragraph).
|
|
127
|
+
* - otherwise — one entry per run-cluster, in document order.
|
|
128
|
+
*
|
|
129
|
+
* **It does not clamp.** `clampSize` needs a live transaction to note the
|
|
130
|
+
* ceiling on, and a readout must report the document's real size even when
|
|
131
|
+
* that size is outside the range a command would write — clamping here would
|
|
132
|
+
* make `sizeAtSelection` lie about a genuine 4000-half-point run.
|
|
133
|
+
*
|
|
134
|
+
* @param {import("../model/node.js").Doc} doc
|
|
135
|
+
* @param {import("../model/position.js").Selection} selection
|
|
136
|
+
* @returns {SizeSegment[] | null}
|
|
137
|
+
*/
|
|
138
|
+
function selectionSizeSegments(doc, selection) {
|
|
139
|
+
const range = selectionRange(doc, selection);
|
|
140
|
+
if (range === null) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const fromAt = locateParagraph(doc, paragraphIdOf(range.from));
|
|
144
|
+
const toAt = locateParagraph(doc, paragraphIdOf(range.to));
|
|
145
|
+
if (fromAt === null || toAt === null || fromAt.containerIndex !== toAt.containerIndex) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const container = fromAt.container;
|
|
150
|
+
const fallback = docDefaultFallback(doc.styles);
|
|
151
|
+
/** @type {SizeSegment[]} */
|
|
152
|
+
const segments = [];
|
|
153
|
+
|
|
154
|
+
for (let i = fromAt.paraIndex; i <= toAt.paraIndex; i += 1) {
|
|
155
|
+
const para = container.content[i];
|
|
156
|
+
const start = i === fromAt.paraIndex ? range.from.offset : 0;
|
|
157
|
+
const end = i === toAt.paraIndex ? range.to.offset : paragraphLength(para);
|
|
158
|
+
if (end <= start) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
for (const seg of sizeRanges(doc.styles, para.content, start, end)) {
|
|
162
|
+
segments.push({
|
|
163
|
+
from: createPosition(container.id, para.id, seg.from),
|
|
164
|
+
to: createPosition(container.id, para.id, seg.to),
|
|
165
|
+
// `sizeRanges` reports `undefined` for "no override, inherited"; every
|
|
166
|
+
// caller here wants the EFFECTIVE size, so the fallback is applied once.
|
|
167
|
+
current: seg.value ?? fallback,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return segments;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The size shared by EVERY run the selection touches, or null when they
|
|
176
|
+
* disagree — `alignmentAtSelection`/`indentAtSelection`'s run-level twin, and
|
|
177
|
+
* what a size READOUT should show ("a value means every selected run is
|
|
178
|
+
* already this; blank means mixed").
|
|
179
|
+
*
|
|
180
|
+
* Two things it is deliberately NOT:
|
|
181
|
+
* - not `effectiveSize(doc, selection.head)`: that answers "what size at this
|
|
182
|
+
* one point", which for a range spanning 9pt and 11pt text would report 9pt
|
|
183
|
+
* as though the whole selection were 9pt.
|
|
184
|
+
* - not a setter. Absolute assignment is `setSizeAbsolute` (ADR-0031), a
|
|
185
|
+
* separate command with its own clamp and inherit story.
|
|
186
|
+
*
|
|
187
|
+
* A COLLAPSED caret folds in a pending `storedMarks.sz`, exactly as
|
|
188
|
+
* `setSizeDelta`'s own collapsed branch does — otherwise pressing A+ with no
|
|
189
|
+
* selection would change what the next typed character will be while the
|
|
190
|
+
* readout kept showing the old size.
|
|
191
|
+
*
|
|
192
|
+
* @param {import("../state/editor-state.js").EditorState} state
|
|
193
|
+
* @returns {number | null} half-points, or null when mixed / unresolvable
|
|
194
|
+
*/
|
|
195
|
+
export function sizeAtSelection(state) {
|
|
196
|
+
const { doc, selection } = state;
|
|
197
|
+
|
|
198
|
+
if (isCollapsed(selection)) {
|
|
199
|
+
return resolveSize(doc.styles, state.storedMarks ?? marksAt(doc, selection.head));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The very walk the setters act on, so the readout can never disagree with
|
|
203
|
+
// what a resize would actually change.
|
|
204
|
+
const segments = selectionSizeSegments(doc, selection);
|
|
205
|
+
if (segments === null) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @type {number | null} */
|
|
210
|
+
let seen = null;
|
|
211
|
+
for (const seg of segments) {
|
|
212
|
+
if (seen === null) {
|
|
213
|
+
seen = seg.current;
|
|
214
|
+
} else if (seen !== seg.current) {
|
|
215
|
+
return null; // mixed — the readout goes blank
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Still null when the range covered no characters at all (an empty
|
|
219
|
+
// paragraph): blank, never an invented number.
|
|
220
|
+
return seen;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Increases or decreases the font size by one step (`STEP_HALF_POINTS`,
|
|
225
|
+
* signed — pass a negative `steps` to decrease).
|
|
226
|
+
*
|
|
227
|
+
* **Collapsed selection:** mirrors `toggleMark`'s storedMarks path — no
|
|
228
|
+
* document change, the pending size rides on `storedMarks` (via
|
|
229
|
+
* `withMarkValue`, not `withMark`) so the next typed character comes out at
|
|
230
|
+
* the new size.
|
|
231
|
+
*
|
|
232
|
+
* **Range selection:** unlike a boolean toggle's single target value, each
|
|
233
|
+
* existing run-cluster in the selection starts from its OWN current size
|
|
234
|
+
* (`sizeRanges`, via `selectionSizeSegments`) — so a mixed-size selection
|
|
235
|
+
* increases each stretch from wherever it already was, not to one uniform
|
|
236
|
+
* value. Contrast `setSizeAbsolute`, which does the opposite on purpose.
|
|
237
|
+
*
|
|
238
|
+
* @param {import("../state/editor-state.js").EditorState} state
|
|
239
|
+
* @param {number} steps positive = larger, negative = smaller
|
|
240
|
+
* @returns {import("../model/transaction.js").Transaction | null}
|
|
241
|
+
*/
|
|
242
|
+
export function setSizeDelta(state, steps) {
|
|
243
|
+
const { doc, selection } = state;
|
|
244
|
+
const delta = steps * STEP_HALF_POINTS;
|
|
245
|
+
|
|
246
|
+
if (isCollapsed(selection)) {
|
|
247
|
+
const current = state.storedMarks ?? marksAt(doc, selection.head);
|
|
248
|
+
const currentSize = resolveSize(doc.styles, current);
|
|
249
|
+
const tr = createTransaction(doc, selection, "format");
|
|
250
|
+
tr.setStoredMarks(withMarkValue(current, "sz", clampSize(tr, currentSize + delta)));
|
|
251
|
+
return tr.build();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const segments = selectionSizeSegments(doc, selection);
|
|
255
|
+
if (segments === null || segments.length === 0) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Created before the clamp walk (not after, like toggleMark's range
|
|
260
|
+
// branch) — clampSize needs a live `tr` to note the ceiling on, the
|
|
261
|
+
// moment it is actually hit.
|
|
262
|
+
const tr = createTransaction(doc, selection, "format");
|
|
263
|
+
|
|
264
|
+
tr.step(
|
|
265
|
+
setSizeStep(
|
|
266
|
+
segments.map((seg) => ({
|
|
267
|
+
from: seg.from,
|
|
268
|
+
to: seg.to,
|
|
269
|
+
value: clampSize(tr, seg.current + delta),
|
|
270
|
+
})),
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
if (!tr.changesDoc) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Sizes never move characters, so the selection is preserved as-is.
|
|
278
|
+
tr.setSelection(selection);
|
|
279
|
+
tr.setStoredMarks(null);
|
|
280
|
+
return tr.build();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Assigns ONE absolute size to everything the selection touches — the
|
|
285
|
+
* combobox's write path (ADR-0031), `setSizeDelta`'s counterpart.
|
|
286
|
+
*
|
|
287
|
+
* Deliberately NOT implemented as a delta: it never reads the current size,
|
|
288
|
+
* so a mixed-size selection comes out UNIFORM rather than each stretch
|
|
289
|
+
* shifting from wherever it already was. That difference is the whole point
|
|
290
|
+
* of the command — it is what "pick 14pt" means in Word, and what "press A+"
|
|
291
|
+
* does not mean.
|
|
292
|
+
*
|
|
293
|
+
* **Assigning the target to a run that merely INHERITS it still writes an
|
|
294
|
+
* explicit override** and still produces a real transaction. Word does the
|
|
295
|
+
* same (choosing a point size writes `w:sz`), but it is worth stating: the
|
|
296
|
+
* box can read 11 and picking 11 can still change the document.
|
|
297
|
+
*
|
|
298
|
+
* @param {import("../state/editor-state.js").EditorState} state
|
|
299
|
+
* @param {number} targetSz HALF-POINTS, the model's own unit
|
|
300
|
+
* @returns {import("../model/transaction.js").Transaction | null}
|
|
301
|
+
*/
|
|
302
|
+
export function setSizeAbsolute(state, targetSz) {
|
|
303
|
+
const { doc, selection } = state;
|
|
304
|
+
|
|
305
|
+
// clampSize is Math.round/min/max, and every one of those passes NaN
|
|
306
|
+
// straight through — unreachable while `setSizeDelta` was the only caller
|
|
307
|
+
// (its steps are always ±1), reachable the moment a text field feeds this
|
|
308
|
+
// one. A NaN here would land in `marks.sz` and from there in `w:sz`.
|
|
309
|
+
if (!Number.isFinite(targetSz)) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (isCollapsed(selection)) {
|
|
314
|
+
// Mirrors setSizeDelta's collapsed branch, minus the one line that
|
|
315
|
+
// matters: no `resolveSize`, because an absolute target does not care
|
|
316
|
+
// what the current size is. The two branches are NOT factored together —
|
|
317
|
+
// they differ in exactly that line, and SZ4's mutation targets it.
|
|
318
|
+
const current = state.storedMarks ?? marksAt(doc, selection.head);
|
|
319
|
+
const tr = createTransaction(doc, selection, "format");
|
|
320
|
+
tr.setStoredMarks(withMarkValue(current, "sz", clampSize(tr, targetSz)));
|
|
321
|
+
return tr.build();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const segments = selectionSizeSegments(doc, selection);
|
|
325
|
+
if (segments === null || segments.length === 0) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const tr = createTransaction(doc, selection, "format");
|
|
330
|
+
// ONE clamp for one target — unlike setSizeDelta, which must clamp each
|
|
331
|
+
// cluster separately because each starts from its own size.
|
|
332
|
+
const value = clampSize(tr, targetSz);
|
|
333
|
+
|
|
334
|
+
tr.step(setSizeStep(segments.map((seg) => ({ from: seg.from, to: seg.to, value }))));
|
|
335
|
+
if (!tr.changesDoc) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
tr.setSelection(selection);
|
|
340
|
+
tr.setStoredMarks(null);
|
|
341
|
+
return tr.build();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Parses what a user typed into the point-size box into the model's own
|
|
346
|
+
* half-point unit. Lives here rather than in `examples/` because it is the
|
|
347
|
+
* only real logic at that UI boundary, and nothing under `examples/` is
|
|
348
|
+
* loaded by any Node test (measured — `SESSION-LOG.md` Ek 57).
|
|
349
|
+
*
|
|
350
|
+
* Accepts a comma as the decimal separator: the UI is Turkish, and the
|
|
351
|
+
* numpad's decimal key produces a comma under a Turkish keyboard layout.
|
|
352
|
+
*
|
|
353
|
+
* **It does not clamp.** Range enforcement belongs to the command, which has
|
|
354
|
+
* a transaction to disclose a ceiling hit on; keeping the two apart is what
|
|
355
|
+
* lets `"5000"` parse cleanly and then be clamped visibly.
|
|
356
|
+
*
|
|
357
|
+
* @param {string} text
|
|
358
|
+
* @returns {number | null} half-points, or null when there is no number here
|
|
359
|
+
*/
|
|
360
|
+
export function parseSizePoints(text) {
|
|
361
|
+
const points = Number.parseFloat(text.trim().replace(",", "."));
|
|
362
|
+
if (!Number.isFinite(points)) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
// Half-points are integers by definition (ST_HpsMeasure is unsigned
|
|
366
|
+
// decimal), so 11.3pt resolves to the nearest half-point, 11.5.
|
|
367
|
+
return Math.round(points * 2);
|
|
368
|
+
}
|