@@ -4,9 +4,9 @@ import { PathCommand } from './font.ts';
44/**
55 * SVG renderer supporting a static subset of SVG 1.1/2:
66 * shapes, paths, transforms, nested svg/use/symbol, presentation attributes, inline styles and simple
7- * <style> sheets (type, class and id selectors), solid colors, linear and radial gradients, strokes
7+ * <style> sheets (type, class and id selectors), solid colors, linear and radial gradients, patterns, strokes
88 * (joins, caps, dashes), opacity, clip paths and luminance masks.
9- * Not supported: text, images, filters, patterns, markers, animation and scripting.
9+ * Not supported: text, images, filters, markers, animation and scripting.
1010 */
1111
1212const UNSUPPORTED = new Set ( [ 'text' , 'image' , 'filter' , 'pattern' , 'marker' , 'foreignObject' , 'script' , 'style' , 'title' , 'desc' , 'metadata' ,
@@ -482,6 +482,8 @@ function validProperty(name: string, value: string): boolean {
482482// the most elements to render through <use> references (like librsvg's limit), and dashes of a path
483483const MAX_INSTANCES = 100000 ;
484484const MAX_DASHES = 1000000 ;
485+ // the most pixels of a pattern's tile
486+ const MAX_TILE = 4096 * 1024 ;
485487
486488class Document {
487489 /** elements rendered through <use> references so far */
@@ -511,6 +513,39 @@ class Document {
511513
512514 this . root = root ;
513515 this . rules = parseStyleSheet ( styles . join ( '\n' ) ) ;
516+ for ( const name of [ 'fill' , 'stroke' ] ) this . breakPatternCycles ( name ) ;
517+ }
518+
519+ // like resvg: in document order, a pattern's descendant painted with the pattern itself, or a descendant of the
520+ // pattern it's painted with painted with the first pattern, paints nothing (instead of recursing)
521+ breakPatternCycles ( name : string ) : void {
522+ const paintOf = ( node : XNode ) : string | undefined => {
523+ const inline = node . attrs . style ? parseDeclarations ( node . attrs . style ) : null ;
524+ return inline ?. important [ name ] ?? inline ?. normal [ name ] ?? node . attrs [ name ] ;
525+ } ;
526+ const descendants = ( node : XNode ) : XNode [ ] => {
527+ const list : XNode [ ] = [ ] , stack = [ ...node . children ] ;
528+ while ( stack . length ) {
529+ const n = stack . shift ( ) as XNode ;
530+ list . push ( n ) ;
531+ stack . unshift ( ...n . children ) ;
532+ }
533+ return list ;
534+ } ;
535+ const patterns = descendants ( this . root ) . filter ( node => node . name === 'pattern' ) ;
536+ for ( const pattern of patterns ) {
537+ for ( const node of descendants ( pattern ) ) {
538+ const linked = this . reference ( paintOf ( node ) ) ;
539+ if ( ! linked ) continue ;
540+ if ( linked === pattern ) this . unpaint ( node , name ) ;
541+ else for ( const inner of descendants ( linked ) ) if ( this . reference ( paintOf ( inner ) ) === pattern ) this . unpaint ( inner , name ) ;
542+ }
543+ }
544+ }
545+
546+ unpaint ( node : XNode , name : string ) : void {
547+ node . attrs [ name ] = 'none' ;
548+ if ( node . attrs . style ) node . attrs . style += `;${ name } :none !important` ;
514549 }
515550
516551 reference ( value : string | undefined ) : XNode | null {
@@ -763,6 +798,10 @@ class Document {
763798 const gradient = this . gradient ( server , bbox , matrix , opacity , ctx ) ;
764799 if ( gradient !== undefined || ! fallback ) return gradient || null ;
765800 }
801+ if ( server && server . name === 'pattern' ) {
802+ const pattern = this . pattern ( server , bbox , matrix , opacity , ctx ) ;
803+ if ( pattern !== undefined || ! fallback ) return pattern || null ;
804+ }
766805
767806 // missing, invalid or unsupported paint servers use the fallback color, if any
768807 color = fallback ;
@@ -898,6 +937,123 @@ class Document {
898937 return solidPaint ( [ c [ 0 ] , c [ 1 ] , c [ 2 ] , c [ 3 ] * opacity ] ) ;
899938 }
900939
940+ // collects pattern attributes along href chains, and the first pattern with children
941+ patternAttributes ( node : XNode ) : { attrs : Record < string , string > , content : XNode | null } {
942+ const attrs : Record < string , string > = { } ;
943+ let content : XNode | null = null ;
944+ const names = [ 'x' , 'y' , 'width' , 'height' , 'patternUnits' , 'patternContentUnits' , 'patternTransform' , 'viewBox' , 'preserveAspectRatio' ] ;
945+ for ( let current : XNode | null = node , depth = 0 ; current && depth < 16 && current . name === 'pattern' ; current = this . reference ( this . href ( current ) ) , depth ++ ) {
946+ for ( const name of names )
947+ if ( attrs [ name ] === undefined && current . attrs [ name ] !== undefined ) attrs [ name ] = current . attrs [ name ] ;
948+ if ( ! content && current . children . some ( child => child . svg ) ) content = current ;
949+ }
950+ return { attrs, content } ;
951+ }
952+
953+ // renders one tile of a pattern in device resolution, painted repeating: undefined for an invalid pattern
954+ pattern ( node : XNode , bbox : BBox | null , matrix : Matrix , opacity : number , ctx : Context ) : Paint | null | undefined {
955+ if ( this . active . has ( node ) ) return null ;
956+ const { attrs, content } = this . patternAttributes ( node ) ;
957+ const objectBox = attrs . patternUnits !== 'userSpaceOnUse' ;
958+ const contentBox = attrs . patternContentUnits === 'objectBoundingBox' ;
959+ const viewBox = parseViewBox ( attrs . viewBox ) ;
960+ if ( ( objectBox || ( contentBox && ! viewBox ) ) && ( ! bbox || bbox . width <= 0 || bbox . height <= 0 ) ) return null ;
961+ const box = bbox || { x : 0 , y : 0 , width : 0 , height : 0 } ;
962+
963+ const coordinate = ( name : string , axis : 'x' | 'y' ) : number => {
964+ const value = attrs [ name ] ;
965+ if ( ! objectBox ) return value === undefined ? 0 : parseLength ( value , ctx . viewport , 16 , axis ) ;
966+ const fraction = value === undefined ? 0 : / % \s * $ / . test ( value ) ? parseFloat ( value ) / 100 : parseNumber ( value , 0 ) ;
967+ return axis === 'x' ? box . x + fraction * box . width : box . y + fraction * box . height ;
968+ } ;
969+ const tx = coordinate ( 'x' , 'x' ) , ty = coordinate ( 'y' , 'y' ) ;
970+ const tw = coordinate ( 'width' , 'x' ) - ( objectBox ? box . x : 0 ) , th = coordinate ( 'height' , 'y' ) - ( objectBox ? box . y : 0 ) ;
971+ if ( ! ( tw > 0 ) || ! ( th > 0 ) || ! content ) return null ;
972+
973+ // (a patternTransform that can't be inverted is ignored, like resvg does)
974+ let patternMatrix = matrix , rotated = matrix [ 1 ] !== 0 || matrix [ 2 ] !== 0 ;
975+ if ( attrs . patternTransform ) {
976+ const transform = parseTransform ( attrs . patternTransform ) , transformed = multiply ( patternMatrix , transform ) ;
977+ if ( invert ( transformed ) ) {
978+ patternMatrix = transformed ;
979+ rotated ||= transform [ 1 ] !== 0 || transform [ 2 ] !== 0 ;
980+ }
981+ }
982+ const inverse = invert ( patternMatrix ) ;
983+ if ( ! inverse ) return null ;
984+
985+ // the tile in device resolution, its size rounded like resvg's (lower for tiles of more than 4096 x 1024 pixels)
986+ let sx = Math . hypot ( patternMatrix [ 0 ] , patternMatrix [ 1 ] ) , sy = Math . hypot ( patternMatrix [ 2 ] , patternMatrix [ 3 ] ) ;
987+ const reduce = Math . min ( 1 , Math . sqrt ( MAX_TILE / ( tw * sx * th * sy ) ) ) ;
988+ sx *= reduce ;
989+ sy *= reduce ;
990+ const width = Math . max ( 1 , Math . round ( tw * sx ) ) , height = Math . max ( 1 , Math . round ( th * sy ) ) ;
991+
992+ let contentMatrix = [ sx , 0 , 0 , sy , 0 , 0 ] ;
993+ if ( viewBox ) contentMatrix = multiply ( contentMatrix , viewBoxTransform ( viewBox , tw , th , attrs . preserveAspectRatio ) ) ;
994+ else if ( contentBox ) contentMatrix = multiply ( contentMatrix , [ box . width , 0 , 0 , box . height , 0 , 0 ] ) ;
995+
996+ const tile = new Layer ( width , height ) ;
997+ this . active . add ( node ) ;
998+ this . renderChildren ( content , { ...ctx , matrix : contentMatrix , style : this . styleOf ( content ) , layer : tile , depth : ctx . depth + 1 ,
999+ viewport : viewBox ? { width : viewBox [ 2 ] , height : viewBox [ 3 ] } : ctx . viewport } ) ;
1000+ this . active . delete ( node ) ;
1001+ if ( tile . x1 <= tile . x0 ) return null ;
1002+
1003+ // the repeated tile sampled like resvg's (tiny-skia's): the nearest pixel where the tile is only translated, else
1004+ // bicubic (Mitchell-Netravali, B = C = 1/3); premultiplied, the result straight
1005+ const d = tile . data ;
1006+ const color : Color = [ 0 , 0 , 0 , 0 ] ;
1007+ // (a rotation and its inverse still filter: tiny-skia's single precision doesn't cancel them exactly)
1008+ const translated = ! rotated && patternMatrix [ 0 ] > 0 && patternMatrix [ 3 ] > 0 ;
1009+ const wrap = ( v : number , n : number ) : number => v - Math . floor ( v / n ) * n ;
1010+ const wx = new Float64Array ( 4 ) , wy = new Float64Array ( 4 ) , xs = new Int32Array ( 4 ) , ys = new Int32Array ( 4 ) ;
1011+ const weights = ( t : number , w : Float64Array ) : void => {
1012+ const s = 1 - t ;
1013+ w [ 0 ] = ( 7 / 18 * s - 6 / 18 ) * s * s ;
1014+ w [ 1 ] = ( ( - 21 / 18 * s + 27 / 18 ) * s + 9 / 18 ) * s + 1 / 18 ;
1015+ w [ 2 ] = ( ( - 21 / 18 * t + 27 / 18 ) * t + 9 / 18 ) * t + 1 / 18 ;
1016+ w [ 3 ] = ( 7 / 18 * t - 6 / 18 ) * t * t ;
1017+ } ;
1018+ return ( px , py ) => {
1019+ const u = ( inverse [ 0 ] * px + inverse [ 2 ] * py + inverse [ 4 ] - tx ) * sx - 0.5 ;
1020+ const v = ( inverse [ 1 ] * px + inverse [ 3 ] * py + inverse [ 5 ] - ty ) * sy - 0.5 ;
1021+ if ( translated ) {
1022+ const i = ( Math . floor ( wrap ( v + 0.5 , height ) ) * width + Math . floor ( wrap ( u + 0.5 , width ) ) ) * 4 ;
1023+ const a = d [ i + 3 ] ;
1024+ if ( a <= 0 ) return TRANSPARENT ;
1025+ color [ 0 ] = d [ i ] / a ;
1026+ color [ 1 ] = d [ i + 1 ] / a ;
1027+ color [ 2 ] = d [ i + 2 ] / a ;
1028+ color [ 3 ] = a * opacity ;
1029+ return color ;
1030+ }
1031+ const x0 = Math . floor ( u ) , y0 = Math . floor ( v ) ;
1032+ weights ( u - x0 , wx ) ;
1033+ weights ( v - y0 , wy ) ;
1034+ for ( let k = 0 ; k < 4 ; k ++ ) {
1035+ xs [ k ] = wrap ( x0 - 1 + k , width ) ;
1036+ ys [ k ] = wrap ( y0 - 1 + k , height ) * width ;
1037+ }
1038+ let r = 0 , g = 0 , b = 0 , a = 0 ;
1039+ for ( let j = 0 ; j < 4 ; j ++ )
1040+ for ( let k = 0 ; k < 4 ; k ++ ) {
1041+ const f = wy [ j ] * wx [ k ] , i = ( ys [ j ] + xs [ k ] ) * 4 ;
1042+ r += d [ i ] * f ;
1043+ g += d [ i + 1 ] * f ;
1044+ b += d [ i + 2 ] * f ;
1045+ a += d [ i + 3 ] * f ;
1046+ }
1047+ a = clamp01 ( a ) ;
1048+ if ( a <= 1e-6 ) return TRANSPARENT ;
1049+ color [ 0 ] = clamp01 ( r / a ) ;
1050+ color [ 1 ] = clamp01 ( g / a ) ;
1051+ color [ 2 ] = clamp01 ( b / a ) ;
1052+ color [ 3 ] = a * opacity ;
1053+ return color ;
1054+ } ;
1055+ }
1056+
9011057 // renders a clipPath into a coverage mask
9021058 clipMask ( clip : XNode , node : XNode , matrix : Matrix , context : Context ) : Float32Array {
9031059 const { width, height } = context . layer ;
0 commit comments