Compare commits

...

6 Commits

Author SHA1 Message Date
Dan Brown
47ca6b9c33 Lexical: Added support for keyCode-based fallback shortcut use
Helps in cases where languages like cyrillic may have the relevant key
to use but the actual text/.key value is the cyrillic key value instead
of the shorcut key we expect.
2026-04-26 11:48:05 +01:00
Dan Brown
74aa897626 Readme: Updated netways sponsor link 2026-04-24 23:16:44 +01:00
Dan Brown
4b624596c8 Merge pull request #6109 from BookStackApp/dompdf_font_loading
PDF: Started building system to allow custom DOMPDF font loading
2026-04-22 13:30:48 +01:00
Dan Brown
00239bb6c8 Exports: Improved dompdf font loading permission errors 2026-04-22 13:22:20 +01:00
Dan Brown
241563e8fc Exports: Added testing coverage for DOMPDF font usage 2026-04-22 13:12:34 +01:00
Dan Brown
e91747785b PDF: Started building system to allow custom DOMPDF font loading 2026-04-20 15:42:28 +01:00
11 changed files with 162 additions and 15 deletions

View File

@@ -68,7 +68,7 @@ return [
* Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
* Symbol, ZapfDingbats.
*/
'font_dir' => storage_path('fonts/'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
'font_dir' => storage_path('fonts/dompdf'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
/**
* The location of the DOMPDF font cache directory.
@@ -78,7 +78,7 @@ return [
*
* Note: This directory must exist and be writable by the webserver process.
*/
'font_cache' => storage_path('fonts/'),
'font_cache' => storage_path('fonts/dompdf/cache'),
/**
* The location of a temporary directory.

View File

@@ -4,6 +4,8 @@ namespace BookStack\Exports;
use BookStack\Exceptions\PdfExportException;
use Dompdf\Dompdf;
use FontLib\Font;
use Illuminate\Support\Str;
use Knp\Snappy\Pdf as SnappyPdf;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
@@ -60,12 +62,65 @@ class PdfGenerator
$domPdf = new Dompdf($options);
$domPdf->setBasePath(base_path('public'));
$fontMetrics = $domPdf->getFontMetrics();
$userFontfamilies = $this->getUserDomPdfFontFamilies();
foreach ($userFontfamilies as $fontFamily => $fonts) {
try {
$fontMetrics->setFontFamily($fontFamily, $fonts);
} catch (\Exception $exception) {
$expectedPath = storage_path('fonts/dompdf');
throw new PdfExportException("Failed to create required font data in {$expectedPath}, Ensure all content in this location is writable by the web server");
}
}
$domPdf->loadHTML($this->convertEntities($html));
$domPdf->render();
return (string) $domPdf->output();
}
/**
* @return array<string, array<string, string>>
*/
protected function getUserDomPdfFontFamilies(): array
{
$fontStore = storage_path('fonts/dompdf');
if (!is_dir($fontStore)) {
return [];
}
$fontFamilies = [];
$fontFiles = glob($fontStore . DIRECTORY_SEPARATOR . '*.ttf');
foreach ($fontFiles as $fontFile) {
$fontFileName = basename($fontFile, '.ttf');
$expectedUfm = $fontStore . DIRECTORY_SEPARATOR . $fontFileName . '.ufm';
if (!file_exists($expectedUfm)) {
$font = Font::load($fontFile);
$font->parse();
try {
$font->saveAdobeFontMetrics($expectedUfm);
} catch (\Exception $exception) {
throw new PdfExportException("Failed to create required font data at $expectedUfm, Ensure this location is writable by the web server");
}
}
$nameParts = explode('-', $fontFileName);
if (count($nameParts) === 1 || $nameParts[1] === 'Regular') {
$nameParts[1] = 'Normal';
}
$family = trim(strtolower(preg_replace('/([A-Z])/', ' $1', $nameParts[0])));
$variation = Str::snake($nameParts[1]);
if (!isset($fontFamilies[$family])) {
$fontFamilies[$family] = [];
}
$fontFamilies[$family][$variation] = $fontStore . DIRECTORY_SEPARATOR . $fontFileName;
}
return $fontFamilies;
}
/**
* @throws PdfExportException
*/

View File

@@ -72,7 +72,7 @@ Big thanks to these companies for supporting the project.
<td align="center"><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
<img width="240" src="https://www.bookstackapp.com/images/sponsors/stellarhosted.png" alt="Stellar Hosted">
</a></td>
<td align="center" style="text-align: center"><a href="https://nws.netways.de/apps/bookstack/" target="_blank">
<td align="center" style="text-align: center"><a href="https://nws.netways.de" target="_blank">
<img width="240" src="https://www.bookstackapp.com/images/sponsors/netways.png" alt="NETWAYS Web Services">
</a></td>
</tr>

View File

@@ -94,30 +94,78 @@ const extendedActionsByKeys: Record<string, ShortcutAction> = {
};
function createKeyDownListener(context: EditorUiContext, useExtended: boolean): (e: KeyboardEvent) => void {
const keySetToUse = useExtended ? extendedActionsByKeys : baseActionsByKeys;
const baseKeySetToUse = useExtended ? extendedActionsByKeys : baseActionsByKeys;
const keySetToUse = extendKeySetWithKeyCodes(baseKeySetToUse);
return (event: KeyboardEvent) => {
const combo = keyboardEventToKeyComboString(event);
// console.log(`pressed: ${combo}`);
if (keySetToUse[combo]) {
const handled = keySetToUse[combo](context.editor, context);
if (handled) {
event.stopPropagation();
event.preventDefault();
const comboStrings = keyboardEventToKeyComboStrings(event);
// console.log(comboStrings, event, keySetToUse);
for (const combo of comboStrings) {
if (keySetToUse[combo]) {
const handled = keySetToUse[combo](context.editor, context);
if (handled) {
event.stopPropagation();
event.preventDefault();
}
break;
}
}
};
}
function keyboardEventToKeyComboString(event: KeyboardEvent): string {
/**
* Takes a shortcut key set and returns a new set with added variations of shortcts where
* they can be sensibly represented as their key code instead of just key, which we can use
* for matching in scenarios where the physical key may be represented of the letter used
* in the shortcut, but produces a different 'key' value.
* Useful for Cyrillic scenarios where the keyboard key would show a latin character
* as an option, and therefore be expected for use for the relevant latin shortcut, but the main
* key output is a Cyrillic character.
*/
function extendKeySetWithKeyCodes(keySet: Record<string, ShortcutAction>): Record<string, ShortcutAction> {
const newKeys: Record<string, ShortcutAction> = {};
const setKeys = Object.keys(keySet);
for (const keyCombo of setKeys) {
const action = keySet[keyCombo];
newKeys[keyCombo] = action;
const comboParts = keyCombo.split('+');
const lastComboPart = comboParts.pop() || '';
if (lastComboPart.match(/^[a-zA-Z]$/)) {
const keyCode = lastComboPart.toUpperCase().charCodeAt(0);
comboParts.push(String(keyCode));
const newCombo = comboParts.join('+');
newKeys[newCombo] = action;
}
}
return newKeys;
}
function keyboardEventToKeyComboStrings(event: KeyboardEvent): string[] {
const metaKeyPressed = isMac() ? event.metaKey : event.ctrlKey;
const parts = [
const mainParts = [
metaKeyPressed ? 'meta' : '',
event.shiftKey ? 'shift' : '',
event.key,
];
return parts.filter(Boolean).join('+').toLowerCase();
const toReturn = [
mainParts.filter(Boolean).join('+').toLowerCase(),
];
// If ending with a standard latin character, provide an alternative
// keyCode based option for scenarios of dual-language keyboard use.
const keyCode = event.keyCode || 0;
if (keyCode >= 65 && keyCode <= 90) {
const keyCodeParts = [...mainParts];
keyCodeParts.pop();
keyCodeParts.push(String(keyCode));
toReturn.push(keyCodeParts.filter(Boolean).join('+').toLowerCase());
}
return toReturn;
}
function isMac(): boolean {

View File

@@ -1,2 +1,6 @@
# Font cache files have once been stored directly in this folder
# therefore its important the contents non-ignored by git
# are chosen selectively
*
!.gitignore
!.gitignore
!dompdf/

3
storage/fonts/dompdf/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*
!.gitignore
!cache/

2
storage/fonts/dompdf/cache/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -79,6 +79,39 @@ class PdfExportTest extends TestCase
$this->assertStringContainsString('<details open="open"', $pdfHtml);
}
public function test_custom_fonts_loaded_for_dom_pdf_when_used()
{
// Set up custom font usage
$page = $this->entities->page()->forceFill([
'html' => '<p><strong>Bold</strong>text</p>',
]);
$page->save();
$this->setSettings([
'app-custom-head' => '<style>* { font-family: "meow words"}</style>'
]);
$normalFont = $this->files->testFilePath('fonts/Cardiff.ttf');
$normalFontTarget = storage_path('fonts/dompdf/MeowWords.ttf');
$boldFont = $this->files->testFilePath('fonts/Cardiff-Bold.ttf');
$boldFontTarget = storage_path('fonts/dompdf/MeowWords-Bold.ttf');
copy($normalFont, $normalFontTarget);
copy($boldFont, $boldFontTarget);
$resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
$resp->assertStatus(200);
// Existance of UFM files indicates the metrics have been generated
$this->assertFileExists(storage_path('fonts/dompdf/MeowWords.ufm'));
$this->assertFileExists(storage_path('fonts/dompdf/MeowWords-Bold.ufm'));
// Existence of cache json files indicates the fonts have been used
$this->assertFileExists(storage_path('fonts/dompdf/cache/MeowWords.ufm.json'));
$this->assertFileExists(storage_path('fonts/dompdf/cache/MeowWords-Bold.ufm.json'));
$filesToCleanUp = [...glob(storage_path('fonts/dompdf/Meow*')), ...glob(storage_path('fonts/dompdf/cache/Meow*'))];
foreach ($filesToCleanUp as $file) {
unlink($file);
}
}
public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true()
{
$page = $this->entities->page();

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,2 @@
Font files by Roger White, in public domain.
https://web.archive.org/web/20110609213636/http://www.rogersfonts.org.uk/