rapier-markdown-kit 1.2.8 → 1.2.10

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 (61) hide show
  1. package/dist/agent/will.mjs +0 -6
  2. package/dist/images/gif.mjs +0 -3
  3. package/dist/images/playback.mjs +0 -10
  4. package/dist/kit/assets.mjs +0 -1
  5. package/dist/kit/conformance/measurer.mjs +0 -0
  6. package/dist/kit/conformance/run.mjs +1 -8
  7. package/dist/kit/export-fonts.mjs +0 -14
  8. package/dist/kit/ledger/authored-origin.mjs +0 -5
  9. package/dist/kit/ledger/authorship.mjs +0 -2
  10. package/dist/kit/ledger/carried.mjs +4 -7
  11. package/dist/kit/ledger/data.mjs +0 -8
  12. package/dist/kit/ledger/format.mjs +0 -25
  13. package/dist/kit/ledger/hash.mjs +11 -7
  14. package/dist/kit/ledger/history.mjs +10 -29
  15. package/dist/kit/ledger/inverse-source.mjs +0 -46
  16. package/dist/kit/ledger/journal-records.mjs +24 -56
  17. package/dist/kit/ledger/merge.mjs +0 -27
  18. package/dist/kit/ledger/text-merge.mjs +0 -20
  19. package/dist/kit/ledger/transport.mjs +0 -47
  20. package/dist/kit/ledger.mjs +0 -4
  21. package/dist/kit/markdown-it.mjs +0 -3
  22. package/dist/kit/pdf-glyphs.mjs +1 -10
  23. package/dist/kit/pdf-source.mjs +5 -5
  24. package/dist/kit/pdf-vectors.mjs +0 -3
  25. package/dist/kit/print-capitals.mjs +0 -4
  26. package/dist/kit/render-entry.mjs +0 -1
  27. package/dist/kit/render-ink.mjs +0 -5
  28. package/dist/kit/render-markdown.mjs +12 -117
  29. package/dist/kit/render-print.mjs +30 -61
  30. package/dist/kit/render-sanitize.mjs +7 -59
  31. package/dist/kit/render-styles.mjs +0 -7
  32. package/dist/kit/render-work.mjs +0 -12
  33. package/dist/kit/render.mjs +46 -176
  34. package/dist/layout/hyphenation.mjs +0 -23
  35. package/dist/layout/line-plan.mjs +0 -20
  36. package/dist/layout/model.mjs +0 -55
  37. package/dist/layout/paragraph-fit.mjs +50 -60
  38. package/dist/layout/preparation.mjs +23 -56
  39. package/dist/layout/prepared-paragraph.mjs +0 -25
  40. package/dist/layout/typography.mjs +103 -41
  41. package/dist/packages/rapier-diff/concerns.mjs +0 -16
  42. package/dist/packages/rapier-diff/diff.mjs +0 -7
  43. package/dist/packages/rapier-diff/index.mjs +0 -1
  44. package/dist/packages/rapier-diff/matching.mjs +0 -19
  45. package/dist/packages/rapier-diff/merge.mjs +0 -9
  46. package/dist/packages/rapier-diff/moves.mjs +0 -7
  47. package/dist/packages/rapier-diff/structure.mjs +0 -2
  48. package/dist/packages/rapier-diff/tokens.mjs +0 -6
  49. package/dist/packages/rapier-diff/walk.mjs +0 -12
  50. package/dist/spec/document-fonts.mjs +0 -9
  51. package/dist/spec/document-settings.mjs +5 -24
  52. package/dist/spec/frontmatter.mjs +0 -1
  53. package/dist/spec/html-blocks.mjs +0 -36
  54. package/dist/spec/md-assets.mjs +2 -61
  55. package/dist/spec/md-layout.mjs +0 -34
  56. package/dist/spec/md-marks.mjs +0 -51
  57. package/dist/spec/md-source.mjs +0 -16
  58. package/dist/spec/template-page.mjs +6 -0
  59. package/dist/spec/word-sidecar.mjs +0 -2
  60. package/package.json +1 -1
  61. package/style.css +32 -16
@@ -3,11 +3,6 @@ import {clearCache, setLocale} from '../agent/vendor/pretext/layout.js';
3
3
  import {prepareRichInline} from '../agent/vendor/pretext/rich-inline.js';
4
4
  import {prepareParagraphCandidateSteps, createParagraphObserver} from './prepared-paragraph.mjs';
5
5
 
6
- // One metric authority for this loaded vendor graph. Document/face/paragraph identities are
7
- // weak; a width is never part of source preparation. A separate captured fitting operation
8
- // completes its requested interval facts before its immutable plan is ready. A font-string
9
- // cache must be retired when the actual face changes, even if a replacement kept its family
10
- // and all its CSS descriptors.
11
6
  const documents = new WeakMap(), faces = new WeakMap(), paragraphs = new WeakMap();
12
7
  let faceId = 0, generation = 0, language;
13
8
  const descriptors = ['family', 'style', 'weight', 'stretch', 'unicodeRange', 'status',
@@ -17,8 +12,10 @@ const finite = value => typeof value === 'number' && Number.isFinite(value);
17
12
 
18
13
  function clearMetrics() { clearCache(); return ++generation; }
19
14
 
15
+ let turn = null;
20
16
  export function captureFontAuthority(document = globalThis.document) {
21
17
  if (!document || typeof document !== 'object') return generation;
18
+ if (turn?.document === document && turn.generation === generation) return generation;
22
19
  const previous = documents.get(document), declared = previous?.declared || new Set();
23
20
  const entries = [];
24
21
  try {
@@ -34,12 +31,13 @@ export function captureFontAuthority(document = globalThis.document) {
34
31
  documents.set(document, {signature, declared});
35
32
  clearMetrics();
36
33
  }
34
+ if (!turn) queueMicrotask(() => { turn = null; });
35
+ turn = {document, generation};
37
36
  return generation;
38
37
  }
39
38
 
40
- // Several hosts can observe the same FontFaceSet event. Its observed identity retires metrics
41
- // once. A non-DOM host has no observable face set and explicitly announces its own replacement.
42
39
  export function invalidateRichMetrics(document = globalThis.document) {
40
+ turn = null;
43
41
  return document ? captureFontAuthority(document) : clearMetrics();
44
42
  }
45
43
 
@@ -51,25 +49,27 @@ function familyName(value) {
51
49
  .toLowerCase();
52
50
  }
53
51
 
54
- // A CSS family with no declared FontFace can intentionally resolve through its fallback list.
55
- // For declared custom faces, a FontFaceSet.check() alone is insufficient: it can report true
56
- // after a failed face has fallen back. Keep that failure and same-name replacement observable.
57
52
  export function fontReady(document, font, text = ' ') {
58
53
  if (!document) return true;
59
54
  if (!document.fonts || document.fonts.status === 'loading') return false;
60
55
  try {
61
- if (document.fonts.check(font, text || ' ') !== true) return false;
62
- const family = /(?:\d*\.)?\d+px(?:\s*\/\s*[^\s]+)?\s+(.+)$/.exec(font)?.[1];
63
- if (!family) return false;
64
- const names = new Set((family.match(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^,]+/g) || []).map(familyName));
65
- const present = new Set();
66
- for (const face of document.fonts) {
67
- const family = familyName(face.family); present.add(family);
68
- if (names.has(family) && face.status !== 'loaded') return false;
56
+ const parts = /^(?:(normal|italic|oblique(?:\s+-?[\d.]+deg)?)\s+)?(?:(\d+)\s+)?(?:\d*\.)?\d+px(?:\s*\/\s*[^\s]+)?\s+(.+)$/.exec(font);
57
+ if (!parts) return false;
58
+ const style = parts[1] || 'normal', weight = Number(parts[2] || 400);
59
+ const names = (parts[3].match(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^,]+/g) || []).map(familyName);
60
+ const declared = documents.get(document)?.declared || new Set(), faces = [...document.fonts];
61
+ for (const name of names) {
62
+ const own = faces.filter(face => familyName(face.family) === name);
63
+ if (!own.length) return !declared.has(name);
64
+ const fitting = own.filter(face => {
65
+ const [low, high = low] = String(face.weight).split(/\s+/).map(Number);
66
+ return (face.style === 'normal') === (style === 'normal') && !(weight < low) && !(weight > high);
67
+ });
68
+ const pool = fitting.length ? fitting : own;
69
+ if (pool.some(face => face.status === 'loading')) return false;
70
+ if (pool.some(face => face.status === 'loaded')) return true;
71
+ if (pool.some(face => face.status === 'unloaded')) { document.fonts.load(font, text || ' ').catch(() => {}); return false; }
69
72
  }
70
- // A deleted declared face is unavailable even when check() now succeeds with a fallback.
71
- // Its expected name belongs to this document lifetime, not an individual projection root.
72
- for (const family of documents.get(document)?.declared || []) if (names.has(family) && !present.has(family)) return false;
73
73
  return true;
74
74
  } catch (_) { return false; }
75
75
  }
@@ -81,17 +81,11 @@ function validLanguage(value) {
81
81
  }
82
82
 
83
83
  function supportedDocument(document) {
84
- if (!document) return true; // A declared non-DOM Canvas host owns its chosen fonts/profile.
84
+ if (!document) return true;
85
85
  const agent = document.defaultView?.navigator?.userAgent ?? globalThis.navigator?.userAgent ?? '';
86
86
  return /(?:^|\s)(?:HeadlessChrome|Chrome|Chromium)\/\d+/.test(agent) && !/\bCriOS\//.test(agent);
87
87
  }
88
88
 
89
- // The pinned API accepts font shorthand, letter spacing, content language and two break modes.
90
- // Canvas supplies its default shaping. It has no public feature/variation/kerning controls or
91
- // paragraph-direction input; these styles retain the native selectable paragraph instead.
92
- // `nowrap` here is for an atomic item; the paragraph itself still needs normal or pre-wrap.
93
- // The app's inherited `pretty` is its existing greedy picture-flow policy, not an implementation
94
- // of native pretty optimization. A host's explicit request for that optimization stays native.
95
89
  export function supportsRichStyle(style, contentLanguage = '') {
96
90
  if (!style || !validLanguage(contentLanguage)) return false;
97
91
  const oneOf = (key, values) => style[key] == null || style[key] === '' || values.includes(style[key]);
@@ -158,8 +152,6 @@ function finish(work) {
158
152
  }
159
153
 
160
154
  function* prepareSteps(items, options, document) {
161
- // FontFaceSet.check depends on the font and the used character set. Check each shaped
162
- // font once across its runs; the scope is the sole font-readiness authority.
163
155
  const usedFonts = new Map();
164
156
  for (const item of items) if (item.text !== undefined) usedFonts.set(item.font, (usedFonts.get(item.font) || '') + item.text);
165
157
  for (const [font, text] of usedFonts) {
@@ -167,8 +159,6 @@ function* prepareSteps(items, options, document) {
167
159
  yield;
168
160
  }
169
161
  try {
170
- // Locale selects content language for this preparation only. Existing handles already own
171
- // their measured language; switching paragraphs must not invalidate those handles.
172
162
  if (language !== options.language) { setLocale(options.language); language = options.language; }
173
163
  if (options.paragraphBreaks && (options.paragraphBreaks[0] !== 0 || options.paragraphBreaks.at(-1) !== items.length ||
174
164
  options.paragraphBreaks.some((value, index, all) => !Number.isInteger(value) || value < 0 || value > items.length || index > 0 && value < all[index - 1]))) return null;
@@ -177,8 +167,6 @@ function* prepareSteps(items, options, document) {
177
167
  const work = prepareParagraphCandidateSteps(items, {...options, document});
178
168
  try {
179
169
  for (;;) {
180
- // Another consumer may have selected a different content language between
181
- // frames. Every resumed step uses this request's original language again.
182
170
  if (language !== options.language) { setLocale(options.language); language = options.language; }
183
171
  const step = work.next();
184
172
  if (step.done) { handle = step.value; break; }
@@ -200,8 +188,6 @@ export function prepareRichParagraph(items, options = {}) {
200
188
  return source && settings ? finish(prepareSteps(source, settings, document)) : null;
201
189
  }
202
190
 
203
- // This metadata is Rapier's original input snapshot, never a reconstruction of opaque vendor
204
- // fields. It exists only while its handle does and is used for exact source containment.
205
191
  export function paragraphItems(handle) { return paragraphs.get(handle)?.items ?? null; }
206
192
 
207
193
  export function createPreparationScope(document = globalThis.document) {
@@ -230,8 +216,6 @@ export function createPreparationScope(document = globalThis.document) {
230
216
  tickets.set(owner, ticket);
231
217
  try {
232
218
  for (;;) {
233
- // A forgotten/replaced source request or changed face cannot publish a result
234
- // after suspension. Until completion, no ready handle is installed for its owner.
235
219
  if (tickets.get(owner) !== ticket || captureFontAuthority(document) !== birth) return null;
236
220
  const step = work.next();
237
221
  if (step.done) {
@@ -249,8 +233,6 @@ export function createPreparationScope(document = globalThis.document) {
249
233
  let slotFor = null, readKnown = null, staged = false, active = false;
250
234
  try {
251
235
  if (!supportedDocument(document) || !owner || typeof owner !== 'object' && typeof owner !== 'function') return null;
252
- // Preparation, fitting and forgetting share one outstanding request for this owner.
253
- // Replacing a fit keeps its immutable source geometry, but retires the old plan work.
254
236
  tickets.delete(owner);
255
237
  const birth = captureFontAuthority(document), source = paragraphs.get(handle);
256
238
  if (owners.get(owner) !== handle || !source || source.generation !== birth ||
@@ -270,17 +252,12 @@ export function createPreparationScope(document = globalThis.document) {
270
252
  !Number.isInteger(from) || from < -1 || !Number.isInteger(to) || to < -1) return -2;
271
253
  const cuts = handle.segments[segment]?.words[word]?.breaks;
272
254
  if (!cuts || from >= cuts.length || to >= cuts.length || from < 0 && to < 0 || from >= 0 && to >= 0 && from >= to) return -2;
273
- if (from >= 0 && to >= 0) return -1; // Exact interior facts remain transient.
255
+ if (from >= 0 && to >= 0) return -1;
274
256
  const cut = cuts[from < 0 ? to : from];
275
257
  if (!Number.isInteger(cut.slot) || cut.slot < 0 || cut.slot >= handle.cutCount) return -2;
276
- // Slots come only from the captured geometry's final legal cuts. A request
277
- // can select coordinates, but cannot supply a cache key or a metric buffer.
278
258
  return cut.slot * 2 + (from < 0 ? 0 : 1);
279
259
  };
280
260
  readKnown = request => {
281
- // This callback belongs only to active numerical work. A warm edge reads an
282
- // exact scalar without a generator crossing, FontFaceSet scan or observation.
283
- // Current source/font authority is checked at each actual resume and finish.
284
261
  if (!active || tickets.get(owner) !== ticket || generation !== birth) return undefined;
285
262
  const slot = slotFor(request?.segment, request?.word, request?.from, request?.to);
286
263
  if (slot < 0 || tickets.get(owner) !== ticket || generation !== birth) return undefined;
@@ -298,7 +275,6 @@ export function createPreparationScope(document = globalThis.document) {
298
275
  const step = work.next(answer);
299
276
  answer = undefined;
300
277
  if (step.done) {
301
- // A host measurement or another synchronous caller may replace this owner too.
302
278
  if (!currentFit()) return null;
303
279
  const plan = step.value;
304
280
  if (!plan || !Array.isArray(plan.lines)) return null;
@@ -306,9 +282,6 @@ export function createPreparationScope(document = globalThis.document) {
306
282
  Object.freeze(plan.lines);
307
283
  Object.freeze(plan);
308
284
  if (!currentFit()) return null;
309
- // The private buffer holds at most two exact numbers per legal cut. It
310
- // never escapes and is never written after publication. A later fit must
311
- // copy it before staging a miss; cancellation cannot poison this snapshot.
312
285
  if (staged) source.edges = facts;
313
286
  return plan;
314
287
  }
@@ -322,8 +295,6 @@ export function createPreparationScope(document = globalThis.document) {
322
295
  if (answer !== undefined && !Number.isNaN(answer)) continue;
323
296
  }
324
297
  selectLanguage();
325
- // No observer exists for a plan proved from whole-word facts. Its temporary
326
- // vendor metric objects are owned only by this operation, never by the handle.
327
298
  observe ??= createParagraphObserver(source.items, handle, source.options.language);
328
299
  answer = observe({segment, word, from, to});
329
300
  if (!finite(answer) || answer < 0) return null;
@@ -357,13 +328,9 @@ export function createPreparationScope(document = globalThis.document) {
357
328
  current,
358
329
  invalidateFonts: () => invalidateRichMetrics(document),
359
330
  forget: owner => { owners.delete(owner); tickets.delete(owner); },
360
- // The existing document/style owner calls this when replacing or retiring its content,
361
- // not for width reflow. Upstream caches outlive handles, so dropping a WeakMap is not enough.
362
331
  retire({documentChanged = false} = {}) {
363
332
  owners = new WeakMap();
364
333
  tickets = new WeakMap();
365
- // Disposing a projection must not erase a still-live peer's missing custom-family
366
- // authority. Only the host's whole-document identity transition resets that history.
367
334
  if (documentChanged && document) documents.delete(document);
368
335
  const before = generation;
369
336
  captureFontAuthority(document);
@@ -5,10 +5,6 @@ import {getEngineProfile, getPreparationLanguage, getFontMeasurement, parseFontS
5
5
  getTextWidth, getEmojiCorrection, textMayContainEmoji, measureWithLetterSpacing} from '../agent/vendor/pretext/measurement.js';
6
6
  import {KIND_BITS, TEXT, SPACE, SOFT_HYPHEN, ZERO_WIDTH_BREAK, ZERO_WIDTH_GLUE, OBJECT, SPACED, UNBROKEN} from '../agent/vendor/pretext/analysis.js';
7
7
 
8
- // The sole pin-specific bridge to bac1774c0f8b7a92e44b9da43b6d5443d55d0d9b.
9
- // Preparation seals source, whole-word geometry and legal edges. A separate captured fit
10
- // observes only requested edge intervals before sealing its plan. Neither phase exports a
11
- // vendor handle, a mutable metric object or a function that can measure after readiness.
12
8
  const opening = /^[\p{Ps}\p{Pi}\p{Pf}"']+/u;
13
9
  const closing = /[\p{Pe}\p{Pi}\p{Pf}"'.,;:!?]+$/u;
14
10
  const empty = values => values == null || !values.some(value => value !== null && value !== 0);
@@ -28,8 +24,6 @@ function sourceMeasurer(items, text, offsets, language) {
28
24
  const metrics = [];
29
25
  return (start, end, suffix = '') => {
30
26
  if (start >= end) return 0;
31
- // The last positive overlap owns the suffix. Duplicate offsets from empty runs
32
- // must not move it into an empty run or the next font.
33
27
  let lo = 0, hi = items.length;
34
28
  while (lo < hi) {
35
29
  const mid = lo + hi >>> 1;
@@ -51,16 +45,12 @@ function sourceMeasurer(items, text, offsets, language) {
51
45
  ? measureWithLetterSpacing(body, item.letterSpacing, metric.emoji, metric.font)
52
46
  : getTextWidth(body, metric.font, metric.emoji);
53
47
  if (measured === null) return NaN;
54
- // Keep the original per-run shaping and left-to-right addition order.
55
48
  width += measured + (item.break === 'never' ? item.extraWidth : 0);
56
49
  }
57
50
  return width;
58
51
  };
59
52
  }
60
53
 
61
- // Only the preparation scope calls this, while its source/font ticket is live. The
62
- // short-lived observer owns the vendor metrics and context rows; the sealed geometry
63
- // retains only immutable coordinates, corrections and its resolved shaping language.
64
54
  export function createParagraphObserver(items, geometry) {
65
55
  const measure = sourceMeasurer(items, geometry.text, geometry.offsets, geometry.language);
66
56
  const rows = new Map();
@@ -73,8 +63,6 @@ export function createParagraphObserver(items, geometry) {
73
63
  for (let at = start.contextAfter; at < contexts.length; at++) correction += contexts[at].delta;
74
64
  return measure(start.offset, word.end) + correction + start.startExtra;
75
65
  }
76
- // For a fixed start the fitter visits increasing ends. Extend that row's exact
77
- // left-to-right sum; no total-minus-prefix arithmetic changes its rounding.
78
66
  let row = rows.get(start);
79
67
  if (!row || end.offset < row.end) rows.set(start, row = {at: start.contextInside, sum: 0, end: start.offset});
80
68
  while (row.at < contexts.length && contexts[row.at].end <= end.offset) row.sum += contexts[row.at++].delta;
@@ -99,8 +87,6 @@ export function* prepareParagraphCandidateSteps(items, options) {
99
87
  if (!source.some(item => item.text)) continue;
100
88
  const flow = prepareRichInlineSegments(source, {whiteSpace: 'normal', wordBreak: options.wordBreak});
101
89
  const data = flow.data, flags = data.segmentFlags, units = [];
102
- // These are object-edge contracts that this text-only projection cannot paint. Refuse
103
- // before making a plan, rather than dropping an advance or inventing a native equivalent.
104
90
  if (!empty(data.items?.insideExtras) || !empty(data.items?.fillExtras) || !empty(data.items?.openingEdges)) return null;
105
91
  for (let index = 0; index < flags.length; index++) {
106
92
  const kind = flags[index] & KIND_BITS;
@@ -138,8 +124,6 @@ export function* prepareParagraphCandidateSteps(items, options) {
138
124
  finish();
139
125
  yield;
140
126
 
141
- // Native language data contributes only legal source positions. Every advance,
142
- // including the displayed discretionary glyph, still comes from Pretext below.
143
127
  const automatic = paragraphHyphens(chunk.words, text, items, offsets, options.language, options.document);
144
128
  if (!automatic) return null;
145
129
  for (const word of chunk.words) for (const offset of automatic.get(word) || []) {
@@ -147,8 +131,6 @@ export function* prepareParagraphCandidateSteps(items, options) {
147
131
  }
148
132
  yield;
149
133
 
150
- // Whole words and optical edges use the same font/language/letter-spacing cache
151
- // as ordinary wrapping. Discretionary edges belong to the captured fit phase.
152
134
  let measuredWords = 0;
153
135
  for (const word of chunk.words) {
154
136
  if (word.protected) word.breaks = [];
@@ -157,11 +139,6 @@ export function* prepareParagraphCandidateSteps(items, options) {
157
139
  const contexts = [];
158
140
  let measuredEdges = 0;
159
141
  if (joined) {
160
- // A joined word and its cut edges are separate shaping observations. Retain the
161
- // engine's contextual advances (including positive following-space kerning and
162
- // punctuation trims) when replacing the sum of pieces with the joined advance.
163
- // A leading combining-only segment already belongs to that joined observation;
164
- // its mark-context correction must not be added a second time.
165
142
  for (let at = word.first; at <= word.last; at++) {
166
143
  const unit = units[at];
167
144
  if (unit.kind !== TEXT || /^\p{M}+$/u.test(text.slice(unit.start, unit.end))) continue;
@@ -174,8 +151,6 @@ export function* prepareParagraphCandidateSteps(items, options) {
174
151
  let autoBefore = word.first - 1, autoAfter = word.first, contextAfter = 0, contextInside = 0, prefixContext = 0;
175
152
  for (const cut of word.breaks) {
176
153
  if (cut.kind === 'soft' || cut.kind === 'auto') {
177
- // The projection's explicit glyph policy: the text font's hyphen-minus.
178
- // Its whole shaped prefix is measured below; no fallback-family search.
179
154
  cut.glyph = '-';
180
155
  }
181
156
  let before = cut.at, after = cut.at + 1;
@@ -2,7 +2,6 @@
2
2
  import {createPreparationScope, supportsRichStyle, captureFontAuthority} from './preparation.mjs';
3
3
  import {createParagraphFitter} from './paragraph-fit.mjs';
4
4
 
5
- // Layout projections keep the original text in place. The factory also runs in an exported page.
6
5
  export function createTypography() {
7
6
  const fitter = createParagraphFitter();
8
7
  const mark = 'data-rapier-typography', boundary = mark + '-break', hang = mark + '-hang';
@@ -11,7 +10,7 @@ export function createTypography() {
11
10
  const exclude = 'code,kbd,samp,math,script,style,.math-rendered,[hidden]';
12
11
  const candidates = 'p[data-md-hyphens="auto"],p[data-md-lines],p[data-md-hanging="on"],p[' + mark + ']';
13
12
  const properties = ['--md-drop-cap-font', '--md-hang-start'];
14
- const states = new WeakMap(), documents = new WeakMap(), live = new Set();
13
+ const states = new WeakMap(), documents = new WeakMap(), live = new Set(), refused = new WeakMap();
15
14
  function scope(document) {
16
15
  let value = documents.get(document);
17
16
  if (!value) { value = createPreparationScope(document); documents.set(document, value); }
@@ -36,6 +35,9 @@ export function createTypography() {
36
35
  return end > start ? {start, end, text: text.slice(start, end)} : null;
37
36
  }
38
37
 
38
+ const flowFloat = '.rapier-flow-float';
39
+ const own = paragraph => [paragraph, ...[...paragraph.querySelectorAll('*')].filter(element => !element.closest(flowFloat))];
40
+
39
41
  function paragraphs(root, selector = candidates) {
40
42
  return [...(root?.matches?.(selector) ? [root] : []), ...(root?.querySelectorAll?.(selector) || [])];
41
43
  }
@@ -63,7 +65,6 @@ export function createTypography() {
63
65
  return item ? {node: item.node, offset: Math.max(0, Math.min(item.node.length, offset - item.start))} : null;
64
66
  }
65
67
 
66
- // This data-only guard is safe on an active edit surface, including during composition.
67
68
  function protectInitial(root) {
68
69
  for (const paragraph of paragraphs(root, 'p[data-md-lines],p[data-md-hanging="on"],p[' + protectedInitial + ']')) {
69
70
  const initial = initialRange(paragraph), letter = initial && [...segment(initial.text)].at(-1);
@@ -94,7 +95,6 @@ export function createTypography() {
94
95
  return paragraph.hasAttribute('data-md-lines') ? Math.max(parseFloat(style.lineHeight) || 0, parseFloat(style.fontSize) * 1.7) : Infinity;
95
96
  }
96
97
 
97
- // Returns a textContent coordinate, independent of markup, selected fonts or a floated initial.
98
98
  function firstLineEnd(paragraph) {
99
99
  if (!paragraph?.isConnected || !paragraph.getClientRects?.().length) return null;
100
100
  const view = paragraph.ownerDocument.defaultView;
@@ -141,8 +141,6 @@ export function createTypography() {
141
141
  if (anchor.node.isConnected && focus.node.isConnected) saved.selection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset);
142
142
  }
143
143
 
144
- // A native initial cannot span inline element boundaries. Move only its opening punctuation
145
- // into the letter's text node; the paragraph's text and all original elements stay in place.
146
144
  function unifyInitial(paragraph, state, initial, letterStart) {
147
145
  const items = runs(paragraph), at = point(items, letterStart);
148
146
  if (!at || at.node.parentElement?.closest(exclude)) return;
@@ -172,15 +170,14 @@ export function createTypography() {
172
170
  if (cursor !== move.next || text !== move.after) return;
173
171
  restore.push({move, pieces});
174
172
  }
175
- // A changed projection belongs to its current writer; restoration never replaces newer text.
176
173
  for (const {move, pieces} of restore) { move.node.data = move.before; for (const piece of pieces.slice(1)) piece.remove(); }
177
174
  }
178
175
 
179
- function clear(root, {forget = true} = {}) {
176
+ function clear(root, {forget = true, keepSelection = true} = {}) {
180
177
  const list = paragraphs(root, 'p[' + mark + '],p[' + protectedInitial + ']');
181
178
  for (const paragraph of list) {
182
179
  if (!paragraph.hasAttribute(mark)) { paragraph.removeAttribute(protectedInitial); continue; }
183
- const selected = selectionBefore(paragraph);
180
+ const selected = keepSelection ? selectionBefore(paragraph) : null;
184
181
  const state = states.get(paragraph);
185
182
  state?.work?.return();
186
183
  state?.fitWork?.return();
@@ -204,9 +201,6 @@ export function createTypography() {
204
201
  }
205
202
  }
206
203
 
207
- // Native clipboard HTML must see the original inline tree. Clear the selected
208
- // projection before the browser/host captures its Range, retaining current metrics.
209
- // The host schedules ordinary refinement again after the synchronous clipboard event.
210
204
  function clearSelection(root) {
211
205
  const selection=(root?.ownerDocument || root)?.getSelection?.();
212
206
  if (!selection?.rangeCount || selection.isCollapsed) return 0;
@@ -220,15 +214,12 @@ export function createTypography() {
220
214
  return selected.length;
221
215
  }
222
216
 
223
- // Optical alignment uses the available margin; paint containment and narrow pages must not
224
- // clip a large quoted initial. Offscreen measurement stages have no viewport clipping edge.
225
217
  function hangingEdge(paragraph, style, box) {
226
218
  const view = paragraph.ownerDocument.defaultView, rtl = style.direction === 'rtl';
227
219
  if (style.visibility !== 'visible' || box.right <= 0 || box.left >= view.innerWidth) return null;
228
220
  let edge = rtl ? view.innerWidth : 0;
229
221
  if (view.matchMedia('print').matches) {
230
222
  const content = paragraph.closest('.md-render')?.getBoundingClientRect();
231
- // The PDF painter clips at the content area even when a physical page margin exists.
232
223
  if (content?.width > 0) edge = rtl ? Math.min(edge, content.right) : Math.max(edge, content.left);
233
224
  }
234
225
  for (let element = paragraph; element; element = element.parentElement) {
@@ -302,8 +293,6 @@ export function createTypography() {
302
293
  for (const element of parents) {
303
294
  const computed = element === paragraph ? baseStyle : view.getComputedStyle(element);
304
295
  const s = Object.fromEntries([...shaping, ...admission].map(name => [name, computed[name]]));
305
- // The fit's own CSS suppresses native rewrapping. Source attributes and ancestor
306
- // whitespace remain in the key; clearing reads the current unprojected styles anew.
307
296
  if (prior?.plan) for (const name of ['whiteSpace', 'hyphens', 'hangingPunctuation', 'textWrapStyle'])
308
297
  s[name] = prior.saved.styles.get(element)?.[name] ?? s[name];
309
298
  const before = view.getComputedStyle(element, '::before').content, after = view.getComputedStyle(element, '::after').content;
@@ -331,7 +320,8 @@ export function createTypography() {
331
320
  ['white-space','text-wrap','hyphens','hanging-punctuation','word-spacing'].some(name => element.style.getPropertyPriority(name))) return null;
332
321
  const s = inspection.styles.get(element), atom = element !== paragraph && (/^(CODE|KBD|SAMP)$/.test(element.tagName) || s.whiteSpace === 'nowrap');
333
322
  if (s.hyphenateCharacter && s.hyphenateCharacter !== 'auto') return null;
334
- if (!supportsRichStyle({...s, hyphens:'manual', hangingPunctuation:'none', whiteSpace: atom ? 'nowrap' : s.whiteSpace}, language) ||
323
+ if (!supportsRichStyle({...s, hyphens:'manual', hangingPunctuation:'none', whiteSpace: atom ? 'nowrap' : s.whiteSpace,
324
+ lineBreak: s.lineBreak === 'after-white-space' ? 'auto' : s.lineBreak}, language) ||
335
325
  !atom && s.whiteSpace !== 'normal') return null;
336
326
  const extras = ['paddingLeft','paddingRight','borderLeftWidth','borderRightWidth','marginLeft','marginRight']
337
327
  .reduce((sum,name) => sum + Math.abs(parseFloat(s[name]) || 0),0);
@@ -383,6 +373,56 @@ export function createTypography() {
383
373
  return {width,indent,left:hanging?Math.max(0,left-clipLeft):0,right:hanging?Math.max(0,clipRight-right):0};
384
374
  }
385
375
 
376
+ function excluded(shape, a, b) {
377
+ const {points, box} = shape;
378
+ if (!points.length) return b > 0 && a < box.height ? [0, box.width] : null;
379
+ let low = Infinity, high = -Infinity;
380
+ for (let index = 0; index < points.length; index++) {
381
+ const [x1, y1] = points[index], [x2, y2] = points[(index + 1) % points.length];
382
+ const top = Math.min(y1, y2), bottom = Math.max(y1, y2);
383
+ if (bottom <= a || top >= b) continue;
384
+ for (const y of [Math.max(a, top), Math.min(b, bottom)]) {
385
+ const x = y1 === y2 ? null : x1 + (x2 - x1) * (y - y1) / (y2 - y1);
386
+ if (x == null) { low = Math.min(low, x1, x2); high = Math.max(high, x1, x2); }
387
+ else { low = Math.min(low, x); high = Math.max(high, x); }
388
+ }
389
+ }
390
+ return high > low ? [low, high] : null;
391
+ }
392
+
393
+ function floatMeasures(paragraph, style, box, width) {
394
+ const view = paragraph.ownerDocument.defaultView;
395
+ const floats = [...paragraph.children].filter(element => element.matches(flowFloat) && view.getComputedStyle(element).cssFloat !== 'none');
396
+ if (!floats.length) return null;
397
+ const lineHeight = parseFloat(style.lineHeight);
398
+ if (!(lineHeight > 0)) return {refuse: true};
399
+ const top = box.top + (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.paddingTop) || 0);
400
+ const left = box.left + (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.paddingLeft) || 0);
401
+ const right = box.right - (parseFloat(style.borderRightWidth) || 0) - (parseFloat(style.paddingRight) || 0);
402
+ const shapes = floats.map(element => {
403
+ const computed = view.getComputedStyle(element), bounds = element.getBoundingClientRect();
404
+ const points = [...computed.shapeOutside.matchAll(/(-?[\d.]+)px\s+(-?[\d.]+)px/g)].map(match => [+match[1], +match[2]]);
405
+ return {side: computed.cssFloat, box: bounds, points, top: bounds.top - (parseFloat(computed.marginTop) || 0),
406
+ bottom: bounds.bottom + (parseFloat(computed.marginBottom) || 0)};
407
+ });
408
+ const bottom = Math.max(...shapes.map(shape => shape.bottom)), widths = [];
409
+ for (let line = 0; top + line * lineHeight < bottom && line < 512; line++) {
410
+ const a = top + line * lineHeight, b = a + lineHeight;
411
+ let start = 0, end = 0;
412
+ for (const shape of shapes) {
413
+ if (b <= shape.top || a >= shape.bottom) continue;
414
+ const span = excluded(shape, a - shape.box.top, b - shape.box.top);
415
+ if (!span) continue;
416
+ if (shape.side === 'left') start = Math.max(start, shape.box.left + span[1] - left);
417
+ else end = Math.max(end, right - (shape.box.left + span[0]));
418
+ }
419
+ widths.push(start || end ? Math.floor((width - start - end - 1) * 64) / 64 : width);
420
+ }
421
+ while (widths.length && widths.at(-1) === width) widths.pop();
422
+ return {widths, lineHeight, key: shapes.map(shape => [shape.side, shape.box.left - left, shape.box.top - top, shape.box.width,
423
+ shape.box.height, shape.points.join(' ')].join(':')).join('|')};
424
+ }
425
+
386
426
  const topology = plan => plan && plan.lines.map(line => [line.start,line.end,line.next,line.kind,line.glyph].join(':')).join('|');
387
427
  function updateProjection(projection, plan) {
388
428
  for (const {node,property,line,sign} of projection.bindings) {
@@ -415,8 +455,6 @@ export function createTypography() {
415
455
  if (first.opening && line.start === first.start) operations.push({start:first.start,end:first.openEnd,opening:true,line:lineIndex});
416
456
  if (last.closing && line.end === last.end) operations.push({start:last.closeStart,end:last.end,closing:true,line:lineIndex});
417
457
  }
418
- // Sweep interval endpoints once. Each original text node is split from right to left;
419
- // links, comments, IDs and inline ancestors never move or get reconstructed.
420
458
  const events = new Map();
421
459
  const event = offset => { let value = events.get(offset); if (!value) { value={start:[],end:[]}; events.set(offset,value); } return value; };
422
460
  for (const op of operations) { event(op.start).start.push(op); event(op.end).end.push(op); }
@@ -435,8 +473,6 @@ export function createTypography() {
435
473
  pieces.push({item,start,end,operations:[...active]}); start=end;
436
474
  }
437
475
  }
438
- // Validate every zero-source insertion before the first write, so a refused source
439
- // coordinate cannot leave a partially projected paragraph behind.
440
476
  const sourceEnds = new Set(pieces.map(piece => piece.end));
441
477
  if (inserts.some(insert => !sourceEnds.has(insert.offset))) return false;
442
478
  const ends = new Map();
@@ -472,7 +508,21 @@ export function createTypography() {
472
508
  paragraph.setAttribute(fitMark,'');return projection;
473
509
  }
474
510
 
475
- function apply(root, {all=false}={}) {
511
+ function laidAsPlanned(paragraph, job) {
512
+ const style = job.view.getComputedStyle(paragraph), box = paragraph.getBoundingClientRect();
513
+ const inner = box.height - (parseFloat(style.paddingTop) || 0) - (parseFloat(style.paddingBottom) || 0) -
514
+ (parseFloat(style.borderTopWidth) || 0) - (parseFloat(style.borderBottomWidth) || 0);
515
+ return Math.abs(inner - job.plan.lines.length * job.beside.lineHeight) < 1;
516
+ }
517
+
518
+ function editKey(paragraph, generation) {
519
+ const page = paragraph.ownerDocument.documentElement;
520
+ return [generation, paragraph.innerHTML, ...[...paragraph.attributes].map(item => item.name + '=' + item.value),
521
+ ...[...page.attributes].map(item => item.name + '=' + item.value), paragraph.ownerDocument.body?.className,
522
+ paragraph.ownerDocument.defaultView.innerWidth].join('\u0000');
523
+ }
524
+
525
+ function apply(root, {all=false, editing=false}={}) {
476
526
  const started = performance.now();
477
527
  const generation = captureFontAuthority(root?.ownerDocument || globalThis.document);
478
528
  for (const paragraph of live) {
@@ -481,55 +531,63 @@ export function createTypography() {
481
531
  }
482
532
  const jobs=[];let pending=false,changed=0,released=0,preparedCount=0;
483
533
  for (const paragraph of paragraphs(root)) {
484
- if (paragraph.closest('table,pre,code,h1,h2,h3,h4,h5,h6,.block-edit') ||
485
- paragraph.isContentEditable && !paragraph.closest('.block-read')) {clear(paragraph);continue;}
534
+ if (paragraph.closest('table,pre,code,h1,h2,h3,h4,h5,h6')) {clear(paragraph);continue;}
535
+ if (paragraph.closest('.block-edit')) {
536
+ if (!editing) continue;
537
+ if (paragraph.getAttribute('data-md-align')!=='justify' || paragraph.getAttribute('data-md-hyphens')!=='auto') {
538
+ if (paragraph.hasAttribute(mark)) clear(paragraph);
539
+ protectInitial(paragraph);continue;
540
+ }
541
+ }
542
+ else if (paragraph.isContentEditable && !paragraph.closest('.block-read')) {clear(paragraph);continue;}
486
543
  if (!paragraph.isConnected || !all && paragraph.checkVisibility && !paragraph.checkVisibility({contentVisibilityAuto:true})) continue;
487
544
  const view=paragraph.ownerDocument.defaultView;
488
545
  if (!view || !paragraph.getClientRects().length) continue;
546
+ if (editing && states.get(paragraph)?.editKey===editKey(paragraph,generation)) continue;
489
547
  const style=view.getComputedStyle(paragraph),box=paragraph.getBoundingClientRect(),prior=states.get(paragraph);
490
548
  if (!all && (box.bottom < -100 || box.top > view.innerHeight+100)) continue;
491
549
  const hanging=paragraph.getAttribute('data-md-hanging')==='on',size=dimensions(paragraph,style,box,hanging);
492
550
  if (!(size.width>0)) continue;
493
- const parents=prior?.parents || [paragraph,...paragraph.querySelectorAll('*')];
551
+ const beside=floatMeasures(paragraph,style,box,size.width);
552
+ if (beside?.refuse) {clear(paragraph);continue;}
553
+ if (beside?.widths.some(width=>!(width>0))) {clear(paragraph);continue;}
554
+ if (beside) size.widths=beside.widths;
555
+ const parents=prior?.parents || own(paragraph);
494
556
  const inspection=inspect(paragraph,parents,prior,style);
495
557
  const reusable=prior?.html===paragraph.innerHTML && prior.key===inspection.key && (!prior.geometry || scope(paragraph.ownerDocument).current(prior.geometry));
496
- const sizeKey=[size.width,size.indent,size.left,size.right].join('|');
558
+ const sizeKey=[size.width,size.indent,size.left,size.right,beside?.key||''].join('|');
559
+ if (refused.get(paragraph)===sizeKey+'\u0000'+paragraph.textContent) {if (paragraph.hasAttribute(mark)) clear(paragraph);continue;}
497
560
  if (reusable && !prior.pending && prior.sizeKey===sizeKey) continue;
498
- // Preparation survives reflow; a suspended fit belongs to its captured width,
499
- // indent and margins. Cancel only that plan when dimensions have changed.
500
561
  const fitWork=reusable && prior.sizeKey===sizeKey?prior.fitWork:null;
501
562
  if (prior?.fitWork && prior.fitWork!==fitWork) {prior.fitWork.return();prior.fitWork=null;}
502
563
  if (!all && jobs.length===4) {if (!reusable) clear(paragraph);pending=true;continue;}
503
- jobs.push({paragraph,view,style,inspection,size,sizeKey,hanging,selected:selectionBefore(paragraph),
564
+ jobs.push({paragraph,view,style,inspection,size,sizeKey,hanging,beside,selected:selectionBefore(paragraph),
504
565
  cap:paragraph.hasAttribute('data-md-lines'),justified:paragraph.getAttribute('data-md-align')==='justify' && paragraph.getAttribute('data-md-hyphens')==='auto',
505
566
  saved:reusable?prior.saved:null,geometry:reusable?prior.geometry:null,prepared:reusable?prior.prepared:null,
506
567
  work:reusable?prior.work:null,fitWork,
507
568
  prior,reusable,needsClear:paragraph.hasAttribute(mark)});
508
569
  }
509
- // Remove obsolete source/style projections before yielding. Unprepared paragraphs
510
- // use native layout until their turn; no stale font plan remains on the page.
511
- for (const job of jobs) if (job.needsClear && !job.saved) clear(job.paragraph);
570
+ if (editing && jobs.some(job=>job.selected && !job.reusable)) root.ownerDocument.getSelection()?.removeAllRanges();
571
+ for (const job of jobs) if (job.needsClear && !job.saved) clear(job.paragraph,{keepSelection:!editing});
512
572
  function stateFor(job) {
513
573
  return {properties:new Map(),moves:[],saved:job.saved,geometry:job.geometry,prepared:job.prepared,plan:job.plan,
514
574
  work:job.work,fitWork:job.fitWork,parents:job.inspection.parents,key:job.inspection.key,generation:job.inspection.generation,sizeKey:job.sizeKey};
515
575
  }
516
576
  function suspend(job) {
517
577
  const state=stateFor(job);
518
- // A reusable projection may remain readable while a new width is being fitted.
519
- // Retain its restoration custody until it is updated or explicitly cleared.
520
578
  if (job.reusable && job.prior) for (const name of ['properties','moves','projection','plan']) state[name]=job.prior[name];
521
579
  state.pending=true;state.html=job.paragraph.innerHTML;
522
580
  states.set(job.paragraph,state);live.add(job.paragraph);job.paragraph.setAttribute(mark,'');pending=true;
523
581
  }
524
- // Both preparation and fitting yield through the same captured source/font owner.
525
- // Native text remains readable until its plan is ready. Each continuation
526
- // rereads the current source/style/font and width; it cannot publish a suspended plan.
527
- // A single native shaping call can exceed this allowance, so this is not a deadline.
528
582
  for (let jobIndex = 0; jobIndex < jobs.length; jobIndex++) {
529
583
  if (!all && jobIndex && performance.now() - started >= 8) {pending=true;break;}
530
584
  const job = jobs[jobIndex];
531
585
  if (!job.saved && job.justified) {
532
- if (job.needsClear) job.inspection=inspect(job.paragraph,[job.paragraph,...job.paragraph.querySelectorAll('*')],null,job.view.getComputedStyle(job.paragraph));
586
+ if (job.needsClear) {
587
+ const elements=own(job.paragraph),parents=job.inspection.parents;
588
+ if (!editing || !job.prior?.saved || elements.length!==parents.length || elements.some((element,index)=>element!==parents[index]))
589
+ job.inspection=inspect(job.paragraph,elements,null,job.view.getComputedStyle(job.paragraph));
590
+ }
533
591
  job.saved=snapshot(job.paragraph,job.inspection);
534
592
  if (job.saved) {
535
593
  job.work=scope(job.paragraph.ownerDocument).prepareSteps(job.paragraph,job.saved.inline,{policy:'paragraph',language:job.saved.language,
@@ -557,7 +615,7 @@ export function createTypography() {
557
615
  if (job.fitWork) {suspend(job);continue;}
558
616
  }
559
617
  job.projection=job.reusable && job.plan && job.prior?.projection?.topology===topology(job.plan) ? job.prior.projection : null;
560
- if (job.needsClear && job.saved && !job.projection) clear(job.paragraph,{forget:false});
618
+ if (job.needsClear && job.saved && !job.projection) clear(job.paragraph,{forget:false,keepSelection:!editing});
561
619
  const {paragraph,view,hanging,cap,justified}=job;
562
620
  if (!cap && !hanging && !justified) {restoreSelection(paragraph,job.selected);continue;}
563
621
  const state=stateFor(job);
@@ -571,10 +629,14 @@ export function createTypography() {
571
629
  }
572
630
  if (job.projection) {updateProjection(job.projection,job.plan);state.projection=job.projection;}
573
631
  else if (job.plan) state.projection=project(paragraph,job.saved,job.geometry,job.plan);
632
+ if (state.projection && job.beside && !laidAsPlanned(paragraph,job)) {
633
+ refused.set(paragraph,job.sizeKey+'\u0000'+paragraph.textContent);clear(paragraph);restoreSelection(paragraph,job.selected);continue;
634
+ }
574
635
  if (state.projection) released+=job.plan.lines.filter(line=>line.kind!=='end').length;
575
636
  else if (hanging) hangPunctuation(paragraph,state,job.style,hangingEdge(paragraph,job.style,paragraph.getBoundingClientRect()));
576
637
  state.html=paragraph.innerHTML;
577
638
  restoreSelection(paragraph,job.selected);changed++;
639
+ if (editing) state.editKey=editKey(paragraph,generation);
578
640
  }
579
641
  return {changed,released,pending,prepared:preparedCount};
580
642
  }