@ev-ry/fx 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +13 -0
  3. package/QUICKSTART.fa.md +94 -0
  4. package/README.md +116 -0
  5. package/assets/fonts/Estedad-OFL.txt +93 -0
  6. package/assets/vendor/bidi-LICENSE.txt +22 -0
  7. package/assets/vendor/bidi.min.js +1 -0
  8. package/assets/vendor/three-LICENSE.txt +21 -0
  9. package/assets/vendor/three.min.js +7 -0
  10. package/build-report.json +328 -0
  11. package/docs/GUIDE.md +125 -0
  12. package/docs/RELEASE-NOTES.md +40 -0
  13. package/examples/AnimatedTitle.jsx +21 -0
  14. package/examples/navigation-away.html +1 -0
  15. package/examples/navigation.html +38 -0
  16. package/examples/script.html +2 -0
  17. package/package.json +57 -0
  18. package/src/dom-attachment.d.ts +78 -0
  19. package/src/dom-attachment.js +187 -0
  20. package/src/dom-auto-reveal.d.ts +17 -0
  21. package/src/dom-auto-reveal.js +53 -0
  22. package/src/dom-auto-route.js +25 -0
  23. package/src/dom-free-bootstrap.js +42 -0
  24. package/src/dom-free-loader.d.ts +13 -0
  25. package/src/dom-free-loader.js +12 -0
  26. package/src/dom-free-script.js +12 -0
  27. package/src/dom-free.d.ts +16 -0
  28. package/src/dom-free.js +42 -0
  29. package/src/dom-image-raster.js +22 -0
  30. package/src/dom-image-surface.js +109 -0
  31. package/src/dom-image-swap.js +27 -0
  32. package/src/dom-once.js +34 -0
  33. package/src/dom-raster-cache.js +33 -0
  34. package/src/dom-reveal-boot.js +11 -0
  35. package/src/dom-reveal.js +75 -0
  36. package/src/dom-rich-text.js +119 -0
  37. package/src/dom-surface-font.js +50 -0
  38. package/src/dom-svg-surface.js +43 -0
  39. package/src/dom-text-fingerprint.js +27 -0
  40. package/src/dom-text-runs.js +45 -0
  41. package/src/dom-text-surface.js +254 -0
  42. package/src/font-mesh-engine.js +368 -0
  43. package/src/font-rasterizer.js +65 -0
  44. package/src/hybrid-text-flow.js +45 -0
  45. package/src/image-preparation-queue.js +16 -0
  46. package/src/image-source.js +61 -0
  47. package/src/image-surface.js +162 -0
  48. package/src/insertion-range.js +29 -0
  49. package/src/mesh-generator-core.js +149 -0
  50. package/src/motion-envelope.js +22 -0
  51. package/src/motion.js +52 -0
  52. package/src/native-run-shaping.js +60 -0
  53. package/src/particle-centers.js +50 -0
  54. package/src/raster-texture-material.js +79 -0
  55. package/src/raster-texture-mesh.js +35 -0
  56. package/src/render-owner.js +63 -0
  57. package/src/runtime-font-engine.js +242 -0
  58. package/src/runtime-lifecycle.js +26 -0
  59. package/src/text-direction.js +25 -0
  60. package/src/text-edit-effect.js +101 -0
  61. package/src/text-edit-motions.js +15 -0
  62. package/src/text-effect-options.js +29 -0
  63. package/src/text-effect-path.js +61 -0
  64. package/src/text-mesh-density.js +36 -0
  65. package/src/text-motion-character-centers.js +74 -0
  66. package/src/text-motion-contour.js +22 -0
  67. package/src/text-motion-primitives.js +91 -0
  68. package/src/text-motion-programs.js +1 -0
  69. package/src/text-motion-recipes.js +71 -0
  70. package/src/text-scene.js +272 -0
  71. package/src/triangle-coverage.js +25 -0
  72. package/src/triangle-effect.js +194 -0
  73. package/src/triangle-motion-frame.js +38 -0
  74. package/src/viewport-clip.js +119 -0
  75. package/src/viewport-render-owner.js +318 -0
@@ -0,0 +1,254 @@
1
+ import {HybridTextFlowEngine} from './hybrid-text-flow.js';
2
+ import {SharedTextScene} from './text-scene.js';
3
+ import {TextEditEffect} from './text-edit-effect.js';
4
+ import {createLifecycle} from './runtime-lifecycle.js';
5
+ import {documentDirection} from './text-direction.js';
6
+ import {textEditMotions} from './text-edit-motions.js';
7
+ import {normalizeTextEffectOptions} from './text-effect-options.js';
8
+ import {adoptDOMFont,domFontAtSize,matchesDOMFont} from './dom-surface-font.js';
9
+ import {DOMRichText} from './dom-rich-text.js';
10
+ import {createDOMTextFingerprint} from './dom-text-fingerprint.js';
11
+
12
+ // Presentation attachment: the original text node, parent control and semantics
13
+ // remain owned by the host. The deliberately narrow first contract is a plain,
14
+ // single-line text slot. Unsupported layout always retains its native paint.
15
+ export function attachTextSurface(element,THREE,options={}){
16
+ if(!element?.ownerDocument?.defaultView)throw TypeError('A DOM text slot is required');
17
+ const document=element.ownerDocument,window=document.defaultView,life=createLifecycle();
18
+ let effectName=options.inputEffect??'dust-wind';
19
+ let resting=options.resting??'mesh';
20
+ const validResting=value=>{if(!['mesh','native'].includes(value))throw TypeError('Expected mesh or native resting mode');};validResting(resting);
21
+ const validMode=mode=>mode==='none'||!!textEditMotions[mode];
22
+ if(!validMode(effectName))throw TypeError('Invalid inputEffect');
23
+ let settings=normalizeTextEffectOptions({formation:-1,...options.inputEffectOptions},effectName==='none'?'dust-wind':effectName);
24
+ let enabled=options.enabled!==false,renderer,canvas,engine,view,effect,scene,content,camera;
25
+ let frame=null,revision=0,preparing=false,dirty=true,layout=null,committed=null,reason=null,fatal=false,intersecting=true;
26
+ let mode='native',draws=0,builds=0,submittedSize='',pendingPhase=options.initialPhase??null,phase='enter',lastSeed=null;
27
+ let effectStarted=null,pendingStarted=null;
28
+ function preserveMotion(){if(pendingPhase===null&&effectStarted!==null&&window.performance.now()-effectStarted<settings.duration){pendingPhase=phase;pendingStarted=effectStarted;}}
29
+ let resolveReady;const ready=new Promise(resolve=>{resolveReady=resolve;});
30
+ let rich=null;
31
+ let fingerprint=null,preparedFingerprint=null,fontRevision=0;
32
+ const needsCharacters=()=>settings.bounceLines||[effectName,settings.exitEffect==='same'?effectName:settings.exitEffect].some(name=>textEditMotions[name]?.characterCenters||textEditMotions[name]?.characterFrame);
33
+ const reduced=window.matchMedia('(prefers-reduced-motion: reduce)'),forced=window.matchMedia('(forced-colors: active)');
34
+ const originalStyle=new Map(),ownedStyle=new Map();
35
+ function ownStyle(name,value){
36
+ if(!originalStyle.has(name))originalStyle.set(name,[element.style.getPropertyValue(name),element.style.getPropertyPriority(name)]);
37
+ if(element.style.getPropertyValue(name)!==value)element.style.setProperty(name,value);
38
+ ownedStyle.set(name,value);
39
+ }
40
+ function restoreStyle(name){
41
+ if(!ownedStyle.has(name))return;
42
+ if(element.style.getPropertyValue(name)===ownedStyle.get(name)){
43
+ const [value,priority]=originalStyle.get(name);if(value)element.style.setProperty(name,value,priority);else element.style.removeProperty(name);
44
+ }
45
+ ownedStyle.delete(name);originalStyle.delete(name);
46
+ }
47
+ function native(why=null){
48
+ rich?.restore();
49
+ mode='native';reason=why;restoreStyle('-webkit-text-fill-color');restoreStyle('text-shadow');
50
+ if(canvas)canvas.style.display='none';
51
+ renderer?.invalidate?.();
52
+ }
53
+ function fail(error){
54
+ if(life.disposed)return;fatal=true;effect?.cancel();rich?.cancel();native(String(error?.message||error));
55
+ if(frame!==null){window.cancelAnimationFrame(frame);frame=null;}
56
+ resolveReady({mode,reason});
57
+ }
58
+ function request(){if(!life.disposed&&!fatal&&!document.hidden&&intersecting&&frame===null)frame=window.requestAnimationFrame(render);}
59
+ function suspend(){if(frame!==null)window.cancelAnimationFrame(frame);frame=null;if(renderer?.globalCanvas)native('not visible');renderer?.invalidate?.();}
60
+ function refresh(){
61
+ if(life.disposed)return;revision++;dirty=true;request();
62
+ }
63
+ function slot(){
64
+ const children=[...element.childNodes].filter(node=>node!==canvas&&node.nodeType!==8);
65
+ if(children.some(node=>node.nodeType!==3)||children.length>1)return null;
66
+ return {node:children[0]??null,text:children[0]?.data??''};
67
+ }
68
+ function measure(current,collectCharacters){
69
+ const style=window.getComputedStyle(element),size=parseFloat(style.fontSize),box=element.getBoundingClientRect();
70
+ const width=element.clientWidth||box.width,height=element.clientHeight||box.height;
71
+ if(!element.isConnected||!(width>0&&height>0))return {reason:'Text slot is not visible'};
72
+ if(!matchesDOMFont(engine,style)||!(size>0)||style.fontStyle!=='normal'||style.textTransform!=='none'||style.writingMode!=='horizontal-tb'
73
+ ||!['normal','0px'].includes(style.letterSpacing)||!['normal','0px'].includes(style.wordSpacing)||style.textDecorationLine!=='none'
74
+ ||/[\n\r\t]/.test(current.text))return {reason:'Unsupported text typography; native text retained'};
75
+ for(const pseudo of ['::before','::after']){
76
+ const p=window.getComputedStyle(element,pseudo);if(p.display!=='none'&&!['none','normal','""',"''"].includes(p.content))return {reason:'Generated text content is not supported'};
77
+ }
78
+ const factor=size/200,context=engine.rasterizer.context;context.font=domFontAtSize(engine,size);
79
+ const metrics=context.measureText('Hgآی'),expected=context.measureText(current.text).width;engine.rasterizer.configure();
80
+ if(!Number.isFinite(metrics.fontBoundingBoxAscent))return {reason:'Native font metrics are unavailable'};
81
+ const direction=element.dir==='auto'?documentDirection(current.text,style.direction):style.direction;
82
+ const rows=[],characters=[];
83
+ if(current.node&&current.text){
84
+ const range=document.createRange();range.selectNodeContents(current.node);
85
+ const rects=[...range.getClientRects()].filter(rect=>rect.height>0),rect=range.getBoundingClientRect();
86
+ if(!rects.length)return {reason:'Text has no measurable layout'};
87
+ if(rects.some(r=>Math.abs(r.top-rects[0].top)>.75)||Math.abs(rect.width-expected)>Math.max(1,expected*.015))return {reason:'Wrapped or transformed text uses native rendering'};
88
+ const top=rect.top-box.top-element.clientTop,left=rect.left-box.left-element.clientLeft;
89
+ const baseline=top+(rect.height+metrics.fontBoundingBoxAscent-metrics.fontBoundingBoxDescent)/2;
90
+ rows.push({start:0,end:current.text.length,text:current.text,top,left,right:left+rect.width,baseline,direction});
91
+ if(collectCharacters)for(const part of new Intl.Segmenter(undefined,{granularity:'grapheme'}).segment(current.text)){
92
+ range.setStart(current.node,part.index);range.setEnd(current.node,part.index+part.segment.length);
93
+ const r=range.getBoundingClientRect();
94
+ if(r.width>0)characters.push({x:(r.left-box.left-element.clientLeft)/factor*engine.scale,
95
+ y:-(baseline+metrics.fontBoundingBoxDescent)/factor*engine.scale,
96
+ width:r.width/factor*engine.scale,height:(metrics.fontBoundingBoxAscent+metrics.fontBoundingBoxDescent)/factor*engine.scale,direction});
97
+ }
98
+ }
99
+ return {rows,characters,factor,width,height,size,direction,color:style.color,text:current.text,
100
+ key:JSON.stringify([current.text,engine.domFont?.key??engine.face.family,size,width,height,rows.map(r=>[r.left,r.baseline,r.direction])])};
101
+ }
102
+ async function prepare(){
103
+ if(preparing||life.disposed||fatal)return;
104
+ let stamp,surfaceIssue;
105
+ const collectCharacters=!!needsCharacters();
106
+ try{
107
+ surfaceIssue=renderer.getSurfaceIssue?.();
108
+ stamp=JSON.stringify([fontRevision,collectCharacters,fingerprint()]);
109
+ if(!surfaceIssue&&enabled&&!forced.matches&&layout&&stamp===preparedFingerprint){dirty=false;reason=null;request();return;}
110
+ }catch(error){fail(error);return;}
111
+ dirty=false;preparing=true;const version=revision;
112
+ preparedFingerprint=null;
113
+ try{
114
+ if(surfaceIssue){effect.cancel();rich?.cancel();native(surfaceIssue);resolveReady({mode,reason});return;}
115
+ const current=slot();
116
+ const typography=window.getComputedStyle(element);
117
+ let wrapped=false;
118
+ if(current?.node){const range=document.createRange();range.selectNodeContents(current.node);const rects=[...range.getClientRects()].filter(r=>r.height>0);wrapped=rects.some(r=>Math.abs(r.top-rects[0].top)>.75);}
119
+ const collapsedWhitespace=current&&['normal','nowrap'].includes(typography.whiteSpace)&&/(^[ \t\r\n\f]|[ \t\r\n\f]$|[ \t\r\n\f]{2,}|[\t\r\n\f])/.test(current.text);
120
+ if(!current||rich||wrapped||collapsedWhitespace||!['normal','0px'].includes(typography.letterSpacing)||!['normal','0px'].includes(typography.wordSpacing)){
121
+ if(!enabled||forced.matches){rich?.cancel();native(!enabled?'disabled':'forced colors');resolveReady({mode,reason});return;}
122
+ if(!rich){effect.cancel();view.dispose();rich=new DOMRichText(element,THREE,content,canvas,options);life.own(()=>rich.dispose());}
123
+ const next=await rich.prepare(()=>life.disposed||version!==revision,()=>{if(rich.stats().active)preserveMotion();});
124
+ if(!next||life.disposed||version!==revision)return;
125
+ if(next.reason){rich.cancel();native(next.reason);resolveReady({mode,reason});return;}
126
+ if(committed!==null&&committed!==next.text&&pendingPhase===null)pendingPhase='enter';
127
+ committed=next.text;layout=next;reason=null;preparedFingerprint=stamp;builds++;request();return;
128
+ }
129
+ if(!enabled||forced.matches){effect.cancel();native(!enabled?'disabled':'forced colors');resolveReady({mode,reason});return;}
130
+ const changed=await adoptDOMFont(engine,element);
131
+ if(life.disposed||version!==revision)return;
132
+ const next=measure(current,collectCharacters);
133
+ if(next.reason){effect.cancel();native(next.reason);resolveReady({mode,reason});return;}
134
+ const densityChanged=engine.setDisplayFontSize(next.size);
135
+ if(changed||densityChanged||!layout||next.key!==layout.key){
136
+ if(effect.active)preserveMotion();
137
+ if(pendingPhase===null)native();effect.cancel();
138
+ const result=await engine.prepareRows(next,{cancelled:()=>life.disposed||version!==revision});
139
+ if(!result||life.disposed||version!==revision)return;
140
+ const textChanged=committed!==null&&committed!==next.text;
141
+ view.setText(next.text);committed=next.text;builds++;
142
+ if(textChanged&&pendingPhase===null)pendingPhase='enter';
143
+ }
144
+ layout=next;reason=null;preparedFingerprint=stamp;request();
145
+ }catch(error){fail(error);}
146
+ finally{preparing=false;if(!life.disposed&&dirty)request();}
147
+ }
148
+ function beginEffect(now){
149
+ if(pendingPhase===null||!layout)return;
150
+ const resuming=pendingStarted!==null;phase=pendingPhase;pendingPhase=null;now=pendingStarted??now;pendingStarted=null;effectStarted=now;content.visible=true;
151
+ if(rich){if(!resuming&&phase==='enter'||lastSeed===null)lastSeed=effect.randomSeed();rich.play(phase,reduced.matches?'none':effectName,settings,lastSeed,now);if(effectName==='none'||reduced.matches)content.visible=phase==='enter';return;}
152
+ if(effectName==='none'||reduced.matches||!committed){effect.cancel();content.visible=phase==='enter';return;}
153
+ const effectMode=phase==='exit'&&settings.exitEffect!=='same'?settings.exitEffect:effectName;
154
+ effect.setMode(effectMode);effect.configure(settings);
155
+ const b=view.bounds,rectangle={x:b.minX-.001,y:b.minY-.001,width:b.maxX-b.minX+.002,height:b.maxY-b.minY+.002,direction:layout.direction};
156
+ if(!resuming&&phase==='enter'||lastSeed===null)lastSeed=effect.randomSeed();
157
+ effect.playRegion(view,rectangle,200*engine.scale,now,{departing:phase==='exit',seed:lastSeed});
158
+ effect.prepareCharacterCenters(view,()=>layout.characters);
159
+ }
160
+ function render(now){
161
+ frame=null;if(life.disposed||fatal||document.hidden||!intersecting)return;
162
+ if(dirty){prepare();return;}
163
+ if(preparing||!layout||!enabled||forced.matches||reason)return;
164
+ try{
165
+ if(!canvas.isConnected)element.append(canvas);
166
+ if(window.getComputedStyle(element).position==='static')ownStyle('position','relative');
167
+ const {width,height,size,factor}=layout,margin=renderer.globalCanvas?0:Math.ceil(size*3),w=width+margin*2,h=height+margin*2;
168
+ const dpr=Math.min(window.devicePixelRatio||1,2),sizeKey=[w,h,dpr].join(':');
169
+ if(submittedSize!==sizeKey){
170
+ renderer.setPixelRatio(dpr);renderer.setSize(w,h,false);submittedSize=sizeKey;
171
+ Object.assign(canvas.style,{left:-margin+'px',top:-margin+'px',width:w+'px',height:h+'px'});
172
+ camera.left=-margin;camera.right=width+margin;camera.top=margin;camera.bottom=-height-margin;camera.updateProjectionMatrix();
173
+ }
174
+ content.scale.setScalar(factor/engine.scale);
175
+ view.uniforms.tint.value.setStyle(window.getComputedStyle(element).color);
176
+ beginEffect(now);const active=rich?rich.step(now,reduced.matches):effect.step(now,reduced.matches);
177
+ if(!active&&phase==='exit')content.visible=false;
178
+ if(!active&&phase!=='exit'&&resting==='native'){renderer.render(scene,camera);native('native resting presentation');resolveReady({mode,reason});return;}
179
+ const handingOffNative=mode==='native';
180
+ renderer.render(scene,camera);draws++;
181
+ canvas.style.display='block';ownStyle('-webkit-text-fill-color','transparent');ownStyle('text-shadow','none');mode='mesh';reason=null;
182
+ rich?.hide();
183
+ // Commit the shared canvas before the browser paints hidden native text.
184
+ if(handingOffNative)renderer.flushPresentation?.();
185
+ renderer.invalidate?.();
186
+ resolveReady({mode,reason});if(active)request();
187
+ }catch(error){fail(error);}
188
+ }
189
+ life.own(()=>{if(frame!==null)window.cancelAnimationFrame(frame);frame=null;});
190
+ life.own(()=>{for(const name of [...ownedStyle.keys()])restoreStyle(name);});
191
+ try{
192
+ renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});life.own(()=>{renderer.dispose();renderer.forceContextLoss();});
193
+ renderer.setSurface?.(element,{escapeHost:element.closest('button,a,[role="button"]')||element});
194
+ canvas=renderer.domElement;canvas.setAttribute('aria-hidden','true');canvas.setAttribute('data-thd-text-surface','');
195
+ fingerprint=createDOMTextFingerprint(element,canvas);
196
+ Object.assign(canvas.style,{position:'absolute',pointerEvents:'none',display:'none',margin:'0',padding:'0',border:'0',maxWidth:'none',maxHeight:'none'});
197
+ element.append(canvas);life.own(()=>canvas.remove());
198
+ life.listen(canvas,'webglcontextlost',event=>{event.preventDefault();fail('WebGL context lost; native text retained');});
199
+ life.listen(canvas,'thdviewportchange',refresh);
200
+ scene=new THREE.Scene();content=new THREE.Group();scene.add(content);
201
+ camera=new THREE.OrthographicCamera(-1,1,1,-1,.1,100);camera.position.z=50;
202
+ engine=new HybridTextFlowEngine({scale:.036,divisions:options.divisions??'auto',textRendering:'texture'});life.own(()=>engine.dispose());
203
+ view=new SharedTextScene(THREE,engine,content);life.own(()=>view.dispose());
204
+ effect=new TextEditEffect(THREE,{mode:effectName==='none'?'dust-wind':effectName,settings});life.own(()=>effect.dispose());
205
+ const mutations=new window.MutationObserver(records=>{
206
+ if(records.some(record=>record.target!==canvas&&!canvas.contains(record.target)))refresh();
207
+ });
208
+ mutations.observe(element,{childList:true,characterData:true,attributes:true,attributeFilter:['class','style','dir'],subtree:true});life.own(()=>mutations.disconnect());
209
+ // Theme and control state are inherited from the existing DOM hierarchy.
210
+ // Observe only attributes on this slot's ancestors, never a document subtree.
211
+ for(let ancestor=element.parentElement;ancestor;ancestor=ancestor.parentElement){
212
+ mutations.observe(ancestor,{attributes:true,attributeFilter:['class','style','dir','disabled','aria-disabled']});
213
+ }
214
+ const resize=new window.ResizeObserver(refresh);resize.observe(element);life.own(()=>resize.disconnect());
215
+ const control=element.closest('button,a,[role="button"]')||element.parentElement||element;
216
+ for(const event of ['mouseenter','mouseleave','focusin','focusout','pointerdown','pointerup','pointercancel'])life.listen(control,event,refresh);
217
+ if(window.IntersectionObserver){
218
+ const visibility=new window.IntersectionObserver(entries=>{
219
+ const next=entries.at(-1)?.isIntersecting!==false;if(next===intersecting)return;
220
+ intersecting=next;if(next)refresh();else suspend();
221
+ });visibility.observe(element);life.own(()=>visibility.disconnect());
222
+ }
223
+ life.listen(document,'visibilitychange',()=>{if(document.hidden)suspend();else refresh();});
224
+ life.listen(window,'resize',refresh);
225
+ for(const event of ['loadingdone','loadingerror'])life.listen(document.fonts,event,()=>{fontRevision++;refresh();});
226
+ life.listen(forced,'change',()=>{native();refresh();});life.listen(reduced,'change',refresh);
227
+ request();
228
+ }catch(error){fail(error);}
229
+ return {element,ready,
230
+ refresh,
231
+ update(next={}){
232
+ if(life.disposed)throw Error('Text surface disposed');
233
+ const candidate=next.inputEffect??effectName;if(!validMode(candidate))throw TypeError('Invalid inputEffect');
234
+ validResting(next.resting??resting);resting=next.resting??resting;
235
+ const normalized=normalizeTextEffectOptions(next.inputEffectOptions?{formation:-1,...next.inputEffectOptions}:settings,candidate==='none'?'dust-wind':candidate);
236
+ effectName=candidate;settings=normalized;if('enabled' in next)enabled=next.enabled!==false;
237
+ effect?.cancel();rich?.cancel();pendingPhase=null;pendingStarted=null;effectStarted=null;phase='enter';if(content)content.visible=true;refresh();
238
+ },
239
+ cancel(){if(life.disposed)return;effect?.cancel();rich?.cancel();pendingPhase=null;pendingStarted=null;effectStarted=null;phase='enter';if(content)content.visible=true;refresh();},
240
+ play(next='enter'){
241
+ if(life.disposed)throw Error('Text surface disposed');if(!['enter','exit'].includes(next))throw TypeError('Expected enter or exit');
242
+ pendingPhase=next;pendingStarted=null;refresh();
243
+ },
244
+ stats:()=>({timeline:{phase,started:effectStarted,ends:effectStarted===null?null:effectStarted+settings.duration},disposed:life.disposed,mode,reason,committed,preparing,draws,builds,pending:frame!==null,suspended:document.hidden||!intersecting,triangles:rich?rich.stats().triangles:view?.triangleCount??0,
245
+ width:layout?.width??0,height:layout?.height??0,fontFamily:engine?.face?.family,displayFontSize:layout?.size,divisions:engine?.divisions,effect:effect?.stats(),rich:rich?.stats()??null}),
246
+ destroy(){if(life.disposed)return;native('destroyed');resolveReady({mode,reason});life.destroy();}
247
+ };
248
+ }
249
+
250
+
251
+
252
+
253
+
254
+
@@ -0,0 +1,368 @@
1
+ const isJoinControl = (value) => value === '\u200C' || value === '\u200D';
2
+ import { bidi, isBidiControl } from './text-direction.js';
3
+ const forms = {
4
+ 'آ':['\uFE81','\uFE82'], 'أ':['\uFE83','\uFE84'], 'إ':['\uFE87','\uFE88'], 'ؤ':['\uFE85','\uFE86'], 'ئ':['\uFE89','\uFE8A','\uFE8B','\uFE8C'],
5
+ 'ا':['\uFE8D','\uFE8E'],'ب':['\uFE8F','\uFE90','\uFE91','\uFE92'],'پ':['\uFB56','\uFB57','\uFB58','\uFB59'],'ت':['\uFE95','\uFE96','\uFE97','\uFE98'],'ث':['\uFE99','\uFE9A','\uFE9B','\uFE9C'],'ج':['\uFE9D','\uFE9E','\uFE9F','\uFEA0'],'چ':['\uFB7A','\uFB7B','\uFB7C','\uFB7D'],'ح':['\uFEA1','\uFEA2','\uFEA3','\uFEA4'],'خ':['\uFEA5','\uFEA6','\uFEA7','\uFEA8'],'د':['\uFEA9','\uFEAA'],'ذ':['\uFEAB','\uFEAC'],'ر':['\uFEAD','\uFEAE'],'ز':['\uFEAF','\uFEB0'],'ژ':['\uFB8A','\uFB8B'],'س':['\uFEB1','\uFEB2','\uFEB3','\uFEB4'],'ش':['\uFEB5','\uFEB6','\uFEB7','\uFEB8'],'ص':['\uFEB9','\uFEBA','\uFEBB','\uFEBC'],'ض':['\uFEBD','\uFEBE','\uFEBF','\uFEC0'],'ط':['\uFEC1','\uFEC2','\uFEC3','\uFEC4'],'ظ':['\uFEC5','\uFEC6','\uFEC7','\uFEC8'],'ع':['\uFEC9','\uFECA','\uFECB','\uFECC'],'غ':['\uFECD','\uFECE','\uFECF','\uFED0'],'ف':['\uFED1','\uFED2','\uFED3','\uFED4'],'ق':['\uFED5','\uFED6','\uFED7','\uFED8'],'ک':['\uFB8E','\uFB8F','\uFB90','\uFB91'],'گ':['\uFB92','\uFB93','\uFB94','\uFB95'],'ل':['\uFEDD','\uFEDE','\uFEDF','\uFEE0'],'م':['\uFEE1','\uFEE2','\uFEE3','\uFEE4'],'ن':['\uFEE5','\uFEE6','\uFEE7','\uFEE8'],'ه':['\uFEE9','\uFEEA','\uFEEB','\uFEEC'],'و':['\uFEED','\uFEEE'],'ی':['\uFBFC','\uFBFD','\uFBFE','\uFBFF']
6
+ };
7
+ export { forms as JOINING_FORMS };
8
+ const graphemes = new Intl.Segmenter('fa', { granularity: 'grapheme' });
9
+ const clusterBase = value => value === 'ۀ' ? 'ه' : Array.from(value || '')[0];
10
+ const formIndexFor = (character, previous, next) => {
11
+ if (!forms[character]) return 0;
12
+ const joinsBefore = Boolean(forms[previous]?.length > 2);
13
+ const joinsAfter = Boolean(forms[character]?.length > 2 && forms[next]?.length > 1);
14
+ return forms[character].length === 2 ? (joinsBefore ? 1 : 0) : joinsBefore && joinsAfter ? 3 : joinsBefore ? 1 : joinsAfter ? 2 : 0;
15
+ };
16
+ const visualOrder = (line, direction) => {
17
+ const chars = []; let index = 0;
18
+ for (const { segment } of graphemes.segment(line)) {
19
+ // Joining controls remain boundaries even when Segmenter attaches them to a letter.
20
+ for (const ch of segment.split(/([\u200C\u200D])/u).filter(Boolean)) {
21
+ chars.push({ ch, i: index }); index += Array.from(ch).length;
22
+ }
23
+ }
24
+ const embedding = bidi.getEmbeddingLevels(line, direction);
25
+ const order = bidi.getReorderedIndices(line, embedding);
26
+ const ranks = new Map(order.map((logical, visual) => [logical, visual]));
27
+ let utf16 = 0;
28
+ for (const item of chars) {
29
+ item.level = embedding.levels[utf16] || 0;
30
+ item.visual = ranks.get(utf16);
31
+ item.mirrored = item.level & 1 ? bidi.getMirroredCharacter(item.ch) : null;
32
+ utf16 += item.ch.length;
33
+ }
34
+ return chars.sort((a, b) => a.visual - b.visual);
35
+ };
36
+
37
+ export function shapeLine(line, direction) {
38
+ const logical = Array.from(line);
39
+ const items = visualOrder(line, direction).map(({ ch, i, level, mirrored }) => {
40
+ let previous = i - 1;
41
+ while (previous >= 0 && /\p{Mark}/u.test(logical[previous])) previous--;
42
+ const form = formIndexFor(clusterBase(ch), clusterBase(logical[previous]), clusterBase(logical[i + Array.from(ch).length]));
43
+ const key = ch === 'ۀ' ? 'هٔ' : (mirrored || ch).normalize('NFC');
44
+ const base = Array.from(key)[0];
45
+ const rasterText = (form ? forms[base]?.[form] || base : base) + Array.from(key).slice(1).join('');
46
+ return { ch, i, level, form, key, rasterText, control: isJoinControl(ch) || isBidiControl(ch) };
47
+ });
48
+ return { logical, items };
49
+ }
50
+
51
+ export class FontMeshEngine {
52
+ constructor({ scale = 0.012, lineHeight = 1 } = {}) {
53
+ this.scale = scale;
54
+ this.lineHeight = lineHeight;
55
+ this.records = new Map();
56
+ this.lineCache = new Map();
57
+ this.cachedLayout = null;
58
+ }
59
+
60
+ async load(source) {
61
+ const compressed = source instanceof Blob ? await source.arrayBuffer() : await (await fetch(source)).arrayBuffer();
62
+ const stream = new DecompressionStream('gzip');
63
+ const writer = stream.writable.getWriter();
64
+ writer.write(new Uint8Array(compressed)); writer.close();
65
+ const bytes = new Uint8Array(await new Response(stream.readable).arrayBuffer());
66
+ const view = new DataView(bytes.buffer);
67
+ if (String.fromCharCode(...bytes.slice(0, 3)) !== 'TMG' || view.getUint8(3) !== 53) {
68
+ throw new Error('فقط فرمت TMG5 پشتیبانی می‌شود.');
69
+ }
70
+ this.lineCache.clear();
71
+ this.cachedLayout = null;
72
+ this.fontMetrics = { ascent: 80, descent: 0, height: 80 };
73
+ this.lineAdvance = 240;
74
+ this.records.clear();
75
+ let offset = 8;
76
+ const count = view.getUint32(offset, true); offset += 4;
77
+ for (let i = 0; i < count; i += 1) {
78
+ const length = view.getUint8(offset++);
79
+ let character = '';
80
+ for (let j = 0; j < length; j += 1) { character += String.fromCodePoint(view.getUint32(offset, true)); offset += 4; }
81
+ const form = view.getUint8(offset++);
82
+ const width = view.getUint16(offset, true); offset += 2;
83
+ const height = view.getUint16(offset, true); offset += 2;
84
+ const advance = view.getFloat32(offset, true); offset += 4;
85
+ const baseline = view.getFloat32(offset, true); offset += 4;
86
+ const drawOffsetX = view.getUint16(offset, true); offset += 2;
87
+ const triangleCount = view.getUint32(offset, true); offset += 4;
88
+ const triangles = [];
89
+ for (let t = 0; t < triangleCount; t += 1) {
90
+ const points = [];
91
+ for (let p = 0; p < 3; p += 1) {
92
+ points.push([view.getUint16(offset, true) / 65535 * width, view.getUint16(offset + 2, true) / 65535 * height]); offset += 4;
93
+ }
94
+ const average = view.getUint8(offset++) / 6;
95
+ if (average > 0) triangles.push({ points, average });
96
+ }
97
+ this.records.set(`${character}:${form}`, { triangles, advance, baseline, drawOffsetX, width, height });
98
+ }
99
+ // TMG5 stores the raster ascent and full canvas height for every glyph.
100
+ // Resolve one font-wide box on load, never from the current text line.
101
+ let ascent = 0, descent = 0;
102
+ for (const glyph of this.records.values()) {
103
+ ascent = Math.max(ascent, glyph.baseline);
104
+ descent = Math.max(descent, glyph.height - glyph.baseline);
105
+ }
106
+ this.fontMetrics = { ascent, descent, height: Math.max(1, ascent + descent) };
107
+ this.lineAdvance = Math.max(this.fontMetrics.height, this.fontMetrics.height * this.lineHeight);
108
+ return { glyphCount: this.records.size };
109
+ }
110
+
111
+ // Cache font-space metrics, so changes to scale do not invalidate shaping.
112
+ layoutLine(line, shaping = shapeLine(line)) {
113
+ const { logical, items: shaped } = shaping;
114
+ let meshWidth = 0;
115
+ const items = [];
116
+ for (const { ch, i, form, key, level, control } of shaped) {
117
+ if (control) continue;
118
+ const selected = this.records.get(`${key}:${form}`) || this.records.get(`${ch}:${form}`) || this.records.get(`${clusterBase(ch)}:${form}`);
119
+ const fallback = this.records.get(`${key}:0`) || this.records.get(`${ch}:0`) || this.records.get(`${clusterBase(ch)}:0`);
120
+ // Preserve the existing mesh centering and missing-glyph behavior.
121
+ meshWidth += selected?.advance || fallback?.advance || 0;
122
+ if (!isJoinControl(ch)) items.push({ ch, i, level, glyph: selected || fallback || this.records.get(' :0') });
123
+ }
124
+ const width = items.reduce((sum, item) => sum + (item.glyph?.advance || 0), 0);
125
+ const boundary = new Array(logical.length + 1);
126
+ let pen = -width / 2;
127
+ let meshPen = -meshWidth / 2;
128
+ for (const item of items) {
129
+ const advance = item.glyph?.advance || 0;
130
+ item.x = meshPen;
131
+ meshPen += advance;
132
+ if (item.level & 1) {
133
+ boundary[item.i] = pen + advance;
134
+ boundary[item.i + Array.from(item.ch).length] = pen;
135
+ } else {
136
+ boundary[item.i] = pen;
137
+ boundary[item.i + Array.from(item.ch).length] = pen + advance;
138
+ }
139
+ pen += advance;
140
+ }
141
+ for (let i = 0; i < boundary.length; i++) {
142
+ if (boundary[i] === undefined) boundary[i] = i ? boundary[i - 1] : -width / 2;
143
+ }
144
+ let utf16Offset = 0;
145
+ const utf16Offsets = logical.map(ch => { const at = utf16Offset; utf16Offset += ch.length; return at; });
146
+ utf16Offsets.push(utf16Offset);
147
+ const stops = Array.from(graphemes.segment(line), part => part.index);
148
+ stops.push(line.length);
149
+ const offsetMap = new Map(utf16Offsets.map((offset, index) => [offset, index]));
150
+ const carets = stops.map(index => ({ index, x: boundary[offsetMap.get(index)] }))
151
+ .sort((a, b) => a.x - b.x || a.index - b.index);
152
+ const caretEdges = new Map(), stopSet = new Set(stops);
153
+ const addEdge = (index, x, affinity) => {
154
+ if (!stopSet.has(index)) return;
155
+ const edges = caretEdges.get(index) || [];
156
+ if (!edges.some(edge => edge.x === x)) edges.push({ x, affinity });
157
+ caretEdges.set(index, edges);
158
+ };
159
+ for (const item of items) {
160
+ const left = item.x, right = left + (item.glyph?.advance || 0);
161
+ addEdge(utf16Offsets[item.i], item.level & 1 ? right : left, 'downstream');
162
+ addEdge(utf16Offsets[item.i + Array.from(item.ch).length], item.level & 1 ? left : right, 'upstream');
163
+ }
164
+ // Ordinary boundaries need no extra retained metadata.
165
+ for (const [index, edges] of caretEdges) if (edges.length < 2) caretEdges.delete(index);
166
+ return { text: line, items, boundary, utf16Offsets, carets, caretEdges, height: items.at(-1)?.glyph?.height || 80 };
167
+ }
168
+
169
+ layout(text) {
170
+ if (this.cachedLayout?.text === text) return this.cachedLayout;
171
+ const lines = text.split(/\r?\n/u);
172
+ const blockHeight = Math.max(0, (lines.length - 1) * this.lineAdvance);
173
+ const nextCache = new Map();
174
+ let offset = 0;
175
+ const entries = lines.map((line, index) => {
176
+ const metrics = nextCache.get(line) || this.lineCache.get(line) || this.layoutLine(line);
177
+ nextCache.set(line, metrics);
178
+ const entry = { metrics, offset, length: line.length,
179
+ baseline: 100 + (lines.length - 1 - index) * this.lineAdvance - blockHeight / 2 };
180
+ offset += line.length + 1;
181
+ return entry;
182
+ });
183
+ // Retain only lines in the current document, not the entire editing history.
184
+ this.lineCache = nextCache;
185
+ this.cachedLayout = { text, entries };
186
+ return this.cachedLayout;
187
+ }
188
+
189
+ // Geometry is local to the line; vertical placement is a renderer uniform.
190
+ buildLine(metrics) {
191
+ if (metrics.mesh?.scale === this.scale) return metrics.mesh;
192
+ const triangleCount = metrics.items.reduce((sum, { glyph }) =>
193
+ sum + (glyph?.meshData?.triangleCount ?? glyph?.triangles?.length ?? 0), 0);
194
+ const positions = new Float32Array(triangleCount * 9);
195
+ const colors = new Float32Array(triangleCount * 9);
196
+ const delays = new Float32Array(triangleCount * 3);
197
+ let vertex = 0;
198
+ let minY = Infinity, maxY = -Infinity;
199
+ const glyphRects = [];
200
+ for (const { ch: character, i, glyph, x } of metrics.items) {
201
+ if (!glyph) continue;
202
+ glyphRects.push({ x: (x - glyph.drawOffsetX) * this.scale, y: -glyph.height * this.scale,
203
+ width: glyph.width * this.scale, height: glyph.height * this.scale, character, index: metrics.utf16Offsets[i] });
204
+ const packed = glyph.meshData;
205
+ const count = packed ? packed.triangleCount : glyph.triangles.length;
206
+ for (let t = 0; t < count; t++) {
207
+ const delay = Math.random();
208
+ const average = packed ? packed.coverage[t] / 6 : glyph.triangles[t].average;
209
+ for (let point = 0; point < 3; point++) {
210
+ const px = packed ? packed.coordinates[t * 6 + point * 2] : glyph.triangles[t].points[point][0];
211
+ const py = packed ? packed.coordinates[t * 6 + point * 2 + 1] : glyph.triangles[t].points[point][1];
212
+ const y = -py * this.scale;
213
+ minY = Math.min(minY, y); maxY = Math.max(maxY, y);
214
+ const at = vertex * 3;
215
+ positions[at] = (x + px - glyph.drawOffsetX) * this.scale;
216
+ positions[at + 1] = y;
217
+ colors[at] = colors[at + 1] = colors[at + 2] = average;
218
+ delays[vertex++] = delay;
219
+ }
220
+ }
221
+ }
222
+ metrics.mesh = { scale: this.scale, positions, colors, delays, glyphRects,
223
+ triangleCount,
224
+ inkMinY: Number.isFinite(minY) ? minY : -80 * this.scale,
225
+ inkMaxY: Number.isFinite(maxY) ? maxY : 0 };
226
+ return metrics.mesh;
227
+ }
228
+
229
+ buildLines(text) {
230
+ return this.layout(text).entries.map(entry => ({ ...entry,
231
+ baseline: entry.baseline * this.scale, mesh: this.buildLine(entry.metrics) }));
232
+ }
233
+
234
+ selectionRects(text, start, end) {
235
+ if (start >= end) return [];
236
+ const rectangles = [];
237
+ for (const { metrics, offset, baseline } of this.layout(text).entries) {
238
+ // Ink bounds include overhangs; advance cells partition the visual line.
239
+ const { bottom, height } = this.fontLineBox(baseline);
240
+ let run = null;
241
+ for (const item of metrics.items) {
242
+ const index = offset + metrics.utf16Offsets[item.i];
243
+ const selected = index < end && index + item.ch.length > start;
244
+ const advance = item.glyph?.advance || 0;
245
+ if (!selected) { run = null; continue; }
246
+ if (advance <= 0) continue;
247
+ const left = item.x * this.scale;
248
+ const right = (item.x + advance) * this.scale;
249
+ if (run && Math.abs(run.x + run.width - left) < 1e-6) {
250
+ run.width = right - run.x;
251
+ } else {
252
+ run = { x: left, y: bottom, width: right - left, height };
253
+ rectangles.push(run);
254
+ }
255
+ }
256
+ }
257
+ return rectangles;
258
+ }
259
+
260
+ fontLineBox(rasterTop) {
261
+ const baseline = rasterTop - this.fontMetrics.ascent;
262
+ const { ascent, descent, height } = this.lineBoxMetrics || this.fontMetrics;
263
+ return { top: (baseline + ascent) * this.scale, bottom: (baseline - descent) * this.scale,
264
+ baseline: baseline * this.scale, height: height * this.scale,
265
+ ascent: ascent * this.scale, descent: descent * this.scale };
266
+ }
267
+
268
+ // DOM selectionStart/End count UTF-16 code units, unlike Array.from().
269
+ caretLocation(text, index) {
270
+ const entries = this.layout(text).entries;
271
+ const position = Math.max(0, Math.min(text.length, index));
272
+ let low = 0, high = entries.length - 1;
273
+ while (low < high) {
274
+ const middle = (low + high) >>> 1;
275
+ if (position > entries[middle].offset + entries[middle].length) low = middle + 1;
276
+ else high = middle;
277
+ }
278
+ const { metrics, offset, baseline } = entries[low];
279
+ const within = position - offset;
280
+ let char = 0, end = metrics.utf16Offsets.length - 1;
281
+ while (char < end) {
282
+ const middle = Math.ceil((char + end) / 2);
283
+ if (metrics.utf16Offsets[middle] <= within) char = middle;
284
+ else end = middle - 1;
285
+ }
286
+ return { metrics, char, within, baseline };
287
+ }
288
+
289
+ caretPositionNative(text, index, affinity) {
290
+ const { metrics, char, within, baseline } = this.caretLocation(text, index);
291
+ const edge = affinity && metrics.caretEdges?.get(within)?.find(edge => edge.affinity === affinity);
292
+ return { x: (edge ? edge.x : metrics.boundary[char]) * this.scale, ...this.fontLineBox(baseline) };
293
+ }
294
+
295
+ caretPositionsNative(text, index) {
296
+ const { metrics, char, within, baseline } = this.caretLocation(text, index);
297
+ const box = this.fontLineBox(baseline), primary = metrics.boundary[char] * this.scale;
298
+ const positions = [{ x: primary, ...box, affinity: null }];
299
+ for (const edge of metrics.caretEdges?.get(within) || []) {
300
+ const x = edge.x * this.scale;
301
+ if (!positions.some(p => Math.abs(p.x - x) < 1e-8)) positions.push({ x, ...box, affinity: edge.affinity });
302
+ }
303
+ return positions;
304
+ }
305
+
306
+ verticalCaret(text, index, delta, preferredX) {
307
+ const { entries } = this.layout(text);
308
+ let line = 0, last = entries.length - 1;
309
+ while (line < last) {
310
+ const middle = (line + last) >>> 1;
311
+ if (index > entries[middle].offset + entries[middle].length) line = middle + 1;
312
+ else last = middle;
313
+ }
314
+ const x = preferredX ?? this.caretPositionNative(text, index).x;
315
+ const target = line + delta;
316
+ if (target < 0 || target >= entries.length) return { index, x };
317
+ const entry = entries[target];
318
+ const carets = entry.metrics.carets;
319
+ let low = 0, high = carets.length - 1;
320
+ while (low < high) {
321
+ const middle = (low + high) >>> 1;
322
+ if (carets[middle].x * this.scale < x) low = middle + 1;
323
+ else high = middle;
324
+ }
325
+ let nearest = carets[low];
326
+ if (low && Math.abs(carets[low - 1].x * this.scale - x) <= Math.abs(nearest.x * this.scale - x)) nearest = carets[low - 1];
327
+ return { index: entry.offset + nearest.index, x };
328
+ }
329
+
330
+ build(text) {
331
+ const positions = [], colors = [], glyphRects = [];
332
+ for (const { metrics, offset, baseline } of this.layout(text).entries) {
333
+ for (const { ch: character, i, glyph, x } of metrics.items) {
334
+ if (!glyph) continue;
335
+ glyphRects.push({ x: (x - glyph.drawOffsetX) * this.scale, y: (baseline - glyph.height) * this.scale,
336
+ width: glyph.width * this.scale, height: glyph.height * this.scale, character, index: offset + i });
337
+ const packed = glyph.meshData;
338
+ const count = packed ? packed.triangleCount : glyph.triangles.length;
339
+ for (let t = 0; t < count; t++) {
340
+ const average = packed ? packed.coverage[t] / 6 : glyph.triangles[t].average;
341
+ for (let point = 0; point < 3; point++) {
342
+ const px = packed ? packed.coordinates[t * 6 + point * 2] : glyph.triangles[t].points[point][0];
343
+ const py = packed ? packed.coordinates[t * 6 + point * 2 + 1] : glyph.triangles[t].points[point][1];
344
+ positions.push((x + px - glyph.drawOffsetX) * this.scale, (baseline - py) * this.scale, 0);
345
+ colors.push(average, average, average);
346
+ }
347
+ }
348
+ }
349
+ }
350
+ return { positions: new Float32Array(positions), colors: new Float32Array(colors), glyphRects, triangleCount: positions.length / 9 };
351
+ }
352
+
353
+ caretPosition(text, index) {
354
+ const { entries } = this.layout(text);
355
+ const position = Math.max(0, index);
356
+ // Find the line without reshaping or scanning all preceding lines.
357
+ let low = 0, high = entries.length - 1;
358
+ while (low < high) {
359
+ const middle = (low + high) >>> 1;
360
+ if (position > entries[middle].offset + entries[middle].length) low = middle + 1;
361
+ else high = middle;
362
+ }
363
+ const { metrics, offset, baseline } = entries[low];
364
+ const caretInLine = Math.min(metrics.boundary.length - 1, position - offset);
365
+ return { x: metrics.boundary[caretInLine] * this.scale, baseline: baseline * this.scale,
366
+ height: Math.max(0.3, metrics.height * this.scale) };
367
+ }
368
+ }