From ff80448924541575bfd72c179a5ceb199912a9cf Mon Sep 17 00:00:00 2001 From: Lynnnnnnxx Date: Sat, 14 Sep 2024 22:29:36 +0800 Subject: [PATCH] feat: support `repeating-linear-gradient` (#630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that #624 has been merged, let me bring`repeating-linear-gradient` to satori~ ❤️ Close https://github.com/vercel/satori/issues/554 Note about the algorithm:
Option 1: `0 < deg < 90` define ```math r=(h/w)^2 ``` then, calculate the intersection point of the last two lines ```math y = - r / tan(angle) ·x + w / 2 + h/2+r·w/ (2·tan(angle)) ``` ```math y=tan(angle) ·x + h ``` Finally, we can get `(x1, y1)`, `(x2, y2)` about length: ```math y = - 1 / tan(angle) ·x + w / 2 + h/2+r·w/ (2·tan(angle)) ``` ```math y=tan(angle) ·x + h ``` then, we can get a point: `(a, b)`, so length is $`2 ·\sqrt{(a - w/2)^2 + (b - h/2)^2}`$
Option 2: `90 < deg < 180` define ```math r=(h/w)^2 ``` then, calculate the intersection point of the last two lines ```math y = - r / tan(angle) ·x + w / 2 + h/2+r·w/ (2·tan(angle)) ``` ```math y=tan(angle) ·x ``` Finally, we can get `(x1, y1)`, `(x2, y2)` about length: ```math y = - 1 / tan(angle) ·x + w / 2 + h/2+r·w/ (2·tan(angle)) ``` ```math y=tan(angle) ·x ``` then, we can get a point: `(a, b)`, so length is $`2 ·\sqrt{(a - w/2)^2 + (b - h/2)^2}`$ Actually, I didn't find any spec of the algorithm on calculating the points. I just came across the algorithm accidentally. It turns out it shows the same result just like chrome renders. --- src/builder/background-image.ts | 5 +- src/builder/gradient/linear.ts | 233 ++++++++++++------ src/builder/gradient/radial.ts | 2 +- src/builder/gradient/utils.ts | 6 + src/handler/expand.ts | 6 +- ...ound-size-and-background-repeat-1-snap.png | Bin 0 -> 833 bytes ...-gradient-should-support-degree-1-snap.png | Bin 0 -> 1499 bytes ...-gradient-should-support-degree-2-snap.png | Bin 0 -> 1793 bytes ...-gradient-should-support-degree-3-snap.png | Bin 0 -> 941 bytes ...-gradient-should-support-degree-4-snap.png | Bin 0 -> 1763 bytes ...tiple-repeating-linear-gradient-1-snap.png | Bin 0 -> 894 bytes ...pport-repeating-linear-gradient-1-snap.png | Bin 0 -> 471 bytes ...pport-repeating-linear-gradient-2-snap.png | Bin 0 -> 3077 bytes test/gradient.test.tsx | 101 ++++++++ 14 files changed, 274 insertions(+), 79 deletions(-) create mode 100644 test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-background-size-and-background-repeat-1-snap.png create mode 100644 test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-degree-1-snap.png create mode 100644 test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-degree-2-snap.png create mode 100644 test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-degree-3-snap.png create mode 100644 test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-degree-4-snap.png create mode 100644 test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-multiple-repeating-linear-gradient-1-snap.png create mode 100644 test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-repeating-linear-gradient-1-snap.png create mode 100644 test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-repeating-linear-gradient-2-snap.png diff --git a/src/builder/background-image.ts b/src/builder/background-image.ts index 9b61160a..58636008 100644 --- a/src/builder/background-image.ts +++ b/src/builder/background-image.ts @@ -88,7 +88,10 @@ export default async function backgroundImage( defaultY: 0, }) - if (image.startsWith('linear-gradient(')) { + if ( + image.startsWith('linear-gradient(') || + image.startsWith('repeating-linear-gradient(') + ) { return buildLinearGradient( { id, width, height, repeatX, repeatY }, image, diff --git a/src/builder/gradient/linear.ts b/src/builder/gradient/linear.ts index 23373f43..f3e2c0a2 100644 --- a/src/builder/gradient/linear.ts +++ b/src/builder/gradient/linear.ts @@ -1,6 +1,6 @@ -import { parseLinearGradient } from 'css-gradient-parser' +import { parseLinearGradient, ColorStop } from 'css-gradient-parser' import { normalizeStops } from './utils.js' -import { buildXMLString, calcDegree } from '../../utils.js' +import { buildXMLString, calcDegree, lengthToNumber } from '../../utils.js' export function buildLinearGradient( { @@ -24,90 +24,44 @@ export function buildLinearGradient( ) { const parsed = parseLinearGradient(image) const [imageWidth, imageHeight] = dimensions + const repeating = image.startsWith('repeating') // Calculate the direction. - let x1, y1, x2, y2, length + let points, length, xys if (parsed.orientation.type === 'directional') { - ;[x1, y1, x2, y2] = resolveXYFromDirection(parsed.orientation.value) + points = resolveXYFromDirection(parsed.orientation.value) length = Math.sqrt( - Math.pow((x2 - x1) * imageWidth, 2) + Math.pow((y2 - y1) * imageHeight, 2) + Math.pow((points.x2 - points.x1) * imageWidth, 2) + + Math.pow((points.y2 - points.y1) * imageHeight, 2) ) } else if (parsed.orientation.type === 'angular') { - const EPS = 0.000001 - const r = imageWidth / imageHeight - - function calc(angle) { - angle = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) - - if (Math.abs(angle - Math.PI / 2) < EPS) { - x1 = 0 - y1 = 0 - x2 = 1 - y2 = 0 - length = imageWidth - return - } else if (Math.abs(angle) < EPS) { - x1 = 0 - y1 = 1 - x2 = 0 - y2 = 0 - length = imageHeight - return - } - - // Assuming 0 <= angle < PI / 2. - if (angle >= Math.PI / 2 && angle < Math.PI) { - calc(Math.PI - angle) - y1 = 1 - y1 - y2 = 1 - y2 - return - } else if (angle >= Math.PI) { - calc(angle - Math.PI) - let tmp = x1 - x1 = x2 - x2 = tmp - tmp = y1 - y1 = y2 - y2 = tmp - return - } - - // Remap SVG distortion - const tan = Math.tan(angle) - const tanTexture = tan * r - const angleTexture = Math.atan(tanTexture) - const l = Math.sqrt(2) * Math.cos(Math.PI / 4 - angleTexture) - x1 = 0 - y1 = 1 - x2 = Math.sin(angleTexture) * l - y2 = 1 - Math.cos(angleTexture) * l - - // Get the angle between the distored gradient direction and diagonal. - const x = 1 - const y = 1 / tan - const cosA = Math.abs( - (x * r + y) / Math.sqrt(x * x + y * y) / Math.sqrt(r * r + 1) - ) - - // Get the distored gradient length. - const diagonal = Math.sqrt( - imageWidth * imageWidth + imageHeight * imageHeight - ) - length = diagonal * cosA - } - - calc( + const { length: l, ...p } = calcNormalPoint( (calcDegree( `${parsed.orientation.value.value}${parsed.orientation.value.unit}` ) / 180) * - Math.PI + Math.PI, + imageWidth, + imageHeight ) + + length = l + points = p } - const stops = normalizeStops(length, parsed.stops, inheritableStyle, from) + xys = repeating + ? calcPercentage(parsed.stops, length, points, inheritableStyle) + : points + + const stops = normalizeStops( + length, + parsed.stops, + inheritableStyle, + repeating, + from + ) const gradientId = `satori_bi${id}` const patternId = `satori_pattern_${id}` @@ -126,10 +80,8 @@ export function buildLinearGradient( 'linearGradient', { id: gradientId, - x1, - y1, - x2, - y2, + ...xys, + spreadMethod: repeating ? 'repeat' : 'pad', }, stops .map((stop) => @@ -173,5 +125,134 @@ function resolveXYFromDirection(dir: string) { y1 = 1 } - return [x1, y1, x2, y2] + return { x1, y1, x2, y2 } +} + +/** + * calc start point and end point of linear gradient + */ +function calcNormalPoint(v: number, w: number, h: number) { + const r = Math.pow(h / w, 2) + + // make sure angle is 0 <= angle <= 360 + v = ((v % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) + + let x1, y1, x2, y2, length, tmp, a, b + + const dfs = (angle: number) => { + if (angle === 0) { + x1 = 0 + y1 = h + x2 = 0 + y2 = 0 + length = h + return + } else if (angle === Math.PI / 2) { + x1 = 0 + y1 = 0 + x2 = w + y2 = 0 + length = w + return + } + if (angle > 0 && angle < Math.PI / 2) { + x1 = + ((r * w) / 2 / Math.tan(angle) - h / 2) / + (Math.tan(angle) + r / Math.tan(angle)) + y1 = Math.tan(angle) * x1 + h + x2 = Math.abs(w / 2 - x1) + w / 2 + y2 = h / 2 - Math.abs(y1 - h / 2) + length = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)) + // y = -1 / tan * x = h / 2 +1 / tan * w/2 + // y = tan * x + h + a = + (w / 2 / Math.tan(angle) - h / 2) / + (Math.tan(angle) + 1 / Math.tan(angle)) + b = Math.tan(angle) * a + h + length = 2 * Math.sqrt(Math.pow(w / 2 - a, 2) + Math.pow(h / 2 - b, 2)) + return + } else if (angle > Math.PI / 2 && angle < Math.PI) { + x1 = + (h / 2 + (r * w) / 2 / Math.tan(angle)) / + (Math.tan(angle) + r / Math.tan(angle)) + y1 = Math.tan(angle) * x1 + x2 = Math.abs(w / 2 - x1) + w / 2 + y2 = h / 2 + Math.abs(y1 - h / 2) + // y = -1 / tan * x + h / 2 + 1 / tan * w / 2 + // y = tan * x + a = + (w / 2 / Math.tan(angle) + h / 2) / + (Math.tan(angle) + 1 / Math.tan(angle)) + b = Math.tan(angle) * a + length = 2 * Math.sqrt(Math.pow(w / 2 - a, 2) + Math.pow(h / 2 - b, 2)) + return + } else if (angle >= Math.PI) { + dfs(angle - Math.PI) + + tmp = x1 + x1 = x2 + x2 = tmp + tmp = y1 + y1 = y2 + y2 = tmp + } + } + + dfs(v) + + return { + x1: x1 / w, + y1: y1 / h, + x2: x2 / w, + y2: y2 / h, + length, + } +} + +function calcPercentage( + stops: ColorStop[], + length: number, + points: { + x1: number + y1: number + x2: number + y2: number + }, + inheritableStyle: Record +) { + const { x1, x2, y1, y2 } = points + const p1 = !stops[0].offset + ? 0 + : stops[0].offset.unit === '%' + ? Number(stops[0].offset.value) / 100 + : lengthToNumber( + `${stops[0].offset.value}${stops[0].offset.unit}`, + inheritableStyle.fontSize as number, + length, + inheritableStyle, + true + ) / length + const p2 = !stops.at(-1).offset + ? 1 + : stops.at(-1).offset.unit === '%' + ? Number(stops.at(-1).offset.value) / 100 + : lengthToNumber( + `${stops.at(-1).offset.value}${stops.at(-1).offset.unit}`, + inheritableStyle.fontSize as number, + length, + inheritableStyle, + true + ) / length + + const sx = (x2 - x1) * p1 + x1 + const sy = (y2 - y1) * p1 + y1 + const ex = (x2 - x1) * p2 + x1 + const ey = (y2 - y1) * p2 + y1 + + return { + x1: sx, + y1: sy, + x2: ex, + y2: ey, + } } diff --git a/src/builder/gradient/radial.ts b/src/builder/gradient/radial.ts index ea50d178..a8fc4bd8 100644 --- a/src/builder/gradient/radial.ts +++ b/src/builder/gradient/radial.ts @@ -48,7 +48,7 @@ export function buildRadialGradient( cx = pos.x cy = pos.y - const stops = normalizeStops(width, colorStops, inheritableStyle, from) + const stops = normalizeStops(width, colorStops, inheritableStyle, false, from) const gradientId = `satori_radial_${id}` const patternId = `satori_pattern_${id}` diff --git a/src/builder/gradient/utils.ts b/src/builder/gradient/utils.ts index 0ae2ea1f..50f2a141 100644 --- a/src/builder/gradient/utils.ts +++ b/src/builder/gradient/utils.ts @@ -11,6 +11,7 @@ export function normalizeStops( totalLength: number, colorStops: ColorStop[], inheritedStyle: Record, + repeating: boolean, from?: 'background' | 'mask' ) { // Resolve the color stops based on the spec: @@ -61,6 +62,11 @@ export function normalizeStops( if (lastStop.offset !== 1) { if (typeof lastStop.offset === 'undefined') { lastStop.offset = 1 + } else if (repeating) { + stops[stops.length - 1] = { + offset: 1, + color: lastStop.color, + } } else { stops.push({ offset: 1, diff --git a/src/handler/expand.ts b/src/handler/expand.ts index 380fb334..92fbc775 100644 --- a/src/handler/expand.ts +++ b/src/handler/expand.ts @@ -164,7 +164,11 @@ function handleSpecialCase( if (name === 'background') { value = value.toString().trim() - if (/^(linear-gradient|radial-gradient|url)\(/.test(value)) { + if ( + /^(linear-gradient|radial-gradient|url|repeating-linear-gradient)\(/.test( + value + ) + ) { return getStylesForProperty('backgroundImage', value, true) } return getStylesForProperty('background', value, true) diff --git a/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-background-size-and-background-repeat-1-snap.png b/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-background-size-and-background-repeat-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..8eee0b01a8c1741cbebb1309803eb4961ef02296 GIT binary patch literal 833 zcmeAS@N?(olHy`uVBq!ia0vp^DL`z*!3HE(nbz%NU|{C(ba4!+nDcgqVV7v2h|B$M z7iZH#<|gj~=0)oq48&p@J32d+b}$}{xzo5^smm}I{mr*?W<~aTe2TBNnH>;pC)&q;M%)h?MLYu4(>LyD;*BUS^c{G zEo09F38ycYb{(=k!ow?n`!4r+(Tm=%xxNb+o4i@NxIU}=7`I@{oerb@g69|gzwoXf zsNZSk{f_jD?}4(p=Eu1^7k=R3&)E3P(f-66eA}f4wvr>8XR>dZaNpa#{=&Wf{Q?0ixC5K31D=JyFz5ao#V{-jnn)7 zZQ1+ly}ru?MyW+&^G?2Ix>diKMRI{+*29^5^cH--bn*U{UQyTjA zM=pqYz^Hr8W&&fCzU<#McV+cm8VmUUEj6h=X1u3Pz$AP}d+_@g@we7~bYf977cvI9 z%k};L*q-^FGZr$+e$@H3x8%$I^YL^3=S{!e6aV(q4&!-Su0OWgT>p8)Ug`2DB zWKH$-!q3<&P+YwD*ilwcpr({u*|FmeF#OVijEa=A&Tj6#-O{gD6n;DrW|b8)r^12z zXHTD$IPM~u2pjDdFYXnfW- z0@5)beB#6}NXqU#cC?x8y93(`ut{m>E85P#cwwq1x95Y-FOZP=HITVS&oE1YbrtX% zI)i<2y0=2C7UUiGYc^nSZUids&shqzuoD=C=U=QSG+c0I{Rtplc+j{q)*-#*ihdX}9>X{EG0s&n{2n1!WcnPgg&ebxsLQ0Nk;O;{X5v literal 0 HcmV?d00001 diff --git a/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-degree-1-snap.png b/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-degree-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..8910b0331274a7b1bc58c84233d431fc1eb8c8b7 GIT binary patch literal 1499 zcmeAS@N?(olHy`uVBq!ia0vp^DL`z*!3HE(nbz%NU|?P3>EaktG3V`6`xjr6CEDM& zmv4xW(9qOm^Xh7BoRqX+$r8N02pzWixOMMu*Ty`*{nK{;=ZOz>-xqw1xn4Z~?K{i+ zg;jq`zMQvO`ESK!^Bwi;m7{apm%N>@MRw9Q=SyWCxA=a4vDjQ^aqHmC9sW8`<$-bq z5&fHA?@m?RAh+)Jnsm?Gika3bP(5k3=ho(%aWnEKhJQ}FWjPzgAoi~o`}fVNb501K zA92cX(ze6v{T(5@;cm%dKL%yJhy*O+J&m) z*wwH1(i9)a-vIifyYTQDB){9OTU)+upW*{xNPLT3^E?mk&q;am*RMyP=6>0I4CJ)k z5$ErMf(qh;-hS!Vy{U=^SkoVaf^fl4>BEiBa_%4$*ygRf4hp1$AiWpvcE8L+NWQuK z_*zx!FJ8v&zzArbeRKy{5jZ*y+u7!=pFU08slaSA$lwBCFy7aHa;_>dl4DIfSSaGV z)Ooo3XMEkUd){pw7Mm7u2))UQD=S9VF z9J~pY2L=@|#Gs*D+Ry&F6BOxcAXgh?i-A1{_Vhtpxpl|4{oC{g;>^U{Ynx#a|Gi?1 zTx6U_d;=soS*$r*H4U1y794;4^};5B2Tj6IeRgN>M_8v%hDuBLUt^x#+EAYWv<+zP zw_LHy_n^tF)+Y9QZmshHXsWnm_WC9y*7D@fU$ceB3Q!k9>e%tmrPo2WZ7zf*m%@L? z5Xr|jZjCiOPC)+1dw;aV92}@OwjV!Tdix7Bpg^vfdvr(QHAvp5*qRp!3bHe>*w3|} zH{B?1{cKR$waA90m|F4IJ8uKSGV$8uv%3;E3w#hjq-o}}-3MU_g#By9?seUeBnL?_ zjdpR z@%_gRy=X+#fMYW8Znn*KP?|lyy!0ee+(2~hp0(dlCc|$7kS8(!x+_xbfXfPt71f4! zLDBX2^sa>0NWlfP?eLn&9-xD^_pg7w=V|K~S483gCdV_s_Q#$2vF%)>7*kAv8B#9W zb{L$Nj(;z`2F#&~JK#whR4ke3FPpph=JwGG~C(_XZ?P6tF zMO>m`j7ZuEaYRMv!dLhxP@;@bTtTuyjg&>CeB8@wSxQ5%rM~yG*F}G{&AsnA=Y7ue zJm*w@t{^}BWXwqphZ8P+M{)uDR)UYFrACMM-P*>6Grgq4_Th z?ij+h5B{FoxCV`Un7ysKa#U`>t^^uIN6uIz4w**unc>Q4RIqm0WL0!n<^m;!4)k+N zs;(?#;*>JFb}}&RP>sLLim^preUpk#<(dOij=C|{aiQ(rvgzb!bn5p`CfEHETy9Uv+#84X+|AyM};C7Cb!m_#~+ky57N~ z)vpN9WVlH?45_g5q4Gq!mz25@yhkc|*`ytV>PzQ^L)1${gOX`XS9ZfbGpW4~`4!Cd ze382kP(p*bC7;H&k&NE3WwPcC`;$HfItY?E-*p-LY;p?VVW;ZBS}_Y>gzXE5_>z1) zV0{s18f4tbQ^2eXECz#@i|zQ$o<~?Ng)G4k$C85J!=w2?6rf4!-PoIWn=We)aoCr)8sBc-GK`oCImAKWrDTx8*dTeD-(4A;Pb>-Wgn z>qLjKMo}_t0dQHP+Q9?xSFj`m<3$o`8hA2Ri5&Sl9?+n@>fia803vhN(n5gNN!7V# z@u)_Pn@RPDog$SeQl%Tje^uhq@+pF?!{rFu0;8nzXb+JB7=S8Ny)}iKk;` zL>*8zqm<_Xeq$%U7GpU^H9cY=twhoNoZznF)eJB2!T)Rqlf1+R$!^l}t~7}%s_0no z^00Hv*ygn&W0&ZVC1Ik{K!@W5Zw_LXW4jOC&yQ>RhNK8~R_v2DBQS00;T7xw5;oeB zuApkSmAs^!3)(&!-BpQAyt2);2b8 z((7U4;e>OMxgrai;DaSTt9Bo!g%K|Muien&eTIBXZK&sDD7AL&3)XyEcy=5;>vlJR Pzc!9EuRzkDtN7#}kePg; literal 0 HcmV?d00001 diff --git a/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-degree-3-snap.png b/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-degree-3-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..8c9ccc97d4999812286f0be440001314bef8f88a GIT binary patch literal 941 zcmV;e15*5nP)r)Ron z-oD_gNw(|j`oG$&@flp4?Sy|&%MRF>za{K~zw@^hi_?oAPB;xCegE{~^{v zFGhHyoD_gwiBc!-hadAGOzEIEBfPE7TmX$JCmw>YOF@{a!6-&}OPw_VG&Cn3g->D- zMjMP~gjaHUMvzFIcoN=agD~k}FM4|^KyN3(PCN^*ih?ja*qbOX1)x_V%9#T%z;ja& zX2i0Dhc_2N!oyh#UWO-CL71M)E@JUo187*Bwcs^)*c626v8;sT^$MUNIlaIeaNi{e z6S=Gg1sLItaN;ev>l%cKSauPLKyOO4SHjbYci?tN5XNg+Er5oX*Mf%=@4?N`AdGcc zYXA+a*Mk-(K7i|8f-u^$UI8>TZzMHNd<<7+HG{2?Gdh9={sQP{I;G!|L>6gjaGFodHRMj7J2CoHLRU-bE)` z&VXnzDu4z#>FrH#FMU^XR-FOSAiX^q;UuEG(sw0R&VUH*$0CsA!k$o4RX@ko9Z*5=!K;K z(N2lp&gL^9&xNH35}wXddO7hCTx|-%)GS;YK%>UlO0*N7!X=j=Om5-U02(=G6j4rm z0q0$VFtl(~01a}|+Y@gmzJb%OLDC4`3zx9YPW%AJn}VbPG`tqJu#V3E*^0XCyW*3} P00000NkvXXu0mjfUJ<8& literal 0 HcmV?d00001 diff --git a/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-degree-4-snap.png b/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-repeating-linear-gradient-should-support-degree-4-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..6a4abdb0d6b9f041c7bb4d157b85921400766b19 GIT binary patch literal 1763 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 { ) expect(toImage(svg, 300)).toMatchImageSnapshot() }) + + describe('repeating-linear-gradient', async () => { + it('should support repeating-linear-gradient', async () => { + const svgs = await Promise.all( + [ + 'repeating-linear-gradient(to right, red, blue 50%)', + 'repeating-linear-gradient(to right top, red, blue 30%)', + ].map((backgroundImage) => + satori( +
, + { + width: 100, + height: 100, + fonts, + } + ) + ) + ) + svgs.forEach((svg) => { + expect(toImage(svg, 100)).toMatchImageSnapshot() + }) + }) + + it('should support degree', async () => { + const svgs = await Promise.all( + [ + 'repeating-linear-gradient(30deg, red, blue 50%)', + 'repeating-linear-gradient(150deg, red, blue 30%)', + 'repeating-linear-gradient(-15deg, red, blue 30%)', + 'repeating-linear-gradient(210deg, red, blue 30%)', + ].map((backgroundImage) => + satori( +
, + { + width: 200, + height: 100, + fonts, + } + ) + ) + ) + + svgs.forEach((svg) => { + expect(toImage(svg, 100)).toMatchImageSnapshot() + }) + }) + + it('should support background-size and background-repeat', async () => { + const svg = await satori( +
, + { + width: 200, + height: 100, + fonts, + } + ) + expect(toImage(svg, 100)).toMatchImageSnapshot() + }) + + it('should support multiple repeating-linear-gradient', async () => { + const svg = await satori( +
, + { + width: 200, + height: 100, + fonts, + } + ) + expect(toImage(svg, 100)).toMatchImageSnapshot() + }) + }) })