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 +1 -7
- package/dist/{chunk-WGVCUEOU.js → chunk-PRR25BMJ.js} +98 -62
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/react.js +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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 = `${
|
|
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
|
|
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 =
|
|
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
|
|
4
|
-
*
|
|
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-
|
|
1
|
+
export { splitText } from './chunk-PRR25BMJ.js';
|
package/dist/react.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { splitText, __spreadProps, __spreadValues, normalizeToPromise } from './chunk-
|
|
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
|
|