@harakah/remotion-arabic 0.0.0-stage → 0.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 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,23 @@
1
- # Temporary Holding Version
1
+ # @harakah/remotion-arabic
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ - Safari / WebKit (the Player on iPhone, iPad, Mac Safari): with a font that has no U+200D (Alexandria, IBM Plex Sans Arabic, Cairo, Tajawal…), WebKit draws a letter + ZWJ in a fallback font. After mount there, `<ArabicText>` switches each Arabic word to the clip method: every letter box shows the whole word, aria-hidden and clipped to that letter's slice, plus one visually hidden copy for screen readers. It re-measures when fonts load and on resize. Renders (Chromium) and server output keep the ZWJ boxes, byte-for-byte unchanged. `joinMode="zwj" | "clip"` forces one method.
10
+ - في Safari ومع خط لا يحتوي U+200D، يرسم المكوّن الكلمة كاملة في كل صندوق ويقصّها على حدود الحرف، فتبقى متصلة.
11
+ - `formatDigits(n | string, "eastern" | "western" | "keep")` gives `١٬٢٥٠٬٠٠٠`, and leaves Latin runs such as `iPhone 17` untouched.
12
+ - Eastern output always uses ٬ (U+066C) and ٫ (U+066B), whatever the renderer's ICU data.
13
+ - Eastern digits typed with Latin separators (`١,٢٥٠`) are corrected too.
14
+ - `withArabicSeparators(loadFont(...))` fixes fonts like Alexandria, which draw ٬ and ٫ with their Latin comma glyph.
15
+ - The returned `fontFamily` puts a unicode-range face first. It covers U+066A–U+066C only and comes from IBM Plex Sans Arabic (OFL).
16
+ - Every other glyph still comes from your font, and font load order doesn't matter.
17
+ - يرسم خط Alexandria الفاصلة ٬ بشكل الفاصلة اللاتينية، وهذه الدالة تصلح ذلك.
18
+ - `isolateLatin(text)` adds FSI/PDI around Latin runs.
19
+
20
+ `splitArabic()` returns strings, so it cannot do the clip method: in Safari, its ZWJ tokens join only with fonts that map U+200D (Noto Naskh Arabic does; `harakah-fix doctor` warns about local fonts that don't). For the Player in Safari, prefer `<ArabicText>`, or animate per word there.
21
+
22
+ 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`.
23
+ Set `REMOTION_HARAKAH_REFERENCE=1` (render-time env) to make `splitArabic` return running text. `harakah-fix compare` uses this for its join verdict.
@@ -0,0 +1,117 @@
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
+ * Safari / WebKit (the Remotion Player on iPhone, iPad and Mac Safari) draws a letter + ZWJ in a fallback font when
14
+ * the font has no cmap entry for U+200D (Alexandria, IBM Plex Sans Arabic, Cairo, Tajawal…). There, after mount,
15
+ * <ArabicText> switches each Arabic word to the clip method: every letter box shows the whole word, clipped to that
16
+ * letter's slice, so the word is shaped once by the font. Renders (Chromium) keep the ZWJ boxes, unchanged.
17
+ *
18
+ * Self-contained (no dependency on harakah-fix) so it can be vendored as a single file.
19
+ */
20
+ import React from "react";
21
+ export type SplitBy = "letter" | "word";
22
+ export interface SplitOptions {
23
+ /** "letter" (default): one token per grapheme, ZWJ-padded. "word": words and space tokens. */
24
+ by?: SplitBy;
25
+ }
26
+ /**
27
+ * Split text for per-letter / per-word animation without breaking Arabic joins.
28
+ * Drop-in replacement for `text.split('')`, `Array.from(text)`, `[...text]`: spaces stay as " " tokens,
29
+ * diacritics stay attached, لا stays one token, Latin text is returned unchanged (grapheme by grapheme).
30
+ *
31
+ * تقسيم النص للتحريك دون تفكيك الحروف العربية. بديل مباشر لـ split(''): المسافات تبقى عناصر مستقلة،
32
+ * والحركات (التشكيل) ملتصقة بحروفها، و«لا» عنصر واحد، والنص اللاتيني لا يتغير.
33
+ */
34
+ export declare function splitArabic(text: string, options?: SplitOptions | SplitBy): string[];
35
+ export type DigitStyle = "eastern" | "western" | "keep";
36
+ /**
37
+ * Format a number or normalize digits in a string. eastern → ١٢٣ (with ٫ ٬ ٪), western → 123.
38
+ * Latin runs such as "iPhone 17" are left untouched.
39
+ *
40
+ * تنسيق الأرقام: مشرقية ١٢٣ (افتراضياً) أو غربية 123، مع ترك الكلمات اللاتينية مثل iPhone 17 كما هي.
41
+ */
42
+ export declare function formatDigits(value: number | string, digits?: DigitStyle, options?: Intl.NumberFormatOptions): string;
43
+ /** Font family that only covers ٪ ٫ ٬ (U+066A–U+066C). اسم الخط الذي يغطي ٪ ٫ ٬ فقط. */
44
+ export declare const ARABIC_SEPARATORS_FAMILY = "Harakah Arabic Separators";
45
+ /**
46
+ * Some display faces (notably Alexandria) draw the Arabic separators ٬ ٫ with the Latin comma glyph,
47
+ * so "١٬٢٥٠٬٠٠٠" looks like "١,٢٥٠,٠٠٠". Wrap the loader: the returned fontFamily puts a tiny
48
+ * unicode-range face (IBM Plex Sans Arabic, OFL) first, so only U+066A–U+066C change; every other
49
+ * glyph still comes from the original font. Works regardless of font load order.
50
+ *
51
+ * بعض خطوط العناوين (مثل Alexandria) ترسم الفواصل العربية ٬ ٫ بشكل الفاصلة اللاتينية. غلّف
52
+ * loadFont() بهذه الدالة فتُرسم هذه الرموز فقط من IBM Plex Sans Arabic ويبقى باقي النص بالخط الأصلي.
53
+ *
54
+ * @example const { fontFamily } = withArabicSeparators(loadFont("normal", { weights: ["800"], subsets: ["arabic"] }));
55
+ */
56
+ export declare function withArabicSeparators<T extends {
57
+ fontFamily: string;
58
+ }>(loaded: T, opts?: {
59
+ src?: (weight: 400 | 600 | 700) => string;
60
+ }): T;
61
+ /** Register the separator-only face (idempotent). تسجيل خط الفواصل مرة واحدة. */
62
+ export declare function registerArabicSeparators(src?: (weight: 400 | 600 | 700) => string): void;
63
+ /**
64
+ * Wrap Latin/number runs inside Arabic text in Unicode isolates (FSI … PDI) so punctuation stays put.
65
+ * عزل الكلمات اللاتينية داخل النص العربي حتى لا تنتقل علامات الترقيم إلى الجهة الخاطئة.
66
+ */
67
+ export declare function isolateLatin(text: string): string;
68
+ /**
69
+ * Keep words together when animating per letter: groups `splitArabic()` tokens into words and wraps each
70
+ * word in a nowrap inline-block box, so lines break only between words, never mid-word. Your render
71
+ * callback is called unchanged with the original token and its global index (stagger timing is kept);
72
+ * space tokens are rendered between the word boxes.
73
+ * {mapArabicWords(splitArabic(title), (ch, i) => <span key={i} style={…}>{ch}</span>)}
74
+ * أبقِ حروف الكلمة معاً: يلتف السطر بين الكلمات فقط، مع الحفاظ على ترتيب الحروف وتوقيتها.
75
+ */
76
+ export declare function mapArabicWords(tokens: readonly string[], render: (token: string, index: number) => React.ReactNode, wordStyle?: React.CSSProperties): React.ReactNode[];
77
+ export type Preset = "rise" | "fade" | "blur" | "scale";
78
+ export interface ArabicTextProps {
79
+ /** The text. النص. */
80
+ text: string;
81
+ /** Animate per letter (default) or per word. حرفاً حرفاً أو كلمة كلمة. */
82
+ by?: SplitBy;
83
+ /** Frames before the first token starts. التأخير قبل أول عنصر (بالإطارات). */
84
+ delay?: number;
85
+ /** Frames between tokens. الفاصل بين العناصر (بالإطارات). */
86
+ stagger?: number;
87
+ /** Frames each token takes to arrive. مدة دخول كل عنصر. */
88
+ duration?: number;
89
+ /** Built-in motion preset. نمط الحركة الجاهز. */
90
+ preset?: Preset;
91
+ /** Custom per-token style from progress 0→1. نمط مخصص حسب التقدم من ٠ إلى ١. */
92
+ animate?: (progress: number, index: number, token: string) => React.CSSProperties;
93
+ /** Use a spring instead of an eased curve. استخدام spring بدلاً من منحنى. */
94
+ springy?: boolean;
95
+ /** Digit policy applied to the text (default eastern). نظام الأرقام (افتراضياً مشرقية). */
96
+ digits?: DigitStyle;
97
+ style?: React.CSSProperties;
98
+ tokenStyle?: React.CSSProperties;
99
+ className?: string;
100
+ as?: "div" | "h1" | "h2" | "h3" | "p" | "span";
101
+ /**
102
+ * How letter boxes keep their joins. "auto" (default): ZWJ padding, and in Safari/WebKit outside a render the
103
+ * clip method (the whole word in every box, clipped to the letter's slice), which works with fonts that have no
104
+ * U+200D. "zwj" / "clip" force one. طريقة وصل الحروف: تلقائي (الافتراضي)، أو zwj، أو clip.
105
+ */
106
+ joinMode?: JoinMode;
107
+ }
108
+ export type JoinMode = "auto" | "zwj" | "clip";
109
+ /** Safari / WebKit, where a letter + ZWJ is drawn in a fallback font if the font has no U+200D. */
110
+ export declare function needsClipJoin(ua?: string): boolean;
111
+ /**
112
+ * Drop-in animated Arabic text: RTL, lang="ar", joined letters, staggered motion.
113
+ * مكوّن نص عربي متحرك: اتجاه من اليمين، lang="ar"، حروف متصلة، وحركة متتابعة.
114
+ *
115
+ * @example <ArabicText text="صناعة المحتوى العربي" preset="rise" stagger={2} />
116
+ */
117
+ export declare const ArabicText: React.FC<ArabicTextProps>;
package/dist/index.js ADDED
@@ -0,0 +1,387 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } 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
+ * Safari / WebKit (the Remotion Player on iPhone, iPad and Mac Safari) draws a letter + ZWJ in a fallback font when
15
+ * the font has no cmap entry for U+200D (Alexandria, IBM Plex Sans Arabic, Cairo, Tajawal…). There, after mount,
16
+ * <ArabicText> switches each Arabic word to the clip method: every letter box shows the whole word, clipped to that
17
+ * letter's slice, so the word is shaped once by the font. Renders (Chromium) keep the ZWJ boxes, unchanged.
18
+ *
19
+ * Self-contained (no dependency on harakah-fix) so it can be vendored as a single file.
20
+ */
21
+ import React from "react";
22
+ import { continueRender, delayRender, Easing, getRemotionEnvironment, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
23
+ const ZWJ = "\u200D";
24
+ const TRANSPARENT = /[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED]/;
25
+ const ARABIC_LETTER = /[\u0620-\u064A\u066E-\u06D3\u06FA-\u06FF\u0750-\u077F\u08A0-\u08C9]/;
26
+ const RIGHT_JOINING = new Set([
27
+ 0x0622, 0x0623, 0x0624, 0x0625, 0x0627, 0x0629, 0x062f, 0x0630, 0x0631, 0x0632, 0x0648, 0x0671, 0x0672, 0x0673,
28
+ 0x0675, 0x0676, 0x0677, 0x0688, 0x0689, 0x068a, 0x068b, 0x068c, 0x068d, 0x068e, 0x068f, 0x0690, 0x0691, 0x0692,
29
+ 0x0693, 0x0694, 0x0695, 0x0696, 0x0697, 0x0698, 0x0699, 0x06c0, 0x06c3, 0x06c4, 0x06c5, 0x06c6, 0x06c7, 0x06c8,
30
+ 0x06c9, 0x06ca, 0x06cb, 0x06cd, 0x06cf, 0x06d2, 0x06d3, 0x06ee, 0x06ef,
31
+ ]);
32
+ const NON_JOINING = new Set([0x0621, 0x0674, 0x06d5]);
33
+ const ALEFS = new Set(["\u0622", "\u0623", "\u0625", "\u0627", "\u0671"]);
34
+ function joinType(ch) {
35
+ const cp = ch.codePointAt(0) ?? 0;
36
+ if (cp === 0x200d || cp === 0x0640)
37
+ return "C";
38
+ if (TRANSPARENT.test(ch))
39
+ return "T";
40
+ if (RIGHT_JOINING.has(cp))
41
+ return "R";
42
+ if (NON_JOINING.has(cp))
43
+ return "U";
44
+ return ARABIC_LETTER.test(ch) ? "D" : "U";
45
+ }
46
+ function edge(s, last) {
47
+ const a = Array.from(s.replace(/\u200D/g, ""));
48
+ if (last)
49
+ a.reverse();
50
+ for (const c of a) {
51
+ const t = joinType(c);
52
+ if (t !== "T")
53
+ return t;
54
+ }
55
+ return "U";
56
+ }
57
+ const joinsNext = (t) => t === "D" || t === "C";
58
+ const joinsPrev = (t) => t === "D" || t === "R" || t === "C";
59
+ const isSpace = (s) => /^[\s\u00A0]+$/.test(s);
60
+ const bases = (s) => Array.from(s.replace(/\u200D/g, "")).filter((c) => joinType(c) !== "T");
61
+ let REFERENCE = false;
62
+ try {
63
+ // Set REMOTION_HARAKAH_REFERENCE=1 to render running text instead of boxes (used by harakah-fix compare).
64
+ REFERENCE = process.env.REMOTION_HARAKAH_REFERENCE === "1";
65
+ }
66
+ catch {
67
+ REFERENCE = false;
68
+ }
69
+ function graphemes(text) {
70
+ const Seg = Intl.Segmenter;
71
+ if (Seg)
72
+ return Array.from(new Seg("ar", { granularity: "grapheme" }).segment(text), (s) => s.segment);
73
+ // Fallback: attach combining marks to the previous character.
74
+ const out = [];
75
+ for (const ch of Array.from(text)) {
76
+ if (out.length && (TRANSPARENT.test(ch) || ch === ZWJ))
77
+ out[out.length - 1] += ch;
78
+ else
79
+ out.push(ch);
80
+ }
81
+ return out;
82
+ }
83
+ /**
84
+ * Split text for per-letter / per-word animation without breaking Arabic joins.
85
+ * Drop-in replacement for `text.split('')`, `Array.from(text)`, `[...text]`: spaces stay as " " tokens,
86
+ * diacritics stay attached, لا stays one token, Latin text is returned unchanged (grapheme by grapheme).
87
+ *
88
+ * تقسيم النص للتحريك دون تفكيك الحروف العربية. بديل مباشر لـ split(''): المسافات تبقى عناصر مستقلة،
89
+ * والحركات (التشكيل) ملتصقة بحروفها، و«لا» عنصر واحد، والنص اللاتيني لا يتغير.
90
+ */
91
+ export function splitArabic(text, options = {}) {
92
+ const by = typeof options === "string" ? options : options.by ?? "letter";
93
+ const src = String(text ?? "").replace(/\u200D/g, "");
94
+ if (REFERENCE)
95
+ return [src];
96
+ if (by === "word")
97
+ return src.split(/([\s\u00A0]+)/).filter((t) => t.length > 0);
98
+ const g = graphemes(src);
99
+ // keep lam + alef together (mandatory ligature)
100
+ const tokens = [];
101
+ for (const t of g) {
102
+ const prev = tokens[tokens.length - 1];
103
+ if (prev && !isSpace(prev) && !isSpace(t)) {
104
+ const pb = bases(prev), tb = bases(t);
105
+ if (pb.length === 1 && pb[0] === "\u0644" && tb.length === 1 && ALEFS.has(tb[0])) {
106
+ tokens[tokens.length - 1] = prev + t;
107
+ continue;
108
+ }
109
+ }
110
+ tokens.push(t);
111
+ }
112
+ return tokens.map((t, i) => {
113
+ if (isSpace(t) || !(ARABIC_LETTER.test(t) || t.includes("\u0640")))
114
+ return t;
115
+ const prev = i > 0 ? tokens[i - 1] : "";
116
+ const next = i < tokens.length - 1 ? tokens[i + 1] : "";
117
+ let out = t;
118
+ if (prev && !isSpace(prev) && joinsNext(edge(prev, true)) && joinsPrev(edge(t, false)))
119
+ out = ZWJ + out;
120
+ if (next && !isSpace(next) && joinsNext(edge(t, true)) && joinsPrev(edge(next, false)))
121
+ out = out + ZWJ;
122
+ return out;
123
+ });
124
+ }
125
+ const LATIN_RUN = /[A-Za-z][A-Za-z0-9]*(?:[ .\-_'’+&#@/:]+[A-Za-z0-9]+)*/g;
126
+ /**
127
+ * Format a number or normalize digits in a string. eastern → ١٢٣ (with ٫ ٬ ٪), western → 123.
128
+ * Latin runs such as "iPhone 17" are left untouched.
129
+ *
130
+ * تنسيق الأرقام: مشرقية ١٢٣ (افتراضياً) أو غربية 123، مع ترك الكلمات اللاتينية مثل iPhone 17 كما هي.
131
+ */
132
+ export function formatDigits(value, digits = "eastern", options = {}) {
133
+ // Numbers are formatted with Latin digits first and then mapped, so the result never depends on the
134
+ // renderer's ICU/CLDR data: eastern always uses ٬ (U+066C) for grouping and ٫ (U+066B) for decimals.
135
+ // نُنسّق بالأرقام اللاتينية ثم نحوّلها، فتكون الفاصلة دائماً ٬ والفاصلة العشرية ٫ مهما كانت بيانات المتصفح.
136
+ if (typeof value === "number") {
137
+ const latin = new Intl.NumberFormat("en-US", options).format(value);
138
+ if (digits === "keep" || digits === "western")
139
+ return latin;
140
+ return toEastern(latin);
141
+ }
142
+ if (digits === "keep")
143
+ return value;
144
+ const conv = (s) => (digits === "eastern" ? toEastern(s, true) : toWestern(s));
145
+ let out = "", last = 0;
146
+ for (const m of value.matchAll(LATIN_RUN)) {
147
+ out += conv(value.slice(last, m.index)) + m[0];
148
+ last = (m.index ?? 0) + m[0].length;
149
+ }
150
+ return out + conv(value.slice(last));
151
+ }
152
+ function toEastern(s, loose = false) {
153
+ // In free text only "1,234" style groups count as thousands; in formatted numbers every comma does.
154
+ const group = loose ? /(\d),(?=\d{3}(?!\d))/g : /(\d),(?=\d)/g;
155
+ return s
156
+ .replace(/(\d)\.(?=\d)/g, "$1\u066B")
157
+ .replace(group, "$1\u066C")
158
+ .replace(/(\d)\s?%/g, "$1\u066A")
159
+ .replace(/[0-9]/g, (d) => String.fromCharCode(0x0660 + Number(d)))
160
+ // Eastern digits typed with Latin separators ("١,٢٥٠") get the Arabic ones too.
161
+ .replace(/([\u0660-\u0669]),(?=[\u0660-\u0669]{3}(?![\u0660-\u0669]))/g, "$1\u066C").replace(/([\u0660-\u0669])\.(?=[\u0660-\u0669])/g, "$1\u066B");
162
+ }
163
+ function toWestern(s) {
164
+ return s
165
+ .replace(/[\u0660-\u0669]/g, (d) => String(d.charCodeAt(0) - 0x0660))
166
+ .replace(/[\u06F0-\u06F9]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
167
+ .replace(/\u066B/g, ".").replace(/\u066C/g, ",").replace(/\u066A/g, "%");
168
+ }
169
+ const PLEX_ARABIC = {
170
+ 400: "https://fonts.gstatic.com/s/ibmplexsansarabic/v15/Qw3CZRtWPQCuHme67tEYUIx3Kh0PHR9N6Ys43PWrfQ.woff2",
171
+ 600: "https://fonts.gstatic.com/s/ibmplexsansarabic/v15/Qw3NZRtWPQCuHme67tEYUIx3Kh0PHR9N6YPi-OCRXMR5Kw.woff2",
172
+ 700: "https://fonts.gstatic.com/s/ibmplexsansarabic/v15/Qw3NZRtWPQCuHme67tEYUIx3Kh0PHR9N6YOG-eCRXMR5Kw.woff2",
173
+ };
174
+ /** Font family that only covers ٪ ٫ ٬ (U+066A–U+066C). اسم الخط الذي يغطي ٪ ٫ ٬ فقط. */
175
+ export const ARABIC_SEPARATORS_FAMILY = "Harakah Arabic Separators";
176
+ let separatorsRegistered = false;
177
+ /**
178
+ * Some display faces (notably Alexandria) draw the Arabic separators ٬ ٫ with the Latin comma glyph,
179
+ * so "١٬٢٥٠٬٠٠٠" looks like "١,٢٥٠,٠٠٠". Wrap the loader: the returned fontFamily puts a tiny
180
+ * unicode-range face (IBM Plex Sans Arabic, OFL) first, so only U+066A–U+066C change; every other
181
+ * glyph still comes from the original font. Works regardless of font load order.
182
+ *
183
+ * بعض خطوط العناوين (مثل Alexandria) ترسم الفواصل العربية ٬ ٫ بشكل الفاصلة اللاتينية. غلّف
184
+ * loadFont() بهذه الدالة فتُرسم هذه الرموز فقط من IBM Plex Sans Arabic ويبقى باقي النص بالخط الأصلي.
185
+ *
186
+ * @example const { fontFamily } = withArabicSeparators(loadFont("normal", { weights: ["800"], subsets: ["arabic"] }));
187
+ */
188
+ export function withArabicSeparators(loaded, opts = {}) {
189
+ registerArabicSeparators(opts.src);
190
+ return { ...loaded, fontFamily: `"${ARABIC_SEPARATORS_FAMILY}", ${loaded.fontFamily}` };
191
+ }
192
+ /** Register the separator-only face (idempotent). تسجيل خط الفواصل مرة واحدة. */
193
+ export function registerArabicSeparators(src = (w) => PLEX_ARABIC[w]) {
194
+ if (separatorsRegistered || typeof document === "undefined" || typeof FontFace === "undefined")
195
+ return;
196
+ separatorsRegistered = true;
197
+ const handle = delayRender("harakah: Arabic separators (IBM Plex Sans Arabic)");
198
+ const ranges = [[400, "1 450"], [600, "451 650"], [700, "651 1000"]];
199
+ const faces = ranges.map(([w, weight]) => new FontFace(ARABIC_SEPARATORS_FAMILY, `url(${src(w)}) format("woff2")`, { weight, unicodeRange: "U+066A-066C" }));
200
+ faces.forEach((f) => document.fonts.add(f));
201
+ Promise.all(faces.map((f) => f.load())).then(() => continueRender(handle), () => continueRender(handle));
202
+ }
203
+ /**
204
+ * Wrap Latin/number runs inside Arabic text in Unicode isolates (FSI … PDI) so punctuation stays put.
205
+ * عزل الكلمات اللاتينية داخل النص العربي حتى لا تنتقل علامات الترقيم إلى الجهة الخاطئة.
206
+ */
207
+ export function isolateLatin(text) {
208
+ return text.replace(/\u2068|\u2069/g, "").replace(LATIN_RUN, (m) => `\u2068${m}\u2069`);
209
+ }
210
+ /**
211
+ * Keep words together when animating per letter: groups `splitArabic()` tokens into words and wraps each
212
+ * word in a nowrap inline-block box, so lines break only between words, never mid-word. Your render
213
+ * callback is called unchanged with the original token and its global index (stagger timing is kept);
214
+ * space tokens are rendered between the word boxes.
215
+ * {mapArabicWords(splitArabic(title), (ch, i) => <span key={i} style={…}>{ch}</span>)}
216
+ * أبقِ حروف الكلمة معاً: يلتف السطر بين الكلمات فقط، مع الحفاظ على ترتيب الحروف وتوقيتها.
217
+ */
218
+ export function mapArabicWords(tokens, render, wordStyle) {
219
+ const out = [];
220
+ let word = [];
221
+ let start = 0;
222
+ const flush = () => {
223
+ if (!word.length)
224
+ return;
225
+ out.push(_jsx("span", { style: { display: "inline-block", whiteSpace: "nowrap", ...wordStyle }, children: word }, `harakah-w${start}`));
226
+ word = [];
227
+ };
228
+ tokens.forEach((t, i) => {
229
+ if (/^[\s\u00A0]+$/.test(t)) {
230
+ flush();
231
+ out.push(_jsx(React.Fragment, { children: render(t, i) }, `harakah-s${i}`));
232
+ return;
233
+ }
234
+ if (!word.length)
235
+ start = i;
236
+ word.push(_jsx(React.Fragment, { children: render(t, i) }, `harakah-t${i}`));
237
+ });
238
+ flush();
239
+ return out;
240
+ }
241
+ /** Safari / WebKit, where a letter + ZWJ is drawn in a fallback font if the font has no U+200D. */
242
+ export function needsClipJoin(ua = typeof navigator === "undefined" ? "" : navigator.userAgent || "") {
243
+ return /AppleWebKit\//.test(ua) && !/(Chrome|Chromium|Edg|OPR|Android)\//.test(ua);
244
+ }
245
+ const PURE_ARABIC = /^[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0620-\u064A\u066E-\u06D3\u06FA-\u06FF\u0750-\u077F\u08A0-\u08C9\u0640]+$/;
246
+ const FONT_PROPS = ["fontFamily", "fontSize", "fontWeight", "fontStyle", "fontStretch", "fontVariationSettings", "fontFeatureSettings", "fontKerning", "fontVariantLigatures", "fontVariantNumeric", "fontSynthesis", "fontOpticalSizing", "letterSpacing", "wordSpacing", "textRendering"];
247
+ const SR_ONLY = { position: "absolute", width: 1, height: 1, margin: -1, padding: 0, overflow: "hidden", clip: "rect(0 0 0 0)", clipPath: "inset(50%)", whiteSpace: "nowrap", border: 0 };
248
+ // Measure each token's slice of the plain word (Range on a hidden copy with the token box's computed font), tiled at
249
+ // the midpoints between neighbouring glyph runs so the slices recompose the word exactly.
250
+ function measureWord(first, toks) {
251
+ const word = toks.join("");
252
+ const m = document.createElement("span");
253
+ m.setAttribute("aria-hidden", "true");
254
+ m.style.cssText = "position:fixed;left:0;top:0;visibility:hidden;z-index:-1;white-space:pre;direction:rtl;unicode-bidi:isolate;pointer-events:none;padding:0;border:0;margin:0;transform:none";
255
+ const cs = getComputedStyle(first);
256
+ for (const k of FONT_PROPS)
257
+ try {
258
+ m.style[k] = cs[k];
259
+ }
260
+ catch { /* unsupported */ }
261
+ m.lang = "ar";
262
+ m.textContent = word;
263
+ document.body.appendChild(m);
264
+ try {
265
+ const t = m.firstChild, mr = m.getBoundingClientRect(), W = mr.width, r = document.createRange();
266
+ if (!(W > 0))
267
+ return null;
268
+ let o = 0;
269
+ const sl = [];
270
+ for (let i = 0; i < toks.length; i++) {
271
+ r.setStart(t, o);
272
+ r.setEnd(t, o + toks[i].length);
273
+ o += toks[i].length;
274
+ const b = r.getBoundingClientRect();
275
+ if (!(b.width > 0))
276
+ return null;
277
+ sl.push({ i, l: b.left - mr.left, r: b.right - mr.left });
278
+ }
279
+ const byX = [...sl].sort((a, b) => a.l - b.l), out = new Array(toks.length);
280
+ const pl = parseFloat(cs.paddingLeft) || 0, pt = parseFloat(cs.paddingTop) || 0;
281
+ byX.forEach((q, n) => {
282
+ const s = n ? (byX[n - 1].r + q.l) / 2 : 0, e = n < byX.length - 1 ? (q.r + byX[n + 1].l) / 2 : W;
283
+ out[q.i] = { s, e, W, pl, pt };
284
+ });
285
+ return out;
286
+ }
287
+ finally {
288
+ m.remove();
289
+ }
290
+ }
291
+ const PRESETS = {
292
+ rise: (p) => ({ opacity: p, transform: `translateY(${((1 - p) * 0.45).toFixed(4)}em)` }),
293
+ fade: (p) => ({ opacity: p }),
294
+ blur: (p) => ({ opacity: p, filter: `blur(${((1 - p) * 10).toFixed(2)}px)` }),
295
+ scale: (p) => ({ opacity: p, transform: `scale(${(0.85 + 0.15 * p).toFixed(4)})` }),
296
+ };
297
+ /**
298
+ * Drop-in animated Arabic text: RTL, lang="ar", joined letters, staggered motion.
299
+ * مكوّن نص عربي متحرك: اتجاه من اليمين، lang="ar"، حروف متصلة، وحركة متتابعة.
300
+ *
301
+ * @example <ArabicText text="صناعة المحتوى العربي" preset="rise" stagger={2} />
302
+ */
303
+ export const ArabicText = ({ text, by = "letter", delay = 0, stagger, duration = 18, preset = "rise", animate, springy = false, digits = "eastern", style, tokenStyle, className, as = "div", joinMode = "auto", }) => {
304
+ const frame = useCurrentFrame();
305
+ const { fps } = useVideoConfig();
306
+ const tokens = splitArabic(digits === "keep" ? text : formatDigits(text, digits), by);
307
+ const gap = stagger ?? (by === "letter" ? 2 : 5);
308
+ const Tag = as;
309
+ // Group letter tokens into words: each word is a nowrap box, so lines only break at spaces
310
+ // (per-letter inline-blocks would otherwise allow a break between any two letters).
311
+ const words = [];
312
+ const parts = []; // " " for spaces, index into words otherwise
313
+ tokens.forEach((t, i) => {
314
+ if (/^[\s\u00A0]+$/.test(t)) {
315
+ parts.push(" ");
316
+ return;
317
+ }
318
+ const last = parts[parts.length - 1];
319
+ if (typeof last === "number")
320
+ words[last].tokens.push(t);
321
+ else {
322
+ words.push({ tokens: [t], start: i });
323
+ parts.push(words.length - 1);
324
+ }
325
+ });
326
+ let n = 0;
327
+ const single = tokens.length === 1;
328
+ // Clip method (Safari/WebKit): decided after mount, so server output, the first paint and renders stay ZWJ boxes.
329
+ const root = React.useRef(null);
330
+ const [clip, setClip] = React.useState(false);
331
+ const [slices, setSlices] = React.useState({});
332
+ const [epoch, setEpoch] = React.useState(0);
333
+ const clipWords = by === "letter" && !single ? words.map((w, wi) => (w.tokens.length >= 2 && PURE_ARABIC.test(w.tokens.join("").replace(/\u200D/g, "")) ? wi : -1)).filter((wi) => wi >= 0) : [];
334
+ const fontKey = [style, tokenStyle].map((x) => (x ? [x.fontFamily, x.fontSize, x.fontWeight, x.fontStyle, x.letterSpacing, x.fontVariationSettings, x.fontFeatureSettings].join("|") : "")).join("/");
335
+ const textKey = words.map((w) => w.tokens.join("")).join(" ");
336
+ React.useLayoutEffect(() => {
337
+ const on = joinMode === "clip" || (joinMode === "auto" && needsClipJoin() && !getRemotionEnvironment().isRendering);
338
+ setClip(on);
339
+ if (!on)
340
+ return;
341
+ const bump = () => setEpoch((e) => e + 1);
342
+ const fonts = typeof document !== "undefined" ? document.fonts : null;
343
+ fonts?.addEventListener?.("loadingdone", bump);
344
+ fonts?.ready?.then(bump, () => { });
345
+ window.addEventListener("resize", bump);
346
+ return () => { fonts?.removeEventListener?.("loadingdone", bump); window.removeEventListener("resize", bump); };
347
+ }, [joinMode]);
348
+ React.useLayoutEffect(() => {
349
+ if (!clip || !root.current || !clipWords.length) {
350
+ if (Object.keys(slices).length)
351
+ setSlices({});
352
+ return;
353
+ }
354
+ const next = {};
355
+ for (const wi of clipWords) {
356
+ const first = root.current.querySelector(`[data-hz-word="${wi}"] > [data-hz-tok]`);
357
+ const sl = first && measureWord(first, words[wi].tokens.map((t) => t.replace(/\u200D/g, "")));
358
+ if (sl)
359
+ next[wi] = sl;
360
+ }
361
+ if (JSON.stringify(next) !== JSON.stringify(slices))
362
+ setSlices(next);
363
+ // eslint-disable-next-line react-hooks/exhaustive-deps
364
+ }, [clip, textKey, fontKey, epoch]);
365
+ return (_jsx(Tag, { ref: root, dir: "rtl", lang: "ar", className: className, style: { direction: "rtl", ...style }, children: parts.map((part, pi) => {
366
+ if (part === " ")
367
+ return _jsx(React.Fragment, { children: " " }, `s${pi}`);
368
+ const wi = part, w = words[wi], sl = clip ? slices[wi] : undefined;
369
+ const word = sl ? w.tokens.join("").replace(/\u200D/g, "") : "";
370
+ const inner = w.tokens.map((t, k) => {
371
+ const local = frame - delay - n++ * gap;
372
+ const p = springy
373
+ ? spring({ frame: local, fps, durationInFrames: duration, config: { damping: 200 } })
374
+ : interpolate(local, [0, duration], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.bezier(0.2, 0.8, 0.2, 1) });
375
+ const motion = animate ? animate(p, w.start + k, t) : PRESETS[preset](p);
376
+ if (sl) {
377
+ // the box keeps the letter's slice width; a hidden strut holds the line height and baseline, and the
378
+ // whole word (aria-hidden) is offset and clipped to the slice; the first box carries the readable copy
379
+ const q = sl[k], plain = t.replace(/\u200D/g, ""), pad = "-0.8em", last = q.e >= q.W - 0.01, firstX = q.s <= 0.01;
380
+ const inset = `inset(${pad} ${last ? pad : `${q.W - q.e}px`} ${pad} ${firstX ? pad : `${q.s}px`})`;
381
+ return (_jsxs("span", { "data-hz-tok": "", style: { display: "inline-block", whiteSpace: "pre", ...tokenStyle, ...motion, position: "relative", boxSizing: "content-box", width: q.e - q.s }, children: [_jsx("span", { "aria-hidden": "true", style: { display: "inline", visibility: "hidden" }, children: plain }), _jsx("span", { "aria-hidden": "true", style: { display: "inline", position: "absolute", left: q.pl - q.s, top: q.pt, width: q.W, whiteSpace: "pre", direction: "rtl", unicodeBidi: "isolate", pointerEvents: "none", userSelect: "none", WebkitUserSelect: "none", margin: 0, padding: 0, border: 0, clipPath: inset, WebkitClipPath: inset }, children: word }), k === 0 ? _jsx("span", { style: SR_ONLY, children: word }) : null] }, k));
382
+ }
383
+ return (_jsx("span", { "data-hz-tok": "", style: { display: "inline-block", whiteSpace: single ? "normal" : "pre", ...tokenStyle, ...motion }, children: t }, k));
384
+ });
385
+ return single || w.tokens.length === 1 ? _jsx(React.Fragment, { children: inner }, `w${pi}`) : _jsx("span", { "data-hz-word": wi, style: { display: "inline-block", whiteSpace: "nowrap" }, children: inner }, `w${pi}`);
386
+ }) }));
387
+ };
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@harakah/remotion-arabic",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.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,406 @@
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
+ * Safari / WebKit (the Remotion Player on iPhone, iPad and Mac Safari) draws a letter + ZWJ in a fallback font when
14
+ * the font has no cmap entry for U+200D (Alexandria, IBM Plex Sans Arabic, Cairo, Tajawal…). There, after mount,
15
+ * <ArabicText> switches each Arabic word to the clip method: every letter box shows the whole word, clipped to that
16
+ * letter's slice, so the word is shaped once by the font. Renders (Chromium) keep the ZWJ boxes, unchanged.
17
+ *
18
+ * Self-contained (no dependency on harakah-fix) so it can be vendored as a single file.
19
+ */
20
+ import React from "react";
21
+ import { continueRender, delayRender, Easing, getRemotionEnvironment, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
22
+
23
+ const ZWJ = "\u200D";
24
+ const TRANSPARENT = /[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED]/;
25
+ const ARABIC_LETTER = /[\u0620-\u064A\u066E-\u06D3\u06FA-\u06FF\u0750-\u077F\u08A0-\u08C9]/;
26
+ const RIGHT_JOINING = new Set([
27
+ 0x0622, 0x0623, 0x0624, 0x0625, 0x0627, 0x0629, 0x062f, 0x0630, 0x0631, 0x0632, 0x0648, 0x0671, 0x0672, 0x0673,
28
+ 0x0675, 0x0676, 0x0677, 0x0688, 0x0689, 0x068a, 0x068b, 0x068c, 0x068d, 0x068e, 0x068f, 0x0690, 0x0691, 0x0692,
29
+ 0x0693, 0x0694, 0x0695, 0x0696, 0x0697, 0x0698, 0x0699, 0x06c0, 0x06c3, 0x06c4, 0x06c5, 0x06c6, 0x06c7, 0x06c8,
30
+ 0x06c9, 0x06ca, 0x06cb, 0x06cd, 0x06cf, 0x06d2, 0x06d3, 0x06ee, 0x06ef,
31
+ ]);
32
+ const NON_JOINING = new Set([0x0621, 0x0674, 0x06d5]);
33
+ const ALEFS = new Set(["\u0622", "\u0623", "\u0625", "\u0627", "\u0671"]);
34
+
35
+ type Join = "U" | "R" | "D" | "C" | "T";
36
+ function joinType(ch: string): Join {
37
+ const cp = ch.codePointAt(0) ?? 0;
38
+ if (cp === 0x200d || cp === 0x0640) return "C";
39
+ if (TRANSPARENT.test(ch)) return "T";
40
+ if (RIGHT_JOINING.has(cp)) return "R";
41
+ if (NON_JOINING.has(cp)) return "U";
42
+ return ARABIC_LETTER.test(ch) ? "D" : "U";
43
+ }
44
+ function edge(s: string, last: boolean): Join {
45
+ const a = Array.from(s.replace(/\u200D/g, ""));
46
+ if (last) a.reverse();
47
+ for (const c of a) { const t = joinType(c); if (t !== "T") return t; }
48
+ return "U";
49
+ }
50
+ const joinsNext = (t: Join) => t === "D" || t === "C";
51
+ const joinsPrev = (t: Join) => t === "D" || t === "R" || t === "C";
52
+ const isSpace = (s: string) => /^[\s\u00A0]+$/.test(s);
53
+ const bases = (s: string) => Array.from(s.replace(/\u200D/g, "")).filter((c) => joinType(c) !== "T");
54
+
55
+ declare const process: { env: Record<string, string | undefined> };
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
+ } catch {
61
+ REFERENCE = false;
62
+ }
63
+
64
+ function graphemes(text: string): string[] {
65
+ const Seg = (Intl as unknown as { Segmenter?: new (l: string, o: { granularity: string }) => { segment(s: string): Iterable<{ segment: string }> } }).Segmenter;
66
+ if (Seg) return Array.from(new Seg("ar", { granularity: "grapheme" }).segment(text), (s) => s.segment);
67
+ // Fallback: attach combining marks to the previous character.
68
+ const out: string[] = [];
69
+ for (const ch of Array.from(text)) {
70
+ if (out.length && (TRANSPARENT.test(ch) || ch === ZWJ)) out[out.length - 1] += ch;
71
+ else out.push(ch);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ export type SplitBy = "letter" | "word";
77
+ export interface SplitOptions {
78
+ /** "letter" (default): one token per grapheme, ZWJ-padded. "word": words and space tokens. */
79
+ by?: SplitBy;
80
+ }
81
+
82
+ /**
83
+ * Split text for per-letter / per-word animation without breaking Arabic joins.
84
+ * Drop-in replacement for `text.split('')`, `Array.from(text)`, `[...text]`: spaces stay as " " tokens,
85
+ * diacritics stay attached, لا stays one token, Latin text is returned unchanged (grapheme by grapheme).
86
+ *
87
+ * تقسيم النص للتحريك دون تفكيك الحروف العربية. بديل مباشر لـ split(''): المسافات تبقى عناصر مستقلة،
88
+ * والحركات (التشكيل) ملتصقة بحروفها، و«لا» عنصر واحد، والنص اللاتيني لا يتغير.
89
+ */
90
+ export function splitArabic(text: string, options: SplitOptions | SplitBy = {}): string[] {
91
+ const by = typeof options === "string" ? options : options.by ?? "letter";
92
+ const src = String(text ?? "").replace(/\u200D/g, "");
93
+ if (REFERENCE) return [src];
94
+ if (by === "word") return src.split(/([\s\u00A0]+)/).filter((t) => t.length > 0);
95
+ const g = graphemes(src);
96
+ // keep lam + alef together (mandatory ligature)
97
+ const tokens: string[] = [];
98
+ for (const t of g) {
99
+ const prev = tokens[tokens.length - 1];
100
+ if (prev && !isSpace(prev) && !isSpace(t)) {
101
+ const pb = bases(prev), tb = bases(t);
102
+ if (pb.length === 1 && pb[0] === "\u0644" && tb.length === 1 && ALEFS.has(tb[0])) { tokens[tokens.length - 1] = prev + t; continue; }
103
+ }
104
+ tokens.push(t);
105
+ }
106
+ return tokens.map((t, i) => {
107
+ if (isSpace(t) || !(ARABIC_LETTER.test(t) || t.includes("\u0640"))) return t;
108
+ const prev = i > 0 ? tokens[i - 1] : "";
109
+ const next = i < tokens.length - 1 ? tokens[i + 1] : "";
110
+ let out = t;
111
+ if (prev && !isSpace(prev) && joinsNext(edge(prev, true)) && joinsPrev(edge(t, false))) out = ZWJ + out;
112
+ if (next && !isSpace(next) && joinsNext(edge(t, true)) && joinsPrev(edge(next, false))) out = out + ZWJ;
113
+ return out;
114
+ });
115
+ }
116
+
117
+ export type DigitStyle = "eastern" | "western" | "keep";
118
+ const LATIN_RUN = /[A-Za-z][A-Za-z0-9]*(?:[ .\-_'’+&#@/:]+[A-Za-z0-9]+)*/g;
119
+
120
+ /**
121
+ * Format a number or normalize digits in a string. eastern → ١٢٣ (with ٫ ٬ ٪), western → 123.
122
+ * Latin runs such as "iPhone 17" are left untouched.
123
+ *
124
+ * تنسيق الأرقام: مشرقية ١٢٣ (افتراضياً) أو غربية 123، مع ترك الكلمات اللاتينية مثل iPhone 17 كما هي.
125
+ */
126
+ export function formatDigits(value: number | string, digits: DigitStyle = "eastern", options: Intl.NumberFormatOptions = {}): string {
127
+ // Numbers are formatted with Latin digits first and then mapped, so the result never depends on the
128
+ // renderer's ICU/CLDR data: eastern always uses ٬ (U+066C) for grouping and ٫ (U+066B) for decimals.
129
+ // نُنسّق بالأرقام اللاتينية ثم نحوّلها، فتكون الفاصلة دائماً ٬ والفاصلة العشرية ٫ مهما كانت بيانات المتصفح.
130
+ if (typeof value === "number") {
131
+ const latin = new Intl.NumberFormat("en-US", options).format(value);
132
+ if (digits === "keep" || digits === "western") return latin;
133
+ return toEastern(latin);
134
+ }
135
+ if (digits === "keep") return value;
136
+ const conv = (s: string) => (digits === "eastern" ? toEastern(s, true) : toWestern(s));
137
+ let out = "", last = 0;
138
+ for (const m of value.matchAll(LATIN_RUN)) { out += conv(value.slice(last, m.index)) + m[0]; last = (m.index ?? 0) + m[0].length; }
139
+ return out + conv(value.slice(last));
140
+ }
141
+
142
+ function toEastern(s: string, loose = false): string {
143
+ // In free text only "1,234" style groups count as thousands; in formatted numbers every comma does.
144
+ const group = loose ? /(\d),(?=\d{3}(?!\d))/g : /(\d),(?=\d)/g;
145
+ return s
146
+ .replace(/(\d)\.(?=\d)/g, "$1\u066B")
147
+ .replace(group, "$1\u066C")
148
+ .replace(/(\d)\s?%/g, "$1\u066A")
149
+ .replace(/[0-9]/g, (d) => String.fromCharCode(0x0660 + Number(d)))
150
+ // Eastern digits typed with Latin separators ("١,٢٥٠") get the Arabic ones too.
151
+ .replace(/([\u0660-\u0669]),(?=[\u0660-\u0669]{3}(?![\u0660-\u0669]))/g, "$1\u066C").replace(/([\u0660-\u0669])\.(?=[\u0660-\u0669])/g, "$1\u066B");
152
+ }
153
+
154
+ function toWestern(s: string): string {
155
+ return s
156
+ .replace(/[\u0660-\u0669]/g, (d) => String(d.charCodeAt(0) - 0x0660))
157
+ .replace(/[\u06F0-\u06F9]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
158
+ .replace(/\u066B/g, ".").replace(/\u066C/g, ",").replace(/\u066A/g, "%");
159
+ }
160
+
161
+ const PLEX_ARABIC: Record<number, string> = {
162
+ 400: "https://fonts.gstatic.com/s/ibmplexsansarabic/v15/Qw3CZRtWPQCuHme67tEYUIx3Kh0PHR9N6Ys43PWrfQ.woff2",
163
+ 600: "https://fonts.gstatic.com/s/ibmplexsansarabic/v15/Qw3NZRtWPQCuHme67tEYUIx3Kh0PHR9N6YPi-OCRXMR5Kw.woff2",
164
+ 700: "https://fonts.gstatic.com/s/ibmplexsansarabic/v15/Qw3NZRtWPQCuHme67tEYUIx3Kh0PHR9N6YOG-eCRXMR5Kw.woff2",
165
+ };
166
+ /** Font family that only covers ٪ ٫ ٬ (U+066A–U+066C). اسم الخط الذي يغطي ٪ ٫ ٬ فقط. */
167
+ export const ARABIC_SEPARATORS_FAMILY = "Harakah Arabic Separators";
168
+ let separatorsRegistered = false;
169
+
170
+ /**
171
+ * Some display faces (notably Alexandria) draw the Arabic separators ٬ ٫ with the Latin comma glyph,
172
+ * so "١٬٢٥٠٬٠٠٠" looks like "١,٢٥٠,٠٠٠". Wrap the loader: the returned fontFamily puts a tiny
173
+ * unicode-range face (IBM Plex Sans Arabic, OFL) first, so only U+066A–U+066C change; every other
174
+ * glyph still comes from the original font. Works regardless of font load order.
175
+ *
176
+ * بعض خطوط العناوين (مثل Alexandria) ترسم الفواصل العربية ٬ ٫ بشكل الفاصلة اللاتينية. غلّف
177
+ * loadFont() بهذه الدالة فتُرسم هذه الرموز فقط من IBM Plex Sans Arabic ويبقى باقي النص بالخط الأصلي.
178
+ *
179
+ * @example const { fontFamily } = withArabicSeparators(loadFont("normal", { weights: ["800"], subsets: ["arabic"] }));
180
+ */
181
+ export function withArabicSeparators<T extends { fontFamily: string }>(loaded: T, opts: { src?: (weight: 400 | 600 | 700) => string } = {}): T {
182
+ registerArabicSeparators(opts.src);
183
+ return { ...loaded, fontFamily: `"${ARABIC_SEPARATORS_FAMILY}", ${loaded.fontFamily}` };
184
+ }
185
+
186
+ /** Register the separator-only face (idempotent). تسجيل خط الفواصل مرة واحدة. */
187
+ export function registerArabicSeparators(src: (weight: 400 | 600 | 700) => string = (w) => PLEX_ARABIC[w]): void {
188
+ if (separatorsRegistered || typeof document === "undefined" || typeof FontFace === "undefined") return;
189
+ separatorsRegistered = true;
190
+ const handle = delayRender("harakah: Arabic separators (IBM Plex Sans Arabic)");
191
+ const ranges: [400 | 600 | 700, string][] = [[400, "1 450"], [600, "451 650"], [700, "651 1000"]];
192
+ const faces = ranges.map(([w, weight]) => new FontFace(ARABIC_SEPARATORS_FAMILY, `url(${src(w)}) format("woff2")`, { weight, unicodeRange: "U+066A-066C" }));
193
+ faces.forEach((f) => (document.fonts as unknown as { add(face: FontFace): void }).add(f));
194
+ Promise.all(faces.map((f) => f.load())).then(() => continueRender(handle), () => continueRender(handle));
195
+ }
196
+
197
+ /**
198
+ * Wrap Latin/number runs inside Arabic text in Unicode isolates (FSI … PDI) so punctuation stays put.
199
+ * عزل الكلمات اللاتينية داخل النص العربي حتى لا تنتقل علامات الترقيم إلى الجهة الخاطئة.
200
+ */
201
+ export function isolateLatin(text: string): string {
202
+ return text.replace(/\u2068|\u2069/g, "").replace(LATIN_RUN, (m) => `\u2068${m}\u2069`);
203
+ }
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: readonly string[], render: (token: string, index: number) => React.ReactNode, wordStyle?: React.CSSProperties): React.ReactNode[] {
214
+ const out: React.ReactNode[] = [];
215
+ let word: React.ReactNode[] = [];
216
+ let start = 0;
217
+ const flush = () => {
218
+ if (!word.length) return;
219
+ out.push(<span key={`harakah-w${start}`} style={{ display: "inline-block", whiteSpace: "nowrap", ...wordStyle }}>{word}</span>);
220
+ word = [];
221
+ };
222
+ tokens.forEach((t, i) => {
223
+ if (/^[\s\u00A0]+$/.test(t)) { flush(); out.push(<React.Fragment key={`harakah-s${i}`}>{render(t, i)}</React.Fragment>); return; }
224
+ if (!word.length) start = i;
225
+ word.push(<React.Fragment key={`harakah-t${i}`}>{render(t, i)}</React.Fragment>);
226
+ });
227
+ flush();
228
+ return out;
229
+ }
230
+
231
+ export type Preset = "rise" | "fade" | "blur" | "scale";
232
+ export interface ArabicTextProps {
233
+ /** The text. النص. */
234
+ text: string;
235
+ /** Animate per letter (default) or per word. حرفاً حرفاً أو كلمة كلمة. */
236
+ by?: SplitBy;
237
+ /** Frames before the first token starts. التأخير قبل أول عنصر (بالإطارات). */
238
+ delay?: number;
239
+ /** Frames between tokens. الفاصل بين العناصر (بالإطارات). */
240
+ stagger?: number;
241
+ /** Frames each token takes to arrive. مدة دخول كل عنصر. */
242
+ duration?: number;
243
+ /** Built-in motion preset. نمط الحركة الجاهز. */
244
+ preset?: Preset;
245
+ /** Custom per-token style from progress 0→1. نمط مخصص حسب التقدم من ٠ إلى ١. */
246
+ animate?: (progress: number, index: number, token: string) => React.CSSProperties;
247
+ /** Use a spring instead of an eased curve. استخدام spring بدلاً من منحنى. */
248
+ springy?: boolean;
249
+ /** Digit policy applied to the text (default eastern). نظام الأرقام (افتراضياً مشرقية). */
250
+ digits?: DigitStyle;
251
+ style?: React.CSSProperties;
252
+ tokenStyle?: React.CSSProperties;
253
+ className?: string;
254
+ as?: "div" | "h1" | "h2" | "h3" | "p" | "span";
255
+ /**
256
+ * How letter boxes keep their joins. "auto" (default): ZWJ padding, and in Safari/WebKit outside a render the
257
+ * clip method (the whole word in every box, clipped to the letter's slice), which works with fonts that have no
258
+ * U+200D. "zwj" / "clip" force one. طريقة وصل الحروف: تلقائي (الافتراضي)، أو zwj، أو clip.
259
+ */
260
+ joinMode?: JoinMode;
261
+ }
262
+ export type JoinMode = "auto" | "zwj" | "clip";
263
+
264
+ /** Safari / WebKit, where a letter + ZWJ is drawn in a fallback font if the font has no U+200D. */
265
+ export function needsClipJoin(ua: string = typeof navigator === "undefined" ? "" : navigator.userAgent || ""): boolean {
266
+ return /AppleWebKit\//.test(ua) && !/(Chrome|Chromium|Edg|OPR|Android)\//.test(ua);
267
+ }
268
+
269
+ type Slice = { s: number; e: number; W: number; pl: number; pt: number };
270
+ const PURE_ARABIC = /^[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0620-\u064A\u066E-\u06D3\u06FA-\u06FF\u0750-\u077F\u08A0-\u08C9\u0640]+$/;
271
+ const FONT_PROPS = ["fontFamily", "fontSize", "fontWeight", "fontStyle", "fontStretch", "fontVariationSettings", "fontFeatureSettings", "fontKerning", "fontVariantLigatures", "fontVariantNumeric", "fontSynthesis", "fontOpticalSizing", "letterSpacing", "wordSpacing", "textRendering"] as const;
272
+ const SR_ONLY: React.CSSProperties = { position: "absolute", width: 1, height: 1, margin: -1, padding: 0, overflow: "hidden", clip: "rect(0 0 0 0)", clipPath: "inset(50%)", whiteSpace: "nowrap", border: 0 };
273
+
274
+ // Measure each token's slice of the plain word (Range on a hidden copy with the token box's computed font), tiled at
275
+ // the midpoints between neighbouring glyph runs so the slices recompose the word exactly.
276
+ function measureWord(first: HTMLElement, toks: string[]): Slice[] | null {
277
+ const word = toks.join("");
278
+ const m = document.createElement("span");
279
+ m.setAttribute("aria-hidden", "true");
280
+ m.style.cssText = "position:fixed;left:0;top:0;visibility:hidden;z-index:-1;white-space:pre;direction:rtl;unicode-bidi:isolate;pointer-events:none;padding:0;border:0;margin:0;transform:none";
281
+ const cs = getComputedStyle(first);
282
+ for (const k of FONT_PROPS) try { (m.style as unknown as Record<string, string>)[k] = (cs as unknown as Record<string, string>)[k]; } catch { /* unsupported */ }
283
+ m.lang = "ar";
284
+ m.textContent = word;
285
+ document.body.appendChild(m);
286
+ try {
287
+ const t = m.firstChild as Text, mr = m.getBoundingClientRect(), W = mr.width, r = document.createRange();
288
+ if (!(W > 0)) return null;
289
+ let o = 0;
290
+ const sl: { i: number; l: number; r: number }[] = [];
291
+ for (let i = 0; i < toks.length; i++) {
292
+ r.setStart(t, o); r.setEnd(t, o + toks[i].length); o += toks[i].length;
293
+ const b = r.getBoundingClientRect();
294
+ if (!(b.width > 0)) return null;
295
+ sl.push({ i, l: b.left - mr.left, r: b.right - mr.left });
296
+ }
297
+ const byX = [...sl].sort((a, b) => a.l - b.l), out: Slice[] = new Array(toks.length);
298
+ const pl = parseFloat(cs.paddingLeft) || 0, pt = parseFloat(cs.paddingTop) || 0;
299
+ byX.forEach((q, n) => {
300
+ const s = n ? (byX[n - 1].r + q.l) / 2 : 0, e = n < byX.length - 1 ? (q.r + byX[n + 1].l) / 2 : W;
301
+ out[q.i] = { s, e, W, pl, pt };
302
+ });
303
+ return out;
304
+ } finally { m.remove(); }
305
+ }
306
+
307
+ const PRESETS: Record<Preset, (p: number) => React.CSSProperties> = {
308
+ rise: (p) => ({ opacity: p, transform: `translateY(${((1 - p) * 0.45).toFixed(4)}em)` }),
309
+ fade: (p) => ({ opacity: p }),
310
+ blur: (p) => ({ opacity: p, filter: `blur(${((1 - p) * 10).toFixed(2)}px)` }),
311
+ scale: (p) => ({ opacity: p, transform: `scale(${(0.85 + 0.15 * p).toFixed(4)})` }),
312
+ };
313
+
314
+ /**
315
+ * Drop-in animated Arabic text: RTL, lang="ar", joined letters, staggered motion.
316
+ * مكوّن نص عربي متحرك: اتجاه من اليمين، lang="ar"، حروف متصلة، وحركة متتابعة.
317
+ *
318
+ * @example <ArabicText text="صناعة المحتوى العربي" preset="rise" stagger={2} />
319
+ */
320
+ export const ArabicText: React.FC<ArabicTextProps> = ({
321
+ text, by = "letter", delay = 0, stagger, duration = 18, preset = "rise", animate, springy = false,
322
+ digits = "eastern", style, tokenStyle, className, as = "div", joinMode = "auto",
323
+ }) => {
324
+ const frame = useCurrentFrame();
325
+ const { fps } = useVideoConfig();
326
+ const tokens = splitArabic(digits === "keep" ? text : formatDigits(text, digits), by);
327
+ const gap = stagger ?? (by === "letter" ? 2 : 5);
328
+ const Tag = as as React.ElementType;
329
+ // Group letter tokens into words: each word is a nowrap box, so lines only break at spaces
330
+ // (per-letter inline-blocks would otherwise allow a break between any two letters).
331
+ const words: { tokens: string[]; start: number }[] = [];
332
+ const parts: (string | number)[] = []; // " " for spaces, index into words otherwise
333
+ tokens.forEach((t, i) => {
334
+ if (/^[\s\u00A0]+$/.test(t)) { parts.push(" "); return; }
335
+ const last = parts[parts.length - 1];
336
+ if (typeof last === "number") words[last].tokens.push(t);
337
+ else { words.push({ tokens: [t], start: i }); parts.push(words.length - 1); }
338
+ });
339
+ let n = 0;
340
+ const single = tokens.length === 1;
341
+ // Clip method (Safari/WebKit): decided after mount, so server output, the first paint and renders stay ZWJ boxes.
342
+ const root = React.useRef<HTMLElement | null>(null);
343
+ const [clip, setClip] = React.useState(false);
344
+ const [slices, setSlices] = React.useState<Record<number, Slice[]>>({});
345
+ const [epoch, setEpoch] = React.useState(0);
346
+ const clipWords = by === "letter" && !single ? words.map((w, wi) => (w.tokens.length >= 2 && PURE_ARABIC.test(w.tokens.join("").replace(/\u200D/g, "")) ? wi : -1)).filter((wi) => wi >= 0) : [];
347
+ const fontKey = [style, tokenStyle].map((x) => (x ? [x.fontFamily, x.fontSize, x.fontWeight, x.fontStyle, x.letterSpacing, x.fontVariationSettings, x.fontFeatureSettings].join("|") : "")).join("/");
348
+ const textKey = words.map((w) => w.tokens.join("")).join(" ");
349
+ React.useLayoutEffect(() => {
350
+ const on = joinMode === "clip" || (joinMode === "auto" && needsClipJoin() && !getRemotionEnvironment().isRendering);
351
+ setClip(on);
352
+ if (!on) return;
353
+ const bump = () => setEpoch((e) => e + 1);
354
+ const fonts = typeof document !== "undefined" ? (document.fonts as unknown as EventTarget & { ready?: Promise<unknown> }) : null;
355
+ fonts?.addEventListener?.("loadingdone", bump);
356
+ fonts?.ready?.then(bump, () => {});
357
+ window.addEventListener("resize", bump);
358
+ return () => { fonts?.removeEventListener?.("loadingdone", bump); window.removeEventListener("resize", bump); };
359
+ }, [joinMode]);
360
+ React.useLayoutEffect(() => {
361
+ if (!clip || !root.current || !clipWords.length) { if (Object.keys(slices).length) setSlices({}); return; }
362
+ const next: Record<number, Slice[]> = {};
363
+ for (const wi of clipWords) {
364
+ const first = root.current.querySelector(`[data-hz-word="${wi}"] > [data-hz-tok]`) as HTMLElement | null;
365
+ const sl = first && measureWord(first, words[wi].tokens.map((t) => t.replace(/\u200D/g, "")));
366
+ if (sl) next[wi] = sl;
367
+ }
368
+ if (JSON.stringify(next) !== JSON.stringify(slices)) setSlices(next);
369
+ // eslint-disable-next-line react-hooks/exhaustive-deps
370
+ }, [clip, textKey, fontKey, epoch]);
371
+ return (
372
+ <Tag ref={root} dir="rtl" lang="ar" className={className} style={{ direction: "rtl", ...style }}>
373
+ {parts.map((part, pi) => {
374
+ if (part === " ") return <React.Fragment key={`s${pi}`}>{" "}</React.Fragment>;
375
+ const wi = part as number, w = words[wi], sl = clip ? slices[wi] : undefined;
376
+ const word = sl ? w.tokens.join("").replace(/\u200D/g, "") : "";
377
+ const inner = w.tokens.map((t, k) => {
378
+ const local = frame - delay - n++ * gap;
379
+ const p = springy
380
+ ? spring({ frame: local, fps, durationInFrames: duration, config: { damping: 200 } })
381
+ : interpolate(local, [0, duration], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.bezier(0.2, 0.8, 0.2, 1) });
382
+ const motion = animate ? animate(p, w.start + k, t) : PRESETS[preset](p);
383
+ if (sl) {
384
+ // the box keeps the letter's slice width; a hidden strut holds the line height and baseline, and the
385
+ // whole word (aria-hidden) is offset and clipped to the slice; the first box carries the readable copy
386
+ const q = sl[k], plain = t.replace(/\u200D/g, ""), pad = "-0.8em", last = q.e >= q.W - 0.01, firstX = q.s <= 0.01;
387
+ const inset = `inset(${pad} ${last ? pad : `${q.W - q.e}px`} ${pad} ${firstX ? pad : `${q.s}px`})`;
388
+ return (
389
+ <span key={k} data-hz-tok="" style={{ display: "inline-block", whiteSpace: "pre", ...tokenStyle, ...motion, position: "relative", boxSizing: "content-box", width: q.e - q.s }}>
390
+ <span aria-hidden="true" style={{ display: "inline", visibility: "hidden" }}>{plain}</span>
391
+ <span aria-hidden="true" style={{ display: "inline", position: "absolute", left: q.pl - q.s, top: q.pt, width: q.W, whiteSpace: "pre", direction: "rtl", unicodeBidi: "isolate", pointerEvents: "none", userSelect: "none", WebkitUserSelect: "none", margin: 0, padding: 0, border: 0, clipPath: inset, WebkitClipPath: inset }}>{word}</span>
392
+ {k === 0 ? <span style={SR_ONLY}>{word}</span> : null}
393
+ </span>
394
+ );
395
+ }
396
+ return (
397
+ <span key={k} data-hz-tok="" style={{ display: "inline-block", whiteSpace: single ? "normal" : "pre", ...tokenStyle, ...motion }}>
398
+ {t}
399
+ </span>
400
+ );
401
+ });
402
+ return single || w.tokens.length === 1 ? <React.Fragment key={`w${pi}`}>{inner}</React.Fragment> : <span key={`w${pi}`} data-hz-word={wi} style={{ display: "inline-block", whiteSpace: "nowrap" }}>{inner}</span>;
403
+ })}
404
+ </Tag>
405
+ );
406
+ };