@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +108 -70
  2. package/LICENSE +21 -21
  3. package/README.md +286 -247
  4. package/dist/canvas/box-clipboard.js +238 -238
  5. package/dist/canvas/box-selection.js +80 -80
  6. package/dist/canvas/canvas-editor.js +502 -502
  7. package/dist/canvas/controller.js +717 -717
  8. package/dist/canvas/geometry.js +131 -131
  9. package/dist/canvas/gesture.d.ts +0 -1
  10. package/dist/canvas/gesture.js +289 -289
  11. package/dist/canvas/handles.js +212 -212
  12. package/dist/canvas/menu.js +82 -82
  13. package/dist/canvas/mode-controller.js +81 -81
  14. package/dist/canvas/nudge.js +108 -108
  15. package/dist/canvas/overlay.js +149 -149
  16. package/dist/canvas/snap.js +132 -132
  17. package/dist/canvas/viewport.js +112 -112
  18. package/dist/canvas/z-order.js +171 -171
  19. package/dist/commands/alignment.js +150 -150
  20. package/dist/commands/box.js +272 -272
  21. package/dist/commands/clipboard.js +93 -93
  22. package/dist/commands/graphemes.js +112 -112
  23. package/dist/commands/marks.js +153 -153
  24. package/dist/commands/paragraph-props.js +176 -176
  25. package/dist/commands/size.js +368 -368
  26. package/dist/commands/text.js +291 -291
  27. package/dist/docx.d.ts +1 -0
  28. package/dist/docx.js +15 -13
  29. package/dist/editor/editing-host.js +434 -434
  30. package/dist/element.js +41 -41
  31. package/dist/fonts/OFL-Carlito.txt +94 -0
  32. package/dist/fonts/calibri-metrics.d.ts +27 -0
  33. package/dist/fonts/calibri-metrics.js +88 -0
  34. package/dist/history/history.js +433 -433
  35. package/dist/index.js +63 -63
  36. package/dist/input/beforeinput.js +224 -224
  37. package/dist/input/clipboard.js +107 -107
  38. package/dist/input/composition.js +341 -341
  39. package/dist/input/keymap.js +54 -54
  40. package/dist/io/ooxml/custgeom.js +72 -72
  41. package/dist/io/ooxml/drawingml.js +232 -232
  42. package/dist/io/ooxml/edits.js +67 -67
  43. package/dist/io/ooxml/export-splice.js +880 -880
  44. package/dist/io/ooxml/export.js +365 -365
  45. package/dist/io/ooxml/fill.d.ts +93 -8
  46. package/dist/io/ooxml/fill.js +618 -562
  47. package/dist/io/ooxml/fit.d.ts +252 -0
  48. package/dist/io/ooxml/fit.js +598 -0
  49. package/dist/io/ooxml/import-gate.js +200 -200
  50. package/dist/io/ooxml/import.js +691 -691
  51. package/dist/io/ooxml/inflate.js +126 -126
  52. package/dist/io/ooxml/media.js +87 -87
  53. package/dist/io/ooxml/ns.d.ts +2 -0
  54. package/dist/io/ooxml/ns.js +26 -21
  55. package/dist/io/ooxml/numbering.js +290 -290
  56. package/dist/io/ooxml/opc-write.js +171 -171
  57. package/dist/io/ooxml/provenance.js +93 -93
  58. package/dist/io/ooxml/recipe.js +708 -708
  59. package/dist/io/ooxml/relationships.js +59 -59
  60. package/dist/io/ooxml/run-fonts.d.ts +89 -0
  61. package/dist/io/ooxml/run-fonts.js +378 -0
  62. package/dist/io/ooxml/slots.d.ts +4 -0
  63. package/dist/io/ooxml/slots.js +582 -581
  64. package/dist/io/ooxml/styles-resolve.js +257 -257
  65. package/dist/io/ooxml/units.js +43 -43
  66. package/dist/io/ooxml/value.js +120 -120
  67. package/dist/io/ooxml/xml-scan.js +616 -616
  68. package/dist/io/ooxml/zip-container.js +228 -228
  69. package/dist/io/ooxml/zip-reject.js +106 -106
  70. package/dist/measure/placement.js +83 -83
  71. package/dist/measure/report.js +75 -75
  72. package/dist/model/ids.js +70 -70
  73. package/dist/model/normalize.js +146 -146
  74. package/dist/model/position.js +283 -283
  75. package/dist/model/transaction.js +209 -209
  76. package/dist/platform/capabilities.js +25 -25
  77. package/dist/react/index.js +21 -21
  78. package/dist/selection/controller.js +315 -315
  79. package/dist/selection/dom-map.js +334 -334
  80. package/dist/state/editor-state.js +109 -109
  81. package/dist/styles.css +66 -66
  82. package/package.json +94 -93
@@ -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
+ }