Back to overview

Creating a letter pdf from script

Scripting & Buttons Published on 31.10.2025

Generating PDF Letters with pdfmake.js

In our final tutorial, we'll build the most advanced button yet: a "Create Letter" button. This button will take data from a primary record (e.g., a correspondence entry) and its linked address record, merge it with a dynamic HTML template, and generate a professional, multi-page PDF letter directly in the browser, complete with your company's letterhead.

This example showcases a powerful combination of server-side data aggregation and client-side file generation using the excellent pdfmake.js library. It's the perfect solution for creating invoices, quotes, letters, or any other custom document on the fly.

The Code: `letter_pdf.php`

This script is a hybrid. The PHP part acts as a data aggregator, preparing everything the client needs. The JavaScript part takes this data and performs the complex task of generating the PDF. Save this file in a setup-specific directory like /setup_adressen/.

<?php
require_once '../init.php';

// --- Access Control ---
if (!isset($_SESSION['user'])) {
    header('Content-Type: application/json');
    echo json_encode(['error' => 'Authentication required']);
    exit();
}

// --- Get parameters from the URL ---
$table_id = (int)($_GET['table_id'] ?? 0);
$record_id = (int)($_GET['id'] ?? 0);
$sig_from_url = trim($_GET['sig'] ?? '');

if ($table_id === 0) {
    header('Content-Type: application/json');
    echo json_encode(['error' => 'Table ID is missing']);
    exit();
}

// --- Security Check: Validate signature ---
$data_to_sign = ($record_id > 0) ? "{$table_id}-{$record_id}" : (string)$table_id;
$expected_sig = hash_hmac('sha256', $data_to_sign, INVITE_SECRET_KEY);
if (!hash_equals($expected_sig, $sig_from_url)) {
    header('Content-Type: application/json');
    echo json_encode(['error' => 'Invalid signature.']);
    exit();
}

// --- Fetch table and record data ---
$table_definition = DBGet('custom_tables', $table_id);
$table_owner_team = $table_definition['team'];
$physical_table_name = "data_team_{$table_owner_team}_{$table_id}";
$main_record = DBGet($physical_table_name, $record_id);

if (!$main_record) {
    header('Content-Type: application/json');
    echo json_encode(['error' => 'Main record not found.']);
    exit();
}
$main_record_json = json_encode($main_record);

// --- Fetch preferences and layout data ---
$user_team = $_SESSION['user']['team'];
$team_preferences_raw = DBGet('preferences', null, ['name', 'value'], 'team = ?', [$user_team]);
$team_preferences = $team_preferences_raw ? array_column($team_preferences_raw, 'value', 'name') : [];

$layout_name = 'Brief'; // The specific layout we want to use
$layout_rows = DBGet('custom_print_layouts', null, ['*'],
    'custom_table_id = ? AND name = ? AND (team = ? OR team = "ADMININTEX")',
    [$table_id, $layout_name, $user_team],
    "CASE WHEN team = " . DBOpen()->quote($user_team) . " THEN 0 ELSE 1 END, id ASC"
);
$layout_html = $layout_rows[0]['layout_html'] ?? '<p>Error: Layout "Brief" not found.</p>';

// --- Fetch related address record ---
$address_record = null;
$address_lookup_field_name = 'adresse';
if (isset($main_record[$address_lookup_field_name]) && $main_record[$address_lookup_field_name] > 0) {
    $address_record_id = $main_record[$address_lookup_field_name];
    $lookup_field_meta_array = DBGet('custom_fields', null, ['options'], 'custom_table_id = ? AND field_name = ?', [$table_id, $address_lookup_field_name]);
    if ($lookup_field_meta_array) {
        $lookup_options = json_decode($lookup_field_meta_array[0]['options'], true);
        $linked_table_id = $lookup_options['linked_table_id'] ?? 0;
        if ($linked_table_id > 0) {
            $linked_table_def = DBGet('custom_tables', $linked_table_id);
            if ($linked_table_def) {
                $linked_physical_table = "data_team_{$linked_table_def['team']}_{$linked_table_id}";
                $address_record = DBGet($linked_physical_table, (int)$address_record_id);
            }
        }
    }
}

// --- Server-side placeholder replacement ---
$placeholder_callback = function ($matches) use ($main_record, $address_record, $team_preferences) {
    $placeholder_key = $matches[1];
    switch (strtoupper($placeholder_key)) {
        case 'DATUM': return date('d.m.Y');
        case 'BENUTZERNAME': return htmlspecialchars($_SESSION['user']['username'] ?? '');
    }
    if (str_starts_with($placeholder_key, 'adresse.') && $address_record) {
        $sub_key = substr($placeholder_key, 8);
        return htmlspecialchars($address_record[$sub_key] ?? '');
    }
    if (isset($main_record[$placeholder_key])) {
        return $main_record[$placeholder_key]; // Allow HTML from record fields
    }
    if (isset($team_preferences[$placeholder_key])) {
        $html_allowed_keys = ['team_footer_col1', 'team_footer_col2', 'team_footer_col3', 'team_footer_col4', 'team_header'];
        return in_array($placeholder_key, $html_allowed_keys) ? $team_preferences[$placeholder_key] : nl2br(htmlspecialchars($team_preferences[$placeholder_key]));
    }
    return $matches[0];
};
$resolved_layout_html = preg_replace_callback('/{([a-zA-Z0-9_.]+)}/', $placeholder_callback, $layout_html);
$resolved_layout_json = json_encode($resolved_layout_html);

// --- Prepare Letterhead and Logo for JavaScript ---
$letterhead_path = $team_preferences['team_letterhead'] ?? null;
$letterhead_content = null;
if ($letterhead_path && file_exists(__DIR__ . '/../' . $letterhead_path)) {
    $letterhead_data = file_get_contents(__DIR__ . '/../' . $letterhead_path);
    $letterhead_mime = mime_content_type(__DIR__ . '/../' . $letterhead_path);
    $letterhead_content = 'data:' . $letterhead_mime . ';base64,' . base64_encode($letterhead_data);
}

$logo_path = $team_preferences['team_logo_path'] ?? '../media/intexlogo.svg';
$logo_content = null;
$is_svg = false;
if (file_exists(__DIR__ . '/../' . $logo_path)) {
    $logo_data = file_get_contents(__DIR__ . '/../' . $logo_path);
    $logo_mime = mime_content_type(__DIR__ . '/../' . $logo_path);
    if ($logo_mime === 'image/svg+xml') {
        $is_svg = true;
        $logo_content = $logo_data;
    } else {
        $logo_content = 'data:' . $logo_mime . ';base64,' . base64_encode($logo_data);
    }
}

?><!doctype html>
<html lang="<?= $current_lang ?>">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Aktion wird ausgeführt...</title>
    <link href="../node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="../style.css">
    <?php include '../theme.php'; ?>
</head>
<body>
    <script src="../node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
    <script src="../js/dialog.js"></script>
    <!-- pdfmake libraries must be loaded before our script -->
    <script src="../node_modules/pdfmake/build/pdfmake.min.js"></script>
    <script src="../node_modules/pdfmake/build/vfs_fonts.js"></script>
    <script src="../node_modules/html-to-pdfmake/browser.js"></script>

    <script>
        showDialog({
            title: 'Brief erstellen',
            text: 'Sie sind dabei, einen Brief als PDF zu erstellen. Möchten Sie fortfahren?',
            okText: 'PDF erstellen',
            cancelText: 'Abbrechen'
        }).then(confirmed => {
            if (confirmed) {
                generatePdf({
                    letterhead: <?= json_encode($letterhead_content) ?>,
                    resolvedHtml: <?= $resolved_layout_json ?>,
                    logo: <?= json_encode($logo_content) ?>,
                    isLogoSvg: <?= $is_svg ? 'true' : 'false' ?>
                });
            }
        });

        const generatePdf = (data) => {
            const resolvedHtml = data.resolvedHtml || '<p>Error: Letter template could not be loaded.</p>';
            const logo = data.logo || null;
            const letterhead = data.letterhead || null;
            const isLogoSvg = !!data.isLogoSvg;

            const content = htmlToPdfmake(resolvedHtml);

            const docDefinition = {
                pageSize: 'A4',
                pageMargins: [56.7, 70.9, 56.7, 70.8], // 2cm left, 2.5cm top/bottom, 2cm right
                header: (currentPage) => {
                    if (letterhead && currentPage === 1) return null; // No header if letterhead is used on page 1
                    const headerContent = [];
                    const logoHeight = (currentPage === 1 && !letterhead) ? 85.05 : 28.35; // 3cm or 1cm
                    if (logo && logoHeight > 0) {
                        if (isLogoSvg) {
                            headerContent.push({ svg: logo, fit: [9999, logoHeight], alignment: 'right' });
                        } else {
                            headerContent.push({ image: 'logo', fit: [9999, logoHeight], alignment: 'right' });
                        }
                    }
                    return {
                        columns: [{ width: '*', text: '' }, { width: 'auto', stack: headerContent }],
                        margin: [56.7, 28.35, 56.7, 0]
                    };
                },
                footer: (currentPage, pageCount) => {
                    if (currentPage === 1) return { text: '' }; // No footer on page 1
                    return {
                        text: `Seite ${currentPage} von ${pageCount}`,
                        alignment: 'right',
                        fontSize: 8,
                        margin: [0, 10, 56.7, 0]
                    };
                },
                content: [
                    { text: '', margin: [0, 42.5, 0, 0] }, // Invisible spacer for first page top margin
                    { stack: content }
                ],
                defaultStyle: { fontSize: 11, lineHeight: 1.15 }
            };

            if (letterhead) {
                docDefinition.background = (currentPage) => {
                    return (currentPage === 1) ? { image: letterhead, width: 595.28, absolutePosition: { x: 0, y: 0 } } : null;
                };
            }

            if (logo && !isLogoSvg) {
                docDefinition.images = { logo: logo };
            }

            try {
                pdfMake.createPdf(docDefinition).open();
            } catch (err) {
                console.error('Error creating PDF:', err);
                showDialog({ title: 'Fehler', text: 'Fehler beim Erstellen des PDFs: ' + err.message, okText: 'OK' });
            }
        };
    </script>
</body>
</html>

Breaking Down the Code

This script is a powerful combination of server-side data preparation and client-side document generation.

Part 1: The PHP Server-Side Aggregator

The PHP part of the script doesn't generate a visible page. Its job is to collect all the necessary pieces of information and pass them to the JavaScript on the client side.

  1. Security: It performs the standard session and signature checks to ensure the request is valid.
  2. Data Fetching: It fetches multiple pieces of data:
    • The main record (e.g., the correspondence entry).
    • The related address record by following the `adresse` lookup field.
    • All team preferences, which contain paths to the logo and letterhead.
    • The HTML content of the print layout named "Brief" from the custom_print_layouts table.
  3. Placeholder Replacement: This is a key step. It takes the raw HTML from the print layout and uses a `preg_replace_callback` function to dynamically replace all placeholders (like {betreff}, {adresse.firma}, or {team_signature}) with the actual data from the records and preferences.
  4. Asset Preparation: It reads the logo and letterhead files from the server, converts them into Base64-encoded data URLs, and prepares them to be embedded in the PDF.
  5. Data Transfer: Finally, it JSON-encodes all this prepared data (the resolved HTML, the logo, the letterhead) and embeds it directly into the JavaScript part of the page.

Part 2: The JavaScript Client-Side PDF Generator

Once the page loads in the browser, the JavaScript takes over to build the PDF.

  1. Dynamic Script Loading: The script no longer relies on static <script> tags in the HTML. It now uses a custom loadScript function to dynamically and asynchronously load pdfmake.min.js, vfs_fonts.js, and html-to-pdfmake/browser.js.
  2. Asynchronous Control Flow: The entire logic is wrapped in an async function. It uses await Promise.all([...]) to ensure all necessary libraries are fully loaded before proceeding. This robust approach prevents timing errors.
  3. User Confirmation: Only after the libraries are ready, it asks the user for confirmation with await showDialog(). If the user cancels, the window is closed.
  4. PDF Generation (`generatePdf`): If confirmed, this function is called.
    • HTML to pdfmake: It uses the `htmlToPdfmake` library to convert the final, resolved HTML string into a content array that pdfmake.js can understand.
    • Document Definition: It constructs a `docDefinition` object. This is the main configuration for the PDF, defining page size, margins, and content.
    • Header & Footer: It includes logic to dynamically add a header (with a logo) and a footer (with page numbers) to each page. It cleverly omits these on the first page if a letterhead is being used.
    • Letterhead Background: If a letterhead was provided, it uses the `background` property to place the letterhead image on the first page of the PDF.
    • Create and Open: Finally, it calls `pdfMake.createPdf(docDefinition).open()` to generate the PDF and open it in a new browser tab.

This client-side approach is modern and efficient. The server prepares the data, but the resource-intensive task of rendering the PDF happens on the user's machine, reducing server load.

Configuration: Setting Up the Letter Template

For this button to work, you need to create the HTML template for the letter and tell the application where to find your logo and letterhead files.

Step 1: Create the Print Layout

  1. Navigate to Settings Print Layouts.
  2. Select the table for which you want to create the letter (e.g., "Correspondence").
  3. Click "New Layout".
  4. Fill out the form:
    • Layout Name: Enter exactly Brief. The script specifically looks for this name.
    • Layout HTML: Paste your letter's HTML code here. You can use placeholders like {betreff} or {adresse.firma} which will be replaced with data.
  5. Save the layout.

Step 2: Set Up Preferences for Logo and Letterhead

  1. Navigate to Settings Preferences.
  2. Create the following settings for your team:
    Setting Key (Name) Example Value Description
    team_letterhead media/letterhead.pdf The path to your letterhead file (PDF or image).
    team_logo_path media/logo.svg The path to your company logo (SVG, PNG, or JPG).
    team_signature Mit freundlichen Grüßen,<br>Ihr Team The signature block to be used in letters. HTML is allowed.

With these settings in place, the "Create Letter" button will be fully functional.


Congratulations! You've completed our custom button tutorial series. You now have the knowledge to create a wide range of custom actions, from simple links to complex, multi-step document generation workflows.