Repository: DenverCoder1/readme-typing-svg (php)

Issue:
Letter Spacing
Some font families have letters that are very close together. That's why it would be nice if you could add the *letter spacing* function.

Pull request:
feat: Added Letter Spacing option
## Summary

Added Letter Spacing option

resolves #251 

## Type of change

- [x] New feature (added a non-breaking change which adds functionality)

## How Has This Been Tested?

- [x] Ran tests with `composer test`
- [x] Added or updated test cases to test new features


Patch:
diff --git a/.gitignore b/.gitignore
index def28a3..5813be0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
 vendor/
 .vscode/
-.env
\ No newline at end of file
+.env
+.idea
\ No newline at end of file
diff --git a/README.md b/README.md
index 1d553a7..5e38ed7 100644
--- a/README.md
+++ b/README.md
@@ -120,22 +120,23 @@ Feel free to [open a PR](https://github.com/DenverCoder1/readme-typing-svg/issue
 
 ## 🔧 Options
 
-|  Parameter   |                                   Details                                   |  Type   |               Example               |
-| :----------: | :-------------------------------------------------------------------------: | :-----: | :---------------------------------: |
-|   `lines`    |       Text to display with lines separated by `;` and `+` for spaces        | string  | `First+line;Second+line;Third+line` |
-|   `height`   |             Height of the output SVG in pixels (default: `50`)              | integer |         Any positive number         |
-|   `width`    |             Width of the output SVG in pixels (default: `400`)              | integer |         Any positive number         |
-|    `size`    |                     Font size in pixels (default: `20`)                     | integer |         Any positive number         |
-|    `font`    |                     Font family (default: `monospace`)                      | string  |     Any font from Google Fonts      |
-|   `color`    |                    Color of the text (default: `36BCF7`)                    | string  |  Hex code without # (eg. `F724A9`)  |
-| `background` |             Background color of the text (default: `00000000`)              | string  |  Hex code without # (eg. `FEFF4C`)  |
-|   `center`   |    `true` to center text or `false` for left aligned (default: `false`)     | boolean |          `true` or `false`          |
-|  `vCenter`   |  `true` to center vertically or `false`(default) to align above the center  | boolean |          `true` or `false`          |
-| `multiline`  |  `true` to wrap lines or `false` to retype on one line (default: `false`)   | boolean |          `true` or `false`          |
-|  `duration`  | Duration of the printing of a single line in milliseconds (default: `5000`) | integer |         Any positive number         |
-|   `pause`    |     Duration of the pause between lines in milliseconds (default: `0`)      | integer |       Any non-negative number       |
-|   `repeat`   |  `true` to loop around to the first line after the last (default: `true`)   | boolean |          `true` or `false`          |
-| `separator`  |     Separator used between lines in the lines parameter (default: `;`)      | string  |        `;`, `;;`, `/`, etc.         |
+|    Parameter    |                                   Details                                   |  Type   |                                                      Example                                                      |
+| :-------------: | :-------------------------------------------------------------------------: | :-----: | :---------------------------------------------------------------------------------------------------------------: |
+|     `lines`     |       Text to display with lines separated by `;` and `+` for spaces        | string  |                                        `First+line;Second+line;Third+line`                                        |
+|    `height`     |             Height of the output SVG in pixels (default: `50`)              | integer |                                                Any positive number                                                |
+|     `width`     |             Width of the output SVG in pixels (default: `400`)              | integer |                                                Any positive number                                                |
+|     `size`      |                     Font size in pixels (default: `20`)                     | integer |                                                Any positive number                                                |
+|     `font`      |                     Font family (default: `monospace`)                      | string  |                                            Any font from Google Fonts                                             |
+|     `color`     |                    Color of the text (default: `36BCF7`)                    | string  |                                         Hex code without # (eg. `F724A9`)                                         |
+|  `background`   |             Background color of the text (default: `00000000`)              | string  |                                         Hex code without # (eg. `FEFF4C`)                                         |
+|    `center`     |    `true` to center text or `false` for left aligned (default: `false`)     | boolean |                                                 `true` or `false`                                                 |
+|    `vCenter`    |  `true` to center vertically or `false`(default) to align above the center  | boolean |                                                 `true` or `false`                                                 |
+|   `multiline`   |  `true` to wrap lines or `false` to retype on one line (default: `false`)   | boolean |                                                 `true` or `false`                                                 |
+|   `duration`    | Duration of the printing of a single line in milliseconds (default: `5000`) | integer |                                                Any positive number                                                |
+|     `pause`     |     Duration of the pause between lines in milliseconds (default: `0`)      | integer |                                              Any non-negative number                                              |
+|    `repeat`     |  `true` to loop around to the first line after the last (default: `true`)   | boolean |                                                 `true` or `false`                                                 |
+|   `separator`   |     Separator used between lines in the lines parameter (default: `;`)      | string  |                                               `;`, `;;`, `/`, etc.                                                |
+| `letterSpacing` |                     Letter spacing (default: `normal`)                      | string  | Any css values for the [letter-spacing](https://developer.mozilla.org/en-US/docs/Web/CSS/letter-spacing) property |
 
 ## 📤 Deploying it on your own
 
diff --git a/src/demo/index.php b/src/demo/index.php
index 5c9184c..657e03e 100644
--- a/src/demo/index.php
+++ b/src/demo/index.php
@@ -70,6 +70,17 @@
                 <label for="size">Font size</label>
                 <input class="param" type="number" id="size" name="size" alt="Font size" placeholder="20" value="20">
 
+                <div class="label-group">
+                    <label for="letterSpacing">Letter spacing</label>
+                    <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/letter-spacing" target="_blank" class="icon tooltip" title="Enter any css value for the letter-spacing property">
+                        <svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg">
+                            <path d="M12 6C9.831 6 8.066 7.765 8.066 9.934h2C10.066 8.867 10.934 8 12 8s1.934.867 1.934 1.934c0 .598-.481 1.032-1.216 1.626-.255.207-.496.404-.691.599C11.029 13.156 11 14.215 11 14.333V15h2l-.001-.633c.001-.016.033-.386.441-.793.15-.15.339-.3.535-.458.779-.631 1.958-1.584 1.958-3.182C15.934 7.765 14.169 6 12 6zM11 16H13V18H11z"></path>
+                            <path d="M12,2C6.486,2,2,6.486,2,12s4.486,10,10,10s10-4.486,10-10S17.514,2,12,2z M12,20c-4.411,0-8-3.589-8-8s3.589-8,8-8 s8,3.589,8,8S16.411,20,12,20z"></path>
+                        </svg>
+                    </a>
+                </div>
+                <input class="param" type="text" id="letterSpacing" name="letterSpacing" alt="Letter spacing" placeholder="normal" value="normal">
+
                 <label for="duration">Duration (ms per line)</label>
                 <input class="param" type="number" id="duration" name="duration" alt="Print duration (ms)" placeholder="5000" value="5000">
 
diff --git a/src/demo/js/script.js b/src/demo/js/script.js
index d067a19..533c525 100644
--- a/src/demo/js/script.js
+++ b/src/demo/js/script.js
@@ -6,6 +6,7 @@ let preview = {
     color: "36BCF7",
     background: "00000000",
     size: "20",
+    letterSpacing: "normal",
     center: "false",
     vCenter: "false",
     multiline: "false",
diff --git a/src/models/RendererModel.php b/src/models/RendererModel.php
index 64e08d0..a8d47e3 100644
--- a/src/models/RendererModel.php
+++ b/src/models/RendererModel.php
@@ -58,6 +58,9 @@ class RendererModel
     /** @var string $fontCSS CSS required for displaying the selected font */
     public $fontCSS;
 
+    /** @var string $letterSpacing Letter spacing */
+    public $letterSpacing;
+
     /** @var string $template Path to template file */
     public $template;
 
@@ -78,6 +81,7 @@ class RendererModel
         "repeat" => "true",
         "separator" => ";",
         "random" => "false",
+        "letterSpacing" => "normal",
     ];
 
     /**
@@ -106,6 +110,7 @@ class RendererModel
         $this->pause = $this->checkNumberNonNegative($params["pause"] ?? $this->DEFAULTS["pause"], "pause");
         $this->repeat = $this->checkBoolean($params["repeat"] ?? $this->DEFAULTS["repeat"]);
         $this->fontCSS = $this->fetchFontCSS($this->font, $this->weight, $params["lines"]);
+        $this->letterSpacing = $this->checkLetterSpacing($params["letterSpacing"] ?? $this->DEFAULTS["letterSpacing"]);
     }
 
     /**
@@ -224,4 +229,43 @@ class RendererModel
         // font is not found
         return "";
     }
+
+    /**
+     * Validate unit for size properties
+     *
+     * This method validates if the given unit is a valid CSS size unit.
+     * It supports various units such as px, em, rem, pt, pc, in, cm, mm,
+     * ex, ch, vh, vw, vmin, vmax, and percentages.
+     *
+     * @param string $unit Unit for validation
+     * @return bool True if valid, false otherwise
+     */
+    private function isValidUnit($unit)
+    {
+        return (bool) preg_match("/^(-?\\d+(\\.\\d+)?(px|em|rem|pt|pc|in|cm|mm|ex|ch|vh|vw|vmin|vmax|%))$/", $unit);
+    }
+
+    /**
+     * Validate letter spacing
+     *
+     * This method validates the letter spacing property for fonts.
+     * It allows specific keywords (normal, inherit, initial, revert, revert-layer, unset)
+     * and valid CSS size units.
+     *
+     * @param string $letterSpacing Letter spacing for validation
+     * @return string Validated letter spacing
+     */
+    private function checkLetterSpacing($letterSpacing)
+    {
+        // List of valid keywords for letter-spacing
+        $keywords = "normal|inherit|initial|revert|revert-layer|unset";
+
+        // Check if the input matches one of the keywords or a valid unit
+        if (preg_match("/^($keywords)$/", $letterSpacing) || $this->isValidUnit($letterSpacing)) {
+            return $letterSpacing;
+        }
+
+        // Return the default letter spacing value if the input is invalid
+        return $this->DEFAULTS["letterSpacing"];
+    }
 }
diff --git a/src/templates/main.php b/src/templates/main.php
index 7664f89..3d42333 100644
--- a/src/templates/main.php
+++ b/src/templates/main.php
@@ -57,7 +57,8 @@
         </path>
     <text font-family='"<?= $font ?>", monospace' fill='<?= $color ?>' font-size='<?= $size ?>'
         dominant-baseline='<?= $vCenter ? "middle" : "auto" ?>'
-        x='<?= $center ? "50%" : "0%" ?>' text-anchor='<?= $center ? "middle" : "start" ?>'>
+        x='<?= $center ? "50%" : "0%" ?>' text-anchor='<?= $center ? "middle" : "start" ?>'
+        letter-spacing='<?= $letterSpacing ?>'>
         <textPath xlink:href='#path<?= $i ?>'>
             <?= $lines[$i] . "\n" ?>
         </textPath>
diff --git a/src/views/RendererView.php b/src/views/RendererView.php
index 7c54000..934ea40 100644
--- a/src/views/RendererView.php
+++ b/src/views/RendererView.php
@@ -41,6 +41,7 @@ class RendererView
             "duration" => $this->model->duration,
             "pause" => $this->model->pause,
             "repeat" => $this->model->repeat,
+            "letterSpacing" => $this->model->letterSpacing,
         ]);
         // render SVG with output buffering
         ob_start();


Test patch:
diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php
index 48ff687..6b8411c 100644
--- a/tests/OptionsTest.php
+++ b/tests/OptionsTest.php
@@ -367,4 +367,33 @@ final class OptionsTest extends TestCase
         $model = new RendererModel("src/templates/main.php", $params);
         $this->assertEquals(false, $model->random);
     }
+
+    /**
+     * Test Letter Spacing
+     */
+    public function testLetterSpacing(): void
+    {
+        // default
+        $params = [
+            "lines" => "text",
+        ];
+        $model = new RendererModel("src/templates/main.php", $params);
+        $this->assertEquals("normal", $model->letterSpacing);
+
+        // invalid
+        $params = [
+            "lines" => "text",
+            "letterSpacing" => "invalid",
+        ];
+        $model = new RendererModel("src/templates/main.php", $params);
+        $this->assertEquals("normal", $model->letterSpacing);
+
+        // valid
+        $params = [
+            "lines" => "text",
+            "letterSpacing" => "10px",
+        ];
+        $model = new RendererModel("src/templates/main.php", $params);
+        $this->assertEquals("10px", $model->letterSpacing);
+    }
 }
diff --git a/tests/RendererTest.php b/tests/RendererTest.php
index 6093aaf..0e08a1c 100644
--- a/tests/RendererTest.php
+++ b/tests/RendererTest.php
@@ -326,4 +326,24 @@ final class RendererTest extends TestCase
             $this->assertStringContainsString("> $line </textPath>", $actualSVG);
         }
     }
+
+    /**
+     * Test Letter Spacing
+     */
+    public function testLetterSpacing()
+    {
+        $params = [
+            "lines" => implode(";", [
+                "Full-stack web and app developer",
+                "Self-taught UI/UX Designer",
+                "10+ years of coding experience",
+                "Always learning new things",
+            ]),
+            "letterSpacing" => "10px",
+        ];
+        $controller = new RendererController($params);
+        $actualSVG = preg_replace("/\s+/", " ", $controller->render());
+        $this->assertStringContainsString("letter-spacing='10px'", $actualSVG);
+        $this->assertStringNotContainsString("letter-spacing='normal'", $actualSVG);
+    }
 }
diff --git a/tests/svg/test_normal.svg b/tests/svg/test_normal.svg
index 7c7b70f..efcced2 100644
--- a/tests/svg/test_normal.svg
+++ b/tests/svg/test_normal.svg
@@ -13,7 +13,8 @@
     </path>
     <text font-family='"monospace", monospace' fill='#36BCF7' font-size='20'
         dominant-baseline='middle'
-        x='50%' text-anchor='middle'>
+        x='50%' text-anchor='middle'
+        letter-spacing='normal'>
         <textPath xlink:href='#path0'>
             Full-stack web and app developer
         </textPath>
@@ -27,7 +28,8 @@
     </path>
     <text font-family='"monospace", monospace' fill='#36BCF7' font-size='20'
         dominant-baseline='middle'
-        x='50%' text-anchor='middle'>
+        x='50%' text-anchor='middle'
+        letter-spacing='normal'>
         <textPath xlink:href='#path1'>
             Self-taught UI/UX Designer
         </textPath>
@@ -41,7 +43,8 @@
     </path>
     <text font-family='"monospace", monospace' fill='#36BCF7' font-size='20'
         dominant-baseline='middle'
-        x='50%' text-anchor='middle'>
+        x='50%' text-anchor='middle'
+        letter-spacing='normal'>
         <textPath xlink:href='#path2'>
             10+ years of coding experience
         </textPath>
@@ -55,7 +58,8 @@
     </path>
     <text font-family='"monospace", monospace' fill='#36BCF7' font-size='20'
         dominant-baseline='middle'
-        x='50%' text-anchor='middle'>
+        x='50%' text-anchor='middle'
+        letter-spacing='normal'>
         <textPath xlink:href='#path3'>
             Always learning new things
         </textPath>