From 7fc98d710ae1014476edc2b5921977d7bcee3c2e Mon Sep 17 00:00:00 2001 From: Jean-Christophe Hoelt Date: Thu, 21 Nov 2024 18:07:11 +0200 Subject: [PATCH] Add emscripten build Integrates the necessary options to build a javascript library, with tests and documentation. Tested with emscripten 3.1.72 --- .gitignore | 1 + CMakeLists.txt | 60 +++++ README-emscripten.md | 89 ++++++++ README.md | 4 + examples/qpdf-js/qpdf-worker.js | 385 ++++++++++++++++++++++++++++++++ examples/qpdf-js/qpdf.js | 196 ++++++++++++++++ examples/qpdf-js/serve.py | 22 ++ examples/qpdf-js/serve.sh | 3 + examples/qpdf-js/test.html | 85 +++++++ 9 files changed, 845 insertions(+) create mode 100644 README-emscripten.md create mode 100644 examples/qpdf-js/qpdf-worker.js create mode 100644 examples/qpdf-js/qpdf.js create mode 100644 examples/qpdf-js/serve.py create mode 100755 examples/qpdf-js/serve.sh create mode 100644 examples/qpdf-js/test.html diff --git a/.gitignore b/.gitignore index 55a89cba..6c79fb66 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ appimage/build .cache /html Doxyfile +/examples/qpdf-js/lib/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 6caba84f..820fd684 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -404,3 +404,63 @@ message(STATUS "") if(NOT (MULTI_CONFIG OR CMAKE_BUILD_TYPE)) message(WARNING " CMAKE_BUILD_TYPE is not set; using default settings") endif() + +option(EMSCRIPTEN "Build with Emscripten" OFF) + +if(EMSCRIPTEN) + set(CMAKE_C_COMPILER emcc) + set(CMAKE_CXX_COMPILER em++) + set(CMAKE_EXECUTABLE_SUFFIX ".js") + + # Define runtime methods to export + set(EXPORTED_RUNTIME_METHODS + "callMain" + "FS" + ) + + # Enhanced debugging and safety flags + set(EMSCRIPTEN_LINK_FLAGS "\ + -s WASM=1 \ + -s ABORTING_MALLOC=1 \ + -s SUPPORT_LONGJMP=1 \ + -s EXPORTED_RUNTIME_METHODS='[${EXPORTED_RUNTIME_METHODS}]' \ + -s EXPORT_ALL=1 \ + -s FORCE_FILESYSTEM=1 \ + -s ALLOW_MEMORY_GROWTH=1 \ + -s MODULARIZE=1 \ + -s USE_ZLIB=1 \ + -s USE_LIBJPEG=1 \ + -s BUILD_AS_WORKER=1 \ + -s PROXY_TO_WORKER=1 \ + -s ENVIRONMENT='worker' \ + -s INVOKE_RUN=0 \ + -s EXIT_RUNTIME=0 \ + -s MAIN_MODULE=1 \ + -s EXPORT_NAME='QPDF' \ + -s ASSERTIONS=0 \ + -s STACK_OVERFLOW_CHECK=0 \ + -s DEMANGLE_SUPPORT=0 \ + -s ERROR_ON_UNDEFINED_SYMBOLS=0 \ + -s DISABLE_EXCEPTION_CATCHING=1 \ + -s SAFE_HEAP=0 \ + -s SAFE_HEAP_LOG=0 \ + -s EXCEPTION_DEBUG=0 \ + -s EXCEPTION_STACK_TRACES=0 \ + -O2" + ) + + # Add sanitizer flags for additional runtime checks + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${EMSCRIPTEN_LINK_FLAGS} -fsanitize=address -fsanitize=undefined") + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${EMSCRIPTEN_LINK_FLAGS} -fsanitize=address -fsanitize=undefined") + set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} ${EMSCRIPTEN_LINK_FLAGS}") + set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} ${EMSCRIPTEN_LINK_FLAGS}") + + # Use native crypto + set(REQUIRE_CRYPTO_NATIVE ON) + set(REQUIRE_CRYPTO_OPENSSL OFF) + set(USE_IMPLICIT_CRYPTO OFF) + + # Static linking + set(BUILD_SHARED_LIBS OFF) + set(BUILD_STATIC_LIBS ON) +endif() diff --git a/README-emscripten.md b/README-emscripten.md new file mode 100644 index 00000000..11975f44 --- /dev/null +++ b/README-emscripten.md @@ -0,0 +1,89 @@ +# Building QPDF with Emscripten + +This guide explains how to compile QPDF to JavaScript/WebAssembly using Emscripten. + +> **Note**: For pre-built bundles and a convenient wrapper around the WebAssembly build, check out [qpdf.js](https://github.com/j3k0/qpdf.js). + +## Prerequisites + +Install Emscripten by following the [official installation instructions](https://emscripten.org/docs/getting_started/downloads.html). + +## Quick Start + +1. Create and enter build directory: +```bash +mkdir build-emscripten && cd build-emscripten +``` + +2. Build and copy to examples: +```bash +# Configure and build +emcmake cmake .. \ + -DEMSCRIPTEN=ON \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF && \ +emmake make -j8 qpdf && \ +cp qpdf/qpdf.* ../examples/qpdf-js/lib/ +``` + +3. Test the build: +```bash +cd ../examples/qpdf-js +python3 serve.py +``` + +4. Open `http://localhost:8523/test.html` in your browser + +## Build Output + +The build produces these files in `build-emscripten/qpdf/`: +- `qpdf.js`: JavaScript glue code +- `qpdf.wasm`: WebAssembly binary + +## Browser Usage Example + +Here's a minimal example to get started: + +```html + + + + QPDF Test + + +

+    
+    
+    
+
+
+```
+
+For a complete working example with PDF operations like checking and encryption, see [examples/qpdf-js/test.html](examples/qpdf-js/test.html) in the repository.
+
+## Additional Resources
+
+- [qpdf.js](https://github.com/j3k0/qpdf.js) - Pre-built WebAssembly bundles with JavaScript wrapper
+- [Emscripten Documentation](https://emscripten.org/docs/getting_started/index.html)
+- [WebAssembly](https://webassembly.org/)
+- [QPDF Documentation](https://qpdf.readthedocs.io/) 
\ No newline at end of file
diff --git a/README.md b/README.md
index f06bd246..486c4758 100644
--- a/README.md
+++ b/README.md
@@ -142,6 +142,10 @@ qpdf is known to build and pass its test suite with mingw and Microsoft Visual C
 work. In addition to the manual, see [README-windows.md](README-windows.md) for more details on how to build under
 Windows.
 
+## WebAssembly / JavaScript Support
+
+QPDF can be compiled to WebAssembly for use in browsers and JavaScript environments. For most users, we recommend using [qpdf.js](https://github.com/j3k0/qpdf.js) which provides pre-built bundles and a convenient wrapper around the WebAssembly build. If you need to build the WebAssembly version yourself, see [README-emscripten.md](README-emscripten.md) for instructions.
+
 # Building Documentation
 
 The qpdf manual is written in reStructured Text format and is build with [sphinx](https://www.sphinx-doc.org). The
diff --git a/examples/qpdf-js/qpdf-worker.js b/examples/qpdf-js/qpdf-worker.js
new file mode 100644
index 00000000..b0dd1efc
--- /dev/null
+++ b/examples/qpdf-js/qpdf-worker.js
@@ -0,0 +1,385 @@
+/* eslint-env worker */
+/* global FS, callMain */
+
+const scriptURL = self.location.href;
+const basePath = scriptURL.substring(0, scriptURL.lastIndexOf('/') + 1);
+let errorOrWarning = '';
+
+function stdout(txt) {
+  postMessage({
+    type: 'stdout',
+    line: txt
+  });
+}
+
+function debugLog(type, ...args) {
+  const msg = args.map(arg => {
+    if (arg instanceof Error) {
+      return arg.message + '\n' + arg.stack;
+    }
+    if (typeof arg === 'object') {
+      return JSON.stringify(arg, null, 2);
+    }
+    return String(arg);
+  }).join(' ');
+  // stdout(`[DEBUG:${type}] ${msg}`);
+}
+
+
+var Module = {
+  thisProgram: 'qpdf',
+  noInitialRun: true,
+  print: function(text) {
+    stdout(text);
+  },
+  printErr: function(text) {
+    if (text.startsWith('WARNING: ')) {
+      errorOrWarning = text.slice(8);
+    }
+    stdout('[stderr] ' + text);
+  },
+  onRuntimeInitialized: function() {
+    debugLog('init', 'QPDF runtime initialized');
+    postMessage({
+      type: 'ready'
+    });
+  },
+  locateFile: function(path, prefix) {
+    if (path.endsWith('.wasm')) {
+      return basePath + 'lib/' + path;
+    }
+    return prefix + path;
+  },
+  quit: function(status, toThrow) {
+    debugLog('quit', `Program quit with status: ${status}`, toThrow);
+    if (toThrow) {
+      throw status;
+    }
+  }
+};
+
+importScripts(basePath + 'lib/qpdf.js');
+
+function getFileData(fileName) {
+  try {
+    if (!FS.analyzePath(fileName).exists) {
+      debugLog('getFileData', `File ${fileName} does not exist`);
+      return null;
+    }
+    const file = FS.root.contents[fileName];
+    if (!file) {
+      debugLog('getFileData', `Could not access ${fileName} in FS`);
+      return null;
+    }
+    debugLog('getFileData', `Successfully read ${fileName}, size: ${file.contents.length}`);
+    return file.contents;
+  } catch (error) {
+    debugLog('getFileData', 'Error reading file:', error);
+    return null;
+  }
+}
+
+onmessage = function(event) {
+  var message = event.data;
+
+  switch (message.type) {
+    case 'save': {
+      const filename = message.filename;
+      const arrayBuffer = message.arrayBuffer;
+      
+      try {
+        debugLog('save', `Saving ${filename}, size: ${arrayBuffer.byteLength}`);
+        
+        if (!(arrayBuffer instanceof ArrayBuffer) && !(ArrayBuffer.isView(arrayBuffer))) {
+          throw new Error(`Invalid data type. Expected ArrayBuffer, got ${Object.prototype.toString.call(arrayBuffer)}`);
+        }
+        
+        // Clean up any existing file
+        if (FS.analyzePath(filename).exists) {
+          debugLog('save', `Removing existing ${filename}`);
+          FS.unlink(filename);
+        }
+        
+        const data = ArrayBuffer.isView(arrayBuffer) ? arrayBuffer : new Uint8Array(arrayBuffer);
+        FS.createDataFile('/', filename, data, true, false);
+        
+        debugLog('save', `Successfully saved ${filename}`);
+        postMessage({
+          type: 'saved',
+          filename
+        });
+      } catch (error) {
+        debugLog('save', 'Error saving file:', error);
+        postMessage({
+          type: 'error',
+          message: error.message
+        });
+      }
+      break;
+    }
+
+    case 'load': {
+      const filename = message.filename;
+      try {
+        debugLog('load', `Loading ${filename}`);
+        const data = getFileData(filename);
+        if (data) {
+          debugLog('load', `Successfully loaded ${filename}`);
+          postMessage({
+            type: 'loaded',
+            filename,
+            arrayBuffer: data
+          });
+        } else {
+          throw new Error(`Failed to load ${filename}`);
+        }
+      } catch (error) {
+        debugLog('load', 'Error loading file:', error);
+        postMessage({
+          type: 'error',
+          message: error.message
+        });
+      }
+      break;
+    }
+
+    case 'execute': {
+      const args = message.args;
+      try {
+        debugLog('execute', `Running qpdf with args: ${args.join(' ')}`);
+        
+        let exitStatus = undefined;
+        let exitMessage = undefined;
+        try {
+          exitStatus = callMain(args);
+        } catch (e) {
+          const decoded = decodeQPDFExc(e);
+          if (decoded) {
+            stdout('QPDFExc thrown');
+            stdout('file: ' + decoded.filename);
+            stdout('message: ' + decoded.message);
+            stdout('dump:');
+            stdout(decoded.dump);
+            exitMessage = decoded.message;
+          }
+          else if (e > 100 && e < HEAP8.length) {
+            stdout(hexdump(HEAP8.slice(e, e + 512), e));
+          }
+          else {
+            stdout('Error thrown: ' + e);
+          }
+        }
+
+        debugLog('execute', `Command completed with status: ${exitStatus === undefined ? EXITSTATUS : exitStatus}`);
+
+        if (exitStatus !== 0) {
+          postMessage({
+            type: 'executed',
+            status: exitStatus,
+            error: exitMessage || errorOrWarning || 'Command failed',
+          });
+        } else {
+          postMessage({
+            type: 'executed',
+            status: exitStatus,
+          });
+        }
+      } catch (error) {
+        debugLog('execute', 'Error during execution:', error);
+        postMessage({
+          type: 'executed',
+          status: 1,
+          error: error.message || String(error)
+        });
+      }
+      break;
+    }
+  }
+};
+
+function decodeQPDFExc(addr) {
+    try {
+        if (typeof addr !== 'number') {
+            debugLog('QPDFExc', 'Invalid address type:', typeof addr);
+            return null;
+        }
+
+        debugLog('QPDFExc', `Attempting to decode exception at address 0x${addr.toString(16)}`);
+
+        // Helper to read a null-terminated string
+        const readCString = (offset) => {
+            let result = '';
+            let i = 0;
+            while (i < 1000) { // Safety limit
+                const char = HEAP8[offset + i] & 0xFF;
+                if (char === 0) break;
+                result += String.fromCharCode(char);
+                i++;
+            }
+            if (result) {
+                debugLog('QPDFExc', `Read C string at 0x${offset.toString(16)}: "${result}"`);
+            }
+            return result;
+        };
+
+        // Helper to read a 32-bit little endian value
+        const readU32 = (offset) => {
+            const value = HEAP8[offset] & 0xFF |
+                ((HEAP8[offset + 1] & 0xFF) << 8) |
+                ((HEAP8[offset + 2] & 0xFF) << 16) |
+                ((HEAP8[offset + 3] & 0xFF) << 24);
+            debugLog('QPDFExc', `Read U32 at 0x${offset.toString(16)}: 0x${value.toString(16)}`);
+            return value;
+        };
+
+        // First field might be vtable
+        const vtable = readU32(addr);
+        debugLog('QPDFExc', `Potential vtable pointer: 0x${vtable.toString(16)}`);
+
+        // Second field
+        const field2 = readU32(addr + 4);
+        debugLog('QPDFExc', `Second field: 0x${field2.toString(16)}`);
+
+        // Next fields appear to be lengths or flags
+        const field3 = readU32(addr + 8);  // 0 in arg error case
+        const field4 = readU32(addr + 12); // 0x3b in arg error case
+        const field5 = readU32(addr + 16); // 0x22 in arg error case
+        const field6 = readU32(addr + 20); // 0x22 in arg error case
+        const field7 = readU32(addr + 24); // 0 in arg error case
+
+        debugLog('QPDFExc', `Additional fields: ${field3}, ${field4}, ${field5}, ${field6}, ${field7}`);
+
+        // Look for error patterns in a more structured way
+        const errorPatterns = [
+            // File errors
+            {
+                type: 'file_error',
+                markers: ["can't find", "error", "failed", "unrecognized", "invalid"]
+            },
+            // Object errors (new pattern)
+            {
+                type: 'object_error',
+                markers: ["expected", "object", "offset"]
+            }
+        ];
+
+        let errorInfo = {
+            filename: null,
+            object: null,
+            offset: null,
+            message: null
+        };
+
+        // Look for structured error components
+        for (let i = 0; i < 200; i++) {
+            const testStr = readCString(addr + i);
+            if (!testStr) continue;
+
+            // Find filename
+            if (testStr.endsWith('.pdf')) {
+                errorInfo.filename = testStr;
+                debugLog('QPDFExc', `Found filename: "${testStr}"`);
+                i += testStr.length;
+            }
+
+            // Find object reference (e.g., "object 2 0")
+            const objectMatch = testStr.match(/object (\d+) (\d+)/);
+            if (objectMatch) {
+                errorInfo.object = `${objectMatch[1]} ${objectMatch[2]}`;
+                debugLog('QPDFExc', `Found object reference: "${errorInfo.object}"`);
+            }
+
+            // Find offset (e.g., "offset 60")
+            const offsetMatch = testStr.match(/offset (\d+)/);
+            if (offsetMatch) {
+                errorInfo.offset = parseInt(offsetMatch[1]);
+                debugLog('QPDFExc', `Found offset: ${errorInfo.offset}`);
+            }
+
+            // Look for error message
+            for (const pattern of errorPatterns) {
+                if (pattern.markers.some(marker => testStr.includes(marker))) {
+                    errorInfo.message = testStr;
+                    debugLog('QPDFExc', `Found error message (${pattern.type}): "${testStr}"`);
+                    i += testStr.length;
+                    break;
+                }
+            }
+        }
+
+        // Format the complete error message
+        let formattedMessage = '';
+        if (errorInfo.filename) {
+            formattedMessage += errorInfo.filename;
+            if (errorInfo.object || errorInfo.offset) {
+                formattedMessage += ' (';
+                if (errorInfo.object) formattedMessage += `object ${errorInfo.object}`;
+                if (errorInfo.object && errorInfo.offset) formattedMessage += ', ';
+                if (errorInfo.offset) formattedMessage += `offset ${errorInfo.offset}`;
+                formattedMessage += ')';
+            }
+            formattedMessage += ': ';
+        }
+        formattedMessage += errorInfo.message || 'Unknown error';
+
+        const result = {
+            type: 'QPDFExc',
+            addr: '0x' + addr.toString(16),
+            vtable: '0x' + readU32(addr).toString(16),
+            field2: '0x' + readU32(addr + 4).toString(16),
+            lengths: [
+                readU32(addr + 8),
+                readU32(addr + 12),
+                readU32(addr + 16),
+                readU32(addr + 20),
+                readU32(addr + 24)
+            ],
+            ...errorInfo,
+            formattedMessage,
+            dump: hexdump(HEAP8.slice(addr, addr + 128), addr)
+        };
+
+        debugLog('QPDFExc', 'Successfully decoded exception:', result);
+        return result;
+
+    } catch (e) {
+        debugLog('QPDFExc', 'Error while decoding:', e);
+        return null;
+    }
+}
+
+function hexdump(array, offset = 0, length = array.length) {
+  const bytesPerLine = 16;
+  let output = '';
+  
+  for (let i = 0; i < length; i += bytesPerLine) {
+    // Address column
+    output += (offset + i).toString(16).padStart(8, '0');
+    output += '  ';
+    
+    // Hex columns
+    for (let j = 0; j < bytesPerLine; j++) {
+      if (j === 8) output += ' ';
+      if (i + j < length) {
+        // Convert signed byte to unsigned byte (0-255)
+        const unsignedByte = array[i + j] & 0xFF;
+        output += unsignedByte.toString(16).padStart(2, '0');
+        output += ' ';
+      } else {
+        output += '   ';
+      }
+    }
+    
+    // ASCII column
+    output += ' |';
+    for (let j = 0; j < bytesPerLine && i + j < length; j++) {
+      // Convert signed byte to unsigned byte (0-255)
+      const unsignedByte = array[i + j] & 0xFF;
+      // Print as ASCII if printable, otherwise print a dot
+      output += (unsignedByte >= 32 && unsignedByte <= 126) ? 
+                String.fromCharCode(unsignedByte) : '.';
+    }
+    output += '|\n';
+  }
+  return output;
+}
diff --git a/examples/qpdf-js/qpdf.js b/examples/qpdf-js/qpdf.js
new file mode 100644
index 00000000..062e30d4
--- /dev/null
+++ b/examples/qpdf-js/qpdf.js
@@ -0,0 +1,196 @@
+/* eslint-env browser */
+
+(function () {
+  // The QPDF Module
+    function QPDF (options) {
+      const {
+        logger = console.log.bind(console),
+        ready,
+        path = QPDF.path || '',
+        keepAlive = false,
+      } = options;
+      let worker = new Worker(path + 'qpdf-worker.js');
+  
+      const listeners = {};
+      let nListeners = 0;
+      const addListener = function (id, fn) {
+        listeners[id] = fn;
+        nListeners += 1;
+      };
+      const callListener = function (id, err, arg) {
+        const fn = listeners[id];
+        if (fn) {
+          delete listeners[id];
+          fn(err, arg);
+        }
+        nListeners -= 1;
+  
+        if (!keepAlive && nListeners === 0) {
+          setTimeout(function () {
+            // No new commands after 1 second?
+            // Then we terminate the worker (unless keepAlive is true).
+            if (worker !== null && nListeners === 0) {
+              worker.terminate();
+              worker = null;
+            }
+          }, 1000);
+        }
+      };
+  
+      const qpdf = {
+        save (filename, arrayBuffer, callback) {
+          if (!worker) { return callback(new Error('worker terminated')); }
+          if (callback) {
+            addListener(filename, callback);
+          }
+          worker.postMessage({
+            type: 'save',
+            filename,
+            arrayBuffer
+          });
+        },
+        load (filename, callback) {
+          if (!worker) { return callback(new Error('worker terminated')); }
+          if (callback) {
+            addListener(filename, callback);
+          }
+          worker.postMessage({
+            type: 'load',
+            filename
+          });
+        },
+        execute (args, callback) {
+          if (!worker) { return callback(new Error('worker terminated')); }
+          if (callback) {
+            addListener('execute', callback);
+          }
+          worker.postMessage({
+            type: 'execute',
+            args
+          });
+        },
+        terminate() {
+          if (worker) {
+            worker.terminate();
+            worker = null;
+          }
+        }
+      };
+  
+      worker.onmessage = function (event) {
+        const message = event.data;
+  
+        switch (message.type) {
+          case 'ready': {
+            logger('[qpdf] ready');
+            if (ready) {
+              ready(qpdf);
+            }
+            break;
+          }
+  
+          case 'stdout':
+            logger('[qpdf.worker] ' + message.line);
+            break;
+  
+          case 'saved': {
+            const filename = message.filename;
+            logger('[qpdf] ' + filename + ' saved');
+            callListener(filename, null);
+            break;
+          }
+  
+          case 'loaded': {
+            const { filename, arrayBuffer } = message;
+            logger('[qpdf] ' + filename + ' loaded (' + arrayBuffer.length + ')');
+            if (arrayBuffer) {
+              callListener(filename, null, arrayBuffer);
+            } else {
+              callListener(filename, new Error('File not found'));
+            }
+            break;
+          }
+  
+          case 'executed': {
+            const { status, error, output } = message;
+            logger('[qpdf] exited with status ' + status);
+            if (output) {
+              if (output.stdout) logger('[qpdf] stdout: ' + output.stdout);
+              if (output.stderr) logger('[qpdf] stderr: ' + output.stderr);
+            }
+            if (status !== 0) {
+              callListener('execute', new Error(error || 'QPDF exited with status ' + status));
+            } else {
+              callListener('execute', null);
+            }
+            break;
+          }
+        }
+      };
+    }
+  
+    QPDF.encrypt = function ({
+      logger,
+      arrayBuffer,
+      userPassword,
+      ownerPassword,
+      keyLength,
+      callback,
+    }) {
+      const safeCallback = function (err, arg) {
+        if (callback) {
+          if (err || arg) {
+            callback(err, arg);
+            callback = null;
+          }
+        }
+      };
+      QPDF({
+        logger,
+        ready: function (qpdf) {
+          qpdf.save('input.pdf', arrayBuffer, safeCallback);
+          qpdf.execute([
+            '--encrypt',
+            userPassword || '',
+            ownerPassword || '',
+            String(keyLength || 256),
+            '--',
+            'input.pdf',
+            'output.pdf'
+          ], safeCallback);
+          qpdf.load('output.pdf', safeCallback);
+        }
+      });
+    };
+  
+    QPDF.help = function (logger) {
+      QPDF({
+        logger,
+        ready: function (qpdf) {
+          qpdf.execute(['--help']);
+        }
+      });
+    };
+  
+    QPDF.base64ToArrayBuffer = function (base64) {
+      const binary = window.atob(base64);
+      const len = binary.length;
+      const bytes = new Uint8Array(len);
+      for (let i = 0; i < len; i++) {
+        bytes[i] = binary.charCodeAt(i);
+      }
+      return bytes.buffer;
+    };
+  
+    QPDF.arrayBufferToBase64 = function (buffer) {
+      let binary = '';
+      const bytes = new Uint8Array(buffer);
+      const len = bytes.byteLength;
+      for (let i = 0; i < len; i++) {
+        binary += String.fromCharCode(bytes[i]);
+      }
+      return window.btoa(binary);
+    };
+  
+    window.QPDF = QPDF;
+  })();
\ No newline at end of file
diff --git a/examples/qpdf-js/serve.py b/examples/qpdf-js/serve.py
new file mode 100644
index 00000000..92a6ad22
--- /dev/null
+++ b/examples/qpdf-js/serve.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+import http.server
+import socketserver
+
+class WASMHandler(http.server.SimpleHTTPRequestHandler):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        
+    def end_headers(self):
+        # Add CORS headers
+        self.send_header('Access-Control-Allow-Origin', '*')
+        # Add proper MIME type for .wasm files
+        if self.path.endswith('.wasm'):
+            self.send_header('Content-Type', 'application/wasm')
+        super().end_headers()
+
+PORT = 8523
+Handler = WASMHandler
+
+with socketserver.TCPServer(("", PORT), Handler) as httpd:
+    print(f"Serving at http://localhost:{PORT}")
+    httpd.serve_forever() 
\ No newline at end of file
diff --git a/examples/qpdf-js/serve.sh b/examples/qpdf-js/serve.sh
new file mode 100755
index 00000000..de1253a9
--- /dev/null
+++ b/examples/qpdf-js/serve.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+cd "$(dirname "$0")"
+python3 serve.py
diff --git a/examples/qpdf-js/test.html b/examples/qpdf-js/test.html
new file mode 100644
index 00000000..9d870801
--- /dev/null
+++ b/examples/qpdf-js/test.html
@@ -0,0 +1,85 @@
+
+
+
+
+    QPDF Test
+
+
+
+    

QPDF Test

+

+
+    
+    
+
+
+
\ No newline at end of file