├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── phpunit-ci-coverage.yml │ └── release.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── composer.json ├── composer.lock ├── src ├── controllers │ └── RendererController.php ├── demo │ ├── css │ │ ├── loader.css │ │ ├── style.css │ │ └── toggle-dark.css │ ├── favicon.png │ ├── index.php │ └── js │ │ ├── jscolor.min.js │ │ ├── script.js │ │ └── toggle-dark.js ├── index.php ├── models │ ├── DatabaseConnection.php │ ├── ErrorModel.php │ ├── GoogleFontConverter.php │ └── RendererModel.php ├── templates │ ├── error.php │ └── main.php └── views │ ├── ErrorView.php │ └── RendererView.php └── tests ├── OptionsTest.php ├── RendererTest.php ├── phpunit └── phpunit.xml └── svg ├── test_fonts.svg ├── test_missing_lines.svg ├── test_multiline.svg ├── test_normal.svg └── test_normal_vertical_alignment.svg /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [DenverCoder1] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: jlawrence 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: I have a question about this project 4 | title: '' 5 | labels: 'question' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | **Description** 12 | 13 | A brief description of the question or issue: 14 | 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "composer" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | Fixes # 6 | 7 | ### Type of change 8 | 9 | 10 | 11 | - [ ] Bug fix (added a non-breaking change which fixes an issue) 12 | - [ ] New feature (added a non-breaking change which adds functionality) 13 | - [ ] Updated documentation (updated the readme, templates, or other repo files) 14 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 15 | 16 | ## How Has This Been Tested? 17 | 18 | 19 | 20 | - [ ] Ran tests with `composer test` 21 | - [ ] Added or updated test cases to test new features 22 | 23 | ## Checklist: 24 | 25 | - [ ] I have checked to make sure no other pull requests are open for this issue 26 | - [ ] The code is properly formatted and is consistent with the existing code style 27 | - [ ] I have commented my code, particularly in hard-to-understand areas 28 | - [ ] I have made corresponding changes to the documentation 29 | - [ ] My changes generate no new warnings 30 | 31 | ## Screenshots 32 | 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/phpunit-ci-coverage.yml: -------------------------------------------------------------------------------- 1 | name: PHPUnit CI 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | build-test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: php-actions/composer@v5 16 | - name: PHPUnit Tests 17 | uses: php-actions/phpunit@v2 18 | with: 19 | bootstrap: vendor/autoload.php 20 | configuration: tests/phpunit/phpunit.xml 21 | args: --testdox 22 | env: 23 | TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | USERNAME: DenverCoder1 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Releases 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | changelog: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: conventional Changelog Action 16 | id: changelog 17 | uses: TriPSs/conventional-changelog-action@v3.7.1 18 | with: 19 | github-token: ${{ secrets.CHANGELOG_RELEASE }} 20 | version-file: './composer.json' 21 | output-file: 'false' 22 | 23 | - name: create release 24 | uses: actions/create-release@v1 25 | if: ${{ steps.changelog.outputs.skipped == 'false' }} 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.CHANGELOG_RELEASE }} 28 | with: 29 | tag_name: ${{ steps.changelog.outputs.tag }} 30 | release_name: ${{ steps.changelog.outputs.tag }} 31 | body: ${{ steps.changelog.outputs.clean_changelog }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .vscode/ 3 | .env -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | jonah@freshidea.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Contributions are welcome! Feel free to open an issue or submit a pull request if you have a way to improve this project. 4 | 5 | Make sure your request is meaningful and you have tested the app locally before submitting a pull request. 6 | 7 | ### Installing Requirements 8 | 9 | #### Requirements 10 | 11 | * [PHP 7.4+](https://www.apachefriends.org/index.html) 12 | * [Composer](https://getcomposer.org) 13 | 14 | #### Linux 15 | 16 | ```bash 17 | sudo apt-get install php 18 | sudo apt-get install php-curl 19 | sudo apt-get install composer 20 | ``` 21 | 22 | #### Windows 23 | 24 | Install PHP from [XAMPP](https://www.apachefriends.org/index.html) or [php.net](https://windows.php.net/download) 25 | 26 | [▶ How to install and run PHP using XAMPP (Windows)](https://www.youtube.com/watch?v=K-qXW9ymeYQ) 27 | 28 | [📥 Download Composer](https://getcomposer.org/download/) 29 | 30 | ### Clone the repository 31 | 32 | ``` 33 | git clone https://github.com/DenverCoder1/readme-typing-svg.git 34 | cd readme-typing-svg 35 | ``` 36 | 37 | ### Running the app locally 38 | 39 | ```bash 40 | composer start 41 | ``` 42 | 43 | Open http://localhost:8000/ and add parameters to run the project locally. 44 | 45 | ### Running the tests 46 | 47 | Before you can run tests, PHPUnit must be installed. You can install it using Composer by running the following command. 48 | 49 | ```bash 50 | composer install 51 | ``` 52 | 53 | Run the following command to run the PHPUnit test script which will verify that the tested functionality is still working. 54 | 55 | ```bash 56 | composer test 57 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jonah Lawrence 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: vendor/bin/heroku-php-apache2 src/ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 |

⌨️ Readme Typing SVG

4 |

5 | 6 |

7 | 8 |

9 | 10 |

11 | 12 | 13 | 14 | 15 |

16 | 17 | 18 | ## ⚡ Quick setup 19 | 20 | 1. Copy-paste the markdown below into your GitHub profile README 21 | 2. Replace the value after `?lines=` with your text. Separate lines of text with semicolons and use `+` or `%20` for spaces. 22 | 3. (Optional) Adjust the width parameter (see below) to fit the full width of your text. 23 | 24 | ```md 25 | [![Typing SVG](https://readme-typing-svg.herokuapp.com/?lines=First+line+of+text;Second+line+of+text)](https://git.io/typing-svg) 26 | ``` 27 | 28 | ## ⚙ Demo site 29 | 30 | Here you can easily customize your Typing SVG with a live preview. 31 | 32 | 33 | 34 | [![Demo Site](https://user-images.githubusercontent.com/62628408/116336814-1bb85200-a7d1-11eb-8586-0ccf5bb97eae.gif "Demo Site")](https://readme-typing-svg.herokuapp.com/demo/) 35 | 36 | ## 🚀 Example usage 37 | 38 | Below are links to profiles where you can see Readme Typing SVGs in action! 39 | 40 | [![Jonah Lawrence](https://github.com/DenverCoder1.png?size=60)](https://github.com/DenverCoder1) 41 | [![Waren Gonzaga](https://github.com/warengonzaga.png?size=60)](https://github.com/warengonzaga) 42 | [![Eke Victor](https://github.com/Evavic44.png?size=60)](https://github.com/Evavic44) 43 | [![8BitJonny](https://github.com/8BitJonny.png?size=60)](https://github.com/8BitJonny) 44 | [![Krish](https://github.com/krishdevdb.png?size=60)](https://github.com/krishdevdb) 45 | [![Aditya Raute](https://github.com/adityaraute.png?size=60)](https://github.com/adityaraute) 46 | [![Shiva Sankeerth Reddy](https://github.com/ShivaSankeerth.png?size=60)](https://github.com/ShivaSankeerth) 47 | [![Tarun Kamboj](https://github.com/Tarun-Kamboj.png?size=60)](://github.com/Tarun-Kamboj) 48 | [![T.A.Vignesh](https://github.com/tavignesh.png?size=60)](https://github.com/tavignesh) 49 | [![Angelo Fallaria](https://github.com/angelofallars.png?size=60)](https://github.com/angelofallars) 50 | [![William J. Ghelfi](https://github.com/trumbitta.png?size=60)](https://github.com/trumbitta) 51 | [![Shivam Yadav](https://github.com/sudoshivam.png?size=60)](https://github.com/sudoshivam) 52 | [![Adam Ross](https://github.com/R055A.png?size=60)](https://github.com/R055A) 53 | [![Krishna Kumar](https://github.com/Krishnapro.png?size=60)](https://github.com/Krishnapro) 54 | [![Pratik Pingale](https://github.com/PROxZIMA.png?size=60)](https://github.com/PROxZIMA) 55 | [![Vydr'Oz](https://github.com/VydrOz.png?size=60)](https://github.com/VydrOz) 56 | [![Caroline Heloíse](https://github.com/Carol42.png?size=60)](https://github.com/Carol42) 57 | [![BenjaminMichaelis](https://github.com/BenjaminMichaelis.png?size=60)](https://github.com/BenjaminMichaelis) 58 | [![Thakur Ballary](https://github.com/thakurballary.png?size=60)](https://github.com/thakurballary) 59 | [![Ossama Mehmood](https://github.com/ossamamehmood.png?size=60)](https://github.com/ossamamehmood) 60 | [![Huynh Duong](https://github.com/betty2310.png?size=60)](https://github.com/betty2310) 61 | [![NiceSapien](https://github.com/nicesapien.png?size=60)](https://github.com/nicesapien) 62 | [![LMFAO-Jude](https://github.com/lmfao-jude.png?size=60)](https://github.com/lmfao-jude) 63 | [![Manthan Ank](https://github.com/manthanank.png?size=60)](https://github.com/manthanank) 64 | [![Ronny Coste](https://github.com/lertsoft.png?size=60)](https://github.com/lertsoft) 65 | [![Vishal Beep](https://github.com/vishal-beep136.png?size=60)](https://github.com/Vishal-beep136) 66 | [![Raihan Khan](https://github.com/raihankhan.png?size=60)](https://github.com/raihankhan) 67 | 68 | Feel free to [open a PR](https://github.com/DenverCoder1/readme-typing-svg/issues/21#issue-870549556) and add yours! 69 | 70 | ## 🔧 Options 71 | 72 | | Parameter | Details | Type | Example | 73 | | :----------: | :-------------------------------------------------------------------------: | :-----: | :---------------------------------: | 74 | | `lines` | Text to display with lines separated by `;` and `+` for spaces | string | `First+line;Second+line;Third+line` | 75 | | `height` | Height of the output SVG in pixels (default: `50`) | integer | Any positive number | 76 | | `width` | Width of the output SVG in pixels (default: `400`) | integer | Any positive number | 77 | | `size` | Font size in pixels (default: `20`) | integer | Any positive number | 78 | | `font` | Font family (default: `monospace`) | string | Any font from Google Fonts | 79 | | `color` | Color of the text (default: `36BCF7`) | string | Hex code without # (eg. `F724A9`) | 80 | | `background` | Background color of the text (default: `00000000`) | string | Hex code without # (eg. `FEFF4C`) | 81 | | `center` | `true` to center text or `false` for left aligned (default: `false`) | boolean | `true` or `false` | 82 | | `vCenter` | `true` to center vertically or `false`(default) to align above the center | boolean | `true` or `false` | 83 | | `multiline` | `true` to wrap lines or `false` to retype on one line (default: `false`) | boolean | `true` or `false` | 84 | | `duration` | Duration of the printing of a single line in milliseconds (default: `5000`) | integer | Any positive number | 85 | 86 | ## 📤 Deploying it on your own 87 | 88 | If you can, it is preferable to host the files on your own server. 89 | 90 | Doing this can lead to better uptime and more control over customization (you can modify the code for your usage). 91 | 92 | You can deploy the PHP files on any website server with PHP installed or as a Heroku app. 93 | 94 | ### Step-by-step instructions for deploying to Heroku 95 | 96 | 1. Sign in to **Heroku** or create a new account at 97 | 2. Click the "Deploy to Heroku" button below 98 | 99 | [![Deploy](https://www.herokucdn.com/deploy/button.svg "Deploy to Heroku")](https://heroku.com/deploy?template=https://github.com/DenverCoder1/readme-typing-svg/tree/main) 100 | 101 | 3. On the page that comes up, click **"Deploy App"** at the end of the form 102 | 4. Once the app is deployed, click **"Manage App"** to go to the dashboard 103 | 5. Scroll down to the **Domains** section in the settings to find the URL you will use in place of `readme-typing-svg.herokuapp.com` 104 | 6. [Optional] To use Google fonts or other custom fonts, you will need to configure the database. The login credentials for the database can be found by clicking the PostgreSQL add-on and going to Settings. The following is the definition for the `fonts` table that needs to be created. 105 | 106 | ```sql 107 | CREATE TABLE fonts ( 108 | "family" varchar(50) NOT NULL, 109 | css varchar(1200000) NOT NULL, 110 | fetch_date date NOT NULL, 111 | CONSTRAINT fonts_pkey PRIMARY KEY (family) 112 | ); 113 | ``` 114 | 115 | ## 🤗 Contributing 116 | 117 | Contributions are welcome! Feel free to open an issue or submit a pull request if you have a way to improve this project. 118 | 119 | Make sure your request is meaningful and you have tested the app locally before submitting a pull request. 120 | 121 | ### Installing requirements 122 | 123 | #### Requirements 124 | 125 | - [PHP 7](https://www.apachefriends.org/index.html) 126 | - [Composer](https://getcomposer.org) 127 | 128 | #### Linux 129 | 130 | ```bash 131 | sudo apt-get install php 132 | sudo apt-get install php-curl 133 | sudo apt-get install composer 134 | ``` 135 | 136 | #### Windows 137 | 138 | Install PHP from [XAMPP](https://www.apachefriends.org/index.html) or [php.net](https://windows.php.net/download) 139 | 140 | [▶ How to install and run PHP using XAMPP (Windows)](https://www.youtube.com/watch?v=K-qXW9ymeYQ) 141 | 142 | [📥 Download Composer](https://getcomposer.org/download/) 143 | 144 | ### Clone the repository 145 | 146 | ```bash 147 | git clone https://github.com/DenverCoder1/readme-typing-svg.git 148 | cd readme-typing-svg 149 | ``` 150 | 151 | ### Running the app locally 152 | 153 | ```bash 154 | composer start 155 | ``` 156 | 157 | Open and add parameters to run the project locally. 158 | 159 | ### Running the tests 160 | 161 | Before you can run tests, PHPUnit must be installed. You can install it using Composer by running the following command. 162 | 163 | ```bash 164 | composer install 165 | ``` 166 | 167 | Run the following command to run the PHPUnit test script which will verify that the tested functionality is still working. 168 | 169 | ```bash 170 | composer test 171 | ``` 172 | 173 | ## 🙋‍♂️ Support 174 | 175 | 💙 If you like this project, give it a ⭐ and share it with friends! 176 | 177 | 178 |

179 | Youtube 180 | Sponsor with Github 181 |

182 | 183 | 184 | [☕ Buy me a coffee](https://ko-fi.com/jlawrence) 185 | 186 | --- 187 | 188 | Made with ❤️ and PHP 189 | 190 | 191 | 192 | Powered by Heroku 193 | 194 | 195 | 196 | This project uses [Twemoji](https://github.com/twitter/twemoji), published under the [CC-BY 4.0 License](https://creativecommons.org/licenses/by/4.0/) 197 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Readme Typing SVG", 3 | "description": "⚡ Dynamically generated, customizable SVG that gives the appearance of typing and deleting text. Typing SVGs can be used as a bio on your Github profile readme or repository.", 4 | "repository": "https://github.com/DenverCoder1/readme-typing-svg/", 5 | "keywords": ["github", "dynamic", "readme", "typing", "svg", "profile"], 6 | "addons": ["heroku-postgresql"], 7 | "formation": { 8 | "web": { 9 | "quantity": 1, 10 | "size": "free" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "denvercoder1/readme-typing-svg", 3 | "description": "⚡ Dynamically generated, customizable SVG that gives the appearance of typing and deleting text. Typing SVGs can be used as a bio on your Github profile readme or repository.", 4 | "keywords": [ 5 | "github", 6 | "dynamic", 7 | "readme", 8 | "typing", 9 | "svg", 10 | "profile" 11 | ], 12 | "license": "MIT", 13 | "version": "0.4.1", 14 | "homepage": "https://github.com/DenverCoder1/readme-typing-svg/", 15 | "autoload": { 16 | "classmap": [ 17 | "src/models/", 18 | "src/views/", 19 | "src/controllers/" 20 | ] 21 | }, 22 | "require": { 23 | "php": "^7.4|^8.0", 24 | "vlucas/phpdotenv": "^5.3" 25 | }, 26 | "require-dev": { 27 | "phpunit/phpunit": "^9" 28 | }, 29 | "scripts": { 30 | "start": "php7 -S localhost:8000 -t src || php -S localhost:8000 -t src", 31 | "test": "./vendor/bin/phpunit --testdox tests" 32 | } 33 | } -------------------------------------------------------------------------------- /src/controllers/RendererController.php: -------------------------------------------------------------------------------- 1 | $params 21 | */ 22 | private $params; 23 | 24 | /** 25 | * Construct RendererController 26 | * 27 | * @param array $params request parameters 28 | */ 29 | public function __construct($params, $database = null) 30 | { 31 | $this->params = $params; 32 | 33 | // create new database connection if none was passed 34 | $database = $database ?? new DatabaseConnection(); 35 | 36 | // set up model and view 37 | try { 38 | // create renderer model 39 | $this->model = new RendererModel(__DIR__ . "/../templates/main.php", $params, $database); 40 | // create renderer view 41 | $this->view = new RendererView($this->model); 42 | } catch (Exception $error) { 43 | // create error rendering model 44 | $this->model = new ErrorModel(__DIR__ . "/../templates/error.php", $error->getMessage()); 45 | // create error rendering view 46 | $this->view = new ErrorView($this->model); 47 | } 48 | } 49 | 50 | /** 51 | * Redirect to the demo site 52 | */ 53 | private function redirectToDemo(): void 54 | { 55 | header('Location: demo/'); 56 | exit; 57 | } 58 | 59 | /** 60 | * Set content type for page output 61 | */ 62 | private function setContentType($type): void 63 | { 64 | header("Content-type: {$type}"); 65 | } 66 | 67 | /** 68 | * Set cache to refresh periodically 69 | * This ensures any updates will roll out to all profiles 70 | */ 71 | private function setCacheRefreshDaily(): void 72 | { 73 | // set cache to refresh once per day 74 | $timestamp = gmdate("D, d M Y 23:59:00") . " GMT"; 75 | header("Expires: $timestamp"); 76 | header("Last-Modified: $timestamp"); 77 | header("Pragma: no-cache"); 78 | header("Cache-Control: no-cache, must-revalidate"); 79 | } 80 | 81 | /** 82 | * Set output headers 83 | */ 84 | public function setHeaders(): void 85 | { 86 | // redirect to demo site if no text is given 87 | if (!isset($this->params["lines"])) { 88 | $this->redirectToDemo(); 89 | } 90 | 91 | // set the content type header 92 | $this->setContentType("image/svg+xml"); 93 | 94 | // set cache headers 95 | $this->setCacheRefreshDaily(); 96 | } 97 | 98 | /** 99 | * Get the rendered SVG 100 | * 101 | * @return string The SVG to output 102 | */ 103 | public function render(): string 104 | { 105 | return $this->view->render(); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/demo/css/loader.css: -------------------------------------------------------------------------------- 1 | .loader, 2 | .loader:before, 3 | .loader:after { 4 | display: none; 5 | border-radius: 50%; 6 | width: 2.5em; 7 | height: 2.5em; 8 | -webkit-animation-fill-mode: both; 9 | animation-fill-mode: both; 10 | -webkit-animation: load7 1.8s infinite ease-in-out; 11 | animation: load7 1.8s infinite ease-in-out; 12 | } 13 | 14 | .loader { 15 | color: var(--blue-light); 16 | font-size: 10px; 17 | margin: 0 0 38px 15px; 18 | position: relative; 19 | text-indent: -9999em; 20 | -webkit-transform: translateY(-4px) translateZ(0) scale(0.5); 21 | -ms-transform: translateY(-4px) translateZ(0) scale(0.5); 22 | transform: translateY(-4px) translateZ(0) scale(0.5); 23 | -webkit-animation-delay: -0.16s; 24 | animation-delay: -0.16s; 25 | } 26 | 27 | .loader:before, 28 | .loader:after { 29 | content: ''; 30 | position: absolute; 31 | top: 0; 32 | } 33 | 34 | .loader:before { 35 | left: -3.5em; 36 | -webkit-animation-delay: -0.32s; 37 | animation-delay: -0.32s; 38 | } 39 | 40 | .loader:after { 41 | left: 3.5em; 42 | } 43 | 44 | @-webkit-keyframes load7 { 45 | 46 | 0%, 47 | 80%, 48 | 100% { 49 | box-shadow: 0 2.5em 0 -1.3em; 50 | } 51 | 52 | 40% { 53 | box-shadow: 0 2.5em 0 0; 54 | } 55 | } 56 | 57 | @keyframes load7 { 58 | 59 | 0%, 60 | 80%, 61 | 100% { 62 | box-shadow: 0 2.5em 0 -1.3em; 63 | } 64 | 65 | 40% { 66 | box-shadow: 0 2.5em 0 0; 67 | } 68 | } 69 | 70 | .loading + .loader, 71 | .loading + .loader:before, 72 | .loading + .loader:after { 73 | display: block; 74 | } 75 | 76 | .loading { 77 | display: none; 78 | } -------------------------------------------------------------------------------- /src/demo/css/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | -webkit-box-sizing: border-box; 3 | -moz-box-sizing: border-box; 4 | box-sizing: border-box; 5 | } 6 | 7 | *, 8 | *::before, 9 | *::after { 10 | -webkit-box-sizing: inherit; 11 | -moz-box-sizing: inherit; 12 | box-sizing: inherit; 13 | } 14 | 15 | :root { 16 | --background: #eee; 17 | --card-background: white; 18 | --text: #1a1a1a; 19 | --border: #ccc; 20 | --stroke: #a9a9a9; 21 | --blue-light: #2196f3; 22 | --blue-dark: #1e88e5; 23 | --button-outline: black; 24 | --red: #ff6464; 25 | --red-alt: #e45353; 26 | } 27 | 28 | [data-theme="dark"] { 29 | --background: #090d13; 30 | --card-background: #0d1117; 31 | --text: #efefef; 32 | --border: #2a2e34; 33 | --stroke: #737373; 34 | --blue-light: #1976d2; 35 | --blue-dark: #1565c0; 36 | --button-outline: black; 37 | --red: #ff6464; 38 | --red-alt: #e45353; 39 | } 40 | 41 | body { 42 | background: var(--background); 43 | font-family: "Open Sans", Roboto, Arial, sans-serif; 44 | padding-top: 10px; 45 | color: var(--text); 46 | } 47 | 48 | .github { 49 | text-align: center; 50 | margin-bottom: 12px; 51 | } 52 | 53 | .github span { 54 | margin: 0 2px; 55 | } 56 | 57 | .container { 58 | margin: auto; 59 | max-width: 100%; 60 | display: flex; 61 | flex-wrap: wrap; 62 | justify-content: center; 63 | } 64 | 65 | .properties, 66 | .output { 67 | max-width: 550px; 68 | margin: 10px; 69 | background: var(--card-background); 70 | padding: 25px; 71 | padding-top: 0; 72 | border: 1px solid var(--border); 73 | border-radius: 6px; 74 | } 75 | 76 | @media only screen and (max-width: 1024px) { 77 | 78 | .properties, 79 | .output { 80 | width: 100%; 81 | } 82 | } 83 | 84 | h1 { 85 | text-align: center; 86 | } 87 | 88 | h2 { 89 | border-bottom: 1px solid var(--stroke); 90 | } 91 | 92 | :not(.btn):focus { 93 | outline: var(--blue-light) auto 2px; 94 | } 95 | 96 | .btn { 97 | max-width: 100%; 98 | background-color: var(--blue-light); 99 | color: white; 100 | padding: 10px 20px; 101 | border: none; 102 | border-radius: 6px; 103 | cursor: pointer; 104 | font-family: inherit; 105 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); 106 | transition: 0.2s ease-in-out; 107 | } 108 | 109 | .btn:focus { 110 | outline: var(--button-outline) auto 2px; 111 | } 112 | 113 | .btn:not(:disabled):hover { 114 | background-color: var(--blue-dark); 115 | box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); 116 | } 117 | 118 | .btn:disabled { 119 | opacity: 0.8; 120 | box-shadow: none; 121 | cursor: not-allowed; 122 | } 123 | 124 | .parameters { 125 | margin: auto; 126 | display: grid; 127 | align-items: center; 128 | justify-content: start; 129 | text-align: left; 130 | grid-gap: 8px; 131 | } 132 | 133 | .two-columns { 134 | grid-template-columns: auto 1fr; 135 | } 136 | 137 | .three-columns { 138 | grid-template-columns: auto 1fr auto; 139 | } 140 | 141 | .parameters .btn { 142 | margin-top: 8px; 143 | } 144 | 145 | .parameters input[type="text"], 146 | .parameters input[type="number"], 147 | .parameters input.jscolor, 148 | .parameters select { 149 | padding: 10px 14px; 150 | display: inline-block; 151 | border: 1px solid var(--border); 152 | border-radius: 6px; 153 | box-sizing: border-box; 154 | font-family: inherit; 155 | background: var(--card-background); 156 | width: 100%; 157 | min-width: 70px; 158 | color: inherit; 159 | } 160 | 161 | .param.inline { 162 | max-width: 54px; 163 | padding: 10px 6px !important; 164 | } 165 | 166 | .parameters input.jscolor { 167 | font-size: 12px; 168 | max-width: 218px; 169 | } 170 | 171 | @media only screen and (max-width: 1024px) { 172 | .parameters input.jscolor { 173 | max-width: none; 174 | } 175 | } 176 | 177 | .parameters select { 178 | -webkit-appearance: none; 179 | -moz-appearance: none; 180 | background-image: url("data:image/svg+xml;utf8,"); 181 | background-repeat: no-repeat; 182 | background-position-x: 100%; 183 | background-position-y: 5px; 184 | } 185 | 186 | [data-theme="dark"] .parameters select { 187 | background-image: url("data:image/svg+xml;utf8,"); 188 | } 189 | 190 | span[title="required"] { 191 | color: var(--red); 192 | } 193 | 194 | input:invalid { 195 | outline: 2px var(--red) auto; 196 | } 197 | 198 | input:focus:invalid { 199 | outline: none; 200 | box-shadow: 0 0 0 1px var(--blue-light), 0 0 0 3px var(--red); 201 | } 202 | 203 | .delete-line.btn { 204 | margin: 0; 205 | background: inherit; 206 | color: inherit; 207 | font-size: 22px; 208 | padding: 0; 209 | height: 40px; 210 | width: 40px; 211 | background: var(--card-background); 212 | color: var(--red-alt); 213 | box-shadow: none; 214 | } 215 | 216 | .delete-line.btn:not(:disabled):hover { 217 | box-shadow: 0 1px 3px rgb(0 0 0 / 12%), 0 1px 2px rgb(0 0 0 / 24%); 218 | background: #e4535314; 219 | } 220 | 221 | .delete-line.btn:disabled { 222 | color: var(--stroke); 223 | } 224 | 225 | .add-line.btn { 226 | width: 100px; 227 | font-size: 1em; 228 | padding: 6px 0; 229 | margin-top: 0.8em; 230 | } 231 | 232 | .output img { 233 | max-width: 100%; 234 | } 235 | 236 | .output img.outlined { 237 | border: 1px dashed var(--red); 238 | border-radius: 2px; 239 | } 240 | 241 | .output .md { 242 | background: var(--border); 243 | border-radius: 6px; 244 | padding: 12px 16px; 245 | word-break: break-all; 246 | } 247 | 248 | .output .btn { 249 | margin-top: 16px; 250 | } 251 | 252 | label.show-border { 253 | display: block; 254 | margin-top: 8px; 255 | } 256 | 257 | .label-group { 258 | display: flex; 259 | gap: 0.5rem; 260 | } 261 | 262 | a.icon { 263 | color: var(--blue-light); 264 | } 265 | 266 | /* tooltips */ 267 | .tooltip { 268 | display: inline-flex; 269 | justify-content: center; 270 | align-items: center; 271 | } 272 | 273 | /* tooltip bubble */ 274 | .tooltip:before { 275 | content: attr(title); 276 | position: absolute; 277 | transform: translateY(-2.45rem); 278 | height: auto; 279 | width: auto; 280 | background: #4a4a4afa; 281 | border-radius: 4px; 282 | color: white; 283 | line-height: 30px; 284 | font-size: 1em; 285 | padding: 0 12px; 286 | pointer-events: none; 287 | opacity: 0; 288 | } 289 | 290 | /* tooltip bottom triangle */ 291 | .tooltip:after { 292 | content: ""; 293 | position: absolute; 294 | transform: translateY(-1.35rem); 295 | border-style: solid; 296 | border-color: #4a4a4afa transparent transparent transparent; 297 | pointer-events: none; 298 | opacity: 0; 299 | } 300 | 301 | /* show tooltip on hover */ 302 | .tooltip[title]:hover:before, 303 | .tooltip[title]:hover:after, 304 | .tooltip:disabled:hover:before, 305 | .tooltip:disabled:hover:after { 306 | transition: 0.2s ease-in opacity; 307 | opacity: 1; 308 | } 309 | 310 | .tooltip:disabled:before { 311 | content: "You must first input valid text."; 312 | } 313 | -------------------------------------------------------------------------------- /src/demo/css/toggle-dark.css: -------------------------------------------------------------------------------- 1 | a.darkmode { 2 | position: fixed; 3 | top: 2em; 4 | right: 2em; 5 | color: var(--text); 6 | background: var(--background); 7 | height: 3em; 8 | width: 3em; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | border-radius: 50%; 13 | border: 2px solid var(--border); 14 | box-shadow: 0 0 3px rgb(0 0 0 / 12%), 0 1px 2px rgb(0 0 0 / 24%); 15 | transition: 0.2s ease-in box-shadow; 16 | } 17 | 18 | a.darkmode:hover { 19 | box-shadow: 0 0 6px rgb(0 0 0 / 16%), 0 3px 6px rgb(0 0 0 / 23%); 20 | } 21 | 22 | @media only screen and (max-width: 600px) { 23 | a.darkmode { 24 | top: unset; 25 | bottom: 1em; 26 | right: 1em; 27 | } 28 | } -------------------------------------------------------------------------------- /src/demo/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3solution/readme-typing-svg/90fd3757298f28f1611ae79dd252c13747091857/src/demo/favicon.png -------------------------------------------------------------------------------- /src/demo/index.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 19 | Readme Typing SVG - Demo Site 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | > 34 |

⌨️ Readme Typing SVG

35 | 36 | 37 |
38 | 39 | Sponsor 40 | 41 | View on GitHub 42 | 43 | Star 44 |
45 | 46 |
47 |
48 |

Add your text

49 |
50 | 51 |
52 | 53 | 54 |

Options

55 |
56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |
65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 84 | 85 | 86 | 90 | 91 | 92 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |

Preview

108 | 109 | Readme Typing SVG 110 |
Loading...
111 | 112 | 116 | 117 |

Markdown

118 |
119 | 120 |
121 | 122 | 125 |
126 |
127 | 128 | 129 | "> 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /src/demo/js/jscolor.min.js: -------------------------------------------------------------------------------- 1 | (function(global,factory){"use strict";if(typeof module==="object"&&typeof module.exports==="object"){module.exports=global.document?factory(global):function(win){if(!win.document){throw new Error("jscolor needs a window with document")}return factory(win)};return}factory(global)})(typeof window!=="undefined"?window:this,function(window){"use strict";var jscolor=function(){var jsc={initialized:false,instances:[],readyQueue:[],register:function(){if(typeof window!=="undefined"&&window.document){window.document.addEventListener("DOMContentLoaded",jsc.pub.init,false)}},installBySelector:function(selector,rootNode){rootNode=rootNode?jsc.node(rootNode):window.document;if(!rootNode){throw new Error("Missing root node")}var elms=rootNode.querySelectorAll(selector);var matchClass=new RegExp("(^|\\s)("+jsc.pub.lookupClass+")(\\s*(\\{[^}]*\\})|\\s|$)","i");for(var i=0;i-1},isButtonEmpty:function(el){switch(jsc.nodeName(el)){case"input":return!el.value||el.value.trim()==="";case"button":return el.textContent.trim()===""}return null},isPassiveEventSupported:function(){var supported=false;try{var opts=Object.defineProperty({},"passive",{get:function(){supported=true}});window.addEventListener("testPassive",null,opts);window.removeEventListener("testPassive",null,opts)}catch(e){}return supported}(),isColorAttrSupported:function(){var elm=window.document.createElement("input");if(elm.setAttribute){elm.setAttribute("type","color");if(elm.type.toLowerCase()=="color"){return true}}return false}(),dataProp:"_data_jscolor",setData:function(){var obj=arguments[0];if(arguments.length===3){var data=obj.hasOwnProperty(jsc.dataProp)?obj[jsc.dataProp]:obj[jsc.dataProp]={};var prop=arguments[1];var value=arguments[2];data[prop]=value;return true}else if(arguments.length===2&&typeof arguments[1]==="object"){var data=obj.hasOwnProperty(jsc.dataProp)?obj[jsc.dataProp]:obj[jsc.dataProp]={};var map=arguments[1];for(var prop in map){if(map.hasOwnProperty(prop)){data[prop]=map[prop]}}return true}throw new Error("Invalid arguments")},removeData:function(){var obj=arguments[0];if(!obj.hasOwnProperty(jsc.dataProp)){return true}for(var i=1;i=3&&(mR=par[0].match(re))&&(mG=par[1].match(re))&&(mB=par[2].match(re))){ret.format="rgb";ret.rgba=[parseFloat(mR[1])||0,parseFloat(mG[1])||0,parseFloat(mB[1])||0,null];if(par.length>=4&&(mA=par[3].match(re))){ret.format="rgba";ret.rgba[3]=parseFloat(mA[1])||0}return ret}}return false},parsePaletteValue:function(mixed){var vals=[];if(typeof mixed==="string"){mixed.replace(/#[0-9A-F]{3}([0-9A-F]{3})?|rgba?\(([^)]*)\)/gi,function(val){vals.push(val)})}else if(Array.isArray(mixed)){vals=mixed}var colors=[];for(var i=0;ivs[a]?-vp[a]+tp[a]+ts[a]/2>vs[a]/2&&tp[a]+ts[a]-ps[a]>=0?tp[a]+ts[a]-ps[a]:tp[a]:tp[a],-vp[b]+tp[b]+ts[b]+ps[b]-l+l*c>vs[b]?-vp[b]+tp[b]+ts[b]/2>vs[b]/2&&tp[b]+ts[b]-l-l*c>=0?tp[b]+ts[b]-l-l*c:tp[b]+ts[b]-l+l*c:tp[b]+ts[b]-l+l*c>=0?tp[b]+ts[b]-l+l*c:tp[b]+ts[b]-l-l*c]}var x=pp[a];var y=pp[b];var positionValue=thisObj.fixed?"fixed":"absolute";var contractShadow=(pp[0]+ps[0]>tp[0]||pp[0]0?Math.ceil(sampleCount/cols):0;cellW=Math.max(1,Math.floor((width-(cols-1)*thisObj.paletteSpacing)/cols));cellH=thisObj.paletteHeight?Math.min(thisObj.paletteHeight,cellW):cellW}if(rows){height=rows*cellH+(rows-1)*thisObj.paletteSpacing}return{cols:cols,rows:rows,cellW:cellW,cellH:cellH,width:width,height:height}},getControlPadding:function(thisObj){return Math.max(thisObj.padding/2,2*thisObj.pointerBorderWidth+thisObj.pointerThickness-thisObj.controlBorderWidth)},getPadYChannel:function(thisObj){switch(thisObj.mode.charAt(1).toLowerCase()){case"v":return"v";break}return"s"},getSliderChannel:function(thisObj){if(thisObj.mode.length>2){switch(thisObj.mode.charAt(2).toLowerCase()){case"s":return"s";break;case"v":return"v";break}}return null},triggerCallback:function(thisObj,prop){if(!thisObj[prop]){return}var callback=null;if(typeof thisObj[prop]==="string"){try{callback=new Function(thisObj[prop])}catch(e){console.error(e)}}else{callback=thisObj[prop]}if(callback){callback.call(thisObj)}},triggerGlobal:function(eventNames){var inst=jsc.getInstances();for(var i=0;i0){for(var y=0;y=2&&typeof arguments[0]==="string"){try{if(!setOption(arguments[0],arguments[1])){return false}}catch(e){console.warn(e);return false}this.redraw();this.exposeColor();return true}else if(arguments.length===1&&typeof arguments[0]==="object"){var opts=arguments[0];var success=true;for(var opt in opts){if(opts.hasOwnProperty(opt)){try{if(!setOption(opt,opts[opt])){success=false}}catch(e){console.warn(e);success=false}}}this.redraw();this.exposeColor();return success}throw new Error("Invalid arguments")};this.channel=function(name,value){if(typeof name!=="string"){throw new Error("Invalid value for channel name: "+name)}if(value===undefined){if(!this.channels.hasOwnProperty(name.toLowerCase())){console.warn("Getting unknown channel: "+name);return false}return this.channels[name.toLowerCase()]}else{var res=false;switch(name.toLowerCase()){case"r":res=this.fromRGBA(value,null,null,null);break;case"g":res=this.fromRGBA(null,value,null,null);break;case"b":res=this.fromRGBA(null,null,value,null);break;case"h":res=this.fromHSVA(value,null,null,null);break;case"s":res=this.fromHSVA(null,value,null,null);break;case"v":res=this.fromHSVA(null,null,value,null);break;case"a":res=this.fromHSVA(null,null,null,value);break;default:console.warn("Setting unknown channel: "+name);return false}if(res){this.redraw();return true}}return false};this.trigger=function(eventNames){var evs=jsc.strList(eventNames);for(var i=0;i255/2};this.hide=function(){if(isPickerOwner()){detachPicker()}};this.show=function(){drawPicker()};this.redraw=function(){if(isPickerOwner()){drawPicker()}};this.getFormat=function(){return this._currentFormat};this._setFormat=function(format){this._currentFormat=format.toLowerCase()};this.hasAlphaChannel=function(){if(this.alphaChannel==="auto"){return this.format.toLowerCase()==="any"||jsc.isAlphaFormat(this.getFormat())||this.alpha!==undefined||this.alphaElement!==undefined}return this.alphaChannel};this.processValueInput=function(str){if(!this.fromString(str)){this.exposeColor()}};this.processAlphaInput=function(str){if(!this.fromHSVA(null,null,null,parseFloat(str))){this.exposeColor()}};this.exposeColor=function(flags){var colorStr=this.toString();var fmt=this.getFormat();jsc.setDataAttr(this.targetElement,"current-color",colorStr);if(!(flags&jsc.flags.leaveValue)&&this.valueElement){if(fmt==="hex"||fmt==="hexa"){if(!this.uppercase){colorStr=colorStr.toLowerCase()}if(!this.hash){colorStr=colorStr.replace(/^#/,"")}}this.setValueElementValue(colorStr)}if(!(flags&jsc.flags.leaveAlpha)&&this.alphaElement){var alphaVal=Math.round(this.channels.a*100)/100;this.setAlphaElementValue(alphaVal)}if(!(flags&jsc.flags.leavePreview)&&this.previewElement){var previewPos=null;if(jsc.isTextInput(this.previewElement)||jsc.isButton(this.previewElement)&&!jsc.isButtonEmpty(this.previewElement)){previewPos=this.previewPosition}this.setPreviewElementBg(this.toRGBAString())}if(isPickerOwner()){redrawPad();redrawSld();redrawASld()}};this.setPreviewElementBg=function(color){if(!this.previewElement){return}var position=null;var width=null;if(jsc.isTextInput(this.previewElement)||jsc.isButton(this.previewElement)&&!jsc.isButtonEmpty(this.previewElement)){position=this.previewPosition;width=this.previewSize}var backgrounds=[];if(!color){backgrounds.push({image:"none",position:"left top",size:"auto",repeat:"no-repeat",origin:"padding-box"})}else{backgrounds.push({image:jsc.genColorPreviewGradient(color,position,width?width-jsc.pub.previewSeparator.length:null),position:"left top",size:"auto",repeat:position?"repeat-y":"repeat",origin:"padding-box"});var preview=jsc.genColorPreviewCanvas("rgba(0,0,0,0)",position?{left:"right",right:"left"}[position]:null,width,true);backgrounds.push({image:"url('"+preview.canvas.toDataURL()+"')",position:(position||"left")+" top",size:preview.width+"px "+preview.height+"px",repeat:position?"repeat-y":"repeat",origin:"padding-box"})}var bg={image:[],position:[],size:[],repeat:[],origin:[]};for(var i=0;i=0;i-=1){var pres=presetsArr[i];if(!pres){continue}if(!jsc.pub.presets.hasOwnProperty(pres)){console.warn("Unknown preset: %s",pres);continue}for(var opt in jsc.pub.presets[pres]){if(jsc.pub.presets[pres].hasOwnProperty(opt)){try{setOption(opt,jsc.pub.presets[pres][opt])}catch(e){console.warn(e)}}}}var nonProperties=["preset"];for(var opt in opts){if(opts.hasOwnProperty(opt)){if(nonProperties.indexOf(opt)===-1){try{setOption(opt,opts[opt])}catch(e){console.warn(e)}}}}if(this.container===undefined){this.container=window.document.body}else{this.container=jsc.node(this.container)}if(!this.container){throw new Error("Cannot instantiate color picker without a container element")}this.targetElement=jsc.node(targetElement);if(!this.targetElement){if(typeof targetElement==="string"&&/^[a-zA-Z][\w:.-]*$/.test(targetElement)){var possiblyId=targetElement;throw new Error("If '"+possiblyId+"' is supposed to be an ID, please use '#"+possiblyId+"' or any valid CSS selector.")}throw new Error("Cannot instantiate color picker without a target element")}if(this.targetElement.jscolor&&this.targetElement.jscolor instanceof jsc.pub){throw new Error("Color picker already installed on this element")}this.targetElement.jscolor=this;jsc.addClass(this.targetElement,jsc.pub.className);jsc.instances.push(this);if(jsc.isButton(this.targetElement)){if(this.targetElement.type.toLowerCase()!=="button"){this.targetElement.type="button"}if(jsc.isButtonEmpty(this.targetElement)){jsc.removeChildren(this.targetElement);this.targetElement.appendChild(window.document.createTextNode(" "));var compStyle=jsc.getCompStyle(this.targetElement);var currMinWidth=parseFloat(compStyle["min-width"])||0;if(currMinWidth-1){var color=jsc.parseColorString(initValue);this._currentFormat=color?color.format:"hex"}else{this._currentFormat=this.format.toLowerCase()}this.processValueInput(initValue);if(initAlpha!==undefined){this.processAlphaInput(initAlpha)}}};jsc.pub.className="jscolor";jsc.pub.activeClassName="jscolor-active";jsc.pub.looseJSON=true;jsc.pub.presets={};jsc.pub.presets["default"]={};jsc.pub.presets["light"]={backgroundColor:"rgba(255,255,255,1)",controlBorderColor:"rgba(187,187,187,1)",buttonColor:"rgba(0,0,0,1)"};jsc.pub.presets["dark"]={backgroundColor:"rgba(51,51,51,1)",controlBorderColor:"rgba(153,153,153,1)",buttonColor:"rgba(240,240,240,1)"};jsc.pub.presets["small"]={width:101,height:101,padding:10,sliderSize:14,paletteCols:8};jsc.pub.presets["medium"]={width:181,height:101,padding:12,sliderSize:16,paletteCols:10};jsc.pub.presets["large"]={width:271,height:151,padding:12,sliderSize:24,paletteCols:15};jsc.pub.presets["thin"]={borderWidth:1,controlBorderWidth:1,pointerBorderWidth:1};jsc.pub.presets["thick"]={borderWidth:2,controlBorderWidth:2,pointerBorderWidth:2};jsc.pub.sliderInnerSpace=3;jsc.pub.chessboardSize=8;jsc.pub.chessboardColor1="#666666";jsc.pub.chessboardColor2="#999999";jsc.pub.previewSeparator=["rgba(255,255,255,.65)","rgba(128,128,128,.65)"];jsc.pub.init=function(){if(jsc.initialized){return}window.document.addEventListener("mousedown",jsc.onDocumentMouseDown,false);window.document.addEventListener("keyup",jsc.onDocumentKeyUp,false);window.addEventListener("resize",jsc.onWindowResize,false);window.addEventListener("scroll",jsc.onWindowScroll,false);jsc.pub.install();jsc.initialized=true;while(jsc.readyQueue.length){var func=jsc.readyQueue.shift();func()}};jsc.pub.install=function(rootNode){var success=true;try{jsc.installBySelector("[data-jscolor]",rootNode)}catch(e){success=false;console.warn(e)}if(jsc.pub.lookupClass){try{jsc.installBySelector("input."+jsc.pub.lookupClass+", "+"button."+jsc.pub.lookupClass,rootNode)}catch(e){}}return success};jsc.pub.ready=function(func){if(typeof func!=="function"){console.warn("Passed value is not a function");return false}if(jsc.initialized){func()}else{jsc.readyQueue.push(func)}return true};jsc.pub.trigger=function(eventNames){var triggerNow=function(){jsc.triggerGlobal(eventNames)};if(jsc.initialized){triggerNow()}else{jsc.pub.ready(triggerNow)}};jsc.pub.hide=function(){if(jsc.picker&&jsc.picker.owner){jsc.picker.owner.hide()}};jsc.pub.chessboard=function(color){if(!color){color="rgba(0,0,0,0)"}var preview=jsc.genColorPreviewCanvas(color);return preview.canvas.toDataURL()};jsc.pub.background=function(color){var backgrounds=[];backgrounds.push(jsc.genColorPreviewGradient(color));var preview=jsc.genColorPreviewCanvas();backgrounds.push(["url('"+preview.canvas.toDataURL()+"')","left top","repeat"].join(" "));return backgrounds.join(", ")};jsc.pub.options={};jsc.pub.lookupClass="jscolor";jsc.pub.installByClassName=function(){console.error('jscolor.installByClassName() is DEPRECATED. Use data-jscolor="" attribute instead of a class name.'+jsc.docsRef);return false};jsc.register();return jsc.pub}();if(typeof window.jscolor==="undefined"){window.jscolor=window.JSColor=jscolor}return jscolor}); 2 | -------------------------------------------------------------------------------- /src/demo/js/script.js: -------------------------------------------------------------------------------- 1 | let preview = { 2 | // default values 3 | defaults: { 4 | font: "monospace", 5 | color: "36BCF7", 6 | background: "00000000", 7 | size: "20", 8 | center: "false", 9 | vCenter: "false", 10 | multiline: "false", 11 | width: "400", 12 | height: "50", 13 | duration: "5000" 14 | }, 15 | dummyText: [ 16 | "The five boxing wizards jump quickly", 17 | "How vexingly quick daft zebras jump", 18 | "Quick fox jumps nightly above wizard", 19 | "Sphinx of black quartz, judge my vow", 20 | "Waltz, bad nymph, for quick jigs vex", 21 | "Glib jocks quiz nymph to vex dwarf", 22 | "Jived fox nymph grabs quick waltz", 23 | ], 24 | // update the preview 25 | update: function () { 26 | const copyButton = document.querySelector(".copy-button"); 27 | // get parameter values from all .param elements 28 | const params = Array.from(document.querySelectorAll(".param:not([data-index])")).reduce( 29 | (acc, next) => { 30 | // copy accumulator into local object 31 | let obj = acc; 32 | let value = next.value; 33 | // remove hash from any colors and remove "FF" if full opacity 34 | value = value.replace(/^#([A-Fa-f0-9]{6})(?:[Ff]{2})?/, "$1"); 35 | // add value to reduction accumulator 36 | obj[next.id] = value; 37 | return obj; 38 | }, {} 39 | ); 40 | const lineInputs = Array.from(document.querySelectorAll(".param[data-index]")); 41 | // disable copy button if any line contains semicolon 42 | if (lineInputs.some((el) => el.value.indexOf(";") >= 0)) { 43 | return copyButton.disabled = "true"; 44 | } 45 | // add lines to parameters 46 | params.lines = lineInputs 47 | .map((el) => el.value) // get values 48 | .filter((val) => val.length) // skip blank entries 49 | .join(";"); // join lines with ';' delimiter 50 | // function to URI encode string but keep semicolons as ';' and spaces as '+' 51 | const encode = (str) => { 52 | return encodeURIComponent(str).replace(/%3B/g, ";").replace(/%20/g, "+") 53 | }; 54 | // convert parameters to query string 55 | const query = Object.keys(params) 56 | .filter((key) => params[key] !== this.defaults[key]) // skip if default value 57 | .map((key) => encode(key) + "=" + encode(params[key])) // encode keys and values 58 | .join("&"); // join lines with '&' delimiter 59 | // generate links and markdown 60 | const imageURL = `${window.location.origin}?${query}`; 61 | const demoImageURL = `/?${query}`; 62 | const repoLink = "https://git.io/typing-svg"; 63 | const md = `[![Typing SVG](${imageURL})](${repoLink})`; 64 | // don't update if nothing has changed 65 | const mdElement = document.querySelector(".md code"); 66 | const image = document.querySelector(".output img"); 67 | if (mdElement.innerText === md) { 68 | return; 69 | } 70 | // update image preview 71 | image.src = demoImageURL; 72 | image.classList.add("loading"); 73 | // update markdown 74 | mdElement.innerText = md; 75 | // disable copy button if no lines are filled in 76 | copyButton.disabled = !params.lines.length; 77 | }, 78 | addLine: function () { 79 | const parent = document.querySelector(".lines"); 80 | const index = parent.querySelectorAll("input").length + 1; 81 | // label 82 | const label = document.createElement("label"); 83 | label.innerText = `Line ${index}`; 84 | label.setAttribute("for", `line-${index}`); 85 | label.dataset.index = index; 86 | // line input box 87 | const input = document.createElement("input"); 88 | input.className = "param"; 89 | input.type = "text"; 90 | input.id = `line-${index}`; 91 | input.name = `line-${index}`; 92 | input.placeholder = "Enter text here"; 93 | input.value = this.dummyText[(index - 1) % this.dummyText.length]; 94 | input.pattern = "^[^;]*$"; 95 | input.title = "Text cannot contain semicolons"; 96 | input.dataset.index = index; 97 | // removal button 98 | const deleteButton = document.createElement("button"); 99 | deleteButton.className = "delete-line btn"; 100 | deleteButton.setAttribute( 101 | "onclick", 102 | "return preview.removeLine(this.dataset.index);" 103 | ); 104 | deleteButton.innerHTML = ' '; 105 | deleteButton.dataset.index = index; 106 | 107 | // add elements 108 | parent.appendChild(label); 109 | parent.appendChild(input); 110 | parent.appendChild(deleteButton); 111 | 112 | // disable button if only 1 113 | parent.querySelector(".delete-line.btn").disabled = index == 1; 114 | 115 | // update and exit 116 | this.update(); 117 | return false; 118 | }, 119 | removeLine: function (index) { 120 | index = Number(index); 121 | const parent = document.querySelector(".lines"); 122 | // remove all elements for given property 123 | parent 124 | .querySelectorAll(`[data-index="${index}"]`) 125 | .forEach((el) => { 126 | parent.removeChild(el); 127 | }); 128 | // update index numbers 129 | const labels = parent.querySelectorAll("label"); 130 | labels 131 | .forEach((label) => { 132 | const labelIndex = Number(label.dataset.index); 133 | if (labelIndex > index) { 134 | label.dataset.index = labelIndex - 1; 135 | label.setAttribute("for", `line-${labelIndex - 1}`); 136 | label.innerText = `Line ${labelIndex - 1}`; 137 | } 138 | }); 139 | const inputs = parent.querySelectorAll(".param"); 140 | inputs 141 | .forEach((input) => { 142 | const inputIndex = Number(input.dataset.index); 143 | if (inputIndex > index) { 144 | input.dataset.index = inputIndex - 1; 145 | input.setAttribute("id", `line-${inputIndex - 1}`); 146 | input.setAttribute("name", `line-${inputIndex - 1}`); 147 | } 148 | }); 149 | const buttons = parent.querySelectorAll(".delete-line.btn"); 150 | buttons 151 | .forEach((button) => { 152 | const buttonIndex = Number(button.dataset.index); 153 | if (buttonIndex > index) { 154 | button.dataset.index = buttonIndex - 1; 155 | } 156 | }); 157 | // disable button if only 1 158 | buttons[0].disabled = buttons.length == 1; 159 | // update and exit 160 | this.update(); 161 | return false; 162 | }, 163 | }; 164 | 165 | let clipboard = { 166 | copy: function (el) { 167 | // create input box to copy from 168 | const input = document.createElement("input"); 169 | input.value = document.querySelector(".md code").innerText; 170 | document.body.appendChild(input); 171 | // select all 172 | input.select(); 173 | input.setSelectionRange(0, 99999); 174 | // copy 175 | document.execCommand("copy"); 176 | // remove input box 177 | input.parentElement.removeChild(input); 178 | // set tooltip text 179 | el.title = "Copied!"; 180 | }, 181 | }; 182 | 183 | let tooltip = { 184 | reset: function (el) { 185 | // remove tooltip text 186 | el.removeAttribute("title"); 187 | }, 188 | }; 189 | 190 | // refresh preview on interactions with the page 191 | document.addEventListener("keyup", () => preview.update(), false); 192 | document.addEventListener("click", () => preview.update(), false); 193 | 194 | // checkbox listener 195 | document.querySelector(".show-border input").addEventListener("change", function () { 196 | const img = document.querySelector(".output img"); 197 | this.checked ? img.classList.add("outlined") : img.classList.remove("outlined"); 198 | }); 199 | 200 | // when the page loads 201 | window.addEventListener( 202 | "load", 203 | () => { 204 | // add first line 205 | preview.addLine(); 206 | preview.update(); 207 | }, 208 | false 209 | ); 210 | -------------------------------------------------------------------------------- /src/demo/js/toggle-dark.js: -------------------------------------------------------------------------------- 1 | // enable dark mode on load if user prefers dark themes and has not used the toggle 2 | getCookie("darkmode") === null && 3 | window.matchMedia("(prefers-color-scheme: dark)").matches && 4 | darkmode(); 5 | 6 | function toggleTheme() { 7 | // turn on dark mode 8 | if (document.body.getAttribute("data-theme") !== "dark") { 9 | darkmode(); 10 | } 11 | // turn off dark mode 12 | else { 13 | lightmode(); 14 | } 15 | } 16 | 17 | function darkmode() { 18 | document.querySelector(".darkmode i").className = "gg-sun"; 19 | setCookie("darkmode", "on", 9999); 20 | document.body.setAttribute("data-theme", "dark"); 21 | } 22 | 23 | function lightmode() { 24 | document.querySelector(".darkmode i").className = "gg-moon"; 25 | setCookie("darkmode", "off", 9999); 26 | document.body.removeAttribute("data-theme"); 27 | } 28 | 29 | function setCookie(cname, cvalue, exdays) { 30 | var d = new Date(); 31 | d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000); 32 | var expires = "expires=" + d.toUTCString(); 33 | document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/"; 34 | } 35 | 36 | function getCookie(name) { 37 | var dc = document.cookie; 38 | var prefix = name + "="; 39 | var begin = dc.indexOf("; " + prefix); 40 | if (begin == -1) { 41 | begin = dc.indexOf(prefix); 42 | if (begin != 0) return null; 43 | } else { 44 | begin += 2; 45 | var end = document.cookie.indexOf(";", begin); 46 | if (end == -1) { 47 | end = dc.length; 48 | } 49 | } 50 | return decodeURI(dc.substring(begin + prefix.length, end)); 51 | } 52 | -------------------------------------------------------------------------------- /src/index.php: -------------------------------------------------------------------------------- 1 | safeLoad(); 8 | 9 | $controller = new RendererController($_REQUEST); 10 | $controller->setHeaders(); 11 | echo $controller->render(); 12 | -------------------------------------------------------------------------------- /src/models/DatabaseConnection.php: -------------------------------------------------------------------------------- 1 | conn = pg_connect($conn_string); 31 | } 32 | 33 | /** 34 | * Fetch CSS from PostgreSQL Database 35 | * 36 | * @param string $font Google Font to fetch 37 | * @return array array containing the date and the CSS for displaying the font 38 | */ 39 | public function fetchFontCSS($font) 40 | { 41 | // check connection 42 | if ($this->conn) { 43 | // fetch font from database 44 | $result = pg_query_params($this->conn, 'SELECT fetch_date, css FROM fonts WHERE family = $1', array($font)); 45 | if (!$result) { 46 | return false; 47 | } 48 | $row = pg_fetch_row($result); 49 | if ($row) { 50 | return $row; 51 | } 52 | } 53 | return false; 54 | } 55 | 56 | /** 57 | * Insert font CSS into database 58 | * 59 | * @param string $font Font Family 60 | * @param string $css CSS with Base64 encoding 61 | * @return bool True if successful, false if connection failed 62 | */ 63 | public function insertFontCSS($font, $css) 64 | { 65 | if ($this->conn) { 66 | $entry = array( 67 | "family" => $font, 68 | "css" => $css, 69 | "fetch_date" => date('Y-m-d'), 70 | ); 71 | $result = pg_insert($this->conn, 'fonts', $entry); 72 | if (!$result) { 73 | throw new InvalidArgumentException("Insertion of Google Font to database failed"); 74 | } 75 | return true; 76 | } 77 | return false; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/models/ErrorModel.php: -------------------------------------------------------------------------------- 1 | message = $message; 23 | $this->template = $template; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/models/GoogleFontConverter.php: -------------------------------------------------------------------------------- 1 | $fontType) { 40 | $response = self::curl_get_contents($url); 41 | $dataURI = "data:font/{$fontType};base64," . base64_encode($response); 42 | $css = str_replace($url, $dataURI, $css); 43 | } 44 | return $css; 45 | } 46 | 47 | /** 48 | * Get the contents of a URL 49 | * 50 | * @param string $url The URL to fetch 51 | * @return string Response from URL 52 | */ 53 | private static function curl_get_contents($url): string 54 | { 55 | $ch = curl_init(); 56 | curl_setopt($ch, CURLOPT_AUTOREFERER, true); 57 | curl_setopt($ch, CURLOPT_HEADER, false); 58 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 59 | curl_setopt($ch, CURLOPT_URL, $url); 60 | curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 61 | curl_setopt($ch, CURLOPT_VERBOSE, false); 62 | curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); 63 | $response = curl_exec($ch); 64 | $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); 65 | curl_close($ch); 66 | if ($httpCode != 200) { 67 | throw new InvalidArgumentException("Failed to fetch Google Font from API."); 68 | } 69 | return $response; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/models/RendererModel.php: -------------------------------------------------------------------------------- 1 | $lines text to display */ 11 | public $lines; 12 | 13 | /** @var string $font Font family */ 14 | public $font; 15 | 16 | /** @var string $color Font color */ 17 | public $color; 18 | 19 | /** @var string $background Background color */ 20 | public $background; 21 | 22 | /** @var int $size Font size */ 23 | public $size; 24 | 25 | /** @var bool $center Whether or not to center text horizontally */ 26 | public $center; 27 | 28 | /** @var bool $vCenter Whether or not to center text vertically */ 29 | public $vCenter; 30 | 31 | /** @var int $width SVG width (px) */ 32 | public $width; 33 | 34 | /** @var int $height SVG height (px) */ 35 | public $height; 36 | 37 | /** @var bool $multiline True = wrap to new lines, False = retype on same line */ 38 | public $multiline; 39 | 40 | /** @var int $duration print duration in milliseconds */ 41 | public $duration; 42 | 43 | /** @var string $fontCSS CSS required for displaying the selected font */ 44 | public $fontCSS; 45 | 46 | /** @var string $template Path to template file */ 47 | public $template; 48 | 49 | /** @var DatabaseConnection $database Database connection */ 50 | private $database; 51 | 52 | /** @var array $DEFAULTS */ 53 | private $DEFAULTS = array( 54 | "font" => "monospace", 55 | "color" => "#36BCF7", 56 | "background" => "#00000000", 57 | "size" => "20", 58 | "center" => "false", 59 | "vCenter" => "false", 60 | "width" => "400", 61 | "height" => "50", 62 | "multiline" => "false", 63 | "duration" => "5000" 64 | ); 65 | 66 | /** 67 | * Construct RendererModel 68 | * 69 | * @param string $template Path to the template file 70 | * @param array $params request parameters 71 | * @param DatabaseConnection $font_db Database connection 72 | */ 73 | public function __construct($template, $params, $database) 74 | { 75 | $this->template = $template; 76 | $this->database = $database; 77 | $this->lines = $this->checkLines($params["lines"] ?? ""); 78 | $this->font = $this->checkFont($params["font"] ?? $this->DEFAULTS["font"]); 79 | $this->color = $this->checkColor($params["color"] ?? $this->DEFAULTS["color"], "color"); 80 | $this->background = $this->checkColor($params["background"] ?? $this->DEFAULTS["background"], "background"); 81 | $this->size = $this->checkNumber($params["size"] ?? $this->DEFAULTS["size"], "Font size"); 82 | $this->center = $this->checkBoolean($params["center"] ?? $this->DEFAULTS["center"]); 83 | $this->vCenter = $this->checkBoolean($params["vCenter"] ?? $this->DEFAULTS["vCenter"]); 84 | $this->width = $this->checkNumber($params["width"] ?? $this->DEFAULTS["width"], "Width"); 85 | $this->height = $this->checkNumber($params["height"] ?? $this->DEFAULTS["height"], "Height"); 86 | $this->multiline = $this->checkBoolean($params["multiline"] ?? $this->DEFAULTS["multiline"]); 87 | $this->duration = $this->checkNumber($params["duration"] ?? $this->DEFAULTS["duration"], "duration"); 88 | $this->fontCSS = $this->fetchFontCSS($this->font); 89 | } 90 | 91 | /** 92 | * Validate lines and return array of string 93 | * 94 | * @param string $lines Semicolon-separated lines parameter 95 | * @return array escaped array of lines 96 | */ 97 | private function checkLines($lines) 98 | { 99 | if (!$lines) { 100 | throw new InvalidArgumentException("Lines parameter must be set."); 101 | } 102 | $trimmed_lines = rtrim($lines, ';'); 103 | $exploded = explode(";", $trimmed_lines); 104 | // escape special characters to prevent code injection 105 | return array_map("htmlspecialchars", $exploded); 106 | } 107 | 108 | /** 109 | * Validate font family and return valid string 110 | * 111 | * @param string $font Font name parameter 112 | * @return string Sanitized font name 113 | */ 114 | private function checkFont($font) 115 | { 116 | // return sanitized font name 117 | return preg_replace("/[^0-9A-Za-z\- ]/", "", $font); 118 | } 119 | 120 | /** 121 | * Validate font color and return valid string 122 | * 123 | * @param string $color Color parameter 124 | * @param string $field Field name for displaying in case of error 125 | * @return string Sanitized color with preceding hash symbol 126 | */ 127 | private function checkColor($color, $field) 128 | { 129 | $sanitized = (string) preg_replace("/[^0-9A-Fa-f]/", "", $color); 130 | // if color is not a valid length, use the default 131 | if (!in_array(strlen($sanitized), [3, 4, 6, 8])) { 132 | return $this->DEFAULTS[$field]; 133 | } 134 | // return sanitized color 135 | return "#" . $sanitized; 136 | } 137 | 138 | /** 139 | * Validate numeric parameter and return valid integer 140 | * 141 | * @param string $num Parameter to validate 142 | * @param string $field Field name for displaying in case of error 143 | * @return int Sanitized digits and int 144 | */ 145 | private function checkNumber($num, $field) 146 | { 147 | $digits = intval(preg_replace("/[^0-9\-]/", "", $num)); 148 | if ($digits <= 0) { 149 | throw new InvalidArgumentException("$field must be a positive number."); 150 | } 151 | return $digits; 152 | } 153 | 154 | /** 155 | * Validate "true" or "false" value as string and return boolean 156 | * 157 | * @param string $bool Boolean parameter as string 158 | * @return boolean Whether or not $bool is set to "true" 159 | */ 160 | private function checkBoolean($bool) 161 | { 162 | return strtolower($bool) == "true"; 163 | } 164 | 165 | /** 166 | * Fetch CSS with Base-64 encoding from database or store new entry if it is missing 167 | * 168 | * @param string $font Google Font to fetch 169 | * @return string The CSS for displaying the font 170 | */ 171 | private function fetchFontCSS($font) 172 | { 173 | // skip checking if left as default 174 | if ($font != $this->DEFAULTS["font"]) { 175 | // fetch from database 176 | $from_database = $this->database->fetchFontCSS($font); 177 | if ($from_database) { 178 | // return the CSS for displaying the font 179 | $date = $from_database[0]; 180 | $css = $from_database[1]; 181 | return "\n"; 182 | } 183 | // fetch and convert from Google Fonts if not found in database 184 | $from_google_fonts = GoogleFontConverter::fetchFontCSS($font); 185 | if ($from_google_fonts) { 186 | // add font to the database 187 | $this->database->insertFontCSS($font, $from_google_fonts); 188 | // return the CSS for displaying the font 189 | $date = date('Y-m-d'); 190 | return "\n"; 191 | } 192 | } 193 | // font is not in database or Google Fonts 194 | return ""; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/templates/error.php: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/templates/main.php: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 21 | 22 | 23 | 25 | dominant-baseline='middle' 26 | 27 | dominant-baseline='auto' 28 | 29 | 30 | x='50%' text-anchor='middle'> 31 | 32 | x='0%' text-anchor='start'> 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/views/ErrorView.php: -------------------------------------------------------------------------------- 1 | model = $model; 20 | } 21 | 22 | /** 23 | * Render SVG Output 24 | * @return string 25 | */ 26 | public function render() 27 | { 28 | // import variables into symbol table 29 | extract(["message" => $this->model->message]); 30 | // render SVG with output buffering 31 | ob_start(); 32 | include $this->model->template; 33 | $output = ob_get_contents(); 34 | ob_end_clean(); 35 | // return rendered output 36 | return $output; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/views/RendererView.php: -------------------------------------------------------------------------------- 1 | model = $model; 20 | } 21 | 22 | /** 23 | * Render SVG Output 24 | * @return string 25 | */ 26 | public function render() 27 | { 28 | // import variables into symbol table 29 | extract(array( 30 | "lines" => $this->model->lines, 31 | "font" => $this->model->font, 32 | "color" => $this->model->color, 33 | "background" => $this->model->background, 34 | "size" => $this->model->size, 35 | "center" => $this->model->center, 36 | "vCenter" => $this->model->vCenter, 37 | "width" => $this->model->width, 38 | "height" => $this->model->height, 39 | "multiline" => $this->model->multiline, 40 | "fontCSS" => $this->model->fontCSS, 41 | "duration" => $this->model->duration, 42 | )); 43 | // render SVG with output buffering 44 | ob_start(); 45 | include $this->model->template; 46 | $output = ob_get_contents(); 47 | ob_end_clean(); 48 | // return rendered output 49 | return $output; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/OptionsTest.php: -------------------------------------------------------------------------------- 1 | expectException("InvalidArgumentException"); 21 | $this->expectExceptionMessage("Lines parameter must be set."); 22 | $params = array( 23 | "center" => "true", 24 | "width" => "380", 25 | "height" => "50", 26 | ); 27 | print_r(new RendererModel("src/templates/main.php", $params, self::$database)); 28 | } 29 | 30 | /** 31 | * Test valid font 32 | */ 33 | public function testValidFont(): void 34 | { 35 | $params = array( 36 | "lines" => "text", 37 | "font" => "Open Sans", 38 | ); 39 | $model = new RendererModel("src/templates/main.php", $params, self::$database); 40 | $this->assertEquals("Open Sans", $model->font); 41 | } 42 | 43 | /** 44 | * Test valid font color 45 | */ 46 | public function testValidFontColor(): void 47 | { 48 | $params = array( 49 | "lines" => "text", 50 | "color" => "000000", 51 | ); 52 | $model = new RendererModel("src/templates/main.php", $params, self::$database); 53 | $this->assertEquals("#000000", $model->color); 54 | } 55 | 56 | /** 57 | * Test invalid font color 58 | */ 59 | public function testInvalidFontColor(): void 60 | { 61 | $params = array( 62 | "lines" => "text", 63 | "color" => "00000", 64 | ); 65 | $model = new RendererModel("src/templates/main.php", $params, self::$database); 66 | $this->assertEquals("#36BCF7", $model->color); 67 | } 68 | 69 | /** 70 | * Test valid background color 71 | */ 72 | public function testValidBackgroundColor(): void 73 | { 74 | $params = array( 75 | "lines" => "text", 76 | "background" => "00000033", 77 | ); 78 | $model = new RendererModel("src/templates/main.php", $params, self::$database); 79 | $this->assertEquals("#00000033", $model->background); 80 | } 81 | 82 | /** 83 | * Test invalid background color 84 | */ 85 | public function testInvalidBackgroundColor(): void 86 | { 87 | $params = array( 88 | "lines" => "text", 89 | "background" => "00000", 90 | ); 91 | $model = new RendererModel("src/templates/main.php", $params, self::$database); 92 | $this->assertEquals("#00000000", $model->background); 93 | } 94 | 95 | /** 96 | * Test valid font size 97 | */ 98 | public function testValidFontSize(): void 99 | { 100 | $params = array( 101 | "lines" => "text", 102 | "size" => "18", 103 | ); 104 | $model = new RendererModel("src/templates/main.php", $params, self::$database); 105 | $this->assertEquals(18, $model->size); 106 | } 107 | 108 | /** 109 | * Test exception thrown when font size is invalid 110 | */ 111 | public function testInvalidFontSize(): void 112 | { 113 | $this->expectException("InvalidArgumentException"); 114 | $this->expectExceptionMessage("Font size must be a positive number."); 115 | $params = array( 116 | "lines" => "text", 117 | "size" => "0", 118 | ); 119 | print_r(new RendererModel("src/templates/main.php", $params, self::$database)); 120 | } 121 | 122 | /** 123 | * Test valid height 124 | */ 125 | public function testValidHeight(): void 126 | { 127 | $params = array( 128 | "lines" => "text", 129 | "height" => "80", 130 | ); 131 | $model = new RendererModel("src/templates/main.php", $params, self::$database); 132 | $this->assertEquals(80, $model->height); 133 | } 134 | 135 | /** 136 | * Test exception thrown when height is invalid 137 | */ 138 | public function testInvalidHeight(): void 139 | { 140 | $this->expectException("InvalidArgumentException"); 141 | $this->expectExceptionMessage("Height must be a positive number."); 142 | $params = array( 143 | "lines" => "text", 144 | "height" => "x", 145 | ); 146 | print_r(new RendererModel("src/templates/main.php", $params, self::$database)); 147 | } 148 | 149 | /** 150 | * Test valid width 151 | */ 152 | public function testValidWidth(): void 153 | { 154 | $params = array( 155 | "lines" => "text", 156 | "width" => "500", 157 | ); 158 | $model = new RendererModel("src/templates/main.php", $params, self::$database); 159 | $this->assertEquals(500, $model->width); 160 | } 161 | 162 | /** 163 | * Test exception thrown when width is invalid 164 | */ 165 | public function testInvalidWidth(): void 166 | { 167 | $this->expectException("InvalidArgumentException"); 168 | $this->expectExceptionMessage("Width must be a positive number."); 169 | $params = array( 170 | "lines" => "text", 171 | "width" => "-1", 172 | ); 173 | print_r(new RendererModel("src/templates/main.php", $params, self::$database)); 174 | } 175 | 176 | /** 177 | * Test center set to true 178 | */ 179 | public function testCenterIsTrue(): void 180 | { 181 | $params = array( 182 | "lines" => "text", 183 | "center" => "true", 184 | ); 185 | $model = new RendererModel("src/templates/main.php", $params, self::$database); 186 | $this->assertEquals(true, $model->center); 187 | } 188 | 189 | /** 190 | * Test center not set to true 191 | */ 192 | public function testCenterIsNotTrue(): void 193 | { 194 | $params = array( 195 | "lines" => "text", 196 | "center" => "other", 197 | ); 198 | $model = new RendererModel("src/templates/main.php", $params, self::$database); 199 | $this->assertEquals(false, $model->center); 200 | } 201 | 202 | /** 203 | * Test vCenter set to true 204 | */ 205 | public function testVCenterIsTrue(): void 206 | { 207 | $params = array( 208 | "lines" => "text", 209 | "vCenter" => "true", 210 | ); 211 | $model = new RendererModel("src/templates/main.php", $params, self::$database); 212 | $this->assertEquals(true, $model->vCenter); 213 | } 214 | 215 | /** 216 | * Test vCenter not set to true 217 | */ 218 | public function testVCenterIsNotTrue(): void 219 | { 220 | $params = array( 221 | "lines" => "text", 222 | "vCenter" => "other", 223 | ); 224 | $model = new RendererModel("src/templates/main.php", $params, self::$database); 225 | $this->assertEquals(false, $model->vCenter); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /tests/RendererTest.php: -------------------------------------------------------------------------------- 1 | implode(";", array( 23 | "Full-stack web and app developer", 24 | "Self-taught UI/UX Designer", 25 | "10+ years of coding experience", 26 | "Always learning new things", 27 | )), 28 | "center" => "true", 29 | "vCenter" => "true", 30 | "width" => "380", 31 | "height" => "50", 32 | ); 33 | $controller = new RendererController($params, self::$database); 34 | $this->assertEquals(file_get_contents("tests/svg/test_normal.svg"), $controller->render()); 35 | } 36 | 37 | public function testMultilineCardRender(): void 38 | { 39 | $params = array( 40 | "lines" => implode(";", array( 41 | "Full-stack web and app developer", 42 | "Self-taught UI/UX Designer", 43 | "10+ years of coding experience", 44 | "Always learning new things", 45 | )), 46 | "center" => "true", 47 | "vCenter" => "true", 48 | "width" => "380", 49 | "height" => "200", 50 | "multiline" => "true", 51 | ); 52 | $controller = new RendererController($params, self::$database); 53 | $this->assertEquals(file_get_contents("tests/svg/test_multiline.svg"), $controller->render()); 54 | } 55 | 56 | /** 57 | * Test error card render 58 | */ 59 | public function testErrorCardRender(): void 60 | { 61 | // missing lines 62 | $params = array( 63 | "center" => "true", 64 | "vCenter" => "true", 65 | "width" => "380", 66 | "height" => "50", 67 | ); 68 | $controller = new RendererController($params, self::$database); 69 | $this->assertEquals(file_get_contents("tests/svg/test_missing_lines.svg"), $controller->render()); 70 | } 71 | 72 | /** 73 | * Test loading a valid Google Font 74 | */ 75 | public function testLoadingGoogleFont(): void 76 | { 77 | $params = array( 78 | "lines" => "text", 79 | "font" => "Roboto", 80 | ); 81 | $controller = new RendererController($params, self::$database); 82 | $expected = preg_replace("/\/\*(.*?)\*\//", "", file_get_contents("tests/svg/test_fonts.svg")); 83 | $actual = preg_replace("/\/\*(.*?)\*\//", "", $controller->render()); 84 | $this->assertEquals($expected, $actual); 85 | } 86 | 87 | /** 88 | * Test loading a valid Google Font 89 | */ 90 | public function testInvalidGoogleFont(): void 91 | { 92 | $params = array( 93 | "lines" => implode(";", array( 94 | "Full-stack web and app developer", 95 | "Self-taught UI/UX Designer", 96 | "10+ years of coding experience", 97 | "Always learning new things", 98 | )), 99 | "center" => "true", 100 | "vCenter" => "true", 101 | "width" => "380", 102 | "font" => "Not-A-Font", 103 | ); 104 | $controller = new RendererController($params, self::$database); 105 | $expected = str_replace('"monospace"', '"Not-A-Font"', file_get_contents("tests/svg/test_normal.svg")); 106 | $this->assertEquals($expected, $controller->render()); 107 | } 108 | 109 | /** 110 | * Test if a trailing ";" in lines is trimmed; see issue #25 111 | */ 112 | public function testLineTrimming(): void 113 | { 114 | $params = array( 115 | "lines" => implode(";", array( 116 | "Full-stack web and app developer", 117 | "Self-taught UI/UX Designer", 118 | "10+ years of coding experience", 119 | "Always learning new things", 120 | "", 121 | )), 122 | "center" => "true", 123 | "vCenter" => "true", 124 | "width" => "380", 125 | "height" => "50", 126 | ); 127 | $controller = new RendererController($params, self::$database); 128 | $this->assertEquals(file_get_contents("tests/svg/test_normal.svg"), $controller->render()); 129 | } 130 | 131 | /** 132 | * Test normal vertical alignment 133 | */ 134 | public function testNormalVerticalAlignment(): void 135 | { 136 | $params = array( 137 | "lines" => implode(";", array( 138 | "Full-stack web and app developer", 139 | "Self-taught UI/UX Designer", 140 | "10+ years of coding experience", 141 | "Always learning new things", 142 | )), 143 | "center" => "true", 144 | "width" => "380", 145 | "height" => "50", 146 | ); 147 | $controller = new RendererController($params, self::$database); 148 | $this->assertEquals(file_get_contents("tests/svg/test_normal_vertical_alignment.svg"), $controller->render()); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /tests/phpunit/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ../ 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/svg/test_missing_lines.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | Lines parameter must be set. 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/svg/test_multiline.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 13 | 14 | 17 | 18 | Full-stack web and app developer 19 | 20 | 21 | 22 | 23 | 26 | 27 | 30 | 31 | Self-taught UI/UX Designer 32 | 33 | 34 | 35 | 36 | 39 | 40 | 43 | 44 | 10+ years of coding experience 45 | 46 | 47 | 48 | 49 | 52 | 53 | 56 | 57 | Always learning new things 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /tests/svg/test_normal.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 12 | 13 | 16 | 17 | Full-stack web and app developer 18 | 19 | 20 | 21 | 22 | 24 | 25 | 28 | 29 | Self-taught UI/UX Designer 30 | 31 | 32 | 33 | 34 | 36 | 37 | 40 | 41 | 10+ years of coding experience 42 | 43 | 44 | 45 | 46 | 48 | 49 | 52 | 53 | Always learning new things 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /tests/svg/test_normal_vertical_alignment.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 12 | 13 | 16 | 17 | Full-stack web and app developer 18 | 19 | 20 | 21 | 22 | 24 | 25 | 28 | 29 | Self-taught UI/UX Designer 30 | 31 | 32 | 33 | 34 | 36 | 37 | 40 | 41 | 10+ years of coding experience 42 | 43 | 44 | 45 | 46 | 48 | 49 | 52 | 53 | Always learning new things 54 | 55 | 56 | 57 | 58 | --------------------------------------------------------------------------------