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 @@