diff --git a/app/Http/Controllers/Admin/FontController.php b/app/Http/Controllers/Admin/FontController.php new file mode 100644 index 00000000..1791649b --- /dev/null +++ b/app/Http/Controllers/Admin/FontController.php @@ -0,0 +1,52 @@ +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); + } + } +} diff --git a/app/Services/FontService.php b/app/Services/FontService.php new file mode 100644 index 00000000..13eef61c --- /dev/null +++ b/app/Services/FontService.php @@ -0,0 +1,260 @@ + [ + '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; + } +} diff --git a/app/Traits/GeneratesPdfTrait.php b/app/Traits/GeneratesPdfTrait.php index 980db0b5..1953dc11 100644 --- a/app/Traits/GeneratesPdfTrait.php +++ b/app/Traits/GeneratesPdfTrait.php @@ -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(); diff --git a/config/dompdf.php b/config/dompdf.php index 10841831..7676c9b8 100644 --- a/config/dompdf.php +++ b/config/dompdf.php @@ -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 diff --git a/lang/en.json b/lang/en.json index 1bb7cd41..0efb22a9 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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", diff --git a/resources/scripts-v2/api/endpoints.ts b/resources/scripts-v2/api/endpoints.ts index d2c70fbc..17cbdb37 100644 --- a/resources/scripts-v2/api/endpoints.ts +++ b/resources/scripts-v2/api/endpoints.ts @@ -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', diff --git a/resources/static/fonts/NotoSans-Bold.ttf b/resources/static/fonts/NotoSans-Bold.ttf new file mode 100644 index 00000000..b7f82909 Binary files /dev/null and b/resources/static/fonts/NotoSans-Bold.ttf differ diff --git a/resources/static/fonts/NotoSans-BoldItalic.ttf b/resources/static/fonts/NotoSans-BoldItalic.ttf new file mode 100644 index 00000000..a36697a2 Binary files /dev/null and b/resources/static/fonts/NotoSans-BoldItalic.ttf differ diff --git a/resources/static/fonts/NotoSans-Italic.ttf b/resources/static/fonts/NotoSans-Italic.ttf new file mode 100644 index 00000000..61772248 Binary files /dev/null and b/resources/static/fonts/NotoSans-Italic.ttf differ diff --git a/resources/static/fonts/NotoSans-Regular.ttf b/resources/static/fonts/NotoSans-Regular.ttf new file mode 100644 index 00000000..cb427d58 Binary files /dev/null and b/resources/static/fonts/NotoSans-Regular.ttf differ diff --git a/resources/views/app/pdf/estimate/estimate1.blade.php b/resources/views/app/pdf/estimate/estimate1.blade.php index 80da043d..6bb507cc 100644 --- a/resources/views/app/pdf/estimate/estimate1.blade.php +++ b/resources/views/app/pdf/estimate/estimate1.blade.php @@ -5,10 +5,11 @@ @lang('pdf_estimate_label') - {{ $estimate->estimate_number }} +@include("app.pdf.partials.fonts") + - @if (App::isLocale('th')) - @include('app.pdf.locale.th') - @endif diff --git a/resources/views/app/pdf/estimate/estimate2.blade.php b/resources/views/app/pdf/estimate/estimate2.blade.php index 55edf048..30d3c3e5 100644 --- a/resources/views/app/pdf/estimate/estimate2.blade.php +++ b/resources/views/app/pdf/estimate/estimate2.blade.php @@ -4,10 +4,11 @@ @lang('pdf_estimate_label') - {{ $estimate->estimate_number }} +@include("app.pdf.partials.fonts") + - @if (App::isLocale('th')) - @include('app.pdf.locale.th') - @endif diff --git a/resources/views/app/pdf/estimate/estimate3.blade.php b/resources/views/app/pdf/estimate/estimate3.blade.php index 5558d287..ef9c34a3 100644 --- a/resources/views/app/pdf/estimate/estimate3.blade.php +++ b/resources/views/app/pdf/estimate/estimate3.blade.php @@ -5,10 +5,11 @@ @lang('pdf_estimate_label') - {{ $estimate->estimate_number }} +@include("app.pdf.partials.fonts") + - @if (App::isLocale('th')) - @include('app.pdf.locale.th') - @endif diff --git a/resources/views/app/pdf/invoice/invoice1.blade.php b/resources/views/app/pdf/invoice/invoice1.blade.php index ca57f6b9..8ec862e1 100644 --- a/resources/views/app/pdf/invoice/invoice1.blade.php +++ b/resources/views/app/pdf/invoice/invoice1.blade.php @@ -5,10 +5,11 @@ @lang('pdf_invoice_label') - {{ $invoice->invoice_number }} +@include("app.pdf.partials.fonts") + - @if (App::isLocale('th')) - @include('app.pdf.locale.th') - @endif diff --git a/resources/views/app/pdf/invoice/invoice3.blade.php b/resources/views/app/pdf/invoice/invoice3.blade.php index 2f5616dc..24c338f2 100644 --- a/resources/views/app/pdf/invoice/invoice3.blade.php +++ b/resources/views/app/pdf/invoice/invoice3.blade.php @@ -5,11 +5,12 @@ @lang('pdf_invoice_label') - {{ $invoice->invoice_number }} +@include("app.pdf.partials.fonts") + - @if (App::isLocale('th')) - @include('app.pdf.locale.th') - @endif diff --git a/resources/views/app/pdf/partials/fonts.blade.php b/resources/views/app/pdf/partials/fonts.blade.php new file mode 100644 index 00000000..964e3532 --- /dev/null +++ b/resources/views/app/pdf/partials/fonts.blade.php @@ -0,0 +1,37 @@ +{{-- Bundled Noto Sans — covers Latin, Cyrillic, Greek, Arabic, Thai, Hindi, and most scripts --}} + diff --git a/resources/views/app/pdf/payment/payment.blade.php b/resources/views/app/pdf/payment/payment.blade.php index 394be0ef..909631a4 100644 --- a/resources/views/app/pdf/payment/payment.blade.php +++ b/resources/views/app/pdf/payment/payment.blade.php @@ -5,10 +5,11 @@ @lang('pdf_payment_label') - {{ $payment->payment_number }} +@include("app.pdf.partials.fonts") + - @if (App::isLocale('th')) - @include('app.pdf.locale.th') - @endif diff --git a/resources/views/app/pdf/reports/expenses.blade.php b/resources/views/app/pdf/reports/expenses.blade.php index a38c2be7..22c40468 100644 --- a/resources/views/app/pdf/reports/expenses.blade.php +++ b/resources/views/app/pdf/reports/expenses.blade.php @@ -3,9 +3,10 @@ @lang('pdf_expense_report_label') +@include("app.pdf.partials.fonts") + - @if (App::isLocale('th')) - @include('app.pdf.locale.th') - @endif diff --git a/resources/views/app/pdf/reports/profit-loss.blade.php b/resources/views/app/pdf/reports/profit-loss.blade.php index 0aa40149..6037dea4 100644 --- a/resources/views/app/pdf/reports/profit-loss.blade.php +++ b/resources/views/app/pdf/reports/profit-loss.blade.php @@ -3,9 +3,10 @@ @lang('pdf_profit_loss_label') +@include("app.pdf.partials.fonts") + - @if (App::isLocale('th')) - @include('app.pdf.locale.th') - @endif diff --git a/resources/views/app/pdf/reports/sales-customers.blade.php b/resources/views/app/pdf/reports/sales-customers.blade.php index b42ce91e..ac4e1351 100644 --- a/resources/views/app/pdf/reports/sales-customers.blade.php +++ b/resources/views/app/pdf/reports/sales-customers.blade.php @@ -3,9 +3,10 @@ @lang('pdf_sales_customers_label') +@include("app.pdf.partials.fonts") + - @if (App::isLocale('th')) - @include('app.pdf.locale.th') - @endif diff --git a/resources/views/app/pdf/reports/sales-items.blade.php b/resources/views/app/pdf/reports/sales-items.blade.php index 8e243f43..1be11191 100644 --- a/resources/views/app/pdf/reports/sales-items.blade.php +++ b/resources/views/app/pdf/reports/sales-items.blade.php @@ -3,9 +3,10 @@ @lang('pdf_sales_items_label') +@include("app.pdf.partials.fonts") + - @if (App::isLocale('th')) - @include('app.pdf.locale.th') - @endif diff --git a/resources/views/app/pdf/reports/tax-summary.blade.php b/resources/views/app/pdf/reports/tax-summary.blade.php index 1485cb94..bf39cf6c 100644 --- a/resources/views/app/pdf/reports/tax-summary.blade.php +++ b/resources/views/app/pdf/reports/tax-summary.blade.php @@ -3,9 +3,10 @@ @lang('pdf_tax_summery_label') +@include("app.pdf.partials.fonts") + - @if (App::isLocale('th')) - @include('app.pdf.locale.th') - @endif diff --git a/routes/api.php b/routes/api.php index 6e9b6785..481f0100 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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 // ----------------------------------