Repository: DenverCoder1/github-readme-streak-stats (php)

Issue:
Add option to specify output width
**Is your feature request related to a problem? Please describe.**

I use https://github.com/anuraghazra/github-readme-stats in my profile readme as well. I like using this in conjunction with streak-stats, but they output different widths. While readme-stats does support a width option, streak-stats does not.

**Describe the solution you'd like**

A `width` option that specifies the output width of the info graphic in pixels (similar to readme-stats).

**Describe alternatives you've considered**

Use the readme-stats width option to match streak-stats, but this is not ideal if I’d like them both to be smaller.


Pull request description:
feat: Support setting a custom card width
## Description

Fixes #492

### Type of change

<!-- Please delete options that are not relevant. -->

- [ ] Bug fix (added a non-breaking change which fixes an issue)
- [x] New feature (added a non-breaking change which adds functionality)
- [ ] Updated documentation (updated the readme, templates, or other repo files)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)

## How Has This Been Tested?

<!--
If you have changed a feature of the stats cards, please describe the tests you made to verify your changes.
Changes strictly related to documentation can skip this section.
-->

- [x] Tested locally with a valid username
- [x] Tested locally with an invalid username
- [x] Ran tests with `composer test`
- [x] Added or updated test cases to test new features

## Checklist:

- [x] I have checked to make sure no other [pull requests](https://github.com/DenverCoder1/github-readme-streak-stats/pulls?q=is%3Apr+sort%3Aupdated-desc+) are open for this issue
- [x] The code is properly formatted and is consistent with the existing code style
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings

## Screenshots

<!-- If you have updated the design or appearance, please include a screenshot of your changes. -->

![image](https://user-images.githubusercontent.com/20955511/232600723-89da23f1-c735-4e6b-8710-06670c54607d.png)
![image](https://user-images.githubusercontent.com/20955511/232600750-0ee8fc08-e657-48a7-ad63-26633244d7da.png)
![image](https://user-images.githubusercontent.com/20955511/232600914-f9002c2f-f8ec-4dd9-932c-bcecb585ec33.png)


Patch:
diff --git a/README.md b/README.md
index b95edc1..6e558a4 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,7 @@ If the `theme` parameter is specified, any color customizations specified will b
 |        `mode`        |          Streak mode (Default: `daily`)          |             `daily` (contribute daily) or `weekly` (contribute once per Sun-Sat week)              |
 |    `exclude_days`    | List of days of the week to exclude from streaks |       Comma-separated list of day abbreviations (Sun,Mon,Tue,Wed,Thu,Fri,Sat) e.g. `Sun,Sat`       |
 | `disable_animations` |    Disable SVG animations (Default: `false`)     |                                         `true` or `false`                                          |
+|     `card_width`     |   Width of the card in pixels (Default: `495`)   |                               Integer greater than or equal to `290`                               |
 
 ### 🖌 Themes
 
diff --git a/src/card.php b/src/card.php
index df469cb..9da33ca 100644
--- a/src/card.php
+++ b/src/card.php
@@ -218,7 +218,7 @@ function splitLines(string $text, int $maxChars, int $line1Offset): string
     $text = htmlspecialchars($text);
     return preg_replace(
         "/^(.*)\n(.*)/",
-        "<tspan x='81.5' dy='{$line1Offset}'>$1</tspan><tspan x='81.5' dy='16'>$2</tspan>",
+        "<tspan x='0' dy='{$line1Offset}'>$1</tspan><tspan x='0' dy='16'>$2</tspan>",
         $text
     );
 }
@@ -277,6 +277,15 @@ function getTranslations(string $localeCode): array
     return $localeTranslations;
 }
 
+/**
+ * Get the card width from params taking into account minimum and default values
+ */
+function getCardWidth(array $params): int
+{
+    // minimum width = 290, default width = 495
+    return max(290, intval($params["card_width"] ?? 495));
+}
+
 /**
  * Generate SVG output for a stats array
  *
@@ -308,7 +317,16 @@ function generateCard(array $stats, array $params = null): string
     $numFormatter = new NumberFormatter($localeCode, NumberFormatter::DECIMAL);
 
     // read border_radius parameter, default to 4.5 if not set
-    $borderRadius = $params["border_radius"] ?? "4.5";
+    $borderRadius = $params["border_radius"] ?? 4.5;
+
+    // read card_width parameter
+    $cardWidth = getCardWidth($params);
+    $rectWidth = $cardWidth - 1;
+    $firstBarOffset = $cardWidth / 3;
+    $secondBarOffset = ($cardWidth * 2) / 3;
+    $firstColumnOffset = $cardWidth / 6;
+    $centerOffset = $cardWidth / 2;
+    $thirdColumnOffset = ($cardWidth * 5) / 6;
 
     // Set Background
     $backgroundParts = explode(",", $theme["background"] ?? "");
@@ -351,26 +369,28 @@ function generateCard(array $stats, array $params = null): string
         $longestStreakRange .= " - " . $longestStreakEnd;
     }
 
-    // if the translations contain a newline, split the text into two tspan elements
-    $totalContributionsText = splitLines($localeTranslations["Total Contributions"], 22, -9);
+    // if the translations contain over max characters or a newline, split the text into two tspan elements
+    $maxCharsPerLineLabels = intval(floor($cardWidth / 22));
+    $totalContributionsText = splitLines($localeTranslations["Total Contributions"], $maxCharsPerLineLabels, -9);
     if ($stats["mode"] === "weekly") {
-        $currentStreakText = splitLines($localeTranslations["Week Streak"], 22, -9);
-        $longestStreakText = splitLines($localeTranslations["Longest Week Streak"], 22, -9);
+        $currentStreakText = splitLines($localeTranslations["Week Streak"], $maxCharsPerLineLabels, -9);
+        $longestStreakText = splitLines($localeTranslations["Longest Week Streak"], $maxCharsPerLineLabels, -9);
     } else {
-        $currentStreakText = splitLines($localeTranslations["Current Streak"], 22, -9);
-        $longestStreakText = splitLines($localeTranslations["Longest Streak"], 22, -9);
+        $currentStreakText = splitLines($localeTranslations["Current Streak"], $maxCharsPerLineLabels, -9);
+        $longestStreakText = splitLines($localeTranslations["Longest Streak"], $maxCharsPerLineLabels, -9);
     }
 
-    // if the ranges contain over 28 characters, split the text into two tspan elements
-    $totalContributionsRange = splitLines($totalContributionsRange, 28, 0);
-    $currentStreakRange = splitLines($currentStreakRange, 28, 0);
-    $longestStreakRange = splitLines($longestStreakRange, 28, 0);
+    // if the ranges contain over max characters, split the text into two tspan elements
+    $maxCharsPerLineDates = intval(floor($cardWidth / 18));
+    $totalContributionsRange = splitLines($totalContributionsRange, $maxCharsPerLineDates, 0);
+    $currentStreakRange = splitLines($currentStreakRange, $maxCharsPerLineDates, 0);
+    $longestStreakRange = splitLines($longestStreakRange, $maxCharsPerLineDates, 0);
 
     // if days are excluded, add a note to the corner
     $excludedDays = "";
     if (!empty($stats["excludedDays"])) {
         $daysCommaSeparated = implode(", ", translateDays($stats["excludedDays"], $localeCode));
-        $offset = $direction === "rtl" ? 495 - 5 : 5;
+        $offset = $direction === "rtl" ? $cardWidth - 5 : 5;
         $excludedDays = "<g style='isolation: isolate'>
                 <!-- Excluded Days -->
                 <g transform='translate({$offset},187)'>
@@ -382,7 +402,7 @@ function generateCard(array $stats, array $params = null): string
     }
 
     return "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'
-                style='isolation: isolate' viewBox='0 0 495 195' width='495px' height='195px' direction='{$direction}'>
+                style='isolation: isolate' viewBox='0 0 {$cardWidth} 195' width='{$cardWidth}px' height='195px' direction='{$direction}'>
         <style>
             @keyframes currstreak {
                 0% { font-size: 3px; opacity: 0.2; }
@@ -397,94 +417,94 @@ function generateCard(array $stats, array $params = null): string
         {$gradient}
         <defs>
             <clipPath id='outer_rectangle'>
-                <rect width='495' height='195' rx='{$borderRadius}'/>
+                <rect width='{$cardWidth}' height='195' rx='{$borderRadius}'/>
             </clipPath>
             <mask id='mask_out_ring_behind_fire'>
-                <rect width='495' height='195' fill='white'/>
-                <ellipse id='mask-ellipse' cx='247.5' cy='32' rx='13' ry='18' fill='black'/>
+                <rect width='{$cardWidth}' height='195' fill='white'/>
+                <ellipse id='mask-ellipse' cx='{$centerOffset}' cy='32' rx='13' ry='18' fill='black'/>
             </mask>
         </defs>
         <g clip-path='url(#outer_rectangle)'>
             <g style='isolation: isolate'>
-                <rect stroke='{$theme["border"]}' fill='{$background}' rx='{$borderRadius}' x='0.5' y='0.5' width='494' height='194'/>
+                <rect stroke='{$theme["border"]}' fill='{$background}' rx='{$borderRadius}' x='0.5' y='0.5' width='{$rectWidth}' height='194'/>
             </g>
             <g style='isolation: isolate'>
-                <line x1='330' y1='28' x2='330' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='{$theme["stroke"]}' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
-                <line x1='165' y1='28' x2='165' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='{$theme["stroke"]}' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
+                <line x1='{$secondBarOffset}' y1='28' x2='{$secondBarOffset}' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='{$theme["stroke"]}' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
+                <line x1='{$firstBarOffset}' y1='28' x2='{$firstBarOffset}' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='{$theme["stroke"]}' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
             </g>
             <g style='isolation: isolate'>
                 <!-- Total Contributions Big Number -->
-                <g transform='translate(1,48)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["sideNums"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.6s'>
+                <g transform='translate({$firstColumnOffset},48)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["sideNums"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.6s'>
                         {$totalContributions}
                     </text>
                 </g>
 
                 <!-- Total Contributions Label -->
-                <g transform='translate(1,84)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["sideLabels"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.7s'>
+                <g transform='translate({$firstColumnOffset},84)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["sideLabels"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.7s'>
                         {$totalContributionsText}
                     </text>
                 </g>
 
                 <!-- total contributions range -->
-                <g transform='translate(1,114)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["dates"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.8s'>
+                <g transform='translate({$firstColumnOffset},114)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["dates"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.8s'>
                         {$totalContributionsRange}
                     </text>
                 </g>
             </g>
             <g style='isolation: isolate'>
                 <!-- Current Streak Big Number -->
-                <g transform='translate(166,48)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["currStreakNum"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='animation: currstreak 0.6s linear forwards'>
+                <g transform='translate({$centerOffset},48)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["currStreakNum"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='animation: currstreak 0.6s linear forwards'>
                         {$currentStreak}
                     </text>
                 </g>
 
                 <!-- Current Streak Label -->
-                <g transform='translate(166,108)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["currStreakLabel"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='700' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.9s'>
+                <g transform='translate({$centerOffset},108)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["currStreakLabel"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='700' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.9s'>
                         {$currentStreakText}
                     </text>
                 </g>
 
                 <!-- Current Streak Range -->
-                <g transform='translate(166,145)'>
-                    <text x='81.5' y='21' stroke-width='0' text-anchor='middle' fill='{$theme["dates"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.9s'>
+                <g transform='translate({$centerOffset},145)'>
+                    <text x='0' y='21' stroke-width='0' text-anchor='middle' fill='{$theme["dates"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.9s'>
                         {$currentStreakRange}
                     </text>
                 </g>
 
                 <!-- ring around number -->
                 <g mask='url(#mask_out_ring_behind_fire)'>
-                    <circle cx='247.5' cy='71' r='40' fill='none' stroke='{$theme["ring"]}' stroke-width='5' style='opacity: 0; animation: fadein 0.5s linear forwards 0.4s'></circle>
+                    <circle cx='{$centerOffset}' cy='71' r='40' fill='none' stroke='{$theme["ring"]}' stroke-width='5' style='opacity: 0; animation: fadein 0.5s linear forwards 0.4s'></circle>
                 </g>
                 <!-- fire icon -->
-                <g stroke-opacity='0' style='opacity: 0; animation: fadein 0.5s linear forwards 0.6s'>
-                    <path d=' M 235.5 19.5 L 259.5 19.5 L 259.5 43.5 L 235.5 43.5 L 235.5 19.5 Z ' fill='none'/>
-                    <path d=' M 249 20.17 C 249 20.17 249.74 22.82 249.74 24.97 C 249.74 27.03 248.39 28.7 246.33 28.7 C 244.26 28.7 242.7 27.03 242.7 24.97 L 242.73 24.61 C 240.71 27.01 239.5 30.12 239.5 33.5 C 239.5 37.92 243.08 41.5 247.5 41.5 C 251.92 41.5 255.5 37.92 255.5 33.5 C 255.5 28.11 252.91 23.3 249 20.17 Z  M 247.21 38.5 C 245.43 38.5 243.99 37.1 243.99 35.36 C 243.99 33.74 245.04 32.6 246.8 32.24 C 248.57 31.88 250.4 31.03 251.42 29.66 C 251.81 30.95 252.01 32.31 252.01 33.7 C 252.01 36.35 249.86 38.5 247.21 38.5 Z ' fill='{$theme["fire"]}' stroke-opacity='0'/>
+                <g transform='translate({$centerOffset}, 19.5)' stroke-opacity='0' style='opacity: 0; animation: fadein 0.5s linear forwards 0.6s'>
+                    <path d='M -12 -0.5 L 15 -0.5 L 15 23.5 L -12 23.5 L -12 -0.5 Z' fill='none'/>
+                    <path d='M 1.5 0.67 C 1.5 0.67 2.24 3.32 2.24 5.47 C 2.24 7.53 0.89 9.2 -1.17 9.2 C -3.23 9.2 -4.79 7.53 -4.79 5.47 L -4.76 5.11 C -6.78 7.51 -8 10.62 -8 13.99 C -8 18.41 -4.42 22 0 22 C 4.42 22 8 18.41 8 13.99 C 8 8.6 5.41 3.79 1.5 0.67 Z M -0.29 19 C -2.07 19 -3.51 17.6 -3.51 15.86 C -3.51 14.24 -2.46 13.1 -0.7 12.74 C 1.07 12.38 2.9 11.53 3.92 10.16 C 4.31 11.45 4.51 12.81 4.51 14.2 C 4.51 16.85 2.36 19 -0.29 19 Z' fill='{$theme["fire"]}' stroke-opacity='0'/>
                 </g>
 
             </g>
             <g style='isolation: isolate'>
                 <!-- Longest Streak Big Number -->
-                <g transform='translate(331,48)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["sideNums"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.2s'>
+                <g transform='translate({$thirdColumnOffset},48)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["sideNums"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.2s'>
                         {$longestStreak}
                     </text>
                 </g>
 
                 <!-- Longest Streak Label -->
-                <g transform='translate(331,84)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["sideLabels"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.3s'>
+                <g transform='translate({$thirdColumnOffset},84)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["sideLabels"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.3s'>
                         {$longestStreakText}
                     </text>
                 </g>
 
                 <!-- Longest Streak Range -->
-                <g transform='translate(331,114)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["dates"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.4s'>
+                <g transform='translate({$thirdColumnOffset},114)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='{$theme["dates"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.4s'>
                         {$longestStreakRange}
                     </text>
                 </g>
@@ -510,9 +530,14 @@ function generateErrorCard(string $message, array $params = null): string
     $theme = getRequestedTheme($params);
 
     // read border_radius parameter, default to 4.5 if not set
-    $borderRadius = $params["border_radius"] ?? "4.5";
+    $borderRadius = $params["border_radius"] ?? 4.5;
+
+    // read card_width parameter
+    $cardWidth = getCardWidth($params);
+    $rectWidth = $cardWidth - 1;
+    $centerOffset = $cardWidth / 2;
 
-    return "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' style='isolation: isolate' viewBox='0 0 495 195' width='495px' height='195px'>
+    return "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' style='isolation: isolate' viewBox='0 0 {$cardWidth} 195' width='{$cardWidth}px' height='195px'>
         <style>
             a {
                 fill: {$theme["dates"]};
@@ -520,17 +545,17 @@ function generateErrorCard(string $message, array $params = null): string
         </style>
         <defs>
             <clipPath id='outer_rectangle'>
-                <rect width='495' height='195' rx='{$borderRadius}'/>
+                <rect width='{$cardWidth}' height='195' rx='{$borderRadius}'/>
             </clipPath>
         </defs>
         <g clip-path='url(#outer_rectangle)'>
             <g style='isolation: isolate'>
-                <rect stroke='{$theme["border"]}' fill='{$theme["background"]}' rx='{$borderRadius}' x='0.5' y='0.5' width='494' height='194'/>
+                <rect stroke='{$theme["border"]}' fill='{$theme["background"]}' rx='{$borderRadius}' x='0.5' y='0.5' width='{$rectWidth}' height='194'/>
             </g>
             <g style='isolation: isolate'>
                 <!-- Error Label -->
-                <g transform='translate(166,108)'>
-                    <text x='81.5' y='50' dy='0.25em' stroke-width='0' text-anchor='middle' fill='{$theme["sideLabels"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal'>
+                <g transform='translate({$centerOffset},108)'>
+                    <text x='0' y='50' dy='0.25em' stroke-width='0' text-anchor='middle' fill='{$theme["sideLabels"]}' stroke='none' font-family='\"Segoe UI\", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal'>
                         {$message}
                     </text>
                 </g>
@@ -539,15 +564,15 @@ function generateErrorCard(string $message, array $params = null): string
                 <defs>
                     <mask id='cut-off-area'>
                         <rect x='0' y='0' width='500' height='500' fill='white' />
-                        <ellipse cx='247.5' cy='31' rx='13' ry='18'/>
+                        <ellipse cx='{$centerOffset}' cy='31' rx='13' ry='18'/>
                     </mask>
                 </defs>
                 <!-- Sad face -->
-                <g>
-                    <path fill='{$theme["fire"]}' d='M248,35.8c-25.2,0-45.7,20.5-45.7,45.7s20.5,45.8,45.7,45.8s45.7-20.5,45.7-45.7S273.2,35.8,248,35.8z M248,122.3c-11.2,0-21.4-4.5-28.8-11.9c-2.9-2.9-5.4-6.3-7.4-10c-3-5.7-4.6-12.1-4.6-18.9c0-22.5,18.3-40.8,40.8-40.8 c10.7,0,20.4,4.1,27.7,10.9c3.8,3.5,6.9,7.7,9.1,12.4c2.6,5.3,4,11.3,4,17.6C288.8,104.1,270.5,122.3,248,122.3z'/>
-                    <path fill='{$theme["fire"]}' d='M252.8,93.8c5.4,1.1,10.3,4.2,13.7,8.6l3.9-3c-4.1-5.3-10-9-16.6-10.4c-10.6-2.2-21.7,1.9-28.3,10.4l3.9,3 C234.9,95.3,244.1,91.9,252.8,93.8z'/>
-                    <circle fill='{$theme["fire"]}' cx='232.8' cy='71.3' r='4.9'/>
-                    <circle fill='{$theme["fire"]}' cx='263.4' cy='71.3' r='4.9'/>
+                <g transform='translate({$centerOffset}, 0)'>
+                    <path fill='{$theme["fire"]}' d='M0,35.8c-25.2,0-45.7,20.5-45.7,45.7s20.5,45.8,45.7,45.8s45.7-20.5,45.7-45.7S25.2,35.8,0,35.8z M0,122.3c-11.2,0-21.4-4.5-28.8-11.9c-2.9-2.9-5.4-6.3-7.4-10c-3-5.7-4.6-12.1-4.6-18.9c0-22.5,18.3-40.8,40.8-40.8 c10.7,0,20.4,4.1,27.7,10.9c3.8,3.5,6.9,7.7,9.1,12.4c2.6,5.3,4,11.3,4,17.6C40.8,104.1,22.5,122.3,0,122.3z'/>
+                    <path fill='{$theme["fire"]}' d='M4.8,93.8c5.4,1.1,10.3,4.2,13.7,8.6l3.9-3c-4.1-5.3-10-9-16.6-10.4c-10.6-2.2-21.7,1.9-28.3,10.4l3.9,3 C-13.1,95.3-3.9,91.9,4.8,93.8z'/>
+                    <circle fill='{$theme["fire"]}' cx='-15' cy='71' r='4.9'/>
+                    <circle fill='{$theme["fire"]}' cx='15' cy='71' r='4.9'/>
                 </g>
             </g>
         </g>
@@ -637,9 +662,10 @@ function convertHexColors(string $svg): string
  * Converts an SVG card to a PNG image
  *
  * @param string $svg The SVG for the card as a string
+ * @param int $cardWidth The width of the card
  * @return string The generated PNG data
  */
-function convertSvgToPng(string $svg): string
+function convertSvgToPng(string $svg, int $cardWidth): string
 {
     // trim off all whitespaces to make it a valid SVG string
     $svg = trim($svg);
@@ -654,7 +680,7 @@ function convertSvgToPng(string $svg): string
     // `--export-filename -`: write output to stdout
     // `-w 495 -h 195`: set width and height of the output image
     // `--export-type png`: set the output format to PNG
-    $cmd = "echo {$svg} | inkscape --pipe --export-filename - -w 495 -h 195 --export-type png";
+    $cmd = "echo {$svg} | inkscape --pipe --export-filename - -w {$cardWidth} -h 195 --export-type png";
 
     // convert svg to png
     $png = shell_exec($cmd); // skipcq: PHP-A1009
@@ -702,7 +728,8 @@ function generateOutput(string|array $output, array $params = null): array
     // output PNG card
     if ($requestedType === "png") {
         try {
-            $png = convertSvgToPng($svg);
+            $cardWidth = getCardWidth($params);
+            $png = convertSvgToPng($svg, $cardWidth);
             return [
                 "contentType" => "image/png",
                 "body" => $png,
diff --git a/src/demo/index.php b/src/demo/index.php
index 5644282..557a1c1 100644
--- a/src/demo/index.php
+++ b/src/demo/index.php
@@ -163,6 +163,9 @@ function camelToSkewer(string $str): string
                     <option value="json">JSON</option>
                 </select>
 
+                <label for="card_width">Card Width</label>
+                <input class="param" type="number" id="card-width" name="card_width" placeholder="495" value="495" step="1" min="290">
+
                 <details class="advanced">
                     <summary>⚙ Advanced Options</summary>
                     <div class="content">
diff --git a/src/demo/js/script.js b/src/demo/js/script.js
index d27e591..33b9a4a 100644
--- a/src/demo/js/script.js
+++ b/src/demo/js/script.js
@@ -14,6 +14,7 @@ const preview = {
     mode: "daily",
     type: "svg",
     exclude_days: "",
+    card_width: "495",
   },
 
   /**


Test patch:
diff --git a/tests/RenderTest.php b/tests/RenderTest.php
index b6ce463..df94800 100644
--- a/tests/RenderTest.php
+++ b/tests/RenderTest.php
@@ -119,19 +119,19 @@ final class RenderTest extends TestCase
         $this->assertEquals("Total Contributions", splitLines("Total Contributions", 24, -9));
         // Check label that is too long, split
         $this->assertEquals(
-            "<tspan x='81.5' dy='-9'>Chuỗi đóng góp hiện</tspan><tspan x='81.5' dy='16'>tại</tspan>",
+            "<tspan x='0' dy='-9'>Chuỗi đóng góp hiện</tspan><tspan x='0' dy='16'>tại</tspan>",
             splitLines("Chuỗi đóng góp hiện tại", 22, -9)
         );
         // Check label with manually inserted line break, split
         $this->assertEquals(
-            "<tspan x='81.5' dy='-9'>Chuỗi đóng góp</tspan><tspan x='81.5' dy='16'>hiện tại</tspan>",
+            "<tspan x='0' dy='-9'>Chuỗi đóng góp</tspan><tspan x='0' dy='16'>hiện tại</tspan>",
             splitLines("Chuỗi đóng góp\nhiện tại", 22, -9)
         );
         // Check date range label, no split
         $this->assertEquals("Mar 28, 2019 – Apr 12, 2019", splitLines("Mar 28, 2019 – Apr 12, 2019", 28, 0));
         // Check date range label that is too long, split
         $this->assertEquals(
-            "<tspan x='81.5' dy='0'>19 de dez. de 2021</tspan><tspan x='81.5' dy='16'>- 14 de mar.</tspan>",
+            "<tspan x='0' dy='0'>19 de dez. de 2021</tspan><tspan x='0' dy='16'>- 14 de mar.</tspan>",
             splitLines("19 de dez. de 2021 - 14 de mar.", 24, 0)
         );
     }
@@ -215,4 +215,20 @@ final class RenderTest extends TestCase
         $render = generateOutput($this->testStats, $this->testParams)["body"];
         $this->assertStringContainsString("* Excluding Sun, Sat", $render);
     }
+
+    /**
+     * Test card width option
+     */
+    public function testCardWidth(): void
+    {
+        $this->testParams["card_width"] = "600";
+        $render = generateOutput($this->testStats, $this->testParams)["body"];
+        $this->assertStringContainsString("viewBox='0 0 600 195' width='600px' height='195px'", $render);
+        $this->assertStringContainsString("<rect width='600' height='195' rx='4.5'/>", $render);
+        $this->assertStringContainsString("<line x1='400' y1='28'", $render);
+        $this->assertStringContainsString("<line x1='200' y1='28'", $render);
+        $this->assertStringContainsString("<g transform='translate(100,", $render);
+        $this->assertStringContainsString("<g transform='translate(300,", $render);
+        $this->assertStringContainsString("<g transform='translate(500,", $render);
+    }
 }
diff --git a/tests/expected/test_card.svg b/tests/expected/test_card.svg
index 1e0904d..39950e1 100644
--- a/tests/expected/test_card.svg
+++ b/tests/expected/test_card.svg
@@ -31,44 +31,44 @@
             </g>
             <g style='isolation: isolate'>
                 <!-- Total Contributions Big Number -->
-                <g transform='translate(1,48)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='#666666' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.6s'>
+                <g transform='translate(82.5,48)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#666666' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.6s'>
                         2,048
                     </text>
                 </g>
 
                 <!-- Total Contributions Label -->
-                <g transform='translate(1,84)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='#888888' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.7s'>
+                <g transform='translate(82.5,84)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#888888' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.7s'>
                         Total Contributions
                     </text>
                 </g>
 
                 <!-- total contributions range -->
-                <g transform='translate(1,114)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='#999999' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.8s'>
+                <g transform='translate(82.5,114)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#999999' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.8s'>
                         Aug 10, 2016 - Present
                     </text>
                 </g>
             </g>
             <g style='isolation: isolate'>
                 <!-- Current Streak Big Number -->
-                <g transform='translate(166,48)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='#555555' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='animation: currstreak 0.6s linear forwards'>
+                <g transform='translate(247.5,48)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#555555' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='animation: currstreak 0.6s linear forwards'>
                         16
                     </text>
                 </g>
 
                 <!-- Current Streak Label -->
-                <g transform='translate(166,108)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='#777777' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.9s'>
+                <g transform='translate(247.5,108)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#777777' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.9s'>
                         Current Streak
                     </text>
                 </g>
 
                 <!-- Current Streak Range -->
-                <g transform='translate(166,145)'>
-                    <text x='81.5' y='21' stroke-width='0' text-anchor='middle' fill='#999999' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.9s'>
+                <g transform='translate(247.5,145)'>
+                    <text x='0' y='21' stroke-width='0' text-anchor='middle' fill='#999999' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 0.9s'>
                         Mar 28, 2019 - Apr 12, 2019
                     </text>
                 </g>
@@ -78,30 +78,30 @@
                     <circle cx='247.5' cy='71' r='40' fill='none' stroke='#333333' stroke-width='5' style='opacity: 0; animation: fadein 0.5s linear forwards 0.4s'></circle>
                 </g>
                 <!-- fire icon -->
-                <g stroke-opacity='0' style='opacity: 0; animation: fadein 0.5s linear forwards 0.6s'>
-                    <path d=' M 235.5 19.5 L 259.5 19.5 L 259.5 43.5 L 235.5 43.5 L 235.5 19.5 Z ' fill='none'/>
-                    <path d=' M 249 20.17 C 249 20.17 249.74 22.82 249.74 24.97 C 249.74 27.03 248.39 28.7 246.33 28.7 C 244.26 28.7 242.7 27.03 242.7 24.97 L 242.73 24.61 C 240.71 27.01 239.5 30.12 239.5 33.5 C 239.5 37.92 243.08 41.5 247.5 41.5 C 251.92 41.5 255.5 37.92 255.5 33.5 C 255.5 28.11 252.91 23.3 249 20.17 Z  M 247.21 38.5 C 245.43 38.5 243.99 37.1 243.99 35.36 C 243.99 33.74 245.04 32.6 246.8 32.24 C 248.57 31.88 250.4 31.03 251.42 29.66 C 251.81 30.95 252.01 32.31 252.01 33.7 C 252.01 36.35 249.86 38.5 247.21 38.5 Z ' fill='#444444' stroke-opacity='0'/>
+                <g transform='translate(247.5, 19.5)' stroke-opacity='0' style='opacity: 0; animation: fadein 0.5s linear forwards 0.6s'>
+                    <path d='M -12 -0.5 L 15 -0.5 L 15 23.5 L -12 23.5 L -12 -0.5 Z' fill='none'/>
+                    <path d='M 1.5 0.67 C 1.5 0.67 2.24 3.32 2.24 5.47 C 2.24 7.53 0.89 9.2 -1.17 9.2 C -3.23 9.2 -4.79 7.53 -4.79 5.47 L -4.76 5.11 C -6.78 7.51 -8 10.62 -8 13.99 C -8 18.41 -4.42 22 0 22 C 4.42 22 8 18.41 8 13.99 C 8 8.6 5.41 3.79 1.5 0.67 Z M -0.29 19 C -2.07 19 -3.51 17.6 -3.51 15.86 C -3.51 14.24 -2.46 13.1 -0.7 12.74 C 1.07 12.38 2.9 11.53 3.92 10.16 C 4.31 11.45 4.51 12.81 4.51 14.2 C 4.51 16.85 2.36 19 -0.29 19 Z' fill='#444444' stroke-opacity='0'/>
                 </g>
 
             </g>
             <g style='isolation: isolate'>
                 <!-- Longest Streak Big Number -->
-                <g transform='translate(331,48)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='#666666' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.2s'>
+                <g transform='translate(412.5,48)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#666666' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.2s'>
                         86
                     </text>
                 </g>
 
                 <!-- Longest Streak Label -->
-                <g transform='translate(331,84)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='#888888' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.3s'>
+                <g transform='translate(412.5,84)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#888888' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.3s'>
                         Longest Streak
                     </text>
                 </g>
 
                 <!-- Longest Streak Range -->
-                <g transform='translate(331,114)'>
-                    <text x='81.5' y='32' stroke-width='0' text-anchor='middle' fill='#999999' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.4s'>
+                <g transform='translate(412.5,114)'>
+                    <text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#999999' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 0; animation: fadein 0.5s linear forwards 1.4s'>
                         Dec 19, 2016 - Mar 14, 2016
                     </text>
                 </g>
diff --git a/tests/expected/test_error_card.svg b/tests/expected/test_error_card.svg
index 0f5459a..0793568 100644
--- a/tests/expected/test_error_card.svg
+++ b/tests/expected/test_error_card.svg
@@ -15,8 +15,8 @@
             </g>
             <g style='isolation: isolate'>
                 <!-- Error Label -->
-                <g transform='translate(166,108)'>
-                    <text x='81.5' y='50' dy='0.25em' stroke-width='0' text-anchor='middle' fill='#888888' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal'>
+                <g transform='translate(247.5,108)'>
+                    <text x='0' y='50' dy='0.25em' stroke-width='0' text-anchor='middle' fill='#888888' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal'>
                         An unknown error occurred
                     </text>
                 </g>
@@ -29,11 +29,11 @@
                     </mask>
                 </defs>
                 <!-- Sad face -->
-                <g>
-                    <path fill='#444444' d='M248,35.8c-25.2,0-45.7,20.5-45.7,45.7s20.5,45.8,45.7,45.8s45.7-20.5,45.7-45.7S273.2,35.8,248,35.8z M248,122.3c-11.2,0-21.4-4.5-28.8-11.9c-2.9-2.9-5.4-6.3-7.4-10c-3-5.7-4.6-12.1-4.6-18.9c0-22.5,18.3-40.8,40.8-40.8 c10.7,0,20.4,4.1,27.7,10.9c3.8,3.5,6.9,7.7,9.1,12.4c2.6,5.3,4,11.3,4,17.6C288.8,104.1,270.5,122.3,248,122.3z'/>
-                    <path fill='#444444' d='M252.8,93.8c5.4,1.1,10.3,4.2,13.7,8.6l3.9-3c-4.1-5.3-10-9-16.6-10.4c-10.6-2.2-21.7,1.9-28.3,10.4l3.9,3 C234.9,95.3,244.1,91.9,252.8,93.8z'/>
-                    <circle fill='#444444' cx='232.8' cy='71.3' r='4.9'/>
-                    <circle fill='#444444' cx='263.4' cy='71.3' r='4.9'/>
+                <g transform='translate(247.5, 0)'>
+                    <path fill='#444444' d='M0,35.8c-25.2,0-45.7,20.5-45.7,45.7s20.5,45.8,45.7,45.8s45.7-20.5,45.7-45.7S25.2,35.8,0,35.8z M0,122.3c-11.2,0-21.4-4.5-28.8-11.9c-2.9-2.9-5.4-6.3-7.4-10c-3-5.7-4.6-12.1-4.6-18.9c0-22.5,18.3-40.8,40.8-40.8 c10.7,0,20.4,4.1,27.7,10.9c3.8,3.5,6.9,7.7,9.1,12.4c2.6,5.3,4,11.3,4,17.6C40.8,104.1,22.5,122.3,0,122.3z'/>
+                    <path fill='#444444' d='M4.8,93.8c5.4,1.1,10.3,4.2,13.7,8.6l3.9-3c-4.1-5.3-10-9-16.6-10.4c-10.6-2.2-21.7,1.9-28.3,10.4l3.9,3 C-13.1,95.3-3.9,91.9,4.8,93.8z'/>
+                    <circle fill='#444444' cx='-15' cy='71' r='4.9'/>
+                    <circle fill='#444444' cx='15' cy='71' r='4.9'/>
                 </g>
             </g>
         </g>