@harakah/remotion-arabic 0.0.0-stage → 0.1.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/LICENSE +22 -0
- package/README.md +18 -2
- package/dist/index.d.ts +103 -0
- package/dist/index.js +286 -0
- package/package.json +47 -4
- package/src/index.tsx +306 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amro Alemam and the Harakah (حركة) contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @harakah/remotion-arabic
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Joining-aware Arabic text animation for Remotion. Private, not published.
|
|
4
|
+
تحريك النص العربي في Remotion حرفاً حرفاً أو كلمة كلمة مع بقاء الحروف متصلة.
|
|
5
|
+
|
|
6
|
+
- `splitArabic(text, "letter" | "word")`: drop-in for `text.split("")` / `[...text]` / `Array.from(text)`. It returns ZWJ-padded grapheme tokens with لا kept as one token, tashkeel attached, and spaces as `" "`.
|
|
7
|
+
- `mapArabicWords(splitArabic(text), (ch, i) => <span key={i}>…</span>)`: keeps words together when you animate per letter. Each word goes in a nowrap box, so wrapped lines never break mid-word. Your callback runs unchanged with the original global index, so the stagger is kept. `harakah-fix fix --wrap-words` writes this for you.
|
|
8
|
+
- `<ArabicText text="…" preset="rise" />`: renders with RTL and `lang="ar"`, staggered per letter or per word. Words sit in nowrap boxes so lines never break mid-word.
|
|
9
|
+
- `formatDigits(n | string, "eastern" | "western" | "keep")` gives `١٬٢٥٠٬٠٠٠`, and leaves Latin runs such as `iPhone 17` untouched.
|
|
10
|
+
- Eastern output always uses ٬ (U+066C) and ٫ (U+066B), whatever the renderer's ICU data.
|
|
11
|
+
- Eastern digits typed with Latin separators (`١,٢٥٠`) are corrected too.
|
|
12
|
+
- `withArabicSeparators(loadFont(...))` fixes fonts like Alexandria, which draw ٬ and ٫ with their Latin comma glyph.
|
|
13
|
+
- The returned `fontFamily` puts a unicode-range face first. It covers U+066A–U+066C only and comes from IBM Plex Sans Arabic (OFL).
|
|
14
|
+
- Every other glyph still comes from your font, and font load order doesn't matter.
|
|
15
|
+
- يرسم خط Alexandria الفاصلة ٬ بشكل الفاصلة اللاتينية، وهذه الدالة تصلح ذلك.
|
|
16
|
+
- `isolateLatin(text)` adds FSI/PDI around Latin runs.
|
|
17
|
+
|
|
18
|
+
It is a single self-contained file (`src/index.tsx`), so `harakah-fix fix` can vendor it into a project as `src/harakah-arabic.tsx`.
|
|
19
|
+
Set `REMOTION_HARAKAH_REFERENCE=1` (render-time env) to make `splitArabic` return running text. `harakah-fix compare` uses this for its join verdict.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @harakah/remotion-arabic: joining-aware Arabic text animation for Remotion.
|
|
3
|
+
* حركة للنص العربي في Remotion: تحريك حرفاً حرفاً أو كلمة كلمة مع بقاء الحروف متصلة.
|
|
4
|
+
*
|
|
5
|
+
* Why: `text.split('')` + `display: inline-block` puts every Arabic letter in its own box, so
|
|
6
|
+
* Chrome draws the isolated form of every letter and the word falls apart. `splitArabic()` returns
|
|
7
|
+
* grapheme tokens padded with U+200D ZERO WIDTH JOINER (and keeps لا as one token), so each box
|
|
8
|
+
* shows the contextual form it would have in running text.
|
|
9
|
+
*
|
|
10
|
+
* لماذا: تقسيم النص بـ split('') مع inline-block يضع كل حرف في صندوق مستقل فيظهر بشكله المنفصل.
|
|
11
|
+
* الدالة splitArabic() تعيد الحروف مع واصل صفري (ZWJ) وتُبقي «لا» حرفاً واحداً، فتبقى الكلمة متصلة.
|
|
12
|
+
*
|
|
13
|
+
* Self-contained (no dependency on harakah-fix) so it can be vendored as a single file.
|
|
14
|
+
*/
|
|
15
|
+
import React from "react";
|
|
16
|
+
export type SplitBy = "letter" | "word";
|
|
17
|
+
export interface SplitOptions {
|
|
18
|
+
/** "letter" (default): one token per grapheme, ZWJ-padded. "word": words and space tokens. */
|
|
19
|
+
by?: SplitBy;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Split text for per-letter / per-word animation without breaking Arabic joins.
|
|
23
|
+
* Drop-in replacement for `text.split('')`, `Array.from(text)`, `[...text]`: spaces stay as " " tokens,
|
|
24
|
+
* diacritics stay attached, لا stays one token, Latin text is returned unchanged (grapheme by grapheme).
|
|
25
|
+
*
|
|
26
|
+
* تقسيم النص للتحريك دون تفكيك الحروف العربية. بديل مباشر لـ split(''): المسافات تبقى عناصر مستقلة،
|
|
27
|
+
* والحركات (التشكيل) ملتصقة بحروفها، و«لا» عنصر واحد، والنص اللاتيني لا يتغير.
|
|
28
|
+
*/
|
|
29
|
+
export declare function splitArabic(text: string, options?: SplitOptions | SplitBy): string[];
|
|
30
|
+
export type DigitStyle = "eastern" | "western" | "keep";
|
|
31
|
+
/**
|
|
32
|
+
* Format a number or normalize digits in a string. eastern → ١٢٣ (with ٫ ٬ ٪), western → 123.
|
|
33
|
+
* Latin runs such as "iPhone 17" are left untouched.
|
|
34
|
+
*
|
|
35
|
+
* تنسيق الأرقام: مشرقية ١٢٣ (افتراضياً) أو غربية 123، مع ترك الكلمات اللاتينية مثل iPhone 17 كما هي.
|
|
36
|
+
*/
|
|
37
|
+
export declare function formatDigits(value: number | string, digits?: DigitStyle, options?: Intl.NumberFormatOptions): string;
|
|
38
|
+
/** Font family that only covers ٪ ٫ ٬ (U+066A–U+066C). اسم الخط الذي يغطي ٪ ٫ ٬ فقط. */
|
|
39
|
+
export declare const ARABIC_SEPARATORS_FAMILY = "Harakah Arabic Separators";
|
|
40
|
+
/**
|
|
41
|
+
* Some display faces (notably Alexandria) draw the Arabic separators ٬ ٫ with the Latin comma glyph,
|
|
42
|
+
* so "١٬٢٥٠٬٠٠٠" looks like "١,٢٥٠,٠٠٠". Wrap the loader: the returned fontFamily puts a tiny
|
|
43
|
+
* unicode-range face (IBM Plex Sans Arabic, OFL) first, so only U+066A–U+066C change; every other
|
|
44
|
+
* glyph still comes from the original font. Works regardless of font load order.
|
|
45
|
+
*
|
|
46
|
+
* بعض خطوط العناوين (مثل Alexandria) ترسم الفواصل العربية ٬ ٫ بشكل الفاصلة اللاتينية. غلّف
|
|
47
|
+
* loadFont() بهذه الدالة فتُرسم هذه الرموز فقط من IBM Plex Sans Arabic ويبقى باقي النص بالخط الأصلي.
|
|
48
|
+
*
|
|
49
|
+
* @example const { fontFamily } = withArabicSeparators(loadFont("normal", { weights: ["800"], subsets: ["arabic"] }));
|
|
50
|
+
*/
|
|
51
|
+
export declare function withArabicSeparators<T extends {
|
|
52
|
+
fontFamily: string;
|
|
53
|
+
}>(loaded: T, opts?: {
|
|
54
|
+
src?: (weight: 400 | 600 | 700) => string;
|
|
55
|
+
}): T;
|
|
56
|
+
/** Register the separator-only face (idempotent). تسجيل خط الفواصل مرة واحدة. */
|
|
57
|
+
export declare function registerArabicSeparators(src?: (weight: 400 | 600 | 700) => string): void;
|
|
58
|
+
/**
|
|
59
|
+
* Wrap Latin/number runs inside Arabic text in Unicode isolates (FSI … PDI) so punctuation stays put.
|
|
60
|
+
* عزل الكلمات اللاتينية داخل النص العربي حتى لا تنتقل علامات الترقيم إلى الجهة الخاطئة.
|
|
61
|
+
*/
|
|
62
|
+
export declare function isolateLatin(text: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* Keep words together when animating per letter: groups `splitArabic()` tokens into words and wraps each
|
|
65
|
+
* word in a nowrap inline-block box, so lines break only between words, never mid-word. Your render
|
|
66
|
+
* callback is called unchanged with the original token and its global index (stagger timing is kept);
|
|
67
|
+
* space tokens are rendered between the word boxes.
|
|
68
|
+
* {mapArabicWords(splitArabic(title), (ch, i) => <span key={i} style={…}>{ch}</span>)}
|
|
69
|
+
* أبقِ حروف الكلمة معاً: يلتف السطر بين الكلمات فقط، مع الحفاظ على ترتيب الحروف وتوقيتها.
|
|
70
|
+
*/
|
|
71
|
+
export declare function mapArabicWords(tokens: readonly string[], render: (token: string, index: number) => React.ReactNode, wordStyle?: React.CSSProperties): React.ReactNode[];
|
|
72
|
+
export type Preset = "rise" | "fade" | "blur" | "scale";
|
|
73
|
+
export interface ArabicTextProps {
|
|
74
|
+
/** The text. النص. */
|
|
75
|
+
text: string;
|
|
76
|
+
/** Animate per letter (default) or per word. حرفاً حرفاً أو كلمة كلمة. */
|
|
77
|
+
by?: SplitBy;
|
|
78
|
+
/** Frames before the first token starts. التأخير قبل أول عنصر (بالإطارات). */
|
|
79
|
+
delay?: number;
|
|
80
|
+
/** Frames between tokens. الفاصل بين العناصر (بالإطارات). */
|
|
81
|
+
stagger?: number;
|
|
82
|
+
/** Frames each token takes to arrive. مدة دخول كل عنصر. */
|
|
83
|
+
duration?: number;
|
|
84
|
+
/** Built-in motion preset. نمط الحركة الجاهز. */
|
|
85
|
+
preset?: Preset;
|
|
86
|
+
/** Custom per-token style from progress 0→1. نمط مخصص حسب التقدم من ٠ إلى ١. */
|
|
87
|
+
animate?: (progress: number, index: number, token: string) => React.CSSProperties;
|
|
88
|
+
/** Use a spring instead of an eased curve. استخدام spring بدلاً من منحنى. */
|
|
89
|
+
springy?: boolean;
|
|
90
|
+
/** Digit policy applied to the text (default eastern). نظام الأرقام (افتراضياً مشرقية). */
|
|
91
|
+
digits?: DigitStyle;
|
|
92
|
+
style?: React.CSSProperties;
|
|
93
|
+
tokenStyle?: React.CSSProperties;
|
|
94
|
+
className?: string;
|
|
95
|
+
as?: "div" | "h1" | "h2" | "h3" | "p" | "span";
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Drop-in animated Arabic text: RTL, lang="ar", joined letters, staggered motion.
|
|
99
|
+
* مكوّن نص عربي متحرك: اتجاه من اليمين، lang="ar"، حروف متصلة، وحركة متتابعة.
|
|
100
|
+
*
|
|
101
|
+
* @example <ArabicText text="صناعة المحتوى العربي" preset="rise" stagger={2} />
|
|
102
|
+
*/
|
|
103
|
+
export declare const ArabicText: React.FC<ArabicTextProps>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* @harakah/remotion-arabic: joining-aware Arabic text animation for Remotion.
|
|
4
|
+
* حركة للنص العربي في Remotion: تحريك حرفاً حرفاً أو كلمة كلمة مع بقاء الحروف متصلة.
|
|
5
|
+
*
|
|
6
|
+
* Why: `text.split('')` + `display: inline-block` puts every Arabic letter in its own box, so
|
|
7
|
+
* Chrome draws the isolated form of every letter and the word falls apart. `splitArabic()` returns
|
|
8
|
+
* grapheme tokens padded with U+200D ZERO WIDTH JOINER (and keeps لا as one token), so each box
|
|
9
|
+
* shows the contextual form it would have in running text.
|
|
10
|
+
*
|
|
11
|
+
* لماذا: تقسيم النص بـ split('') مع inline-block يضع كل حرف في صندوق مستقل فيظهر بشكله المنفصل.
|
|
12
|
+
* الدالة splitArabic() تعيد الحروف مع واصل صفري (ZWJ) وتُبقي «لا» حرفاً واحداً، فتبقى الكلمة متصلة.
|
|
13
|
+
*
|
|
14
|
+
* Self-contained (no dependency on harakah-fix) so it can be vendored as a single file.
|
|
15
|
+
*/
|
|
16
|
+
import React from "react";
|
|
17
|
+
import { continueRender, delayRender, Easing, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
18
|
+
const ZWJ = "\u200D";
|
|
19
|
+
const TRANSPARENT = /[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED]/;
|
|
20
|
+
const ARABIC_LETTER = /[\u0620-\u064A\u066E-\u06D3\u06FA-\u06FF\u0750-\u077F\u08A0-\u08C9]/;
|
|
21
|
+
const RIGHT_JOINING = new Set([
|
|
22
|
+
0x0622, 0x0623, 0x0624, 0x0625, 0x0627, 0x0629, 0x062f, 0x0630, 0x0631, 0x0632, 0x0648, 0x0671, 0x0672, 0x0673,
|
|
23
|
+
0x0675, 0x0676, 0x0677, 0x0688, 0x0689, 0x068a, 0x068b, 0x068c, 0x068d, 0x068e, 0x068f, 0x0690, 0x0691, 0x0692,
|
|
24
|
+
0x0693, 0x0694, 0x0695, 0x0696, 0x0697, 0x0698, 0x0699, 0x06c0, 0x06c3, 0x06c4, 0x06c5, 0x06c6, 0x06c7, 0x06c8,
|
|
25
|
+
0x06c9, 0x06ca, 0x06cb, 0x06cd, 0x06cf, 0x06d2, 0x06d3, 0x06ee, 0x06ef,
|
|
26
|
+
]);
|
|
27
|
+
const NON_JOINING = new Set([0x0621, 0x0674, 0x06d5]);
|
|
28
|
+
const ALEFS = new Set(["\u0622", "\u0623", "\u0625", "\u0627", "\u0671"]);
|
|
29
|
+
function joinType(ch) {
|
|
30
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
31
|
+
if (cp === 0x200d || cp === 0x0640)
|
|
32
|
+
return "C";
|
|
33
|
+
if (TRANSPARENT.test(ch))
|
|
34
|
+
return "T";
|
|
35
|
+
if (RIGHT_JOINING.has(cp))
|
|
36
|
+
return "R";
|
|
37
|
+
if (NON_JOINING.has(cp))
|
|
38
|
+
return "U";
|
|
39
|
+
return ARABIC_LETTER.test(ch) ? "D" : "U";
|
|
40
|
+
}
|
|
41
|
+
function edge(s, last) {
|
|
42
|
+
const a = Array.from(s.replace(/\u200D/g, ""));
|
|
43
|
+
if (last)
|
|
44
|
+
a.reverse();
|
|
45
|
+
for (const c of a) {
|
|
46
|
+
const t = joinType(c);
|
|
47
|
+
if (t !== "T")
|
|
48
|
+
return t;
|
|
49
|
+
}
|
|
50
|
+
return "U";
|
|
51
|
+
}
|
|
52
|
+
const joinsNext = (t) => t === "D" || t === "C";
|
|
53
|
+
const joinsPrev = (t) => t === "D" || t === "R" || t === "C";
|
|
54
|
+
const isSpace = (s) => /^[\s\u00A0]+$/.test(s);
|
|
55
|
+
const bases = (s) => Array.from(s.replace(/\u200D/g, "")).filter((c) => joinType(c) !== "T");
|
|
56
|
+
let REFERENCE = false;
|
|
57
|
+
try {
|
|
58
|
+
// Set REMOTION_HARAKAH_REFERENCE=1 to render running text instead of boxes (used by harakah-fix compare).
|
|
59
|
+
REFERENCE = process.env.REMOTION_HARAKAH_REFERENCE === "1";
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
REFERENCE = false;
|
|
63
|
+
}
|
|
64
|
+
function graphemes(text) {
|
|
65
|
+
const Seg = Intl.Segmenter;
|
|
66
|
+
if (Seg)
|
|
67
|
+
return Array.from(new Seg("ar", { granularity: "grapheme" }).segment(text), (s) => s.segment);
|
|
68
|
+
// Fallback: attach combining marks to the previous character.
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const ch of Array.from(text)) {
|
|
71
|
+
if (out.length && (TRANSPARENT.test(ch) || ch === ZWJ))
|
|
72
|
+
out[out.length - 1] += ch;
|
|
73
|
+
else
|
|
74
|
+
out.push(ch);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Split text for per-letter / per-word animation without breaking Arabic joins.
|
|
80
|
+
* Drop-in replacement for `text.split('')`, `Array.from(text)`, `[...text]`: spaces stay as " " tokens,
|
|
81
|
+
* diacritics stay attached, لا stays one token, Latin text is returned unchanged (grapheme by grapheme).
|
|
82
|
+
*
|
|
83
|
+
* تقسيم النص للتحريك دون تفكيك الحروف العربية. بديل مباشر لـ split(''): المسافات تبقى عناصر مستقلة،
|
|
84
|
+
* والحركات (التشكيل) ملتصقة بحروفها، و«لا» عنصر واحد، والنص اللاتيني لا يتغير.
|
|
85
|
+
*/
|
|
86
|
+
export function splitArabic(text, options = {}) {
|
|
87
|
+
const by = typeof options === "string" ? options : options.by ?? "letter";
|
|
88
|
+
const src = String(text ?? "").replace(/\u200D/g, "");
|
|
89
|
+
if (REFERENCE)
|
|
90
|
+
return [src];
|
|
91
|
+
if (by === "word")
|
|
92
|
+
return src.split(/([\s\u00A0]+)/).filter((t) => t.length > 0);
|
|
93
|
+
const g = graphemes(src);
|
|
94
|
+
// keep lam + alef together (mandatory ligature)
|
|
95
|
+
const tokens = [];
|
|
96
|
+
for (const t of g) {
|
|
97
|
+
const prev = tokens[tokens.length - 1];
|
|
98
|
+
if (prev && !isSpace(prev) && !isSpace(t)) {
|
|
99
|
+
const pb = bases(prev), tb = bases(t);
|
|
100
|
+
if (pb.length === 1 && pb[0] === "\u0644" && tb.length === 1 && ALEFS.has(tb[0])) {
|
|
101
|
+
tokens[tokens.length - 1] = prev + t;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
tokens.push(t);
|
|
106
|
+
}
|
|
107
|
+
return tokens.map((t, i) => {
|
|
108
|
+
if (isSpace(t) || !(ARABIC_LETTER.test(t) || t.includes("\u0640")))
|
|
109
|
+
return t;
|
|
110
|
+
const prev = i > 0 ? tokens[i - 1] : "";
|
|
111
|
+
const next = i < tokens.length - 1 ? tokens[i + 1] : "";
|
|
112
|
+
let out = t;
|
|
113
|
+
if (prev && !isSpace(prev) && joinsNext(edge(prev, true)) && joinsPrev(edge(t, false)))
|
|
114
|
+
out = ZWJ + out;
|
|
115
|
+
if (next && !isSpace(next) && joinsNext(edge(t, true)) && joinsPrev(edge(next, false)))
|
|
116
|
+
out = out + ZWJ;
|
|
117
|
+
return out;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const LATIN_RUN = /[A-Za-z][A-Za-z0-9]*(?:[ .\-_'’+&#@/:]+[A-Za-z0-9]+)*/g;
|
|
121
|
+
/**
|
|
122
|
+
* Format a number or normalize digits in a string. eastern → ١٢٣ (with ٫ ٬ ٪), western → 123.
|
|
123
|
+
* Latin runs such as "iPhone 17" are left untouched.
|
|
124
|
+
*
|
|
125
|
+
* تنسيق الأرقام: مشرقية ١٢٣ (افتراضياً) أو غربية 123، مع ترك الكلمات اللاتينية مثل iPhone 17 كما هي.
|
|
126
|
+
*/
|
|
127
|
+
export function formatDigits(value, digits = "eastern", options = {}) {
|
|
128
|
+
// Numbers are formatted with Latin digits first and then mapped, so the result never depends on the
|
|
129
|
+
// renderer's ICU/CLDR data: eastern always uses ٬ (U+066C) for grouping and ٫ (U+066B) for decimals.
|
|
130
|
+
// نُنسّق بالأرقام اللاتينية ثم نحوّلها، فتكون الفاصلة دائماً ٬ والفاصلة العشرية ٫ مهما كانت بيانات المتصفح.
|
|
131
|
+
if (typeof value === "number") {
|
|
132
|
+
const latin = new Intl.NumberFormat("en-US", options).format(value);
|
|
133
|
+
if (digits === "keep" || digits === "western")
|
|
134
|
+
return latin;
|
|
135
|
+
return toEastern(latin);
|
|
136
|
+
}
|
|
137
|
+
if (digits === "keep")
|
|
138
|
+
return value;
|
|
139
|
+
const conv = (s) => (digits === "eastern" ? toEastern(s, true) : toWestern(s));
|
|
140
|
+
let out = "", last = 0;
|
|
141
|
+
for (const m of value.matchAll(LATIN_RUN)) {
|
|
142
|
+
out += conv(value.slice(last, m.index)) + m[0];
|
|
143
|
+
last = (m.index ?? 0) + m[0].length;
|
|
144
|
+
}
|
|
145
|
+
return out + conv(value.slice(last));
|
|
146
|
+
}
|
|
147
|
+
function toEastern(s, loose = false) {
|
|
148
|
+
// In free text only "1,234" style groups count as thousands; in formatted numbers every comma does.
|
|
149
|
+
const group = loose ? /(\d),(?=\d{3}(?!\d))/g : /(\d),(?=\d)/g;
|
|
150
|
+
return s
|
|
151
|
+
.replace(/(\d)\.(?=\d)/g, "$1\u066B")
|
|
152
|
+
.replace(group, "$1\u066C")
|
|
153
|
+
.replace(/(\d)\s?%/g, "$1\u066A")
|
|
154
|
+
.replace(/[0-9]/g, (d) => String.fromCharCode(0x0660 + Number(d)))
|
|
155
|
+
// Eastern digits typed with Latin separators ("١,٢٥٠") get the Arabic ones too.
|
|
156
|
+
.replace(/([\u0660-\u0669]),(?=[\u0660-\u0669]{3}(?![\u0660-\u0669]))/g, "$1\u066C").replace(/([\u0660-\u0669])\.(?=[\u0660-\u0669])/g, "$1\u066B");
|
|
157
|
+
}
|
|
158
|
+
function toWestern(s) {
|
|
159
|
+
return s
|
|
160
|
+
.replace(/[\u0660-\u0669]/g, (d) => String(d.charCodeAt(0) - 0x0660))
|
|
161
|
+
.replace(/[\u06F0-\u06F9]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
|
|
162
|
+
.replace(/\u066B/g, ".").replace(/\u066C/g, ",").replace(/\u066A/g, "%");
|
|
163
|
+
}
|
|
164
|
+
const PLEX_ARABIC = {
|
|
165
|
+
400: "https://fonts.gstatic.com/s/ibmplexsansarabic/v15/Qw3CZRtWPQCuHme67tEYUIx3Kh0PHR9N6Ys43PWrfQ.woff2",
|
|
166
|
+
600: "https://fonts.gstatic.com/s/ibmplexsansarabic/v15/Qw3NZRtWPQCuHme67tEYUIx3Kh0PHR9N6YPi-OCRXMR5Kw.woff2",
|
|
167
|
+
700: "https://fonts.gstatic.com/s/ibmplexsansarabic/v15/Qw3NZRtWPQCuHme67tEYUIx3Kh0PHR9N6YOG-eCRXMR5Kw.woff2",
|
|
168
|
+
};
|
|
169
|
+
/** Font family that only covers ٪ ٫ ٬ (U+066A–U+066C). اسم الخط الذي يغطي ٪ ٫ ٬ فقط. */
|
|
170
|
+
export const ARABIC_SEPARATORS_FAMILY = "Harakah Arabic Separators";
|
|
171
|
+
let separatorsRegistered = false;
|
|
172
|
+
/**
|
|
173
|
+
* Some display faces (notably Alexandria) draw the Arabic separators ٬ ٫ with the Latin comma glyph,
|
|
174
|
+
* so "١٬٢٥٠٬٠٠٠" looks like "١,٢٥٠,٠٠٠". Wrap the loader: the returned fontFamily puts a tiny
|
|
175
|
+
* unicode-range face (IBM Plex Sans Arabic, OFL) first, so only U+066A–U+066C change; every other
|
|
176
|
+
* glyph still comes from the original font. Works regardless of font load order.
|
|
177
|
+
*
|
|
178
|
+
* بعض خطوط العناوين (مثل Alexandria) ترسم الفواصل العربية ٬ ٫ بشكل الفاصلة اللاتينية. غلّف
|
|
179
|
+
* loadFont() بهذه الدالة فتُرسم هذه الرموز فقط من IBM Plex Sans Arabic ويبقى باقي النص بالخط الأصلي.
|
|
180
|
+
*
|
|
181
|
+
* @example const { fontFamily } = withArabicSeparators(loadFont("normal", { weights: ["800"], subsets: ["arabic"] }));
|
|
182
|
+
*/
|
|
183
|
+
export function withArabicSeparators(loaded, opts = {}) {
|
|
184
|
+
registerArabicSeparators(opts.src);
|
|
185
|
+
return { ...loaded, fontFamily: `"${ARABIC_SEPARATORS_FAMILY}", ${loaded.fontFamily}` };
|
|
186
|
+
}
|
|
187
|
+
/** Register the separator-only face (idempotent). تسجيل خط الفواصل مرة واحدة. */
|
|
188
|
+
export function registerArabicSeparators(src = (w) => PLEX_ARABIC[w]) {
|
|
189
|
+
if (separatorsRegistered || typeof document === "undefined" || typeof FontFace === "undefined")
|
|
190
|
+
return;
|
|
191
|
+
separatorsRegistered = true;
|
|
192
|
+
const handle = delayRender("harakah: Arabic separators (IBM Plex Sans Arabic)");
|
|
193
|
+
const ranges = [[400, "1 450"], [600, "451 650"], [700, "651 1000"]];
|
|
194
|
+
const faces = ranges.map(([w, weight]) => new FontFace(ARABIC_SEPARATORS_FAMILY, `url(${src(w)}) format("woff2")`, { weight, unicodeRange: "U+066A-066C" }));
|
|
195
|
+
faces.forEach((f) => document.fonts.add(f));
|
|
196
|
+
Promise.all(faces.map((f) => f.load())).then(() => continueRender(handle), () => continueRender(handle));
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Wrap Latin/number runs inside Arabic text in Unicode isolates (FSI … PDI) so punctuation stays put.
|
|
200
|
+
* عزل الكلمات اللاتينية داخل النص العربي حتى لا تنتقل علامات الترقيم إلى الجهة الخاطئة.
|
|
201
|
+
*/
|
|
202
|
+
export function isolateLatin(text) {
|
|
203
|
+
return text.replace(/\u2068|\u2069/g, "").replace(LATIN_RUN, (m) => `\u2068${m}\u2069`);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Keep words together when animating per letter: groups `splitArabic()` tokens into words and wraps each
|
|
207
|
+
* word in a nowrap inline-block box, so lines break only between words, never mid-word. Your render
|
|
208
|
+
* callback is called unchanged with the original token and its global index (stagger timing is kept);
|
|
209
|
+
* space tokens are rendered between the word boxes.
|
|
210
|
+
* {mapArabicWords(splitArabic(title), (ch, i) => <span key={i} style={…}>{ch}</span>)}
|
|
211
|
+
* أبقِ حروف الكلمة معاً: يلتف السطر بين الكلمات فقط، مع الحفاظ على ترتيب الحروف وتوقيتها.
|
|
212
|
+
*/
|
|
213
|
+
export function mapArabicWords(tokens, render, wordStyle) {
|
|
214
|
+
const out = [];
|
|
215
|
+
let word = [];
|
|
216
|
+
let start = 0;
|
|
217
|
+
const flush = () => {
|
|
218
|
+
if (!word.length)
|
|
219
|
+
return;
|
|
220
|
+
out.push(_jsx("span", { style: { display: "inline-block", whiteSpace: "nowrap", ...wordStyle }, children: word }, `harakah-w${start}`));
|
|
221
|
+
word = [];
|
|
222
|
+
};
|
|
223
|
+
tokens.forEach((t, i) => {
|
|
224
|
+
if (/^[\s\u00A0]+$/.test(t)) {
|
|
225
|
+
flush();
|
|
226
|
+
out.push(_jsx(React.Fragment, { children: render(t, i) }, `harakah-s${i}`));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!word.length)
|
|
230
|
+
start = i;
|
|
231
|
+
word.push(_jsx(React.Fragment, { children: render(t, i) }, `harakah-t${i}`));
|
|
232
|
+
});
|
|
233
|
+
flush();
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
const PRESETS = {
|
|
237
|
+
rise: (p) => ({ opacity: p, transform: `translateY(${((1 - p) * 0.45).toFixed(4)}em)` }),
|
|
238
|
+
fade: (p) => ({ opacity: p }),
|
|
239
|
+
blur: (p) => ({ opacity: p, filter: `blur(${((1 - p) * 10).toFixed(2)}px)` }),
|
|
240
|
+
scale: (p) => ({ opacity: p, transform: `scale(${(0.85 + 0.15 * p).toFixed(4)})` }),
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
243
|
+
* Drop-in animated Arabic text: RTL, lang="ar", joined letters, staggered motion.
|
|
244
|
+
* مكوّن نص عربي متحرك: اتجاه من اليمين، lang="ar"، حروف متصلة، وحركة متتابعة.
|
|
245
|
+
*
|
|
246
|
+
* @example <ArabicText text="صناعة المحتوى العربي" preset="rise" stagger={2} />
|
|
247
|
+
*/
|
|
248
|
+
export const ArabicText = ({ text, by = "letter", delay = 0, stagger, duration = 18, preset = "rise", animate, springy = false, digits = "eastern", style, tokenStyle, className, as = "div", }) => {
|
|
249
|
+
const frame = useCurrentFrame();
|
|
250
|
+
const { fps } = useVideoConfig();
|
|
251
|
+
const tokens = splitArabic(digits === "keep" ? text : formatDigits(text, digits), by);
|
|
252
|
+
const gap = stagger ?? (by === "letter" ? 2 : 5);
|
|
253
|
+
const Tag = as;
|
|
254
|
+
// Group letter tokens into words: each word is a nowrap box, so lines only break at spaces
|
|
255
|
+
// (per-letter inline-blocks would otherwise allow a break between any two letters).
|
|
256
|
+
const words = [];
|
|
257
|
+
const parts = []; // " " for spaces, index into words otherwise
|
|
258
|
+
tokens.forEach((t, i) => {
|
|
259
|
+
if (/^[\s\u00A0]+$/.test(t)) {
|
|
260
|
+
parts.push(" ");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const last = parts[parts.length - 1];
|
|
264
|
+
if (typeof last === "number")
|
|
265
|
+
words[last].tokens.push(t);
|
|
266
|
+
else {
|
|
267
|
+
words.push({ tokens: [t], start: i });
|
|
268
|
+
parts.push(words.length - 1);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
let n = 0;
|
|
272
|
+
const single = tokens.length === 1;
|
|
273
|
+
return (_jsx(Tag, { dir: "rtl", lang: "ar", className: className, style: { direction: "rtl", ...style }, children: parts.map((part, pi) => {
|
|
274
|
+
if (part === " ")
|
|
275
|
+
return _jsx(React.Fragment, { children: " " }, `s${pi}`);
|
|
276
|
+
const w = words[part];
|
|
277
|
+
const inner = w.tokens.map((t, k) => {
|
|
278
|
+
const local = frame - delay - n++ * gap;
|
|
279
|
+
const p = springy
|
|
280
|
+
? spring({ frame: local, fps, durationInFrames: duration, config: { damping: 200 } })
|
|
281
|
+
: interpolate(local, [0, duration], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.bezier(0.2, 0.8, 0.2, 1) });
|
|
282
|
+
return (_jsx("span", { style: { display: "inline-block", whiteSpace: single ? "normal" : "pre", ...tokenStyle, ...(animate ? animate(p, w.start + k, t) : PRESETS[preset](p)) }, children: t }, k));
|
|
283
|
+
});
|
|
284
|
+
return single || w.tokens.length === 1 ? _jsx(React.Fragment, { children: inner }, `w${pi}`) : _jsx("span", { style: { display: "inline-block", whiteSpace: "nowrap" }, children: inner }, `w${pi}`);
|
|
285
|
+
}) }));
|
|
286
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harakah/remotion-arabic",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Joining-aware Arabic text animation for Remotion: <ArabicText>, splitArabic(), formatDigits(), isolateLatin()",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"arabic",
|
|
7
|
+
"rtl",
|
|
8
|
+
"remotion",
|
|
9
|
+
"animation",
|
|
10
|
+
"typography",
|
|
11
|
+
"zwj"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"homepage": "https://harakah.si",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/harakah-si/harakah/issues",
|
|
17
|
+
"email": "hello@harakah.si"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "dist/index.js",
|
|
21
|
+
"types": "dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"src"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"react": ">=18",
|
|
35
|
+
"remotion": ">=4"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc -p .",
|
|
39
|
+
"prepack": "tsc -p ."
|
|
40
|
+
},
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/harakah-si/harakah.git",
|
|
44
|
+
"directory": "packages/remotion-arabic"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @harakah/remotion-arabic: joining-aware Arabic text animation for Remotion.
|
|
3
|
+
* حركة للنص العربي في Remotion: تحريك حرفاً حرفاً أو كلمة كلمة مع بقاء الحروف متصلة.
|
|
4
|
+
*
|
|
5
|
+
* Why: `text.split('')` + `display: inline-block` puts every Arabic letter in its own box, so
|
|
6
|
+
* Chrome draws the isolated form of every letter and the word falls apart. `splitArabic()` returns
|
|
7
|
+
* grapheme tokens padded with U+200D ZERO WIDTH JOINER (and keeps لا as one token), so each box
|
|
8
|
+
* shows the contextual form it would have in running text.
|
|
9
|
+
*
|
|
10
|
+
* لماذا: تقسيم النص بـ split('') مع inline-block يضع كل حرف في صندوق مستقل فيظهر بشكله المنفصل.
|
|
11
|
+
* الدالة splitArabic() تعيد الحروف مع واصل صفري (ZWJ) وتُبقي «لا» حرفاً واحداً، فتبقى الكلمة متصلة.
|
|
12
|
+
*
|
|
13
|
+
* Self-contained (no dependency on harakah-fix) so it can be vendored as a single file.
|
|
14
|
+
*/
|
|
15
|
+
import React from "react";
|
|
16
|
+
import { continueRender, delayRender, Easing, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
17
|
+
|
|
18
|
+
const ZWJ = "\u200D";
|
|
19
|
+
const TRANSPARENT = /[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED]/;
|
|
20
|
+
const ARABIC_LETTER = /[\u0620-\u064A\u066E-\u06D3\u06FA-\u06FF\u0750-\u077F\u08A0-\u08C9]/;
|
|
21
|
+
const RIGHT_JOINING = new Set([
|
|
22
|
+
0x0622, 0x0623, 0x0624, 0x0625, 0x0627, 0x0629, 0x062f, 0x0630, 0x0631, 0x0632, 0x0648, 0x0671, 0x0672, 0x0673,
|
|
23
|
+
0x0675, 0x0676, 0x0677, 0x0688, 0x0689, 0x068a, 0x068b, 0x068c, 0x068d, 0x068e, 0x068f, 0x0690, 0x0691, 0x0692,
|
|
24
|
+
0x0693, 0x0694, 0x0695, 0x0696, 0x0697, 0x0698, 0x0699, 0x06c0, 0x06c3, 0x06c4, 0x06c5, 0x06c6, 0x06c7, 0x06c8,
|
|
25
|
+
0x06c9, 0x06ca, 0x06cb, 0x06cd, 0x06cf, 0x06d2, 0x06d3, 0x06ee, 0x06ef,
|
|
26
|
+
]);
|
|
27
|
+
const NON_JOINING = new Set([0x0621, 0x0674, 0x06d5]);
|
|
28
|
+
const ALEFS = new Set(["\u0622", "\u0623", "\u0625", "\u0627", "\u0671"]);
|
|
29
|
+
|
|
30
|
+
type Join = "U" | "R" | "D" | "C" | "T";
|
|
31
|
+
function joinType(ch: string): Join {
|
|
32
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
33
|
+
if (cp === 0x200d || cp === 0x0640) return "C";
|
|
34
|
+
if (TRANSPARENT.test(ch)) return "T";
|
|
35
|
+
if (RIGHT_JOINING.has(cp)) return "R";
|
|
36
|
+
if (NON_JOINING.has(cp)) return "U";
|
|
37
|
+
return ARABIC_LETTER.test(ch) ? "D" : "U";
|
|
38
|
+
}
|
|
39
|
+
function edge(s: string, last: boolean): Join {
|
|
40
|
+
const a = Array.from(s.replace(/\u200D/g, ""));
|
|
41
|
+
if (last) a.reverse();
|
|
42
|
+
for (const c of a) { const t = joinType(c); if (t !== "T") return t; }
|
|
43
|
+
return "U";
|
|
44
|
+
}
|
|
45
|
+
const joinsNext = (t: Join) => t === "D" || t === "C";
|
|
46
|
+
const joinsPrev = (t: Join) => t === "D" || t === "R" || t === "C";
|
|
47
|
+
const isSpace = (s: string) => /^[\s\u00A0]+$/.test(s);
|
|
48
|
+
const bases = (s: string) => Array.from(s.replace(/\u200D/g, "")).filter((c) => joinType(c) !== "T");
|
|
49
|
+
|
|
50
|
+
declare const process: { env: Record<string, string | undefined> };
|
|
51
|
+
let REFERENCE = false;
|
|
52
|
+
try {
|
|
53
|
+
// Set REMOTION_HARAKAH_REFERENCE=1 to render running text instead of boxes (used by harakah-fix compare).
|
|
54
|
+
REFERENCE = process.env.REMOTION_HARAKAH_REFERENCE === "1";
|
|
55
|
+
} catch {
|
|
56
|
+
REFERENCE = false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function graphemes(text: string): string[] {
|
|
60
|
+
const Seg = (Intl as unknown as { Segmenter?: new (l: string, o: { granularity: string }) => { segment(s: string): Iterable<{ segment: string }> } }).Segmenter;
|
|
61
|
+
if (Seg) return Array.from(new Seg("ar", { granularity: "grapheme" }).segment(text), (s) => s.segment);
|
|
62
|
+
// Fallback: attach combining marks to the previous character.
|
|
63
|
+
const out: string[] = [];
|
|
64
|
+
for (const ch of Array.from(text)) {
|
|
65
|
+
if (out.length && (TRANSPARENT.test(ch) || ch === ZWJ)) out[out.length - 1] += ch;
|
|
66
|
+
else out.push(ch);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type SplitBy = "letter" | "word";
|
|
72
|
+
export interface SplitOptions {
|
|
73
|
+
/** "letter" (default): one token per grapheme, ZWJ-padded. "word": words and space tokens. */
|
|
74
|
+
by?: SplitBy;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Split text for per-letter / per-word animation without breaking Arabic joins.
|
|
79
|
+
* Drop-in replacement for `text.split('')`, `Array.from(text)`, `[...text]`: spaces stay as " " tokens,
|
|
80
|
+
* diacritics stay attached, لا stays one token, Latin text is returned unchanged (grapheme by grapheme).
|
|
81
|
+
*
|
|
82
|
+
* تقسيم النص للتحريك دون تفكيك الحروف العربية. بديل مباشر لـ split(''): المسافات تبقى عناصر مستقلة،
|
|
83
|
+
* والحركات (التشكيل) ملتصقة بحروفها، و«لا» عنصر واحد، والنص اللاتيني لا يتغير.
|
|
84
|
+
*/
|
|
85
|
+
export function splitArabic(text: string, options: SplitOptions | SplitBy = {}): string[] {
|
|
86
|
+
const by = typeof options === "string" ? options : options.by ?? "letter";
|
|
87
|
+
const src = String(text ?? "").replace(/\u200D/g, "");
|
|
88
|
+
if (REFERENCE) return [src];
|
|
89
|
+
if (by === "word") return src.split(/([\s\u00A0]+)/).filter((t) => t.length > 0);
|
|
90
|
+
const g = graphemes(src);
|
|
91
|
+
// keep lam + alef together (mandatory ligature)
|
|
92
|
+
const tokens: string[] = [];
|
|
93
|
+
for (const t of g) {
|
|
94
|
+
const prev = tokens[tokens.length - 1];
|
|
95
|
+
if (prev && !isSpace(prev) && !isSpace(t)) {
|
|
96
|
+
const pb = bases(prev), tb = bases(t);
|
|
97
|
+
if (pb.length === 1 && pb[0] === "\u0644" && tb.length === 1 && ALEFS.has(tb[0])) { tokens[tokens.length - 1] = prev + t; continue; }
|
|
98
|
+
}
|
|
99
|
+
tokens.push(t);
|
|
100
|
+
}
|
|
101
|
+
return tokens.map((t, i) => {
|
|
102
|
+
if (isSpace(t) || !(ARABIC_LETTER.test(t) || t.includes("\u0640"))) return t;
|
|
103
|
+
const prev = i > 0 ? tokens[i - 1] : "";
|
|
104
|
+
const next = i < tokens.length - 1 ? tokens[i + 1] : "";
|
|
105
|
+
let out = t;
|
|
106
|
+
if (prev && !isSpace(prev) && joinsNext(edge(prev, true)) && joinsPrev(edge(t, false))) out = ZWJ + out;
|
|
107
|
+
if (next && !isSpace(next) && joinsNext(edge(t, true)) && joinsPrev(edge(next, false))) out = out + ZWJ;
|
|
108
|
+
return out;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type DigitStyle = "eastern" | "western" | "keep";
|
|
113
|
+
const LATIN_RUN = /[A-Za-z][A-Za-z0-9]*(?:[ .\-_'’+&#@/:]+[A-Za-z0-9]+)*/g;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Format a number or normalize digits in a string. eastern → ١٢٣ (with ٫ ٬ ٪), western → 123.
|
|
117
|
+
* Latin runs such as "iPhone 17" are left untouched.
|
|
118
|
+
*
|
|
119
|
+
* تنسيق الأرقام: مشرقية ١٢٣ (افتراضياً) أو غربية 123، مع ترك الكلمات اللاتينية مثل iPhone 17 كما هي.
|
|
120
|
+
*/
|
|
121
|
+
export function formatDigits(value: number | string, digits: DigitStyle = "eastern", options: Intl.NumberFormatOptions = {}): string {
|
|
122
|
+
// Numbers are formatted with Latin digits first and then mapped, so the result never depends on the
|
|
123
|
+
// renderer's ICU/CLDR data: eastern always uses ٬ (U+066C) for grouping and ٫ (U+066B) for decimals.
|
|
124
|
+
// نُنسّق بالأرقام اللاتينية ثم نحوّلها، فتكون الفاصلة دائماً ٬ والفاصلة العشرية ٫ مهما كانت بيانات المتصفح.
|
|
125
|
+
if (typeof value === "number") {
|
|
126
|
+
const latin = new Intl.NumberFormat("en-US", options).format(value);
|
|
127
|
+
if (digits === "keep" || digits === "western") return latin;
|
|
128
|
+
return toEastern(latin);
|
|
129
|
+
}
|
|
130
|
+
if (digits === "keep") return value;
|
|
131
|
+
const conv = (s: string) => (digits === "eastern" ? toEastern(s, true) : toWestern(s));
|
|
132
|
+
let out = "", last = 0;
|
|
133
|
+
for (const m of value.matchAll(LATIN_RUN)) { out += conv(value.slice(last, m.index)) + m[0]; last = (m.index ?? 0) + m[0].length; }
|
|
134
|
+
return out + conv(value.slice(last));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function toEastern(s: string, loose = false): string {
|
|
138
|
+
// In free text only "1,234" style groups count as thousands; in formatted numbers every comma does.
|
|
139
|
+
const group = loose ? /(\d),(?=\d{3}(?!\d))/g : /(\d),(?=\d)/g;
|
|
140
|
+
return s
|
|
141
|
+
.replace(/(\d)\.(?=\d)/g, "$1\u066B")
|
|
142
|
+
.replace(group, "$1\u066C")
|
|
143
|
+
.replace(/(\d)\s?%/g, "$1\u066A")
|
|
144
|
+
.replace(/[0-9]/g, (d) => String.fromCharCode(0x0660 + Number(d)))
|
|
145
|
+
// Eastern digits typed with Latin separators ("١,٢٥٠") get the Arabic ones too.
|
|
146
|
+
.replace(/([\u0660-\u0669]),(?=[\u0660-\u0669]{3}(?![\u0660-\u0669]))/g, "$1\u066C").replace(/([\u0660-\u0669])\.(?=[\u0660-\u0669])/g, "$1\u066B");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function toWestern(s: string): string {
|
|
150
|
+
return s
|
|
151
|
+
.replace(/[\u0660-\u0669]/g, (d) => String(d.charCodeAt(0) - 0x0660))
|
|
152
|
+
.replace(/[\u06F0-\u06F9]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
|
|
153
|
+
.replace(/\u066B/g, ".").replace(/\u066C/g, ",").replace(/\u066A/g, "%");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const PLEX_ARABIC: Record<number, string> = {
|
|
157
|
+
400: "https://fonts.gstatic.com/s/ibmplexsansarabic/v15/Qw3CZRtWPQCuHme67tEYUIx3Kh0PHR9N6Ys43PWrfQ.woff2",
|
|
158
|
+
600: "https://fonts.gstatic.com/s/ibmplexsansarabic/v15/Qw3NZRtWPQCuHme67tEYUIx3Kh0PHR9N6YPi-OCRXMR5Kw.woff2",
|
|
159
|
+
700: "https://fonts.gstatic.com/s/ibmplexsansarabic/v15/Qw3NZRtWPQCuHme67tEYUIx3Kh0PHR9N6YOG-eCRXMR5Kw.woff2",
|
|
160
|
+
};
|
|
161
|
+
/** Font family that only covers ٪ ٫ ٬ (U+066A–U+066C). اسم الخط الذي يغطي ٪ ٫ ٬ فقط. */
|
|
162
|
+
export const ARABIC_SEPARATORS_FAMILY = "Harakah Arabic Separators";
|
|
163
|
+
let separatorsRegistered = false;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Some display faces (notably Alexandria) draw the Arabic separators ٬ ٫ with the Latin comma glyph,
|
|
167
|
+
* so "١٬٢٥٠٬٠٠٠" looks like "١,٢٥٠,٠٠٠". Wrap the loader: the returned fontFamily puts a tiny
|
|
168
|
+
* unicode-range face (IBM Plex Sans Arabic, OFL) first, so only U+066A–U+066C change; every other
|
|
169
|
+
* glyph still comes from the original font. Works regardless of font load order.
|
|
170
|
+
*
|
|
171
|
+
* بعض خطوط العناوين (مثل Alexandria) ترسم الفواصل العربية ٬ ٫ بشكل الفاصلة اللاتينية. غلّف
|
|
172
|
+
* loadFont() بهذه الدالة فتُرسم هذه الرموز فقط من IBM Plex Sans Arabic ويبقى باقي النص بالخط الأصلي.
|
|
173
|
+
*
|
|
174
|
+
* @example const { fontFamily } = withArabicSeparators(loadFont("normal", { weights: ["800"], subsets: ["arabic"] }));
|
|
175
|
+
*/
|
|
176
|
+
export function withArabicSeparators<T extends { fontFamily: string }>(loaded: T, opts: { src?: (weight: 400 | 600 | 700) => string } = {}): T {
|
|
177
|
+
registerArabicSeparators(opts.src);
|
|
178
|
+
return { ...loaded, fontFamily: `"${ARABIC_SEPARATORS_FAMILY}", ${loaded.fontFamily}` };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Register the separator-only face (idempotent). تسجيل خط الفواصل مرة واحدة. */
|
|
182
|
+
export function registerArabicSeparators(src: (weight: 400 | 600 | 700) => string = (w) => PLEX_ARABIC[w]): void {
|
|
183
|
+
if (separatorsRegistered || typeof document === "undefined" || typeof FontFace === "undefined") return;
|
|
184
|
+
separatorsRegistered = true;
|
|
185
|
+
const handle = delayRender("harakah: Arabic separators (IBM Plex Sans Arabic)");
|
|
186
|
+
const ranges: [400 | 600 | 700, string][] = [[400, "1 450"], [600, "451 650"], [700, "651 1000"]];
|
|
187
|
+
const faces = ranges.map(([w, weight]) => new FontFace(ARABIC_SEPARATORS_FAMILY, `url(${src(w)}) format("woff2")`, { weight, unicodeRange: "U+066A-066C" }));
|
|
188
|
+
faces.forEach((f) => (document.fonts as unknown as { add(face: FontFace): void }).add(f));
|
|
189
|
+
Promise.all(faces.map((f) => f.load())).then(() => continueRender(handle), () => continueRender(handle));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Wrap Latin/number runs inside Arabic text in Unicode isolates (FSI … PDI) so punctuation stays put.
|
|
194
|
+
* عزل الكلمات اللاتينية داخل النص العربي حتى لا تنتقل علامات الترقيم إلى الجهة الخاطئة.
|
|
195
|
+
*/
|
|
196
|
+
export function isolateLatin(text: string): string {
|
|
197
|
+
return text.replace(/\u2068|\u2069/g, "").replace(LATIN_RUN, (m) => `\u2068${m}\u2069`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Keep words together when animating per letter: groups `splitArabic()` tokens into words and wraps each
|
|
202
|
+
* word in a nowrap inline-block box, so lines break only between words, never mid-word. Your render
|
|
203
|
+
* callback is called unchanged with the original token and its global index (stagger timing is kept);
|
|
204
|
+
* space tokens are rendered between the word boxes.
|
|
205
|
+
* {mapArabicWords(splitArabic(title), (ch, i) => <span key={i} style={…}>{ch}</span>)}
|
|
206
|
+
* أبقِ حروف الكلمة معاً: يلتف السطر بين الكلمات فقط، مع الحفاظ على ترتيب الحروف وتوقيتها.
|
|
207
|
+
*/
|
|
208
|
+
export function mapArabicWords(tokens: readonly string[], render: (token: string, index: number) => React.ReactNode, wordStyle?: React.CSSProperties): React.ReactNode[] {
|
|
209
|
+
const out: React.ReactNode[] = [];
|
|
210
|
+
let word: React.ReactNode[] = [];
|
|
211
|
+
let start = 0;
|
|
212
|
+
const flush = () => {
|
|
213
|
+
if (!word.length) return;
|
|
214
|
+
out.push(<span key={`harakah-w${start}`} style={{ display: "inline-block", whiteSpace: "nowrap", ...wordStyle }}>{word}</span>);
|
|
215
|
+
word = [];
|
|
216
|
+
};
|
|
217
|
+
tokens.forEach((t, i) => {
|
|
218
|
+
if (/^[\s\u00A0]+$/.test(t)) { flush(); out.push(<React.Fragment key={`harakah-s${i}`}>{render(t, i)}</React.Fragment>); return; }
|
|
219
|
+
if (!word.length) start = i;
|
|
220
|
+
word.push(<React.Fragment key={`harakah-t${i}`}>{render(t, i)}</React.Fragment>);
|
|
221
|
+
});
|
|
222
|
+
flush();
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export type Preset = "rise" | "fade" | "blur" | "scale";
|
|
227
|
+
export interface ArabicTextProps {
|
|
228
|
+
/** The text. النص. */
|
|
229
|
+
text: string;
|
|
230
|
+
/** Animate per letter (default) or per word. حرفاً حرفاً أو كلمة كلمة. */
|
|
231
|
+
by?: SplitBy;
|
|
232
|
+
/** Frames before the first token starts. التأخير قبل أول عنصر (بالإطارات). */
|
|
233
|
+
delay?: number;
|
|
234
|
+
/** Frames between tokens. الفاصل بين العناصر (بالإطارات). */
|
|
235
|
+
stagger?: number;
|
|
236
|
+
/** Frames each token takes to arrive. مدة دخول كل عنصر. */
|
|
237
|
+
duration?: number;
|
|
238
|
+
/** Built-in motion preset. نمط الحركة الجاهز. */
|
|
239
|
+
preset?: Preset;
|
|
240
|
+
/** Custom per-token style from progress 0→1. نمط مخصص حسب التقدم من ٠ إلى ١. */
|
|
241
|
+
animate?: (progress: number, index: number, token: string) => React.CSSProperties;
|
|
242
|
+
/** Use a spring instead of an eased curve. استخدام spring بدلاً من منحنى. */
|
|
243
|
+
springy?: boolean;
|
|
244
|
+
/** Digit policy applied to the text (default eastern). نظام الأرقام (افتراضياً مشرقية). */
|
|
245
|
+
digits?: DigitStyle;
|
|
246
|
+
style?: React.CSSProperties;
|
|
247
|
+
tokenStyle?: React.CSSProperties;
|
|
248
|
+
className?: string;
|
|
249
|
+
as?: "div" | "h1" | "h2" | "h3" | "p" | "span";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const PRESETS: Record<Preset, (p: number) => React.CSSProperties> = {
|
|
253
|
+
rise: (p) => ({ opacity: p, transform: `translateY(${((1 - p) * 0.45).toFixed(4)}em)` }),
|
|
254
|
+
fade: (p) => ({ opacity: p }),
|
|
255
|
+
blur: (p) => ({ opacity: p, filter: `blur(${((1 - p) * 10).toFixed(2)}px)` }),
|
|
256
|
+
scale: (p) => ({ opacity: p, transform: `scale(${(0.85 + 0.15 * p).toFixed(4)})` }),
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Drop-in animated Arabic text: RTL, lang="ar", joined letters, staggered motion.
|
|
261
|
+
* مكوّن نص عربي متحرك: اتجاه من اليمين، lang="ar"، حروف متصلة، وحركة متتابعة.
|
|
262
|
+
*
|
|
263
|
+
* @example <ArabicText text="صناعة المحتوى العربي" preset="rise" stagger={2} />
|
|
264
|
+
*/
|
|
265
|
+
export const ArabicText: React.FC<ArabicTextProps> = ({
|
|
266
|
+
text, by = "letter", delay = 0, stagger, duration = 18, preset = "rise", animate, springy = false,
|
|
267
|
+
digits = "eastern", style, tokenStyle, className, as = "div",
|
|
268
|
+
}) => {
|
|
269
|
+
const frame = useCurrentFrame();
|
|
270
|
+
const { fps } = useVideoConfig();
|
|
271
|
+
const tokens = splitArabic(digits === "keep" ? text : formatDigits(text, digits), by);
|
|
272
|
+
const gap = stagger ?? (by === "letter" ? 2 : 5);
|
|
273
|
+
const Tag = as as React.ElementType;
|
|
274
|
+
// Group letter tokens into words: each word is a nowrap box, so lines only break at spaces
|
|
275
|
+
// (per-letter inline-blocks would otherwise allow a break between any two letters).
|
|
276
|
+
const words: { tokens: string[]; start: number }[] = [];
|
|
277
|
+
const parts: (string | number)[] = []; // " " for spaces, index into words otherwise
|
|
278
|
+
tokens.forEach((t, i) => {
|
|
279
|
+
if (/^[\s\u00A0]+$/.test(t)) { parts.push(" "); return; }
|
|
280
|
+
const last = parts[parts.length - 1];
|
|
281
|
+
if (typeof last === "number") words[last].tokens.push(t);
|
|
282
|
+
else { words.push({ tokens: [t], start: i }); parts.push(words.length - 1); }
|
|
283
|
+
});
|
|
284
|
+
let n = 0;
|
|
285
|
+
const single = tokens.length === 1;
|
|
286
|
+
return (
|
|
287
|
+
<Tag dir="rtl" lang="ar" className={className} style={{ direction: "rtl", ...style }}>
|
|
288
|
+
{parts.map((part, pi) => {
|
|
289
|
+
if (part === " ") return <React.Fragment key={`s${pi}`}>{" "}</React.Fragment>;
|
|
290
|
+
const w = words[part as number];
|
|
291
|
+
const inner = w.tokens.map((t, k) => {
|
|
292
|
+
const local = frame - delay - n++ * gap;
|
|
293
|
+
const p = springy
|
|
294
|
+
? spring({ frame: local, fps, durationInFrames: duration, config: { damping: 200 } })
|
|
295
|
+
: interpolate(local, [0, duration], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.bezier(0.2, 0.8, 0.2, 1) });
|
|
296
|
+
return (
|
|
297
|
+
<span key={k} style={{ display: "inline-block", whiteSpace: single ? "normal" : "pre", ...tokenStyle, ...(animate ? animate(p, w.start + k, t) : PRESETS[preset](p)) }}>
|
|
298
|
+
{t}
|
|
299
|
+
</span>
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
return single || w.tokens.length === 1 ? <React.Fragment key={`w${pi}`}>{inner}</React.Fragment> : <span key={`w${pi}`} style={{ display: "inline-block", whiteSpace: "nowrap" }}>{inner}</span>;
|
|
303
|
+
})}
|
|
304
|
+
</Tag>
|
|
305
|
+
);
|
|
306
|
+
};
|