@ev-ry/fx 0.1.0-rc.1 → 0.1.0-rc.2
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/QUICKSTART.fa.md +2 -2
- package/README.md +116 -116
- package/build-report.json +19 -19
- package/docs/GUIDE.md +120 -116
- package/docs/RELEASE-NOTES.md +52 -40
- package/package.json +1 -1
- package/src/dom-image-raster.js +14 -0
- package/src/dom-rich-text.js +133 -116
- package/src/dom-surface-font.js +4 -4
- package/src/dom-text-fingerprint.js +27 -27
- package/src/dom-text-surface.js +261 -248
- package/src/font-rasterizer.js +18 -7
- package/src/image-surface.js +3 -2
- package/src/text-scene.js +41 -39
package/docs/RELEASE-NOTES.md
CHANGED
|
@@ -1,40 +1,52 @@
|
|
|
1
|
-
# EV-RY FX Free 0.1.0-rc.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
1
|
+
# EV-RY FX Free 0.1.0-rc.2
|
|
2
|
+
|
|
3
|
+
- Fix collapsed whitespace at wrapped line ends triggering native fallback, including Android headings with negative letter spacing.
|
|
4
|
+
- Respect DOM font kerning and text-rendering settings; include them in cache identity and invalidation.
|
|
5
|
+
- Normalize high-resolution raster advances using the actual display font size. Physical iOS width parity remains to be confirmed.
|
|
6
|
+
- Smooth native-rest handoff: native text fades in over 350ms starting 175ms before entry ends; the mesh fades out over the following 350ms. Reduced motion skips this transition.
|
|
7
|
+
- Tie the handoff to the original effect clock to avoid restarting on delayed frames.
|
|
8
|
+
|
|
9
|
+
No Free API removal or effect expansion. The marketing site's SPA router, loader and staged headlines are site behavior, not package exports.
|
|
10
|
+
|
|
11
|
+
## Previous release
|
|
12
|
+
|
|
13
|
+
# EV-RY FX Free 0.1.0-rc.1
|
|
14
|
+
|
|
15
|
+
Public preview release candidate from EV-RY. The Free edition uses the [MIT license](../LICENSE). Package: `@ev-ry/fx`. Try the [live demo](https://kbaghini.github.io/evry-fx/docs/) or browse the [source and examples](https://github.com/kbaghini/evry-fx).
|
|
16
|
+
|
|
17
|
+
The product is now EV-RY FX. Existing `THDFree`, `data-thd-*`, `createFree`, `loadFree` and `src/dom-*` names remain compatible. No behavior or API rename is implied by the branding change.
|
|
18
|
+
|
|
19
|
+
## Included
|
|
20
|
+
|
|
21
|
+
- Automatic intersection reveal for headings, marked display text and marked images.
|
|
22
|
+
- Manual text, image and supported static SVG attachment, plus simple image swaps.
|
|
23
|
+
- Four fixed presets: dust wind / smoke for text, drifting snow / melt for media. Textured triangular particles, a 2000ms timeline and native resting presentation.
|
|
24
|
+
- Classic script loading, explicit `loadFree()` ES-module initialization, TypeScript declarations and integration examples.
|
|
25
|
+
- Document-connected shared canvas with conservative local routing for special layouts.
|
|
26
|
+
|
|
27
|
+
## Reliability work
|
|
28
|
+
|
|
29
|
+
- Early opt-in masking prevents native content flashing before initial reveal, while preserving layout and providing failure recovery.
|
|
30
|
+
- Wrapped headings and ordinary HTML whitespace use browser-measured placement. Initially empty or recoverable unsupported text can resume after valid content/layout and refresh.
|
|
31
|
+
- Reentry during an unfinished reveal preserves its clock. Completed once-only entries are not restarted by minor scrolling or presentation updates.
|
|
32
|
+
- Unsupported content returns to native rendering instead of repeatedly retrying known failures. Repeated local refresh preserves valid attachments.
|
|
33
|
+
- Font loading invalidates shared pixels and refreshes rich-text meshes even when the CSS font name and line metrics stay unchanged.
|
|
34
|
+
- Automatic loader ownership handles browser back/forward-cache returns; manual owners retain explicit lifecycle responsibility.
|
|
35
|
+
- `FreeSurface.cancel()` is required in the declarations, matching the runtime API. The module loader has its own typed export.
|
|
36
|
+
|
|
37
|
+
## Performance work
|
|
38
|
+
|
|
39
|
+
- Validated, unchanged text reuses its layout before the expensive per-character placement pass. Character-frame data is collected only when the selected entry/exit effect needs it.
|
|
40
|
+
- Display-text raster pixels use a document-scoped cache capped at 16MiB. This is a cache limit, not total page or GPU memory.
|
|
41
|
+
- Offscreen images prepare near the viewport. Internal image preparation avoids an intermediate PNG encode/decode, and rapid resize requests are coalesced.
|
|
42
|
+
- Geometry settings, visual quality, preset choices and effect duration are preserved. These changes remove redundant work; they do not promise a universal FPS improvement or eliminate all cold WebGL startup cost.
|
|
43
|
+
|
|
44
|
+
## Validation and limits
|
|
45
|
+
|
|
46
|
+
Chrome desktop regression checks cover actual entry/exit motion, rapid reentry, plain/rich text rebuilding, whitespace and Persian mixed-text cases, fallback recovery, lifecycle cleanup and cache invalidation. Strict TypeScript consumer checks cover source and packaged declarations, cancellation, module-loader exports and rejected Free-only configuration.
|
|
47
|
+
|
|
48
|
+
Earlier document-canvas scrolling tests on physical iPhone and Android devices were reported successful by the user. That is not fresh physical-device acceptance of every optimization in this candidate, nor a guarantee for all browsers, GPUs or websites.
|
|
49
|
+
|
|
50
|
+
The Free build retains native fallback for unsupported content. Complete CSS stacking/clipping reproduction, arbitrary SVG, editable controls, custom effect recipes and dedicated framework component packs are outside this release. Cross-origin media must satisfy browser canvas restrictions. Review the [guide](GUIDE.md) before integration.
|
|
51
|
+
|
|
52
|
+
[Back to the overview](../README.md)
|
package/package.json
CHANGED
package/src/dom-image-raster.js
CHANGED
|
@@ -13,6 +13,20 @@ export async function rasterizeDOMImage(element,{maxSide=2048}={}){
|
|
|
13
13
|
return {x:read(parts[0],box.width),y:read(parts[1]??parts[0],box.height)};
|
|
14
14
|
});
|
|
15
15
|
context.beginPath();context.roundRect(0,0,box.width,box.height,radii);context.clip();
|
|
16
|
+
// Bake ancestor overflow masks into the texture; moving particles keep the
|
|
17
|
+
// final image silhouette rather than snapping to a rounded frame at rest.
|
|
18
|
+
for(let parent=element.parentElement;parent;parent=parent.parentElement){
|
|
19
|
+
const style=doc.defaultView.getComputedStyle(parent);
|
|
20
|
+
if(!['hidden','clip','auto','scroll'].includes(style.overflowX)||!['hidden','clip','auto','scroll'].includes(style.overflowY))continue;
|
|
21
|
+
const rect=parent.getBoundingClientRect();
|
|
22
|
+
if(style.transform!=='none')continue;
|
|
23
|
+
const left=parseFloat(style.borderLeftWidth)||0,top=parseFloat(style.borderTopWidth)||0;
|
|
24
|
+
const right=parseFloat(style.borderRightWidth)||0,bottom=parseFloat(style.borderBottomWidth)||0;
|
|
25
|
+
const corners=['borderTopLeftRadius','borderTopRightRadius','borderBottomRightRadius','borderBottomLeftRadius'];
|
|
26
|
+
const borders=[[left,top],[right,top],[right,bottom],[left,bottom]];
|
|
27
|
+
const clipRadii=corners.map((key,i)=>{const parts=style[key].split(/\s+/);const read=(s,n)=>parseFloat(s)*(s.endsWith('%')?n/100:1);return {x:Math.max(0,read(parts[0],rect.width)-borders[i][0]),y:Math.max(0,read(parts[1]??parts[0],rect.height)-borders[i][1])};});
|
|
28
|
+
context.beginPath();context.roundRect(rect.left-box.left+left,rect.top-box.top+top,Math.max(0,rect.width-left-right),Math.max(0,rect.height-top-bottom),clipRadii);context.clip();
|
|
29
|
+
}
|
|
16
30
|
const factor=css.objectFit==='cover'?Math.max(box.width/element.naturalWidth,box.height/element.naturalHeight):Math.min(box.width/element.naturalWidth,box.height/element.naturalHeight);
|
|
17
31
|
const w=css.objectFit==='fill'?box.width:element.naturalWidth*factor,h=css.objectFit==='fill'?box.height:element.naturalHeight*factor;
|
|
18
32
|
context.drawImage(element,(box.width-w)/2,(box.height-h)/2,w,h);
|
package/src/dom-rich-text.js
CHANGED
|
@@ -1,119 +1,136 @@
|
|
|
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 {adoptDOMFont,domFontAtSize} from './dom-surface-font.js';
|
|
5
|
-
import {groupDOMTextNodes,setRunRange,prepareFontBoundaries} from './dom-text-runs.js';
|
|
6
|
-
|
|
7
|
-
// One scene, independent styled text runs. DOM Range supplies line placement;
|
|
8
|
-
// the browser remains responsible for wrapping, links and label semantics.
|
|
9
|
-
export class DOMRichText {
|
|
10
|
-
constructor(element,THREE,content,canvas,options){Object.assign(this,{element,THREE,content,canvas,options});this.runs=new Map();this.styles=new Map();this.disposed=false;}
|
|
11
|
-
restore(){for(const [node,values] of this.styles)for(const [name,[original,priority,owned]] of values)if(node.style.getPropertyValue(name)===owned){if(original)node.style.setProperty(name,original,priority);else node.style.removeProperty(name);}this.styles.clear();}
|
|
12
|
-
hide(){for(const run of this.runs.values())for(const text of run.nodes)for(const [name,value] of [['-webkit-text-fill-color','transparent'],['text-shadow','none']]){
|
|
13
|
-
const node=text.parentElement;let values=this.styles.get(node);if(!values){values=new Map();this.styles.set(node,values);}
|
|
14
|
-
if(!values.has(name))values.set(name,[node.style.getPropertyValue(name),node.style.getPropertyPriority(name),value]);
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if(
|
|
29
|
-
|
|
30
|
-
if(!(
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
run.
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if(
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if(
|
|
61
|
-
|
|
62
|
-
if(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
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 {adoptDOMFont,domFontAtSize} from './dom-surface-font.js';
|
|
5
|
+
import {groupDOMTextNodes,setRunRange,prepareFontBoundaries} from './dom-text-runs.js';
|
|
6
|
+
|
|
7
|
+
// One scene, independent styled text runs. DOM Range supplies line placement;
|
|
8
|
+
// the browser remains responsible for wrapping, links and label semantics.
|
|
9
|
+
export class DOMRichText {
|
|
10
|
+
constructor(element,THREE,content,canvas,options){Object.assign(this,{element,THREE,content,canvas,options});this.runs=new Map();this.styles=new Map();this.disposed=false;}
|
|
11
|
+
restore(){for(const [node,values] of this.styles)for(const [name,[original,priority,owned]] of values)if(node.style.getPropertyValue(name)===owned){if(original)node.style.setProperty(name,original,priority);else node.style.removeProperty(name);}this.styles.clear();}
|
|
12
|
+
hide(alpha=0){for(const run of this.runs.values())for(const text of run.nodes)for(const [name,value] of [['-webkit-text-fill-color',alpha>0?`color-mix(in srgb, ${text.parentElement.ownerDocument.defaultView.getComputedStyle(text.parentElement).color} ${alpha*100}%, transparent)`:'transparent'],['text-shadow','none']]){
|
|
13
|
+
const node=text.parentElement;let values=this.styles.get(node);if(!values){values=new Map();this.styles.set(node,values);}
|
|
14
|
+
if(!values.has(name))values.set(name,[node.style.getPropertyValue(name),node.style.getPropertyPriority(name),value]);
|
|
15
|
+
values.get(name)[2]=value;
|
|
16
|
+
if(node.style.getPropertyValue(name)!==value)node.style.setProperty(name,value);
|
|
17
|
+
}}
|
|
18
|
+
setPresentationOpacity(value){for(const run of this.runs.values())run.view.uniforms.presentationOpacity.value=value;}
|
|
19
|
+
cancel(){for(const r of this.runs.values())r.effect.cancel();}
|
|
20
|
+
drop(run){run.effect.dispose();run.view.dispose();run.engine.dispose();run.group.removeFromParent();}
|
|
21
|
+
dispose(){this.disposed=true;this.restore();for(const r of this.runs.values())this.drop(r);this.runs.clear();}
|
|
22
|
+
async prepare(cancelled,onRebuild=()=>{}){
|
|
23
|
+
const {element,canvas,THREE}=this,document=element.ownerDocument,window=document.defaultView;
|
|
24
|
+
if(element.isContentEditable||element.matches('input,textarea,select,img,svg'))return {reason:'Rich attachment is for display text only'};
|
|
25
|
+
const raw=[],walker=document.createTreeWalker(element,window.NodeFilter.SHOW_TEXT);
|
|
26
|
+
while(walker.nextNode()){const node=walker.currentNode;if(!canvas.contains(node)&&node.data.trim())raw.push(node);}
|
|
27
|
+
const nodes=groupDOMTextNodes(raw,document);
|
|
28
|
+
if([...element.querySelectorAll('input,textarea,select,img,svg,canvas,[contenteditable="true"]')].some(node=>node!==canvas&&!canvas.contains(node)))return {reason:'Rich text currently accepts text elements only'};
|
|
29
|
+
// Shaping across styled-node boundaries needs a shared shaping context.
|
|
30
|
+
if(!prepareFontBoundaries(nodes,document))return {reason:'Unsupported joining boundary uses native shaping'};
|
|
31
|
+
this.shapingMismatch=null;
|
|
32
|
+
const box=element.getBoundingClientRect(),width=element.clientWidth||box.width,height=element.clientHeight||box.height;
|
|
33
|
+
if(!(width>0&&height>0))return {reason:'Text is not visible'};
|
|
34
|
+
const wanted=new Set(nodes.map(n=>n.first));for(const [node,run] of this.runs)if(!wanted.has(node)){this.restore();this.drop(run);this.runs.delete(node);}
|
|
35
|
+
const segmenter=new Intl.Segmenter(undefined,{granularity:'grapheme'});
|
|
36
|
+
for(const node of nodes){
|
|
37
|
+
if(cancelled()||this.disposed)return null;
|
|
38
|
+
const parent=node.parentElement,style=window.getComputedStyle(parent),size=parseFloat(style.fontSize);
|
|
39
|
+
const collapseWhitespace=['normal','nowrap'].includes(style.whiteSpace);
|
|
40
|
+
if(!(size>0)||style.writingMode!=='horizontal-tb'||style.textTransform!=='none'||style.textAlign==='justify'||node.data.includes('\t')&&!collapseWhitespace)return {reason:'Unsupported rich typography uses native text'};
|
|
41
|
+
for(let ancestor=parent;ancestor;ancestor=ancestor.parentElement){
|
|
42
|
+
const css=window.getComputedStyle(ancestor);
|
|
43
|
+
for(const pseudo of ['::before','::after']){const p=window.getComputedStyle(ancestor,pseudo);if(p.display!=='none'&&!['none','normal','""',"''"].includes(p.content))return {reason:'Generated content uses native text'};}
|
|
44
|
+
if(ancestor!==element&&(css.transform!=='none'||css.opacity!=='1'||css.overflowX!=='visible'||css.overflowY!=='visible'))return {reason:'Nested transformed, faded or clipped text uses native text'};
|
|
45
|
+
if(ancestor===element)break;
|
|
46
|
+
}
|
|
47
|
+
let run=this.runs.get(node.first);
|
|
48
|
+
if(!run){const group=new THREE.Group();this.content.add(group);const engine=new HybridTextFlowEngine({scale:.036,divisions:this.options.divisions??'auto',textRendering:'texture'});
|
|
49
|
+
run={node:node.first,group,engine,view:new SharedTextScene(THREE,engine,group),effect:new TextEditEffect(THREE,{mode:'dust-wind'}),key:null};this.runs.set(node.first,run);}
|
|
50
|
+
run.nodes=node.nodes;
|
|
51
|
+
const fontChanged=await adoptDOMFont(run.engine,parent);
|
|
52
|
+
// A newly loaded font can change glyph pixels without changing CSS names,
|
|
53
|
+
// advances or line boxes. Rebind the run to the refreshed raster source.
|
|
54
|
+
if(fontChanged)run.key=null;
|
|
55
|
+
if(cancelled()||this.disposed)return null;
|
|
56
|
+
const factor=size/200,context=run.engine.rasterizer.context;context.font=domFontAtSize(run.engine,size);context.direction=style.direction;
|
|
57
|
+
const spacing=[style.letterSpacing,style.wordSpacing].map(value=>value==='normal'?0:parseFloat(value));
|
|
58
|
+
if(spacing.some(value=>!Number.isFinite(value))||spacing.some((value,i)=>value!==0&&!(['letterSpacing','wordSpacing'][i] in context)))return {reason:'Canvas spacing unavailable; native text retained'};
|
|
59
|
+
const spacingKey=JSON.stringify(spacing.map(value=>value/factor));
|
|
60
|
+
if(run.spacingKey!==spacingKey){run.engine.clearMeshes();run.engine.rasterizer.cache.clear();run.key=null;run.spacingKey=spacingKey;}
|
|
61
|
+
run.engine.rasterizer.letterSpacing=spacing[0]/factor;run.engine.rasterizer.wordSpacing=spacing[1]/factor;
|
|
62
|
+
if('letterSpacing' in context)context.letterSpacing=`${spacing[0]}px`;
|
|
63
|
+
if('wordSpacing' in context)context.wordSpacing=`${spacing[1]}px`;
|
|
64
|
+
const metrics=context.measureText('Hgآی'),rows=[],characters=[];let row=null;
|
|
65
|
+
if(!Number.isFinite(metrics.fontBoundingBoxAscent))return {reason:'Native font metrics unavailable'};
|
|
66
|
+
for(const part of segmenter.segment(node.data)){
|
|
67
|
+
const range=document.createRange();setRunRange(range,node,part.index,part.index+part.segment.length);
|
|
68
|
+
const rect=range.getBoundingClientRect();if(!rect.width||!rect.height)continue;
|
|
69
|
+
const top=rect.top-box.top-element.clientTop;
|
|
70
|
+
if(!row||Math.abs(row.top-top)>.75){row={start:part.index,end:part.index,top,left:Infinity,right:-Infinity,direction:style.direction,baseline:top+(rect.height+metrics.fontBoundingBoxAscent-metrics.fontBoundingBoxDescent)/2};rows.push(row);}
|
|
71
|
+
row.end=part.index+part.segment.length;row.left=Math.min(row.left,rect.left-box.left-element.clientLeft);row.right=Math.max(row.right,rect.right-box.left-element.clientLeft);
|
|
72
|
+
const unit=run.engine.scale/factor;
|
|
73
|
+
characters.push({x:(rect.left-box.left-element.clientLeft)*unit,y:-(row.baseline+metrics.fontBoundingBoxDescent)*unit,width:rect.width*unit,height:(metrics.fontBoundingBoxAscent+metrics.fontBoundingBoxDescent)*unit,direction:style.direction});
|
|
74
|
+
}
|
|
75
|
+
for(const r of rows){
|
|
76
|
+
// Preserve original offsets for DOM Range/color mapping; normalize only
|
|
77
|
+
// the raster string when CSS collapses ASCII whitespace. NBSP and
|
|
78
|
+
// preformatted spacing are deliberately untouched.
|
|
79
|
+
// Collapsible whitespace at a visual line end has no native advance.
|
|
80
|
+
// Range may still report a rectangle for it (notably with negative
|
|
81
|
+
// letter-spacing on Android), so exclude it before canvas measurement.
|
|
82
|
+
if(collapseWhitespace&&(r.end<node.data.length||node===nodes.at(-1))){
|
|
83
|
+
while(r.end>r.start&&/[ \t\r\n\f]/.test(node.data[r.end-1]))r.end--;
|
|
84
|
+
if(r.end>r.start){
|
|
85
|
+
const range=document.createRange();setRunRange(range,node,r.start,r.end);
|
|
86
|
+
const rects=[...range.getClientRects()].filter(rect=>rect.height>0);
|
|
87
|
+
if(rects.length){r.left=Math.min(...rects.map(rect=>rect.left))-box.left-element.clientLeft;r.right=Math.max(...rects.map(rect=>rect.right))-box.left-element.clientLeft;}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let text=node.data.slice(r.start,r.end);
|
|
91
|
+
if(collapseWhitespace)text=text.replace(/[ \t\r\n\f]+/g,' ');
|
|
92
|
+
r.text=(r.start===0&&node.joinStart?'\u200d':'')+text+(r.end===node.data.length&&node.joinEnd?'\u200d':'');
|
|
93
|
+
const expected=context.measureText(r.text).width;if(Math.abs(expected-(r.right-r.left))>Math.max(1,expected*.015)){
|
|
94
|
+
this.shapingMismatch={text:r.text,canvasWidth:expected,domWidth:r.right-r.left,font:context.font,letterSpacing:context.letterSpacing,wordSpacing:context.wordSpacing,kerning:context.fontKerning,textRendering:context.textRendering};
|
|
95
|
+
return {reason:'Rich run shaping differs from DOM; native text retained'};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
run.engine.rasterizer.displayFontSize=size;run.engine.rasterizer.configure();const layout={rows,factor};
|
|
99
|
+
const paints=node.nodes.map(text=>({text,color:window.getComputedStyle(text.parentElement).color}));
|
|
100
|
+
const multicolor=new Set(paints.map(p=>p.color)).size>1;
|
|
101
|
+
const key=JSON.stringify([node.data,size,run.engine.domFont.key,rows,paints.map(p=>p.color)]);
|
|
102
|
+
if(key!==run.key){onRebuild();run.effect.cancel();run.engine.setDisplayFontSize(size);await run.engine.prepareRows(layout,{cancelled});if(cancelled()||this.disposed)return null;
|
|
103
|
+
if(multicolor)for(let i=0;i<rows.length;i++){
|
|
104
|
+
const row=rows[i],entry=run.engine.flowLayout.entries[i],item=entry.metrics.items[0],glyph=item.glyph;
|
|
105
|
+
const spans=[];let offset=0;
|
|
106
|
+
for(const paint of paints){const start=Math.max(row.start,offset),end=Math.min(row.end,offset+paint.text.length);offset+=paint.text.length;if(end<=start)continue;
|
|
107
|
+
const range=document.createRange();setRunRange(range,node,start,end);
|
|
108
|
+
const color=new THREE.Color().setStyle(paint.color).convertLinearToSRGB();
|
|
109
|
+
for(const rect of range.getClientRects())if(Math.abs(rect.top-box.top-element.clientTop-row.top)<.75)spans.push({left:rect.left-box.left-element.clientLeft,right:rect.right-box.left-element.clientLeft,color});
|
|
110
|
+
}
|
|
111
|
+
const source=glyph.rasterSurface,rgba=new Uint8ClampedArray(source.rgba);
|
|
112
|
+
for(let x=0;x<source.width;x++){
|
|
113
|
+
const px=row.left+(x+.5-glyph.drawOffsetX)*factor;
|
|
114
|
+
let nearest=null,distance=Infinity;for(const span of spans){const d=Math.max(span.left-px,px-span.right,0);if(d<distance){distance=d;nearest=span;}}
|
|
115
|
+
if(nearest)for(let y=0;y<source.height;y++){const p=(y*source.width+x)*4;rgba[p]=Math.round(nearest.color.r*255);rgba[p+1]=Math.round(nearest.color.g*255);rgba[p+2]=Math.round(nearest.color.b*255);}
|
|
116
|
+
}
|
|
117
|
+
// A presentation-owned copy preserves the engine's reusable alpha raster.
|
|
118
|
+
item.glyph={...glyph,rasterSurface:{...source,rgba,mask:false}};
|
|
119
|
+
}
|
|
120
|
+
run.view.setText(node.data);run.key=key;}
|
|
121
|
+
run.group.scale.setScalar(factor/run.engine.scale);run.view.uniforms.tint.value.setStyle(multicolor?'white':style.color);run.size=size;run.characters=characters;
|
|
122
|
+
}
|
|
123
|
+
return {width,height,size:parseFloat(window.getComputedStyle(element).fontSize),factor:.036,text:nodes.map(n=>n.data).join(''),direction:window.getComputedStyle(element).direction,characters:[]};
|
|
124
|
+
}
|
|
125
|
+
play(phase,mode,settings,seed,now){for(const run of this.runs.values()){
|
|
126
|
+
run.effect.cancel();if(mode==='none')continue;
|
|
127
|
+
run.effect.setMode(phase==='exit'&&settings.exitEffect!=='same'?settings.exitEffect:mode);run.effect.configure(settings);
|
|
128
|
+
const b=run.view.bounds;run.effect.playRegion(run.view,{x:b.minX-.001,y:b.minY-.001,width:b.maxX-b.minX+.002,height:b.maxY-b.minY+.002,direction:run.engine.domFont?run.node.parentElement.ownerDocument.defaultView.getComputedStyle(run.node.parentElement).direction:'ltr'},200*run.engine.scale,now,{departing:phase==='exit',seed});
|
|
129
|
+
run.effect.prepareCharacterCenters(run.view,()=>run.characters);
|
|
130
|
+
}}
|
|
131
|
+
step(now,reduced){let active=false;for(const run of this.runs.values())active=run.effect.step(now,reduced)||active;return active;}
|
|
132
|
+
stats(){return {shapingMismatch:this.shapingMismatch??null,runs:this.runs.size,triangles:[...this.runs.values()].reduce((n,r)=>n+(r.view.triangleCount??0),0),active:[...this.runs.values()].some(r=>r.effect.active)};}
|
|
133
|
+
}
|
|
117
134
|
|
|
118
135
|
|
|
119
136
|
|
package/src/dom-surface-font.js
CHANGED
|
@@ -4,8 +4,8 @@ import {domRasterCache} from './dom-raster-cache.js';
|
|
|
4
4
|
const owners=new WeakMap();
|
|
5
5
|
export function readDOMFont(element){
|
|
6
6
|
const style=element.ownerDocument.defaultView.getComputedStyle(element);
|
|
7
|
-
const font={family:style.fontFamily,weight:Number(style.fontWeight)||400,style:style.fontStyle};
|
|
8
|
-
font.key=JSON.stringify([font.family,font.weight,font.style]);return font;
|
|
7
|
+
const font={family:style.fontFamily,weight:Number(style.fontWeight)||400,style:style.fontStyle,kerning:style.fontKerning,textRendering:style.textRendering};
|
|
8
|
+
font.key=JSON.stringify([font.family,font.weight,font.style,font.kerning,font.textRendering]);return font;
|
|
9
9
|
}
|
|
10
10
|
export function domFontAtSize(engine,size){
|
|
11
11
|
const font=engine.domFont;
|
|
@@ -34,12 +34,12 @@ export async function adoptDOMFont(engine,element){
|
|
|
34
34
|
rasterizer.canvas=document.createElement('canvas');
|
|
35
35
|
rasterizer.context=rasterizer.canvas.getContext('2d',{willReadFrequently:true});
|
|
36
36
|
if(!rasterizer.context)throw Error('Canvas font rasterization unavailable');
|
|
37
|
-
rasterizer.font=`${next.style} ${next.weight} 200px ${next.family}`;rasterizer.cache=new Map();rasterizer.configure();
|
|
37
|
+
rasterizer.font=`${next.style} ${next.weight} 200px ${next.family}`;rasterizer.cache=new Map();rasterizer.fontKerning=next.kerning;rasterizer.textRendering=next.textRendering;rasterizer.configure();
|
|
38
38
|
const metric=rasterizer.context.measureText('آبپچگژهمیABCgj');
|
|
39
39
|
rasterizer.ascent=Math.ceil(Math.max(metric.fontBoundingBoxAscent||0,metric.actualBoundingBoxAscent||0));
|
|
40
40
|
rasterizer.descent=Math.ceil(Math.max(metric.fontBoundingBoxDescent||0,metric.actualBoundingBoxDescent||0));
|
|
41
41
|
rasterizer.raster=function(text,direction='ltr'){
|
|
42
|
-
const key=JSON.stringify([this.font,this.ascent,this.descent,this.letterSpacing||0,this.wordSpacing||0,direction,text]);
|
|
42
|
+
const key=JSON.stringify([this.font,this.displayFontSize,this.fontKerning,this.textRendering,this.ascent,this.descent,this.letterSpacing||0,this.wordSpacing||0,direction,text]);
|
|
43
43
|
return shared.raster(key,()=>{this.cache.clear();return FontRasterizer.prototype.raster.call(this,text,direction);});
|
|
44
44
|
};
|
|
45
45
|
owner.epoch=shared.epoch;
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
// Validate native layout with whole-node line boxes before the expensive
|
|
2
|
-
// per-grapheme placement pass. Coordinates are relative to the slot, so page
|
|
3
|
-
// scrolling does not invalidate text geometry. Node identity matters: identical
|
|
4
|
-
// replacement markup still needs fresh run references.
|
|
5
|
-
const fields=['display','visibility','fontFamily','fontSize','fontWeight','fontStyle','fontStretch','fontFeatureSettings','fontVariationSettings','fontKerning','fontVariant','fontOpticalSizing','lineHeight','direction','unicodeBidi','whiteSpace','letterSpacing','wordSpacing','verticalAlign','textAlign','textIndent','textTransform','textDecorationLine','writingMode','textOrientation','wordBreak','overflowWrap','hyphens','tabSize','opacity','transform','overflowX','overflowY','color'];
|
|
6
|
-
export function createDOMTextFingerprint(element,canvas){
|
|
7
|
-
const document=element.ownerDocument,window=document.defaultView,ids=new WeakMap();let sequence=0;
|
|
8
|
-
const id=node=>{if(!ids.has(node))ids.set(node,++sequence);return ids.get(node);};
|
|
9
|
-
return ()=>{
|
|
10
|
-
const box=element.getBoundingClientRect(),left=box.left+element.clientLeft,top=box.top+element.clientTop;
|
|
11
|
-
const parts=[element.isConnected,element.clientWidth||box.width,element.clientHeight||box.height,element.clientLeft,element.clientTop];
|
|
12
|
-
function visit(node){
|
|
13
|
-
if(node===canvas)return;
|
|
14
|
-
if(node.nodeType===3){
|
|
15
|
-
const range=document.createRange();range.selectNodeContents(node);
|
|
16
|
-
parts.push(['text',id(node),node.data,[...range.getClientRects()].map(r=>[r.left-left,r.top-top,r.width,r.height])]);return;
|
|
17
|
-
}
|
|
18
|
-
if(node.nodeType!==1)return;
|
|
19
|
-
const style=window.getComputedStyle(node);
|
|
20
|
-
parts.push(['element',id(node),node.localName,node.isContentEditable,fields.map(name=>style[name])]);
|
|
21
|
-
for(const pseudo of ['::before','::after']){const css=window.getComputedStyle(node,pseudo);parts.push([css.display,css.content]);}
|
|
22
|
-
for(const child of node.childNodes)visit(child);
|
|
23
|
-
parts.push('end');
|
|
24
|
-
}
|
|
25
|
-
visit(element);return JSON.stringify(parts);
|
|
26
|
-
};
|
|
27
|
-
}
|
|
1
|
+
// Validate native layout with whole-node line boxes before the expensive
|
|
2
|
+
// per-grapheme placement pass. Coordinates are relative to the slot, so page
|
|
3
|
+
// scrolling does not invalidate text geometry. Node identity matters: identical
|
|
4
|
+
// replacement markup still needs fresh run references.
|
|
5
|
+
const fields=['display','visibility','fontFamily','fontSize','fontWeight','fontStyle','fontStretch','fontFeatureSettings','fontVariationSettings','fontKerning','textRendering','fontVariant','fontOpticalSizing','lineHeight','direction','unicodeBidi','whiteSpace','letterSpacing','wordSpacing','verticalAlign','textAlign','textIndent','textTransform','textDecorationLine','writingMode','textOrientation','wordBreak','overflowWrap','hyphens','tabSize','opacity','transform','overflowX','overflowY','color'];
|
|
6
|
+
export function createDOMTextFingerprint(element,canvas){
|
|
7
|
+
const document=element.ownerDocument,window=document.defaultView,ids=new WeakMap();let sequence=0;
|
|
8
|
+
const id=node=>{if(!ids.has(node))ids.set(node,++sequence);return ids.get(node);};
|
|
9
|
+
return ()=>{
|
|
10
|
+
const box=element.getBoundingClientRect(),left=box.left+element.clientLeft,top=box.top+element.clientTop;
|
|
11
|
+
const parts=[element.isConnected,element.clientWidth||box.width,element.clientHeight||box.height,element.clientLeft,element.clientTop];
|
|
12
|
+
function visit(node){
|
|
13
|
+
if(node===canvas)return;
|
|
14
|
+
if(node.nodeType===3){
|
|
15
|
+
const range=document.createRange();range.selectNodeContents(node);
|
|
16
|
+
parts.push(['text',id(node),node.data,[...range.getClientRects()].map(r=>[r.left-left,r.top-top,r.width,r.height])]);return;
|
|
17
|
+
}
|
|
18
|
+
if(node.nodeType!==1)return;
|
|
19
|
+
const style=window.getComputedStyle(node);
|
|
20
|
+
parts.push(['element',id(node),node.localName,node.isContentEditable,fields.map(name=>style[name])]);
|
|
21
|
+
for(const pseudo of ['::before','::after']){const css=window.getComputedStyle(node,pseudo);parts.push([css.display,css.content]);}
|
|
22
|
+
for(const child of node.childNodes)visit(child);
|
|
23
|
+
parts.push('end');
|
|
24
|
+
}
|
|
25
|
+
visit(element);return JSON.stringify(parts);
|
|
26
|
+
};
|
|
27
|
+
}
|