Hacks & Customizations
These hacks are unsupported customizations meant as unofficial workarounds.
They can cause instability, introduce issues and may conflict with future updates. Apply at your own risk!

Interactive Embedded Page Drawings

  • Author: @ssddanbrown
  • Created: 22nd Jun 2025
  • Updated: 22nd Jun 2025
  • Last Tested On: v25.05.1

This hack will, on page view, attempt to convert any drawing images into interactive embedded drawing viewers so that you’ll be able to pan & zoom around the drawings while also being able to interact with things like links within the drawings.

Considerations

  • The drawings are loaded via the external “https://viewer.diagrams.net” site/service, and therefore this relies on that service being accessible from the browser, and drawing data is sent to that domain/location.
  • This hack will dynamically alter the ALLOWED_IFRAME_SOURCES option to allow the needed embedded viewers.
  • The embedded viewers will take up more space than the original drawing, as extra room is needed for the viewer toolbar/UI. This may result in extra page movement/jumping on page load.
  • While this has been tested with some drawings, this isn’t built on public/strong standards & APIs so there may be cases where this does not work, and there’s no assurance this will continue to work in the future.

Usage

After setup of the required hack files, this should automatically convert drawings when viewing a page.

Code

layouts/parts/base-body-start.blade.php
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
<script type="module" nonce="{{ $cspNonce ?? '' }}">
    /**
     * This script performs the following:
     * - Finds drawings within page content on page view.
     * - Fetches the data for those PNG-based drawings.
     * - Extracts out the diagrams.net drawing data from the PNG data.
     * - Builds embedded "viewer" iframes for the drawings.
     * - Replaces the original drawings with embedded viewers.
     */

    /**
     * Reads a given PNG data text chunk and returns drawing data if found.
     * @param {Uint8Array} textData
     * @returns {string|null}
     */
    function readTextChunkForDrawing(textData) {
        const start = String.fromCharCode(...textData.slice(0, 7));
        if (start !== "mxfile\0") {
            return null;
        }

        const drawingText = String.fromCharCode(...textData.slice(7));
        return decodeURIComponent(drawingText);
    }

    /**
     * Attempts to extract drawing data from a PNG image.
     * @param {Uint8Array} pngData
     * @returns {string}
     */
    function extractDrawingFromPngData(pngData) {
        // Ensure the file appears to be valid PNG file data
        const signature = pngData.slice(0, 8).join(',');
        if (signature !== '137,80,78,71,13,10,26,10') {
            throw new Error('Invalid png signature');
        }

        // Search through the chunks of data within the PNG file
        const dataView = new DataView(pngData.buffer);
        let offset = 8;
        let searching = true;
        while (searching && offset < pngData.length) {
            const length = dataView.getUint32(offset);
            const chunkType = String.fromCharCode(...pngData.slice(offset + 4, offset + 8));

            if (chunkType === 'tEXt') {
                // Extract and return drawing data if found within a text data chunk
                const textData = pngData.slice(offset + 8, offset + 8 + length);
                const drawingData = readTextChunkForDrawing(textData);
                if (drawingData !== null) {
                    return drawingData;
                }
            } else if (chunkType === 'IEND') {
                searching = false;
            }

            offset += 12 + length; // 12 = length + type + crc bytes
        }

        return '';
    }

    /**
     * Creates an iframe-based viewer for the given drawing data.
     * @param {string} drawingData
     * @returns {HTMLElement}
     */
    function createViewerContainer(drawingData) {
        const params = {
            lightbox: '0',
            highlight: '0000ff',
            layers: '1',
            nav: '1',
            dark: 'auto',
            toolbar: '1',
        };

        const query = (new URLSearchParams(params)).toString();
        const hash = `R${encodeURIComponent(drawingData)}`;
        const url = `https://viewer.diagrams.net/?${query}#${hash}`;

        const el = document.createElement('iframe');
        el.classList.add('mxgraph');
        el.style.width = '100%';
        el.style.maxWidth = '100%';
        el.src = url;
        el.frameBorder = '0';
        return el;
    }

    /**
     * Swap the given original drawing wrapper with the given viewer iframe.
     * Attempts to somewhat match sizing based on original drawing size, but
     * extra height is given to account for the viewer toolbar/UI.
     * @param {HTMLElement} wrapper
     * @param {HTMLElement} viewer
     */
    function swapDrawingWithViewer(wrapper, viewer) {
        const size = wrapper.getBoundingClientRect();
        viewer.style.height = (Math.round(size.height) + 146) + 'px';
        wrapper.replaceWith(viewer);
    }

    /**
     * Attempt to make a drawing interactive by converting it to an embedded iframe.
     * @param {HTMLElement} wrapper
     * @returns Promise<void>
     */
    async function makeDrawingInteractive(wrapper) {
        const drawingUrl = wrapper.querySelector('img')?.src;
        if (!drawingUrl) {
            return;
        }

        const drawingPngData = await (await fetch(drawingUrl)).bytes();
        const drawingData = extractDrawingFromPngData(drawingPngData);
        if (!drawingData) {
            return;
        }

        const viewer = createViewerContainer(drawingData);
        swapDrawingWithViewer(wrapper, viewer);
    }

    // Cycle through found drawings on a page and update them to make them interactive
    const drawings = document.querySelectorAll('.page-content [drawio-diagram]');
    for (const drawingWrap of drawings) {
        makeDrawingInteractive(drawingWrap);
    }
</script>
functions.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<?php

use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;

// Update the application configuration to allow diagrams.net
// viewer as an approved iframe source.
Theme::listen(ThemeEvents::APP_BOOT, function () {
    $iframeSources = config()->get('app.iframe_sources');
    $iframeSources .= ' https://viewer.diagrams.net';
    config()->set('app.iframe_sources', $iframeSources);
});


Request an Update

Hack not working on the latest version of BookStack?
You can request this hack to be updated & tested for a small one-time fee.
This helps keeps these hacks updated & maintained in a sustainable manner.


Latest Hacks