fetta 1.1.2 → 1.2.1

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,159 @@ 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 KERNING_STYLE_PROPS = [
67
+ "font",
68
+ "font-kerning",
69
+ "font-variant-ligatures",
70
+ "font-feature-settings",
71
+ "font-variation-settings",
72
+ "font-optical-sizing",
73
+ "font-size-adjust",
74
+ "font-stretch",
75
+ "font-variant-caps",
76
+ "font-variant-numeric",
77
+ "font-variant-east-asian",
78
+ "font-synthesis",
79
+ "font-synthesis-weight",
80
+ "font-synthesis-style",
81
+ "letter-spacing",
82
+ "word-spacing",
83
+ "text-rendering",
84
+ "text-transform",
85
+ "direction",
86
+ "unicode-bidi"
87
+ ];
88
+ function copyKerningStyles(target, styles) {
89
+ KERNING_STYLE_PROPS.forEach((prop) => {
90
+ const value = styles.getPropertyValue(prop);
91
+ if (value) target.style.setProperty(prop, value);
92
+ });
93
+ }
94
+ function buildCanvasFontString(styles) {
95
+ const fontStyle = styles.fontStyle || "normal";
96
+ const fontWeight = styles.fontWeight || "normal";
97
+ const fontSize = styles.fontSize || "16px";
98
+ const fontFamily = styles.fontFamily || "sans-serif";
99
+ return [fontStyle, fontWeight, fontSize, fontFamily].filter(Boolean).join(" ");
100
+ }
101
+ function applyKerningStylesToCanvas(ctx, styles) {
102
+ ctx.font = buildCanvasFontString(styles);
103
+ const ctxAny = ctx;
104
+ const setIfExists = (prop, value) => {
105
+ if (!value || value === "normal") return;
106
+ if (prop in ctxAny) ctxAny[prop] = value;
107
+ };
108
+ setIfExists("fontKerning", styles.getPropertyValue("font-kerning"));
109
+ setIfExists("fontVariantLigatures", styles.getPropertyValue("font-variant-ligatures"));
110
+ setIfExists("fontFeatureSettings", styles.getPropertyValue("font-feature-settings"));
111
+ setIfExists("fontVariationSettings", styles.getPropertyValue("font-variation-settings"));
112
+ setIfExists("fontOpticalSizing", styles.getPropertyValue("font-optical-sizing"));
113
+ setIfExists("fontSizeAdjust", styles.getPropertyValue("font-size-adjust"));
114
+ setIfExists("fontStretch", styles.getPropertyValue("font-stretch"));
115
+ setIfExists("fontVariantCaps", styles.getPropertyValue("font-variant-caps"));
116
+ setIfExists("fontVariantNumeric", styles.getPropertyValue("font-variant-numeric"));
117
+ setIfExists("fontVariantEastAsian", styles.getPropertyValue("font-variant-east-asian"));
118
+ setIfExists("fontSynthesis", styles.getPropertyValue("font-synthesis"));
119
+ setIfExists("fontSynthesisWeight", styles.getPropertyValue("font-synthesis-weight"));
120
+ setIfExists("fontSynthesisStyle", styles.getPropertyValue("font-synthesis-style"));
121
+ setIfExists("letterSpacing", styles.getPropertyValue("letter-spacing"));
122
+ setIfExists("wordSpacing", styles.getPropertyValue("word-spacing"));
123
+ setIfExists("textRendering", styles.getPropertyValue("text-rendering"));
124
+ setIfExists("direction", styles.getPropertyValue("direction"));
125
+ }
126
+ function buildKerningStyleKey(styles) {
127
+ return KERNING_STYLE_PROPS.map((prop) => styles.getPropertyValue(prop)).join("|");
128
+ }
129
+ function shouldUseDomKerning(styles) {
130
+ const textTransform = styles.getPropertyValue("text-transform");
131
+ if (textTransform && textTransform !== "none") return true;
132
+ const fontVariant = styles.getPropertyValue("font-variant");
133
+ if (fontVariant && fontVariant !== "normal") return true;
134
+ const fontStretch = styles.getPropertyValue("font-stretch");
135
+ if (fontStretch && fontStretch !== "normal" && fontStretch !== "100%") return true;
136
+ const fontFeatureSettings = styles.getPropertyValue("font-feature-settings");
137
+ if (fontFeatureSettings && fontFeatureSettings !== "normal") return true;
138
+ const fontVariationSettings = styles.getPropertyValue("font-variation-settings");
139
+ if (fontVariationSettings && fontVariationSettings !== "normal") return true;
140
+ const fontOpticalSizing = styles.getPropertyValue("font-optical-sizing");
141
+ if (fontOpticalSizing && fontOpticalSizing !== "auto") return true;
142
+ const fontSizeAdjust = styles.getPropertyValue("font-size-adjust");
143
+ if (fontSizeAdjust && fontSizeAdjust !== "none") return true;
144
+ return false;
145
+ }
146
+ var isSafariBrowser = null;
147
+ function isSafari() {
148
+ if (isSafariBrowser !== null) return isSafariBrowser;
149
+ if (typeof navigator === "undefined") return false;
150
+ isSafariBrowser = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
151
+ return isSafariBrowser;
152
+ }
153
+ function measureKerningCanvas(styleSource, chars, styles) {
154
+ const kerningMap = /* @__PURE__ */ new Map();
155
+ if (chars.length < 2) return kerningMap;
156
+ const canvas = document.createElement("canvas");
157
+ const ctx = canvas.getContext("2d");
158
+ if (!ctx) return kerningMap;
159
+ const computedStyles = styles != null ? styles : getComputedStyle(styleSource);
160
+ applyKerningStylesToCanvas(ctx, computedStyles);
161
+ const charWidths = /* @__PURE__ */ new Map();
162
+ for (const char of new Set(chars)) {
163
+ charWidths.set(char, ctx.measureText(char).width);
164
+ }
165
+ for (let i = 0; i < chars.length - 1; i++) {
166
+ const char1 = chars[i];
167
+ const char2 = chars[i + 1];
168
+ const pairWidth = ctx.measureText(char1 + char2).width;
169
+ const kerning = pairWidth - charWidths.get(char1) - charWidths.get(char2);
170
+ if (Math.abs(kerning) > 0.01) {
171
+ kerningMap.set(i + 1, kerning);
172
+ }
173
+ }
174
+ return kerningMap;
175
+ }
176
+ function measureKerningDOM(container, styleSource, chars, styles) {
177
+ const kerningMap = /* @__PURE__ */ new Map();
178
+ if (chars.length < 2) return kerningMap;
179
+ const measurer = document.createElement("span");
180
+ measurer.style.cssText = `
181
+ position: absolute;
182
+ visibility: hidden;
183
+ white-space: pre;
184
+ `;
185
+ const computedStyles = styles != null ? styles : getComputedStyle(styleSource);
186
+ copyKerningStyles(measurer, computedStyles);
187
+ const webkitSmoothing = computedStyles.webkitFontSmoothing || computedStyles["-webkit-font-smoothing"];
188
+ const mozSmoothing = computedStyles.MozOsxFontSmoothing || computedStyles["-moz-osx-font-smoothing"];
189
+ if (webkitSmoothing) {
190
+ measurer.style.webkitFontSmoothing = webkitSmoothing;
191
+ }
192
+ if (mozSmoothing) {
193
+ measurer.style.MozOsxFontSmoothing = mozSmoothing;
194
+ }
195
+ container.appendChild(measurer);
196
+ const charWidths = /* @__PURE__ */ new Map();
197
+ for (const char of new Set(chars)) {
198
+ measurer.textContent = char;
199
+ charWidths.set(char, measurer.getBoundingClientRect().width);
200
+ }
201
+ for (let i = 0; i < chars.length - 1; i++) {
202
+ const char1 = chars[i];
203
+ const char2 = chars[i + 1];
204
+ measurer.textContent = char1 + char2;
205
+ const pairWidth = measurer.getBoundingClientRect().width;
206
+ const kerning = pairWidth - charWidths.get(char1) - charWidths.get(char2);
207
+ if (Math.abs(kerning) > 0.01) {
208
+ kerningMap.set(i + 1, kerning);
209
+ }
210
+ }
211
+ container.removeChild(measurer);
212
+ return kerningMap;
213
+ }
214
+ function measureKerning(container, styleSource, chars, styles) {
215
+ if (chars.length < 2) return /* @__PURE__ */ new Map();
216
+ const computedStyles = styles != null ? styles : getComputedStyle(styleSource);
217
+ return isSafari() || shouldUseDomKerning(computedStyles) ? measureKerningDOM(container, styleSource, chars, computedStyles) : measureKerningCanvas(styleSource, chars, computedStyles);
218
+ }
67
219
  var srOnlyStylesInjected = false;
68
220
  function injectSrOnlyStyles() {
69
221
  if (srOnlyStylesInjected || typeof document === "undefined") return;
@@ -191,24 +343,20 @@ function buildAncestorChain(textNode, rootElement, ancestorCache) {
191
343
  }
192
344
  return ancestors;
193
345
  }
194
- function measureOriginalText(element, splitChars, trackAncestors) {
195
- const range = document.createRange();
346
+ function collectTextStructure(element, trackAncestors) {
196
347
  const words = [];
197
348
  const ancestorCache = trackAncestors ? /* @__PURE__ */ new WeakMap() : null;
198
349
  const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
199
350
  let node;
200
351
  let currentWord = [];
201
- let wordStartLeft = null;
202
352
  let noSpaceBeforeNext = false;
203
353
  const pushWord = () => {
204
354
  if (currentWord.length > 0) {
205
355
  words.push({
206
356
  chars: currentWord,
207
- startLeft: wordStartLeft != null ? wordStartLeft : 0,
208
357
  noSpaceBefore: noSpaceBeforeNext
209
358
  });
210
359
  currentWord = [];
211
- wordStartLeft = null;
212
360
  noSpaceBeforeNext = false;
213
361
  }
214
362
  };
@@ -217,29 +365,16 @@ function measureOriginalText(element, splitChars, trackAncestors) {
217
365
  const text = node.textContent || "";
218
366
  const ancestors = trackAncestors ? buildAncestorChain(node, element, ancestorCache) : emptyAncestors;
219
367
  const graphemes = segmentGraphemes(text);
220
- let charOffset = 0;
221
368
  for (const grapheme of graphemes) {
222
369
  if (grapheme === " " || grapheme === "\n" || grapheme === " ") {
223
370
  pushWord();
224
- charOffset += grapheme.length;
225
371
  continue;
226
372
  }
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
- }
373
+ currentWord.push({ char: grapheme, ancestors });
238
374
  if (BREAK_CHARS.has(grapheme)) {
239
375
  pushWord();
240
376
  noSpaceBeforeNext = true;
241
377
  }
242
- charOffset += grapheme.length;
243
378
  }
244
379
  }
245
380
  pushWord();
@@ -325,11 +460,6 @@ function performSplit(element, measuredWords, charClass, wordClass, lineClass, s
325
460
  });
326
461
  charSpan.textContent = measuredChar.char;
327
462
  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
463
  if ((options == null ? void 0 : options.mask) === "chars") {
334
464
  const charWrapper = createMaskWrapper("inline-block");
335
465
  charWrapper.appendChild(charSpan);
@@ -347,18 +477,12 @@ function performSplit(element, measuredWords, charClass, wordClass, lineClass, s
347
477
  }
348
478
  charGroups.forEach((group) => {
349
479
  group.chars.forEach((measuredChar) => {
350
- const charIndexInWord = measuredWord.chars.indexOf(measuredChar);
351
480
  const charSpan = createSpan(charClass, globalCharIndex, "inline-block", {
352
481
  propIndex: options == null ? void 0 : options.propIndex,
353
482
  propName: "char"
354
483
  });
355
484
  charSpan.textContent = measuredChar.char;
356
485
  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
486
  if ((options == null ? void 0 : options.mask) === "chars") {
363
487
  const charWrapper = createMaskWrapper("inline-block");
364
488
  charWrapper.appendChild(charSpan);
@@ -455,30 +579,42 @@ function performSplit(element, measuredWords, charClass, wordClass, lineClass, s
455
579
  i++;
456
580
  }
457
581
  }
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;
582
+ if (splitChars && allWords.length > 0) {
583
+ for (const wordSpan of allWords) {
584
+ const wordChars = Array.from(wordSpan.querySelectorAll(`.${charClass}`));
585
+ if (wordChars.length < 2) continue;
586
+ const styleGroups = [];
587
+ const firstCharStyles = getComputedStyle(wordChars[0]);
588
+ let currentKey = buildKerningStyleKey(firstCharStyles);
589
+ let currentGroup = {
590
+ chars: [wordChars[0]],
591
+ styleSource: wordChars[0],
592
+ styles: firstCharStyles
593
+ };
594
+ for (let i2 = 1; i2 < wordChars.length; i2++) {
595
+ const char = wordChars[i2];
596
+ const charStyles = getComputedStyle(char);
597
+ const key = buildKerningStyleKey(charStyles);
598
+ if (key === currentKey) {
599
+ currentGroup.chars.push(char);
600
+ } else {
601
+ styleGroups.push(currentGroup);
602
+ currentKey = key;
603
+ currentGroup = { chars: [char], styleSource: char, styles: charStyles };
604
+ }
467
605
  }
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) {
478
- const targetElement = (options == null ? void 0 : options.mask) === "chars" && charSpan.parentElement ? charSpan.parentElement : charSpan;
479
- targetElement.style.marginLeft = `${delta}px`;
606
+ styleGroups.push(currentGroup);
607
+ for (const group of styleGroups) {
608
+ if (group.chars.length < 2) continue;
609
+ const charStrings = group.chars.map((c) => c.textContent || "");
610
+ const kerningMap = measureKerning(element, group.styleSource, charStrings, group.styles);
611
+ for (const [charIndex, kerning] of kerningMap) {
612
+ const charSpan = group.chars[charIndex];
613
+ if (charSpan && Math.abs(kerning) < 20) {
614
+ const targetElement = (options == null ? void 0 : options.mask) === "chars" && charSpan.parentElement ? charSpan.parentElement : charSpan;
615
+ targetElement.style.marginLeft = `${kerning}px`;
616
+ }
480
617
  }
481
- delete charSpan.dataset.expectedGap;
482
618
  }
483
619
  }
484
620
  }
@@ -666,12 +802,8 @@ function splitText(element, {
666
802
  if (splitChars) {
667
803
  element.style.fontVariantLigatures = "none";
668
804
  }
669
- if (isSafari && splitChars && revertOnComplete) {
670
- element.style.fontKerning = "none";
671
- }
672
805
  const trackAncestors = hasInlineDescendants(element);
673
- const measureChars = splitChars && !isSafari;
674
- const measuredWords = measureOriginalText(element, measureChars, trackAncestors);
806
+ const measuredWords = collectTextStructure(element, trackAncestors);
675
807
  const { chars, words, lines } = performSplit(
676
808
  element,
677
809
  measuredWords,
@@ -720,9 +852,6 @@ function splitText(element, {
720
852
  if (splitChars) {
721
853
  element.style.fontVariantLigatures = "none";
722
854
  }
723
- if (isSafari && splitChars && revertOnComplete) {
724
- element.style.fontKerning = "none";
725
- }
726
855
  dispose();
727
856
  };
728
857
  if (autoSplit) {
@@ -749,7 +878,7 @@ function splitText(element, {
749
878
  element.innerHTML = originalHTML;
750
879
  requestAnimationFrame(() => {
751
880
  if (!isActive) return;
752
- const newMeasuredWords = measureOriginalText(element, measureChars, trackAncestors);
881
+ const newMeasuredWords = collectTextStructure(element, trackAncestors);
753
882
  const result = performSplit(
754
883
  element,
755
884
  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-Q2D5AQBW.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-Q2D5AQBW.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.1",
4
4
  "description": "Text splitting library with kerning compensation for animations",
5
5
  "type": "module",
6
6
  "sideEffects": false,