Added SVG to PNG conversion tool, Added tool page and code

Update .gitignore, sidebar.php, and 17 more files...
This commit is contained in:
2023-05-26 05:50:37 +02:00
parent e6e00ad02f
commit 6e9bf25866
19 changed files with 559 additions and 38 deletions

1
.gitignore vendored
View File

@@ -25,3 +25,4 @@ commons/trash/
scrollbar.scss
commons/strings/_*/
_unsorted/
tools/items/*/*.min.js

View File

@@ -41,7 +41,11 @@ function printSidebarEntry($url, $title, $icon) {
printSidebarEntry(l10n_url_abs('/content/?tags=electronic'), localize("sidebar.text.electronics"), "fad fa-microchip");
?>
</div>
<hr class="subtle">
<hr class="subtle">
<?php
printSidebarEntry(l10n_url_abs('/tools/'), localize("sidebar.text.tools"), "fad fa-tools");
?>
<hr class="subtle">
<?php
printSidebarEntry(l10n_url_abs('/links/'), localize("sidebar.text.links"), "fad fa-link");
?>

View File

@@ -10,7 +10,7 @@ $host_uri = "https://nibblepoker.lu";
$dir_commons = dirname(__FILE__);
$dir_root = realpath($dir_commons . "/../");
$config_dir_content = realpath($dir_commons . "/../" . "content/");
$config_dir_tools = realpath($dir_commons . "/../" . "content/");
$config_dir_tools = realpath($dir_commons . "/../" . "tools/");
// Optional features
$enable_grids = false;

View File

@@ -31,7 +31,7 @@ class ContentIndexEntry {
$this->priority = is_null($priority) ? 0 : $priority;
}
static function from_json(array $json_data) : ?ContentIndexEntry {
static function from_json(array $json_data): ?ContentIndexEntry {
if(!key_exists("id", $json_data)) {
return null;
}
@@ -70,7 +70,7 @@ class ContentManager {
if(!$this->hasError) {
if($this->displayType == ContentDisplayType::SEARCH) {
$this->loadRootIndex(realpath($contentRootPath . "/index.json"));
} else if($this->displayType == ContentDisplayType::CONTENT) {
} elseif($this->displayType == ContentDisplayType::CONTENT) {
$this->prepareContentFilePath($contentRootPath);
}
}
@@ -80,7 +80,7 @@ class ContentManager {
// Doing some dark magic whose inner workings are lost to times...
$requestedUrlPart = explode(
"?",
explode("#", preg_replace("^\/(content)^", "", $requestedUrl))[0]
explode("#", preg_replace("^\/(content|tools)^", "", $requestedUrl))[0]
)[0];
if(strcmp($requestedUrlPart, "/") == 0) {
@@ -111,7 +111,7 @@ class ContentManager {
}
} else {
$this->displayType = ContentDisplayType::CONTENT;
$this->requestedId = ltrim($requestedUrlPart, "/");
$this->requestedId = ltrim(rtrim($requestedUrlPart, "/"), "/");
}
}
@@ -154,7 +154,7 @@ class ContentManager {
}
// Sorting entries based on their priority
usort($this->rootIndexEntries, function (ContentIndexEntry $a, ContentIndexEntry $b) {
usort($this->rootIndexEntries, function(ContentIndexEntry $a, ContentIndexEntry $b) {
if($a->priority == $b->priority) {
return 0;
}
@@ -182,7 +182,7 @@ class ContentManager {
}
// Common utilities
function get_content_file_path(string $contentRootPath, string $contentId) : ?string {
function get_content_file_path(string $contentRootPath, string $contentId): ?string {
if(ctype_alnum(str_replace("-", "", $contentId))) {
return realpath($contentRootPath . "/items/" . $contentId . ".json");
}

124
commons/content/tools.php Normal file
View File

@@ -0,0 +1,124 @@
<?php
// Making sure the file is included and not accessed directly.
if(basename(__FILE__) == basename($_SERVER["SCRIPT_FILENAME"])) {
header('HTTP/1.1 403 Forbidden');
die();
}
// Including required helpers.
include_once 'commons/config.php';
include_once 'commons/langs.php';
include_once 'commons/content.php';
// Required to make headings
include_once 'commons/DOM/utils.php';
// Defining the template types.
class ToolInfoFile {
public string $domFile;
public ?string $langFile;
public array $codeFilesPaths;
public array $styleFilesPaths;
public string $icon;
public string $titleKey;
public ?string $subTitleKey;
function __construct(string $domFile, ?string $langFile, ?array $codeFilesPaths, ?array $styleFilesPaths,
?string $icon, ?string $titleKey, ?string $subTitleKey) {
$this->domFile = $domFile;
$this->langFile = $langFile;
$this->codeFilesPaths = is_null($codeFilesPaths) ? array() : $codeFilesPaths;
$this->styleFilesPaths = is_null($styleFilesPaths) ? array() : $styleFilesPaths;
$this->icon = is_null($icon) ? "fad fa-question" : $icon;
$this->titleKey = is_null($titleKey) ? "unset" : $titleKey;
$this->subTitleKey = $subTitleKey;
}
static function from_json(array $json_data): ?ToolInfoFile {
if(!key_exists("dom", $json_data)) {
return null;
}
return new ToolInfoFile(
$json_data["dom"],
key_exists("lang", $json_data) ? $json_data["lang"] : null,
key_exists("code", $json_data) ? $json_data["code"] : null,
key_exists("styles", $json_data) ? $json_data["styles"] : null,
key_exists("icon", $json_data) ? $json_data["icon"] : null,
key_exists("title", $json_data) ? $json_data["title"] : null,
key_exists("subtitle", $json_data) ? $json_data["subtitle"] : null
);
}
function validateFiles(ContentManager $contentManager): void {
if(!(file_exists($this->domFile) && is_file($this->domFile))) {
$contentManager->hasError = true;
$contentManager->errorMessageKey = "content.error.message.missing.file.dom";
return;
}
if(!is_null($this->langFile)) {
if(!(file_exists($this->langFile) && is_file($this->langFile))) {
$contentManager->hasError = true;
$contentManager->errorMessageKey = "content.error.message.missing.file.lang";
return;
}
}
foreach($this->codeFilesPaths as $codeFilePath) {
if(!(file_exists($codeFilePath) && is_file($codeFilePath))) {
$contentManager->hasError = true;
$contentManager->errorMessageKey = "content.error.message.missing.file.code";
return;
}
}
foreach($this->styleFilesPaths as $styleFilePath) {
if(!(file_exists($styleFilePath) && is_file($styleFilePath))) {
$contentManager->hasError = true;
$contentManager->errorMessageKey = "content.error.message.missing.file.style";
return;
}
}
}
}
abstract class ToolsContent {
static function loadItemIndexFile(ContentManager $contentManager, string $contentRootPath): ?ToolInfoFile {
// Preliminary check
if(!$contentManager->displayType == ContentDisplayType::CONTENT) {
$contentManager->hasError = true;
$contentManager->errorMessageKey = "content.error.message.cannot.load.item.as.not.content";
return null;
}
// Loading the index file
$itemIndexJsonData = json_decode(file_get_contents($contentManager->contentFilepath), true);
if(is_null($itemIndexJsonData)) {
return null;
}
$toolInfo = ToolInfoFile::from_json($itemIndexJsonData);
unset($itemIndexJsonData);
// Making paths absolute
if(!is_null($toolInfo)) {
$toolInfo->domFile = realpath($contentRootPath . "/items/" . $toolInfo->domFile);
if(!is_null($toolInfo->langFile)) {
$toolInfo->langFile = realpath($contentRootPath . "/items/" . $toolInfo->langFile);
}
for($iCodeFilePath = 0; $iCodeFilePath < count($toolInfo->codeFilesPaths); $iCodeFilePath++) {
$toolInfo->codeFilesPaths[$iCodeFilePath] = realpath(
$contentRootPath . "/items/" . $toolInfo->codeFilesPaths[$iCodeFilePath]);
}
for($iStyleFilePath = 0; $iStyleFilePath < count($toolInfo->styleFilesPaths); $iStyleFilePath++) {
$toolInfo->styleFilesPaths[$iStyleFilePath] = realpath(
$contentRootPath . "/items/" . $toolInfo->styleFilesPaths[$iStyleFilePath]);
}
} else {
$contentManager->hasError = true;
$contentManager->errorMessageKey = "content.error.message.cannot.load";
}
return $toolInfo;
}
}
?>

File diff suppressed because one or more lines are too long

View File

@@ -37,6 +37,13 @@
"content.error.message.data.no.tags": "No tags found !",
"content.error.message.data.no.title": "No title found !",
"___": "Messages returned by 'commons/content/tools.php'",
"_content.error.message.cannot.load.item.as.not.content": "",
"_content.error.message.missing.file.dom": "",
"_content.error.message.missing.file.lang": "",
"_content.error.message.missing.file.code": "",
"_content.error.message.missing.file.style": "",
"content.item.head.title.prefix": "",
"content.item.head.title.suffix": " - NibblePoker",

View File

@@ -5,6 +5,7 @@
"sidebar.text.applications": "Applications",
"sidebar.text.libraries": "Libraries",
"sidebar.text.electronics": "Electronics",
"sidebar.text.tools": "Tools",
"sidebar.text.links": "Links",
"sidebar.text.downloads": "Downloads",
"sidebar.text.gitea": "Git Repos.",

View File

@@ -5,6 +5,7 @@
"sidebar.text.applications": "Applications",
"sidebar.text.libraries": "Librairies",
"sidebar.text.electronics": "Électronique",
"sidebar.text.tools": "Outils",
"sidebar.text.links": "Liens",
"sidebar.text.downloads": "Téléchargements",
"sidebar.text.gitea": "Dépôts Git",

View File

@@ -43,3 +43,6 @@
.p-mxs {
padding: calc(#{$margin-base-size} * 0.375);
}
.p-xxs {
padding: calc(#{$margin-base-size} * 0.25);
}

View File

@@ -21,32 +21,6 @@ table.stylish {
border-right: 1.5px solid #{$color-table-border};
}
// Applying .p-xs and .p-s to all cells if needed
// See 'core/spacing' for more info.
&.table-p-xs {
td, th {
padding: calc(#{$margin-base-size} * 0.5);
}
}
&.table-h-p-s {
th {
padding: calc(#{$margin-base-size} * 0.75);
}
}
&.table-p-s {
td, th {
padding: calc(#{$margin-base-size} * 0.75);
}
}
&.table-v-center {
tr, td {
vertical-align: middle;
}
}
// Fixing border issues when using rounded corners by using a "fake" one using the background's color.
// It will look like utter shit when rounded on firefox because its rendering engine cannot clip rounded corners apparently.
// I guess that's what being at less than 3% of the market share does to you and your ability to care about basic shit.
@@ -54,3 +28,29 @@ table.stylish {
background-color: #{$color-border-all};
}
}
// Applying .p-xs and .p-s to all cells if needed
// See 'core/spacing' for more info.
.table-p-xs {
td, th {
padding: calc(#{$margin-base-size} * 0.5);
}
}
.table-h-p-s {
th {
padding: calc(#{$margin-base-size} * 0.75);
}
}
.table-p-s {
td, th {
padding: calc(#{$margin-base-size} * 0.75);
}
}
.table-v-center {
tr, td {
vertical-align: middle;
}
}

View File

@@ -6,6 +6,8 @@ https://nibblepoker.lu/content/youtube-auto-archiver
https://nibblepoker.lu/content/excel-worksheet-password-remover
https://nibblepoker.lu/content/mc-expanded-iron-bundles
https://nibblepoker.lu/content/dotnet-arguments
https://nibblepoker.lu/tools/
https://nibblepoker.lu/tools/svg-to-png/
https://nibblepoker.lu/links/
https://nibblepoker.lu/contact/
https://nibblepoker.lu/privacy/
@@ -17,6 +19,8 @@ https://nibblepoker.lu/en/content/youtube-auto-archiver
https://nibblepoker.lu/en/content/excel-worksheet-password-remover
https://nibblepoker.lu/en/content/mc-expanded-iron-bundles
https://nibblepoker.lu/en/content/dotnet-arguments
https://nibblepoker.lu/en/tools/
https://nibblepoker.lu/en/tools/svg-to-png/
https://nibblepoker.lu/en/links/
https://nibblepoker.lu/en/contact/
https://nibblepoker.lu/en/privacy/
@@ -28,6 +32,8 @@ https://nibblepoker.lu/fr/content/youtube-auto-archiver
https://nibblepoker.lu/fr/content/excel-worksheet-password-remover
https://nibblepoker.lu/fr/content/mc-expanded-iron-bundles
https://nibblepoker.lu/fr/content/dotnet-arguments
https://nibblepoker.lu/fr/tools/
https://nibblepoker.lu/fr/tools/svg-to-png/
https://nibblepoker.lu/fr/links/
https://nibblepoker.lu/fr/contact/
https://nibblepoker.lu/fr/privacy/

View File

@@ -1,3 +1,9 @@
# Redirecting any URL that starts with "/tools" to the root of this folder.
RewriteEngine On
RewriteRule ^(.*) index.php [NC]
RewriteRule ^\/?(tools\/)?[a-zA-Z0-9\-]+\/?$ index.php [NC]
# Redirecting any URL that starts with "/content" to the root of this folder.
#RewriteEngine On
#RewriteRule ^\/tools\/[a-zA-Z0-9\-]+\/?$ index.php [L]
# ^(?!\/.*\.(js|css)).*$ Should have worked :/

16
tools/index.json Normal file
View File

@@ -0,0 +1,16 @@
[
{
"id": "svg-to-png",
"title": {
"en": "SVG to PNG Converter",
"fr": "Convertisseur SVG vers PNG"
},
"preamble": {
"en": "",
"fr": ""
},
"_image": "",
"tags": ["converter", "svg", "png"],
"priority": 100
}
]

185
tools/index.php Normal file
View File

@@ -0,0 +1,185 @@
<?php
$start_time = microtime(true);
// Importing required scripts.
set_include_path('../');
include_once 'commons/config.php';
include_once 'commons/langs.php';
// Preparing the content
include_once 'commons/content.php';
include_once 'commons/content/tools.php';
$contentManager = getContentManager($config_dir_tools);
$toolInfo = NULL;
if(!$contentManager->hasError && $contentManager->displayType == ContentDisplayType::CONTENT) {
$toolInfo = ToolsContent::loadItemIndexFile($contentManager, $config_dir_tools);
if(!is_null($toolInfo)) {
$toolInfo->validateFiles($contentManager);
}
// If we still don't have errors, we load the lang file.
if(!$contentManager->hasError && !is_null($toolInfo->langFile)) {
// FIXME: Refactor the 'langs.php' to include this bit.
$toolLangJson = file_get_contents($toolInfo->langFile);
$toolLangData = json_decode($toolLangJson, true);
unset($toolLangJson);
if(array_key_exists($default_language, $toolLangData)) {
$lang_data[$default_language] = array_merge($lang_data[$default_language], $toolLangData[$default_language]);
}
if($default_language != $user_language && array_key_exists($user_language, $toolLangData)) {
$lang_data[$user_language] = array_merge($lang_data[$user_language], $toolLangData[$user_language]);
}
unset($toolLangData);
}
}
$content_error_message = localize($contentManager->errorMessageKey);
// Checking if an error occurred while loading data and parsing the URL.
// And if not, enabling special features.
$content_error_code = 200;
if($contentManager->hasError) {
// TODO: Add condition for the lack of data for an item.
if(is_null($contentManager->rootIndexEntries)) {
// Failed to get a display type or to extract types.
header("HTTP/1.1 400 Bad Request");
$content_error_code = 400;
} else {
//Other error. (No article, ...)
header("HTTP/1.1 500 Internal Server Error");
$content_error_code = 500;
}
} else {
$enable_code_highlight = true;
$enable_glider = true;
}
?>
<!DOCTYPE html>
<html lang="<?php echo($user_language); ?>">
<head>
<?php include 'commons/DOM/head.php'; ?>
<title><?php print(localize('tools.head.title')); ?></title>
<meta name="description" content="<?php print(localize('tools.head.description')); ?>">
<meta property="og:title" content="<?php print(localize('tools.og.title')); ?>"/>
<meta property="og:type" content="website"/>
<meta property="og:url" content="<?php echo($host_uri . l10n_url_abs('/')); ?>"/>
<meta property="og:image" content="<?php echo($host_uri); ?>/resources/NibblePoker/images/logos/v2_opengraph.png"/>
<meta property="og:image:type" content="image/png"/>
<meta property="og:description" content="<?php print(localize('tools.og.description')); ?>"/>
</head>
<body>
<?php
include_once 'commons/DOM/utils.php';
include 'commons/DOM/body-1.php';
$SIDEBAR_ID = 'tools';
include 'commons/DOM/sidebar.php';
include 'commons/DOM/body-2.php';
?>
<header class="w-full p-m pl-s">
<h1 class="t-size-17 t-w-500">
<i class="fad fa-tools t-size-16 mr-s t-muted"></i><?php print(localize("tools.header.title")); ?>
</h1>
<?php //include 'header-lang.php'; ?>
</header>
<?php include 'commons/DOM/body-3.php'; ?>
<main id="main" class="rl-m border border-r-0 p-l">
<?php
// Checking if an error occurred.
if($content_error_code != 200) {
if($contentManager->displayType == ContentDisplayType::SEARCH) {
printMainHeader(localize("content.error.heading.main.search"), "fad fa-exclamation-triangle");
} elseif($contentManager->displayType == ContentDisplayType::CONTENT) {
printMainHeader(localize("content.error.heading.main.content"), "fad fa-exclamation-triangle");
} else {
printMainHeader(localize("content.error.heading.main.fallback"), "fad fa-exclamation-triangle");
}
echo('<h3 class="mt-m t-size-18 t-center content-error-text mx-auto">' . $content_error_message . '</h3>');
goto content_printing_end;
}
if($contentManager->displayType == ContentDisplayType::SEARCH) {
// We are handling a content search with at least one result.
// Making the header with the amount of results.
printMainHeader(
count($contentManager->rootIndexEntries) > 1 ?
localize("content.search.heading.main.multiple") :
localize("content.search.heading.main.single"),
"fad fa-file-search",
count($contentManager->rootIndexEntries) . " " . (
count($contentManager->rootIndexEntries) > 1 ?
localize("content.search.count.multiple") :
localize("content.search.count.single")));
// Printing the entry for each piece of relevant content.
$doPrintRuler = false;
foreach($contentManager->rootIndexEntries as $current_content) {
/** @var ContentIndexEntry $current_content */
if($doPrintRuler) {
echo('<hr class="subtle">');
} else {
$doPrintRuler = true;
}
echo('<div class="p-s">');
echo('<a class="casper-link" href="'.l10n_url_abs("/tools/".$current_content->id).'">');
echo('<div class="content-search-entry">');
echo('<img class="content-search-image mr-s r-l" src="' . $current_content->image . '">');
echo('<h3 class="mb-xs">' . $current_content->title[$user_language] . '</h3>');
echo('<p>' . $current_content->preamble[$user_language] . '</p>');
echo('</div>');
echo('</a>');
echo('<p class="mt-xs"><i class="fad fa-tags t-size-8"></i>');
foreach($current_content->tags as $current_content_tag) {
echo('<a href="' . l10n_url_abs("/tools/?tags=".$current_content_tag) .
'" class="ml-xs">#' . $current_content_tag . '</a>');
}
echo('</p>');
echo('</div>');
}
// TODO: Print the tags used in the search and others that may be available.
} elseif($contentManager->displayType == ContentDisplayType::CONTENT) {
// Printing the main heading (Lifted from composer.php in the templates section)
echo(getMainHeader(
localize($toolInfo->titleKey),
$toolInfo->icon,
is_null($toolInfo->subTitleKey) ? null : localize($toolInfo->subTitleKey),
null,
false,
null,
3,
false,
true
));
// Printing the content
echo('<div class="px-xxs">'); // mt-l
include($toolInfo->domFile);
echo('</div>');
}
// Label used when there is an error to skip the content printing parts.
content_printing_end:
?>
</main>
<?php
include 'commons/DOM/body-4.php';
include 'commons/DOM/footer.php';
include 'commons/DOM/body-5.php';
include 'commons/DOM/scripts.php';
// Including the tool's scripts if required.
if(!$contentManager->hasError && $contentManager->displayType == ContentDisplayType::CONTENT) {
foreach($toolInfo->codeFilesPaths as $codeFilePath) {
echo('<script src="'.substr($codeFilePath, strlen($dir_root)).'"></script>');
}
}
?>
</body>
</html>
<?php
$end_time = microtime(true);
if($print_execution_timer) {
echo("<!-- PHP execution took " . round(($end_time - $start_time) * 1000, 2) . " ms -->");
}
?>

View File

@@ -0,0 +1,9 @@
{
"dom": "svg-to-png/page.php",
"lang": "svg-to-png/lang.json",
"code": [
"svg-to-png/code.min.js"
],
"icon": "fad fa-exchange-alt",
"title": "tool.svg-to-png.title"
}

View File

@@ -0,0 +1,70 @@
document.addEventListener("DOMContentLoaded", () => {
const eInputFiles = document.getElementById("tool-svg-to-png-files");
const eFileSelectButton = document.getElementById("tool-svg-to-png-btn-select");
const eTextNoFiles = document.getElementById("tool-svg-to-png-text-none");
const eTextHasFiles = document.getElementById("tool-svg-to-png-text-good");
const eTextFileCount = document.getElementById("tool-svg-to-png-file-count");
const eInputWidth = document.getElementById("tool-svg-to-png-width");
const eInputHeight = document.getElementById("tool-svg-to-png-height");
const eFileConvertButton = document.getElementById("tool-svg-to-png-btn-convert");
// Propagating the button click to the input element
eFileSelectButton.onclick = function () {
eInputFiles.click();
}
// Handling file selection
eInputFiles.addEventListener('change', function(e) {
eTextNoFiles.hidden = true;
eTextHasFiles.hidden = true;
eTextFileCount.innerText = e.target.files.length.toString();
if(e.target.files.length > 0) {
eTextHasFiles.hidden = false;
} else {
eTextNoFiles.hidden = false;
}
});
// Handling conversion
eFileConvertButton.onclick = function () {
const canvas = document.getElementById('conversion-canvas');
const ctx = canvas.getContext('2d');
if(eInputFiles.files.length === 0) {
console.error("No files selected !");
return
}
canvas.width = parseInt(eInputWidth.value);
canvas.height = parseInt(eInputHeight.value);
for(let iFile = 0; iFile < eInputFiles.files.length; iFile++) {
const imageFile = eInputFiles.files[iFile];
const fileReader = new FileReader();
console.log("Handling: " + imageFile.name);
fileReader.onload = (function(file) {
return function(e) {
const image = new Image();
image.onload = function() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
const dataURL = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.download = imageFile.name.replace(".svg", ".png");
link.href = dataURL;
link.click();
};
image.src = e.target.result; // Set the image source
};
})(imageFile);
fileReader.readAsDataURL(imageFile);
}
}
});

View File

@@ -0,0 +1,32 @@
{
"en": {
"tool.svg-to-png.title": "SVG to PNG Converter",
"tool.svg-to-png.input.title": "File selection",
"tool.svg-to-png.options.title": "Options",
"tool.svg-to-png.output.title": "Output",
"tool.svg-to-png.text.no.files": "No files selected.",
"tool.svg-to-png.text.has.files": "You selected <span id=\"tool-svg-to-png-file-count\"></span> file(s).",
"tool.svg-to-png.select.files": "Select File(s)",
"tool.svg-to-png.files": "File(s)",
"tool.svg-to-png.width": "Width",
"tool.svg-to-png.height": "Height",
"tool.svg-to-png.convert": "Convert image(s)"
},
"fr": {
"tool.svg-to-png.title": "Convertisseur SVG vers PNG",
"tool.svg-to-png.input.title": "Selection de fichier(s)",
"tool.svg-to-png.options.title": "Options",
"_tool.svg-to-png.output.title": "",
"tool.svg-to-png.text.no.files": "Aucun fichier sélectionné.",
"tool.svg-to-png.text.has.files": "Vous avez sélectionné <span id=\"tool-svg-to-png-file-count\"></span> fichier(s).",
"tool.svg-to-png.select.files": "Sélection de Fichier(s)",
"tool.svg-to-png.files": "Fichier(s)",
"tool.svg-to-png.width": "Largeur",
"tool.svg-to-png.height": "Hauteur",
"tool.svg-to-png.convert": "Convertir vers PNG"
}
}

View File

@@ -0,0 +1,56 @@
<?php
echo(getMainHeader(localize("tool.svg-to-png.input.title"), null, null, null,
true, "bkgd-math", 3, false, false, true));
echo('<table class="table-v-center table-p-xs mt-xs"><tr><td>');
echo('<label for="tool-svg-to-png-files" hidden>' . localize("tool.svg-to-png.files") . ':</label>');
echo('<input type="file" id="tool-svg-to-png-files" name="tool-svg-to-png-files" class="d-none" accept=".svg,image/svg+xml" multiple>');
echo('<p id="tool-svg-to-png-text-none" class="t-italic px-xxs">' . localize("tool.svg-to-png.text.no.files") . '</p>');
echo('<p id="tool-svg-to-png-text-good" class="t-italic px-xxs" hidden>' . localize("tool.svg-to-png.text.has.files") . '</p>');
echo('</td></tr><tr><td>');
echo('<button id="tool-svg-to-png-btn-select" class="p-mxs r-s border b-light primary">');
echo('<span class="text-monospace"><i class="fad fa-file-search"></i>&nbsp;&nbsp;');
echo(localize("tool.svg-to-png.select.files"));
echo('</span></button>');
echo('</td></tr></table>');
echo(getMainHeader(localize("tool.svg-to-png.options.title"), null, null, null,
true, "bkgd-math", 3, false, false, true));
echo('<table class="table-v-center table-p-xs mt-xs"><tr><td>');
echo('<label for="tool-svg-to-png-width">' . localize("tool.svg-to-png.width") . ': </label>');
echo('</td><td>');
echo('<input type="number" id="tool-svg-to-png-width" name="tool-svg-to-png-width" class="border p-xs r-s" value="256" min="1" max="8192"/>');
echo('</td></tr><tr><td>');
echo('<label for="tool-svg-to-png-height">' . localize("tool.svg-to-png.height") . ': </label>');
echo('</td><td>');
echo('<input type="number" id="tool-svg-to-png-height" name="tool-svg-to-png-height" class="border p-xs r-s" value="256" min="1" max="8192"/>');
echo('</td></tr></table>');
echo(getMainHeader(localize("tool.svg-to-png.output.title"), null, null, null,
true, "bkgd-math", 3, false, false, true));
echo('<div class="p-s pt-m">');
echo('<button id="tool-svg-to-png-btn-convert" class="p-mxs r-s border b-light primary">');
echo('<span class="text-monospace"><i class="fad fa-file-search"></i>&nbsp;&nbsp;');
echo(localize("tool.svg-to-png.convert"));
echo('</span></button>');
// TODO: Add 2nd button with aspect ration preservation
echo('</div>');
echo('<br>');
echo('<div class="p-s">');
echo('<canvas id="conversion-canvas" width="256" height="256" class="border r-l d-none"></canvas>');
echo('</div>');
?>