Add multilingual PDF font system with Noto Sans and on-demand CJK packages

Bundle Noto Sans (Regular/Bold/Italic/BoldItalic) under resources/static/fonts/ as the default PDF face — it covers Latin, Cyrillic, Greek, Arabic, Thai and Hindi out of the box, replacing the limited DejaVu Sans fallback. Move all @font-face declarations into app.pdf.partials.fonts and include it from every invoice/estimate/payment/report template, dropping per-template font-family hardcodes and the conditional Thai locale include.

Introduce FontService + FontController to download static Noto Sans CJK packages (zh, zh_CN, ja, ko) from life888888/cjk-fonts-ttf on demand. GeneratesPdfTrait::ensureFontsForLocale primes the family before rendering and the partial emits @font-face rules for installed packages so dompdf resolves them through standard CSS — no separate registerFont() instance required. Static TTFs are mandatory because dompdf's PHP-Font-Lib does not parse variable fonts (fvar/gvar tables), which is why Google Fonts' NotoSansTC[wght].ttf rendered empty boxes.

Expose status/install via /api/v1/fonts/status and /api/v1/fonts/{package}/install with matching FONTS_STATUS / FONTS_INSTALL constants in scripts-v2/api/endpoints.ts. Flip DOMPDF_ENABLE_REMOTE default to true for remote asset loading.
This commit is contained in:
Darko Gjorgjijoski
2026-04-06 23:32:00 +02:00
parent 346e5df7ee
commit ba5c6c39ba
24 changed files with 401 additions and 47 deletions

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\FontService;
use Illuminate\Http\JsonResponse;
class FontController extends Controller
{
public function __construct(
private readonly FontService $fontService,
) {}
public function status(): JsonResponse
{
$this->authorize('manage settings');
return response()->json([
'packages' => $this->fontService->getPackageStatuses(),
]);
}
public function install(string $package): JsonResponse
{
$this->authorize('manage settings');
if (! isset(FontService::FONT_PACKAGES[$package])) {
return response()->json(['error' => 'Unknown font package'], 404);
}
$pkg = FontService::FONT_PACKAGES[$package];
if ($this->fontService->isInstalled($pkg)) {
return response()->json(['success' => true, 'message' => 'Already installed']);
}
try {
$this->fontService->downloadPackage($pkg);
return response()->json([
'success' => true,
'installed' => true,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
}

View File

@@ -0,0 +1,260 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
class FontService
{
/**
* Font packages available for on-demand download.
*
* Each package downloads STATIC TrueType fonts (not variable fonts) — dompdf's
* PHP-Font-Lib does not support variable fonts (`fvar`/`gvar` tables), which is
* why Google Fonts' `NotoSansTC[wght].ttf` produces empty boxes when registered.
* Source: https://github.com/life888888/cjk-fonts-ttf — official Adobe Source Han
* Sans / Noto Sans CJK rebuilt as static TTF in Regular + Bold weights.
*/
public const FONT_PACKAGES = [
'noto-sans-sc' => [
'name' => 'Noto Sans Simplified Chinese',
'family' => 'NotoSansCJKsc',
'locales' => ['zh_CN'],
'size' => '~32MB',
'files' => [
[
'file' => 'NotoSansCJKsc-Regular.ttf',
'url' => 'https://github.com/life888888/cjk-fonts-ttf/releases/download/v0.1.0/NotoSansCJKsc-Regular.ttf',
'weight' => 'normal',
'style' => 'normal',
],
[
'file' => 'NotoSansCJKsc-Bold.ttf',
'url' => 'https://github.com/life888888/cjk-fonts-ttf/releases/download/v0.1.0/NotoSansCJKsc-Bold.ttf',
'weight' => 'bold',
'style' => 'normal',
],
],
],
'noto-sans-tc' => [
'name' => 'Noto Sans Traditional Chinese',
'family' => 'NotoSansCJKtc',
'locales' => ['zh'],
'size' => '~32MB',
'files' => [
[
'file' => 'NotoSansCJKtc-Regular.ttf',
'url' => 'https://github.com/life888888/cjk-fonts-ttf/releases/download/v0.1.0/NotoSansCJKtc-Regular.ttf',
'weight' => 'normal',
'style' => 'normal',
],
[
'file' => 'NotoSansCJKtc-Bold.ttf',
'url' => 'https://github.com/life888888/cjk-fonts-ttf/releases/download/v0.1.0/NotoSansCJKtc-Bold.ttf',
'weight' => 'bold',
'style' => 'normal',
],
],
],
'noto-sans-jp' => [
'name' => 'Noto Sans Japanese',
'family' => 'NotoSansCJKjp',
'locales' => ['ja'],
'size' => '~32MB',
'files' => [
[
'file' => 'NotoSansCJKjp-Regular.ttf',
'url' => 'https://github.com/life888888/cjk-fonts-ttf/releases/download/v0.1.0/NotoSansCJKjp-Regular.ttf',
'weight' => 'normal',
'style' => 'normal',
],
[
'file' => 'NotoSansCJKjp-Bold.ttf',
'url' => 'https://github.com/life888888/cjk-fonts-ttf/releases/download/v0.1.0/NotoSansCJKjp-Bold.ttf',
'weight' => 'bold',
'style' => 'normal',
],
],
],
'noto-sans-kr' => [
'name' => 'Noto Sans Korean',
'family' => 'NotoSansCJKkr',
'locales' => ['ko'],
'size' => '~32MB',
'files' => [
[
'file' => 'NotoSansCJKkr-Regular.ttf',
'url' => 'https://github.com/life888888/cjk-fonts-ttf/releases/download/v0.1.0/NotoSansCJKkr-Regular.ttf',
'weight' => 'normal',
'style' => 'normal',
],
[
'file' => 'NotoSansCJKkr-Bold.ttf',
'url' => 'https://github.com/life888888/cjk-fonts-ttf/releases/download/v0.1.0/NotoSansCJKkr-Bold.ttf',
'weight' => 'bold',
'style' => 'normal',
],
],
],
];
/**
* Check if a locale requires an on-demand font download.
*/
public function needsDownload(string $locale): bool
{
$package = $this->getPackageForLocale($locale);
return $package && ! $this->isInstalled($package);
}
/**
* Ensure fonts are available for the given locale.
* Downloads synchronously if not already installed (blocking fallback).
*/
public function ensureFontsForLocale(string $locale): void
{
$package = $this->getPackageForLocale($locale);
if (! $package) {
return;
}
if (! $this->isInstalled($package)) {
$this->downloadPackage($package);
}
}
/**
* Check if a font package is installed (all files present).
*/
public function isInstalled(array $package): bool
{
foreach ($package['files'] as $entry) {
if (! File::exists(storage_path('fonts/'.$entry['file']))) {
return false;
}
}
return true;
}
/**
* Download a font package to storage/fonts/.
*/
public function downloadPackage(array $package): void
{
$fontsDir = storage_path('fonts');
if (! File::isDirectory($fontsDir)) {
File::makeDirectory($fontsDir, 0755, true);
}
foreach ($package['files'] as $entry) {
$targetPath = $fontsDir.'/'.$entry['file'];
if (File::exists($targetPath)) {
continue;
}
$response = Http::timeout(180)->get($entry['url']);
if ($response->successful()) {
File::put($targetPath, $response->body());
}
}
}
/**
* Get the status of all font packages (for the admin UI).
*/
public function getPackageStatuses(): array
{
$statuses = [];
foreach (self::FONT_PACKAGES as $key => $package) {
$statuses[] = [
'key' => $key,
'name' => $package['name'],
'family' => $package['family'],
'locales' => $package['locales'],
'size' => $package['size'],
'installed' => $this->isInstalled($package),
];
}
return $statuses;
}
/**
* Generate @font-face CSS rules for all installed on-demand fonts.
* Used by the PDF fonts partial so dompdf can resolve CJK families
* via standard CSS — no separate registerFont() dance required.
*/
public function getInstalledFontFaces(): array
{
$faces = [];
$fontsDir = storage_path('fonts');
foreach (self::FONT_PACKAGES as $package) {
if (! $this->isInstalled($package)) {
continue;
}
foreach ($package['files'] as $entry) {
$filePath = $fontsDir.'/'.$entry['file'];
$faces[] = "@font-face {
font-family: '{$package['family']}';
font-style: {$entry['style']};
font-weight: {$entry['weight']};
src: url(\"{$filePath}\") format('truetype');
}";
}
}
return $faces;
}
/**
* Get the primary font family for the current locale.
*
* DomPDF doesn't support font-family fallback for missing glyphs —
* it uses the first font for ALL characters. So CJK locales must
* use the CJK font as the primary, not as a fallback.
*/
public function getFontFamilyForLocale(?string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
// Check if this locale has an installed on-demand font
$package = $this->getPackageForLocale($locale);
if ($package && $this->isInstalled($package)) {
return '"'.$package['family'].'"';
}
return '"NotoSans"';
}
/**
* Get the full font-family CSS value for the current locale.
*/
public function getFontFamilyChain(?string $locale = null): string
{
$primary = $this->getFontFamilyForLocale($locale);
return $primary.', "NotoSans", "DejaVu Sans", sans-serif';
}
private function getPackageForLocale(string $locale): ?array
{
foreach (self::FONT_PACKAGES as $package) {
if (in_array($locale, $package['locales'])) {
return $package;
}
}
return null;
}
}

View File

@@ -6,6 +6,7 @@ use App\Models\Address;
use App\Models\CompanySetting;
use App\Models\FileDisk;
use App\Models\Setting;
use App\Services\FontService;
use Carbon\Carbon;
use Illuminate\Support\Facades\App;
@@ -24,6 +25,7 @@ trait GeneratesPdfTrait
$locale = CompanySetting::getSetting('language', $this->company_id);
App::setLocale($locale);
app(FontService::class)->ensureFontsForLocale($locale);
$pdf = $this->getPDFData();
@@ -78,6 +80,7 @@ trait GeneratesPdfTrait
$locale = CompanySetting::getSetting('language', $this->company_id);
App::setLocale($locale);
app(FontService::class)->ensureFontsForLocale($locale);
$pdf = $this->getPDFData();

View File

@@ -227,7 +227,7 @@ return [
*
* @var bool
*/
'enable_remote' => env('DOMPDF_ENABLE_REMOTE', false),
'enable_remote' => env('DOMPDF_ENABLE_REMOTE', true),
/**
* A ratio applied to the fonts height to be more like browsers' line height

View File

@@ -889,6 +889,7 @@
"update_app": "Update App",
"backup": "Backup",
"file_disk": "File Disk",
"fonts": "Fonts",
"custom_fields": "Custom Fields",
"payment_modes": "Payment Modes",
"notes": "Record Notes",
@@ -1350,6 +1351,18 @@
"december_november": "December - November"
}
},
"fonts": {
"title": "PDF Fonts",
"description": "Manage fonts used for PDF generation. Noto Sans is bundled and covers most languages. Additional font packages can be installed for CJK languages.",
"bundled_info": "Noto Sans (bundled) covers Latin, Cyrillic, Greek, Arabic, Thai, Hindi, and most other scripts. Install additional packages below for CJK language support.",
"installed": "Installed",
"install": "Install",
"downloading": "Downloading...",
"download_started": "Downloading {name}. This may take a moment.",
"download_complete": "{name} installed successfully. PDFs will now use this font.",
"download_failed": "Failed to download {name}. Please try again.",
"no_packages": "No additional font packages available."
},
"update_app": {
"title": "Update App",
"description": "You can easily update InvoiceShelf by checking for a new update by clicking the button below",

View File

@@ -122,6 +122,10 @@ export const API = {
BACKUPS: '/api/v1/backups',
DOWNLOAD_BACKUP: '/api/v1/download-backup',
// Fonts
FONTS_STATUS: '/api/v1/fonts/status',
FONTS_INSTALL: '/api/v1/fonts',
// Exchange Rates & Currencies
CURRENCIES: '/api/v1/currencies',
CURRENCIES_USED: '/api/v1/currencies/used',

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -5,10 +5,11 @@
<title>@lang('pdf_estimate_label') - {{ $estimate->estimate_number }}</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
@include("app.pdf.partials.fonts")
<style type="text/css">
/* -- Base -- */
body {
font-family: "DejaVu Sans";
}
html {
@@ -387,9 +388,6 @@
</style>
@if (App::isLocale('th'))
@include('app.pdf.locale.th')
@endif
</head>
<body>

View File

@@ -4,10 +4,11 @@
<head>
<title>@lang('pdf_estimate_label') - {{ $estimate->estimate_number }}</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
@include("app.pdf.partials.fonts")
<style type="text/css">
/* -- Base -- */
body {
font-family: "DejaVu Sans";
}
html {
@@ -77,7 +78,6 @@
font-size: 24px;
line-height: 36px;
text-align: right;
font-family: "DejaVu Sans";
}
.estimate-details-container h4 {
@@ -409,9 +409,6 @@
</style>
@if (App::isLocale('th'))
@include('app.pdf.locale.th')
@endif
</head>
<body>

View File

@@ -5,10 +5,11 @@
<title>@lang('pdf_estimate_label') - {{ $estimate->estimate_number }}</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
@include("app.pdf.partials.fonts")
<style type="text/css">
/* -- Base -- */
body {
font-family: "DejaVu Sans";
}
html {
@@ -347,9 +348,6 @@
</style>
@if (App::isLocale('th'))
@include('app.pdf.locale.th')
@endif
</head>
<body>

View File

@@ -5,10 +5,11 @@
<title>@lang('pdf_invoice_label') - {{ $invoice->invoice_number }}</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
@include("app.pdf.partials.fonts")
<style type="text/css">
/* -- Base -- */
body {
font-family: "DejaVu Sans";
}
html {

View File

@@ -4,10 +4,11 @@
<head>
<title>@lang('pdf_invoice_label') - {{ $invoice->invoice_number }}</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
@include("app.pdf.partials.fonts")
<style type="text/css">
/* -- Base -- */
body {
font-family: "DejaVu Sans";
}
html {
@@ -379,9 +380,6 @@
</style>
@if (App::isLocale('th'))
@include('app.pdf.locale.th')
@endif
</head>
<body>

View File

@@ -5,11 +5,12 @@
<title>@lang('pdf_invoice_label') - {{ $invoice->invoice_number }}</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
@include("app.pdf.partials.fonts")
<style type="text/css">
/* -- Base -- */
body {
font-family: "DejaVu Sans";
}
html {
@@ -308,9 +309,6 @@
</style>
@if (App::isLocale('th'))
@include('app.pdf.locale.th')
@endif
</head>
<body>

View File

@@ -0,0 +1,37 @@
{{-- Bundled Noto Sans — covers Latin, Cyrillic, Greek, Arabic, Thai, Hindi, and most scripts --}}
<style type="text/css">
@font-face {
font-family: 'NotoSans';
font-style: normal;
font-weight: normal;
src: url("{{ resource_path('static/fonts/NotoSans-Regular.ttf') }}") format('truetype');
}
@font-face {
font-family: 'NotoSans';
font-style: normal;
font-weight: bold;
src: url("{{ resource_path('static/fonts/NotoSans-Bold.ttf') }}") format('truetype');
}
@font-face {
font-family: 'NotoSans';
font-style: italic;
font-weight: normal;
src: url("{{ resource_path('static/fonts/NotoSans-Italic.ttf') }}") format('truetype');
}
@font-face {
font-family: 'NotoSans';
font-style: italic;
font-weight: bold;
src: url("{{ resource_path('static/fonts/NotoSans-BoldItalic.ttf') }}") format('truetype');
}
{{-- On-demand CJK @font-face rules (only emitted for installed packages). --}}
{!! implode("\n", app(\App\Services\FontService::class)->getInstalledFontFaces()) !!}
body {
font-family: {!! app(\App\Services\FontService::class)->getFontFamilyChain() !!};
}
</style>

View File

@@ -5,10 +5,11 @@
<title>@lang('pdf_payment_label') - {{ $payment->payment_number }}</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
@include("app.pdf.partials.fonts")
<style type="text/css">
/* -- Base -- */
body {
font-family: "DejaVu Sans";
}
html {
@@ -277,9 +278,6 @@
</style>
@if (App::isLocale('th'))
@include('app.pdf.locale.th')
@endif
</head>
<body>

View File

@@ -3,9 +3,10 @@
<head>
<title>@lang('pdf_expense_report_label')</title>
@include("app.pdf.partials.fonts")
<style type="text/css">
body {
font-family: "DejaVu Sans";
}
table {
@@ -135,9 +136,6 @@
}
</style>
@if (App::isLocale('th'))
@include('app.pdf.locale.th')
@endif
</head>
<body>

View File

@@ -3,9 +3,10 @@
<head>
<title>@lang('pdf_profit_loss_label')</title>
@include("app.pdf.partials.fonts")
<style type="text/css">
body {
font-family: "DejaVu Sans";
}
table {
@@ -160,9 +161,6 @@
}
</style>
@if (App::isLocale('th'))
@include('app.pdf.locale.th')
@endif
</head>
<body>

View File

@@ -3,9 +3,10 @@
<head>
<title>@lang('pdf_sales_customers_label')</title>
@include("app.pdf.partials.fonts")
<style type="text/css">
body {
font-family: "DejaVu Sans";
}
table {
@@ -139,9 +140,6 @@
}
</style>
@if (App::isLocale('th'))
@include('app.pdf.locale.th')
@endif
</head>
<body>

View File

@@ -3,9 +3,10 @@
<head>
<title>@lang('pdf_sales_items_label')</title>
@include("app.pdf.partials.fonts")
<style type="text/css">
body {
font-family: "DejaVu Sans";
}
table {
@@ -139,9 +140,6 @@
}
</style>
@if (App::isLocale('th'))
@include('app.pdf.locale.th')
@endif
</head>
<body>

View File

@@ -3,9 +3,10 @@
<head>
<title>@lang('pdf_tax_summery_label')</title>
@include("app.pdf.partials.fonts")
<style type="text/css">
body {
font-family: "DejaVu Sans";
}
table {
@@ -136,9 +137,6 @@
}
</style>
@if (App::isLocale('th'))
@include('app.pdf.locale.th')
@endif
</head>
<body>

View File

@@ -5,6 +5,7 @@ use App\Http\Controllers\Admin\BackupsController;
use App\Http\Controllers\Admin\CompaniesController;
use App\Http\Controllers\Admin\CountriesController;
use App\Http\Controllers\Admin\CurrenciesController;
use App\Http\Controllers\Admin\FontController;
use App\Http\Controllers\Admin\Modules\ModuleInstallationController;
use App\Http\Controllers\Admin\Modules\ModulesController;
use App\Http\Controllers\Admin\Settings\DiskController;
@@ -349,6 +350,12 @@ Route::prefix('/v1')->group(function () {
Route::get('/disk/purposes', [DiskController::class, 'getDiskPurposes']);
Route::put('/disk/purposes', [DiskController::class, 'updateDiskPurposes']);
// Fonts
// ----------------------------------
Route::get('/fonts/status', [FontController::class, 'status']);
Route::post('/fonts/{package}/install', [FontController::class, 'install']);
// Exchange Rate
// ----------------------------------