3984 lines
191 KiB
JavaScript
3984 lines
191 KiB
JavaScript
import { Texture, LinearFilter, Color, InstancedBufferGeometry, Sphere, Box3, InstancedBufferAttribute, PlaneGeometry, Vector2, Vector4, Matrix3, Mesh, MeshBasicMaterial, DoubleSide, Matrix4, Vector3, DataTexture, RGBAFormat, FloatType, DynamicDrawUsage } from 'three';
|
|
import { defineWorkerModule, terminateWorker } from 'troika-worker-utils';
|
|
import createSDFGenerator from 'webgl-sdf-generator';
|
|
import bidiFactory from 'bidi-js';
|
|
import { createDerivedMaterial, voidMainRegExp } from 'troika-three-utils';
|
|
|
|
/*!
|
|
Custom build of Typr.ts (https://github.com/fredli74/Typr.ts) for use in Troika text rendering.
|
|
Original MIT license applies: https://github.com/fredli74/Typr.ts/blob/master/LICENSE
|
|
*/
|
|
function typrFactory(){return "undefined"==typeof window&&(self.window=self),function(r){var e={parse:function(r){var t=e._bin,a=new Uint8Array(r);if("ttcf"==t.readASCII(a,0,4)){var n=4;t.readUshort(a,n),n+=2,t.readUshort(a,n),n+=2;var o=t.readUint(a,n);n+=4;for(var s=[],i=0;i<o;i++){var h=t.readUint(a,n);n+=4,s.push(e._readFont(a,h));}return s}return [e._readFont(a,0)]},_readFont:function(r,t){var a=e._bin,n=t;a.readFixed(r,t),t+=4;var o=a.readUshort(r,t);t+=2,a.readUshort(r,t),t+=2,a.readUshort(r,t),t+=2,a.readUshort(r,t),t+=2;for(var s=["cmap","head","hhea","maxp","hmtx","name","OS/2","post","loca","glyf","kern","CFF ","GDEF","GPOS","GSUB","SVG "],i={_data:r,_offset:n},h={},d=0;d<o;d++){var f=a.readASCII(r,t,4);t+=4,a.readUint(r,t),t+=4;var u=a.readUint(r,t);t+=4;var l=a.readUint(r,t);t+=4,h[f]={offset:u,length:l};}for(d=0;d<s.length;d++){var v=s[d];h[v]&&(i[v.trim()]=e[v.trim()].parse(r,h[v].offset,h[v].length,i));}return i},_tabOffset:function(r,t,a){for(var n=e._bin,o=n.readUshort(r,a+4),s=a+12,i=0;i<o;i++){var h=n.readASCII(r,s,4);s+=4,n.readUint(r,s),s+=4;var d=n.readUint(r,s);if(s+=4,n.readUint(r,s),s+=4,h==t)return d}return 0}};e._bin={readFixed:function(r,e){return (r[e]<<8|r[e+1])+(r[e+2]<<8|r[e+3])/65540},readF2dot14:function(r,t){return e._bin.readShort(r,t)/16384},readInt:function(r,t){return e._bin._view(r).getInt32(t)},readInt8:function(r,t){return e._bin._view(r).getInt8(t)},readShort:function(r,t){return e._bin._view(r).getInt16(t)},readUshort:function(r,t){return e._bin._view(r).getUint16(t)},readUshorts:function(r,t,a){for(var n=[],o=0;o<a;o++)n.push(e._bin.readUshort(r,t+2*o));return n},readUint:function(r,t){return e._bin._view(r).getUint32(t)},readUint64:function(r,t){return 4294967296*e._bin.readUint(r,t)+e._bin.readUint(r,t+4)},readASCII:function(r,e,t){for(var a="",n=0;n<t;n++)a+=String.fromCharCode(r[e+n]);return a},readUnicode:function(r,e,t){for(var a="",n=0;n<t;n++){var o=r[e++]<<8|r[e++];a+=String.fromCharCode(o);}return a},_tdec:"undefined"!=typeof window&&window.TextDecoder?new window.TextDecoder:null,readUTF8:function(r,t,a){var n=e._bin._tdec;return n&&0==t&&a==r.length?n.decode(r):e._bin.readASCII(r,t,a)},readBytes:function(r,e,t){for(var a=[],n=0;n<t;n++)a.push(r[e+n]);return a},readASCIIArray:function(r,e,t){for(var a=[],n=0;n<t;n++)a.push(String.fromCharCode(r[e+n]));return a},_view:function(r){return r._dataView||(r._dataView=r.buffer?new DataView(r.buffer,r.byteOffset,r.byteLength):new DataView(new Uint8Array(r).buffer))}},e._lctf={},e._lctf.parse=function(r,t,a,n,o){var s=e._bin,i={},h=t;s.readFixed(r,t),t+=4;var d=s.readUshort(r,t);t+=2;var f=s.readUshort(r,t);t+=2;var u=s.readUshort(r,t);return t+=2,i.scriptList=e._lctf.readScriptList(r,h+d),i.featureList=e._lctf.readFeatureList(r,h+f),i.lookupList=e._lctf.readLookupList(r,h+u,o),i},e._lctf.readLookupList=function(r,t,a){var n=e._bin,o=t,s=[],i=n.readUshort(r,t);t+=2;for(var h=0;h<i;h++){var d=n.readUshort(r,t);t+=2;var f=e._lctf.readLookupTable(r,o+d,a);s.push(f);}return s},e._lctf.readLookupTable=function(r,t,a){var n=e._bin,o=t,s={tabs:[]};s.ltype=n.readUshort(r,t),t+=2,s.flag=n.readUshort(r,t),t+=2;var i=n.readUshort(r,t);t+=2;for(var h=s.ltype,d=0;d<i;d++){var f=n.readUshort(r,t);t+=2;var u=a(r,h,o+f,s);s.tabs.push(u);}return s},e._lctf.numOfOnes=function(r){for(var e=0,t=0;t<32;t++)0!=(r>>>t&1)&&e++;return e},e._lctf.readClassDef=function(r,t){var a=e._bin,n=[],o=a.readUshort(r,t);if(t+=2,1==o){var s=a.readUshort(r,t);t+=2;var i=a.readUshort(r,t);t+=2;for(var h=0;h<i;h++)n.push(s+h),n.push(s+h),n.push(a.readUshort(r,t)),t+=2;}if(2==o){var d=a.readUshort(r,t);t+=2;for(h=0;h<d;h++)n.push(a.readUshort(r,t)),t+=2,n.push(a.readUshort(r,t)),t+=2,n.push(a.readUshort(r,t)),t+=2;}return n},e._lctf.getInterval=function(r,e){for(var t=0;t<r.length;t+=3){var a=r[t],n=r[t+1];if(r[t+2],a<=e&&e<=n)return t}return -1},e._lctf.readCoverage=function(r,t){var a=e._bin,n={};n.fmt=a.readUshort(r,t),t+=2;var o=a.readUshort(r,t);return t+=2,1==n.fmt&&(n.tab=a.readUshorts(r,t,o)),2==n.fmt&&(n.tab=a.readUshorts(r,t,3*o)),n},e._lctf.coverageIndex=function(r,t){var a=r.tab;if(1==r.fmt)return a.indexOf(t);if(2==r.fmt){var n=e._lctf.getInterval(a,t);if(-1!=n)return a[n+2]+(t-a[n])}return -1},e._lctf.readFeatureList=function(r,t){var a=e._bin,n=t,o=[],s=a.readUshort(r,t);t+=2;for(var i=0;i<s;i++){var h=a.readASCII(r,t,4);t+=4;var d=a.readUshort(r,t);t+=2;var f=e._lctf.readFeatureTable(r,n+d);f.tag=h.trim(),o.push(f);}return o},e._lctf.readFeatureTable=function(r,t){var a=e._bin,n=t,o={},s=a.readUshort(r,t);t+=2,s>0&&(o.featureParams=n+s);var i=a.readUshort(r,t);t+=2,o.tab=[];for(var h=0;h<i;h++)o.tab.push(a.readUshort(r,t+2*h));return o},e._lctf.readScriptList=function(r,t){var a=e._bin,n=t,o={},s=a.readUshort(r,t);t+=2;for(var i=0;i<s;i++){var h=a.readASCII(r,t,4);t+=4;var d=a.readUshort(r,t);t+=2,o[h.trim()]=e._lctf.readScriptTable(r,n+d);}return o},e._lctf.readScriptTable=function(r,t){var a=e._bin,n=t,o={},s=a.readUshort(r,t);t+=2,s>0&&(o.default=e._lctf.readLangSysTable(r,n+s));var i=a.readUshort(r,t);t+=2;for(var h=0;h<i;h++){var d=a.readASCII(r,t,4);t+=4;var f=a.readUshort(r,t);t+=2,o[d.trim()]=e._lctf.readLangSysTable(r,n+f);}return o},e._lctf.readLangSysTable=function(r,t){var a=e._bin,n={};a.readUshort(r,t),t+=2,n.reqFeature=a.readUshort(r,t),t+=2;var o=a.readUshort(r,t);return t+=2,n.features=a.readUshorts(r,t,o),n},e.CFF={},e.CFF.parse=function(r,t,a){var n=e._bin;(r=new Uint8Array(r.buffer,t,a))[t=0],r[++t],r[++t],r[++t],t++;var o=[];t=e.CFF.readIndex(r,t,o);for(var s=[],i=0;i<o.length-1;i++)s.push(n.readASCII(r,t+o[i],o[i+1]-o[i]));t+=o[o.length-1];var h=[];t=e.CFF.readIndex(r,t,h);var d=[];for(i=0;i<h.length-1;i++)d.push(e.CFF.readDict(r,t+h[i],t+h[i+1]));t+=h[h.length-1];var f=d[0],u=[];t=e.CFF.readIndex(r,t,u);var l=[];for(i=0;i<u.length-1;i++)l.push(n.readASCII(r,t+u[i],u[i+1]-u[i]));if(t+=u[u.length-1],e.CFF.readSubrs(r,t,f),f.CharStrings){t=f.CharStrings;u=[];t=e.CFF.readIndex(r,t,u);var v=[];for(i=0;i<u.length-1;i++)v.push(n.readBytes(r,t+u[i],u[i+1]-u[i]));f.CharStrings=v;}if(f.ROS){t=f.FDArray;var c=[];t=e.CFF.readIndex(r,t,c),f.FDArray=[];for(i=0;i<c.length-1;i++){var p=e.CFF.readDict(r,t+c[i],t+c[i+1]);e.CFF._readFDict(r,p,l),f.FDArray.push(p);}t+=c[c.length-1],t=f.FDSelect,f.FDSelect=[];var U=r[t];if(t++,3!=U)throw U;var g=n.readUshort(r,t);t+=2;for(i=0;i<g+1;i++)f.FDSelect.push(n.readUshort(r,t),r[t+2]),t+=3;}return f.Encoding&&(f.Encoding=e.CFF.readEncoding(r,f.Encoding,f.CharStrings.length)),f.charset&&(f.charset=e.CFF.readCharset(r,f.charset,f.CharStrings.length)),e.CFF._readFDict(r,f,l),f},e.CFF._readFDict=function(r,t,a){var n;for(var o in t.Private&&(n=t.Private[1],t.Private=e.CFF.readDict(r,n,n+t.Private[0]),t.Private.Subrs&&e.CFF.readSubrs(r,n+t.Private.Subrs,t.Private)),t)-1!=["FamilyName","FontName","FullName","Notice","version","Copyright"].indexOf(o)&&(t[o]=a[t[o]-426+35]);},e.CFF.readSubrs=function(r,t,a){var n=e._bin,o=[];t=e.CFF.readIndex(r,t,o);var s,i=o.length;s=i<1240?107:i<33900?1131:32768,a.Bias=s,a.Subrs=[];for(var h=0;h<o.length-1;h++)a.Subrs.push(n.readBytes(r,t+o[h],o[h+1]-o[h]));},e.CFF.tableSE=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,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,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,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,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,0,111,112,113,114,0,115,116,117,118,119,120,121,122,0,123,0,124,125,126,127,128,129,130,131,0,132,133,0,134,135,136,137,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,138,0,139,0,0,0,0,140,141,142,143,0,0,0,0,0,144,0,0,0,145,0,0,146,147,148,149,0,0,0,0],e.CFF.glyphByUnicode=function(r,e){for(var t=0;t<r.charset.length;t++)if(r.charset[t]==e)return t;return -1},e.CFF.glyphBySE=function(r,t){return t<0||t>255?-1:e.CFF.glyphByUnicode(r,e.CFF.tableSE[t])},e.CFF.readEncoding=function(r,t,a){e._bin;var n=[".notdef"],o=r[t];if(t++,0!=o)throw "error: unknown encoding format: "+o;var s=r[t];t++;for(var i=0;i<s;i++)n.push(r[t+i]);return n},e.CFF.readCharset=function(r,t,a){var n=e._bin,o=[".notdef"],s=r[t];if(t++,0==s)for(var i=0;i<a;i++){var h=n.readUshort(r,t);t+=2,o.push(h);}else {if(1!=s&&2!=s)throw "error: format: "+s;for(;o.length<a;){h=n.readUshort(r,t);t+=2;var d=0;1==s?(d=r[t],t++):(d=n.readUshort(r,t),t+=2);for(i=0;i<=d;i++)o.push(h),h++;}}return o},e.CFF.readIndex=function(r,t,a){var n=e._bin,o=n.readUshort(r,t)+1,s=r[t+=2];if(t++,1==s)for(var i=0;i<o;i++)a.push(r[t+i]);else if(2==s)for(i=0;i<o;i++)a.push(n.readUshort(r,t+2*i));else if(3==s)for(i=0;i<o;i++)a.push(16777215&n.readUint(r,t+3*i-1));else if(1!=o)throw "unsupported offset size: "+s+", count: "+o;return (t+=o*s)-1},e.CFF.getCharString=function(r,t,a){var n=e._bin,o=r[t],s=r[t+1];r[t+2],r[t+3],r[t+4];var i=1,h=null,d=null;o<=20&&(h=o,i=1),12==o&&(h=100*o+s,i=2),21<=o&&o<=27&&(h=o,i=1),28==o&&(d=n.readShort(r,t+1),i=3),29<=o&&o<=31&&(h=o,i=1),32<=o&&o<=246&&(d=o-139,i=1),247<=o&&o<=250&&(d=256*(o-247)+s+108,i=2),251<=o&&o<=254&&(d=256*-(o-251)-s-108,i=2),255==o&&(d=n.readInt(r,t+1)/65535,i=5),a.val=null!=d?d:"o"+h,a.size=i;},e.CFF.readCharString=function(r,t,a){for(var n=t+a,o=e._bin,s=[];t<n;){var i=r[t],h=r[t+1];r[t+2],r[t+3],r[t+4];var d=1,f=null,u=null;i<=20&&(f=i,d=1),12==i&&(f=100*i+h,d=2),19!=i&&20!=i||(f=i,d=2),21<=i&&i<=27&&(f=i,d=1),28==i&&(u=o.readShort(r,t+1),d=3),29<=i&&i<=31&&(f=i,d=1),32<=i&&i<=246&&(u=i-139,d=1),247<=i&&i<=250&&(u=256*(i-247)+h+108,d=2),251<=i&&i<=254&&(u=256*-(i-251)-h-108,d=2),255==i&&(u=o.readInt(r,t+1)/65535,d=5),s.push(null!=u?u:"o"+f),t+=d;}return s},e.CFF.readDict=function(r,t,a){for(var n=e._bin,o={},s=[];t<a;){var i=r[t],h=r[t+1];r[t+2],r[t+3],r[t+4];var d=1,f=null,u=null;if(28==i&&(u=n.readShort(r,t+1),d=3),29==i&&(u=n.readInt(r,t+1),d=5),32<=i&&i<=246&&(u=i-139,d=1),247<=i&&i<=250&&(u=256*(i-247)+h+108,d=2),251<=i&&i<=254&&(u=256*-(i-251)-h-108,d=2),255==i)throw u=n.readInt(r,t+1)/65535,d=5,"unknown number";if(30==i){var l=[];for(d=1;;){var v=r[t+d];d++;var c=v>>4,p=15&v;if(15!=c&&l.push(c),15!=p&&l.push(p),15==p)break}for(var U="",g=[0,1,2,3,4,5,6,7,8,9,".","e","e-","reserved","-","endOfNumber"],S=0;S<l.length;S++)U+=g[l[S]];u=parseFloat(U);}if(i<=21)if(f=["version","Notice","FullName","FamilyName","Weight","FontBBox","BlueValues","OtherBlues","FamilyBlues","FamilyOtherBlues","StdHW","StdVW","escape","UniqueID","XUID","charset","Encoding","CharStrings","Private","Subrs","defaultWidthX","nominalWidthX"][i],d=1,12==i)f=["Copyright","isFixedPitch","ItalicAngle","UnderlinePosition","UnderlineThickness","PaintType","CharstringType","FontMatrix","StrokeWidth","BlueScale","BlueShift","BlueFuzz","StemSnapH","StemSnapV","ForceBold",0,0,"LanguageGroup","ExpansionFactor","initialRandomSeed","SyntheticBase","PostScript","BaseFontName","BaseFontBlend",0,0,0,0,0,0,"ROS","CIDFontVersion","CIDFontRevision","CIDFontType","CIDCount","UIDBase","FDArray","FDSelect","FontName"][h],d=2;null!=f?(o[f]=1==s.length?s[0]:s,s=[]):s.push(u),t+=d;}return o},e.cmap={},e.cmap.parse=function(r,t,a){r=new Uint8Array(r.buffer,t,a),t=0;var n=e._bin,o={};n.readUshort(r,t),t+=2;var s=n.readUshort(r,t);t+=2;var i=[];o.tables=[];for(var h=0;h<s;h++){var d=n.readUshort(r,t);t+=2;var f=n.readUshort(r,t);t+=2;var u=n.readUint(r,t);t+=4;var l="p"+d+"e"+f,v=i.indexOf(u);if(-1==v){var c;v=o.tables.length,i.push(u);var p=n.readUshort(r,u);0==p?c=e.cmap.parse0(r,u):4==p?c=e.cmap.parse4(r,u):6==p?c=e.cmap.parse6(r,u):12==p?c=e.cmap.parse12(r,u):console.debug("unknown format: "+p,d,f,u),o.tables.push(c);}if(null!=o[l])throw "multiple tables for one platform+encoding";o[l]=v;}return o},e.cmap.parse0=function(r,t){var a=e._bin,n={};n.format=a.readUshort(r,t),t+=2;var o=a.readUshort(r,t);t+=2,a.readUshort(r,t),t+=2,n.map=[];for(var s=0;s<o-6;s++)n.map.push(r[t+s]);return n},e.cmap.parse4=function(r,t){var a=e._bin,n=t,o={};o.format=a.readUshort(r,t),t+=2;var s=a.readUshort(r,t);t+=2,a.readUshort(r,t),t+=2;var i=a.readUshort(r,t);t+=2;var h=i/2;o.searchRange=a.readUshort(r,t),t+=2,o.entrySelector=a.readUshort(r,t),t+=2,o.rangeShift=a.readUshort(r,t),t+=2,o.endCount=a.readUshorts(r,t,h),t+=2*h,t+=2,o.startCount=a.readUshorts(r,t,h),t+=2*h,o.idDelta=[];for(var d=0;d<h;d++)o.idDelta.push(a.readShort(r,t)),t+=2;for(o.idRangeOffset=a.readUshorts(r,t,h),t+=2*h,o.glyphIdArray=[];t<n+s;)o.glyphIdArray.push(a.readUshort(r,t)),t+=2;return o},e.cmap.parse6=function(r,t){var a=e._bin,n={};n.format=a.readUshort(r,t),t+=2,a.readUshort(r,t),t+=2,a.readUshort(r,t),t+=2,n.firstCode=a.readUshort(r,t),t+=2;var o=a.readUshort(r,t);t+=2,n.glyphIdArray=[];for(var s=0;s<o;s++)n.glyphIdArray.push(a.readUshort(r,t)),t+=2;return n},e.cmap.parse12=function(r,t){var a=e._bin,n={};n.format=a.readUshort(r,t),t+=2,t+=2,a.readUint(r,t),t+=4,a.readUint(r,t),t+=4;var o=a.readUint(r,t);t+=4,n.groups=[];for(var s=0;s<o;s++){var i=t+12*s,h=a.readUint(r,i+0),d=a.readUint(r,i+4),f=a.readUint(r,i+8);n.groups.push([h,d,f]);}return n},e.glyf={},e.glyf.parse=function(r,e,t,a){for(var n=[],o=0;o<a.maxp.numGlyphs;o++)n.push(null);return n},e.glyf._parseGlyf=function(r,t){var a=e._bin,n=r._data,o=e._tabOffset(n,"glyf",r._offset)+r.loca[t];if(r.loca[t]==r.loca[t+1])return null;var s={};if(s.noc=a.readShort(n,o),o+=2,s.xMin=a.readShort(n,o),o+=2,s.yMin=a.readShort(n,o),o+=2,s.xMax=a.readShort(n,o),o+=2,s.yMax=a.readShort(n,o),o+=2,s.xMin>=s.xMax||s.yMin>=s.yMax)return null;if(s.noc>0){s.endPts=[];for(var i=0;i<s.noc;i++)s.endPts.push(a.readUshort(n,o)),o+=2;var h=a.readUshort(n,o);if(o+=2,n.length-o<h)return null;s.instructions=a.readBytes(n,o,h),o+=h;var d=s.endPts[s.noc-1]+1;s.flags=[];for(i=0;i<d;i++){var f=n[o];if(o++,s.flags.push(f),0!=(8&f)){var u=n[o];o++;for(var l=0;l<u;l++)s.flags.push(f),i++;}}s.xs=[];for(i=0;i<d;i++){var v=0!=(2&s.flags[i]),c=0!=(16&s.flags[i]);v?(s.xs.push(c?n[o]:-n[o]),o++):c?s.xs.push(0):(s.xs.push(a.readShort(n,o)),o+=2);}s.ys=[];for(i=0;i<d;i++){v=0!=(4&s.flags[i]),c=0!=(32&s.flags[i]);v?(s.ys.push(c?n[o]:-n[o]),o++):c?s.ys.push(0):(s.ys.push(a.readShort(n,o)),o+=2);}var p=0,U=0;for(i=0;i<d;i++)p+=s.xs[i],U+=s.ys[i],s.xs[i]=p,s.ys[i]=U;}else {var g;s.parts=[];do{g=a.readUshort(n,o),o+=2;var S={m:{a:1,b:0,c:0,d:1,tx:0,ty:0},p1:-1,p2:-1};if(s.parts.push(S),S.glyphIndex=a.readUshort(n,o),o+=2,1&g){var m=a.readShort(n,o);o+=2;var b=a.readShort(n,o);o+=2;}else {m=a.readInt8(n,o);o++;b=a.readInt8(n,o);o++;}2&g?(S.m.tx=m,S.m.ty=b):(S.p1=m,S.p2=b),8&g?(S.m.a=S.m.d=a.readF2dot14(n,o),o+=2):64&g?(S.m.a=a.readF2dot14(n,o),o+=2,S.m.d=a.readF2dot14(n,o),o+=2):128&g&&(S.m.a=a.readF2dot14(n,o),o+=2,S.m.b=a.readF2dot14(n,o),o+=2,S.m.c=a.readF2dot14(n,o),o+=2,S.m.d=a.readF2dot14(n,o),o+=2);}while(32&g);if(256&g){var y=a.readUshort(n,o);o+=2,s.instr=[];for(i=0;i<y;i++)s.instr.push(n[o]),o++;}}return s},e.GDEF={},e.GDEF.parse=function(r,t,a,n){var o=t;t+=4;var s=e._bin.readUshort(r,t);return {glyphClassDef:0===s?null:e._lctf.readClassDef(r,o+s)}},e.GPOS={},e.GPOS.parse=function(r,t,a,n){return e._lctf.parse(r,t,a,n,e.GPOS.subt)},e.GPOS.subt=function(r,t,a,n){var o=e._bin,s=a,i={};if(i.fmt=o.readUshort(r,a),a+=2,1==t||2==t||3==t||7==t||8==t&&i.fmt<=2){var h=o.readUshort(r,a);a+=2,i.coverage=e._lctf.readCoverage(r,h+s);}if(1==t&&1==i.fmt){var d=o.readUshort(r,a);a+=2,0!=d&&(i.pos=e.GPOS.readValueRecord(r,a,d));}else if(2==t&&i.fmt>=1&&i.fmt<=2){d=o.readUshort(r,a);a+=2;var f=o.readUshort(r,a);a+=2;var u=e._lctf.numOfOnes(d),l=e._lctf.numOfOnes(f);if(1==i.fmt){i.pairsets=[];var v=o.readUshort(r,a);a+=2;for(var c=0;c<v;c++){var p=s+o.readUshort(r,a);a+=2;var U=o.readUshort(r,p);p+=2;for(var g=[],S=0;S<U;S++){var m=o.readUshort(r,p);p+=2,0!=d&&(P=e.GPOS.readValueRecord(r,p,d),p+=2*u),0!=f&&(x=e.GPOS.readValueRecord(r,p,f),p+=2*l),g.push({gid2:m,val1:P,val2:x});}i.pairsets.push(g);}}if(2==i.fmt){var b=o.readUshort(r,a);a+=2;var y=o.readUshort(r,a);a+=2;var F=o.readUshort(r,a);a+=2;var C=o.readUshort(r,a);a+=2,i.classDef1=e._lctf.readClassDef(r,s+b),i.classDef2=e._lctf.readClassDef(r,s+y),i.matrix=[];for(c=0;c<F;c++){var _=[];for(S=0;S<C;S++){var P=null,x=null;0!=d&&(P=e.GPOS.readValueRecord(r,a,d),a+=2*u),0!=f&&(x=e.GPOS.readValueRecord(r,a,f),a+=2*l),_.push({val1:P,val2:x});}i.matrix.push(_);}}}else if(4==t&&1==i.fmt)i.markCoverage=e._lctf.readCoverage(r,o.readUshort(r,a)+s),i.baseCoverage=e._lctf.readCoverage(r,o.readUshort(r,a+2)+s),i.markClassCount=o.readUshort(r,a+4),i.markArray=e.GPOS.readMarkArray(r,o.readUshort(r,a+6)+s),i.baseArray=e.GPOS.readBaseArray(r,o.readUshort(r,a+8)+s,i.markClassCount);else if(6==t&&1==i.fmt)i.mark1Coverage=e._lctf.readCoverage(r,o.readUshort(r,a)+s),i.mark2Coverage=e._lctf.readCoverage(r,o.readUshort(r,a+2)+s),i.markClassCount=o.readUshort(r,a+4),i.mark1Array=e.GPOS.readMarkArray(r,o.readUshort(r,a+6)+s),i.mark2Array=e.GPOS.readBaseArray(r,o.readUshort(r,a+8)+s,i.markClassCount);else {if(9==t&&1==i.fmt){var I=o.readUshort(r,a);a+=2;var w=o.readUint(r,a);if(a+=4,9==n.ltype)n.ltype=I;else if(n.ltype!=I)throw "invalid extension substitution";return e.GPOS.subt(r,n.ltype,s+w)}console.debug("unsupported GPOS table LookupType",t,"format",i.fmt);}return i},e.GPOS.readValueRecord=function(r,t,a){var n=e._bin,o=[];return o.push(1&a?n.readShort(r,t):0),t+=1&a?2:0,o.push(2&a?n.readShort(r,t):0),t+=2&a?2:0,o.push(4&a?n.readShort(r,t):0),t+=4&a?2:0,o.push(8&a?n.readShort(r,t):0),t+=8&a?2:0,o},e.GPOS.readBaseArray=function(r,t,a){var n=e._bin,o=[],s=t,i=n.readUshort(r,t);t+=2;for(var h=0;h<i;h++){for(var d=[],f=0;f<a;f++)d.push(e.GPOS.readAnchorRecord(r,s+n.readUshort(r,t))),t+=2;o.push(d);}return o},e.GPOS.readMarkArray=function(r,t){var a=e._bin,n=[],o=t,s=a.readUshort(r,t);t+=2;for(var i=0;i<s;i++){var h=e.GPOS.readAnchorRecord(r,a.readUshort(r,t+2)+o);h.markClass=a.readUshort(r,t),n.push(h),t+=4;}return n},e.GPOS.readAnchorRecord=function(r,t){var a=e._bin,n={};return n.fmt=a.readUshort(r,t),n.x=a.readShort(r,t+2),n.y=a.readShort(r,t+4),n},e.GSUB={},e.GSUB.parse=function(r,t,a,n){return e._lctf.parse(r,t,a,n,e.GSUB.subt)},e.GSUB.subt=function(r,t,a,n){var o=e._bin,s=a,i={};if(i.fmt=o.readUshort(r,a),a+=2,1!=t&&2!=t&&4!=t&&5!=t&&6!=t)return null;if(1==t||2==t||4==t||5==t&&i.fmt<=2||6==t&&i.fmt<=2){var h=o.readUshort(r,a);a+=2,i.coverage=e._lctf.readCoverage(r,s+h);}if(1==t&&i.fmt>=1&&i.fmt<=2){if(1==i.fmt)i.delta=o.readShort(r,a),a+=2;else if(2==i.fmt){var d=o.readUshort(r,a);a+=2,i.newg=o.readUshorts(r,a,d),a+=2*i.newg.length;}}else if(2==t&&1==i.fmt){d=o.readUshort(r,a);a+=2,i.seqs=[];for(var f=0;f<d;f++){var u=o.readUshort(r,a)+s;a+=2;var l=o.readUshort(r,u);i.seqs.push(o.readUshorts(r,u+2,l));}}else if(4==t){i.vals=[];d=o.readUshort(r,a);a+=2;for(f=0;f<d;f++){var v=o.readUshort(r,a);a+=2,i.vals.push(e.GSUB.readLigatureSet(r,s+v));}}else if(5==t&&2==i.fmt){if(2==i.fmt){var c=o.readUshort(r,a);a+=2,i.cDef=e._lctf.readClassDef(r,s+c),i.scset=[];var p=o.readUshort(r,a);a+=2;for(f=0;f<p;f++){var U=o.readUshort(r,a);a+=2,i.scset.push(0==U?null:e.GSUB.readSubClassSet(r,s+U));}}}else if(6==t&&3==i.fmt){if(3==i.fmt){for(f=0;f<3;f++){d=o.readUshort(r,a);a+=2;for(var g=[],S=0;S<d;S++)g.push(e._lctf.readCoverage(r,s+o.readUshort(r,a+2*S)));a+=2*d,0==f&&(i.backCvg=g),1==f&&(i.inptCvg=g),2==f&&(i.ahedCvg=g);}d=o.readUshort(r,a);a+=2,i.lookupRec=e.GSUB.readSubstLookupRecords(r,a,d);}}else {if(7==t&&1==i.fmt){var m=o.readUshort(r,a);a+=2;var b=o.readUint(r,a);if(a+=4,9==n.ltype)n.ltype=m;else if(n.ltype!=m)throw "invalid extension substitution";return e.GSUB.subt(r,n.ltype,s+b)}console.debug("unsupported GSUB table LookupType",t,"format",i.fmt);}return i},e.GSUB.readSubClassSet=function(r,t){var a=e._bin.readUshort,n=t,o=[],s=a(r,t);t+=2;for(var i=0;i<s;i++){var h=a(r,t);t+=2,o.push(e.GSUB.readSubClassRule(r,n+h));}return o},e.GSUB.readSubClassRule=function(r,t){var a=e._bin.readUshort,n={},o=a(r,t),s=a(r,t+=2);t+=2,n.input=[];for(var i=0;i<o-1;i++)n.input.push(a(r,t)),t+=2;return n.substLookupRecords=e.GSUB.readSubstLookupRecords(r,t,s),n},e.GSUB.readSubstLookupRecords=function(r,t,a){for(var n=e._bin.readUshort,o=[],s=0;s<a;s++)o.push(n(r,t),n(r,t+2)),t+=4;return o},e.GSUB.readChainSubClassSet=function(r,t){var a=e._bin,n=t,o=[],s=a.readUshort(r,t);t+=2;for(var i=0;i<s;i++){var h=a.readUshort(r,t);t+=2,o.push(e.GSUB.readChainSubClassRule(r,n+h));}return o},e.GSUB.readChainSubClassRule=function(r,t){for(var a=e._bin,n={},o=["backtrack","input","lookahead"],s=0;s<o.length;s++){var i=a.readUshort(r,t);t+=2,1==s&&i--,n[o[s]]=a.readUshorts(r,t,i),t+=2*n[o[s]].length;}i=a.readUshort(r,t);return t+=2,n.subst=a.readUshorts(r,t,2*i),t+=2*n.subst.length,n},e.GSUB.readLigatureSet=function(r,t){var a=e._bin,n=t,o=[],s=a.readUshort(r,t);t+=2;for(var i=0;i<s;i++){var h=a.readUshort(r,t);t+=2,o.push(e.GSUB.readLigature(r,n+h));}return o},e.GSUB.readLigature=function(r,t){var a=e._bin,n={chain:[]};n.nglyph=a.readUshort(r,t),t+=2;var o=a.readUshort(r,t);t+=2;for(var s=0;s<o-1;s++)n.chain.push(a.readUshort(r,t)),t+=2;return n},e.head={},e.head.parse=function(r,t,a){var n=e._bin,o={};return n.readFixed(r,t),t+=4,o.fontRevision=n.readFixed(r,t),t+=4,n.readUint(r,t),t+=4,n.readUint(r,t),t+=4,o.flags=n.readUshort(r,t),t+=2,o.unitsPerEm=n.readUshort(r,t),t+=2,o.created=n.readUint64(r,t),t+=8,o.modified=n.readUint64(r,t),t+=8,o.xMin=n.readShort(r,t),t+=2,o.yMin=n.readShort(r,t),t+=2,o.xMax=n.readShort(r,t),t+=2,o.yMax=n.readShort(r,t),t+=2,o.macStyle=n.readUshort(r,t),t+=2,o.lowestRecPPEM=n.readUshort(r,t),t+=2,o.fontDirectionHint=n.readShort(r,t),t+=2,o.indexToLocFormat=n.readShort(r,t),t+=2,o.glyphDataFormat=n.readShort(r,t),t+=2,o},e.hhea={},e.hhea.parse=function(r,t,a){var n=e._bin,o={};return n.readFixed(r,t),t+=4,o.ascender=n.readShort(r,t),t+=2,o.descender=n.readShort(r,t),t+=2,o.lineGap=n.readShort(r,t),t+=2,o.advanceWidthMax=n.readUshort(r,t),t+=2,o.minLeftSideBearing=n.readShort(r,t),t+=2,o.minRightSideBearing=n.readShort(r,t),t+=2,o.xMaxExtent=n.readShort(r,t),t+=2,o.caretSlopeRise=n.readShort(r,t),t+=2,o.caretSlopeRun=n.readShort(r,t),t+=2,o.caretOffset=n.readShort(r,t),t+=2,t+=8,o.metricDataFormat=n.readShort(r,t),t+=2,o.numberOfHMetrics=n.readUshort(r,t),t+=2,o},e.hmtx={},e.hmtx.parse=function(r,t,a,n){for(var o=e._bin,s={aWidth:[],lsBearing:[]},i=0,h=0,d=0;d<n.maxp.numGlyphs;d++)d<n.hhea.numberOfHMetrics&&(i=o.readUshort(r,t),t+=2,h=o.readShort(r,t),t+=2),s.aWidth.push(i),s.lsBearing.push(h);return s},e.kern={},e.kern.parse=function(r,t,a,n){var o=e._bin,s=o.readUshort(r,t);if(t+=2,1==s)return e.kern.parseV1(r,t-2,a,n);var i=o.readUshort(r,t);t+=2;for(var h={glyph1:[],rval:[]},d=0;d<i;d++){t+=2;a=o.readUshort(r,t);t+=2;var f=o.readUshort(r,t);t+=2;var u=f>>>8;if(0!=(u&=15))throw "unknown kern table format: "+u;t=e.kern.readFormat0(r,t,h);}return h},e.kern.parseV1=function(r,t,a,n){var o=e._bin;o.readFixed(r,t),t+=4;var s=o.readUint(r,t);t+=4;for(var i={glyph1:[],rval:[]},h=0;h<s;h++){o.readUint(r,t),t+=4;var d=o.readUshort(r,t);t+=2,o.readUshort(r,t),t+=2;var f=d>>>8;if(0!=(f&=15))throw "unknown kern table format: "+f;t=e.kern.readFormat0(r,t,i);}return i},e.kern.readFormat0=function(r,t,a){var n=e._bin,o=-1,s=n.readUshort(r,t);t+=2,n.readUshort(r,t),t+=2,n.readUshort(r,t),t+=2,n.readUshort(r,t),t+=2;for(var i=0;i<s;i++){var h=n.readUshort(r,t);t+=2;var d=n.readUshort(r,t);t+=2;var f=n.readShort(r,t);t+=2,h!=o&&(a.glyph1.push(h),a.rval.push({glyph2:[],vals:[]}));var u=a.rval[a.rval.length-1];u.glyph2.push(d),u.vals.push(f),o=h;}return t},e.loca={},e.loca.parse=function(r,t,a,n){var o=e._bin,s=[],i=n.head.indexToLocFormat,h=n.maxp.numGlyphs+1;if(0==i)for(var d=0;d<h;d++)s.push(o.readUshort(r,t+(d<<1))<<1);if(1==i)for(d=0;d<h;d++)s.push(o.readUint(r,t+(d<<2)));return s},e.maxp={},e.maxp.parse=function(r,t,a){var n=e._bin,o={},s=n.readUint(r,t);return t+=4,o.numGlyphs=n.readUshort(r,t),t+=2,65536==s&&(o.maxPoints=n.readUshort(r,t),t+=2,o.maxContours=n.readUshort(r,t),t+=2,o.maxCompositePoints=n.readUshort(r,t),t+=2,o.maxCompositeContours=n.readUshort(r,t),t+=2,o.maxZones=n.readUshort(r,t),t+=2,o.maxTwilightPoints=n.readUshort(r,t),t+=2,o.maxStorage=n.readUshort(r,t),t+=2,o.maxFunctionDefs=n.readUshort(r,t),t+=2,o.maxInstructionDefs=n.readUshort(r,t),t+=2,o.maxStackElements=n.readUshort(r,t),t+=2,o.maxSizeOfInstructions=n.readUshort(r,t),t+=2,o.maxComponentElements=n.readUshort(r,t),t+=2,o.maxComponentDepth=n.readUshort(r,t),t+=2),o},e.name={},e.name.parse=function(r,t,a){var n=e._bin,o={};n.readUshort(r,t),t+=2;var s=n.readUshort(r,t);t+=2,n.readUshort(r,t);for(var i,h=["copyright","fontFamily","fontSubfamily","ID","fullName","version","postScriptName","trademark","manufacturer","designer","description","urlVendor","urlDesigner","licence","licenceURL","---","typoFamilyName","typoSubfamilyName","compatibleFull","sampleText","postScriptCID","wwsFamilyName","wwsSubfamilyName","lightPalette","darkPalette"],d=t+=2,f=0;f<s;f++){var u=n.readUshort(r,t);t+=2;var l=n.readUshort(r,t);t+=2;var v=n.readUshort(r,t);t+=2;var c=n.readUshort(r,t);t+=2;var p=n.readUshort(r,t);t+=2;var U=n.readUshort(r,t);t+=2;var g,S=h[c],m=d+12*s+U;if(0==u)g=n.readUnicode(r,m,p/2);else if(3==u&&0==l)g=n.readUnicode(r,m,p/2);else if(0==l)g=n.readASCII(r,m,p);else if(1==l)g=n.readUnicode(r,m,p/2);else if(3==l)g=n.readUnicode(r,m,p/2);else {if(1!=u)throw "unknown encoding "+l+", platformID: "+u;g=n.readASCII(r,m,p),console.debug("reading unknown MAC encoding "+l+" as ASCII");}var b="p"+u+","+v.toString(16);null==o[b]&&(o[b]={}),o[b][void 0!==S?S:c]=g,o[b]._lang=v;}for(var y in o)if(null!=o[y].postScriptName&&1033==o[y]._lang)return o[y];for(var y in o)if(null!=o[y].postScriptName&&0==o[y]._lang)return o[y];for(var y in o)if(null!=o[y].postScriptName&&3084==o[y]._lang)return o[y];for(var y in o)if(null!=o[y].postScriptName)return o[y];for(var y in o){i=y;break}return console.debug("returning name table with languageID "+o[i]._lang),o[i]},e["OS/2"]={},e["OS/2"].parse=function(r,t,a){var n=e._bin.readUshort(r,t);t+=2;var o={};if(0==n)e["OS/2"].version0(r,t,o);else if(1==n)e["OS/2"].version1(r,t,o);else if(2==n||3==n||4==n)e["OS/2"].version2(r,t,o);else {if(5!=n)throw "unknown OS/2 table version: "+n;e["OS/2"].version5(r,t,o);}return o},e["OS/2"].version0=function(r,t,a){var n=e._bin;return a.xAvgCharWidth=n.readShort(r,t),t+=2,a.usWeightClass=n.readUshort(r,t),t+=2,a.usWidthClass=n.readUshort(r,t),t+=2,a.fsType=n.readUshort(r,t),t+=2,a.ySubscriptXSize=n.readShort(r,t),t+=2,a.ySubscriptYSize=n.readShort(r,t),t+=2,a.ySubscriptXOffset=n.readShort(r,t),t+=2,a.ySubscriptYOffset=n.readShort(r,t),t+=2,a.ySuperscriptXSize=n.readShort(r,t),t+=2,a.ySuperscriptYSize=n.readShort(r,t),t+=2,a.ySuperscriptXOffset=n.readShort(r,t),t+=2,a.ySuperscriptYOffset=n.readShort(r,t),t+=2,a.yStrikeoutSize=n.readShort(r,t),t+=2,a.yStrikeoutPosition=n.readShort(r,t),t+=2,a.sFamilyClass=n.readShort(r,t),t+=2,a.panose=n.readBytes(r,t,10),t+=10,a.ulUnicodeRange1=n.readUint(r,t),t+=4,a.ulUnicodeRange2=n.readUint(r,t),t+=4,a.ulUnicodeRange3=n.readUint(r,t),t+=4,a.ulUnicodeRange4=n.readUint(r,t),t+=4,a.achVendID=[n.readInt8(r,t),n.readInt8(r,t+1),n.readInt8(r,t+2),n.readInt8(r,t+3)],t+=4,a.fsSelection=n.readUshort(r,t),t+=2,a.usFirstCharIndex=n.readUshort(r,t),t+=2,a.usLastCharIndex=n.readUshort(r,t),t+=2,a.sTypoAscender=n.readShort(r,t),t+=2,a.sTypoDescender=n.readShort(r,t),t+=2,a.sTypoLineGap=n.readShort(r,t),t+=2,a.usWinAscent=n.readUshort(r,t),t+=2,a.usWinDescent=n.readUshort(r,t),t+=2},e["OS/2"].version1=function(r,t,a){var n=e._bin;return t=e["OS/2"].version0(r,t,a),a.ulCodePageRange1=n.readUint(r,t),t+=4,a.ulCodePageRange2=n.readUint(r,t),t+=4},e["OS/2"].version2=function(r,t,a){var n=e._bin;return t=e["OS/2"].version1(r,t,a),a.sxHeight=n.readShort(r,t),t+=2,a.sCapHeight=n.readShort(r,t),t+=2,a.usDefault=n.readUshort(r,t),t+=2,a.usBreak=n.readUshort(r,t),t+=2,a.usMaxContext=n.readUshort(r,t),t+=2},e["OS/2"].version5=function(r,t,a){var n=e._bin;return t=e["OS/2"].version2(r,t,a),a.usLowerOpticalPointSize=n.readUshort(r,t),t+=2,a.usUpperOpticalPointSize=n.readUshort(r,t),t+=2},e.post={},e.post.parse=function(r,t,a){var n=e._bin,o={};return o.version=n.readFixed(r,t),t+=4,o.italicAngle=n.readFixed(r,t),t+=4,o.underlinePosition=n.readShort(r,t),t+=2,o.underlineThickness=n.readShort(r,t),t+=2,o},null==e&&(e={}),null==e.U&&(e.U={}),e.U.codeToGlyph=function(r,e){var t=r.cmap,a=-1;if(null!=t.p0e4?a=t.p0e4:null!=t.p3e1?a=t.p3e1:null!=t.p1e0?a=t.p1e0:null!=t.p0e3&&(a=t.p0e3),-1==a)throw "no familiar platform and encoding!";var n=t.tables[a];if(0==n.format)return e>=n.map.length?0:n.map[e];if(4==n.format){for(var o=-1,s=0;s<n.endCount.length;s++)if(e<=n.endCount[s]){o=s;break}if(-1==o)return 0;if(n.startCount[o]>e)return 0;return 65535&(0!=n.idRangeOffset[o]?n.glyphIdArray[e-n.startCount[o]+(n.idRangeOffset[o]>>1)-(n.idRangeOffset.length-o)]:e+n.idDelta[o])}if(12==n.format){if(e>n.groups[n.groups.length-1][1])return 0;for(s=0;s<n.groups.length;s++){var i=n.groups[s];if(i[0]<=e&&e<=i[1])return i[2]+(e-i[0])}return 0}throw "unknown cmap table format "+n.format},e.U.glyphToPath=function(r,t){var a={cmds:[],crds:[]};if(r.SVG&&r.SVG.entries[t]){var n=r.SVG.entries[t];return null==n?a:("string"==typeof n&&(n=e.SVG.toPath(n),r.SVG.entries[t]=n),n)}if(r.CFF){var o={x:0,y:0,stack:[],nStems:0,haveWidth:!1,width:r.CFF.Private?r.CFF.Private.defaultWidthX:0,open:!1},s=r.CFF,i=r.CFF.Private;if(s.ROS){for(var h=0;s.FDSelect[h+2]<=t;)h+=2;i=s.FDArray[s.FDSelect[h+1]].Private;}e.U._drawCFF(r.CFF.CharStrings[t],o,s,i,a);}else r.glyf&&e.U._drawGlyf(t,r,a);return a},e.U._drawGlyf=function(r,t,a){var n=t.glyf[r];null==n&&(n=t.glyf[r]=e.glyf._parseGlyf(t,r)),null!=n&&(n.noc>-1?e.U._simpleGlyph(n,a):e.U._compoGlyph(n,t,a));},e.U._simpleGlyph=function(r,t){for(var a=0;a<r.noc;a++){for(var n=0==a?0:r.endPts[a-1]+1,o=r.endPts[a],s=n;s<=o;s++){var i=s==n?o:s-1,h=s==o?n:s+1,d=1&r.flags[s],f=1&r.flags[i],u=1&r.flags[h],l=r.xs[s],v=r.ys[s];if(s==n)if(d){if(!f){e.U.P.moveTo(t,l,v);continue}e.U.P.moveTo(t,r.xs[i],r.ys[i]);}else f?e.U.P.moveTo(t,r.xs[i],r.ys[i]):e.U.P.moveTo(t,(r.xs[i]+l)/2,(r.ys[i]+v)/2);d?f&&e.U.P.lineTo(t,l,v):u?e.U.P.qcurveTo(t,l,v,r.xs[h],r.ys[h]):e.U.P.qcurveTo(t,l,v,(l+r.xs[h])/2,(v+r.ys[h])/2);}e.U.P.closePath(t);}},e.U._compoGlyph=function(r,t,a){for(var n=0;n<r.parts.length;n++){var o={cmds:[],crds:[]},s=r.parts[n];e.U._drawGlyf(s.glyphIndex,t,o);for(var i=s.m,h=0;h<o.crds.length;h+=2){var d=o.crds[h],f=o.crds[h+1];a.crds.push(d*i.a+f*i.b+i.tx),a.crds.push(d*i.c+f*i.d+i.ty);}for(h=0;h<o.cmds.length;h++)a.cmds.push(o.cmds[h]);}},e.U._getGlyphClass=function(r,t){var a=e._lctf.getInterval(t,r);return -1==a?0:t[a+2]},e.U._applySubs=function(r,t,a,n){for(var o=r.length-t-1,s=0;s<a.tabs.length;s++)if(null!=a.tabs[s]){var i,h=a.tabs[s];if(!h.coverage||-1!=(i=e._lctf.coverageIndex(h.coverage,r[t])))if(1==a.ltype)r[t],1==h.fmt?r[t]=r[t]+h.delta:r[t]=h.newg[i];else if(4==a.ltype)for(var d=h.vals[i],f=0;f<d.length;f++){var u=d[f],l=u.chain.length;if(!(l>o)){for(var v=!0,c=0,p=0;p<l;p++){for(;-1==r[t+c+(1+p)];)c++;u.chain[p]!=r[t+c+(1+p)]&&(v=!1);}if(v){r[t]=u.nglyph;for(p=0;p<l+c;p++)r[t+p+1]=-1;break}}}else if(5==a.ltype&&2==h.fmt)for(var U=e._lctf.getInterval(h.cDef,r[t]),g=h.cDef[U+2],S=h.scset[g],m=0;m<S.length;m++){var b=S[m],y=b.input;if(!(y.length>o)){for(v=!0,p=0;p<y.length;p++){var F=e._lctf.getInterval(h.cDef,r[t+1+p]);if(-1==U&&h.cDef[F+2]!=y[p]){v=!1;break}}if(v){var C=b.substLookupRecords;for(f=0;f<C.length;f+=2)C[f],C[f+1];}}}else if(6==a.ltype&&3==h.fmt){if(!e.U._glsCovered(r,h.backCvg,t-h.backCvg.length))continue;if(!e.U._glsCovered(r,h.inptCvg,t))continue;if(!e.U._glsCovered(r,h.ahedCvg,t+h.inptCvg.length))continue;var _=h.lookupRec;for(m=0;m<_.length;m+=2){U=_[m];var P=n[_[m+1]];e.U._applySubs(r,t+U,P,n);}}}},e.U._glsCovered=function(r,t,a){for(var n=0;n<t.length;n++){if(-1==e._lctf.coverageIndex(t[n],r[a+n]))return !1}return !0},e.U.glyphsToPath=function(r,t,a){for(var n={cmds:[],crds:[]},o=0,s=0;s<t.length;s++){var i=t[s];if(-1!=i){for(var h=s<t.length-1&&-1!=t[s+1]?t[s+1]:0,d=e.U.glyphToPath(r,i),f=0;f<d.crds.length;f+=2)n.crds.push(d.crds[f]+o),n.crds.push(d.crds[f+1]);a&&n.cmds.push(a);for(f=0;f<d.cmds.length;f++)n.cmds.push(d.cmds[f]);a&&n.cmds.push("X"),o+=r.hmtx.aWidth[i],s<t.length-1&&(o+=e.U.getPairAdjustment(r,i,h));}}return n},e.U.P={},e.U.P.moveTo=function(r,e,t){r.cmds.push("M"),r.crds.push(e,t);},e.U.P.lineTo=function(r,e,t){r.cmds.push("L"),r.crds.push(e,t);},e.U.P.curveTo=function(r,e,t,a,n,o,s){r.cmds.push("C"),r.crds.push(e,t,a,n,o,s);},e.U.P.qcurveTo=function(r,e,t,a,n){r.cmds.push("Q"),r.crds.push(e,t,a,n);},e.U.P.closePath=function(r){r.cmds.push("Z");},e.U._drawCFF=function(r,t,a,n,o){for(var s=t.stack,i=t.nStems,h=t.haveWidth,d=t.width,f=t.open,u=0,l=t.x,v=t.y,c=0,p=0,U=0,g=0,S=0,m=0,b=0,y=0,F=0,C=0,_={val:0,size:0};u<r.length;){e.CFF.getCharString(r,u,_);var P=_.val;if(u+=_.size,"o1"==P||"o18"==P)s.length%2!=0&&!h&&(d=s.shift()+n.nominalWidthX),i+=s.length>>1,s.length=0,h=!0;else if("o3"==P||"o23"==P){s.length%2!=0&&!h&&(d=s.shift()+n.nominalWidthX),i+=s.length>>1,s.length=0,h=!0;}else if("o4"==P)s.length>1&&!h&&(d=s.shift()+n.nominalWidthX,h=!0),f&&e.U.P.closePath(o),v+=s.pop(),e.U.P.moveTo(o,l,v),f=!0;else if("o5"==P)for(;s.length>0;)l+=s.shift(),v+=s.shift(),e.U.P.lineTo(o,l,v);else if("o6"==P||"o7"==P)for(var x=s.length,I="o6"==P,w=0;w<x;w++){var k=s.shift();I?l+=k:v+=k,I=!I,e.U.P.lineTo(o,l,v);}else if("o8"==P||"o24"==P){x=s.length;for(var G=0;G+6<=x;)c=l+s.shift(),p=v+s.shift(),U=c+s.shift(),g=p+s.shift(),l=U+s.shift(),v=g+s.shift(),e.U.P.curveTo(o,c,p,U,g,l,v),G+=6;"o24"==P&&(l+=s.shift(),v+=s.shift(),e.U.P.lineTo(o,l,v));}else {if("o11"==P)break;if("o1234"==P||"o1235"==P||"o1236"==P||"o1237"==P)"o1234"==P&&(p=v,U=(c=l+s.shift())+s.shift(),C=g=p+s.shift(),m=g,y=v,l=(b=(S=(F=U+s.shift())+s.shift())+s.shift())+s.shift(),e.U.P.curveTo(o,c,p,U,g,F,C),e.U.P.curveTo(o,S,m,b,y,l,v)),"o1235"==P&&(c=l+s.shift(),p=v+s.shift(),U=c+s.shift(),g=p+s.shift(),F=U+s.shift(),C=g+s.shift(),S=F+s.shift(),m=C+s.shift(),b=S+s.shift(),y=m+s.shift(),l=b+s.shift(),v=y+s.shift(),s.shift(),e.U.P.curveTo(o,c,p,U,g,F,C),e.U.P.curveTo(o,S,m,b,y,l,v)),"o1236"==P&&(c=l+s.shift(),p=v+s.shift(),U=c+s.shift(),C=g=p+s.shift(),m=g,b=(S=(F=U+s.shift())+s.shift())+s.shift(),y=m+s.shift(),l=b+s.shift(),e.U.P.curveTo(o,c,p,U,g,F,C),e.U.P.curveTo(o,S,m,b,y,l,v)),"o1237"==P&&(c=l+s.shift(),p=v+s.shift(),U=c+s.shift(),g=p+s.shift(),F=U+s.shift(),C=g+s.shift(),S=F+s.shift(),m=C+s.shift(),b=S+s.shift(),y=m+s.shift(),Math.abs(b-l)>Math.abs(y-v)?l=b+s.shift():v=y+s.shift(),e.U.P.curveTo(o,c,p,U,g,F,C),e.U.P.curveTo(o,S,m,b,y,l,v));else if("o14"==P){if(s.length>0&&!h&&(d=s.shift()+a.nominalWidthX,h=!0),4==s.length){var O=s.shift(),T=s.shift(),D=s.shift(),B=s.shift(),A=e.CFF.glyphBySE(a,D),R=e.CFF.glyphBySE(a,B);e.U._drawCFF(a.CharStrings[A],t,a,n,o),t.x=O,t.y=T,e.U._drawCFF(a.CharStrings[R],t,a,n,o);}f&&(e.U.P.closePath(o),f=!1);}else if("o19"==P||"o20"==P){s.length%2!=0&&!h&&(d=s.shift()+n.nominalWidthX),i+=s.length>>1,s.length=0,h=!0,u+=i+7>>3;}else if("o21"==P)s.length>2&&!h&&(d=s.shift()+n.nominalWidthX,h=!0),v+=s.pop(),l+=s.pop(),f&&e.U.P.closePath(o),e.U.P.moveTo(o,l,v),f=!0;else if("o22"==P)s.length>1&&!h&&(d=s.shift()+n.nominalWidthX,h=!0),l+=s.pop(),f&&e.U.P.closePath(o),e.U.P.moveTo(o,l,v),f=!0;else if("o25"==P){for(;s.length>6;)l+=s.shift(),v+=s.shift(),e.U.P.lineTo(o,l,v);c=l+s.shift(),p=v+s.shift(),U=c+s.shift(),g=p+s.shift(),l=U+s.shift(),v=g+s.shift(),e.U.P.curveTo(o,c,p,U,g,l,v);}else if("o26"==P)for(s.length%2&&(l+=s.shift());s.length>0;)c=l,p=v+s.shift(),l=U=c+s.shift(),v=(g=p+s.shift())+s.shift(),e.U.P.curveTo(o,c,p,U,g,l,v);else if("o27"==P)for(s.length%2&&(v+=s.shift());s.length>0;)p=v,U=(c=l+s.shift())+s.shift(),g=p+s.shift(),l=U+s.shift(),v=g,e.U.P.curveTo(o,c,p,U,g,l,v);else if("o10"==P||"o29"==P){var L="o10"==P?n:a;if(0==s.length)console.debug("error: empty stack");else {var W=s.pop(),M=L.Subrs[W+L.Bias];t.x=l,t.y=v,t.nStems=i,t.haveWidth=h,t.width=d,t.open=f,e.U._drawCFF(M,t,a,n,o),l=t.x,v=t.y,i=t.nStems,h=t.haveWidth,d=t.width,f=t.open;}}else if("o30"==P||"o31"==P){var V=s.length,E=(G=0,"o31"==P);for(G+=V-(x=-3&V);G<x;)E?(p=v,U=(c=l+s.shift())+s.shift(),v=(g=p+s.shift())+s.shift(),x-G==5?(l=U+s.shift(),G++):l=U,E=!1):(c=l,p=v+s.shift(),U=c+s.shift(),g=p+s.shift(),l=U+s.shift(),x-G==5?(v=g+s.shift(),G++):v=g,E=!0),e.U.P.curveTo(o,c,p,U,g,l,v),G+=4;}else {if("o"==(P+"").charAt(0))throw console.debug("Unknown operation: "+P,r),P;s.push(P);}}}t.x=l,t.y=v,t.nStems=i,t.haveWidth=h,t.width=d,t.open=f;};var t=e,a={Typr:t};return r.Typr=t,r.default=a,Object.defineProperty(r,"__esModule",{value:!0}),r}({}).Typr}
|
|
|
|
/*!
|
|
Custom bundle of woff2otf (https://github.com/arty-name/woff2otf) with fflate
|
|
(https://github.com/101arrowz/fflate) for use in Troika text rendering.
|
|
Original licenses apply:
|
|
- fflate: https://github.com/101arrowz/fflate/blob/master/LICENSE (MIT)
|
|
- woff2otf.js: https://github.com/arty-name/woff2otf/blob/master/woff2otf.js (Apache2)
|
|
*/
|
|
function woff2otfFactory(){return function(r){var e=Uint8Array,n=Uint16Array,t=Uint32Array,a=new e([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),i=new e([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),o=new e([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),f=function(r,e){for(var a=new n(31),i=0;i<31;++i)a[i]=e+=1<<r[i-1];var o=new t(a[30]);for(i=1;i<30;++i)for(var f=a[i];f<a[i+1];++f)o[f]=f-a[i]<<5|i;return [a,o]},u=f(a,2),v=u[0],s=u[1];v[28]=258,s[258]=28;for(var l=f(i,0)[0],c=new n(32768),g=0;g<32768;++g){var h=(43690&g)>>>1|(21845&g)<<1;h=(61680&(h=(52428&h)>>>2|(13107&h)<<2))>>>4|(3855&h)<<4,c[g]=((65280&h)>>>8|(255&h)<<8)>>>1;}var w=function(r,e,t){for(var a=r.length,i=0,o=new n(e);i<a;++i)++o[r[i]-1];var f,u=new n(e);for(i=0;i<e;++i)u[i]=u[i-1]+o[i-1]<<1;if(t){f=new n(1<<e);var v=15-e;for(i=0;i<a;++i)if(r[i])for(var s=i<<4|r[i],l=e-r[i],g=u[r[i]-1]++<<l,h=g|(1<<l)-1;g<=h;++g)f[c[g]>>>v]=s;}else for(f=new n(a),i=0;i<a;++i)r[i]&&(f[i]=c[u[r[i]-1]++]>>>15-r[i]);return f},d=new e(288);for(g=0;g<144;++g)d[g]=8;for(g=144;g<256;++g)d[g]=9;for(g=256;g<280;++g)d[g]=7;for(g=280;g<288;++g)d[g]=8;var m=new e(32);for(g=0;g<32;++g)m[g]=5;var b=w(d,9,1),p=w(m,5,1),y=function(r){for(var e=r[0],n=1;n<r.length;++n)r[n]>e&&(e=r[n]);return e},L=function(r,e,n){var t=e/8|0;return (r[t]|r[t+1]<<8)>>(7&e)&n},U=function(r,e){var n=e/8|0;return (r[n]|r[n+1]<<8|r[n+2]<<16)>>(7&e)},k=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],T=function(r,e,n){var t=new Error(e||k[r]);if(t.code=r,Error.captureStackTrace&&Error.captureStackTrace(t,T),!n)throw t;return t},O=function(r,f,u){var s=r.length;if(!s||u&&!u.l&&s<5)return f||new e(0);var c=!f||u,g=!u||u.i;u||(u={}),f||(f=new e(3*s));var h,d=function(r){var n=f.length;if(r>n){var t=new e(Math.max(2*n,r));t.set(f),f=t;}},m=u.f||0,k=u.p||0,O=u.b||0,A=u.l,x=u.d,E=u.m,D=u.n,M=8*s;do{if(!A){u.f=m=L(r,k,1);var S=L(r,k+1,3);if(k+=3,!S){var V=r[(I=((h=k)/8|0)+(7&h&&1)+4)-4]|r[I-3]<<8,_=I+V;if(_>s){g&&T(0);break}c&&d(O+V),f.set(r.subarray(I,_),O),u.b=O+=V,u.p=k=8*_;continue}if(1==S)A=b,x=p,E=9,D=5;else if(2==S){var j=L(r,k,31)+257,z=L(r,k+10,15)+4,C=j+L(r,k+5,31)+1;k+=14;for(var F=new e(C),P=new e(19),q=0;q<z;++q)P[o[q]]=L(r,k+3*q,7);k+=3*z;var B=y(P),G=(1<<B)-1,H=w(P,B,1);for(q=0;q<C;){var I,J=H[L(r,k,G)];if(k+=15&J,(I=J>>>4)<16)F[q++]=I;else {var K=0,N=0;for(16==I?(N=3+L(r,k,3),k+=2,K=F[q-1]):17==I?(N=3+L(r,k,7),k+=3):18==I&&(N=11+L(r,k,127),k+=7);N--;)F[q++]=K;}}var Q=F.subarray(0,j),R=F.subarray(j);E=y(Q),D=y(R),A=w(Q,E,1),x=w(R,D,1);}else T(1);if(k>M){g&&T(0);break}}c&&d(O+131072);for(var W=(1<<E)-1,X=(1<<D)-1,Y=k;;Y=k){var Z=(K=A[U(r,k)&W])>>>4;if((k+=15&K)>M){g&&T(0);break}if(K||T(2),Z<256)f[O++]=Z;else {if(256==Z){Y=k,A=null;break}var $=Z-254;if(Z>264){var rr=a[q=Z-257];$=L(r,k,(1<<rr)-1)+v[q],k+=rr;}var er=x[U(r,k)&X],nr=er>>>4;er||T(3),k+=15&er;R=l[nr];if(nr>3){rr=i[nr];R+=U(r,k)&(1<<rr)-1,k+=rr;}if(k>M){g&&T(0);break}c&&d(O+131072);for(var tr=O+$;O<tr;O+=4)f[O]=f[O-R],f[O+1]=f[O+1-R],f[O+2]=f[O+2-R],f[O+3]=f[O+3-R];O=tr;}}u.l=A,u.p=Y,u.b=O,A&&(m=1,u.m=E,u.d=x,u.n=D);}while(!m);return O==f.length?f:function(r,a,i){(null==a||a<0)&&(a=0),(null==i||i>r.length)&&(i=r.length);var o=new(r instanceof n?n:r instanceof t?t:e)(i-a);return o.set(r.subarray(a,i)),o}(f,0,O)},A=new e(0);var x="undefined"!=typeof TextDecoder&&new TextDecoder;try{x.decode(A,{stream:!0}),1;}catch(r){}return r.convert_streams=function(r){var e=new DataView(r),n=0;function t(){var r=e.getUint16(n);return n+=2,r}function a(){var r=e.getUint32(n);return n+=4,r}function i(r){m.setUint16(b,r),b+=2;}function o(r){m.setUint32(b,r),b+=4;}for(var f={signature:a(),flavor:a(),length:a(),numTables:t(),reserved:t(),totalSfntSize:a(),majorVersion:t(),minorVersion:t(),metaOffset:a(),metaLength:a(),metaOrigLength:a(),privOffset:a(),privLength:a()},u=0;Math.pow(2,u)<=f.numTables;)u++;u--;for(var v=16*Math.pow(2,u),s=16*f.numTables-v,l=12,c=[],g=0;g<f.numTables;g++)c.push({tag:a(),offset:a(),compLength:a(),origLength:a(),origChecksum:a()}),l+=16;var h,w=new Uint8Array(12+16*c.length+c.reduce((function(r,e){return r+e.origLength+4}),0)),d=w.buffer,m=new DataView(d),b=0;return o(f.flavor),i(f.numTables),i(v),i(u),i(s),c.forEach((function(r){o(r.tag),o(r.origChecksum),o(l),o(r.origLength),r.outOffset=l,(l+=r.origLength)%4!=0&&(l+=4-l%4);})),c.forEach((function(e){var n,t=r.slice(e.offset,e.offset+e.compLength);if(e.compLength!=e.origLength){var a=new Uint8Array(e.origLength);n=new Uint8Array(t,2),O(n,a);}else a=new Uint8Array(t);w.set(a,e.outOffset);var i=0;(l=e.outOffset+e.origLength)%4!=0&&(i=4-l%4),w.set(new Uint8Array(i).buffer,e.outOffset+e.origLength),h=l+i;})),d.slice(0,h)},Object.defineProperty(r,"__esModule",{value:!0}),r}({}).convert_streams}
|
|
|
|
/**
|
|
* A factory wrapper parsing a font file using Typr.
|
|
* Also adds support for WOFF files (not WOFF2).
|
|
*/
|
|
|
|
/**
|
|
* @typedef ParsedFont
|
|
* @property {number} ascender
|
|
* @property {number} descender
|
|
* @property {number} xHeight
|
|
* @property {(number) => boolean} supportsCodePoint
|
|
* @property {(text:string, fontSize:number, letterSpacing:number, callback) => number} forEachGlyph
|
|
* @property {number} lineGap
|
|
* @property {number} capHeight
|
|
* @property {number} unitsPerEm
|
|
*/
|
|
|
|
/**
|
|
* @typedef {(buffer: ArrayBuffer) => ParsedFont} FontParser
|
|
*/
|
|
|
|
/**
|
|
* @returns {FontParser}
|
|
*/
|
|
function parserFactory(Typr, woff2otf) {
|
|
const cmdArgLengths = {
|
|
M: 2,
|
|
L: 2,
|
|
Q: 4,
|
|
C: 6,
|
|
Z: 0
|
|
};
|
|
|
|
// {joinType: "skip+step,..."}
|
|
const joiningTypeRawData = {"C":"18g,ca,368,1kz","D":"17k,6,2,2+4,5+c,2+6,2+1,10+1,9+f,j+11,2+1,a,2,2+1,15+2,3,j+2,6+3,2+8,2,2,2+1,w+a,4+e,3+3,2,3+2,3+5,23+w,2f+4,3,2+9,2,b,2+3,3,1k+9,6+1,3+1,2+2,2+d,30g,p+y,1,1+1g,f+x,2,sd2+1d,jf3+4,f+3,2+4,2+2,b+3,42,2,4+2,2+1,2,3,t+1,9f+w,2,el+2,2+g,d+2,2l,2+1,5,3+1,2+1,2,3,6,16wm+1v","R":"17m+3,2,2,6+3,m,15+2,2+2,h+h,13,3+8,2,2,3+1,2,p+1,x,5+4,5,a,2,2,3,u,c+2,g+1,5,2+1,4+1,5j,6+1,2,b,2+2,f,2+1,1s+2,2,3+1,7,1ez0,2,2+1,4+4,b,4,3,b,42,2+2,4,3,2+1,2,o+3,ae,ep,x,2o+2,3+1,3,5+1,6","L":"x9u,jff,a,fd,jv","T":"4t,gj+33,7o+4,1+1,7c+18,2,2+1,2+1,2,21+a,2,1b+k,h,2u+6,3+5,3+1,2+3,y,2,v+q,2k+a,1n+8,a,p+3,2+8,2+2,2+4,18+2,3c+e,2+v,1k,2,5+7,5,4+6,b+1,u,1n,5+3,9,l+1,r,3+1,1m,5+1,5+1,3+2,4,v+1,4,c+1,1m,5+4,2+1,5,l+1,n+5,2,1n,3,2+3,9,8+1,c+1,v,1q,d,1f,4,1m+2,6+2,2+3,8+1,c+1,u,1n,3,7,6+1,l+1,t+1,1m+1,5+3,9,l+1,u,21,8+2,2,2j,3+6,d+7,2r,3+8,c+5,23+1,s,2,2,1k+d,2+4,2+1,6+a,2+z,a,2v+3,2+5,2+1,3+1,q+1,5+2,h+3,e,3+1,7,g,jk+2,qb+2,u+2,u+1,v+1,1t+1,2+6,9,3+a,a,1a+2,3c+1,z,3b+2,5+1,a,7+2,64+1,3,1n,2+6,2,2,3+7,7+9,3,1d+d,1,1+1,1s+3,1d,2+4,2,6,15+8,d+1,x+3,3+1,2+2,1l,2+1,4,2+2,1n+7,3+1,49+2,2+c,2+6,5,7,4+1,5j+1l,2+4,ek,3+1,r+4,1e+4,6+5,2p+c,1+3,1,1+2,1+b,2db+2,3y,2p+v,ff+3,30+1,n9x,1+2,2+9,x+1,29+1,7l,4,5,q+1,6,48+1,r+h,e,13+7,q+a,1b+2,1d,3+3,3+1,14,1w+5,3+1,3+1,d,9,1c,1g,2+2,3+1,6+1,2,17+1,9,6n,3,5,fn5,ki+f,h+f,5s,6y+2,ea,6b,46+4,1af+2,2+1,6+3,15+2,5,4m+1,fy+3,as+1,4a+a,4x,1j+e,1l+2,1e+3,3+1,1y+2,11+4,2+7,1r,d+1,1h+8,b+3,3,2o+2,3,2+1,7,4h,4+7,m+1,1m+1,4,12+6,4+4,5g+7,3+2,2,o,2d+5,2,5+1,2+1,6n+3,7+1,2+1,s+1,2e+7,3,2+1,2z,2,3+5,2,2u+2,3+3,2+4,78+8,2+1,75+1,2,5,41+3,3+1,5,x+9,15+5,3+3,9,a+5,3+2,1b+c,2+1,bb+6,2+5,2,2b+l,3+6,2+1,2+1,3f+5,4,2+1,2+6,2,21+1,4,2,9o+1,470+8,at4+4,1o+6,t5,1s+3,2a,f5l+1,2+3,43o+2,a+7,1+7,3+6,v+3,45+2,1j0+1i,5+1d,9,f,n+4,2+e,11t+6,2+g,3+6,2+1,2+4,7a+6,c6+3,15t+6,32+6,1,gzau,v+2n,3l+6n"};
|
|
|
|
const JT_LEFT = 1, //indicates that a character joins with the subsequent character, but does not join with the preceding character.
|
|
JT_RIGHT = 2, //indicates that a character joins with the preceding character, but does not join with the subsequent character.
|
|
JT_DUAL = 4, //indicates that a character joins with the preceding character and joins with the subsequent character.
|
|
JT_TRANSPARENT = 8, //indicates that the character does not join with adjacent characters and that the character must be skipped over when the shaping engine is evaluating the joining positions in a sequence of characters. When a JT_TRANSPARENT character is encountered in a sequence, the JOINING_TYPE of the preceding character passes through. Diacritical marks are frequently assigned this value.
|
|
JT_JOIN_CAUSING = 16, //indicates that the character forces the use of joining forms with the preceding and subsequent characters. Kashidas and the Zero Width Joiner (U+200D) are both JOIN_CAUSING characters.
|
|
JT_NON_JOINING = 32; //indicates that a character does not join with the preceding or with the subsequent character.,
|
|
|
|
let joiningTypeMap;
|
|
function getCharJoiningType(ch) {
|
|
if (!joiningTypeMap) {
|
|
const m = {
|
|
R: JT_RIGHT,
|
|
L: JT_LEFT,
|
|
D: JT_DUAL,
|
|
C: JT_JOIN_CAUSING,
|
|
U: JT_NON_JOINING,
|
|
T: JT_TRANSPARENT
|
|
};
|
|
joiningTypeMap = new Map();
|
|
for (let type in joiningTypeRawData) {
|
|
let lastCode = 0;
|
|
joiningTypeRawData[type].split(',').forEach(range => {
|
|
let [skip, step] = range.split('+');
|
|
skip = parseInt(skip,36);
|
|
step = step ? parseInt(step, 36) : 0;
|
|
joiningTypeMap.set(lastCode += skip, m[type]);
|
|
for (let i = step; i--;) {
|
|
joiningTypeMap.set(++lastCode, m[type]);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return joiningTypeMap.get(ch) || JT_NON_JOINING
|
|
}
|
|
|
|
const ISOL = 1, INIT = 2, FINA = 3, MEDI = 4;
|
|
const formsToFeatures = [null, 'isol', 'init', 'fina', 'medi'];
|
|
|
|
function detectJoiningForms(str) {
|
|
// This implements the algorithm described here:
|
|
// https://github.com/n8willis/opentype-shaping-documents/blob/master/opentype-shaping-arabic-general.md
|
|
const joiningForms = new Uint8Array(str.length);
|
|
let prevJoiningType = JT_NON_JOINING;
|
|
let prevForm = ISOL;
|
|
let prevIndex = -1;
|
|
for (let i = 0; i < str.length; i++) {
|
|
const code = str.codePointAt(i);
|
|
let joiningType = getCharJoiningType(code) | 0;
|
|
let form = ISOL;
|
|
if (joiningType & JT_TRANSPARENT) {
|
|
continue
|
|
}
|
|
if (prevJoiningType & (JT_LEFT | JT_DUAL | JT_JOIN_CAUSING)) {
|
|
if (joiningType & (JT_RIGHT | JT_DUAL | JT_JOIN_CAUSING)) {
|
|
form = FINA;
|
|
// isol->init, fina->medi
|
|
if (prevForm === ISOL || prevForm === FINA) {
|
|
joiningForms[prevIndex]++;
|
|
}
|
|
}
|
|
else if (joiningType & (JT_LEFT | JT_NON_JOINING)) {
|
|
// medi->fina, init->isol
|
|
if (prevForm === INIT || prevForm === MEDI) {
|
|
joiningForms[prevIndex]--;
|
|
}
|
|
}
|
|
}
|
|
else if (prevJoiningType & (JT_RIGHT | JT_NON_JOINING)) {
|
|
// medi->fina, init->isol
|
|
if (prevForm === INIT || prevForm === MEDI) {
|
|
joiningForms[prevIndex]--;
|
|
}
|
|
}
|
|
prevForm = joiningForms[i] = form;
|
|
prevJoiningType = joiningType;
|
|
prevIndex = i;
|
|
if (code > 0xffff) i++;
|
|
}
|
|
// console.log(str.split('').map(ch => ch.codePointAt(0).toString(16)))
|
|
// console.log(str.split('').map(ch => getCharJoiningType(ch.codePointAt(0))))
|
|
// console.log(Array.from(joiningForms).map(f => formsToFeatures[f] || 'none'))
|
|
return joiningForms
|
|
}
|
|
|
|
function stringToGlyphs (font, str) {
|
|
const glyphIds = [];
|
|
for (let i = 0; i < str.length; i++) {
|
|
const cc = str.codePointAt(i);
|
|
if (cc > 0xffff) i++;
|
|
glyphIds.push(Typr.U.codeToGlyph(font, cc));
|
|
}
|
|
|
|
const gsub = font['GSUB'];
|
|
if (gsub) {
|
|
const {lookupList, featureList} = gsub;
|
|
let joiningForms;
|
|
const supportedFeatures = /^(rlig|liga|mset|isol|init|fina|medi|half|pres|blws|ccmp)$/;
|
|
const usedLookups = [];
|
|
featureList.forEach(feature => {
|
|
if (supportedFeatures.test(feature.tag)) {
|
|
for (let ti = 0; ti < feature.tab.length; ti++) {
|
|
if (usedLookups[feature.tab[ti]]) continue
|
|
usedLookups[feature.tab[ti]] = true;
|
|
const tab = lookupList[feature.tab[ti]];
|
|
const isJoiningFeature = /^(isol|init|fina|medi)$/.test(feature.tag);
|
|
if (isJoiningFeature && !joiningForms) { //lazy
|
|
joiningForms = detectJoiningForms(str);
|
|
}
|
|
for (let ci = 0; ci < glyphIds.length; ci++) {
|
|
if (!joiningForms || !isJoiningFeature || formsToFeatures[joiningForms[ci]] === feature.tag) {
|
|
Typr.U._applySubs(glyphIds, ci, tab, lookupList);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return glyphIds
|
|
}
|
|
|
|
// Calculate advances and x/y offsets for each glyph, e.g. kerning and mark
|
|
// attachments. This is a more complete version of Typr.U.getPairAdjustment
|
|
// and should become an upstream replacement eventually.
|
|
function calcGlyphPositions(font, glyphIds) {
|
|
const positions = new Int16Array(glyphIds.length * 3); // [offsetX, offsetY, advanceX, ...]
|
|
let glyphIndex = 0;
|
|
for (; glyphIndex < glyphIds.length; glyphIndex++) {
|
|
const glyphId = glyphIds[glyphIndex];
|
|
if (glyphId === -1) continue;
|
|
|
|
positions[glyphIndex * 3 + 2] = font.hmtx.aWidth[glyphId]; // populate advanceX in...advance.
|
|
|
|
const gpos = font.GPOS;
|
|
if (gpos) {
|
|
const llist = gpos.lookupList;
|
|
for (let i = 0; i < llist.length; i++) {
|
|
const lookup = llist[i];
|
|
for (let j = 0; j < lookup.tabs.length; j++) {
|
|
const tab = lookup.tabs[j];
|
|
// Single char placement
|
|
if (lookup.ltype === 1) {
|
|
const ind = Typr._lctf.coverageIndex(tab.coverage, glyphId);
|
|
if (ind !== -1 && tab.pos) {
|
|
applyValueRecord(tab.pos, glyphIndex);
|
|
break
|
|
}
|
|
}
|
|
// Pairs (kerning)
|
|
else if (lookup.ltype === 2) {
|
|
let adj = null;
|
|
let prevGlyphIndex = getPrevGlyphIndex();
|
|
if (prevGlyphIndex !== -1) {
|
|
const coverageIndex = Typr._lctf.coverageIndex(tab.coverage, glyphIds[prevGlyphIndex]);
|
|
if (coverageIndex !== -1) {
|
|
if (tab.fmt === 1) {
|
|
const right = tab.pairsets[coverageIndex];
|
|
for (let k = 0; k < right.length; k++) {
|
|
if (right[k].gid2 === glyphId) adj = right[k];
|
|
}
|
|
} else if (tab.fmt === 2) {
|
|
const c1 = Typr.U._getGlyphClass(glyphIds[prevGlyphIndex], tab.classDef1);
|
|
const c2 = Typr.U._getGlyphClass(glyphId, tab.classDef2);
|
|
adj = tab.matrix[c1][c2];
|
|
}
|
|
if (adj) {
|
|
if (adj.val1) applyValueRecord(adj.val1, prevGlyphIndex);
|
|
if (adj.val2) applyValueRecord(adj.val2, glyphIndex);
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Mark to base
|
|
else if (lookup.ltype === 4) {
|
|
const markArrIndex = Typr._lctf.coverageIndex(tab.markCoverage, glyphId);
|
|
if (markArrIndex !== -1) {
|
|
const baseGlyphIndex = getPrevGlyphIndex(isBaseGlyph);
|
|
const baseArrIndex = baseGlyphIndex === -1 ? -1 : Typr._lctf.coverageIndex(tab.baseCoverage, glyphIds[baseGlyphIndex]);
|
|
if (baseArrIndex !== -1) {
|
|
const markRecord = tab.markArray[markArrIndex];
|
|
const baseAnchor = tab.baseArray[baseArrIndex][markRecord.markClass];
|
|
positions[glyphIndex * 3] = baseAnchor.x - markRecord.x + positions[baseGlyphIndex * 3] - positions[baseGlyphIndex * 3 + 2];
|
|
positions[glyphIndex * 3 + 1] = baseAnchor.y - markRecord.y + positions[baseGlyphIndex * 3 + 1];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Mark to mark
|
|
else if (lookup.ltype === 6) {
|
|
const mark1ArrIndex = Typr._lctf.coverageIndex(tab.mark1Coverage, glyphId);
|
|
if (mark1ArrIndex !== -1) {
|
|
const prevGlyphIndex = getPrevGlyphIndex();
|
|
if (prevGlyphIndex !== -1) {
|
|
const prevGlyphId = glyphIds[prevGlyphIndex];
|
|
if (getGlyphClass(font, prevGlyphId) === 3) { // only check mark glyphs
|
|
const mark2ArrIndex = Typr._lctf.coverageIndex(tab.mark2Coverage, prevGlyphId);
|
|
if (mark2ArrIndex !== -1) {
|
|
const mark1Record = tab.mark1Array[mark1ArrIndex];
|
|
const mark2Anchor = tab.mark2Array[mark2ArrIndex][mark1Record.markClass];
|
|
positions[glyphIndex * 3] = mark2Anchor.x - mark1Record.x + positions[prevGlyphIndex * 3] - positions[prevGlyphIndex * 3 + 2];
|
|
positions[glyphIndex * 3 + 1] = mark2Anchor.y - mark1Record.y + positions[prevGlyphIndex * 3 + 1];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Check kern table if no GPOS
|
|
else if (font.kern && !font.cff) {
|
|
const prevGlyphIndex = getPrevGlyphIndex();
|
|
if (prevGlyphIndex !== -1) {
|
|
const ind1 = font.kern.glyph1.indexOf(glyphIds[prevGlyphIndex]);
|
|
if (ind1 !== -1) {
|
|
const ind2 = font.kern.rval[ind1].glyph2.indexOf(glyphId);
|
|
if (ind2 !== -1) {
|
|
positions[prevGlyphIndex * 3 + 2] += font.kern.rval[ind1].vals[ind2];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return positions;
|
|
|
|
function getPrevGlyphIndex(filter) {
|
|
for (let i = glyphIndex - 1; i >=0; i--) {
|
|
if (glyphIds[i] !== -1 && (!filter || filter(glyphIds[i]))) {
|
|
return i
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function isBaseGlyph(glyphId) {
|
|
return getGlyphClass(font, glyphId) === 1;
|
|
}
|
|
|
|
function applyValueRecord(source, gi) {
|
|
for (let i = 0; i < 3; i++) {
|
|
positions[gi * 3 + i] += source[i] || 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
function getGlyphClass(font, glyphId) {
|
|
const classDef = font.GDEF && font.GDEF.glyphClassDef;
|
|
return classDef ? Typr.U._getGlyphClass(glyphId, classDef) : 0;
|
|
}
|
|
|
|
function firstNum(...args) {
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (typeof args[i] === 'number') {
|
|
return args[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns ParsedFont
|
|
*/
|
|
function wrapFontObj(typrFont) {
|
|
const glyphMap = Object.create(null);
|
|
|
|
const os2 = typrFont['OS/2'];
|
|
const hhea = typrFont.hhea;
|
|
const unitsPerEm = typrFont.head.unitsPerEm;
|
|
const ascender = firstNum(os2 && os2.sTypoAscender, hhea && hhea.ascender, unitsPerEm);
|
|
|
|
/** @type ParsedFont */
|
|
const fontObj = {
|
|
unitsPerEm,
|
|
ascender,
|
|
descender: firstNum(os2 && os2.sTypoDescender, hhea && hhea.descender, 0),
|
|
capHeight: firstNum(os2 && os2.sCapHeight, ascender),
|
|
xHeight: firstNum(os2 && os2.sxHeight, ascender),
|
|
lineGap: firstNum(os2 && os2.sTypoLineGap, hhea && hhea.lineGap),
|
|
supportsCodePoint(code) {
|
|
return Typr.U.codeToGlyph(typrFont, code) > 0
|
|
},
|
|
forEachGlyph(text, fontSize, letterSpacing, callback) {
|
|
let penX = 0;
|
|
const fontScale = 1 / fontObj.unitsPerEm * fontSize;
|
|
|
|
const glyphIds = stringToGlyphs(typrFont, text);
|
|
let charIndex = 0;
|
|
const positions = calcGlyphPositions(typrFont, glyphIds);
|
|
|
|
glyphIds.forEach((glyphId, i) => {
|
|
// Typr returns a glyph index per string codepoint, with -1s in place of those that
|
|
// were omitted due to ligature substitution. So we can track original index in the
|
|
// string via simple increment, and skip everything else when seeing a -1.
|
|
if (glyphId !== -1) {
|
|
let glyphObj = glyphMap[glyphId];
|
|
if (!glyphObj) {
|
|
const {cmds, crds} = Typr.U.glyphToPath(typrFont, glyphId);
|
|
|
|
// Build path string
|
|
let path = '';
|
|
let crdsIdx = 0;
|
|
for (let i = 0, len = cmds.length; i < len; i++) {
|
|
const numArgs = cmdArgLengths[cmds[i]];
|
|
path += cmds[i];
|
|
for (let j = 1; j <= numArgs; j++) {
|
|
path += (j > 1 ? ',' : '') + crds[crdsIdx++];
|
|
}
|
|
}
|
|
|
|
// Find extents - Glyf gives this in metadata but not CFF, and Typr doesn't
|
|
// normalize the two, so it's simplest just to iterate ourselves.
|
|
let xMin, yMin, xMax, yMax;
|
|
if (crds.length) {
|
|
xMin = yMin = Infinity;
|
|
xMax = yMax = -Infinity;
|
|
for (let i = 0, len = crds.length; i < len; i += 2) {
|
|
let x = crds[i];
|
|
let y = crds[i + 1];
|
|
if (x < xMin) xMin = x;
|
|
if (y < yMin) yMin = y;
|
|
if (x > xMax) xMax = x;
|
|
if (y > yMax) yMax = y;
|
|
}
|
|
} else {
|
|
xMin = xMax = yMin = yMax = 0;
|
|
}
|
|
|
|
glyphObj = glyphMap[glyphId] = {
|
|
index: glyphId,
|
|
advanceWidth: typrFont.hmtx.aWidth[glyphId],
|
|
xMin,
|
|
yMin,
|
|
xMax,
|
|
yMax,
|
|
path,
|
|
};
|
|
}
|
|
|
|
callback.call(
|
|
null,
|
|
glyphObj,
|
|
penX + positions[i * 3] * fontScale,
|
|
positions[i * 3 + 1] * fontScale,
|
|
charIndex
|
|
);
|
|
|
|
penX += positions[i * 3 + 2] * fontScale;
|
|
if (letterSpacing) {
|
|
penX += letterSpacing * fontSize;
|
|
}
|
|
}
|
|
charIndex += (text.codePointAt(charIndex) > 0xffff ? 2 : 1);
|
|
});
|
|
|
|
return penX
|
|
}
|
|
};
|
|
|
|
return fontObj
|
|
}
|
|
|
|
/**
|
|
* @type FontParser
|
|
*/
|
|
return function parse(buffer) {
|
|
// Look to see if we have a WOFF file and convert it if so:
|
|
const peek = new Uint8Array(buffer, 0, 4);
|
|
const tag = Typr._bin.readASCII(peek, 0, 4);
|
|
if (tag === 'wOFF') {
|
|
buffer = woff2otf(buffer);
|
|
} else if (tag === 'wOF2') {
|
|
throw new Error('woff2 fonts not supported')
|
|
}
|
|
return wrapFontObj(Typr.parse(buffer)[0])
|
|
}
|
|
}
|
|
|
|
|
|
const workerModule = /*#__PURE__*/defineWorkerModule({
|
|
name: 'Typr Font Parser',
|
|
dependencies: [typrFactory, woff2otfFactory, parserFactory],
|
|
init(typrFactory, woff2otfFactory, parserFactory) {
|
|
const Typr = typrFactory();
|
|
const woff2otf = woff2otfFactory();
|
|
return parserFactory(Typr, woff2otf)
|
|
}
|
|
});
|
|
|
|
/*!
|
|
Custom bundle of @unicode-font-resolver/client v1.0.2 (https://github.com/lojjic/unicode-font-resolver)
|
|
for use in Troika text rendering.
|
|
Original MIT license applies
|
|
*/
|
|
function unicodeFontResolverClientFactory(){return function(t){var n=function(){this.buckets=new Map;};n.prototype.add=function(t){var n=t>>5;this.buckets.set(n,(this.buckets.get(n)||0)|1<<(31&t));},n.prototype.has=function(t){var n=this.buckets.get(t>>5);return void 0!==n&&0!=(n&1<<(31&t))},n.prototype.serialize=function(){var t=[];return this.buckets.forEach((function(n,r){t.push((+r).toString(36)+":"+n.toString(36));})),t.join(",")},n.prototype.deserialize=function(t){var n=this;this.buckets.clear(),t.split(",").forEach((function(t){var r=t.split(":");n.buckets.set(parseInt(r[0],36),parseInt(r[1],36));}));};var r=Math.pow(2,8),e=r-1,o=~e;function a(t){var n=function(t){return t&o}(t).toString(16),e=function(t){return (t&o)+r-1}(t).toString(16);return "codepoint-index/plane"+(t>>16)+"/"+n+"-"+e+".json"}function i(t,n){var r=t&e,o=n.codePointAt(r/6|0);return 0!=((o=(o||48)-48)&1<<r%6)}function u(t,n){var r;(r=t,r.replace(/U\+/gi,"").replace(/^,+|,+$/g,"").split(/,+/).map((function(t){return t.split("-").map((function(t){return parseInt(t.trim(),16)}))}))).forEach((function(t){var r=t[0],e=t[1];void 0===e&&(e=r),n(r,e);}));}function c(t,n){u(t,(function(t,r){for(var e=t;e<=r;e++)n(e);}));}var s={},f={},l=new WeakMap,v="https://cdn.jsdelivr.net/gh/lojjic/unicode-font-resolver@v1.0.1/packages/data";function d(t){var r=l.get(t);return r||(r=new n,c(t.ranges,(function(t){return r.add(t)})),l.set(t,r)),r}var h,p=new Map;function g(t,n,r){return t[n]?n:t[r]?r:function(t){for(var n in t)return n}(t)}function w(t,n){var r=n;if(!t.includes(r)){r=1/0;for(var e=0;e<t.length;e++)Math.abs(t[e]-n)<Math.abs(r-n)&&(r=t[e]);}return r}function k(t){return h||(h=new Set,c("9-D,20,85,A0,1680,2000-200A,2028-202F,205F,3000",(function(t){h.add(t);}))),h.has(t)}return t.CodePointSet=n,t.clearCache=function(){s={},f={};},t.getFontsForString=function(t,n){void 0===n&&(n={});var r,e=n.lang;void 0===e&&(e=/\p{Script=Hangul}/u.test(r=t)?"ko":/\p{Script=Hiragana}|\p{Script=Katakana}/u.test(r)?"ja":"en");var o=n.category;void 0===o&&(o="sans-serif");var u=n.style;void 0===u&&(u="normal");var c=n.weight;void 0===c&&(c=400);var l=(n.dataUrl||v).replace(/\/$/g,""),h=new Map,y=new Uint8Array(t.length),b={},m={},A=new Array(t.length),S=new Map,j=!1;function M(t){var n=p.get(t);return n||(n=fetch(l+"/"+t).then((function(t){if(!t.ok)throw new Error(t.statusText);return t.json().then((function(t){if(!Array.isArray(t)||1!==t[0])throw new Error("Incorrect schema version; need 1, got "+t[0]);return t[1]}))})).catch((function(n){if(l!==v)return j||(console.error('unicode-font-resolver: Failed loading from dataUrl "'+l+'", trying default CDN. '+n.message),j=!0),l=v,p.delete(t),M(t);throw n})),p.set(t,n)),n}for(var P=function(n){var r=t.codePointAt(n),e=a(r);A[n]=e,s[e]||S.has(e)||S.set(e,M(e).then((function(t){s[e]=t;}))),r>65535&&(n++,E=n);},E=0;E<t.length;E++)P(E);return Promise.all(S.values()).then((function(){S.clear();for(var n=function(n){var o=t.codePointAt(n),a=null,u=s[A[n]],c=void 0;for(var l in u){var v=m[l];if(void 0===v&&(v=m[l]=new RegExp(l).test(e||"en")),v){for(var d in c=l,u[l])if(i(o,u[l][d])){a=d;break}break}}if(!a)t:for(var h in u)if(h!==c)for(var p in u[h])if(i(o,u[h][p])){a=p;break t}a||(console.debug("No font coverage for U+"+o.toString(16)),a="latin"),A[n]=a,f[a]||S.has(a)||S.set(a,M("font-meta/"+a+".json").then((function(t){f[a]=t;}))),o>65535&&(n++,r=n);},r=0;r<t.length;r++)n(r);return Promise.all(S.values())})).then((function(){for(var n,r=null,e=0;e<t.length;e++){var a=t.codePointAt(e);if(r&&(k(a)||d(r).has(a)))y[e]=y[e-1];else {r=f[A[e]];var i=b[r.id];if(!i){var s=r.typeforms,v=g(s,o,"sans-serif"),p=g(s[v],u,"normal"),m=w(null===(n=s[v])||void 0===n?void 0:n[p],c);i=b[r.id]=l+"/font-files/"+r.id+"/"+v+"."+p+"."+m+".woff";}var S=h.get(i);null==S&&(S=h.size,h.set(i,S)),y[e]=S;}a>65535&&(e++,y[e]=y[e-1]);}return {fontUrls:Array.from(h.keys()),chars:y}}))},Object.defineProperty(t,"__esModule",{value:!0}),t}({})}
|
|
|
|
/**
|
|
* @typedef {string | {src:string, label?:string, unicodeRange?:string, lang?:string}} UserFont
|
|
*/
|
|
|
|
/**
|
|
* @typedef {ClientOptions} FontResolverOptions
|
|
* @property {Array<UserFont>|UserFont} [fonts]
|
|
* @property {'normal'|'italic'} [style]
|
|
* @property {'normal'|'bold'|number} [style]
|
|
* @property {string} [unicodeFontsURL]
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} FontResolverResult
|
|
* @property {Uint8Array} chars
|
|
* @property {Array<ParsedFont & {src:string}>} fonts
|
|
*/
|
|
|
|
/**
|
|
* @typedef {function} FontResolver
|
|
* @param {string} text
|
|
* @param {(FontResolverResult) => void} callback
|
|
* @param {FontResolverOptions} [options]
|
|
*/
|
|
|
|
/**
|
|
* Factory for the FontResolver function.
|
|
* @param {FontParser} fontParser
|
|
* @param {{getFontsForString: function, CodePointSet: function}} unicodeFontResolverClient
|
|
* @return {FontResolver}
|
|
*/
|
|
function createFontResolver(fontParser, unicodeFontResolverClient) {
|
|
/**
|
|
* @type {Record<string, ParsedFont>}
|
|
*/
|
|
const parsedFonts = Object.create(null);
|
|
|
|
/**
|
|
* @type {Record<string, Array<(ParsedFont) => void>>}
|
|
*/
|
|
const loadingFonts = Object.create(null);
|
|
|
|
/**
|
|
* Load a given font url
|
|
*/
|
|
function doLoadFont(url, callback) {
|
|
const onError = err => {
|
|
console.error(`Failure loading font ${url}`, err);
|
|
};
|
|
try {
|
|
const request = new XMLHttpRequest();
|
|
request.open('get', url, true);
|
|
request.responseType = 'arraybuffer';
|
|
request.onload = function () {
|
|
if (request.status >= 400) {
|
|
onError(new Error(request.statusText));
|
|
}
|
|
else if (request.status > 0) {
|
|
try {
|
|
const fontObj = fontParser(request.response);
|
|
fontObj.src = url;
|
|
callback(fontObj);
|
|
} catch (e) {
|
|
onError(e);
|
|
}
|
|
}
|
|
};
|
|
request.onerror = onError;
|
|
request.send();
|
|
} catch(err) {
|
|
onError(err);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Load a given font url if needed, invoking a callback when it's loaded. If already
|
|
* loaded, the callback will be called synchronously.
|
|
* @param {string} fontUrl
|
|
* @param {(font: ParsedFont) => void} callback
|
|
*/
|
|
function loadFont(fontUrl, callback) {
|
|
let font = parsedFonts[fontUrl];
|
|
if (font) {
|
|
callback(font);
|
|
} else if (loadingFonts[fontUrl]) {
|
|
loadingFonts[fontUrl].push(callback);
|
|
} else {
|
|
loadingFonts[fontUrl] = [callback];
|
|
doLoadFont(fontUrl, fontObj => {
|
|
fontObj.src = fontUrl;
|
|
parsedFonts[fontUrl] = fontObj;
|
|
loadingFonts[fontUrl].forEach(cb => cb(fontObj));
|
|
delete loadingFonts[fontUrl];
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* For a given string of text, determine which fonts are required to fully render it and
|
|
* ensure those fonts are loaded.
|
|
*/
|
|
return function (text, callback, {
|
|
lang,
|
|
fonts: userFonts = [],
|
|
style = 'normal',
|
|
weight = 'normal',
|
|
unicodeFontsURL
|
|
} = {}) {
|
|
const charResolutions = new Uint8Array(text.length);
|
|
const fontResolutions = [];
|
|
if (!text.length) {
|
|
allDone();
|
|
}
|
|
|
|
const fontIndices = new Map();
|
|
const fallbackRanges = []; // [[start, end], ...]
|
|
|
|
if (style !== 'italic') style = 'normal';
|
|
if (typeof weight !== 'number') {
|
|
weight = weight === 'bold' ? 700 : 400;
|
|
}
|
|
|
|
if (userFonts && !Array.isArray(userFonts)) {
|
|
userFonts = [userFonts];
|
|
}
|
|
userFonts = userFonts.slice()
|
|
// filter by language
|
|
.filter(def => !def.lang || def.lang.test(lang))
|
|
// switch order for easier iteration
|
|
.reverse();
|
|
if (userFonts.length) {
|
|
const UNKNOWN = 0;
|
|
const RESOLVED = 1;
|
|
const NEEDS_FALLBACK = 2;
|
|
let prevCharResult = UNKNOWN
|
|
|
|
;(function resolveUserFonts (startIndex = 0) {
|
|
for (let i = startIndex, iLen = text.length; i < iLen; i++) {
|
|
const codePoint = text.codePointAt(i);
|
|
// Carry previous character's result forward if:
|
|
// - it resolved to a font that also covers this character
|
|
// - this character is whitespace
|
|
if (
|
|
(prevCharResult === RESOLVED && fontResolutions[charResolutions[i - 1]].supportsCodePoint(codePoint)) ||
|
|
(i > 0 && /\s/.test(text[i]))
|
|
) {
|
|
charResolutions[i] = charResolutions[i - 1];
|
|
if (prevCharResult === NEEDS_FALLBACK) {
|
|
fallbackRanges[fallbackRanges.length - 1][1] = i;
|
|
}
|
|
} else {
|
|
for (let j = charResolutions[i], jLen = userFonts.length; j <= jLen; j++) {
|
|
if (j === jLen) {
|
|
// none of the user fonts matched; needs fallback
|
|
const range = prevCharResult === NEEDS_FALLBACK ?
|
|
fallbackRanges[fallbackRanges.length - 1] :
|
|
(fallbackRanges[fallbackRanges.length] = [i, i]);
|
|
range[1] = i;
|
|
prevCharResult = NEEDS_FALLBACK;
|
|
} else {
|
|
charResolutions[i] = j;
|
|
const { src, unicodeRange } = userFonts[j];
|
|
// filter by optional explicit unicode ranges
|
|
if (!unicodeRange || isCodeInRanges(codePoint, unicodeRange)) {
|
|
const fontObj = parsedFonts[src];
|
|
// font not yet loaded, load it and resume
|
|
if (!fontObj) {
|
|
loadFont(src, () => {
|
|
resolveUserFonts(i);
|
|
});
|
|
return;
|
|
}
|
|
// if the font actually contains a glyph for this char, lock it in
|
|
if (fontObj.supportsCodePoint(codePoint)) {
|
|
let fontIndex = fontIndices.get(fontObj);
|
|
if (typeof fontIndex !== 'number') {
|
|
fontIndex = fontResolutions.length;
|
|
fontResolutions.push(fontObj);
|
|
fontIndices.set(fontObj, fontIndex);
|
|
}
|
|
charResolutions[i] = fontIndex;
|
|
prevCharResult = RESOLVED;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (codePoint > 0xffff && i + 1 < iLen) {
|
|
charResolutions[i + 1] = charResolutions[i];
|
|
i++;
|
|
if (prevCharResult === NEEDS_FALLBACK) {
|
|
fallbackRanges[fallbackRanges.length - 1][1] = i;
|
|
}
|
|
}
|
|
}
|
|
resolveFallbacks();
|
|
})();
|
|
} else {
|
|
fallbackRanges.push([0, text.length - 1]);
|
|
resolveFallbacks();
|
|
}
|
|
|
|
function resolveFallbacks() {
|
|
if (fallbackRanges.length) {
|
|
// Combine all fallback substrings into a single string for querying
|
|
const fallbackString = fallbackRanges.map(range => text.substring(range[0], range[1] + 1)).join('\n');
|
|
unicodeFontResolverClient.getFontsForString(fallbackString, {
|
|
lang: lang || undefined,
|
|
style,
|
|
weight,
|
|
dataUrl: unicodeFontsURL
|
|
}).then(({fontUrls, chars}) => {
|
|
// Extract results and put them back in the main array
|
|
const fontIndexOffset = fontResolutions.length;
|
|
let charIdx = 0;
|
|
fallbackRanges.forEach(range => {
|
|
for (let i = 0, endIdx = range[1] - range[0]; i <= endIdx; i++) {
|
|
charResolutions[range[0] + i] = chars[charIdx++] + fontIndexOffset;
|
|
}
|
|
charIdx++; //skip segment separator
|
|
});
|
|
|
|
// Load and parse the fallback fonts - avoiding Promise here to prevent polyfills in the worker
|
|
let loadedCount = 0;
|
|
fontUrls.forEach((url, i) => {
|
|
loadFont(url, fontObj => {
|
|
fontResolutions[i + fontIndexOffset] = fontObj;
|
|
if (++loadedCount === fontUrls.length) {
|
|
allDone();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
} else {
|
|
allDone();
|
|
}
|
|
}
|
|
|
|
function allDone() {
|
|
callback({
|
|
chars: charResolutions,
|
|
fonts: fontResolutions
|
|
});
|
|
}
|
|
|
|
function isCodeInRanges(code, ranges) {
|
|
// todo optimize search - CodePointSet from unicode-font-resolver?
|
|
for (let k = 0; k < ranges.length; k++) {
|
|
const [start, end = start] = ranges[k];
|
|
if (start <= code && code <= end) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
const fontResolverWorkerModule = /*#__PURE__*/defineWorkerModule({
|
|
name: 'FontResolver',
|
|
dependencies: [
|
|
createFontResolver,
|
|
workerModule,
|
|
unicodeFontResolverClientFactory,
|
|
],
|
|
init(createFontResolver, fontParser, unicodeFontResolverClientFactory) {
|
|
return createFontResolver(fontParser, unicodeFontResolverClientFactory());
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @typedef {number|'left'|'center'|'right'} AnchorXValue
|
|
*/
|
|
/**
|
|
* @typedef {number|'top'|'top-baseline'|'top-cap'|'top-ex'|'middle'|'bottom-baseline'|'bottom'} AnchorYValue
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} TypesetParams
|
|
* @property {string} text
|
|
* @property {UserFont|UserFont[]} [font]
|
|
* @property {string} [lang]
|
|
* @property {number} [sdfGlyphSize=64]
|
|
* @property {number} [fontSize=1]
|
|
* @property {number|'normal'|'bold'} [fontWeight='normal']
|
|
* @property {'normal'|'italic'} [fontStyle='normal']
|
|
* @property {number} [letterSpacing=0]
|
|
* @property {'normal'|number} [lineHeight='normal']
|
|
* @property {number} [maxWidth]
|
|
* @property {'ltr'|'rtl'} [direction='ltr']
|
|
* @property {string} [textAlign='left']
|
|
* @property {number} [textIndent=0]
|
|
* @property {'normal'|'nowrap'} [whiteSpace='normal']
|
|
* @property {'normal'|'break-word'} [overflowWrap='normal']
|
|
* @property {AnchorXValue} [anchorX=0]
|
|
* @property {AnchorYValue} [anchorY=0]
|
|
* @property {boolean} [metricsOnly=false]
|
|
* @property {string} [unicodeFontsURL]
|
|
* @property {FontResolverResult} [preResolvedFonts]
|
|
* @property {boolean} [includeCaretPositions=false]
|
|
* @property {number} [chunkedBoundsSize=8192]
|
|
* @property {{[rangeStartIndex]: number}} [colorRanges]
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} TypesetResult
|
|
* @property {Uint16Array} glyphIds id for each glyph, specific to that glyph's font
|
|
* @property {Uint8Array} glyphFontIndices index into fontData for each glyph
|
|
* @property {Float32Array} glyphPositions x,y of each glyph's origin in layout
|
|
* @property {{[font]: {[glyphId]: {path: string, pathBounds: number[]}}}} glyphData data about each glyph appearing in the text
|
|
* @property {TypesetFontData[]} fontData data about each font used in the text
|
|
* @property {Float32Array} [caretPositions] startX,endX,bottomY caret positions for each char
|
|
* @property {Uint8Array} [glyphColors] color for each glyph, if color ranges supplied
|
|
* chunkedBounds, //total rects per (n=chunkedBoundsSize) consecutive glyphs
|
|
* fontSize, //calculated em height
|
|
* topBaseline: anchorYOffset + lines[0].baseline, //y coordinate of the top line's baseline
|
|
* blockBounds: [ //bounds for the whole block of text, including vertical padding for lineHeight
|
|
* anchorXOffset,
|
|
* anchorYOffset - totalHeight,
|
|
* anchorXOffset + maxLineWidth,
|
|
* anchorYOffset
|
|
* ],
|
|
* visibleBounds, //total bounds of visible text paths, may be larger or smaller than blockBounds
|
|
* timings
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} TypesetFontData
|
|
* @property src
|
|
* @property unitsPerEm
|
|
* @property ascender
|
|
* @property descender
|
|
* @property lineHeight
|
|
* @property capHeight
|
|
* @property xHeight
|
|
*/
|
|
|
|
/**
|
|
* @typedef {function} TypesetterTypesetFunction - compute fonts and layout for some text.
|
|
* @param {TypesetParams} params
|
|
* @param {(TypesetResult) => void} callback - function called when typesetting is complete.
|
|
* If the params included `preResolvedFonts`, this will be called synchronously.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {function} TypesetterMeasureFunction - compute width/height for some text.
|
|
* @param {TypesetParams} params
|
|
* @param {(width:number, height:number) => void} callback - function called when measurement is complete.
|
|
* If the params included `preResolvedFonts`, this will be called synchronously.
|
|
*/
|
|
|
|
|
|
/**
|
|
* Factory function that creates a self-contained environment for processing text typesetting requests.
|
|
*
|
|
* It is important that this function has no closure dependencies, so that it can be easily injected
|
|
* into the source for a Worker without requiring a build step or complex dependency loading. All its
|
|
* dependencies must be passed in at initialization.
|
|
*
|
|
* @param {FontResolver} resolveFonts - function to resolve a string to parsed fonts
|
|
* @param {object} bidi - the bidi.js implementation object
|
|
* @return {{typeset: TypesetterTypesetFunction, measure: TypesetterMeasureFunction}}
|
|
*/
|
|
function createTypesetter(resolveFonts, bidi) {
|
|
const INF = Infinity;
|
|
|
|
// Set of Unicode Default_Ignorable_Code_Point characters, these will not produce visible glyphs
|
|
// eslint-disable-next-line no-misleading-character-class
|
|
const DEFAULT_IGNORABLE_CHARS = /[\u00AD\u034F\u061C\u115F-\u1160\u17B4-\u17B5\u180B-\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0F\uFEFF\uFFA0\uFFF0-\uFFF8]/;
|
|
|
|
// This regex (instead of /\s/) allows us to select all whitespace EXCEPT for non-breaking white spaces
|
|
const lineBreakingWhiteSpace = `[^\\S\\u00A0]`;
|
|
|
|
// Incomplete set of characters that allow line breaking after them
|
|
// In the future we may consider a full Unicode line breaking algorithm impl: https://www.unicode.org/reports/tr14
|
|
const BREAK_AFTER_CHARS = new RegExp(`${lineBreakingWhiteSpace}|[\\-\\u007C\\u00AD\\u2010\\u2012-\\u2014\\u2027\\u2056\\u2E17\\u2E40]`);
|
|
|
|
/**
|
|
* Load and parse all the necessary fonts to render a given string of text, then group
|
|
* them into consecutive runs of characters sharing a font.
|
|
*/
|
|
function calculateFontRuns({text, lang, fonts, style, weight, preResolvedFonts, unicodeFontsURL}, onDone) {
|
|
const onResolved = ({chars, fonts: parsedFonts}) => {
|
|
let curRun, prevVal;
|
|
const runs = [];
|
|
for (let i = 0; i < chars.length; i++) {
|
|
if (chars[i] !== prevVal) {
|
|
prevVal = chars[i];
|
|
runs.push(curRun = { start: i, end: i, fontObj: parsedFonts[chars[i]]});
|
|
} else {
|
|
curRun.end = i;
|
|
}
|
|
}
|
|
onDone(runs);
|
|
};
|
|
if (preResolvedFonts) {
|
|
onResolved(preResolvedFonts);
|
|
} else {
|
|
resolveFonts(
|
|
text,
|
|
onResolved,
|
|
{ lang, fonts, style, weight, unicodeFontsURL }
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main entry point.
|
|
* Process a text string with given font and formatting parameters, and return all info
|
|
* necessary to render all its glyphs.
|
|
* @type TypesetterTypesetFunction
|
|
*/
|
|
function typeset(
|
|
{
|
|
text='',
|
|
font,
|
|
lang,
|
|
sdfGlyphSize=64,
|
|
fontSize=400,
|
|
fontWeight=1,
|
|
fontStyle='normal',
|
|
letterSpacing=0,
|
|
lineHeight='normal',
|
|
maxWidth=INF,
|
|
direction,
|
|
textAlign='left',
|
|
textIndent=0,
|
|
whiteSpace='normal',
|
|
overflowWrap='normal',
|
|
anchorX = 0,
|
|
anchorY = 0,
|
|
metricsOnly=false,
|
|
unicodeFontsURL,
|
|
preResolvedFonts=null,
|
|
includeCaretPositions=false,
|
|
chunkedBoundsSize=8192,
|
|
colorRanges=null
|
|
},
|
|
callback
|
|
) {
|
|
const mainStart = now();
|
|
const timings = {fontLoad: 0, typesetting: 0};
|
|
|
|
// Ensure newlines are normalized
|
|
if (text.indexOf('\r') > -1) {
|
|
console.info('Typesetter: got text with \\r chars; normalizing to \\n');
|
|
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
}
|
|
|
|
// Ensure we've got numbers not strings
|
|
fontSize = +fontSize;
|
|
letterSpacing = +letterSpacing;
|
|
maxWidth = +maxWidth;
|
|
lineHeight = lineHeight || 'normal';
|
|
textIndent = +textIndent;
|
|
|
|
calculateFontRuns({
|
|
text,
|
|
lang,
|
|
style: fontStyle,
|
|
weight: fontWeight,
|
|
fonts: typeof font === 'string' ? [{src: font}] : font,
|
|
unicodeFontsURL,
|
|
preResolvedFonts
|
|
}, runs => {
|
|
timings.fontLoad = now() - mainStart;
|
|
const hasMaxWidth = isFinite(maxWidth);
|
|
let glyphIds = null;
|
|
let glyphFontIndices = null;
|
|
let glyphPositions = null;
|
|
let glyphData = null;
|
|
let glyphColors = null;
|
|
let caretPositions = null;
|
|
let visibleBounds = null;
|
|
let chunkedBounds = null;
|
|
let maxLineWidth = 0;
|
|
let renderableGlyphCount = 0;
|
|
let canWrap = whiteSpace !== 'nowrap';
|
|
const metricsByFont = new Map(); // fontObj -> metrics
|
|
const typesetStart = now();
|
|
|
|
// Distribute glyphs into lines based on wrapping
|
|
let lineXOffset = textIndent;
|
|
let prevRunEndX = 0;
|
|
let currentLine = new TextLine();
|
|
const lines = [currentLine];
|
|
runs.forEach(run => {
|
|
const { fontObj } = run;
|
|
const { ascender, descender, unitsPerEm, lineGap, capHeight, xHeight } = fontObj;
|
|
|
|
// Calculate metrics for each font used
|
|
let fontData = metricsByFont.get(fontObj);
|
|
if (!fontData) {
|
|
// Find conversion between native font units and fontSize units
|
|
const fontSizeMult = fontSize / unitsPerEm;
|
|
|
|
// Determine appropriate value for 'normal' line height based on the font's actual metrics
|
|
// This does not guarantee individual glyphs won't exceed the line height, e.g. Roboto; should we use yMin/Max instead?
|
|
const calcLineHeight = lineHeight === 'normal' ?
|
|
(ascender - descender + lineGap) * fontSizeMult : lineHeight * fontSize;
|
|
|
|
// Determine line height and leading adjustments
|
|
const halfLeading = (calcLineHeight - (ascender - descender) * fontSizeMult) / 2;
|
|
const caretHeight = Math.min(calcLineHeight, (ascender - descender) * fontSizeMult);
|
|
const caretTop = (ascender + descender) / 2 * fontSizeMult + caretHeight / 2;
|
|
fontData = {
|
|
index: metricsByFont.size,
|
|
src: fontObj.src,
|
|
fontObj,
|
|
fontSizeMult,
|
|
unitsPerEm,
|
|
ascender: ascender * fontSizeMult,
|
|
descender: descender * fontSizeMult,
|
|
capHeight: capHeight * fontSizeMult,
|
|
xHeight: xHeight * fontSizeMult,
|
|
lineHeight: calcLineHeight,
|
|
baseline: -halfLeading - ascender * fontSizeMult, // baseline offset from top of line height
|
|
// cap: -halfLeading - capHeight * fontSizeMult, // cap from top of line height
|
|
// ex: -halfLeading - xHeight * fontSizeMult, // ex from top of line height
|
|
caretTop,
|
|
caretBottom: caretTop - caretHeight
|
|
};
|
|
metricsByFont.set(fontObj, fontData);
|
|
}
|
|
const { fontSizeMult } = fontData;
|
|
|
|
const runText = text.slice(run.start, run.end + 1);
|
|
let prevGlyphX, prevGlyphObj;
|
|
fontObj.forEachGlyph(runText, fontSize, letterSpacing, (glyphObj, glyphX, glyphY, charIndex) => {
|
|
glyphX += prevRunEndX;
|
|
charIndex += run.start;
|
|
prevGlyphX = glyphX;
|
|
prevGlyphObj = glyphObj;
|
|
const char = text.charAt(charIndex);
|
|
const glyphWidth = glyphObj.advanceWidth * fontSizeMult;
|
|
const curLineCount = currentLine.count;
|
|
let nextLine;
|
|
|
|
// Calc isWhitespace and isEmpty once per glyphObj
|
|
if (!('isEmpty' in glyphObj)) {
|
|
glyphObj.isWhitespace = !!char && new RegExp(lineBreakingWhiteSpace).test(char);
|
|
glyphObj.canBreakAfter = !!char && BREAK_AFTER_CHARS.test(char);
|
|
glyphObj.isEmpty = glyphObj.xMin === glyphObj.xMax || glyphObj.yMin === glyphObj.yMax || DEFAULT_IGNORABLE_CHARS.test(char);
|
|
}
|
|
if (!glyphObj.isWhitespace && !glyphObj.isEmpty) {
|
|
renderableGlyphCount++;
|
|
}
|
|
|
|
// If a non-whitespace character overflows the max width, we need to soft-wrap
|
|
if (canWrap && hasMaxWidth && !glyphObj.isWhitespace && glyphX + glyphWidth + lineXOffset > maxWidth && curLineCount) {
|
|
// If it's the first char after a whitespace, start a new line
|
|
if (currentLine.glyphAt(curLineCount - 1).glyphObj.canBreakAfter) {
|
|
nextLine = new TextLine();
|
|
lineXOffset = -glyphX;
|
|
} else {
|
|
// Back up looking for a whitespace character to wrap at
|
|
for (let i = curLineCount; i--;) {
|
|
// If we got the start of the line there's no soft break point; make hard break if overflowWrap='break-word'
|
|
if (i === 0 && overflowWrap === 'break-word') {
|
|
nextLine = new TextLine();
|
|
lineXOffset = -glyphX;
|
|
break
|
|
}
|
|
// Found a soft break point; move all chars since it to a new line
|
|
else if (currentLine.glyphAt(i).glyphObj.canBreakAfter) {
|
|
nextLine = currentLine.splitAt(i + 1);
|
|
const adjustX = nextLine.glyphAt(0).x;
|
|
lineXOffset -= adjustX;
|
|
for (let j = nextLine.count; j--;) {
|
|
nextLine.glyphAt(j).x -= adjustX;
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (nextLine) {
|
|
currentLine.isSoftWrapped = true;
|
|
currentLine = nextLine;
|
|
lines.push(currentLine);
|
|
maxLineWidth = maxWidth; //after soft wrapping use maxWidth as calculated width
|
|
}
|
|
}
|
|
|
|
let fly = currentLine.glyphAt(currentLine.count);
|
|
fly.glyphObj = glyphObj;
|
|
fly.x = glyphX + lineXOffset;
|
|
fly.y = glyphY;
|
|
fly.width = glyphWidth;
|
|
fly.charIndex = charIndex;
|
|
fly.fontData = fontData;
|
|
|
|
// Handle hard line breaks
|
|
if (char === '\n') {
|
|
currentLine = new TextLine();
|
|
lines.push(currentLine);
|
|
lineXOffset = -(glyphX + glyphWidth + (letterSpacing * fontSize)) + textIndent;
|
|
}
|
|
});
|
|
// At the end of a run we must capture the x position as the starting point for the next run
|
|
prevRunEndX = prevGlyphX + prevGlyphObj.advanceWidth * fontSizeMult + letterSpacing * fontSize;
|
|
});
|
|
|
|
// Calculate width/height/baseline of each line (excluding trailing whitespace) and maximum block width
|
|
let totalHeight = 0;
|
|
lines.forEach(line => {
|
|
let isTrailingWhitespace = true;
|
|
for (let i = line.count; i--;) {
|
|
const glyphInfo = line.glyphAt(i);
|
|
// omit trailing whitespace from width calculation
|
|
if (isTrailingWhitespace && !glyphInfo.glyphObj.isWhitespace) {
|
|
line.width = glyphInfo.x + glyphInfo.width;
|
|
if (line.width > maxLineWidth) {
|
|
maxLineWidth = line.width;
|
|
}
|
|
isTrailingWhitespace = false;
|
|
}
|
|
// use the tallest line height, lowest baseline, and highest cap/ex
|
|
let {lineHeight, capHeight, xHeight, baseline} = glyphInfo.fontData;
|
|
if (lineHeight > line.lineHeight) line.lineHeight = lineHeight;
|
|
const baselineDiff = baseline - line.baseline;
|
|
if (baselineDiff < 0) { //shift all metrics down
|
|
line.baseline += baselineDiff;
|
|
line.cap += baselineDiff;
|
|
line.ex += baselineDiff;
|
|
}
|
|
// compare cap/ex based on new lowest baseline
|
|
line.cap = Math.max(line.cap, line.baseline + capHeight);
|
|
line.ex = Math.max(line.ex, line.baseline + xHeight);
|
|
}
|
|
line.baseline -= totalHeight;
|
|
line.cap -= totalHeight;
|
|
line.ex -= totalHeight;
|
|
totalHeight += line.lineHeight;
|
|
});
|
|
|
|
// Find overall position adjustments for anchoring
|
|
let anchorXOffset = 0;
|
|
let anchorYOffset = 0;
|
|
if (anchorX) {
|
|
if (typeof anchorX === 'number') {
|
|
anchorXOffset = -anchorX;
|
|
}
|
|
else if (typeof anchorX === 'string') {
|
|
anchorXOffset = -maxLineWidth * (
|
|
anchorX === 'left' ? 0 :
|
|
anchorX === 'center' ? 0.5 :
|
|
anchorX === 'right' ? 1 :
|
|
parsePercent(anchorX)
|
|
);
|
|
}
|
|
}
|
|
if (anchorY) {
|
|
if (typeof anchorY === 'number') {
|
|
anchorYOffset = -anchorY;
|
|
}
|
|
else if (typeof anchorY === 'string') {
|
|
anchorYOffset = anchorY === 'top' ? 0 :
|
|
anchorY === 'top-baseline' ? -lines[0].baseline :
|
|
anchorY === 'top-cap' ? -lines[0].cap :
|
|
anchorY === 'top-ex' ? -lines[0].ex :
|
|
anchorY === 'middle' ? totalHeight / 2 :
|
|
anchorY === 'bottom' ? totalHeight :
|
|
anchorY === 'bottom-baseline' ? -lines[lines.length - 1].baseline :
|
|
parsePercent(anchorY) * totalHeight;
|
|
}
|
|
}
|
|
|
|
if (!metricsOnly) {
|
|
// Resolve bidi levels
|
|
const bidiLevelsResult = bidi.getEmbeddingLevels(text, direction);
|
|
|
|
// Process each line, applying alignment offsets, adding each glyph to the atlas, and
|
|
// collecting all renderable glyphs into a single collection.
|
|
glyphIds = new Uint16Array(renderableGlyphCount);
|
|
glyphFontIndices = new Uint8Array(renderableGlyphCount);
|
|
glyphPositions = new Float32Array(renderableGlyphCount * 2);
|
|
glyphData = {};
|
|
visibleBounds = [INF, INF, -INF, -INF];
|
|
chunkedBounds = [];
|
|
if (includeCaretPositions) {
|
|
caretPositions = new Float32Array(text.length * 4);
|
|
}
|
|
if (colorRanges) {
|
|
glyphColors = new Uint8Array(renderableGlyphCount * 3);
|
|
}
|
|
let renderableGlyphIndex = 0;
|
|
let prevCharIndex = -1;
|
|
let colorCharIndex = -1;
|
|
let chunk;
|
|
let currentColor;
|
|
lines.forEach((line, lineIndex) => {
|
|
let {count:lineGlyphCount, width:lineWidth} = line;
|
|
|
|
// Ignore empty lines
|
|
if (lineGlyphCount > 0) {
|
|
// Count trailing whitespaces, we want to ignore these for certain things
|
|
let trailingWhitespaceCount = 0;
|
|
for (let i = lineGlyphCount; i-- && line.glyphAt(i).glyphObj.isWhitespace;) {
|
|
trailingWhitespaceCount++;
|
|
}
|
|
|
|
// Apply horizontal alignment adjustments
|
|
let lineXOffset = 0;
|
|
let justifyAdjust = 0;
|
|
if (textAlign === 'center') {
|
|
lineXOffset = (maxLineWidth - lineWidth) / 2;
|
|
} else if (textAlign === 'right') {
|
|
lineXOffset = maxLineWidth - lineWidth;
|
|
} else if (textAlign === 'justify' && line.isSoftWrapped) {
|
|
// count non-trailing whitespace characters, and we'll adjust the offsets per character in the next loop
|
|
let whitespaceCount = 0;
|
|
for (let i = lineGlyphCount - trailingWhitespaceCount; i--;) {
|
|
if (line.glyphAt(i).glyphObj.isWhitespace) {
|
|
whitespaceCount++;
|
|
}
|
|
}
|
|
justifyAdjust = (maxLineWidth - lineWidth) / whitespaceCount;
|
|
}
|
|
if (justifyAdjust || lineXOffset) {
|
|
let justifyOffset = 0;
|
|
for (let i = 0; i < lineGlyphCount; i++) {
|
|
let glyphInfo = line.glyphAt(i);
|
|
const glyphObj = glyphInfo.glyphObj;
|
|
glyphInfo.x += lineXOffset + justifyOffset;
|
|
// Expand non-trailing whitespaces for justify alignment
|
|
if (justifyAdjust !== 0 && glyphObj.isWhitespace && i < lineGlyphCount - trailingWhitespaceCount) {
|
|
justifyOffset += justifyAdjust;
|
|
glyphInfo.width += justifyAdjust;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Perform bidi range flipping
|
|
const flips = bidi.getReorderSegments(
|
|
text, bidiLevelsResult, line.glyphAt(0).charIndex, line.glyphAt(line.count - 1).charIndex
|
|
);
|
|
for (let fi = 0; fi < flips.length; fi++) {
|
|
const [start, end] = flips[fi];
|
|
// Map start/end string indices to indices in the line
|
|
let left = Infinity, right = -Infinity;
|
|
for (let i = 0; i < lineGlyphCount; i++) {
|
|
if (line.glyphAt(i).charIndex >= start) { // gte to handle removed characters
|
|
let startInLine = i, endInLine = i;
|
|
for (; endInLine < lineGlyphCount; endInLine++) {
|
|
let info = line.glyphAt(endInLine);
|
|
if (info.charIndex > end) {
|
|
break
|
|
}
|
|
if (endInLine < lineGlyphCount - trailingWhitespaceCount) { //don't include trailing ws in flip width
|
|
left = Math.min(left, info.x);
|
|
right = Math.max(right, info.x + info.width);
|
|
}
|
|
}
|
|
for (let j = startInLine; j < endInLine; j++) {
|
|
const glyphInfo = line.glyphAt(j);
|
|
glyphInfo.x = right - (glyphInfo.x + glyphInfo.width - left);
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Assemble final data arrays
|
|
let glyphObj;
|
|
const setGlyphObj = g => glyphObj = g;
|
|
for (let i = 0; i < lineGlyphCount; i++) {
|
|
const glyphInfo = line.glyphAt(i);
|
|
glyphObj = glyphInfo.glyphObj;
|
|
const glyphId = glyphObj.index;
|
|
|
|
// Replace mirrored characters in rtl
|
|
const rtl = bidiLevelsResult.levels[glyphInfo.charIndex] & 1; //odd level means rtl
|
|
if (rtl) {
|
|
const mirrored = bidi.getMirroredCharacter(text[glyphInfo.charIndex]);
|
|
if (mirrored) {
|
|
glyphInfo.fontData.fontObj.forEachGlyph(mirrored, 0, 0, setGlyphObj);
|
|
}
|
|
}
|
|
|
|
// Add caret positions
|
|
if (includeCaretPositions) {
|
|
const {charIndex, fontData} = glyphInfo;
|
|
const caretLeft = glyphInfo.x + anchorXOffset;
|
|
const caretRight = glyphInfo.x + glyphInfo.width + anchorXOffset;
|
|
caretPositions[charIndex * 4] = rtl ? caretRight : caretLeft; //start edge x
|
|
caretPositions[charIndex * 4 + 1] = rtl ? caretLeft : caretRight; //end edge x
|
|
caretPositions[charIndex * 4 + 2] = line.baseline + fontData.caretBottom + anchorYOffset; //common bottom y
|
|
caretPositions[charIndex * 4 + 3] = line.baseline + fontData.caretTop + anchorYOffset; //common top y
|
|
|
|
// If we skipped any chars from the previous glyph (due to ligature subs), fill in caret
|
|
// positions for those missing char indices; currently this uses a best-guess by dividing
|
|
// the ligature's width evenly. In the future we may try to use the font's LigatureCaretList
|
|
// table to get better interior caret positions.
|
|
const ligCount = charIndex - prevCharIndex;
|
|
if (ligCount > 1) {
|
|
fillLigatureCaretPositions(caretPositions, prevCharIndex, ligCount);
|
|
}
|
|
prevCharIndex = charIndex;
|
|
}
|
|
|
|
// Track current color range
|
|
if (colorRanges) {
|
|
const {charIndex} = glyphInfo;
|
|
while(charIndex > colorCharIndex) {
|
|
colorCharIndex++;
|
|
if (colorRanges.hasOwnProperty(colorCharIndex)) {
|
|
currentColor = colorRanges[colorCharIndex];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get atlas data for renderable glyphs
|
|
if (!glyphObj.isWhitespace && !glyphObj.isEmpty) {
|
|
const idx = renderableGlyphIndex++;
|
|
const {fontSizeMult, src: fontSrc, index: fontIndex} = glyphInfo.fontData;
|
|
|
|
// Add this glyph's path data
|
|
const fontGlyphData = glyphData[fontSrc] || (glyphData[fontSrc] = {});
|
|
if (!fontGlyphData[glyphId]) {
|
|
fontGlyphData[glyphId] = {
|
|
path: glyphObj.path,
|
|
pathBounds: [glyphObj.xMin, glyphObj.yMin, glyphObj.xMax, glyphObj.yMax]
|
|
};
|
|
}
|
|
|
|
// Determine final glyph position and add to glyphPositions array
|
|
const glyphX = glyphInfo.x + anchorXOffset;
|
|
const glyphY = glyphInfo.y + line.baseline + anchorYOffset;
|
|
glyphPositions[idx * 2] = glyphX;
|
|
glyphPositions[idx * 2 + 1] = glyphY;
|
|
|
|
// Track total visible bounds
|
|
const visX0 = glyphX + glyphObj.xMin * fontSizeMult;
|
|
const visY0 = glyphY + glyphObj.yMin * fontSizeMult;
|
|
const visX1 = glyphX + glyphObj.xMax * fontSizeMult;
|
|
const visY1 = glyphY + glyphObj.yMax * fontSizeMult;
|
|
if (visX0 < visibleBounds[0]) visibleBounds[0] = visX0;
|
|
if (visY0 < visibleBounds[1]) visibleBounds[1] = visY0;
|
|
if (visX1 > visibleBounds[2]) visibleBounds[2] = visX1;
|
|
if (visY1 > visibleBounds[3]) visibleBounds[3] = visY1;
|
|
|
|
// Track bounding rects for each chunk of N glyphs
|
|
if (idx % chunkedBoundsSize === 0) {
|
|
chunk = {start: idx, end: idx, rect: [INF, INF, -INF, -INF]};
|
|
chunkedBounds.push(chunk);
|
|
}
|
|
chunk.end++;
|
|
const chunkRect = chunk.rect;
|
|
if (visX0 < chunkRect[0]) chunkRect[0] = visX0;
|
|
if (visY0 < chunkRect[1]) chunkRect[1] = visY0;
|
|
if (visX1 > chunkRect[2]) chunkRect[2] = visX1;
|
|
if (visY1 > chunkRect[3]) chunkRect[3] = visY1;
|
|
|
|
// Add to glyph ids and font indices arrays
|
|
glyphIds[idx] = glyphId;
|
|
glyphFontIndices[idx] = fontIndex;
|
|
|
|
// Add colors
|
|
if (colorRanges) {
|
|
const start = idx * 3;
|
|
glyphColors[start] = currentColor >> 16 & 255;
|
|
glyphColors[start + 1] = currentColor >> 8 & 255;
|
|
glyphColors[start + 2] = currentColor & 255;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Fill in remaining caret positions in case the final character was a ligature
|
|
if (caretPositions) {
|
|
const ligCount = text.length - prevCharIndex;
|
|
if (ligCount > 1) {
|
|
fillLigatureCaretPositions(caretPositions, prevCharIndex, ligCount);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Assemble final data about each font used
|
|
const fontData = [];
|
|
metricsByFont.forEach(({index, src, unitsPerEm, ascender, descender, lineHeight, capHeight, xHeight}) => {
|
|
fontData[index] = {src, unitsPerEm, ascender, descender, lineHeight, capHeight, xHeight};
|
|
});
|
|
|
|
// Timing stats
|
|
timings.typesetting = now() - typesetStart;
|
|
|
|
callback({
|
|
glyphIds, //id for each glyph, specific to that glyph's font
|
|
glyphFontIndices, //index into fontData for each glyph
|
|
glyphPositions, //x,y of each glyph's origin in layout
|
|
glyphData, //dict holding data about each glyph appearing in the text
|
|
fontData, //data about each font used in the text
|
|
caretPositions, //startX,endX,bottomY caret positions for each char
|
|
// caretHeight, //height of cursor from bottom to top - todo per glyph?
|
|
glyphColors, //color for each glyph, if color ranges supplied
|
|
chunkedBounds, //total rects per (n=chunkedBoundsSize) consecutive glyphs
|
|
fontSize, //calculated em height
|
|
topBaseline: anchorYOffset + lines[0].baseline, //y coordinate of the top line's baseline
|
|
blockBounds: [ //bounds for the whole block of text, including vertical padding for lineHeight
|
|
anchorXOffset,
|
|
anchorYOffset - totalHeight,
|
|
anchorXOffset + maxLineWidth,
|
|
anchorYOffset
|
|
],
|
|
visibleBounds, //total bounds of visible text paths, may be larger or smaller than blockBounds
|
|
timings
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* For a given text string and font parameters, determine the resulting block dimensions
|
|
* after wrapping for the given maxWidth.
|
|
* @param args
|
|
* @param callback
|
|
*/
|
|
function measure(args, callback) {
|
|
typeset({...args, metricsOnly: true}, (result) => {
|
|
const [x0, y0, x1, y1] = result.blockBounds;
|
|
callback({
|
|
width: x1 - x0,
|
|
height: y1 - y0
|
|
});
|
|
});
|
|
}
|
|
|
|
function parsePercent(str) {
|
|
let match = str.match(/^([\d.]+)%$/);
|
|
let pct = match ? parseFloat(match[1]) : NaN;
|
|
return isNaN(pct) ? 0 : pct / 100
|
|
}
|
|
|
|
function fillLigatureCaretPositions(caretPositions, ligStartIndex, ligCount) {
|
|
const ligStartX = caretPositions[ligStartIndex * 4];
|
|
const ligEndX = caretPositions[ligStartIndex * 4 + 1];
|
|
const ligBottom = caretPositions[ligStartIndex * 4 + 2];
|
|
const ligTop = caretPositions[ligStartIndex * 4 + 3];
|
|
const guessedAdvanceX = (ligEndX - ligStartX) / ligCount;
|
|
for (let i = 0; i < ligCount; i++) {
|
|
const startIndex = (ligStartIndex + i) * 4;
|
|
caretPositions[startIndex] = ligStartX + guessedAdvanceX * i;
|
|
caretPositions[startIndex + 1] = ligStartX + guessedAdvanceX * (i + 1);
|
|
caretPositions[startIndex + 2] = ligBottom;
|
|
caretPositions[startIndex + 3] = ligTop;
|
|
}
|
|
}
|
|
|
|
function now() {
|
|
return (self.performance || Date).now()
|
|
}
|
|
|
|
// Array-backed structure for a single line's glyphs data
|
|
function TextLine() {
|
|
this.data = [];
|
|
}
|
|
const textLineProps = ['glyphObj', 'x', 'y', 'width', 'charIndex', 'fontData'];
|
|
TextLine.prototype = {
|
|
width: 0,
|
|
lineHeight: 0,
|
|
baseline: 0,
|
|
cap: 0,
|
|
ex: 0,
|
|
isSoftWrapped: false,
|
|
get count() {
|
|
return Math.ceil(this.data.length / textLineProps.length)
|
|
},
|
|
glyphAt(i) {
|
|
let fly = TextLine.flyweight;
|
|
fly.data = this.data;
|
|
fly.index = i;
|
|
return fly
|
|
},
|
|
splitAt(i) {
|
|
let newLine = new TextLine();
|
|
newLine.data = this.data.splice(i * textLineProps.length);
|
|
return newLine
|
|
}
|
|
};
|
|
TextLine.flyweight = textLineProps.reduce((obj, prop, i, all) => {
|
|
Object.defineProperty(obj, prop, {
|
|
get() {
|
|
return this.data[this.index * textLineProps.length + i]
|
|
},
|
|
set(val) {
|
|
this.data[this.index * textLineProps.length + i] = val;
|
|
}
|
|
});
|
|
return obj
|
|
}, {data: null, index: 0});
|
|
|
|
|
|
return {
|
|
typeset,
|
|
measure,
|
|
}
|
|
}
|
|
|
|
const now = () => (self.performance || Date).now();
|
|
|
|
const mainThreadGenerator = /*#__PURE__*/ createSDFGenerator();
|
|
|
|
let warned;
|
|
|
|
/**
|
|
* Generate an SDF texture image for a single glyph path, placing the result into a webgl canvas at a
|
|
* given location and channel. Utilizes the webgl-sdf-generator external package for GPU-accelerated SDF
|
|
* generation when supported.
|
|
*/
|
|
function generateSDF(width, height, path, viewBox, distance, exponent, canvas, x, y, channel, useWebGL = true) {
|
|
// Allow opt-out
|
|
if (!useWebGL) {
|
|
return generateSDF_JS_Worker(width, height, path, viewBox, distance, exponent, canvas, x, y, channel)
|
|
}
|
|
|
|
// Attempt GPU-accelerated generation first
|
|
return generateSDF_GL(width, height, path, viewBox, distance, exponent, canvas, x, y, channel).then(
|
|
null,
|
|
err => {
|
|
// WebGL failed either due to a hard error or unexpected results; fall back to JS in workers
|
|
if (!warned) {
|
|
console.warn(`WebGL SDF generation failed, falling back to JS`, err);
|
|
warned = true;
|
|
}
|
|
return generateSDF_JS_Worker(width, height, path, viewBox, distance, exponent, canvas, x, y, channel)
|
|
}
|
|
)
|
|
}
|
|
|
|
const queue = [];
|
|
const chunkTimeBudget = 5; // ms
|
|
let timer = 0;
|
|
|
|
function nextChunk() {
|
|
const start = now();
|
|
while (queue.length && now() - start < chunkTimeBudget) {
|
|
queue.shift()();
|
|
}
|
|
timer = queue.length ? setTimeout(nextChunk, 0) : 0;
|
|
}
|
|
|
|
/**
|
|
* WebGL-based implementation executed on the main thread. Requests are executed in time-bounded
|
|
* macrotask chunks to allow render frames to execute in between.
|
|
*/
|
|
const generateSDF_GL = (...args) => {
|
|
return new Promise((resolve, reject) => {
|
|
queue.push(() => {
|
|
const start = now();
|
|
try {
|
|
mainThreadGenerator.webgl.generateIntoCanvas(...args);
|
|
resolve({ timing: now() - start });
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
if (!timer) {
|
|
timer = setTimeout(nextChunk, 0);
|
|
}
|
|
})
|
|
};
|
|
|
|
const threadCount = 4; // how many workers to spawn
|
|
const idleTimeout = 2000; // workers will be terminated after being idle this many milliseconds
|
|
const threads = {};
|
|
let callNum = 0;
|
|
|
|
/**
|
|
* Fallback JS-based implementation, fanned out to a number of worker threads for parallelism
|
|
*/
|
|
function generateSDF_JS_Worker(width, height, path, viewBox, distance, exponent, canvas, x, y, channel) {
|
|
const workerId = 'TroikaTextSDFGenerator_JS_' + ((callNum++) % threadCount);
|
|
let thread = threads[workerId];
|
|
if (!thread) {
|
|
thread = threads[workerId] = {
|
|
workerModule: defineWorkerModule({
|
|
name: workerId,
|
|
workerId,
|
|
dependencies: [
|
|
createSDFGenerator,
|
|
now
|
|
],
|
|
init(_createSDFGenerator, now) {
|
|
const generate = _createSDFGenerator().javascript.generate;
|
|
return function (...args) {
|
|
const start = now();
|
|
const textureData = generate(...args);
|
|
return {
|
|
textureData,
|
|
timing: now() - start
|
|
}
|
|
}
|
|
},
|
|
getTransferables(result) {
|
|
return [result.textureData.buffer]
|
|
}
|
|
}),
|
|
requests: 0,
|
|
idleTimer: null
|
|
};
|
|
}
|
|
|
|
thread.requests++;
|
|
clearTimeout(thread.idleTimer);
|
|
return thread.workerModule(width, height, path, viewBox, distance, exponent)
|
|
.then(({ textureData, timing }) => {
|
|
// copy result data into the canvas
|
|
const start = now();
|
|
// expand single-channel data into rgba
|
|
const imageData = new Uint8Array(textureData.length * 4);
|
|
for (let i = 0; i < textureData.length; i++) {
|
|
imageData[i * 4 + channel] = textureData[i];
|
|
}
|
|
mainThreadGenerator.webglUtils.renderImageData(canvas, imageData, x, y, width, height, 1 << (3 - channel));
|
|
timing += now() - start;
|
|
|
|
// clean up workers after a while
|
|
if (--thread.requests === 0) {
|
|
thread.idleTimer = setTimeout(() => { terminateWorker(workerId); }, idleTimeout);
|
|
}
|
|
return { timing }
|
|
})
|
|
}
|
|
|
|
function warmUpSDFCanvas(canvas) {
|
|
if (!canvas._warm) {
|
|
mainThreadGenerator.webgl.isSupported(canvas);
|
|
canvas._warm = true;
|
|
}
|
|
}
|
|
|
|
const resizeWebGLCanvasWithoutClearing = mainThreadGenerator.webglUtils.resizeWebGLCanvasWithoutClearing;
|
|
|
|
const CONFIG = {
|
|
defaultFontURL: null,
|
|
unicodeFontsURL: null,
|
|
sdfGlyphSize: 64,
|
|
sdfMargin: 1 / 16,
|
|
sdfExponent: 9,
|
|
textureWidth: 2048,
|
|
useWorker: true,
|
|
};
|
|
const tempColor = /*#__PURE__*/new Color();
|
|
let hasRequested = false;
|
|
|
|
function now$1() {
|
|
return (self.performance || Date).now()
|
|
}
|
|
|
|
/**
|
|
* Customizes the text builder configuration. This must be called prior to the first font processing
|
|
* request, and applies to all fonts.
|
|
*
|
|
* @param {String} config.defaultFontURL - The URL of the default font to use for text processing
|
|
* requests, in case none is specified or the specifiede font fails to load or parse.
|
|
* Defaults to "Roboto Regular" from Google Fonts.
|
|
* @param {String} config.unicodeFontsURL - A custom location for the fallback unicode-font-resolver
|
|
* data and font files, if you don't want to use the default CDN. See
|
|
* https://github.com/lojjic/unicode-font-resolver for details. It can also be
|
|
* configured per text instance, but this lets you do it once globally.
|
|
* @param {Number} config.sdfGlyphSize - The default size of each glyph's SDF (signed distance field)
|
|
* texture used for rendering. Must be a power-of-two number, and applies to all fonts,
|
|
* but note that this can also be overridden per call to `getTextRenderInfo()`.
|
|
* Larger sizes can improve the quality of glyph rendering by increasing the sharpness
|
|
* of corners and preventing loss of very thin lines, at the expense of memory. Defaults
|
|
* to 64 which is generally a good balance of size and quality.
|
|
* @param {Number} config.sdfExponent - The exponent used when encoding the SDF values. A higher exponent
|
|
* shifts the encoded 8-bit values to achieve higher precision/accuracy at texels nearer
|
|
* the glyph's path, with lower precision further away. Defaults to 9.
|
|
* @param {Number} config.sdfMargin - How much space to reserve in the SDF as margin outside the glyph's
|
|
* path, as a percentage of the SDF width. A larger margin increases the quality of
|
|
* extruded glyph outlines, but decreases the precision available for the glyph itself.
|
|
* Defaults to 1/16th of the glyph size.
|
|
* @param {Number} config.textureWidth - The width of the SDF texture; must be a power of 2. Defaults to
|
|
* 2048 which is a safe maximum texture dimension according to the stats at
|
|
* https://webglstats.com/webgl/parameter/MAX_TEXTURE_SIZE and should allow for a
|
|
* reasonably large number of glyphs (default glyph size of 64^2 and safe texture size of
|
|
* 2048^2, times 4 channels, allows for 4096 glyphs.) This can be increased if you need to
|
|
* increase the glyph size and/or have an extraordinary number of glyphs.
|
|
* @param {Boolean} config.useWorker - Whether to run typesetting in a web worker. Defaults to true.
|
|
*/
|
|
function configureTextBuilder(config) {
|
|
if (hasRequested) {
|
|
console.warn('configureTextBuilder called after first font request; will be ignored.');
|
|
} else {
|
|
assign(CONFIG, config);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Repository for all font SDF atlas textures and their glyph mappings. There is a separate atlas for
|
|
* each sdfGlyphSize. Each atlas has a single Texture that holds all glyphs for all fonts.
|
|
*
|
|
* {
|
|
* [sdfGlyphSize]: {
|
|
* glyphCount: number,
|
|
* sdfGlyphSize: number,
|
|
* sdfTexture: Texture,
|
|
* sdfCanvas: HTMLCanvasElement,
|
|
* contextLost: boolean,
|
|
* glyphsByFont: Map<fontURL, Map<glyphID, {path, atlasIndex, sdfViewBox}>>
|
|
* }
|
|
* }
|
|
*/
|
|
const atlases = Object.create(null);
|
|
|
|
/**
|
|
* @typedef {object} TroikaTextRenderInfo - Format of the result from `getTextRenderInfo`.
|
|
* @property {TypesetParams} parameters - The normalized input arguments to the render call.
|
|
* @property {Texture} sdfTexture - The SDF atlas texture.
|
|
* @property {number} sdfGlyphSize - The size of each glyph's SDF; see `configureTextBuilder`.
|
|
* @property {number} sdfExponent - The exponent used in encoding the SDF's values; see `configureTextBuilder`.
|
|
* @property {Float32Array} glyphBounds - List of [minX, minY, maxX, maxY] quad bounds for each glyph.
|
|
* @property {Float32Array} glyphAtlasIndices - List holding each glyph's index in the SDF atlas.
|
|
* @property {Uint8Array} [glyphColors] - List holding each glyph's [r, g, b] color, if `colorRanges` was supplied.
|
|
* @property {Float32Array} [caretPositions] - A list of caret positions for all characters in the string; each is
|
|
* four elements: the starting X, the ending X, the bottom Y, and the top Y for the caret.
|
|
* @property {number} [caretHeight] - An appropriate height for all selection carets.
|
|
* @property {number} ascender - The font's ascender metric.
|
|
* @property {number} descender - The font's descender metric.
|
|
* @property {number} capHeight - The font's cap height metric, based on the height of Latin capital letters.
|
|
* @property {number} xHeight - The font's x height metric, based on the height of Latin lowercase letters.
|
|
* @property {number} lineHeight - The final computed lineHeight measurement.
|
|
* @property {number} topBaseline - The y position of the top line's baseline.
|
|
* @property {Array<number>} blockBounds - The total [minX, minY, maxX, maxY] rect of the whole text block;
|
|
* this can include extra vertical space beyond the visible glyphs due to lineHeight, and is
|
|
* equivalent to the dimensions of a block-level text element in CSS.
|
|
* @property {Array<number>} visibleBounds - The total [minX, minY, maxX, maxY] rect of the whole text block;
|
|
* unlike `blockBounds` this is tightly wrapped to the visible glyph paths.
|
|
* @property {Array<object>} chunkedBounds - List of bounding rects for each consecutive set of N glyphs,
|
|
* in the format `{start:N, end:N, rect:[minX, minY, maxX, maxY]}`.
|
|
* @property {object} timings - Timing info for various parts of the rendering logic including SDF
|
|
* generation, typesetting, etc.
|
|
* @frozen
|
|
*/
|
|
|
|
/**
|
|
* @callback getTextRenderInfo~callback
|
|
* @param {TroikaTextRenderInfo} textRenderInfo
|
|
*/
|
|
|
|
/**
|
|
* Main entry point for requesting the data needed to render a text string with given font parameters.
|
|
* This is an asynchronous call, performing most of the logic in a web worker thread.
|
|
* @param {TypesetParams} args
|
|
* @param {getTextRenderInfo~callback} callback
|
|
*/
|
|
function getTextRenderInfo(args, callback) {
|
|
hasRequested = true;
|
|
args = assign({}, args);
|
|
const totalStart = now$1();
|
|
|
|
// Convert relative URL to absolute so it can be resolved in the worker, and add fallbacks.
|
|
// In the future we'll allow args.font to be a list with unicode ranges too.
|
|
const { defaultFontURL } = CONFIG;
|
|
const fonts = [];
|
|
if (defaultFontURL) {
|
|
fonts.push({label: 'default', src: toAbsoluteURL(defaultFontURL)});
|
|
}
|
|
if (args.font) {
|
|
fonts.push({label: 'user', src: toAbsoluteURL(args.font)});
|
|
}
|
|
args.font = fonts;
|
|
|
|
// Normalize text to a string
|
|
args.text = '' + args.text;
|
|
|
|
args.sdfGlyphSize = args.sdfGlyphSize || CONFIG.sdfGlyphSize;
|
|
args.unicodeFontsURL = args.unicodeFontsURL || CONFIG.unicodeFontsURL;
|
|
|
|
// Normalize colors
|
|
if (args.colorRanges != null) {
|
|
let colors = {};
|
|
for (let key in args.colorRanges) {
|
|
if (args.colorRanges.hasOwnProperty(key)) {
|
|
let val = args.colorRanges[key];
|
|
if (typeof val !== 'number') {
|
|
val = tempColor.set(val).getHex();
|
|
}
|
|
colors[key] = val;
|
|
}
|
|
}
|
|
args.colorRanges = colors;
|
|
}
|
|
|
|
Object.freeze(args);
|
|
|
|
// Init the atlas if needed
|
|
const {textureWidth, sdfExponent} = CONFIG;
|
|
const {sdfGlyphSize} = args;
|
|
const glyphsPerRow = (textureWidth / sdfGlyphSize * 4);
|
|
let atlas = atlases[sdfGlyphSize];
|
|
if (!atlas) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = textureWidth;
|
|
canvas.height = sdfGlyphSize * 256 / glyphsPerRow; // start tall enough to fit 256 glyphs
|
|
atlas = atlases[sdfGlyphSize] = {
|
|
glyphCount: 0,
|
|
sdfGlyphSize,
|
|
sdfCanvas: canvas,
|
|
sdfTexture: new Texture(
|
|
canvas,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
LinearFilter,
|
|
LinearFilter
|
|
),
|
|
contextLost: false,
|
|
glyphsByFont: new Map()
|
|
};
|
|
atlas.sdfTexture.generateMipmaps = false;
|
|
initContextLossHandling(atlas);
|
|
}
|
|
|
|
const {sdfTexture, sdfCanvas} = atlas;
|
|
|
|
// Issue request to the typesetting engine in the worker
|
|
const typeset = CONFIG.useWorker ? typesetInWorker : typesetOnMainThread;
|
|
typeset(args).then(result => {
|
|
const {glyphIds, glyphFontIndices, fontData, glyphPositions, fontSize, timings} = result;
|
|
const neededSDFs = [];
|
|
const glyphBounds = new Float32Array(glyphIds.length * 4);
|
|
let boundsIdx = 0;
|
|
let positionsIdx = 0;
|
|
const quadsStart = now$1();
|
|
|
|
const fontGlyphMaps = fontData.map(font => {
|
|
let map = atlas.glyphsByFont.get(font.src);
|
|
if (!map) {
|
|
atlas.glyphsByFont.set(font.src, map = new Map());
|
|
}
|
|
return map
|
|
});
|
|
|
|
glyphIds.forEach((glyphId, i) => {
|
|
const fontIndex = glyphFontIndices[i];
|
|
const {src: fontSrc, unitsPerEm} = fontData[fontIndex];
|
|
let glyphInfo = fontGlyphMaps[fontIndex].get(glyphId);
|
|
|
|
// If this is a glyphId not seen before, add it to the atlas
|
|
if (!glyphInfo) {
|
|
const {path, pathBounds} = result.glyphData[fontSrc][glyphId];
|
|
|
|
// Margin around path edges in SDF, based on a percentage of the glyph's max dimension.
|
|
// Note we add an extra 0.5 px over the configured value because the outer 0.5 doesn't contain
|
|
// useful interpolated values and will be ignored anyway.
|
|
const fontUnitsMargin = Math.max(pathBounds[2] - pathBounds[0], pathBounds[3] - pathBounds[1])
|
|
/ sdfGlyphSize * (CONFIG.sdfMargin * sdfGlyphSize + 0.5);
|
|
|
|
const atlasIndex = atlas.glyphCount++;
|
|
const sdfViewBox = [
|
|
pathBounds[0] - fontUnitsMargin,
|
|
pathBounds[1] - fontUnitsMargin,
|
|
pathBounds[2] + fontUnitsMargin,
|
|
pathBounds[3] + fontUnitsMargin,
|
|
];
|
|
fontGlyphMaps[fontIndex].set(glyphId, (glyphInfo = { path, atlasIndex, sdfViewBox }));
|
|
|
|
// Collect those that need SDF generation
|
|
neededSDFs.push(glyphInfo);
|
|
}
|
|
|
|
// Calculate bounds for renderable quads
|
|
// TODO can we get this back off the main thread?
|
|
const {sdfViewBox} = glyphInfo;
|
|
const posX = glyphPositions[positionsIdx++];
|
|
const posY = glyphPositions[positionsIdx++];
|
|
const fontSizeMult = fontSize / unitsPerEm;
|
|
glyphBounds[boundsIdx++] = posX + sdfViewBox[0] * fontSizeMult;
|
|
glyphBounds[boundsIdx++] = posY + sdfViewBox[1] * fontSizeMult;
|
|
glyphBounds[boundsIdx++] = posX + sdfViewBox[2] * fontSizeMult;
|
|
glyphBounds[boundsIdx++] = posY + sdfViewBox[3] * fontSizeMult;
|
|
|
|
// Convert glyphId to SDF index for the shader
|
|
glyphIds[i] = glyphInfo.atlasIndex;
|
|
});
|
|
timings.quads = (timings.quads || 0) + (now$1() - quadsStart);
|
|
|
|
const sdfStart = now$1();
|
|
timings.sdf = {};
|
|
|
|
// Grow the texture height by power of 2 if needed
|
|
const currentHeight = sdfCanvas.height;
|
|
const neededRows = Math.ceil(atlas.glyphCount / glyphsPerRow);
|
|
const neededHeight = Math.pow(2, Math.ceil(Math.log2(neededRows * sdfGlyphSize)));
|
|
if (neededHeight > currentHeight) {
|
|
// Since resizing the canvas clears its render buffer, it needs special handling to copy the old contents over
|
|
console.info(`Increasing SDF texture size ${currentHeight}->${neededHeight}`);
|
|
resizeWebGLCanvasWithoutClearing(sdfCanvas, textureWidth, neededHeight);
|
|
// As of Three r136 textures cannot be resized once they're allocated on the GPU, we must dispose to reallocate it
|
|
sdfTexture.dispose();
|
|
}
|
|
|
|
Promise.all(neededSDFs.map(glyphInfo =>
|
|
generateGlyphSDF(glyphInfo, atlas, args.gpuAccelerateSDF).then(({timing}) => {
|
|
timings.sdf[glyphInfo.atlasIndex] = timing;
|
|
})
|
|
)).then(() => {
|
|
if (neededSDFs.length && !atlas.contextLost) {
|
|
safariPre15Workaround(atlas);
|
|
sdfTexture.needsUpdate = true;
|
|
}
|
|
timings.sdfTotal = now$1() - sdfStart;
|
|
timings.total = now$1() - totalStart;
|
|
// console.log(`SDF - ${timings.sdfTotal}, Total - ${timings.total - timings.fontLoad}`)
|
|
|
|
// Invoke callback with the text layout arrays and updated texture
|
|
callback(Object.freeze({
|
|
parameters: args,
|
|
sdfTexture,
|
|
sdfGlyphSize,
|
|
sdfExponent,
|
|
glyphBounds,
|
|
glyphAtlasIndices: glyphIds,
|
|
glyphColors: result.glyphColors,
|
|
caretPositions: result.caretPositions,
|
|
chunkedBounds: result.chunkedBounds,
|
|
ascender: result.ascender,
|
|
descender: result.descender,
|
|
lineHeight: result.lineHeight,
|
|
capHeight: result.capHeight,
|
|
xHeight: result.xHeight,
|
|
topBaseline: result.topBaseline,
|
|
blockBounds: result.blockBounds,
|
|
visibleBounds: result.visibleBounds,
|
|
timings: result.timings,
|
|
}));
|
|
});
|
|
});
|
|
|
|
// While the typesetting request is being handled, go ahead and make sure the atlas canvas context is
|
|
// "warmed up"; the first request will be the longest due to shader program compilation so this gets
|
|
// a head start on that process before SDFs actually start getting processed.
|
|
Promise.resolve().then(() => {
|
|
if (!atlas.contextLost) {
|
|
warmUpSDFCanvas(sdfCanvas);
|
|
}
|
|
});
|
|
}
|
|
|
|
function generateGlyphSDF({path, atlasIndex, sdfViewBox}, {sdfGlyphSize, sdfCanvas, contextLost}, useGPU) {
|
|
if (contextLost) {
|
|
// If the context is lost there's nothing we can do, just quit silently and let it
|
|
// get regenerated when the context is restored
|
|
return Promise.resolve({timing: -1})
|
|
}
|
|
const {textureWidth, sdfExponent} = CONFIG;
|
|
const maxDist = Math.max(sdfViewBox[2] - sdfViewBox[0], sdfViewBox[3] - sdfViewBox[1]);
|
|
const squareIndex = Math.floor(atlasIndex / 4);
|
|
const x = squareIndex % (textureWidth / sdfGlyphSize) * sdfGlyphSize;
|
|
const y = Math.floor(squareIndex / (textureWidth / sdfGlyphSize)) * sdfGlyphSize;
|
|
const channel = atlasIndex % 4;
|
|
return generateSDF(sdfGlyphSize, sdfGlyphSize, path, sdfViewBox, maxDist, sdfExponent, sdfCanvas, x, y, channel, useGPU)
|
|
}
|
|
|
|
function initContextLossHandling(atlas) {
|
|
const canvas = atlas.sdfCanvas;
|
|
|
|
/*
|
|
// Begin context loss simulation
|
|
if (!window.WebGLDebugUtils) {
|
|
let script = document.getElementById('WebGLDebugUtilsScript')
|
|
if (!script) {
|
|
script = document.createElement('script')
|
|
script.id = 'WebGLDebugUtils'
|
|
document.head.appendChild(script)
|
|
script.src = 'https://cdn.jsdelivr.net/gh/KhronosGroup/WebGLDeveloperTools@b42e702/src/debug/webgl-debug.js'
|
|
}
|
|
script.addEventListener('load', () => {
|
|
initContextLossHandling(atlas)
|
|
})
|
|
return
|
|
}
|
|
window.WebGLDebugUtils.makeLostContextSimulatingCanvas(canvas)
|
|
canvas.loseContextInNCalls(500)
|
|
canvas.addEventListener('webglcontextrestored', (event) => {
|
|
canvas.loseContextInNCalls(5000)
|
|
})
|
|
// End context loss simulation
|
|
*/
|
|
|
|
canvas.addEventListener('webglcontextlost', (event) => {
|
|
console.log('Context Lost', event);
|
|
event.preventDefault();
|
|
atlas.contextLost = true;
|
|
});
|
|
canvas.addEventListener('webglcontextrestored', (event) => {
|
|
console.log('Context Restored', event);
|
|
atlas.contextLost = false;
|
|
// Regenerate all glyphs into the restored canvas:
|
|
const promises = [];
|
|
atlas.glyphsByFont.forEach(glyphMap => {
|
|
glyphMap.forEach(glyph => {
|
|
promises.push(generateGlyphSDF(glyph, atlas, true));
|
|
});
|
|
});
|
|
Promise.all(promises).then(() => {
|
|
safariPre15Workaround(atlas);
|
|
atlas.sdfTexture.needsUpdate = true;
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Preload a given font and optionally pre-generate glyph SDFs for one or more character sequences.
|
|
* This can be useful to avoid long pauses when first showing text in a scene, by preloading the
|
|
* needed fonts and glyphs up front along with other assets.
|
|
*
|
|
* @param {object} options
|
|
* @param {string} options.font - URL of the font file to preload. If not given, the default font will
|
|
* be loaded.
|
|
* @param {string|string[]} options.characters - One or more character sequences for which to pre-
|
|
* generate glyph SDFs. Note that this will honor ligature substitution, so you may need
|
|
* to specify ligature sequences in addition to their individual characters to get all
|
|
* possible glyphs, e.g. `["t", "h", "th"]` to get the "t" and "h" glyphs plus the "th" ligature.
|
|
* @param {number} options.sdfGlyphSize - The size at which to prerender the SDF textures for the
|
|
* specified `characters`.
|
|
* @param {function} callback - A function that will be called when the preloading is complete.
|
|
*/
|
|
function preloadFont({font, characters, sdfGlyphSize}, callback) {
|
|
let text = Array.isArray(characters) ? characters.join('\n') : '' + characters;
|
|
getTextRenderInfo({ font, sdfGlyphSize, text }, callback);
|
|
}
|
|
|
|
|
|
// Local assign impl so we don't have to import troika-core
|
|
function assign(toObj, fromObj) {
|
|
for (let key in fromObj) {
|
|
if (fromObj.hasOwnProperty(key)) {
|
|
toObj[key] = fromObj[key];
|
|
}
|
|
}
|
|
return toObj
|
|
}
|
|
|
|
// Utility for making URLs absolute
|
|
let linkEl;
|
|
function toAbsoluteURL(path) {
|
|
if (!linkEl) {
|
|
linkEl = typeof document === 'undefined' ? {} : document.createElement('a');
|
|
}
|
|
linkEl.href = path;
|
|
return linkEl.href
|
|
}
|
|
|
|
/**
|
|
* Safari < v15 seems unable to use the SDF webgl canvas as a texture. This applies a workaround
|
|
* where it reads the pixels out of that canvas and uploads them as a data texture instead, at
|
|
* a slight performance cost.
|
|
*/
|
|
function safariPre15Workaround(atlas) {
|
|
// Use createImageBitmap support as a proxy for Safari<15, all other mainstream browsers
|
|
// have supported it for a long while so any false positives should be minimal.
|
|
if (typeof createImageBitmap !== 'function') {
|
|
console.info('Safari<15: applying SDF canvas workaround');
|
|
const {sdfCanvas, sdfTexture} = atlas;
|
|
const {width, height} = sdfCanvas;
|
|
const gl = atlas.sdfCanvas.getContext('webgl');
|
|
let pixels = sdfTexture.image.data;
|
|
if (!pixels || pixels.length !== width * height * 4) {
|
|
pixels = new Uint8Array(width * height * 4);
|
|
sdfTexture.image = {width, height, data: pixels};
|
|
sdfTexture.flipY = false;
|
|
sdfTexture.isDataTexture = true;
|
|
}
|
|
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
|
}
|
|
}
|
|
|
|
const typesetterWorkerModule = /*#__PURE__*/defineWorkerModule({
|
|
name: 'Typesetter',
|
|
dependencies: [
|
|
createTypesetter,
|
|
fontResolverWorkerModule,
|
|
bidiFactory,
|
|
],
|
|
init(createTypesetter, fontResolver, bidiFactory) {
|
|
return createTypesetter(fontResolver, bidiFactory())
|
|
}
|
|
});
|
|
|
|
const typesetInWorker = /*#__PURE__*/defineWorkerModule({
|
|
name: 'Typesetter',
|
|
dependencies: [
|
|
typesetterWorkerModule,
|
|
],
|
|
init(typesetter) {
|
|
return function(args) {
|
|
return new Promise(resolve => {
|
|
typesetter.typeset(args, resolve);
|
|
})
|
|
}
|
|
},
|
|
getTransferables(result) {
|
|
// Mark array buffers as transferable to avoid cloning during postMessage
|
|
const transferables = [];
|
|
for (let p in result) {
|
|
if (result[p] && result[p].buffer) {
|
|
transferables.push(result[p].buffer);
|
|
}
|
|
}
|
|
return transferables
|
|
}
|
|
});
|
|
|
|
const typesetOnMainThread = typesetInWorker.onMainThread;
|
|
|
|
function dumpSDFTextures() {
|
|
Object.keys(atlases).forEach(size => {
|
|
const canvas = atlases[size].sdfCanvas;
|
|
const {width, height} = canvas;
|
|
console.log("%c.", `
|
|
background: url(${canvas.toDataURL()});
|
|
background-size: ${width}px ${height}px;
|
|
color: transparent;
|
|
font-size: 0;
|
|
line-height: ${height}px;
|
|
padding-left: ${width}px;
|
|
`);
|
|
});
|
|
}
|
|
|
|
const templateGeometries = {};
|
|
|
|
function getTemplateGeometry(detail) {
|
|
let geom = templateGeometries[detail];
|
|
if (!geom) {
|
|
geom = templateGeometries[detail] = new PlaneGeometry(1, 1, detail, detail).translate(0.5, 0.5, 0);
|
|
}
|
|
return geom
|
|
}
|
|
|
|
const glyphBoundsAttrName = 'aTroikaGlyphBounds';
|
|
const glyphIndexAttrName = 'aTroikaGlyphIndex';
|
|
const glyphColorAttrName = 'aTroikaGlyphColor';
|
|
|
|
/**
|
|
@class GlyphsGeometry
|
|
|
|
A specialized Geometry for rendering a set of text glyphs. Uses InstancedBufferGeometry to
|
|
render the glyphs using GPU instancing of a single quad, rather than constructing a whole
|
|
geometry with vertices, for much smaller attribute arraybuffers according to this math:
|
|
|
|
Where N = number of glyphs...
|
|
|
|
Instanced:
|
|
- position: 4 * 3
|
|
- index: 2 * 3
|
|
- normal: 4 * 3
|
|
- uv: 4 * 2
|
|
- glyph x/y bounds: N * 4
|
|
- glyph indices: N * 1
|
|
= 5N + 38
|
|
|
|
Non-instanced:
|
|
- position: N * 4 * 3
|
|
- index: N * 2 * 3
|
|
- normal: N * 4 * 3
|
|
- uv: N * 4 * 2
|
|
- glyph indices: N * 1
|
|
= 39N
|
|
|
|
A downside of this is the rare-but-possible lack of the instanced arrays extension,
|
|
which we could potentially work around with a fallback non-instanced implementation.
|
|
|
|
*/
|
|
class GlyphsGeometry extends InstancedBufferGeometry {
|
|
constructor() {
|
|
super();
|
|
|
|
this.detail = 1;
|
|
this.curveRadius = 0;
|
|
|
|
// Define groups for rendering text outline as a separate pass; these will only
|
|
// be used when the `material` getter returns an array, i.e. outlineWidth > 0.
|
|
this.groups = [
|
|
{start: 0, count: Infinity, materialIndex: 0},
|
|
{start: 0, count: Infinity, materialIndex: 1}
|
|
];
|
|
|
|
// Preallocate empty bounding objects
|
|
this.boundingSphere = new Sphere();
|
|
this.boundingBox = new Box3();
|
|
}
|
|
|
|
computeBoundingSphere () {
|
|
// No-op; we'll sync the boundingSphere proactively when needed.
|
|
}
|
|
|
|
computeBoundingBox() {
|
|
// No-op; we'll sync the boundingBox proactively when needed.
|
|
}
|
|
|
|
set detail(detail) {
|
|
if (detail !== this._detail) {
|
|
this._detail = detail;
|
|
if (typeof detail !== 'number' || detail < 1) {
|
|
detail = 1;
|
|
}
|
|
let tpl = getTemplateGeometry(detail)
|
|
;['position', 'normal', 'uv'].forEach(attr => {
|
|
this.attributes[attr] = tpl.attributes[attr].clone();
|
|
});
|
|
this.setIndex(tpl.getIndex().clone());
|
|
}
|
|
}
|
|
get detail() {
|
|
return this._detail
|
|
}
|
|
|
|
set curveRadius(r) {
|
|
if (r !== this._curveRadius) {
|
|
this._curveRadius = r;
|
|
this._updateBounds();
|
|
}
|
|
}
|
|
get curveRadius() {
|
|
return this._curveRadius
|
|
}
|
|
|
|
/**
|
|
* Update the geometry for a new set of glyphs.
|
|
* @param {Float32Array} glyphBounds - An array holding the planar bounds for all glyphs
|
|
* to be rendered, 4 entries for each glyph: x1,x2,y1,y1
|
|
* @param {Float32Array} glyphAtlasIndices - An array holding the index of each glyph within
|
|
* the SDF atlas texture.
|
|
* @param {Array} blockBounds - An array holding the [minX, minY, maxX, maxY] across all glyphs
|
|
* @param {Array} [chunkedBounds] - An array of objects describing bounds for each chunk of N
|
|
* consecutive glyphs: `{start:N, end:N, rect:[minX, minY, maxX, maxY]}`. This can be
|
|
* used with `applyClipRect` to choose an optimized `instanceCount`.
|
|
* @param {Uint8Array} [glyphColors] - An array holding r,g,b values for each glyph.
|
|
*/
|
|
updateGlyphs(glyphBounds, glyphAtlasIndices, blockBounds, chunkedBounds, glyphColors) {
|
|
// Update the instance attributes
|
|
this.updateAttributeData(glyphBoundsAttrName, glyphBounds, 4);
|
|
this.updateAttributeData(glyphIndexAttrName, glyphAtlasIndices, 1);
|
|
this.updateAttributeData(glyphColorAttrName, glyphColors, 3);
|
|
this._blockBounds = blockBounds;
|
|
this._chunkedBounds = chunkedBounds;
|
|
this.instanceCount = glyphAtlasIndices.length;
|
|
this._updateBounds();
|
|
}
|
|
|
|
_updateBounds() {
|
|
const bounds = this._blockBounds;
|
|
if (bounds) {
|
|
const { curveRadius, boundingBox: bbox } = this;
|
|
if (curveRadius) {
|
|
const { PI, floor, min, max, sin, cos } = Math;
|
|
const halfPi = PI / 2;
|
|
const twoPi = PI * 2;
|
|
const absR = Math.abs(curveRadius);
|
|
const leftAngle = bounds[0] / absR;
|
|
const rightAngle = bounds[2] / absR;
|
|
const minX = floor((leftAngle + halfPi) / twoPi) !== floor((rightAngle + halfPi) / twoPi)
|
|
? -absR : min(sin(leftAngle) * absR, sin(rightAngle) * absR);
|
|
const maxX = floor((leftAngle - halfPi) / twoPi) !== floor((rightAngle - halfPi) / twoPi)
|
|
? absR : max(sin(leftAngle) * absR, sin(rightAngle) * absR);
|
|
const maxZ = floor((leftAngle + PI) / twoPi) !== floor((rightAngle + PI) / twoPi)
|
|
? absR * 2 : max(absR - cos(leftAngle) * absR, absR - cos(rightAngle) * absR);
|
|
bbox.min.set(minX, bounds[1], curveRadius < 0 ? -maxZ : 0);
|
|
bbox.max.set(maxX, bounds[3], curveRadius < 0 ? 0 : maxZ);
|
|
} else {
|
|
bbox.min.set(bounds[0], bounds[1], 0);
|
|
bbox.max.set(bounds[2], bounds[3], 0);
|
|
}
|
|
bbox.getBoundingSphere(this.boundingSphere);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Given a clipping rect, and the chunkedBounds from the last updateGlyphs call, choose the lowest
|
|
* `instanceCount` that will show all glyphs within the clipped view. This is an optimization
|
|
* for long blocks of text that are clipped, to skip vertex shader evaluation for glyphs that would
|
|
* be clipped anyway.
|
|
*
|
|
* Note that since `drawElementsInstanced[ANGLE]` only accepts an instance count and not a starting
|
|
* offset, this optimization becomes less effective as the clipRect moves closer to the end of the
|
|
* text block. We could fix that by switching from instancing to a full geometry with a drawRange,
|
|
* but at the expense of much larger attribute buffers (see classdoc above.)
|
|
*
|
|
* @param {Vector4} clipRect
|
|
*/
|
|
applyClipRect(clipRect) {
|
|
let count = this.getAttribute(glyphIndexAttrName).count;
|
|
let chunks = this._chunkedBounds;
|
|
if (chunks) {
|
|
for (let i = chunks.length; i--;) {
|
|
count = chunks[i].end;
|
|
let rect = chunks[i].rect;
|
|
// note: both rects are l-b-r-t
|
|
if (rect[1] < clipRect.w && rect[3] > clipRect.y && rect[0] < clipRect.z && rect[2] > clipRect.x) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
this.instanceCount = count;
|
|
}
|
|
|
|
/**
|
|
* Utility for updating instance attributes with automatic resizing
|
|
*/
|
|
updateAttributeData(attrName, newArray, itemSize) {
|
|
const attr = this.getAttribute(attrName);
|
|
if (newArray) {
|
|
// If length isn't changing, just update the attribute's array data
|
|
if (attr && attr.array.length === newArray.length) {
|
|
attr.array.set(newArray);
|
|
attr.needsUpdate = true;
|
|
} else {
|
|
this.setAttribute(attrName, new InstancedBufferAttribute(newArray, itemSize));
|
|
// If the new attribute has a different size, we also have to (as of r117) manually clear the
|
|
// internal cached max instance count. See https://github.com/mrdoob/three.js/issues/19706
|
|
// It's unclear if this is a threejs bug or a truly unsupported scenario; discussion in
|
|
// that ticket is ambiguous as to whether replacing a BufferAttribute with one of a
|
|
// different size is supported, but https://github.com/mrdoob/three.js/pull/17418 strongly
|
|
// implies it should be supported. It's possible we need to
|
|
delete this._maxInstanceCount; //for r117+, could be fragile
|
|
this.dispose(); //for r118+, more robust feeling, but more heavy-handed than I'd like
|
|
}
|
|
} else if (attr) {
|
|
this.deleteAttribute(attrName);
|
|
}
|
|
}
|
|
}
|
|
|
|
// language=GLSL
|
|
const VERTEX_DEFS = `
|
|
uniform vec2 uTroikaSDFTextureSize;
|
|
uniform float uTroikaSDFGlyphSize;
|
|
uniform vec4 uTroikaTotalBounds;
|
|
uniform vec4 uTroikaClipRect;
|
|
uniform mat3 uTroikaOrient;
|
|
uniform bool uTroikaUseGlyphColors;
|
|
uniform float uTroikaEdgeOffset;
|
|
uniform float uTroikaBlurRadius;
|
|
uniform vec2 uTroikaPositionOffset;
|
|
uniform float uTroikaCurveRadius;
|
|
attribute vec4 aTroikaGlyphBounds;
|
|
attribute float aTroikaGlyphIndex;
|
|
attribute vec3 aTroikaGlyphColor;
|
|
varying vec2 vTroikaGlyphUV;
|
|
varying vec4 vTroikaTextureUVBounds;
|
|
varying float vTroikaTextureChannel;
|
|
varying vec3 vTroikaGlyphColor;
|
|
varying vec2 vTroikaGlyphDimensions;
|
|
`;
|
|
|
|
// language=GLSL prefix="void main() {" suffix="}"
|
|
const VERTEX_TRANSFORM = `
|
|
vec4 bounds = aTroikaGlyphBounds;
|
|
bounds.xz += uTroikaPositionOffset.x;
|
|
bounds.yw -= uTroikaPositionOffset.y;
|
|
|
|
vec4 outlineBounds = vec4(
|
|
bounds.xy - uTroikaEdgeOffset - uTroikaBlurRadius,
|
|
bounds.zw + uTroikaEdgeOffset + uTroikaBlurRadius
|
|
);
|
|
vec4 clippedBounds = vec4(
|
|
clamp(outlineBounds.xy, uTroikaClipRect.xy, uTroikaClipRect.zw),
|
|
clamp(outlineBounds.zw, uTroikaClipRect.xy, uTroikaClipRect.zw)
|
|
);
|
|
|
|
vec2 clippedXY = (mix(clippedBounds.xy, clippedBounds.zw, position.xy) - bounds.xy) / (bounds.zw - bounds.xy);
|
|
|
|
position.xy = mix(bounds.xy, bounds.zw, clippedXY);
|
|
|
|
uv = (position.xy - uTroikaTotalBounds.xy) / (uTroikaTotalBounds.zw - uTroikaTotalBounds.xy);
|
|
|
|
float rad = uTroikaCurveRadius;
|
|
if (rad != 0.0) {
|
|
float angle = position.x / rad;
|
|
position.xz = vec2(sin(angle) * rad, rad - cos(angle) * rad);
|
|
normal.xz = vec2(sin(angle), cos(angle));
|
|
}
|
|
|
|
position = uTroikaOrient * position;
|
|
normal = uTroikaOrient * normal;
|
|
|
|
vTroikaGlyphUV = clippedXY.xy;
|
|
vTroikaGlyphDimensions = vec2(bounds[2] - bounds[0], bounds[3] - bounds[1]);
|
|
|
|
${''/* NOTE: it seems important to calculate the glyph's bounding texture UVs here in the
|
|
vertex shader, rather than in the fragment shader, as the latter gives strange artifacts
|
|
on some glyphs (those in the leftmost texture column) on some systems. The exact reason
|
|
isn't understood but doing this here, then mix()-ing in the fragment shader, seems to work. */}
|
|
float txCols = uTroikaSDFTextureSize.x / uTroikaSDFGlyphSize;
|
|
vec2 txUvPerSquare = uTroikaSDFGlyphSize / uTroikaSDFTextureSize;
|
|
vec2 txStartUV = txUvPerSquare * vec2(
|
|
mod(floor(aTroikaGlyphIndex / 4.0), txCols),
|
|
floor(floor(aTroikaGlyphIndex / 4.0) / txCols)
|
|
);
|
|
vTroikaTextureUVBounds = vec4(txStartUV, vec2(txStartUV) + txUvPerSquare);
|
|
vTroikaTextureChannel = mod(aTroikaGlyphIndex, 4.0);
|
|
`;
|
|
|
|
// language=GLSL
|
|
const FRAGMENT_DEFS = `
|
|
uniform sampler2D uTroikaSDFTexture;
|
|
uniform vec2 uTroikaSDFTextureSize;
|
|
uniform float uTroikaSDFGlyphSize;
|
|
uniform float uTroikaSDFExponent;
|
|
uniform float uTroikaEdgeOffset;
|
|
uniform float uTroikaFillOpacity;
|
|
uniform float uTroikaBlurRadius;
|
|
uniform vec3 uTroikaStrokeColor;
|
|
uniform float uTroikaStrokeWidth;
|
|
uniform float uTroikaStrokeOpacity;
|
|
uniform bool uTroikaSDFDebug;
|
|
varying vec2 vTroikaGlyphUV;
|
|
varying vec4 vTroikaTextureUVBounds;
|
|
varying float vTroikaTextureChannel;
|
|
varying vec2 vTroikaGlyphDimensions;
|
|
|
|
float troikaSdfValueToSignedDistance(float alpha) {
|
|
// Inverse of exponential encoding in webgl-sdf-generator
|
|
${''/* TODO - there's some slight inaccuracy here when dealing with interpolated alpha values; those
|
|
are linearly interpolated where the encoding is exponential. Look into improving this by rounding
|
|
to nearest 2 whole texels, decoding those exponential values, and linearly interpolating the result.
|
|
*/}
|
|
float maxDimension = max(vTroikaGlyphDimensions.x, vTroikaGlyphDimensions.y);
|
|
float absDist = (1.0 - pow(2.0 * (alpha > 0.5 ? 1.0 - alpha : alpha), 1.0 / uTroikaSDFExponent)) * maxDimension;
|
|
float signedDist = absDist * (alpha > 0.5 ? -1.0 : 1.0);
|
|
return signedDist;
|
|
}
|
|
|
|
float troikaGlyphUvToSdfValue(vec2 glyphUV) {
|
|
vec2 textureUV = mix(vTroikaTextureUVBounds.xy, vTroikaTextureUVBounds.zw, glyphUV);
|
|
vec4 rgba = texture2D(uTroikaSDFTexture, textureUV);
|
|
float ch = floor(vTroikaTextureChannel + 0.5); //NOTE: can't use round() in WebGL1
|
|
return ch == 0.0 ? rgba.r : ch == 1.0 ? rgba.g : ch == 2.0 ? rgba.b : rgba.a;
|
|
}
|
|
|
|
float troikaGlyphUvToDistance(vec2 uv) {
|
|
return troikaSdfValueToSignedDistance(troikaGlyphUvToSdfValue(uv));
|
|
}
|
|
|
|
float troikaGetAADist() {
|
|
${''/*
|
|
When the standard derivatives extension is available, we choose an antialiasing alpha threshold based
|
|
on the potential change in the SDF's alpha from this fragment to its neighbor. This strategy maximizes
|
|
readability and edge crispness at all sizes and screen resolutions.
|
|
*/}
|
|
#if defined(GL_OES_standard_derivatives) || __VERSION__ >= 300
|
|
return length(fwidth(vTroikaGlyphUV * vTroikaGlyphDimensions)) * 0.5;
|
|
#else
|
|
return vTroikaGlyphDimensions.x / 64.0;
|
|
#endif
|
|
}
|
|
|
|
float troikaGetFragDistValue() {
|
|
vec2 clampedGlyphUV = clamp(vTroikaGlyphUV, 0.5 / uTroikaSDFGlyphSize, 1.0 - 0.5 / uTroikaSDFGlyphSize);
|
|
float distance = troikaGlyphUvToDistance(clampedGlyphUV);
|
|
|
|
// Extrapolate distance when outside bounds:
|
|
distance += clampedGlyphUV == vTroikaGlyphUV ? 0.0 :
|
|
length((vTroikaGlyphUV - clampedGlyphUV) * vTroikaGlyphDimensions);
|
|
|
|
${''/*
|
|
// TODO more refined extrapolated distance by adjusting for angle of gradient at edge...
|
|
// This has potential but currently gives very jagged extensions, maybe due to precision issues?
|
|
float uvStep = 1.0 / uTroikaSDFGlyphSize;
|
|
vec2 neighbor1UV = clampedGlyphUV + (
|
|
vTroikaGlyphUV.x != clampedGlyphUV.x ? vec2(0.0, uvStep * sign(0.5 - vTroikaGlyphUV.y)) :
|
|
vTroikaGlyphUV.y != clampedGlyphUV.y ? vec2(uvStep * sign(0.5 - vTroikaGlyphUV.x), 0.0) :
|
|
vec2(0.0)
|
|
);
|
|
vec2 neighbor2UV = clampedGlyphUV + (
|
|
vTroikaGlyphUV.x != clampedGlyphUV.x ? vec2(0.0, uvStep * -sign(0.5 - vTroikaGlyphUV.y)) :
|
|
vTroikaGlyphUV.y != clampedGlyphUV.y ? vec2(uvStep * -sign(0.5 - vTroikaGlyphUV.x), 0.0) :
|
|
vec2(0.0)
|
|
);
|
|
float neighbor1Distance = troikaGlyphUvToDistance(neighbor1UV);
|
|
float neighbor2Distance = troikaGlyphUvToDistance(neighbor2UV);
|
|
float distToUnclamped = length((vTroikaGlyphUV - clampedGlyphUV) * vTroikaGlyphDimensions);
|
|
float distToNeighbor = length((clampedGlyphUV - neighbor1UV) * vTroikaGlyphDimensions);
|
|
float gradientAngle1 = min(asin(abs(neighbor1Distance - distance) / distToNeighbor), PI / 2.0);
|
|
float gradientAngle2 = min(asin(abs(neighbor2Distance - distance) / distToNeighbor), PI / 2.0);
|
|
distance += (cos(gradientAngle1) + cos(gradientAngle2)) / 2.0 * distToUnclamped;
|
|
*/}
|
|
|
|
return distance;
|
|
}
|
|
|
|
float troikaGetEdgeAlpha(float distance, float distanceOffset, float aaDist) {
|
|
#if defined(IS_DEPTH_MATERIAL) || defined(IS_DISTANCE_MATERIAL)
|
|
float alpha = step(-distanceOffset, -distance);
|
|
#else
|
|
|
|
float alpha = smoothstep(
|
|
distanceOffset + aaDist,
|
|
distanceOffset - aaDist,
|
|
distance
|
|
);
|
|
#endif
|
|
|
|
return alpha;
|
|
}
|
|
`;
|
|
|
|
// language=GLSL prefix="void main() {" suffix="}"
|
|
const FRAGMENT_TRANSFORM = `
|
|
float aaDist = troikaGetAADist();
|
|
float fragDistance = troikaGetFragDistValue();
|
|
float edgeAlpha = uTroikaSDFDebug ?
|
|
troikaGlyphUvToSdfValue(vTroikaGlyphUV) :
|
|
troikaGetEdgeAlpha(fragDistance, uTroikaEdgeOffset, max(aaDist, uTroikaBlurRadius));
|
|
|
|
#if !defined(IS_DEPTH_MATERIAL) && !defined(IS_DISTANCE_MATERIAL)
|
|
vec4 fillRGBA = gl_FragColor;
|
|
fillRGBA.a *= uTroikaFillOpacity;
|
|
vec4 strokeRGBA = uTroikaStrokeWidth == 0.0 ? fillRGBA : vec4(uTroikaStrokeColor, uTroikaStrokeOpacity);
|
|
if (fillRGBA.a == 0.0) fillRGBA.rgb = strokeRGBA.rgb;
|
|
gl_FragColor = mix(fillRGBA, strokeRGBA, smoothstep(
|
|
-uTroikaStrokeWidth - aaDist,
|
|
-uTroikaStrokeWidth + aaDist,
|
|
fragDistance
|
|
));
|
|
gl_FragColor.a *= edgeAlpha;
|
|
#endif
|
|
|
|
if (edgeAlpha == 0.0) {
|
|
discard;
|
|
}
|
|
`;
|
|
|
|
|
|
/**
|
|
* Create a material for rendering text, derived from a baseMaterial
|
|
*/
|
|
function createTextDerivedMaterial(baseMaterial) {
|
|
const textMaterial = createDerivedMaterial(baseMaterial, {
|
|
chained: true,
|
|
extensions: {
|
|
derivatives: true
|
|
},
|
|
uniforms: {
|
|
uTroikaSDFTexture: {value: null},
|
|
uTroikaSDFTextureSize: {value: new Vector2()},
|
|
uTroikaSDFGlyphSize: {value: 0},
|
|
uTroikaSDFExponent: {value: 0},
|
|
uTroikaTotalBounds: {value: new Vector4(0,0,0,0)},
|
|
uTroikaClipRect: {value: new Vector4(0,0,0,0)},
|
|
uTroikaEdgeOffset: {value: 0},
|
|
uTroikaFillOpacity: {value: 1},
|
|
uTroikaPositionOffset: {value: new Vector2()},
|
|
uTroikaCurveRadius: {value: 0},
|
|
uTroikaBlurRadius: {value: 0},
|
|
uTroikaStrokeWidth: {value: 0},
|
|
uTroikaStrokeColor: {value: new Color()},
|
|
uTroikaStrokeOpacity: {value: 1},
|
|
uTroikaOrient: {value: new Matrix3()},
|
|
uTroikaUseGlyphColors: {value: true},
|
|
uTroikaSDFDebug: {value: false}
|
|
},
|
|
vertexDefs: VERTEX_DEFS,
|
|
vertexTransform: VERTEX_TRANSFORM,
|
|
fragmentDefs: FRAGMENT_DEFS,
|
|
fragmentColorTransform: FRAGMENT_TRANSFORM,
|
|
customRewriter({vertexShader, fragmentShader}) {
|
|
let uDiffuseRE = /\buniform\s+vec3\s+diffuse\b/;
|
|
if (uDiffuseRE.test(fragmentShader)) {
|
|
// Replace all instances of `diffuse` with our varying
|
|
fragmentShader = fragmentShader
|
|
.replace(uDiffuseRE, 'varying vec3 vTroikaGlyphColor')
|
|
.replace(/\bdiffuse\b/g, 'vTroikaGlyphColor');
|
|
// Make sure the vertex shader declares the uniform so we can grab it as a fallback
|
|
if (!uDiffuseRE.test(vertexShader)) {
|
|
vertexShader = vertexShader.replace(
|
|
voidMainRegExp,
|
|
'uniform vec3 diffuse;\n$&\nvTroikaGlyphColor = uTroikaUseGlyphColors ? aTroikaGlyphColor / 255.0 : diffuse;\n'
|
|
);
|
|
}
|
|
}
|
|
return { vertexShader, fragmentShader }
|
|
}
|
|
});
|
|
|
|
// Force transparency - TODO is this reasonable?
|
|
textMaterial.transparent = true;
|
|
|
|
// Force single draw call when double-sided
|
|
textMaterial.forceSinglePass = true;
|
|
|
|
Object.defineProperties(textMaterial, {
|
|
isTroikaTextMaterial: {value: true},
|
|
|
|
// WebGLShadowMap reverses the side of the shadow material by default, which fails
|
|
// for planes, so here we force the `shadowSide` to always match the main side.
|
|
shadowSide: {
|
|
get() {
|
|
return this.side
|
|
},
|
|
set() {
|
|
//no-op
|
|
}
|
|
}
|
|
});
|
|
|
|
return textMaterial
|
|
}
|
|
|
|
const defaultMaterial = /*#__PURE__*/ new MeshBasicMaterial({
|
|
color: 0xffffff,
|
|
side: DoubleSide,
|
|
transparent: true
|
|
});
|
|
const defaultStrokeColor = 0x808080;
|
|
|
|
const tempMat4 = /*#__PURE__*/ new Matrix4();
|
|
const tempVec3a = /*#__PURE__*/ new Vector3();
|
|
const tempVec3b = /*#__PURE__*/ new Vector3();
|
|
const tempArray = [];
|
|
const origin = /*#__PURE__*/ new Vector3();
|
|
const defaultOrient = '+x+y';
|
|
|
|
function first(o) {
|
|
return Array.isArray(o) ? o[0] : o
|
|
}
|
|
|
|
let getFlatRaycastMesh = () => {
|
|
const mesh = new Mesh(
|
|
new PlaneGeometry(1, 1),
|
|
defaultMaterial
|
|
);
|
|
getFlatRaycastMesh = () => mesh;
|
|
return mesh
|
|
};
|
|
let getCurvedRaycastMesh = () => {
|
|
const mesh = new Mesh(
|
|
new PlaneGeometry(1, 1, 32, 1),
|
|
defaultMaterial
|
|
);
|
|
getCurvedRaycastMesh = () => mesh;
|
|
return mesh
|
|
};
|
|
|
|
const syncStartEvent = { type: 'syncstart' };
|
|
const syncCompleteEvent = { type: 'synccomplete' };
|
|
|
|
const SYNCABLE_PROPS = [
|
|
'font',
|
|
'fontSize',
|
|
'fontStyle',
|
|
'fontWeight',
|
|
'lang',
|
|
'letterSpacing',
|
|
'lineHeight',
|
|
'maxWidth',
|
|
'overflowWrap',
|
|
'text',
|
|
'direction',
|
|
'textAlign',
|
|
'textIndent',
|
|
'whiteSpace',
|
|
'anchorX',
|
|
'anchorY',
|
|
'colorRanges',
|
|
'sdfGlyphSize'
|
|
];
|
|
|
|
const COPYABLE_PROPS = SYNCABLE_PROPS.concat(
|
|
'material',
|
|
'color',
|
|
'depthOffset',
|
|
'clipRect',
|
|
'curveRadius',
|
|
'orientation',
|
|
'glyphGeometryDetail'
|
|
);
|
|
|
|
/**
|
|
* @class Text
|
|
*
|
|
* A ThreeJS Mesh that renders a string of text on a plane in 3D space using signed distance
|
|
* fields (SDF).
|
|
*/
|
|
class Text extends Mesh {
|
|
constructor() {
|
|
const geometry = new GlyphsGeometry();
|
|
super(geometry, null);
|
|
|
|
// === Text layout properties: === //
|
|
|
|
/**
|
|
* @member {string} text
|
|
* The string of text to be rendered.
|
|
*/
|
|
this.text = '';
|
|
|
|
/**
|
|
* @member {number|string} anchorX
|
|
* Defines the horizontal position in the text block that should line up with the local origin.
|
|
* Can be specified as a numeric x position in local units, a string percentage of the total
|
|
* text block width e.g. `'25%'`, or one of the following keyword strings: 'left', 'center',
|
|
* or 'right'.
|
|
*/
|
|
this.anchorX = 0;
|
|
|
|
/**
|
|
* @member {number|string} anchorY
|
|
* Defines the vertical position in the text block that should line up with the local origin.
|
|
* Can be specified as a numeric y position in local units (note: down is negative y), a string
|
|
* percentage of the total text block height e.g. `'25%'`, or one of the following keyword strings:
|
|
* 'top', 'top-baseline', 'top-cap', 'top-ex', 'middle', 'bottom-baseline', or 'bottom'.
|
|
*/
|
|
this.anchorY = 0;
|
|
|
|
/**
|
|
* @member {number} curveRadius
|
|
* Defines a cylindrical radius along which the text's plane will be curved. Positive numbers put
|
|
* the cylinder's centerline (oriented vertically) that distance in front of the text, for a concave
|
|
* curvature, while negative numbers put it behind the text for a convex curvature. The centerline
|
|
* will be aligned with the text's local origin; you can use `anchorX` to offset it.
|
|
*
|
|
* Since each glyph is by default rendered with a simple quad, each glyph remains a flat plane
|
|
* internally. You can use `glyphGeometryDetail` to add more vertices for curvature inside glyphs.
|
|
*/
|
|
this.curveRadius = 0;
|
|
|
|
/**
|
|
* @member {string} direction
|
|
* Sets the base direction for the text. The default value of "auto" will choose a direction based
|
|
* on the text's content according to the bidi spec. A value of "ltr" or "rtl" will force the direction.
|
|
*/
|
|
this.direction = 'auto';
|
|
|
|
/**
|
|
* @member {string|null} font
|
|
* URL of a custom font to be used. Font files can be in .ttf, .otf, or .woff (not .woff2) formats.
|
|
* Defaults to Noto Sans.
|
|
*/
|
|
this.font = null; //will use default from TextBuilder
|
|
|
|
this.unicodeFontsURL = null; //defaults to CDN
|
|
|
|
/**
|
|
* @member {number} fontSize
|
|
* The size at which to render the font in local units; corresponds to the em-box height
|
|
* of the chosen `font`.
|
|
*/
|
|
this.fontSize = 0.1;
|
|
|
|
/**
|
|
* @member {number|'normal'|'bold'}
|
|
* The weight of the font. Currently only used for fallback Noto fonts.
|
|
*/
|
|
this.fontWeight = 'normal';
|
|
|
|
/**
|
|
* @member {'normal'|'italic'}
|
|
* The style of the font. Currently only used for fallback Noto fonts.
|
|
*/
|
|
this.fontStyle = 'normal';
|
|
|
|
/**
|
|
* @member {string|null} lang
|
|
* The language code of this text; can be used for explicitly selecting certain CJK fonts.
|
|
*/
|
|
this.lang = null;
|
|
|
|
/**
|
|
* @member {number} letterSpacing
|
|
* Sets a uniform adjustment to spacing between letters after kerning is applied. Positive
|
|
* numbers increase spacing and negative numbers decrease it.
|
|
*/
|
|
this.letterSpacing = 0;
|
|
|
|
/**
|
|
* @member {number|string} lineHeight
|
|
* Sets the height of each line of text, as a multiple of the `fontSize`. Defaults to 'normal'
|
|
* which chooses a reasonable height based on the chosen font's ascender/descender metrics.
|
|
*/
|
|
this.lineHeight = 'normal';
|
|
|
|
/**
|
|
* @member {number} maxWidth
|
|
* The maximum width of the text block, above which text may start wrapping according to the
|
|
* `whiteSpace` and `overflowWrap` properties.
|
|
*/
|
|
this.maxWidth = Infinity;
|
|
|
|
/**
|
|
* @member {string} overflowWrap
|
|
* Defines how text wraps if the `whiteSpace` property is `normal`. Can be either `'normal'`
|
|
* to break at whitespace characters, or `'break-word'` to allow breaking within words.
|
|
* Defaults to `'normal'`.
|
|
*/
|
|
this.overflowWrap = 'normal';
|
|
|
|
/**
|
|
* @member {string} textAlign
|
|
* The horizontal alignment of each line of text within the overall text bounding box.
|
|
*/
|
|
this.textAlign = 'left';
|
|
|
|
/**
|
|
* @member {number} textIndent
|
|
* Indentation for the first character of a line; see CSS `text-indent`.
|
|
*/
|
|
this.textIndent = 0;
|
|
|
|
/**
|
|
* @member {string} whiteSpace
|
|
* Defines whether text should wrap when a line reaches the `maxWidth`. Can
|
|
* be either `'normal'` (the default), to allow wrapping according to the `overflowWrap` property,
|
|
* or `'nowrap'` to prevent wrapping. Note that `'normal'` here honors newline characters to
|
|
* manually break lines, making it behave more like `'pre-wrap'` does in CSS.
|
|
*/
|
|
this.whiteSpace = 'normal';
|
|
|
|
|
|
// === Presentation properties: === //
|
|
|
|
/**
|
|
* @member {THREE.Material} material
|
|
* Defines a _base_ material to be used when rendering the text. This material will be
|
|
* automatically replaced with a material derived from it, that adds shader code to
|
|
* decrease the alpha for each fragment (pixel) outside the text glyphs, with antialiasing.
|
|
* By default it will derive from a simple white MeshBasicMaterial, but you can use any
|
|
* of the other mesh materials to gain other features like lighting, texture maps, etc.
|
|
*
|
|
* Also see the `color` shortcut property.
|
|
*/
|
|
this.material = null;
|
|
|
|
/**
|
|
* @member {string|number|THREE.Color} color
|
|
* This is a shortcut for setting the `color` of the text's material. You can use this
|
|
* if you don't want to specify a whole custom `material`. Also, if you do use a custom
|
|
* `material`, this color will only be used for this particuar Text instance, even if
|
|
* that same material instance is shared across multiple Text objects.
|
|
*/
|
|
this.color = null;
|
|
|
|
/**
|
|
* @member {object|null} colorRanges
|
|
* WARNING: This API is experimental and may change.
|
|
* This allows more fine-grained control of colors for individual or ranges of characters,
|
|
* taking precedence over the material's `color`. Its format is an Object whose keys each
|
|
* define a starting character index for a range, and whose values are the color for each
|
|
* range. The color value can be a numeric hex color value, a `THREE.Color` object, or
|
|
* any of the strings accepted by `THREE.Color`.
|
|
*/
|
|
this.colorRanges = null;
|
|
|
|
/**
|
|
* @member {number|string} outlineWidth
|
|
* WARNING: This API is experimental and may change.
|
|
* The width of an outline/halo to be drawn around each text glyph using the `outlineColor` and `outlineOpacity`.
|
|
* Can be specified as either an absolute number in local units, or as a percentage string e.g.
|
|
* `"12%"` which is treated as a percentage of the `fontSize`. Defaults to `0`, which means
|
|
* no outline will be drawn unless an `outlineOffsetX/Y` or `outlineBlur` is set.
|
|
*/
|
|
this.outlineWidth = 0;
|
|
|
|
/**
|
|
* @member {string|number|THREE.Color} outlineColor
|
|
* WARNING: This API is experimental and may change.
|
|
* The color of the text outline, if `outlineWidth`/`outlineBlur`/`outlineOffsetX/Y` are set.
|
|
* Defaults to black.
|
|
*/
|
|
this.outlineColor = 0x000000;
|
|
|
|
/**
|
|
* @member {number} outlineOpacity
|
|
* WARNING: This API is experimental and may change.
|
|
* The opacity of the outline, if `outlineWidth`/`outlineBlur`/`outlineOffsetX/Y` are set.
|
|
* Defaults to `1`.
|
|
*/
|
|
this.outlineOpacity = 1;
|
|
|
|
/**
|
|
* @member {number|string} outlineBlur
|
|
* WARNING: This API is experimental and may change.
|
|
* A blur radius applied to the outer edge of the text's outline. If the `outlineWidth` is
|
|
* zero, the blur will be applied at the glyph edge, like CSS's `text-shadow` blur radius.
|
|
* Can be specified as either an absolute number in local units, or as a percentage string e.g.
|
|
* `"12%"` which is treated as a percentage of the `fontSize`. Defaults to `0`.
|
|
*/
|
|
this.outlineBlur = 0;
|
|
|
|
/**
|
|
* @member {number|string} outlineOffsetX
|
|
* WARNING: This API is experimental and may change.
|
|
* A horizontal offset for the text outline.
|
|
* Can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"`
|
|
* which is treated as a percentage of the `fontSize`. Defaults to `0`.
|
|
*/
|
|
this.outlineOffsetX = 0;
|
|
|
|
/**
|
|
* @member {number|string} outlineOffsetY
|
|
* WARNING: This API is experimental and may change.
|
|
* A vertical offset for the text outline.
|
|
* Can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"`
|
|
* which is treated as a percentage of the `fontSize`. Defaults to `0`.
|
|
*/
|
|
this.outlineOffsetY = 0;
|
|
|
|
/**
|
|
* @member {number|string} strokeWidth
|
|
* WARNING: This API is experimental and may change.
|
|
* The width of an inner stroke drawn inside each text glyph using the `strokeColor` and `strokeOpacity`.
|
|
* Can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"`
|
|
* which is treated as a percentage of the `fontSize`. Defaults to `0`.
|
|
*/
|
|
this.strokeWidth = 0;
|
|
|
|
/**
|
|
* @member {string|number|THREE.Color} strokeColor
|
|
* WARNING: This API is experimental and may change.
|
|
* The color of the text stroke, if `strokeWidth` is greater than zero. Defaults to gray.
|
|
*/
|
|
this.strokeColor = defaultStrokeColor;
|
|
|
|
/**
|
|
* @member {number} strokeOpacity
|
|
* WARNING: This API is experimental and may change.
|
|
* The opacity of the stroke, if `strokeWidth` is greater than zero. Defaults to `1`.
|
|
*/
|
|
this.strokeOpacity = 1;
|
|
|
|
/**
|
|
* @member {number} fillOpacity
|
|
* WARNING: This API is experimental and may change.
|
|
* The opacity of the glyph's fill from 0 to 1. This behaves like the material's `opacity` but allows
|
|
* giving the fill a different opacity than the `strokeOpacity`. A fillOpacity of `0` makes the
|
|
* interior of the glyph invisible, leaving just the `strokeWidth`. Defaults to `1`.
|
|
*/
|
|
this.fillOpacity = 1;
|
|
|
|
/**
|
|
* @member {number} depthOffset
|
|
* This is a shortcut for setting the material's `polygonOffset` and related properties,
|
|
* which can be useful in preventing z-fighting when this text is laid on top of another
|
|
* plane in the scene. Positive numbers are further from the camera, negatives closer.
|
|
*/
|
|
this.depthOffset = 0;
|
|
|
|
/**
|
|
* @member {Array<number>} clipRect
|
|
* If specified, defines a `[minX, minY, maxX, maxY]` of a rectangle outside of which all
|
|
* pixels will be discarded. This can be used for example to clip overflowing text when
|
|
* `whiteSpace='nowrap'`.
|
|
*/
|
|
this.clipRect = null;
|
|
|
|
/**
|
|
* @member {string} orientation
|
|
* Defines the axis plane on which the text should be laid out when the mesh has no extra
|
|
* rotation transform. It is specified as a string with two axes: the horizontal axis with
|
|
* positive pointing right, and the vertical axis with positive pointing up. By default this
|
|
* is '+x+y', meaning the text sits on the xy plane with the text's top toward positive y
|
|
* and facing positive z. A value of '+x-z' would place it on the xz plane with the text's
|
|
* top toward negative z and facing positive y.
|
|
*/
|
|
this.orientation = defaultOrient;
|
|
|
|
/**
|
|
* @member {number} glyphGeometryDetail
|
|
* Controls number of vertical/horizontal segments that make up each glyph's rectangular
|
|
* plane. Defaults to 1. This can be increased to provide more geometrical detail for custom
|
|
* vertex shader effects, for example.
|
|
*/
|
|
this.glyphGeometryDetail = 1;
|
|
|
|
/**
|
|
* @member {number|null} sdfGlyphSize
|
|
* The size of each glyph's SDF (signed distance field) used for rendering. This must be a
|
|
* power-of-two number. Defaults to 64 which is generally a good balance of size and quality
|
|
* for most fonts. Larger sizes can improve the quality of glyph rendering by increasing
|
|
* the sharpness of corners and preventing loss of very thin lines, at the expense of
|
|
* increased memory footprint and longer SDF generation time.
|
|
*/
|
|
this.sdfGlyphSize = null;
|
|
|
|
/**
|
|
* @member {boolean} gpuAccelerateSDF
|
|
* When `true`, the SDF generation process will be GPU-accelerated with WebGL when possible,
|
|
* making it much faster especially for complex glyphs, and falling back to a JavaScript version
|
|
* executed in web workers when support isn't available. It should automatically detect support,
|
|
* but it's still somewhat experimental, so you can set it to `false` to force it to use the JS
|
|
* version if you encounter issues with it.
|
|
*/
|
|
this.gpuAccelerateSDF = true;
|
|
|
|
this.debugSDF = false;
|
|
}
|
|
|
|
/**
|
|
* Updates the text rendering according to the current text-related configuration properties.
|
|
* This is an async process, so you can pass in a callback function to be executed when it
|
|
* finishes.
|
|
* @param {function} [callback]
|
|
*/
|
|
sync(callback) {
|
|
if (this._needsSync) {
|
|
this._needsSync = false;
|
|
|
|
// If there's another sync still in progress, queue
|
|
if (this._isSyncing) {
|
|
(this._queuedSyncs || (this._queuedSyncs = [])).push(callback);
|
|
} else {
|
|
this._isSyncing = true;
|
|
this.dispatchEvent(syncStartEvent);
|
|
|
|
getTextRenderInfo({
|
|
text: this.text,
|
|
font: this.font,
|
|
lang: this.lang,
|
|
fontSize: this.fontSize || 0.1,
|
|
fontWeight: this.fontWeight || 'normal',
|
|
fontStyle: this.fontStyle || 'normal',
|
|
letterSpacing: this.letterSpacing || 0,
|
|
lineHeight: this.lineHeight || 'normal',
|
|
maxWidth: this.maxWidth,
|
|
direction: this.direction || 'auto',
|
|
textAlign: this.textAlign,
|
|
textIndent: this.textIndent,
|
|
whiteSpace: this.whiteSpace,
|
|
overflowWrap: this.overflowWrap,
|
|
anchorX: this.anchorX,
|
|
anchorY: this.anchorY,
|
|
colorRanges: this.colorRanges,
|
|
includeCaretPositions: true, //TODO parameterize
|
|
sdfGlyphSize: this.sdfGlyphSize,
|
|
gpuAccelerateSDF: this.gpuAccelerateSDF,
|
|
unicodeFontsURL: this.unicodeFontsURL,
|
|
}, textRenderInfo => {
|
|
this._isSyncing = false;
|
|
|
|
// Save result for later use in onBeforeRender
|
|
this._textRenderInfo = textRenderInfo;
|
|
|
|
// Update the geometry attributes
|
|
this.geometry.updateGlyphs(
|
|
textRenderInfo.glyphBounds,
|
|
textRenderInfo.glyphAtlasIndices,
|
|
textRenderInfo.blockBounds,
|
|
textRenderInfo.chunkedBounds,
|
|
textRenderInfo.glyphColors
|
|
);
|
|
|
|
// If we had extra sync requests queued up, kick it off
|
|
const queued = this._queuedSyncs;
|
|
if (queued) {
|
|
this._queuedSyncs = null;
|
|
this._needsSync = true;
|
|
this.sync(() => {
|
|
queued.forEach(fn => fn && fn());
|
|
});
|
|
}
|
|
|
|
this.dispatchEvent(syncCompleteEvent);
|
|
if (callback) {
|
|
callback();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initiate a sync if needed - note it won't complete until next frame at the
|
|
* earliest so if possible it's a good idea to call sync() manually as soon as
|
|
* all the properties have been set.
|
|
* @override
|
|
*/
|
|
onBeforeRender(renderer, scene, camera, geometry, material, group) {
|
|
this.sync();
|
|
|
|
// This may not always be a text material, e.g. if there's a scene.overrideMaterial present
|
|
if (material.isTroikaTextMaterial) {
|
|
this._prepareForRender(material);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shortcut to dispose the geometry specific to this instance.
|
|
* Note: we don't also dispose the derived material here because if anything else is
|
|
* sharing the same base material it will result in a pause next frame as the program
|
|
* is recompiled. Instead users can dispose the base material manually, like normal,
|
|
* and we'll also dispose the derived material at that time.
|
|
*/
|
|
dispose() {
|
|
this.geometry.dispose();
|
|
}
|
|
|
|
/**
|
|
* @property {TroikaTextRenderInfo|null} textRenderInfo
|
|
* @readonly
|
|
* The current processed rendering data for this TextMesh, returned by the TextBuilder after
|
|
* a `sync()` call. This will be `null` initially, and may be stale for a short period until
|
|
* the asynchrous `sync()` process completes.
|
|
*/
|
|
get textRenderInfo() {
|
|
return this._textRenderInfo || null
|
|
}
|
|
|
|
/**
|
|
* Create the text derived material from the base material. Can be overridden to use a custom
|
|
* derived material.
|
|
*/
|
|
createDerivedMaterial(baseMaterial) {
|
|
return createTextDerivedMaterial(baseMaterial)
|
|
}
|
|
|
|
// Handler for automatically wrapping the base material with our upgrades. We do the wrapping
|
|
// lazily on _read_ rather than write to avoid unnecessary wrapping on transient values.
|
|
get material() {
|
|
let derivedMaterial = this._derivedMaterial;
|
|
const baseMaterial = this._baseMaterial || this._defaultMaterial || (this._defaultMaterial = defaultMaterial.clone());
|
|
if (!derivedMaterial || !derivedMaterial.isDerivedFrom(baseMaterial)) {
|
|
derivedMaterial = this._derivedMaterial = this.createDerivedMaterial(baseMaterial);
|
|
// dispose the derived material when its base material is disposed:
|
|
baseMaterial.addEventListener('dispose', function onDispose() {
|
|
baseMaterial.removeEventListener('dispose', onDispose);
|
|
derivedMaterial.dispose();
|
|
});
|
|
}
|
|
// If text outline is configured, render it as a preliminary draw using Three's multi-material
|
|
// feature (see GlyphsGeometry which sets up `groups` for this purpose) Doing it with multi
|
|
// materials ensures the layers are always rendered consecutively in a consistent order.
|
|
// Each layer will trigger onBeforeRender with the appropriate material.
|
|
if (this.hasOutline()) {
|
|
let outlineMaterial = derivedMaterial._outlineMtl;
|
|
if (!outlineMaterial) {
|
|
outlineMaterial = derivedMaterial._outlineMtl = Object.create(derivedMaterial, {
|
|
id: {value: derivedMaterial.id + 0.1}
|
|
});
|
|
outlineMaterial.isTextOutlineMaterial = true;
|
|
outlineMaterial.depthWrite = false;
|
|
outlineMaterial.map = null; //???
|
|
derivedMaterial.addEventListener('dispose', function onDispose() {
|
|
derivedMaterial.removeEventListener('dispose', onDispose);
|
|
outlineMaterial.dispose();
|
|
});
|
|
}
|
|
return [
|
|
outlineMaterial,
|
|
derivedMaterial
|
|
]
|
|
} else {
|
|
return derivedMaterial
|
|
}
|
|
}
|
|
set material(baseMaterial) {
|
|
if (baseMaterial && baseMaterial.isTroikaTextMaterial) { //prevent double-derivation
|
|
this._derivedMaterial = baseMaterial;
|
|
this._baseMaterial = baseMaterial.baseMaterial;
|
|
} else {
|
|
this._baseMaterial = baseMaterial;
|
|
}
|
|
}
|
|
|
|
hasOutline() {
|
|
return !!(this.outlineWidth || this.outlineBlur || this.outlineOffsetX || this.outlineOffsetY)
|
|
}
|
|
|
|
get glyphGeometryDetail() {
|
|
return this.geometry.detail
|
|
}
|
|
set glyphGeometryDetail(detail) {
|
|
this.geometry.detail = detail;
|
|
}
|
|
|
|
get curveRadius() {
|
|
return this.geometry.curveRadius
|
|
}
|
|
set curveRadius(r) {
|
|
this.geometry.curveRadius = r;
|
|
}
|
|
|
|
// Create and update material for shadows upon request:
|
|
get customDepthMaterial() {
|
|
return first(this.material).getDepthMaterial()
|
|
}
|
|
set customDepthMaterial(m) {
|
|
// future: let the user override with their own?
|
|
}
|
|
get customDistanceMaterial() {
|
|
return first(this.material).getDistanceMaterial()
|
|
}
|
|
set customDistanceMaterial(m) {
|
|
// future: let the user override with their own?
|
|
}
|
|
|
|
_prepareForRender(material) {
|
|
const isOutline = material.isTextOutlineMaterial;
|
|
const uniforms = material.uniforms;
|
|
const textInfo = this.textRenderInfo;
|
|
if (textInfo) {
|
|
const {sdfTexture, blockBounds} = textInfo;
|
|
uniforms.uTroikaSDFTexture.value = sdfTexture;
|
|
uniforms.uTroikaSDFTextureSize.value.set(sdfTexture.image.width, sdfTexture.image.height);
|
|
uniforms.uTroikaSDFGlyphSize.value = textInfo.sdfGlyphSize;
|
|
uniforms.uTroikaSDFExponent.value = textInfo.sdfExponent;
|
|
uniforms.uTroikaTotalBounds.value.fromArray(blockBounds);
|
|
uniforms.uTroikaUseGlyphColors.value = !isOutline && !!textInfo.glyphColors;
|
|
|
|
let distanceOffset = 0;
|
|
let blurRadius = 0;
|
|
let strokeWidth = 0;
|
|
let fillOpacity;
|
|
let strokeOpacity;
|
|
let strokeColor;
|
|
let offsetX = 0;
|
|
let offsetY = 0;
|
|
|
|
if (isOutline) {
|
|
let {outlineWidth, outlineOffsetX, outlineOffsetY, outlineBlur, outlineOpacity} = this;
|
|
distanceOffset = this._parsePercent(outlineWidth) || 0;
|
|
blurRadius = Math.max(0, this._parsePercent(outlineBlur) || 0);
|
|
fillOpacity = outlineOpacity;
|
|
offsetX = this._parsePercent(outlineOffsetX) || 0;
|
|
offsetY = this._parsePercent(outlineOffsetY) || 0;
|
|
} else {
|
|
strokeWidth = Math.max(0, this._parsePercent(this.strokeWidth) || 0);
|
|
if (strokeWidth) {
|
|
strokeColor = this.strokeColor;
|
|
uniforms.uTroikaStrokeColor.value.set(strokeColor == null ? defaultStrokeColor : strokeColor);
|
|
strokeOpacity = this.strokeOpacity;
|
|
if (strokeOpacity == null) strokeOpacity = 1;
|
|
}
|
|
fillOpacity = this.fillOpacity;
|
|
}
|
|
|
|
uniforms.uTroikaEdgeOffset.value = distanceOffset;
|
|
uniforms.uTroikaPositionOffset.value.set(offsetX, offsetY);
|
|
uniforms.uTroikaBlurRadius.value = blurRadius;
|
|
uniforms.uTroikaStrokeWidth.value = strokeWidth;
|
|
uniforms.uTroikaStrokeOpacity.value = strokeOpacity;
|
|
uniforms.uTroikaFillOpacity.value = fillOpacity == null ? 1 : fillOpacity;
|
|
uniforms.uTroikaCurveRadius.value = this.curveRadius || 0;
|
|
|
|
let clipRect = this.clipRect;
|
|
if (clipRect && Array.isArray(clipRect) && clipRect.length === 4) {
|
|
uniforms.uTroikaClipRect.value.fromArray(clipRect);
|
|
} else {
|
|
// no clipping - choose a finite rect that shouldn't ever be reached by overflowing glyphs or outlines
|
|
const pad = (this.fontSize || 0.1) * 100;
|
|
uniforms.uTroikaClipRect.value.set(
|
|
blockBounds[0] - pad,
|
|
blockBounds[1] - pad,
|
|
blockBounds[2] + pad,
|
|
blockBounds[3] + pad
|
|
);
|
|
}
|
|
this.geometry.applyClipRect(uniforms.uTroikaClipRect.value);
|
|
}
|
|
uniforms.uTroikaSDFDebug.value = !!this.debugSDF;
|
|
material.polygonOffset = !!this.depthOffset;
|
|
material.polygonOffsetFactor = material.polygonOffsetUnits = this.depthOffset || 0;
|
|
|
|
// Shortcut for setting material color via `color` prop on the mesh; this is
|
|
// applied only to the derived material to avoid mutating a shared base material.
|
|
const color = isOutline ? (this.outlineColor || 0) : this.color;
|
|
|
|
if (color == null) {
|
|
delete material.color; //inherit from base
|
|
} else {
|
|
const colorObj = material.hasOwnProperty('color') ? material.color : (material.color = new Color());
|
|
if (color !== colorObj._input || typeof color === 'object') {
|
|
colorObj.set(colorObj._input = color);
|
|
}
|
|
}
|
|
|
|
// base orientation
|
|
let orient = this.orientation || defaultOrient;
|
|
if (orient !== material._orientation) {
|
|
let rotMat = uniforms.uTroikaOrient.value;
|
|
orient = orient.replace(/[^-+xyz]/g, '');
|
|
let match = orient !== defaultOrient && orient.match(/^([-+])([xyz])([-+])([xyz])$/);
|
|
if (match) {
|
|
let [, hSign, hAxis, vSign, vAxis] = match;
|
|
tempVec3a.set(0, 0, 0)[hAxis] = hSign === '-' ? 1 : -1;
|
|
tempVec3b.set(0, 0, 0)[vAxis] = vSign === '-' ? -1 : 1;
|
|
tempMat4.lookAt(origin, tempVec3a.cross(tempVec3b), tempVec3b);
|
|
rotMat.setFromMatrix4(tempMat4);
|
|
} else {
|
|
rotMat.identity();
|
|
}
|
|
material._orientation = orient;
|
|
}
|
|
}
|
|
|
|
_parsePercent(value) {
|
|
if (typeof value === 'string') {
|
|
let match = value.match(/^(-?[\d.]+)%$/);
|
|
let pct = match ? parseFloat(match[1]) : NaN;
|
|
value = (isNaN(pct) ? 0 : pct / 100) * this.fontSize;
|
|
}
|
|
return value
|
|
}
|
|
|
|
/**
|
|
* Translate a point in local space to an x/y in the text plane.
|
|
*/
|
|
localPositionToTextCoords(position, target = new Vector2()) {
|
|
target.copy(position); //simple non-curved case is 1:1
|
|
const r = this.curveRadius;
|
|
if (r) { //flatten the curve
|
|
target.x = Math.atan2(position.x, Math.abs(r) - Math.abs(position.z)) * Math.abs(r);
|
|
}
|
|
return target
|
|
}
|
|
|
|
/**
|
|
* Translate a point in world space to an x/y in the text plane.
|
|
*/
|
|
worldPositionToTextCoords(position, target = new Vector2()) {
|
|
tempVec3a.copy(position);
|
|
return this.localPositionToTextCoords(this.worldToLocal(tempVec3a), target)
|
|
}
|
|
|
|
/**
|
|
* @override Custom raycasting to test against the whole text block's max rectangular bounds
|
|
* TODO is there any reason to make this more granular, like within individual line or glyph rects?
|
|
*/
|
|
raycast(raycaster, intersects) {
|
|
const {textRenderInfo, curveRadius} = this;
|
|
if (textRenderInfo) {
|
|
const bounds = textRenderInfo.blockBounds;
|
|
const raycastMesh = curveRadius ? getCurvedRaycastMesh() : getFlatRaycastMesh();
|
|
const geom = raycastMesh.geometry;
|
|
const {position, uv} = geom.attributes;
|
|
for (let i = 0; i < uv.count; i++) {
|
|
let x = bounds[0] + (uv.getX(i) * (bounds[2] - bounds[0]));
|
|
const y = bounds[1] + (uv.getY(i) * (bounds[3] - bounds[1]));
|
|
let z = 0;
|
|
if (curveRadius) {
|
|
z = curveRadius - Math.cos(x / curveRadius) * curveRadius;
|
|
x = Math.sin(x / curveRadius) * curveRadius;
|
|
}
|
|
position.setXYZ(i, x, y, z);
|
|
}
|
|
geom.boundingSphere = this.geometry.boundingSphere;
|
|
geom.boundingBox = this.geometry.boundingBox;
|
|
raycastMesh.matrixWorld = this.matrixWorld;
|
|
raycastMesh.material.side = this.material.side;
|
|
tempArray.length = 0;
|
|
raycastMesh.raycast(raycaster, tempArray);
|
|
for (let i = 0; i < tempArray.length; i++) {
|
|
tempArray[i].object = this;
|
|
intersects.push(tempArray[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
copy(source) {
|
|
// Prevent copying the geometry reference so we don't end up sharing attributes between instances
|
|
const geom = this.geometry;
|
|
super.copy(source);
|
|
this.geometry = geom;
|
|
|
|
COPYABLE_PROPS.forEach(prop => {
|
|
this[prop] = source[prop];
|
|
});
|
|
return this
|
|
}
|
|
|
|
clone() {
|
|
return new this.constructor().copy(this)
|
|
}
|
|
}
|
|
|
|
|
|
// Create setters for properties that affect text layout:
|
|
SYNCABLE_PROPS.forEach(prop => {
|
|
const privateKey = '_private_' + prop;
|
|
Object.defineProperty(Text.prototype, prop, {
|
|
get() {
|
|
return this[privateKey]
|
|
},
|
|
set(value) {
|
|
if (value !== this[privateKey]) {
|
|
this[privateKey] = value;
|
|
this._needsSync = true;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
const syncStartEvent$1 = { type: "syncstart" };
|
|
const syncCompleteEvent$1 = { type: "synccomplete" };
|
|
const memberIndexAttrName = "aTroikaTextBatchMemberIndex";
|
|
|
|
|
|
/*
|
|
Data texture packing strategy:
|
|
|
|
# Common:
|
|
0-15: matrix
|
|
16-19: uTroikaTotalBounds
|
|
20-23: uTroikaClipRect
|
|
24: diffuse (color/outlineColor)
|
|
25: uTroikaFillOpacity (fillOpacity/outlineOpacity)
|
|
26: uTroikaCurveRadius
|
|
27: <blank>
|
|
|
|
# Main:
|
|
28: uTroikaStrokeWidth
|
|
29: uTroikaStrokeColor
|
|
30: uTroikaStrokeOpacity
|
|
|
|
# Outline:
|
|
28-29: uTroikaPositionOffset
|
|
30: uTroikaEdgeOffset
|
|
31: uTroikaBlurRadius
|
|
*/
|
|
const floatsPerMember = 32;
|
|
|
|
const tempBox3 = new Box3();
|
|
const tempColor$1 = new Color();
|
|
|
|
/**
|
|
* @experimental
|
|
*
|
|
* A specialized `Text` implementation that accepts any number of `Text` children
|
|
* and automatically batches them together to render in a single draw call.
|
|
*
|
|
* The `material` of each child `Text` will be ignored, and the `material` of the
|
|
* `BatchedText` will be used for all of them instead.
|
|
*
|
|
* NOTE: This only works in WebGL2 or where the OES_texture_float extension is available.
|
|
*/
|
|
class BatchedText extends Text {
|
|
constructor () {
|
|
super();
|
|
|
|
/**
|
|
* @typedef {Object} PackingInfo
|
|
* @property {number} index - the packing order index when last packed, or -1
|
|
* @property {boolean} dirty - whether it has synced since last pack
|
|
*/
|
|
|
|
/**
|
|
* @type {Map<Text, PackingInfo>}
|
|
*/
|
|
this._members = new Map();
|
|
this._dataTextures = {};
|
|
|
|
this._onMemberSynced = (e) => {
|
|
this._members.get(e.target).dirty = true;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* Batch any Text objects added as children
|
|
*/
|
|
add (...objects) {
|
|
for (let i = 0; i < objects.length; i++) {
|
|
if (objects[i] instanceof Text) {
|
|
this.addText(objects[i]);
|
|
} else {
|
|
super.add(objects[i]);
|
|
}
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
remove (...objects) {
|
|
for (let i = 0; i < objects.length; i++) {
|
|
if (objects[i] instanceof Text) {
|
|
this.removeText(objects[i]);
|
|
} else {
|
|
super.remove(objects[i]);
|
|
}
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* @param {Text} text
|
|
*/
|
|
addText (text) {
|
|
if (!this._members.has(text)) {
|
|
this._members.set(text, {
|
|
index: -1,
|
|
glyphCount: -1,
|
|
dirty: true
|
|
});
|
|
text.addEventListener("synccomplete", this._onMemberSynced);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Text} text
|
|
*/
|
|
removeText (text) {
|
|
this._needsRepack = true;
|
|
text.removeEventListener("synccomplete", this._onMemberSynced);
|
|
this._members.delete(text);
|
|
}
|
|
|
|
/**
|
|
* Use the custom derivation with extra batching logic
|
|
*/
|
|
createDerivedMaterial (baseMaterial) {
|
|
return createBatchedTextMaterial(baseMaterial);
|
|
}
|
|
|
|
updateMatrixWorld (force) {
|
|
super.updateMatrixWorld(force);
|
|
this.updateBounds();
|
|
}
|
|
|
|
/**
|
|
* Update the batched geometry bounds to hold all members
|
|
*/
|
|
updateBounds () {
|
|
// Update member local matrices and the overall bounds
|
|
const bbox = this.geometry.boundingBox.makeEmpty();
|
|
this._members.forEach((_, text) => {
|
|
if (text.matrixAutoUpdate) text.updateMatrix(); // ignore world matrix
|
|
tempBox3.copy(text.geometry.boundingBox).applyMatrix4(text.matrix);
|
|
bbox.union(tempBox3);
|
|
});
|
|
bbox.getBoundingSphere(this.geometry.boundingSphere);
|
|
}
|
|
|
|
/** @override */
|
|
hasOutline() {
|
|
// Iterator.some() not supported in Safari
|
|
for (let member of this._members.keys()) {
|
|
if (member.hasOutline()) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* Copy member matrices and uniform values into the data texture
|
|
*/
|
|
_prepareForRender (material) {
|
|
const isOutline = material.isTextOutlineMaterial;
|
|
material.uniforms.uTroikaIsOutline.value = isOutline;
|
|
|
|
// Resize the texture to fit in powers of 2
|
|
let texture = this._dataTextures[isOutline ? 'outline' : 'main'];
|
|
const dataLength = Math.pow(2, Math.ceil(Math.log2(this._members.size * floatsPerMember)));
|
|
if (!texture || dataLength !== texture.image.data.length) {
|
|
// console.log(`resizing: ${dataLength}`);
|
|
if (texture) texture.dispose();
|
|
const width = Math.min(dataLength / 4, 1024);
|
|
texture = this._dataTextures[isOutline ? 'outline' : 'main'] = new DataTexture(
|
|
new Float32Array(dataLength),
|
|
width,
|
|
dataLength / 4 / width,
|
|
RGBAFormat,
|
|
FloatType
|
|
);
|
|
}
|
|
|
|
const texData = texture.image.data;
|
|
const setTexData = (index, value) => {
|
|
if (value !== texData[index]) {
|
|
texData[index] = value;
|
|
texture.needsUpdate = true;
|
|
}
|
|
};
|
|
this._members.forEach(({ index, dirty }, text) => {
|
|
if (index > -1) {
|
|
const startIndex = index * floatsPerMember;
|
|
|
|
// Matrix
|
|
const matrix = text.matrix.elements;
|
|
for (let i = 0; i < 16; i++) {
|
|
setTexData(startIndex + i, matrix[i]);
|
|
}
|
|
|
|
// Let the member populate the uniforms, since that does all the appropriate
|
|
// logic and handling of defaults, and we'll just grab the results from there
|
|
text._prepareForRender(material);
|
|
const {
|
|
uTroikaTotalBounds,
|
|
uTroikaClipRect,
|
|
uTroikaPositionOffset,
|
|
uTroikaEdgeOffset,
|
|
uTroikaBlurRadius,
|
|
uTroikaStrokeWidth,
|
|
uTroikaStrokeColor,
|
|
uTroikaStrokeOpacity,
|
|
uTroikaFillOpacity,
|
|
uTroikaCurveRadius,
|
|
} = material.uniforms;
|
|
|
|
// Total bounds for uv
|
|
for (let i = 0; i < 4; i++) {
|
|
setTexData(startIndex + 16 + i, uTroikaTotalBounds.value.getComponent(i));
|
|
}
|
|
|
|
// Clip rect
|
|
for (let i = 0; i < 4; i++) {
|
|
setTexData(startIndex + 20 + i, uTroikaClipRect.value.getComponent(i));
|
|
}
|
|
|
|
// Color
|
|
let color = isOutline ? (text.outlineColor || 0) : text.color;
|
|
if (color == null) color = this.color;
|
|
if (color == null) color = this.material.color;
|
|
if (color == null) color = 0xffffff;
|
|
setTexData(startIndex + 24, tempColor$1.set(color).getHex());
|
|
|
|
// Fill opacity / outline opacity
|
|
setTexData(startIndex + 25, uTroikaFillOpacity.value);
|
|
|
|
// Curve radius
|
|
setTexData(startIndex + 26, uTroikaCurveRadius.value);
|
|
|
|
if (isOutline) {
|
|
// Outline properties
|
|
setTexData(startIndex + 28, uTroikaPositionOffset.value.x);
|
|
setTexData(startIndex + 29, uTroikaPositionOffset.value.y);
|
|
setTexData(startIndex + 30, uTroikaEdgeOffset.value);
|
|
setTexData(startIndex + 31, uTroikaBlurRadius.value);
|
|
} else {
|
|
// Stroke properties
|
|
setTexData(startIndex + 28, uTroikaStrokeWidth.value);
|
|
setTexData(startIndex + 29, tempColor$1.set(uTroikaStrokeColor.value).getHex());
|
|
setTexData(startIndex + 30, uTroikaStrokeOpacity.value);
|
|
}
|
|
}
|
|
});
|
|
material.setMatrixTexture(texture);
|
|
|
|
// For the non-member-specific uniforms:
|
|
super._prepareForRender(material);
|
|
}
|
|
|
|
sync (callback) {
|
|
// TODO: skip members updating their geometries, just use textRenderInfo directly
|
|
|
|
// Trigger sync on all members that need it
|
|
let syncPromises = this._needsRepack ? [] : null;
|
|
this._needsRepack = false;
|
|
this._members.forEach((packingInfo, text) => {
|
|
if (packingInfo.dirty || text._needsSync) {
|
|
packingInfo.dirty = false;
|
|
(syncPromises || (syncPromises = [])).push(new Promise(resolve => {
|
|
if (text._needsSync) {
|
|
text.sync(resolve);
|
|
} else {
|
|
resolve();
|
|
}
|
|
}));
|
|
}
|
|
});
|
|
|
|
// If any needed syncing, wait for them and then repack the batched geometry
|
|
if (syncPromises) {
|
|
this.dispatchEvent(syncStartEvent$1);
|
|
|
|
Promise.all(syncPromises).then(() => {
|
|
const { geometry } = this;
|
|
const batchedAttributes = geometry.attributes;
|
|
let memberIndexes = batchedAttributes[memberIndexAttrName] && batchedAttributes[memberIndexAttrName].array || new Uint16Array(0);
|
|
let batchedGlyphIndexes = batchedAttributes[glyphIndexAttrName] && batchedAttributes[glyphIndexAttrName].array || new Float32Array(0);
|
|
let batchedGlyphBounds = batchedAttributes[glyphBoundsAttrName] && batchedAttributes[glyphBoundsAttrName].array || new Float32Array(0);
|
|
|
|
// Initial pass to collect total glyph count and resize the arrays if needed
|
|
let totalGlyphCount = 0;
|
|
this._members.forEach((packingInfo, { textRenderInfo }) => {
|
|
if (textRenderInfo) {
|
|
totalGlyphCount += textRenderInfo.glyphAtlasIndices.length;
|
|
this._textRenderInfo = textRenderInfo; // TODO - need this, but be smarter
|
|
}
|
|
});
|
|
if (totalGlyphCount !== memberIndexes.length) {
|
|
memberIndexes = cloneAndResize(memberIndexes, totalGlyphCount);
|
|
batchedGlyphIndexes = cloneAndResize(batchedGlyphIndexes, totalGlyphCount);
|
|
batchedGlyphBounds = cloneAndResize(batchedGlyphBounds, totalGlyphCount * 4);
|
|
}
|
|
|
|
// Populate batch arrays
|
|
let memberIndex = 0;
|
|
let glyphIndex = 0;
|
|
this._members.forEach((packingInfo, { textRenderInfo }) => {
|
|
if (textRenderInfo) {
|
|
const glyphCount = textRenderInfo.glyphAtlasIndices.length;
|
|
memberIndexes.fill(memberIndex, glyphIndex, glyphIndex + glyphCount);
|
|
|
|
// TODO can skip these for members that are not dirty or shifting overall position:
|
|
batchedGlyphIndexes.set(textRenderInfo.glyphAtlasIndices, glyphIndex, glyphIndex + glyphCount);
|
|
batchedGlyphBounds.set(textRenderInfo.glyphBounds, glyphIndex * 4, (glyphIndex + glyphCount) * 4);
|
|
|
|
glyphIndex += glyphCount;
|
|
packingInfo.index = memberIndex++;
|
|
}
|
|
});
|
|
|
|
// Update the geometry attributes
|
|
geometry.updateAttributeData(memberIndexAttrName, memberIndexes, 1);
|
|
geometry.getAttribute(memberIndexAttrName).setUsage(DynamicDrawUsage);
|
|
geometry.updateAttributeData(glyphIndexAttrName, batchedGlyphIndexes, 1);
|
|
geometry.updateAttributeData(glyphBoundsAttrName, batchedGlyphBounds, 4);
|
|
|
|
this.updateBounds();
|
|
|
|
this.dispatchEvent(syncCompleteEvent$1);
|
|
if (callback) {
|
|
callback();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
copy (source) {
|
|
if (source instanceof BatchedText) {
|
|
super.copy(source);
|
|
this._members.forEach((_, text) => this.removeText(text));
|
|
source._members.forEach((_, text) => this.addText(text));
|
|
}
|
|
return this;
|
|
}
|
|
|
|
dispose () {
|
|
super.dispose();
|
|
Object.values(this._dataTextures).forEach(tex => tex.dispose());
|
|
}
|
|
}
|
|
|
|
function cloneAndResize (source, newLength) {
|
|
const copy = new source.constructor(newLength);
|
|
copy.set(source.subarray(0, newLength));
|
|
return copy;
|
|
}
|
|
|
|
function createBatchedTextMaterial (baseMaterial) {
|
|
const texUniformName = "uTroikaMatricesTexture";
|
|
const texSizeUniformName = "uTroikaMatricesTextureSize";
|
|
|
|
// Due to how vertexTransform gets injected, the matrix transforms must happen
|
|
// in the base material of TextDerivedMaterial, but other transforms to its
|
|
// shader must come after, so we sandwich it between two derivations.
|
|
|
|
// Transform the vertex position
|
|
let batchMaterial = createDerivedMaterial(baseMaterial, {
|
|
chained: true,
|
|
uniforms: {
|
|
[texSizeUniformName]: { value: new Vector2() },
|
|
[texUniformName]: { value: null }
|
|
},
|
|
// language=GLSL
|
|
vertexDefs: `
|
|
uniform highp sampler2D ${texUniformName};
|
|
uniform vec2 ${texSizeUniformName};
|
|
attribute float ${memberIndexAttrName};
|
|
|
|
vec4 troikaBatchTexel(float offset) {
|
|
offset += ${memberIndexAttrName} * ${floatsPerMember.toFixed(1)} / 4.0;
|
|
float w = ${texSizeUniformName}.x;
|
|
vec2 uv = (vec2(mod(offset, w), floor(offset / w)) + 0.5) / ${texSizeUniformName};
|
|
return texture2D(${texUniformName}, uv);
|
|
}
|
|
`,
|
|
// language=GLSL prefix="void main() {" suffix="}"
|
|
vertexTransform: `
|
|
mat4 matrix = mat4(
|
|
troikaBatchTexel(0.0),
|
|
troikaBatchTexel(1.0),
|
|
troikaBatchTexel(2.0),
|
|
troikaBatchTexel(3.0)
|
|
);
|
|
position.xyz = (matrix * vec4(position, 1.0)).xyz;
|
|
`,
|
|
});
|
|
|
|
// Add the text shaders
|
|
batchMaterial = createTextDerivedMaterial(batchMaterial);
|
|
|
|
// Now make other changes to the derived text shader code
|
|
batchMaterial = createDerivedMaterial(batchMaterial, {
|
|
chained: true,
|
|
uniforms: {
|
|
uTroikaIsOutline: {value: false},
|
|
},
|
|
customRewriter(shaders) {
|
|
// Convert some text shader uniforms to varyings
|
|
const varyingUniforms = [
|
|
'uTroikaTotalBounds',
|
|
'uTroikaClipRect',
|
|
'uTroikaPositionOffset',
|
|
'uTroikaEdgeOffset',
|
|
'uTroikaBlurRadius',
|
|
'uTroikaStrokeWidth',
|
|
'uTroikaStrokeColor',
|
|
'uTroikaStrokeOpacity',
|
|
'uTroikaFillOpacity',
|
|
'uTroikaCurveRadius',
|
|
'diffuse'
|
|
];
|
|
varyingUniforms.forEach(uniformName => {
|
|
shaders = uniformToVarying(shaders, uniformName);
|
|
});
|
|
return shaders
|
|
},
|
|
// language=GLSL
|
|
vertexDefs: `
|
|
uniform bool uTroikaIsOutline;
|
|
vec3 troikaFloatToColor(float v) {
|
|
return mod(floor(vec3(v / 65536.0, v / 256.0, v)), 256.0) / 256.0;
|
|
}
|
|
`,
|
|
// language=GLSL prefix="void main() {" suffix="}"
|
|
vertexTransform: `
|
|
uTroikaTotalBounds = troikaBatchTexel(4.0);
|
|
uTroikaClipRect = troikaBatchTexel(5.0);
|
|
|
|
vec4 data = troikaBatchTexel(6.0);
|
|
diffuse = troikaFloatToColor(data.x);
|
|
uTroikaFillOpacity = data.y;
|
|
uTroikaCurveRadius = data.z;
|
|
|
|
data = troikaBatchTexel(7.0);
|
|
if (uTroikaIsOutline) {
|
|
if (data == vec4(0.0)) { // degenerate if zero outline
|
|
position = vec3(0.0);
|
|
} else {
|
|
uTroikaPositionOffset = data.xy;
|
|
uTroikaEdgeOffset = data.z;
|
|
uTroikaBlurRadius = data.w;
|
|
}
|
|
} else {
|
|
uTroikaStrokeWidth = data.x;
|
|
uTroikaStrokeColor = troikaFloatToColor(data.y);
|
|
uTroikaStrokeOpacity = data.z;
|
|
}
|
|
`,
|
|
});
|
|
|
|
batchMaterial.setMatrixTexture = (texture) => {
|
|
batchMaterial.uniforms[texUniformName].value = texture;
|
|
batchMaterial.uniforms[texSizeUniformName].value.set(texture.image.width, texture.image.height);
|
|
};
|
|
return batchMaterial;
|
|
}
|
|
|
|
/**
|
|
* Turn a uniform into a varying/writeable value.
|
|
* - If the uniform was used in the fragment shader, it will become a varying in both shaders.
|
|
* - If the uniform was only used in the vertex shader, it will become a writeable var.
|
|
*/
|
|
function uniformToVarying({vertexShader, fragmentShader}, uniformName, varyingName = uniformName) {
|
|
const uniformRE = new RegExp(`uniform\\s+(bool|float|vec[234]|mat[34])\\s+${uniformName}\\b`);
|
|
|
|
let type;
|
|
let hadFragmentUniform = false;
|
|
fragmentShader = fragmentShader.replace(uniformRE, ($0, $1) => {
|
|
hadFragmentUniform = true;
|
|
return `varying ${type = $1} ${varyingName}`
|
|
});
|
|
|
|
let hadVertexUniform = false;
|
|
vertexShader = vertexShader.replace(uniformRE, (_, $1) => {
|
|
hadVertexUniform = true;
|
|
return `${hadFragmentUniform ? 'varying' : ''} ${type = $1} ${varyingName}`
|
|
});
|
|
if (!hadVertexUniform) {
|
|
vertexShader = `${hadFragmentUniform ? 'varying' : ''} ${type} ${varyingName};\n${vertexShader}`;
|
|
}
|
|
return {vertexShader, fragmentShader}
|
|
}
|
|
|
|
//=== Utility functions for dealing with carets and selection ranges ===//
|
|
|
|
/**
|
|
* @typedef {object} TextCaret
|
|
* @property {number} x - x position of the caret
|
|
* @property {number} y - y position of the caret's bottom
|
|
* @property {number} height - height of the caret
|
|
* @property {number} charIndex - the index in the original input string of this caret's target
|
|
* character; the caret will be for the position _before_ that character.
|
|
*/
|
|
|
|
/**
|
|
* Given a local x/y coordinate in the text block plane, find the nearest caret position.
|
|
* @param {TroikaTextRenderInfo} textRenderInfo - a result object from TextBuilder#getTextRenderInfo
|
|
* @param {number} x
|
|
* @param {number} y
|
|
* @return {TextCaret | null}
|
|
*/
|
|
function getCaretAtPoint(textRenderInfo, x, y) {
|
|
let closestCaret = null;
|
|
const rows = groupCaretsByRow(textRenderInfo);
|
|
|
|
// Find nearest row by y first
|
|
let closestRow = null;
|
|
rows.forEach(row => {
|
|
if (!closestRow || Math.abs(y - (row.top + row.bottom) / 2) < Math.abs(y - (closestRow.top + closestRow.bottom) / 2)) {
|
|
closestRow = row;
|
|
}
|
|
});
|
|
|
|
// Then find closest caret by x within that row
|
|
closestRow.carets.forEach(caret => {
|
|
if (!closestCaret || Math.abs(x - caret.x) < Math.abs(x - closestCaret.x)) {
|
|
closestCaret = caret;
|
|
}
|
|
});
|
|
return closestCaret
|
|
}
|
|
|
|
|
|
const _rectsCache = new WeakMap();
|
|
|
|
/**
|
|
* Given start and end character indexes, return a list of rectangles covering all the
|
|
* characters within that selection.
|
|
* @param {TroikaTextRenderInfo} textRenderInfo
|
|
* @param {number} start - index of the first char in the selection
|
|
* @param {number} end - index of the first char after the selection
|
|
* @return {Array<{left, top, right, bottom}> | null}
|
|
*/
|
|
function getSelectionRects(textRenderInfo, start, end) {
|
|
let rects;
|
|
if (textRenderInfo) {
|
|
// Check cache - textRenderInfo is frozen so it's safe to cache based on it
|
|
let prevResult = _rectsCache.get(textRenderInfo);
|
|
if (prevResult && prevResult.start === start && prevResult.end === end) {
|
|
return prevResult.rects
|
|
}
|
|
|
|
const {caretPositions} = textRenderInfo;
|
|
|
|
// Normalize
|
|
if (end < start) {
|
|
const s = start;
|
|
start = end;
|
|
end = s;
|
|
}
|
|
start = Math.max(start, 0);
|
|
end = Math.min(end, caretPositions.length + 1);
|
|
|
|
// Build list of rects, expanding the current rect for all characters in a run and starting
|
|
// a new rect whenever reaching a new line or a new bidi direction
|
|
rects = [];
|
|
let currentRect = null;
|
|
for (let i = start; i < end; i++) {
|
|
const x1 = caretPositions[i * 4];
|
|
const x2 = caretPositions[i * 4 + 1];
|
|
const left = Math.min(x1, x2);
|
|
const right = Math.max(x1, x2);
|
|
const bottom = caretPositions[i * 4 + 2];
|
|
const top = caretPositions[i * 4 + 3];
|
|
if (!currentRect || bottom !== currentRect.bottom || top !== currentRect.top || left > currentRect.right || right < currentRect.left) {
|
|
currentRect = {
|
|
left: Infinity,
|
|
right: -Infinity,
|
|
bottom,
|
|
top,
|
|
};
|
|
rects.push(currentRect);
|
|
}
|
|
currentRect.left = Math.min(left, currentRect.left);
|
|
currentRect.right = Math.max(right, currentRect.right);
|
|
}
|
|
|
|
// Merge any overlapping rects, e.g. those formed by adjacent bidi runs
|
|
rects.sort((a, b) => b.bottom - a.bottom || a.left - b.left);
|
|
for (let i = rects.length - 1; i-- > 0;) {
|
|
const rectA = rects[i];
|
|
const rectB = rects[i + 1];
|
|
if (rectA.bottom === rectB.bottom && rectA.top === rectB.top && rectA.left <= rectB.right && rectA.right >= rectB.left) {
|
|
rectB.left = Math.min(rectB.left, rectA.left);
|
|
rectB.right = Math.max(rectB.right, rectA.right);
|
|
rects.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
_rectsCache.set(textRenderInfo, {start, end, rects});
|
|
}
|
|
return rects
|
|
}
|
|
|
|
const _caretsByRowCache = new WeakMap();
|
|
|
|
/**
|
|
* Group a set of carets by row of text, caching the result. A single row of text may contain carets of
|
|
* differing positions/heights if it has multiple fonts, and they may overlap slightly across rows, so this
|
|
* uses an assumption of "at least overlapping by half" to put them in the same row.
|
|
* @return Array<{bottom: number, top: number, carets: TextCaret[]}>
|
|
*/
|
|
function groupCaretsByRow(textRenderInfo) {
|
|
// textRenderInfo is frozen so it's safe to cache based on it
|
|
let rows = _caretsByRowCache.get(textRenderInfo);
|
|
if (!rows) {
|
|
rows = [];
|
|
const {caretPositions} = textRenderInfo;
|
|
let curRow;
|
|
|
|
const visitCaret = (x, bottom, top, charIndex) => {
|
|
// new row if not overlapping by at least half
|
|
if (!curRow || (top < (curRow.top + curRow.bottom) / 2)) {
|
|
rows.push(curRow = {bottom, top, carets: []});
|
|
}
|
|
// expand vertical limits if necessary
|
|
if (top > curRow.top) curRow.top = top;
|
|
if (bottom < curRow.bottom) curRow.bottom = bottom;
|
|
curRow.carets.push({
|
|
x,
|
|
y: bottom,
|
|
height: top - bottom,
|
|
charIndex,
|
|
});
|
|
};
|
|
|
|
let i = 0;
|
|
for (; i < caretPositions.length; i += 4) {
|
|
visitCaret(caretPositions[i], caretPositions[i + 2], caretPositions[i + 3], i / 4);
|
|
}
|
|
// Add one more caret after the final char
|
|
visitCaret(caretPositions[i - 3], caretPositions[i - 2], caretPositions[i - 1], i / 4);
|
|
}
|
|
_caretsByRowCache.set(textRenderInfo, rows);
|
|
return rows
|
|
}
|
|
|
|
export { BatchedText, GlyphsGeometry, Text, configureTextBuilder, createTextDerivedMaterial, dumpSDFTextures, fontResolverWorkerModule, getCaretAtPoint, getSelectionRects, getTextRenderInfo, preloadFont, typesetterWorkerModule };
|