fetta 1.1.2 → 1.2.0

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/README.md CHANGED
@@ -6,7 +6,7 @@ Split text into characters, words, and lines while preserving the original typog
6
6
 
7
7
  ## Features
8
8
 
9
- - **Kerning Compensation** — Measures character positions before splitting, then applies margin adjustments to maintain original spacing
9
+ - **Kerning Compensation** — Measures kerning between character pairs, applies margin adjustments to maintain original spacing
10
10
  - **Nested Elements** — Preserves inline HTML elements (`<a>`, `<em>`, `<strong>`, etc.) with all attributes intact
11
11
  - **Line Detection** — Detects lines based on Y-position clustering, works with any container width
12
12
  - **Dash Handling** — Allows text to wrap naturally after em-dashes, en-dashes, and hyphens
@@ -265,12 +265,6 @@ Requires:
265
265
  - `IntersectionObserver`
266
266
  - `Intl.Segmenter`
267
267
 
268
- ### Safari
269
-
270
- Kerning compensation is not available in Safari due to its Range API returning integer values instead of sub-pixel precision. Text splitting works normally, just without the margin adjustments.
271
-
272
- When using `revertOnComplete` with character splitting in Safari, font kerning is automatically disabled to prevent visual shift on revert.
273
-
274
268
  ## License
275
269
 
276
270
  MIT
@@ -63,7 +63,89 @@ var INLINE_ELEMENTS = /* @__PURE__ */ new Set([
63
63
  "u",
64
64
  "var"
65
65
  ]);
66
- var isSafari = typeof navigator !== "undefined" && /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
66
+ var isSafariBrowser = null;
67
+ function isSafari() {
68
+ if (isSafariBrowser !== null) return isSafariBrowser;
69
+ if (typeof navigator === "undefined") return false;
70
+ isSafariBrowser = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
71
+ return isSafariBrowser;
72
+ }
73
+ function measureKerningCanvas(element, chars) {
74
+ const kerningMap = /* @__PURE__ */ new Map();
75
+ if (chars.length < 2) return kerningMap;
76
+ const canvas = document.createElement("canvas");
77
+ const ctx = canvas.getContext("2d");
78
+ if (!ctx) return kerningMap;
79
+ const styles = getComputedStyle(element);
80
+ ctx.font = `${styles.fontStyle} ${styles.fontWeight} ${styles.fontSize} ${styles.fontFamily}`;
81
+ if (styles.letterSpacing && styles.letterSpacing !== "normal") {
82
+ ctx.letterSpacing = styles.letterSpacing;
83
+ }
84
+ if (styles.wordSpacing && styles.wordSpacing !== "normal") {
85
+ ctx.wordSpacing = styles.wordSpacing;
86
+ }
87
+ if ("fontVariantLigatures" in ctx) ctx.fontVariantLigatures = "none";
88
+ const charWidths = /* @__PURE__ */ new Map();
89
+ for (const char of new Set(chars)) {
90
+ charWidths.set(char, ctx.measureText(char).width);
91
+ }
92
+ for (let i = 0; i < chars.length - 1; i++) {
93
+ const char1 = chars[i];
94
+ const char2 = chars[i + 1];
95
+ const pairWidth = ctx.measureText(char1 + char2).width;
96
+ const kerning = pairWidth - charWidths.get(char1) - charWidths.get(char2);
97
+ if (Math.abs(kerning) > 0.01) {
98
+ kerningMap.set(i + 1, kerning);
99
+ }
100
+ }
101
+ return kerningMap;
102
+ }
103
+ function measureKerningDOM(element, chars) {
104
+ const kerningMap = /* @__PURE__ */ new Map();
105
+ if (chars.length < 2) return kerningMap;
106
+ const measurer = document.createElement("span");
107
+ measurer.style.cssText = `
108
+ position: absolute;
109
+ visibility: hidden;
110
+ white-space: pre;
111
+ `;
112
+ const styles = getComputedStyle(element);
113
+ measurer.style.font = styles.font;
114
+ measurer.style.letterSpacing = styles.letterSpacing;
115
+ measurer.style.wordSpacing = styles.wordSpacing;
116
+ measurer.style.fontKerning = styles.fontKerning;
117
+ measurer.style.fontVariantLigatures = "none";
118
+ const webkitSmoothing = styles.webkitFontSmoothing || styles["-webkit-font-smoothing"];
119
+ const mozSmoothing = styles.MozOsxFontSmoothing || styles["-moz-osx-font-smoothing"];
120
+ if (webkitSmoothing) {
121
+ measurer.style.webkitFontSmoothing = webkitSmoothing;
122
+ }
123
+ if (mozSmoothing) {
124
+ measurer.style.MozOsxFontSmoothing = mozSmoothing;
125
+ }
126
+ element.appendChild(measurer);
127
+ const charWidths = /* @__PURE__ */ new Map();
128
+ for (const char of new Set(chars)) {
129
+ measurer.textContent = char;
130
+ charWidths.set(char, measurer.getBoundingClientRect().width);
131
+ }
132
+ for (let i = 0; i < chars.length - 1; i++) {
133
+ const char1 = chars[i];
134
+ const char2 = chars[i + 1];
135
+ measurer.textContent = char1 + char2;
136
+ const pairWidth = measurer.getBoundingClientRect().width;
137
+ const kerning = pairWidth - charWidths.get(char1) - charWidths.get(char2);
138
+ if (Math.abs(kerning) > 0.01) {
139
+ kerningMap.set(i + 1, kerning);
140
+ }
141
+ }
142
+ element.removeChild(measurer);
143
+ return kerningMap;
144
+ }
145
+ function measureKerning(element, chars) {
146
+ if (chars.length < 2) return /* @__PURE__ */ new Map();
147
+ return isSafari() ? measureKerningDOM(element, chars) : measureKerningCanvas(element, chars);
148
+ }
67
149
  var srOnlyStylesInjected = false;
68
150
  function injectSrOnlyStyles() {
69
151
  if (srOnlyStylesInjected || typeof document === "undefined") return;
@@ -191,24 +273,20 @@ function buildAncestorChain(textNode, rootElement, ancestorCache) {
191
273
  }
192
274
  return ancestors;
193
275
  }
194
- function measureOriginalText(element, splitChars, trackAncestors) {
195
- const range = document.createRange();
276
+ function collectTextStructure(element, trackAncestors) {
196
277
  const words = [];
197
278
  const ancestorCache = trackAncestors ? /* @__PURE__ */ new WeakMap() : null;
198
279
  const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
199
280
  let node;
200
281
  let currentWord = [];
201
- let wordStartLeft = null;
202
282
  let noSpaceBeforeNext = false;
203
283
  const pushWord = () => {
204
284
  if (currentWord.length > 0) {
205
285
  words.push({
206
286
  chars: currentWord,
207
- startLeft: wordStartLeft != null ? wordStartLeft : 0,
208
287
  noSpaceBefore: noSpaceBeforeNext
209
288
  });
210
289
  currentWord = [];
211
- wordStartLeft = null;
212
290
  noSpaceBeforeNext = false;
213
291
  }
214
292
  };
@@ -217,29 +295,16 @@ function measureOriginalText(element, splitChars, trackAncestors) {
217
295
  const text = node.textContent || "";
218
296
  const ancestors = trackAncestors ? buildAncestorChain(node, element, ancestorCache) : emptyAncestors;
219
297
  const graphemes = segmentGraphemes(text);
220
- let charOffset = 0;
221
298
  for (const grapheme of graphemes) {
222
299
  if (grapheme === " " || grapheme === "\n" || grapheme === " ") {
223
300
  pushWord();
224
- charOffset += grapheme.length;
225
301
  continue;
226
302
  }
227
- if (splitChars) {
228
- range.setStart(node, charOffset);
229
- range.setEnd(node, charOffset + grapheme.length);
230
- const rect = range.getBoundingClientRect();
231
- if (wordStartLeft === null) {
232
- wordStartLeft = rect.left;
233
- }
234
- currentWord.push({ char: grapheme, left: rect.left, ancestors });
235
- } else {
236
- currentWord.push({ char: grapheme, left: 0, ancestors });
237
- }
303
+ currentWord.push({ char: grapheme, ancestors });
238
304
  if (BREAK_CHARS.has(grapheme)) {
239
305
  pushWord();
240
306
  noSpaceBeforeNext = true;
241
307
  }
242
- charOffset += grapheme.length;
243
308
  }
244
309
  }
245
310
  pushWord();
@@ -325,11 +390,6 @@ function performSplit(element, measuredWords, charClass, wordClass, lineClass, s
325
390
  });
326
391
  charSpan.textContent = measuredChar.char;
327
392
  globalCharIndex++;
328
- if (charIndexInWord > 0) {
329
- const prevCharLeft = measuredWord.chars[charIndexInWord - 1].left;
330
- const gap = measuredChar.left - prevCharLeft;
331
- charSpan.dataset.expectedGap = gap.toString();
332
- }
333
393
  if ((options == null ? void 0 : options.mask) === "chars") {
334
394
  const charWrapper = createMaskWrapper("inline-block");
335
395
  charWrapper.appendChild(charSpan);
@@ -347,18 +407,13 @@ function performSplit(element, measuredWords, charClass, wordClass, lineClass, s
347
407
  }
348
408
  charGroups.forEach((group) => {
349
409
  group.chars.forEach((measuredChar) => {
350
- const charIndexInWord = measuredWord.chars.indexOf(measuredChar);
410
+ measuredWord.chars.indexOf(measuredChar);
351
411
  const charSpan = createSpan(charClass, globalCharIndex, "inline-block", {
352
412
  propIndex: options == null ? void 0 : options.propIndex,
353
413
  propName: "char"
354
414
  });
355
415
  charSpan.textContent = measuredChar.char;
356
416
  globalCharIndex++;
357
- if (charIndexInWord > 0) {
358
- const prevCharLeft = measuredWord.chars[charIndexInWord - 1].left;
359
- const gap = measuredChar.left - prevCharLeft;
360
- charSpan.dataset.expectedGap = gap.toString();
361
- }
362
417
  if ((options == null ? void 0 : options.mask) === "chars") {
363
418
  const charWrapper = createMaskWrapper("inline-block");
364
419
  charWrapper.appendChild(charSpan);
@@ -455,30 +510,18 @@ function performSplit(element, measuredWords, charClass, wordClass, lineClass, s
455
510
  i++;
456
511
  }
457
512
  }
458
- if (splitChars && allChars.length > 1 && !isSafari) {
459
- const range = document.createRange();
460
- const positions = allChars.map((c) => {
461
- var _a;
462
- const textNode = c.firstChild;
463
- if (textNode && textNode.nodeType === Node.TEXT_NODE) {
464
- range.setStart(textNode, 0);
465
- range.setEnd(textNode, ((_a = textNode.textContent) == null ? void 0 : _a.length) || 1);
466
- return range.getBoundingClientRect().left;
467
- }
468
- return c.getBoundingClientRect().left;
469
- });
470
- for (let i2 = 1; i2 < allChars.length; i2++) {
471
- const charSpan = allChars[i2];
472
- const expectedGap = charSpan.dataset.expectedGap;
473
- if (expectedGap !== void 0) {
474
- const originalGap = parseFloat(expectedGap);
475
- const currentGap = positions[i2] - positions[i2 - 1];
476
- const delta = originalGap - currentGap;
477
- if (Math.abs(delta) > 0.1 && Math.abs(delta) < 20) {
513
+ if (splitChars && allWords.length > 0) {
514
+ for (const wordSpan of allWords) {
515
+ const wordChars = Array.from(wordSpan.querySelectorAll(`.${charClass}`));
516
+ if (wordChars.length < 2) continue;
517
+ const charStrings = wordChars.map((c) => c.textContent || "");
518
+ const kerningMap = measureKerning(element, charStrings);
519
+ for (const [charIndex, kerning] of kerningMap) {
520
+ const charSpan = wordChars[charIndex];
521
+ if (charSpan && Math.abs(kerning) < 20) {
478
522
  const targetElement = (options == null ? void 0 : options.mask) === "chars" && charSpan.parentElement ? charSpan.parentElement : charSpan;
479
- targetElement.style.marginLeft = `${delta}px`;
523
+ targetElement.style.marginLeft = `${kerning}px`;
480
524
  }
481
- delete charSpan.dataset.expectedGap;
482
525
  }
483
526
  }
484
527
  }
@@ -666,12 +709,8 @@ function splitText(element, {
666
709
  if (splitChars) {
667
710
  element.style.fontVariantLigatures = "none";
668
711
  }
669
- if (isSafari && splitChars && revertOnComplete) {
670
- element.style.fontKerning = "none";
671
- }
672
712
  const trackAncestors = hasInlineDescendants(element);
673
- const measureChars = splitChars && !isSafari;
674
- const measuredWords = measureOriginalText(element, measureChars, trackAncestors);
713
+ const measuredWords = collectTextStructure(element, trackAncestors);
675
714
  const { chars, words, lines } = performSplit(
676
715
  element,
677
716
  measuredWords,
@@ -720,9 +759,6 @@ function splitText(element, {
720
759
  if (splitChars) {
721
760
  element.style.fontVariantLigatures = "none";
722
761
  }
723
- if (isSafari && splitChars && revertOnComplete) {
724
- element.style.fontKerning = "none";
725
- }
726
762
  dispose();
727
763
  };
728
764
  if (autoSplit) {
@@ -749,7 +785,7 @@ function splitText(element, {
749
785
  element.innerHTML = originalHTML;
750
786
  requestAnimationFrame(() => {
751
787
  if (!isActive) return;
752
- const newMeasuredWords = measureOriginalText(element, measureChars, trackAncestors);
788
+ const newMeasuredWords = collectTextStructure(element, trackAncestors);
753
789
  const result = performSplit(
754
790
  element,
755
791
  newMeasuredWords,
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Custom splitText implementation with built-in kerning compensation.
3
- * Measures character positions before splitting, applies compensation,
4
- * then detects lines based on actual rendered positions.
3
+ * Measures kerning between character pairs, splits text into spans,
4
+ * applies margin compensation, and detects lines based on rendered positions.
5
5
  */
6
6
  /**
7
7
  * Configuration options for the splitText function.
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { splitText } from './chunk-WGVCUEOU.js';
1
+ export { splitText } from './chunk-PRR25BMJ.js';
package/dist/react.js CHANGED
@@ -1,4 +1,4 @@
1
- import { splitText, __spreadProps, __spreadValues, normalizeToPromise } from './chunk-WGVCUEOU.js';
1
+ import { splitText, __spreadProps, __spreadValues, normalizeToPromise } from './chunk-PRR25BMJ.js';
2
2
  import { forwardRef, useRef, useCallback, useState, useLayoutEffect, useEffect, isValidElement, cloneElement } from 'react';
3
3
  import { jsx } from 'react/jsx-runtime';
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fetta",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Text splitting library with kerning compensation for animations",
5
5
  "type": "module",
6
6
  "sideEffects": false,