22 * SVG renderer supporting a static subset of SVG 1.1/2:
33 * shapes, paths, transforms, nested svg/use/symbol, presentation attributes, inline styles and simple
44 * <style> sheets (type, class and id selectors), solid colors, linear and radial gradients, patterns, strokes
5- * (joins, caps, dashes), opacity, clip paths and luminance masks.
6- * Not supported: text, images, filters, markers, animation and scripting.
5+ * (joins, caps, dashes), markers, opacity, clip paths and luminance masks.
6+ * Not supported: text, images, filters, animation and scripting.
77 * How patterns are rendered (a tile in device resolution, its filtering) and their reference cycles broken follows
8- * resvg/usvg (https://github.com/linebender/resvg; Copyright 2017 the Resvg Authors, MIT License, see
9- * THIRD_PARTY_NOTICES.md)
8+ * resvg/usvg, the markers (their placement and orientation, crates/usvg/src/parser/marker.rs) are ported from usvg
9+ * (https://github.com/linebender/resvg; Copyright 2017 the Resvg Authors, MIT License, see THIRD_PARTY_NOTICES.md)
1010 */
1111
1212import { decode as decodeUTF8 } from '../util/utf8.ts' ;
@@ -16,7 +16,8 @@ const UNSUPPORTED = new Set(['text', 'image', 'filter', 'pattern', 'marker', 'fo
1616 'defs' , 'clipPath' , 'mask' , 'linearGradient' , 'radialGradient' , 'stop' , 'symbol' , 'font' , 'font-face' , 'animate' , 'animateTransform' , 'animateMotion' , 'set' ] ) ;
1717
1818const INHERITED = new Set ( [ 'fill' , 'fill-opacity' , 'fill-rule' , 'stroke' , 'stroke-width' , 'stroke-opacity' , 'stroke-linecap' , 'stroke-linejoin' ,
19- 'stroke-miterlimit' , 'stroke-dasharray' , 'stroke-dashoffset' , 'color' , 'visibility' , 'clip-rule' , 'font-size' ] ) ;
19+ 'stroke-miterlimit' , 'stroke-dasharray' , 'stroke-dashoffset' , 'color' , 'visibility' , 'clip-rule' , 'font-size' , 'marker-start' , 'marker-mid' ,
20+ 'marker-end' ] ) ;
2021
2122// elements a switch may choose from
2223const SWITCHABLE = new Set ( [ 'svg' , 'g' , 'a' , 'switch' , 'use' , 'path' , 'rect' , 'circle' , 'ellipse' , 'line' , 'polyline' , 'polygon' , 'text' , 'image' , 'foreignObject' ] ) ;
@@ -100,7 +101,7 @@ const DEFAULTS: Record<string, string> = {
100101 'fill' : 'black' , 'fill-opacity' : '1' , 'fill-rule' : 'nonzero' , 'stroke' : 'none' , 'stroke-width' : '1' , 'stroke-opacity' : '1' ,
101102 'stroke-linecap' : 'butt' , 'stroke-linejoin' : 'miter' , 'stroke-miterlimit' : '4' , 'stroke-dasharray' : 'none' , 'stroke-dashoffset' : '0' ,
102103 'color' : 'black' , 'visibility' : 'visible' , 'clip-rule' : 'nonzero' , 'font-size' : '16' , 'opacity' : '1' , 'display' : 'inline' ,
103- 'stop-color' : 'black' , 'stop-opacity' : '1'
104+ 'stop-color' : 'black' , 'stop-opacity' : '1' , 'marker-start' : 'none' , 'marker-mid' : 'none' , 'marker-end' : 'none'
104105} ;
105106
106107const SAMPLES = 16 ; // sub-scanlines per pixel
@@ -567,7 +568,11 @@ class Document {
567568
568569 const own : Record < string , string > = { } ;
569570 const apply = ( declarations : Record < string , string > ) : void => {
570- for ( const name in declarations ) if ( validProperty ( name , declarations [ name ] ) ) own [ name ] = declarations [ name ] ;
571+ for ( const name in declarations ) {
572+ // (the marker shorthand only exists in CSS, not as an attribute)
573+ if ( name === 'marker' ) for ( const kind of [ 'marker-start' , 'marker-mid' , 'marker-end' ] ) own [ kind ] = declarations [ name ] ;
574+ else if ( validProperty ( name , declarations [ name ] ) ) own [ name ] = declarations [ name ] ;
575+ }
571576 } ;
572577
573578 const presentation : Record < string , string > = { } ;
@@ -654,7 +659,9 @@ class Document {
654659 case 'use' : return this . renderUse ( node , ctx ) ;
655660 default : {
656661 const path = shapePath ( node , ctx . viewport , parseNumber ( style [ 'font-size' ] , 16 ) ) ;
657- if ( path ) this . paintPath ( path , node , ctx ) ;
662+ if ( ! path ) return ;
663+ this . paintPath ( path , node , ctx ) ;
664+ if ( style . visibility === 'visible' ) this . renderMarkers ( path , node , ctx ) ;
658665 }
659666 }
660667 } ;
@@ -788,6 +795,74 @@ class Document {
788795 }
789796 }
790797
798+ // draws the markers of a shape (like usvg's markers, crates/usvg/src/parser/marker.rs)
799+ renderMarkers ( path : PathCommand [ ] , node : XNode , ctx : Context ) : void {
800+ const style = ctx . style as Style ;
801+ for ( let p = node . parent ; p ; p = p . parent ) if ( p . name === 'clipPath' ) return ;
802+ const kinds = [ 'marker-start' , 'marker-mid' , 'marker-end' ] as const ;
803+ if ( kinds . every ( kind => ! style [ kind ] || style [ kind ] === 'none' ) ) return ;
804+ const segments = markerSegments ( path ) ;
805+ if ( segments . length < 2 ) return ;
806+
807+ for ( let k = 0 ; k < 3 ; k ++ ) {
808+ const marker = this . reference ( style [ kinds [ k ] ] ) ;
809+ if ( ! marker || marker . name !== 'marker' || this . active . has ( marker ) ) continue ;
810+
811+ const units = marker . attrs . markerUnits === 'userSpaceOnUse' ? 1 : parseLength ( style [ 'stroke-width' ] , ctx . viewport , 16 , 'diagonal' ) ;
812+ if ( ! ( units > 0 ) ) continue ;
813+ const length = ( name : string , axis : 'x' | 'y' , fallback : number ) : number =>
814+ marker . attrs [ name ] === undefined ? fallback : parseLength ( marker . attrs [ name ] , ctx . viewport , 16 , axis ) ;
815+ const refX = length ( 'refX' , 'x' , 0 ) , refY = length ( 'refY' , 'y' , 0 ) ;
816+ const width = length ( 'markerWidth' , 'x' , 3 ) , height = length ( 'markerHeight' , 'y' , 3 ) ;
817+ if ( ! ( width > 0 ) || ! ( height > 0 ) ) continue ;
818+
819+ const viewBox = parseViewBox ( marker . attrs . viewBox ) ;
820+ const viewBoxMatrix = viewBox ? viewBoxTransform ( viewBox , width , height , marker . attrs . preserveAspectRatio ) : [ 1 , 0 , 0 , 1 , 0 , 0 ] ;
821+ const sx = Math . hypot ( viewBoxMatrix [ 0 ] , viewBoxMatrix [ 1 ] ) , sy = Math . hypot ( viewBoxMatrix [ 2 ] , viewBoxMatrix [ 3 ] ) ;
822+ // the clip (unless overflow is visible): the marker's size in the viewBox's coordinates
823+ const inverse = invert ( viewBoxMatrix ) ;
824+ if ( ! inverse ) continue ;
825+ const clip = / ^ ( v i s i b l e | a u t o ) $ / . test ( marker . attrs . overflow ?. trim ( ) || '' ) ? null
826+ : transformPoints ( [ 0 , 0 , width , height ] , inverse ) ;
827+
828+ const orient = ( marker . attrs . orient || '' ) . trim ( ) ;
829+ const fixed = orient === 'auto' || orient === 'auto-start-reverse' ? null : parseAngle ( orient ) ;
830+ const markerStyle = this . styleOf ( marker ) ;
831+
832+ const draw = ( x : number , y : number , index : number ) : void => {
833+ let angle = fixed ?? vertexAngle ( segments , index ) ;
834+ if ( orient === 'auto-start-reverse' && index === 0 ) angle = ( angle + 180 ) % 360 ;
835+ let matrix = multiply ( ctx . matrix , [ 1 , 0 , 0 , 1 , x , y ] ) ;
836+ if ( angle ) {
837+ const r = angle * Math . PI / 180 , cos = Math . cos ( r ) , sin = Math . sin ( r ) ;
838+ matrix = multiply ( matrix , [ cos , sin , - sin , cos , 0 , 0 ] ) ;
839+ }
840+ matrix = multiply ( matrix , [ sx * units , 0 , 0 , sy * units , - refX * sx * units , - refY * sy * units ] ) ;
841+
842+ const inner = { ...ctx , matrix, style : markerStyle , depth : ctx . depth + 1 , viewport : viewBox ? { width : viewBox [ 2 ] , height : viewBox [ 3 ] } : ctx . viewport } ;
843+ this . active . add ( marker ) ;
844+ if ( ! clip ) this . renderChildren ( marker , inner ) ;
845+ else this . clipped ( inner , Math . min ( clip [ 0 ] , clip [ 2 ] ) , Math . min ( clip [ 1 ] , clip [ 3 ] ) , Math . abs ( clip [ 2 ] - clip [ 0 ] ) , Math . abs ( clip [ 3 ] - clip [ 1 ] ) ,
846+ layer => this . renderChildren ( marker , { ...inner , layer } ) ) ;
847+ this . active . delete ( marker ) ;
848+ } ;
849+
850+ if ( k === 0 ) {
851+ if ( segments [ 0 ] . type === 'M' ) draw ( segments [ 0 ] . x , segments [ 0 ] . y , 0 ) ;
852+ } else if ( k === 1 ) {
853+ for ( let i = 1 ; i < segments . length - 1 ; i ++ )
854+ if ( segments [ i ] . type !== 'Z' ) draw ( segments [ i ] . x , segments [ i ] . y , i ) ;
855+ } else {
856+ const i = segments . length - 1 , last = segments [ i ] ;
857+ if ( last . type === 'L' || last . type === 'C' ) draw ( last . x , last . y , i ) ;
858+ else if ( last . type === 'Z' ) {
859+ const start = subpathStart ( segments , i ) ;
860+ draw ( start . x , start . y , i ) ;
861+ }
862+ }
863+ }
864+ }
865+
791866 // returns a paint function (x, y) => [r, g, b, a] in device pixels, or null for no paint
792867 paint ( value : string | undefined , style : Style , bbox : BBox | null , matrix : Matrix , opacityValue : string | undefined , ctx : Context ) : Paint | null {
793868 if ( ! value || value === 'none' ) return null ;
@@ -1212,6 +1287,147 @@ class Document {
12121287 }
12131288}
12141289
1290+ // ---------------------------------------------------------------------------
1291+ // markers (like usvg's, crates/usvg/src/parser/marker.rs)
1292+ // ---------------------------------------------------------------------------
1293+
1294+ /** a path segment: its end point (x, y), a cubic's control points (x1, y1, x2, y2) */
1295+ interface Segment {
1296+ type : string ;
1297+ x : number ;
1298+ y : number ;
1299+ x1 : number ;
1300+ y1 : number ;
1301+ x2 : number ;
1302+ y2 : number ;
1303+ }
1304+
1305+ // the segments of a path like tiny-skia's path builder makes them: repeated moves and closes merged, a move after a
1306+ // close that's followed by a line or curve, no move at the end
1307+ function markerSegments ( path : PathCommand [ ] ) : Segment [ ] {
1308+ const segments : Segment [ ] = [ ] ;
1309+ let mx = 0 , my = 0 ;
1310+ const push = ( type : string , x : number , y : number , x1 = 0 , y1 = 0 , x2 = 0 , y2 = 0 ) : void => {
1311+ segments . push ( { type, x, y, x1, y1, x2, y2 } ) ;
1312+ } ;
1313+ for ( const [ command , ...v ] of path ) {
1314+ const last = segments [ segments . length - 1 ] ;
1315+ if ( command === 'M' ) {
1316+ if ( last && last . type === 'M' ) segments . pop ( ) ;
1317+ push ( 'M' , mx = v [ 0 ] , my = v [ 1 ] ) ;
1318+ } else if ( command === 'Z' ) {
1319+ if ( last && last . type !== 'Z' && last . type !== 'M' ) push ( 'Z' , mx , my ) ;
1320+ } else {
1321+ if ( ! last || last . type === 'Z' ) push ( 'M' , mx , my ) ;
1322+ if ( command === 'L' ) push ( 'L' , v [ 0 ] , v [ 1 ] ) ;
1323+ else push ( 'C' , v [ 4 ] , v [ 5 ] , v [ 0 ] , v [ 1 ] , v [ 2 ] , v [ 3 ] ) ;
1324+ }
1325+ }
1326+ if ( segments . length && segments [ segments . length - 1 ] . type === 'M' ) segments . pop ( ) ;
1327+ return segments ;
1328+ }
1329+
1330+ function subpathStart ( segments : Segment [ ] , index : number ) : Segment {
1331+ for ( let i = index - 1 ; i >= 0 ; i -- ) if ( segments [ i ] . type === 'M' ) return segments [ i ] ;
1332+ return { type : 'M' , x : 0 , y : 0 , x1 : 0 , y1 : 0 , x2 : 0 , y2 : 0 } ;
1333+ }
1334+
1335+ function previousVertex ( segments : Segment [ ] , index : number ) : Segment {
1336+ const previous = segments [ index - 1 ] ;
1337+ return previous . type === 'Z' ? subpathStart ( segments , index ) : previous ;
1338+ }
1339+
1340+ // (usvg compares single precision floats within 4 ulps)
1341+ function near ( a : number , b : number ) : boolean {
1342+ return Math . abs ( a - b ) <= 4 * 1.1920929e-7 * Math . max ( Math . abs ( a ) , Math . abs ( b ) ) ;
1343+ }
1344+
1345+ function normalizeAngle ( rad : number ) : number {
1346+ const v = rad % ( Math . PI * 2 ) ;
1347+ return v < 0 ? v + Math . PI * 2 : v ;
1348+ }
1349+
1350+ // the bisector of the incoming (1 to 2) and outgoing (3 to 4) directions, in degrees
1351+ function bisector ( x1 : number , y1 : number , x2 : number , y2 : number , x3 : number , y3 : number , x4 : number , y4 : number ) : number {
1352+ const incoming = normalizeAngle ( Math . atan2 ( y2 - y1 , x2 - x1 ) ) , outgoing = normalizeAngle ( Math . atan2 ( y4 - y3 , x4 - x3 ) ) ;
1353+ const d = ( outgoing - incoming ) / 2 ;
1354+ let angle = incoming + d ;
1355+ if ( Math . abs ( d ) > Math . PI / 2 ) angle -= Math . PI ;
1356+ return normalizeAngle ( angle ) * 180 / Math . PI ;
1357+ }
1358+
1359+ function lineAngle ( x1 : number , y1 : number , x2 : number , y2 : number ) : number {
1360+ return bisector ( x1 , y1 , x2 , y2 , x1 , y1 , x2 , y2 ) ;
1361+ }
1362+
1363+ function curvesAngle ( px : number , py : number , cx1 : number , cy1 : number , x : number , y : number , cx2 : number , cy2 : number , nx : number , ny : number ) : number {
1364+ if ( near ( cx1 , x ) && near ( cy1 , y ) ) return bisector ( px , py , x , y , x , y , cx2 , cy2 ) ;
1365+ if ( near ( x , cx2 ) && near ( y , cy2 ) ) return bisector ( cx1 , cy1 , x , y , x , y , nx , ny ) ;
1366+ return bisector ( cx1 , cy1 , x , y , x , y , cx2 , cy2 ) ;
1367+ }
1368+
1369+ // a marker's orientation at a vertex (orient="auto")
1370+ function vertexAngle ( segments : Segment [ ] , index : number ) : number {
1371+ if ( index === 0 ) {
1372+ const [ m , s ] = segments ;
1373+ if ( m . type !== 'M' ) return 0 ;
1374+ if ( s . type === 'L' ) return lineAngle ( m . x , m . y , s . x , s . y ) ;
1375+ if ( s . type === 'C' ) return near ( m . x , s . x1 ) && near ( m . y , s . y1 ) ? lineAngle ( m . x , m . y , s . x , s . y ) : lineAngle ( m . x , m . y , s . x1 , s . y1 ) ;
1376+ return 0 ;
1377+ }
1378+
1379+ if ( index === segments . length - 1 ) {
1380+ const a = segments [ index - 1 ] , b = segments [ index ] ;
1381+ if ( b . type === 'L' ) {
1382+ const prev = previousVertex ( segments , index ) ;
1383+ return lineAngle ( prev . x , prev . y , b . x , b . y ) ;
1384+ }
1385+ if ( b . type === 'C' ) return near ( b . x2 , b . x ) && near ( b . y2 , b . y ) ? lineAngle ( b . x1 , b . y1 , b . x , b . y ) : lineAngle ( b . x2 , b . y2 , b . x , b . y ) ;
1386+ if ( b . type === 'Z' ) {
1387+ const next = subpathStart ( segments , index ) ;
1388+ if ( a . type === 'L' ) return lineAngle ( a . x , a . y , next . x , next . y ) ;
1389+ if ( a . type === 'C' ) {
1390+ const prev = previousVertex ( segments , index ) ;
1391+ return curvesAngle ( prev . x , prev . y , a . x2 , a . y2 , a . x , a . y , next . x , next . y , next . x , next . y ) ;
1392+ }
1393+ }
1394+ return 0 ;
1395+ }
1396+
1397+ const a = segments [ index ] , b = segments [ index + 1 ] ;
1398+ if ( a . type === 'M' ) {
1399+ if ( b . type === 'L' ) return lineAngle ( a . x , a . y , b . x , b . y ) ;
1400+ if ( b . type === 'C' ) return lineAngle ( a . x , a . y , b . x1 , b . y1 ) ;
1401+ return 0 ;
1402+ }
1403+ if ( a . type === 'Z' ) return 0 ;
1404+ const prev = previousVertex ( segments , index ) ;
1405+ if ( b . type === 'L' ) {
1406+ if ( a . type === 'L' ) return bisector ( prev . x , prev . y , a . x , a . y , a . x , a . y , b . x , b . y ) ;
1407+ return curvesAngle ( prev . x , prev . y , a . x2 , a . y2 , a . x , a . y , b . x , b . y , b . x , b . y ) ;
1408+ }
1409+ if ( b . type === 'C' ) {
1410+ if ( a . type === 'L' ) return curvesAngle ( prev . x , prev . y , prev . x , prev . y , a . x , a . y , b . x1 , b . y1 , b . x , b . y ) ;
1411+ return curvesAngle ( prev . x , prev . y , a . x2 , a . y2 , a . x , a . y , b . x1 , b . y1 , b . x , b . y ) ;
1412+ }
1413+ if ( b . type === 'M' ) {
1414+ if ( a . type === 'L' ) return lineAngle ( prev . x , prev . y , a . x , a . y ) ;
1415+ return near ( a . x , a . x2 ) && near ( a . y , a . y2 ) ? lineAngle ( prev . x , prev . y , a . x , a . y ) : lineAngle ( a . x2 , a . y2 , a . x , a . y ) ;
1416+ }
1417+ // (b is a close)
1418+ const next = subpathStart ( segments , index ) ;
1419+ if ( a . type === 'L' ) return bisector ( prev . x , prev . y , a . x , a . y , a . x , a . y , next . x , next . y ) ;
1420+ return lineAngle ( prev . x , prev . y , next . x , next . y ) ;
1421+ }
1422+
1423+ // an angle in degrees (deg, grad, rad, turn or a plain number), 0 if invalid
1424+ function parseAngle ( value : string ) : number {
1425+ const m = / ^ ( [ + - ] ? (?: \d + \. ? \d * | \. \d + ) (?: e [ + - ] ? \d + ) ? ) ( d e g | g r a d | r a d | t u r n ) ? $ / i. exec ( value ) ;
1426+ if ( ! m ) return 0 ;
1427+ const n = parseFloat ( m [ 1 ] ) , unit = ( m [ 2 ] || 'deg' ) . toLowerCase ( ) ;
1428+ return unit === 'grad' ? n * 0.9 : unit === 'rad' ? n * 180 / Math . PI : unit === 'turn' ? n * 360 : n ;
1429+ }
1430+
12151431const TRANSPARENT : Color = [ 0 , 0 , 0 , 0 ] ;
12161432
12171433function solidPaint ( color : Color ) : Paint {
@@ -1701,7 +1917,9 @@ function arcToCubics(path: PathCommand[], x1: number, y1: number, rx: number, ry
17011917 if ( ! sweep && delta > 0 ) delta -= 2 * Math . PI ;
17021918 else if ( sweep && delta < 0 ) delta += 2 * Math . PI ;
17031919
1704- const segments = Math . ceil ( Math . abs ( delta ) / ( Math . PI / 2 ) - 1e-9 ) ;
1920+ // as many cubics as kurbo makes for usvg (at a tolerance of 0.1), which places mid markers at their ends
1921+ const perTurn = Math . max ( Math . pow ( 1.1163 * Math . max ( rx , ry ) / 0.1 , 1 / 6 ) , 3.999999 ) ;
1922+ const segments = Math . max ( 1 , Math . ceil ( perTurn * Math . abs ( delta ) / ( 2 * Math . PI ) ) ) ;
17051923 const step = delta / segments ;
17061924 const k = 4 / 3 * Math . tan ( step / 4 ) ;
17071925
0 commit comments