Various improvements

- Better UX overall
- Fixes for phone in landscape
- Added line height option
- Use Warbreaker prologue (and added attribution)
This commit is contained in:
2026-03-02 21:21:53 +01:00
parent ae5818dd62
commit 676c5ad8bc
7 changed files with 986 additions and 904 deletions

2
.gitignore vendored
View File

@@ -1 +1 @@
ebook-fonts
repos/ebook-fonts

223
assets/app.js Normal file
View File

@@ -0,0 +1,223 @@
const sizeRange = document.getElementById("sizeRange");
const lineHeightRange = document.getElementById("lineHeightRange");
const sampleArea = document.getElementById("sampleArea");
const fontSelect = document.getElementById("fontSelect");
const fontListCore = document.getElementById("fontListCore");
const fontListExtra = document.getElementById("fontListExtra");
const extraFonts = document.getElementById("extraFonts");
const darkModeToggle = document.getElementById("darkModeToggle");
const bezelToggle = document.getElementById("bezelToggle");
const reader = document.querySelector(".reader");
const sizeValue = document.getElementById("sizeValue");
const lineHeightValue = document.getElementById("lineHeightValue");
const fonts = new Map();
const preferredOrder = [
"Readerly",
"Cartisse",
"NV NinePoint",
"NV Charis",
"NV Garamond",
"NV Jost"
];
let sampleHtml = "";
let activeFamily = "";
function parseFont(file) {
const fileName = file.split("/").pop().replace(".ttf", "");
const parts = fileName.split("-");
const rawFamily = parts.slice(0, -1).join("-") || fileName;
const rawStyle = parts.length > 1 ? parts[parts.length - 1] : "Regular";
const family = rawFamily.replace(/_/g, " ");
const styleToken = rawStyle || "Regular";
const isBold = styleToken.includes("Bold");
const isItalic = styleToken.includes("Italic");
return {
file,
family,
weight: isBold ? 700 : 400,
style: isItalic ? "italic" : "normal",
collection: file.includes("/core/") ? "Core" : "Extra"
};
}
function buildFontFaces() {
const styleEl = document.createElement("style");
const rules = [];
fontFiles.forEach((file) => {
const info = parseFont(file);
if (!fonts.has(info.family)) {
fonts.set(info.family, {
family: info.family,
collection: info.collection,
files: []
});
}
fonts.get(info.family).files.push(info);
rules.push(
`@font-face {\n` +
` font-family: "${info.family}";\n` +
` src: url("./${info.file}") format("truetype");\n` +
` font-weight: ${info.weight};\n` +
` font-style: ${info.style};\n` +
` font-display: swap;\n` +
`}`
);
});
styleEl.textContent = rules.join("\n");
document.head.appendChild(styleEl);
}
function sortFonts() {
return [...fonts.values()].sort((a, b) => {
const aIndex = preferredOrder.indexOf(a.family);
const bIndex = preferredOrder.indexOf(b.family);
if (aIndex !== -1 || bIndex !== -1) {
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
}
return a.family.localeCompare(b.family);
});
}
function createFontCard(font) {
const card = document.createElement("button");
card.type = "button";
card.className = "font-card";
if (font.family === "NV NinePoint") card.classList.add("is-ninepoint");
if (font.family === "NV Charis") card.classList.add("is-charis");
card.style.fontFamily = `"${font.family}", serif`;
card.innerHTML = `<p>${font.family}</p>`;
card.addEventListener("click", () => setActiveFont(font.family));
return card;
}
function createFontOption(font) {
const option = document.createElement("option");
option.value = font.family;
option.textContent = font.family;
return option;
}
function updateFontList() {
fontListCore.innerHTML = "";
fontListExtra.innerHTML = "";
fontSelect.innerHTML = "";
const coreGroup = document.createElement("optgroup");
coreGroup.label = "Core Collection";
const extraGroup = document.createElement("optgroup");
extraGroup.label = "Extra Collection";
sortFonts().forEach((font) => {
const target = font.collection === "Core" ? fontListCore : fontListExtra;
target.appendChild(createFontCard(font));
const group = font.collection === "Core" ? coreGroup : extraGroup;
group.appendChild(createFontOption(font));
});
fontSelect.appendChild(coreGroup);
fontSelect.appendChild(extraGroup);
const scrollHint = document.createElement("div");
scrollHint.className = "scroll-hint";
scrollHint.textContent = "Scroll to see more fonts";
fontListExtra.appendChild(scrollHint);
}
function setActiveFont(family) {
activeFamily = family;
renderPreview();
document.querySelectorAll(".font-card").forEach((card) => {
card.classList.toggle("is-active", card.textContent.trim() === family);
});
if (fontSelect.value !== family) {
fontSelect.value = family;
}
}
function updateValueDisplays() {
sizeValue.textContent = `(${sizeRange.value}pt)`;
lineHeightValue.textContent = `(${lineHeightRange.value})`;
}
function renderPreview() {
sampleArea.style.fontFamily = `"${activeFamily}", serif`;
sampleArea.style.fontSize = `${sizeRange.value}px`;
sampleArea.style.lineHeight = lineHeightRange.value;
sampleArea.style.fontWeight = "400";
sampleArea.style.fontStyle = "normal";
updateValueDisplays();
if (sampleHtml) {
sampleArea.innerHTML = sampleHtml;
}
}
function loadSampleText() {
fetch("assets/sample.html")
.then((r) => r.text())
.then((html) => {
sampleHtml = html;
renderPreview();
});
}
function setupScrollFade(list) {
function update() {
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 2;
list.classList.toggle("is-overflowing", !atBottom);
}
list.addEventListener("scroll", update);
extraFonts.addEventListener("toggle", update);
update();
}
function setupInteractions() {
sizeRange.addEventListener("input", renderPreview);
lineHeightRange.addEventListener("input", renderPreview);
fontSelect.addEventListener("change", (e) => setActiveFont(e.target.value));
darkModeToggle.addEventListener("change", (e) => {
reader.classList.toggle("is-dark", e.target.checked);
});
bezelToggle.addEventListener("change", (e) => {
reader.classList.toggle("is-bezel-dark", !e.target.checked);
});
if (fontListExtra) {
setupScrollFade(fontListExtra);
}
}
function syncToggles() {
darkModeToggle.checked = reader.classList.contains("is-dark");
bezelToggle.checked = !reader.classList.contains("is-bezel-dark");
}
function resetControls() {
const isDesktop = window.matchMedia("(min-width: 1051px)").matches;
sizeRange.value = isDesktop ? "22" : "20";
lineHeightRange.value = "1.45";
}
function init() {
buildFontFaces();
updateFontList();
activeFamily = preferredOrder.find((f) => fonts.has(f)) || fonts.keys().next().value;
setupInteractions();
resetControls();
syncToggles();
loadSampleText();
if (extraFonts) {
extraFonts.open = window.matchMedia("(max-width: 1050px)").matches;
}
setActiveFont(activeFamily);
}
init();

18
assets/sample.html Normal file
View File

@@ -0,0 +1,18 @@
<h3 class="chapter-title">Prologue</h3>
<p><em>It's funny,</em> Vasher thought, <em>how many things begin with my getting thrown into prison.</em></p>
<p>The guards laughed to one another, slamming the cell door shut with a clang. Vasher stood and dusted himself off, rolling his shoulder and wincing. While the bottom half of his cell door was solid wood, the top half was barred, and he could see the three guards open his large duffel and rifle through his possessions.</p>
<p>One of them noticed him watching. The guard was an oversized beast of a man with a shaved head and a dirty uniform that barely retained the bright yellow and blue coloring of the T'Telir city guard.</p>
<p><em>Bright colors,</em> Vasher thought. <em>I'll have to get used to those again.</em> In any other nation, the vibrant blues and yellows would have been ridiculous on soldiers. This, however, was Hallandren: land of Returned gods, Lifeless servants, BioChromatic research, and—of course—color.</p>
<p>The large guard sauntered up to the cell door, leaving his friends to amuse themselves with Vasher's belongings. "They say you're pretty tough," the man said, sizing up Vasher.</p>
<p>Vasher did not respond.</p>
<p>"The bartender says you beat down some twenty men in the brawl." The guard rubbed his chin. "You don't look that tough to me. Either way, you should have known better than to strike a priest. The others, they'll spend a night locked up. You, though&#8201;.&#8201;.&#8201;.&#8201;you'll hang. Colorless fool."</p>
<p>Vasher turned away. His cell was functional, if unoriginal. A thin slit at the top of one wall let in light, the stone walls dripped with water and moss, and a pile of dirty straw decomposed in the corner.</p>
<p>"You ignoring me?" the guard asked, stepping closer to the door. The colors of his uniform brightened, as if he'd stepped into a stronger light. The change was slight. Vasher didn't have much Breath remaining, and so his aura didn't do much to the colors around him. The guard didn't notice the change in color—just as he hadn't noticed back in the bar, when he and his buddies had picked Vasher up off the floor and thrown him in their cart. Of course, the change was so slight to the unaided eye that it would have been nearly impossible to pick out.</p>
<p>"Here, now," said one of the men looking through Vasher's duffel. "What's <em>this?</em>" Vasher had always found it interesting that the men who watched dungeons tended to be as bad as, or worse than, the men they guarded. Perhaps that was deliberate. Society didn't seem to care if such men were outside the cells or in them, so long as they were kept away from more honest men.</p>
<p>Assuming that such a thing existed.</p>
<p>From Vasher's bag, a guard pulled free a long object wrapped in white linen. The man whistled as he unwrapped the cloth, revealing a long, thin-bladed sword in a silver sheath. The hilt was pure black. "Who do you suppose he stole <em>this</em> from?"</p>
<p>The lead guard eyed Vasher, likely wondering if Vasher was some kind of nobleman. Though Hallandren had no aristocracy, many neighboring kingdoms had their lords and ladies. Yet what lord would wear a drab brown cloak, ripped in several places? What lord would sport bruises from a bar fight, a half-grown beard, and boots worn from years of walking? The guard turned away, apparently convinced that Vasher was no lord.</p>
<p>He was right. And he was wrong.</p>
<p>"Let me see that," the lead guard said, taking the sword. He grunted, obviously surprised by its weight. He turned it about, noting the clasp that tied sheath to hilt, keeping the blade from being drawn. He undid the clasp.</p>
<p>The colors in the room deepened. They didn't grow brighter—not the way the guard's vest had when he approached Vasher. Instead, they grew <em>stronger.</em> Darker. Reds became maroon. Yellows hardened to gold. Blues approached navy.</p>
<p>"Be careful, friend," Vasher said softly, "that sword can be dangerous."</p>

688
assets/styles.css Normal file
View File

@@ -0,0 +1,688 @@
:root {
color-scheme: light;
--ink: #171717;
--muted: #5b5b5b;
--accent: #a04c24;
--accent-soft: #f2d2c2;
--cta: #a04c24;
--cta-hover: #7f3b1b;
--surface: #fffdf8;
--panel: #ffffff;
--border: #e6ded6;
--shadow: 0 18px 40px rgba(29, 18, 10, 0.12);
--screen-width: 632px;
--screen-height: 840px;
--screen-bg: #f4efe6;
--screen-bg-dark: #1d1a17;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
color: var(--ink);
background: radial-gradient(circle at 15% 20%, #f6e9de 0%, #fff8f0 45%, #f6efe9 100%);
min-height: 100vh;
}
.page {
max-width: 1380px;
margin: 0 auto;
padding: 28px 16px 40px;
}
header {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 18px;
}
h1 {
font-size: clamp(2rem, 3.4vw, 3rem);
margin: 0;
letter-spacing: -0.02em;
}
header p {
margin: 0;
color: var(--muted);
font-size: 1.05rem;
max-width: 640px;
}
.layout {
display: grid;
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
gap: 28px;
align-items: center;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 20px;
padding: 18px;
box-shadow: var(--shadow);
}
label {
font-size: 1rem;
color: #3e3530;
text-transform: uppercase;
letter-spacing: 0.08em;
display: block;
margin-bottom: 6px;
padding-bottom: 6px;
font-weight: 600;
}
label .value {
float: right;
font-weight: 400;
color: var(--muted);
}
input[type="range"] {
width: 100%;
font: inherit;
border-radius: 12px;
border: 1px solid var(--border);
padding: 6px 10px;
background: var(--surface);
color: var(--ink);
accent-color: var(--accent);
}
select {
width: 100%;
font: inherit;
border-radius: 12px;
border: 1px solid var(--border);
padding: 10px 12px;
background: var(--surface);
color: var(--ink);
}
.sidebar {
display: flex;
flex-direction: column;
gap: 12px;
align-self: center;
}
.sidebar-controls {
display: flex;
flex-direction: column;
gap: 10px;
border-top: 1px solid var(--border);
padding-top: 16px;
}
.sidebar-controls > div {
padding-top: 0;
}
.sidebar-controls .control-group {
border-top: 1px solid var(--border);
padding-top: 14px;
margin-top: 2px;
}
.sidebar-actions-group {
border-top: 1px solid var(--border);
padding-top: 20px;
margin-top: 8px;
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.toggle-label {
font-size: 0.9rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
}
.toggle-input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
appearance: none;
}
.toggle-track {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
width: 170px;
padding: 9px 6px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
}
.toggle-option {
flex: 1;
text-align: center;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
position: relative;
z-index: 2;
user-select: none;
}
.toggle-thumb {
position: absolute;
top: 6px;
bottom: 6px;
left: 6px;
width: calc(50% - 8px);
border-radius: 999px;
background: var(--accent);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
transition: transform 0.2s ease;
}
.toggle-input:not(:checked) + .toggle-track .toggle-option--left {
color: #fff;
}
.toggle-input:checked + .toggle-track .toggle-option--right {
color: #fff;
}
.toggle-input:checked + .toggle-track .toggle-thumb {
transform: translateX(100%);
}
.badge {
padding: 6px 10px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.device-footer {
margin-top: 18px;
text-align: center;
color: #8a7f74;
font-size: 0.95rem;
}
.device-footer a {
color: var(--accent);
text-decoration: none;
font-weight: 600;
}
.device-footer a:hover {
color: var(--cta-hover);
}
.attribution {
margin-top: 4px;
font-size: 0.8rem;
}
.sidebar-actions {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
padding-top: 16px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 18px;
border-radius: 999px;
border: 1px solid var(--border);
background: #fff;
color: var(--ink);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
text-decoration: none;
gap: 8px;
}
.button svg {
width: 16px;
height: 16px;
}
.button--primary {
border-color: var(--cta);
background: var(--cta);
color: #fff;
}
.button--primary:hover {
background: var(--cta-hover);
border-color: var(--cta-hover);
}
.preview {
display: flex;
flex-direction: column;
gap: 18px;
padding: 0;
}
@media (min-width: 1051px) {
.preview {
min-height: calc(100vh - 68px);
justify-content: center;
}
}
.reader {
align-self: center;
width: min(100%, calc(var(--screen-width) + 56px));
background: #ffffff;
border-radius: 24px;
padding: 36px 30px 54px;
box-shadow: 0 22px 50px rgba(15, 10, 8, 0.28);
border: 2px solid #e7e1da;
position: relative;
max-height: 100vh;
}
.reader.is-bezel-dark {
background: #0f0f0f;
border-color: #111;
box-shadow: 0 22px 50px rgba(15, 10, 8, 0.32);
}
.reader-screen {
background: var(--screen-bg);
border-radius: 0;
padding: 22px;
width: 100%;
min-height: 520px;
height: min(var(--screen-height), calc(100vh - 140px));
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
gap: 18px;
overflow: hidden;
}
.reader.is-dark .reader-screen {
background: var(--screen-bg-dark);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.reader.is-dark .reader-toolbar,
.reader.is-dark .reader-footer {
color: #b8ada3;
}
.reader.is-dark .sample {
color: #f3ede7;
}
.reader.is-dark .chapter-title {
color: #d2c4b6;
}
.reader-toolbar {
display: flex;
justify-content: center;
align-items: center;
font-size: 0.85rem;
color: #6b6158;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.reader-meta {
font-weight: 600;
letter-spacing: 0.03em;
display: flex;
align-items: center;
gap: 10px;
}
.reader-dot {
width: 4px;
height: 4px;
border-radius: 999px;
background: currentColor;
opacity: 0.7;
}
.sample {
padding: 8px 6px 0;
min-height: 220px;
line-height: 1.45;
color: #1f1a16;
flex: 1;
overflow: hidden;
word-break: break-word;
overflow-wrap: anywhere;
position: relative;
}
.sample::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 56px;
background: linear-gradient(to bottom, rgba(244, 239, 230, 0), rgba(244, 239, 230, 1));
pointer-events: none;
}
.reader.is-dark .sample::after {
background: linear-gradient(to bottom, rgba(29, 26, 23, 0), rgba(29, 26, 23, 1));
}
.chapter-title {
margin: 0 0 2em;
padding-top: 0.5em;
text-align: center;
font-size: 1.5em;
color: #3a332c;
}
.sample p {
margin: 0;
text-indent: 1.5em;
}
.sample p:first-child {
text-indent: 0;
}
.reader-footer {
display: flex;
justify-content: center;
align-items: center;
font-size: 0.8rem;
color: #8a7f74;
padding-top: 4px;
margin-top: auto;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.font-list {
display: grid;
grid-template-columns: repeat(2, minmax(120px, 1fr));
gap: 8px;
max-height: calc(var(--screen-height) - 200px);
overflow: auto;
padding-right: 6px;
}
.desktop-only {
display: block;
}
.mobile-only {
display: none;
}
.font-sections {
display: flex;
flex-direction: column;
gap: 12px;
}
.quick-title {
font-size: 1rem;
color: #3e3530;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
padding-bottom: 6px;
font-weight: 600;
}
summary.quick-title {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
margin-bottom: 0;
padding-bottom: 0;
}
summary.quick-title::-webkit-details-marker {
display: none;
}
details .quick-title::after {
content: "Show";
font-size: 0.7rem;
letter-spacing: 0.12em;
color: var(--muted);
text-transform: uppercase;
margin-left: auto;
}
details .quick-title::before {
content: "";
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-left: 8px solid var(--muted);
transition: transform 0.2s ease;
}
details[open] .quick-title::after {
content: "Hide";
}
details[open] .quick-title::before {
transform: rotate(90deg);
}
details[open] .quick-title {
padding-bottom: 10px;
}
.quick-group + .quick-group {
border-top: 1px solid var(--border);
padding-top: 14px;
margin-top: 10px;
}
#extraFonts {
padding: 20px 0 10px;
}
#extraFonts > .font-list {
max-height: 280px;
overflow-y: scroll;
scrollbar-width: none;
}
#extraFonts > .font-list::-webkit-scrollbar {
display: none;
}
#extraFonts[open] > .font-list .scroll-hint {
display: none;
}
#extraFonts[open] > .font-list.is-overflowing .scroll-hint {
display: flex;
align-items: flex-end;
justify-content: center;
position: sticky;
bottom: 0;
height: 72px;
margin-top: -72px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 80%);
pointer-events: none;
font-size: 0.7rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
padding-bottom: 6px;
grid-column: 1 / -1;
}
.quick-group > .font-list {
margin-top: 10px;
}
.font-card {
display: flex;
align-items: center;
justify-content: center;
min-height: 42px;
border-radius: 14px;
border: 1px solid var(--border);
padding: 4px 8px;
background: #fff;
color: var(--ink);
cursor: pointer;
}
.font-card:hover {
border-color: var(--accent);
}
.font-card.is-active {
border-color: var(--accent);
background: var(--accent);
color: #fff;
box-shadow: inset 0 0 0 1px var(--accent);
}
.font-card p {
margin: 0;
font-size: 1.02rem;
line-height: 1;
}
.font-card.is-ninepoint p {
transform: translateY(1px);
}
.font-card.is-charis p {
transform: translateY(-1px);
}
@media (max-width: 1050px) {
:root {
--screen-width: 480px;
--screen-height: 640px;
}
.layout {
grid-template-columns: 1fr;
align-items: start;
}
.reader {
width: min(100%, 520px);
max-height: 100vh;
}
.reader-screen {
height: min(var(--screen-height), calc(100vh - 120px));
min-height: 420px;
}
.font-list {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
max-height: none;
overflow: visible;
padding-right: 0;
}
.desktop-only {
display: none;
}
.mobile-only {
display: block;
}
}
@media (max-height: 500px) and (orientation: landscape) {
:root {
--screen-height: 100vh;
}
.page {
padding-top: 12px;
padding-bottom: 12px;
}
.reader {
padding: 16px 16px 20px;
border-radius: 14px;
max-height: calc(100vh - 24px);
}
.reader-screen {
min-height: 0;
height: calc(100vh - 100px);
padding: 12px 16px;
gap: 8px;
}
.sample {
min-height: 0;
}
.reader-toolbar,
.reader-footer {
font-size: 0.75rem;
}
}
@media (max-width: 640px) {
:root {
--screen-width: 320px;
--screen-height: 500px;
}
.page {
padding-top: 20px;
}
.reader {
width: min(100%, 360px);
padding: 20px 16px 28px;
border-radius: 14px;
max-height: 100vh;
}
.reader-screen {
padding: 16px;
min-height: 500px;
height: min(var(--screen-height), calc(100vh - 120px));
}
}

922
index.php
View File

@@ -1,39 +1,6 @@
<?php
$fontRoot = __DIR__ . '/ebook-fonts/fonts';
$fontEntries = [];
$fontFamilies = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($fontRoot, FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $fileInfo) {
if (!$fileInfo->isFile()) {
continue;
}
if (strtolower($fileInfo->getExtension()) !== 'ttf') {
continue;
}
$relative = str_replace(__DIR__ . '/', '', $fileInfo->getPathname());
$relative = str_replace(DIRECTORY_SEPARATOR, '/', $relative);
$fileName = pathinfo($relative, PATHINFO_FILENAME);
$parts = explode('-', $fileName);
$rawFamily = count($parts) > 1 ? implode('-', array_slice($parts, 0, -1)) : $fileName;
$family = str_replace('_', ' ', $rawFamily);
if ($family === 'NV OpenDyslexic') {
continue;
}
$fontFamilies[$family][] = $relative;
$fontEntries[] = $relative;
}
sort($fontEntries, SORT_NATURAL | SORT_FLAG_CASE);
$fontFilesJson = json_encode($fontEntries, JSON_UNESCAPED_SLASHES);
if ($fontFilesJson === false) {
$fontFilesJson = '[]';
}
define('APP_ROOT', __DIR__);
require __DIR__ . '/web/load_fonts.php';
?>
<!DOCTYPE html>
<html lang="en">
@@ -47,617 +14,7 @@ if ($fontFilesJson === false) {
<meta property="og:type" content="website" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23a04c24'/%3E%3Ctext x='50%25' y='56%25' font-size='34' text-anchor='middle' fill='white' font-family='Georgia,serif'%3EAa%3C/text%3E%3C/svg%3E" />
<title>eBook Fonts Showcase</title>
<style>
:root {
color-scheme: light;
--ink: #171717;
--muted: #5b5b5b;
--accent: #a04c24;
--accent-soft: #f2d2c2;
--cta: #a04c24;
--cta-hover: #7f3b1b;
--surface: #fffdf8;
--panel: #ffffff;
--border: #e6ded6;
--shadow: 0 18px 40px rgba(29, 18, 10, 0.12);
--screen-width: 632px;
--screen-height: 840px;
--screen-bg: #f4efe6;
--screen-bg-dark: #1d1a17;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
color: var(--ink);
background: radial-gradient(circle at 15% 20%, #f6e9de 0%, #fff8f0 45%, #f6efe9 100%);
min-height: 100vh;
}
.page {
max-width: 1380px;
margin: 0 auto;
padding: 28px 16px 40px;
}
header {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 18px;
}
h1 {
font-size: clamp(2rem, 3.4vw, 3rem);
margin: 0;
letter-spacing: -0.02em;
}
header p {
margin: 0;
color: var(--muted);
font-size: 1.05rem;
max-width: 640px;
}
.layout {
display: grid;
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
gap: 28px;
align-items: center;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 20px;
padding: 18px;
box-shadow: var(--shadow);
}
label {
font-size: 1rem;
color: #3e3530;
text-transform: uppercase;
letter-spacing: 0.08em;
display: block;
margin-bottom: 6px;
padding-bottom: 6px;
font-weight: 600;
}
input[type="range"] {
width: 100%;
font: inherit;
border-radius: 12px;
border: 1px solid var(--border);
padding: 6px 10px;
background: var(--surface);
color: var(--ink);
accent-color: var(--accent);
}
select {
width: 100%;
font: inherit;
border-radius: 12px;
border: 1px solid var(--border);
padding: 10px 12px;
background: var(--surface);
color: var(--ink);
}
.sidebar {
display: flex;
flex-direction: column;
gap: 12px;
align-self: center;
}
.sidebar-controls {
display: flex;
flex-direction: column;
gap: 10px;
border-top: 1px solid var(--border);
padding-top: 16px;
}
.sidebar-controls > div {
padding-top: 0;
}
.sidebar-controls .control-group {
border-top: 1px solid var(--border);
padding-top: 14px;
margin-top: 2px;
}
.sidebar-actions-group {
border-top: 1px solid var(--border);
padding-top: 20px;
margin-top: 8px;
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.toggle-label {
font-size: 0.9rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
}
.toggle-input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
appearance: none;
}
.toggle-track {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
width: 170px;
padding: 9px 6px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
}
.toggle-option {
flex: 1;
text-align: center;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
position: relative;
z-index: 2;
user-select: none;
}
.toggle-thumb {
position: absolute;
top: 6px;
bottom: 6px;
left: 6px;
width: calc(50% - 8px);
border-radius: 999px;
background: var(--accent);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
transition: transform 0.2s ease;
}
.toggle-input:not(:checked) + .toggle-track .toggle-option--left {
color: #fff;
}
.toggle-input:checked + .toggle-track .toggle-option--right {
color: #fff;
}
.toggle-input:checked + .toggle-track .toggle-thumb {
transform: translateX(100%);
}
.badge {
padding: 6px 10px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.device-footer {
margin-top: 18px;
text-align: center;
color: #8a7f74;
font-size: 0.95rem;
}
.device-footer a {
color: var(--accent);
text-decoration: none;
font-weight: 600;
}
.device-footer a:hover {
color: var(--cta-hover);
}
.sidebar-actions {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
padding-top: 16px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 18px;
border-radius: 999px;
border: 1px solid var(--border);
background: #fff;
color: var(--ink);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
text-decoration: none;
gap: 8px;
}
.button svg {
width: 16px;
height: 16px;
}
.button--primary {
border-color: var(--cta);
background: var(--cta);
color: #fff;
}
.button--primary:hover {
background: var(--cta-hover);
border-color: var(--cta-hover);
}
.preview {
display: flex;
flex-direction: column;
gap: 18px;
padding: 0;
}
@media (min-width: 1051px) {
.preview {
min-height: calc(100vh - 68px);
justify-content: center;
}
}
.reader {
align-self: center;
width: min(100%, calc(var(--screen-width) + 56px));
background: #ffffff;
border-radius: 24px;
padding: 36px 30px 54px;
box-shadow: 0 22px 50px rgba(15, 10, 8, 0.28);
border: 2px solid #e7e1da;
position: relative;
max-height: 100vh;
}
.reader.is-bezel-dark {
background: #0f0f0f;
border-color: #111;
box-shadow: 0 22px 50px rgba(15, 10, 8, 0.32);
}
.reader-screen {
background: var(--screen-bg);
border-radius: 0;
padding: 22px;
width: 100%;
min-height: 520px;
height: min(var(--screen-height), calc(100vh - 140px));
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
gap: 18px;
overflow: hidden;
}
.reader.is-dark .reader-screen {
background: var(--screen-bg-dark);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.reader.is-dark .reader-toolbar,
.reader.is-dark .reader-footer {
color: #b8ada3;
}
.reader.is-dark .sample {
color: #f3ede7;
}
.reader.is-dark .chapter-title {
color: #d2c4b6;
}
.reader-toolbar {
display: flex;
justify-content: center;
align-items: center;
font-size: 0.85rem;
color: #6b6158;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.reader-meta {
font-weight: 600;
letter-spacing: 0.03em;
display: flex;
align-items: center;
gap: 10px;
}
.reader-dot {
width: 4px;
height: 4px;
border-radius: 999px;
background: currentColor;
opacity: 0.7;
}
.sample {
padding: 8px 6px 0;
min-height: 220px;
line-height: 1.45;
color: #1f1a16;
flex: 1;
overflow: hidden;
word-break: break-word;
overflow-wrap: anywhere;
position: relative;
}
.sample::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 56px;
background: linear-gradient(to bottom, rgba(244, 239, 230, 0), rgba(244, 239, 230, 1));
pointer-events: none;
}
.reader.is-dark .sample::after {
background: linear-gradient(to bottom, rgba(29, 26, 23, 0), rgba(29, 26, 23, 1));
}
.chapter-title {
margin: 0 0 2em;
padding-top: 0.5em;
text-align: center;
font-size: 1.4em;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #3a332c;
}
.sample p {
margin: 0;
text-indent: 1.5em;
}
.sample p:first-child {
text-indent: 0;
}
.reader-footer {
display: flex;
justify-content: center;
align-items: center;
font-size: 0.8rem;
color: #8a7f74;
padding-top: 4px;
margin-top: auto;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.font-list {
display: grid;
grid-template-columns: repeat(2, minmax(120px, 1fr));
gap: 8px;
max-height: calc(var(--screen-height) - 200px);
overflow: auto;
padding-right: 6px;
}
.desktop-only {
display: block;
}
.mobile-only {
display: none;
}
.font-sections {
display: flex;
flex-direction: column;
gap: 12px;
}
.quick-title {
font-size: 1rem;
color: #3e3530;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
padding-bottom: 6px;
font-weight: 600;
}
summary.quick-title {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
margin-bottom: 0;
padding-bottom: 0;
}
summary.quick-title::-webkit-details-marker {
display: none;
}
details .quick-title::after {
content: "Show";
font-size: 0.7rem;
letter-spacing: 0.12em;
color: var(--muted);
text-transform: uppercase;
margin-left: auto;
}
details .quick-title::before {
content: "";
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-left: 8px solid var(--muted);
transition: transform 0.2s ease;
}
details[open] .quick-title::after {
content: "Hide";
}
details[open] .quick-title::before {
transform: rotate(90deg);
}
.quick-group + .quick-group {
border-top: 1px solid var(--border);
padding-top: 14px;
margin-top: 10px;
}
#extraFonts {
padding: 20px 0 10px;
}
.quick-group > .font-list {
margin-top: 10px;
}
.font-card {
display: flex;
align-items: center;
justify-content: center;
min-height: 42px;
border-radius: 14px;
border: 1px solid var(--border);
padding: 4px 8px;
background: #fff;
cursor: pointer;
}
.font-card:hover {
border-color: var(--accent);
}
.font-card.is-active {
border-color: var(--accent);
background: var(--accent);
color: #fff;
box-shadow: inset 0 0 0 1px var(--accent);
}
.font-card p {
margin: 0;
font-size: 1.02rem;
line-height: 1;
}
.font-card.is-ninepoint p {
transform: translateY(1px);
}
.font-card.is-charis p {
transform: translateY(-1px);
}
@media (max-width: 1050px) {
:root {
--screen-width: 480px;
--screen-height: 640px;
}
.layout {
grid-template-columns: 1fr;
align-items: start;
}
.reader {
width: min(100%, 520px);
max-height: 100vh;
}
.reader-screen {
height: min(var(--screen-height), calc(100vh - 120px));
min-height: 420px;
}
.font-list {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
max-height: none;
overflow: visible;
padding-right: 0;
}
.desktop-only {
display: none;
}
.mobile-only {
display: block;
}
}
@media (max-width: 640px) {
:root {
--screen-width: 320px;
--screen-height: 500px;
}
.page {
padding-top: 20px;
}
.reader {
width: min(100%, 360px);
padding: 20px 16px 28px;
border-radius: 14px;
max-height: 100vh;
}
.reader-screen {
padding: 16px;
min-height: 500px;
height: min(var(--screen-height), calc(100vh - 120px));
}
}
</style>
<link rel="stylesheet" href="assets/styles.css">
</head>
<body>
<div class="page">
@@ -681,9 +38,13 @@ if ($fontFilesJson === false) {
</div>
<div class="sidebar-controls">
<div>
<label for="sizeRange">Preview size</label>
<label for="sizeRange">Font size <span class="value" id="sizeValue"></span></label>
<input id="sizeRange" type="range" min="14" max="42" value="20" />
</div>
<div>
<label for="lineHeightRange">Line height <span class="value" id="lineHeightValue"></span></label>
<input id="lineHeightRange" type="range" min="1.0" max="2.2" step="0.05" value="1.45" />
</div>
<div class="control-group">
<div class="toggle-row">
<span class="toggle-label">Screen</span>
@@ -703,8 +64,8 @@ if ($fontFilesJson === false) {
<div class="toggle-group">
<input id="bezelToggle" class="toggle-input" type="checkbox" />
<label for="bezelToggle" class="toggle-track" aria-label="Bezel color">
<span class="toggle-option toggle-option--left">White</span>
<span class="toggle-option toggle-option--right">Black</span>
<span class="toggle-option toggle-option--left">Black</span>
<span class="toggle-option toggle-option--right">White</span>
<span class="toggle-thumb"></span>
</label>
</div>
@@ -730,275 +91,34 @@ if ($fontFilesJson === false) {
</section>
<section class="preview">
<div class="reader">
<div class="reader is-bezel-dark">
<div class="reader-screen">
<div class="reader-toolbar">
<span class="reader-meta">
<span id="readerChapter">Chapter 1</span>
<span>Prologue</span>
<span class="reader-dot" aria-hidden="true"></span>
<span id="readerChapterProgress">1 OF 17</span>
<span>1 OF 58</span>
</span>
</div>
<div class="sample" id="sampleArea"></div>
<div class="reader-footer">
<span class="reader-meta">
<span id="readerBook">Pride and Prejudice</span>
<span>Warbreaker</span>
<span class="reader-dot" aria-hidden="true"></span>
<span id="readerBookProgress">1 OF 256</span>
<span>1 OF 592</span>
</span>
</div>
</div>
</div>
<div class="device-footer">Made with &hearts; for digital reading by <a href="https://nicoverbruggen.be" target="_blank" rel="noreferrer">Nico Verbruggen</a>.</div>
<div class="device-footer">
<p>Made with &hearts; for digital reading by <a href="https://nicoverbruggen.be" target="_blank" rel="noreferrer">Nico Verbruggen</a>.</p>
<p class="attribution">Preview text from <a href="https://www.brandonsanderson.com/blogs/blog/warbreaker-rights-explanation"><em>Warbreaker</em></a> by Brandon Sanderson, used under <a href="https://creativecommons.org/licenses/by-nc-nd/3.0/us/" target="_blank" rel="noreferrer">Creative Commons</a>.</p>
</div>
</section>
</div>
</div>
<script>
const fontFiles = <?php echo $fontFilesJson; ?>;
const sizeRange = document.getElementById("sizeRange");
const sampleArea = document.getElementById("sampleArea");
const fontSelect = document.getElementById("fontSelect");
const fontListCore = document.getElementById("fontListCore");
const fontListExtra = document.getElementById("fontListExtra");
const extraFonts = document.getElementById("extraFonts");
const darkModeToggle = document.getElementById("darkModeToggle");
const bezelToggle = document.getElementById("bezelToggle");
const reader = document.querySelector(".reader");
const readerChapter = document.getElementById("readerChapter");
const readerChapterProgress = document.getElementById("readerChapterProgress");
const readerBook = document.getElementById("readerBook");
const readerBookProgress = document.getElementById("readerBookProgress");
const fonts = new Map();
const sampleText = `Chapter 1
It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.
However little known the feelings or views of such a man may be on his first entering a neighbourhood, this truth is so well fixed in the minds of the surrounding families, that he is considered the rightful property of some one or other of their daughters.
"My dear Mr. Bennet," said his lady to him one day, "have you heard that Netherfield Park is let at last?"
Mr. Bennet replied that he had not.
"But it is," returned she; "for Mrs. Long has just been here, and she told me all about it."
Mr. Bennet made no answer.
"Do you not want to know who has taken it?" cried his wife impatiently.
"You want to tell me, and I have no objection to hearing it."
This was invitation enough.
"Why, my dear, you must know, Mrs. Long says that Netherfield is taken by a young man of large fortune from the north of England; that he came down on Monday in a chaise and four to see the place, and was so much delighted with it that he agreed with Mr. Morris immediately; that he is to take possession before Michaelmas, and some of his servants are to be in the house by the end of next week."
"What is his name?"
"Bingley."
"Is he married or single?"
"Oh! Single, my dear, to be sure! A single man of large fortune; four or five thousand a year. What a fine thing for our girls!"
"How so? how can it affect them?"
"My dear Mr. Bennet," replied his wife, "how can you be so tiresome! You must know that I am thinking of his marrying one of them."
"Is that his design in settling here?"
"Design! nonsense, how can you talk so! But it is very likely that he may fall in love with one of them, and therefore you must visit him as soon as he comes."
"I see no occasion for that. You and the girls may go, or you may send them by themselves, which perhaps will be still better, for as you are as handsome as any of them, Mr. Bingley may like you the best of the party."
"My dear, you flatter me. I certainly have had my share of beauty, but I do not pretend to be anything extraordinary now."`;
function parseFont(file) {
const fileName = file.split("/").pop().replace(".ttf", "");
const parts = fileName.split("-");
const rawFamily = parts.slice(0, -1).join("-") || fileName;
const rawStyle = parts.length > 1 ? parts[parts.length - 1] : "Regular";
const family = rawFamily.replace(/_/g, " ");
const styleToken = rawStyle || "Regular";
const isBold = styleToken.includes("Bold");
const isItalic = styleToken.includes("Italic");
const weight = isBold ? 700 : 400;
const style = isItalic ? "italic" : "normal";
const styleKey = `${isBold ? "bold" : "regular"}${isItalic ? "italic" : ""}`;
return {
file,
family,
weight,
style,
styleKey,
collection: file.includes("/core/") ? "Core" : "Extra"
};
}
function buildFontFaces() {
const styleEl = document.createElement("style");
const rules = [];
fontFiles.forEach((file) => {
const info = parseFont(file);
if (!fonts.has(info.family)) {
fonts.set(info.family, {
family: info.family,
collection: info.collection,
files: []
});
}
fonts.get(info.family).files.push(info);
rules.push(`@font-face {\n font-family: "${info.family}";\n src: url("./${info.file}") format("truetype");\n font-weight: ${info.weight};\n font-style: ${info.style};\n font-display: swap;\n}`);
});
styleEl.textContent = rules.join("\n");
document.head.appendChild(styleEl);
}
function updateFontList(filter) {
fontListCore.innerHTML = "";
fontListExtra.innerHTML = "";
fontSelect.innerHTML = "";
const preferredOrder = [
"NV NinePoint",
"NV Charis",
"NV Garamond",
"NV Jost"
];
const items = [...fonts.values()]
.filter((font) => font.family.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => {
const aIndex = preferredOrder.indexOf(a.family);
const bIndex = preferredOrder.indexOf(b.family);
if (aIndex !== -1 || bIndex !== -1) {
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
}
return a.family.localeCompare(b.family);
});
const coreGroup = document.createElement("optgroup");
coreGroup.label = "Core Collection";
const extraGroup = document.createElement("optgroup");
extraGroup.label = "Extra Collection";
items.forEach((font) => {
const card = document.createElement("button");
card.type = "button";
card.className = "font-card";
if (font.family === "NV NinePoint") {
card.classList.add("is-ninepoint");
}
if (font.family === "NV Charis") {
card.classList.add("is-charis");
}
card.style.fontFamily = `"${font.family}", serif`;
card.innerHTML = `<p>${font.family}</p>`;
card.addEventListener("click", () => {
setActiveFont(font.family);
});
if (font.collection === "Core") {
fontListCore.appendChild(card);
} else {
fontListExtra.appendChild(card);
}
const option = document.createElement("option");
option.value = font.family;
option.textContent = font.family;
if (font.collection === "Core") {
coreGroup.appendChild(option);
} else {
extraGroup.appendChild(option);
}
});
fontSelect.appendChild(coreGroup);
fontSelect.appendChild(extraGroup);
}
function setActiveFont(family) {
activeFamily = family;
renderPreview();
const cards = document.querySelectorAll(".font-card");
cards.forEach((card) => {
card.classList.toggle("is-active", card.textContent.trim() === family);
});
if (fontSelect.value !== family) {
fontSelect.value = family;
}
}
function renderPreview() {
const family = activeFamily;
const info = fonts.get(family);
sampleArea.style.fontFamily = `"${family}", serif`;
sampleArea.style.fontSize = `${sizeRange.value}px`;
sampleArea.style.fontWeight = "400";
sampleArea.style.fontStyle = "normal";
const paragraphs = sampleText
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean);
const rendered = paragraphs
.map((paragraph, index) => {
const safeText = paragraph.replace(/\n/g, " ");
if (index === 0 && /^chapter\s+\d+/i.test(safeText)) {
return `<h3 class="chapter-title">${safeText}</h3>`;
}
return `<p>${safeText}</p>`;
})
.join("");
sampleArea.innerHTML = rendered;
readerChapter.textContent = "Chapter 1";
readerChapterProgress.textContent = "1 OF 17";
readerBook.textContent = "Pride and Prejudice";
readerBookProgress.textContent = "1 OF 256";
}
function setupInteractions() {
sizeRange.addEventListener("input", renderPreview);
fontSelect.addEventListener("change", (event) => {
setActiveFont(event.target.value);
});
darkModeToggle.addEventListener("change", (event) => {
reader.classList.toggle("is-dark", event.target.checked);
});
bezelToggle.addEventListener("change", (event) => {
reader.classList.toggle("is-bezel-dark", event.target.checked);
});
window.addEventListener("resize", renderPreview);
}
function syncToggles() {
darkModeToggle.checked = reader.classList.contains("is-dark");
bezelToggle.checked = reader.classList.contains("is-bezel-dark");
}
let activeFamily = "NV NinePoint";
buildFontFaces();
updateFontList("");
setupInteractions();
if (window.matchMedia("(min-width: 1051px)").matches) {
sizeRange.value = "22";
}
syncToggles();
if (extraFonts) {
extraFonts.open = window.matchMedia("(max-width: 1050px)").matches;
}
setActiveFont(activeFamily);
if (fontSelect.value !== activeFamily) {
fontSelect.value = activeFamily;
}
</script>
<script>const fontFiles = <?php echo $fontFilesJson; ?>;</script>
<script src="assets/app.js"></script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
[phases.setup]
nixPkgs = ["php84"]
cmds = ["git clone --branch v3.x --depth 1 https://github.com/nicoverbruggen/ebook-fonts.git ./ebook-fonts"]
nixPkgs = ["php85"]
cmds = ["git clone --branch v3.x --depth 1 https://github.com/nicoverbruggen/ebook-fonts.git ./repos/ebook-fonts"]
[start]
cmd = "php -S 0.0.0.0:${PORT:-8080} -t ."

33
web/load_fonts.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
if (!defined('APP_ROOT')) {
http_response_code(403);
exit;
}
$fontRoot = APP_ROOT . '/repos/ebook-fonts/fonts';
$fontEntries = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($fontRoot, FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $fileInfo) {
if (!$fileInfo->isFile()) {
continue;
}
if (strtolower($fileInfo->getExtension()) !== 'ttf') {
continue;
}
if (str_contains($fileInfo->getFilename(), 'NV_OpenDyslexic')) {
continue;
}
$relative = str_replace(APP_ROOT . '/', '', $fileInfo->getPathname());
$relative = str_replace(DIRECTORY_SEPARATOR, '/', $relative);
$fontEntries[] = $relative;
}
sort($fontEntries, SORT_NATURAL | SORT_FLAG_CASE);
$fontFilesJson = json_encode($fontEntries, JSON_UNESCAPED_SLASHES);
if ($fontFilesJson === false) {
$fontFilesJson = '[]';
}